// Package model defines the data types persisted as the tool's state: // hosts (each offering one or more TCP services) grouped into access // groups, and users assigned to those 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 one jump-target reachable via port forwarding through the // bastion, identified by address and offering one or more Services. 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 Services []Service `yaml:"services"` } // Group bundles hosts that share the same access policy (e.g. "team-billing"). // Users are assigned to one or more groups; their effective allowed // forwards are the union of all services on all hosts in their groups. type Group struct { Name string `yaml:"name"` Hosts []Host `yaml:"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 { Groups []Group `yaml:"groups"` Users []User `yaml:"users"` } // 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 groups the user belongs to, in stable encounter order (group // order, then host order, then service order, as defined in state.yaml). // Two services that resolve to the same address:port (even from different // groups) collapse to a single Forward — forwarding the same destination // twice would be meaningless. 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 _, h := range g.Hosts { 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 }