This commit is contained in:
redlineo committed 2026-07-22 17:13:35 +03:00
1 parent e39ad275b5
commit 3d7e3a88f2
14 files changed
+710 -234

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
)
// HashForwards returns a stable, order-independent digest of a Forward set,
// used to detect whether a user's already-delivered client config still
// matches their current access. Sorted before hashing so that reordering
// the underlying groups/hosts/services (without actually changing what's
// granted) never produces a spurious "stale" result.
func HashForwards(forwards []Forward) string {
lines := make([]string, len(forwards))
for i, f := range forwards {
lines[i] = fmt.Sprintf("%s|%s|%d|%s|%s", f.HostName, f.Address, f.Port, f.ServiceName, f.Description)
}
sort.Strings(lines)
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return hex.EncodeToString(sum[:])
}
// IsConfigStale reports whether u's live AllowedForwards no longer matches
// the set they were handed in their last config (full export or
// config-only update). Users who never had a key issued are not "stale" —
// that's a distinct, more basic state (see State.NeverExportedUsernames).
func (s *State) IsConfigStale(u *User) bool {
if u.PublicKey == "" {
return false
}
return u.ForwardsHash != HashForwards(s.AllowedForwards(u))
}
// NeverExportedUsernames returns active users who have never had a key
// issued at all (empty PublicKey) — created but forgotten before "Экспорт
// архивов".
func (s *State) NeverExportedUsernames() []string {
var out []string
for _, u := range s.Users {
if u.Status == StatusActive && u.PublicKey == "" {
out = append(out, u.Username)
}
}
return out
}
// StaleConfigUsernames returns active, already-issued users whose local
// config no longer matches their real access (see IsConfigStale) — they
// need "Обновить конфиги", not a full re-export.
func (s *State) StaleConfigUsernames() []string {
var out []string
for i := range s.Users {
u := &s.Users[i]
if u.Status == StatusActive && u.PublicKey != "" && s.IsConfigStale(u) {
out = append(out, u.Username)
}
}
return out
}
+137 -56
View File
@@ -1,10 +1,9 @@
// 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.
// 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 (
@@ -20,7 +19,8 @@ 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.
// 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)
}
@@ -36,8 +36,8 @@ type Service struct {
// 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.
// (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
@@ -45,14 +45,22 @@ type Host struct {
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.
// 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"`
Hosts []string `yaml:"hosts"` // names referencing State.Hosts
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
Services []ServiceRef `yaml:"services"`
}
// UserStatus is the lifecycle state of a managed account.
@@ -69,10 +77,15 @@ type User struct {
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"`
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"`
CreatedAt time.Time `yaml:"created_at"`
LockedAt *time.Time `yaml:"locked_at,omitempty"`
}
// State is the full persisted configuration of the tool.
@@ -125,9 +138,9 @@ type Forward struct {
}
// 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.
// 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
@@ -136,36 +149,60 @@ func (s *State) AllowedForwards(u *User) []Forward {
if g == nil {
continue
}
for _, hostName := range g.Hosts {
h := s.FindHost(hostName)
for _, ref := range g.Services {
h := s.FindHost(ref.Host)
if h == nil {
continue
}
for _, svc := range h.Services {
key := fmt.Sprintf("%s:%d", h.Address, svc.Port)
if seen[key] {
continue
var svc *Service
for i := range h.Services {
if h.Services[i].Name == ref.Service {
svc = &h.Services[i]
break
}
seen[key] = true
out = append(out, Forward{
HostName: h.Name,
Address: h.Address,
Port: svc.Port,
ServiceName: svc.Name,
Description: svc.Description,
})
}
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
}
// GroupNamesReferencingHost returns every group that includes hostName.
func (s *State) GroupNamesReferencingHost(hostName string) []string {
// 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 _, h := range g.Hosts {
if h == hostName {
for _, ref := range g.Services {
if ref.Host == hostName && ref.Service == serviceName {
out = append(out, g.Name)
break
}
@@ -188,12 +225,12 @@ func (s *State) UsernamesInGroup(groupName string) []string {
return out
}
// UsernamesUsingHost returns every user reachable transitively through any
// group that references hostName (deduplicated).
func (s *State) UsernamesUsingHost(hostName string) []string {
// 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 s.GroupNamesReferencingHost(hostName) {
for _, gname := range groupNames {
for _, uname := range s.UsernamesInGroup(gname) {
if !seen[uname] {
seen[uname] = true
@@ -204,6 +241,18 @@ func (s *State) UsernamesUsingHost(hostName string) []string {
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) {
@@ -240,24 +289,26 @@ func (s *State) DeleteGroup(name string) {
}
}
// 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.
// 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, hn := range s.Groups[i].Hosts {
if hn == oldName {
s.Groups[i].Hosts[j] = newName
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 from every group that
// referenced it. Caller is responsible for resyncing authorized_keys for
// any affected user (see UsernamesUsingHost, called before this).
// 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 {
@@ -266,12 +317,42 @@ func (s *State) DeleteHost(name string) {
}
}
for i := range s.Groups {
var kept []string
for _, hn := range s.Groups[i].Hosts {
if hn != name {
kept = append(kept, hn)
var kept []ServiceRef
for _, ref := range s.Groups[i].Services {
if ref.Host != name {
kept = append(kept, ref)
}
}
s.Groups[i].Hosts = kept
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
}
}