Files
ssh-access-tui/internal/model/types.go
T

278 lines
8.2 KiB
Go

// 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
}
}