// Package model defines the data types persisted as the tool's state. // // Hosts (each offering one or more TCP services) form a shared catalog, // defined once; groups reference individual services of that catalog (not // whole hosts) by name, so access can be scoped per-service rather than // per-host; and users are assigned to one or more groups. package model import ( "fmt" "regexp" "time" ) // nameRe restricts identifiers that end up as ssh_config tokens or // authorized_keys comments: no spaces, no shell/config metacharacters. var nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) // ValidName reports whether s is safe to use as a group/host/service name: // letters, digits, dot, underscore, hyphen, no spaces. Names end up as // ssh_config comments/tokens and authorized_keys metadata, where a stray // space or "#" would corrupt the generated file. Names also never contain // "/", which ServiceRef keys (see below) rely on as a safe separator. func ValidName(s string) bool { return nameRe.MatchString(s) } // Service is one TCP port reachable on a Host — e.g. the host's sshd on // port 22, or an unrelated application on port 7888. A single Host commonly // exposes several of these. type Service struct { Name string `yaml:"name"` // short identifier, e.g. "ssh" or "web-api" Port int `yaml:"port"` // remote TCP port on the host Description string `yaml:"description,omitempty"` } // Host is a catalog entry: one jump-target reachable via port forwarding // through the bastion, defined once and referenced by any number of groups // (see Group.Services) — hosts/services shared across many groups only // need to be entered a single time. type Host struct { Name string `yaml:"name"` // short identifier, e.g. "db-prod-1" Address string `yaml:"address"` // hostname or IP as seen from the jump host Description string `yaml:"description,omitempty"` Services []Service `yaml:"services"` } // ServiceRef points at one specific service of one specific catalog host. // Groups grant access per-service, not per-host: a group can include just // "db1/ssh" without also granting "db1/web-admin". type ServiceRef struct { Host string `yaml:"host"` Service string `yaml:"service"` } // Group is a named access policy referencing a subset of catalog services // (by host+service name). Users are assigned to one or more groups; their // effective allowed forwards are the union of all services referenced by // all their groups. type Group struct { Name string `yaml:"name"` Description string `yaml:"description,omitempty"` Services []ServiceRef `yaml:"services"` } // UserStatus is the lifecycle state of a managed account. type UserStatus string const ( StatusActive UserStatus = "active" StatusLocked UserStatus = "locked" ) // User is one managed Linux/SSH account on the jump host. type User struct { Username string `yaml:"username"` Groups []string `yaml:"groups"` Status UserStatus `yaml:"status"` Fingerprint string `yaml:"fingerprint"` // SHA256 fingerprint of the current authorized public key PublicKey string `yaml:"public_key,omitempty"` // full "ssh-ed25519 AAAA... comment" line; safe to store/back up (public), lets us rewrite authorized_keys (e.g. after a group's referenced services change) without re-issuing the keypair // ForwardsHash is HashForwards(AllowedForwards(u)) as of the last time // this user was handed a config (full export or config-only update). If // it no longer matches the live computation, their local ssh config is // stale — see IsConfigStale. ForwardsHash string `yaml:"forwards_hash,omitempty"` Comment string `yaml:"comment,omitempty"` // ProxyJump selects the client config style delivered to this user: when // true, each allowed service gets its own "Host " ssh_config block // proxied through "jump" via ProxyJump (plain "ssh "); when false // (the default, preserving existing state.yaml behavior), the client // config instead lists one LocalForward per service on a single always-open // "jump" connection. Purely a client-config/instructions choice — the // server-side authorized_keys permitopen= restriction is identical either // way (see internal/sysadm/authorizedkeys.go). ProxyJump bool `yaml:"proxy_jump,omitempty"` CreatedAt time.Time `yaml:"created_at"` LockedAt *time.Time `yaml:"locked_at,omitempty"` } // State is the full persisted configuration of the tool. type State struct { Hosts []Host `yaml:"hosts"` // shared catalog Groups []Group `yaml:"groups"` Users []User `yaml:"users"` } // FindHost returns a pointer to the catalog host with the given name, or nil. func (s *State) FindHost(name string) *Host { for i := range s.Hosts { if s.Hosts[i].Name == name { return &s.Hosts[i] } } return nil } // FindGroup returns a pointer to the group with the given name, or nil. func (s *State) FindGroup(name string) *Group { for i := range s.Groups { if s.Groups[i].Name == name { return &s.Groups[i] } } return nil } // FindUser returns a pointer to the user with the given username, or nil. func (s *State) FindUser(username string) *User { for i := range s.Users { if s.Users[i].Username == username { return &s.Users[i] } } return nil } // Forward is one flattened (host, service) pair a user is allowed to reach. // This is the actual unit both server-side enforcement (authorized_keys // "permitopen=") and the client config (one "LocalForward" line per // Forward) are built from. type Forward struct { HostName string Address string Port int ServiceName string Description string } // AllowedForwards returns the de-duplicated union of (host, service) pairs // across every ServiceRef of every group the user belongs to, in stable // encounter order. Two services that resolve to the same address:port // (even from different groups) collapse to a single Forward. func (s *State) AllowedForwards(u *User) []Forward { seen := map[string]bool{} var out []Forward for _, gname := range u.Groups { g := s.FindGroup(gname) if g == nil { continue } for _, ref := range g.Services { h := s.FindHost(ref.Host) if h == nil { continue } var svc *Service for i := range h.Services { if h.Services[i].Name == ref.Service { svc = &h.Services[i] break } } if svc == nil { continue } key := fmt.Sprintf("%s:%d", h.Address, svc.Port) if seen[key] { continue } seen[key] = true out = append(out, Forward{ HostName: h.Name, Address: h.Address, Port: svc.Port, ServiceName: svc.Name, Description: svc.Description, }) } } return out } // GroupNamesReferencingHost returns every group that references at least // one service of hostName. func (s *State) GroupNamesReferencingHost(hostName string) []string { var out []string for _, g := range s.Groups { for _, ref := range g.Services { if ref.Host == hostName { out = append(out, g.Name) break } } } return out } // GroupNamesReferencingService returns every group that references this // exact (host, service) pair. func (s *State) GroupNamesReferencingService(hostName, serviceName string) []string { var out []string for _, g := range s.Groups { for _, ref := range g.Services { if ref.Host == hostName && ref.Service == serviceName { out = append(out, g.Name) break } } } return out } // UsernamesInGroup returns every user currently a member of groupName. func (s *State) UsernamesInGroup(groupName string) []string { var out []string for _, u := range s.Users { for _, g := range u.Groups { if g == groupName { out = append(out, u.Username) break } } } return out } // usernamesInAnyGroup returns the deduplicated union of UsernamesInGroup // across the given group names. func (s *State) usernamesInAnyGroup(groupNames []string) []string { seen := map[string]bool{} var out []string for _, gname := range groupNames { for _, uname := range s.UsernamesInGroup(gname) { if !seen[uname] { seen[uname] = true out = append(out, uname) } } } return out } // UsernamesUsingHost returns every user reachable transitively through any // group that references any service of hostName (deduplicated). func (s *State) UsernamesUsingHost(hostName string) []string { return s.usernamesInAnyGroup(s.GroupNamesReferencingHost(hostName)) } // UsernamesUsingService returns every user reachable transitively through // any group that references this exact (host, service) pair (deduplicated). func (s *State) UsernamesUsingService(hostName, serviceName string) []string { return s.usernamesInAnyGroup(s.GroupNamesReferencingService(hostName, serviceName)) } // RenameGroup renames a group and updates every user's membership reference // so nobody silently falls out of a group just because it was renamed. func (s *State) RenameGroup(oldName, newName string) { if g := s.FindGroup(oldName); g != nil { g.Name = newName } for i := range s.Users { for j, gn := range s.Users[i].Groups { if gn == oldName { s.Users[i].Groups[j] = newName } } } } // DeleteGroup removes a group and drops the membership reference from every // user who had it. Caller is responsible for resyncing authorized_keys for // any affected user (see UsernamesInGroup, called before this). func (s *State) DeleteGroup(name string) { for i := range s.Groups { if s.Groups[i].Name == name { s.Groups = append(s.Groups[:i], s.Groups[i+1:]...) break } } for i := range s.Users { var kept []string for _, gn := range s.Users[i].Groups { if gn != name { kept = append(kept, gn) } } s.Users[i].Groups = kept } } // RenameHost renames a catalog host and updates every group's ServiceRefs // pointing at it, so a rename doesn't silently drop the host's services out // of groups using them. func (s *State) RenameHost(oldName, newName string) { if h := s.FindHost(oldName); h != nil { h.Name = newName } for i := range s.Groups { for j := range s.Groups[i].Services { if s.Groups[i].Services[j].Host == oldName { s.Groups[i].Services[j].Host = newName } } } } // DeleteHost removes a host from the catalog and drops every group's // ServiceRefs that pointed at any of its services. Caller is responsible // for resyncing authorized_keys for any affected user (see // UsernamesUsingHost, called before this). func (s *State) DeleteHost(name string) { for i := range s.Hosts { if s.Hosts[i].Name == name { s.Hosts = append(s.Hosts[:i], s.Hosts[i+1:]...) break } } for i := range s.Groups { var kept []ServiceRef for _, ref := range s.Groups[i].Services { if ref.Host != name { kept = append(kept, ref) } } s.Groups[i].Services = kept } } // RenameServiceRefs updates every group's ServiceRef pointing at // (hostName, oldService) to (hostName, newService), so renaming a service // doesn't silently drop it out of groups using it. func (s *State) RenameServiceRefs(hostName, oldService, newService string) { for i := range s.Groups { for j := range s.Groups[i].Services { ref := &s.Groups[i].Services[j] if ref.Host == hostName && ref.Service == oldService { ref.Service = newService } } } } // RemoveServiceRefs drops every group's ServiceRef pointing at // (hostName, serviceName). Caller is responsible for resyncing // authorized_keys for any affected user (see UsernamesUsingService, called // before this). func (s *State) RemoveServiceRefs(hostName, serviceName string) { for i := range s.Groups { var kept []ServiceRef for _, ref := range s.Groups[i].Services { if !(ref.Host == hostName && ref.Service == serviceName) { kept = append(kept, ref) } } s.Groups[i].Services = kept } }