// 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 a subset of that catalog by name rather // than redefining hosts/services per group — this matters when the same // hosts/services are reused across many groups; 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. 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.Hosts) — 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"` } // Group is a named access policy referencing a subset of catalog hosts (by // name — all of that host's services are included). Users are assigned to // one or more groups; their effective allowed forwards are the union of // services on all hosts referenced by all their groups. type Group struct { Name string `yaml:"name"` Description string `yaml:"description,omitempty"` Hosts []string `yaml:"hosts"` // names referencing State.Hosts } // 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 host list changes) without re-issuing the keypair Comment string `yaml:"comment,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 all catalog hosts referenced by all of the user's groups, 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 _, hostName := range g.Hosts { h := s.FindHost(hostName) if h == nil { continue } for _, svc := range h.Services { 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 includes hostName. func (s *State) GroupNamesReferencingHost(hostName string) []string { var out []string for _, g := range s.Groups { for _, h := range g.Hosts { if h == hostName { 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 } // UsernamesUsingHost returns every user reachable transitively through any // group that references hostName (deduplicated). func (s *State) UsernamesUsingHost(hostName string) []string { seen := map[string]bool{} var out []string for _, gname := range s.GroupNamesReferencingHost(hostName) { for _, uname := range s.UsernamesInGroup(gname) { if !seen[uname] { seen[uname] = true out = append(out, uname) } } } return out } // 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 reference to // it, so a rename doesn't silently drop the host out of groups using it. func (s *State) RenameHost(oldName, newName string) { if h := s.FindHost(oldName); h != nil { h.Name = newName } for i := range s.Groups { for j, hn := range s.Groups[i].Hosts { if hn == oldName { s.Groups[i].Hosts[j] = newName } } } } // DeleteHost removes a host from the catalog and from every group that // referenced it. 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 []string for _, hn := range s.Groups[i].Hosts { if hn != name { kept = append(kept, hn) } } s.Groups[i].Hosts = kept } }