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

+19 -4
View File
@@ -35,7 +35,17 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName)
b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\n\n")
b.WriteString("ШАГ 2. Подключитесь\n")
b.WriteString("ШАГ 2. Настройте доступ к файлу ключа\n")
b.WriteString("----------------------------------------\n")
b.WriteString("Windows откажется использовать секретный ключ, если его может прочитать\n")
b.WriteString("кто-то ещё, кроме вас. Откройте PowerShell и выполните по очереди (это две\n")
b.WriteString("отдельные команды, копируйте и выполняйте одну за другой):\n\n")
fmt.Fprintf(&b, " icacls \"$env:USERPROFILE\\.ssh\\%s\" /inheritance:r\n", keyFileName)
fmt.Fprintf(&b, " icacls \"$env:USERPROFILE\\.ssh\\%s\" /grant:r \"${env:USERNAME}:(R)\"\n\n", keyFileName)
b.WriteString("Если файлы лежат не в стандартной папке .ssh, а где-то ещё — замените путь\n")
b.WriteString("в обеих командах на реальный путь к файлу ключа (в кавычках, как выше).\n\n")
b.WriteString("ШАГ 3. Подключитесь\n")
b.WriteString("---------------------\n")
b.WriteString("В PowerShell введите команду:\n\n")
b.WriteString(" ssh -N jump\n\n")
@@ -44,7 +54,7 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
b.WriteString("таблицы ниже. Закрыли окно (или выключили компьютер) — доступ пропал, просто\n")
b.WriteString("откройте заново командой выше, когда снова понадобится.\n\n")
b.WriteString("ШАГ 3. Пользуйтесь\n")
b.WriteString("ШАГ 4. Пользуйтесь\n")
b.WriteString("--------------------\n")
b.WriteString("Эта настройка открывает у вас на компьютере (локально) следующие адреса.\n")
b.WriteString("В программе, которая должна подключиться к сервису (браузер, клиент базы\n")
@@ -71,8 +81,13 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
b.WriteString("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n")
b.WriteString("------------------------\n")
b.WriteString("Обратитесь к администратору, который выдал вам этот архив. Возможно,\n")
b.WriteString("изменился список сервисов — тогда вам пришлют обновлённый архив.\n")
b.WriteString("Написано «This account is currently not available» и соединение сразу\n")
b.WriteString("обрывается? Это нормально, если вы вводили команду БЕЗ \"-N\" (например,\n")
b.WriteString("просто \"ssh jump\") — обычный вход сюда специально отключён, используйте\n")
b.WriteString("именно команду из шага 3 (\"ssh -N jump\").\n\n")
b.WriteString("Если не работает именно шаг 3 или шаг 4 — обратитесь к администратору,\n")
b.WriteString("который выдал вам этот архив. Возможно, изменился список сервисов — тогда\n")
b.WriteString("вам пришлют обновлённый архив.\n")
return b.String()
}
+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
}
}
+33 -13
View File
@@ -38,6 +38,7 @@ const (
scrGroupHostsPick
scrExportSelect
scrExportResult
scrConfigUpdateSelect
scrConfigUpdateResult
scrAudit
scrQuitConfirm
@@ -83,6 +84,15 @@ func newList(items []list.Item, title string, w, h int) list.Model {
l.Title = title
l.Styles.Title = listTitleStyle
l.SetShowStatusBar(false)
// bubbles/list binds "q" (and ForceQuit) to return tea.Quit directly,
// bypassing whatever screen it's on. Every screen except the main menu
// falls through unhandled keys straight into the list's own Update, so
// without this, pressing "q" on the Users/Groups/Catalog screens would
// quit the whole program immediately — skipping attemptQuit's warnings
// about unexported or stale-config users entirely. The menu handles "q"
// itself before reaching the list, so disabling it here is harmless
// there and load-bearing everywhere else.
l.DisableQuitKeybindings()
return l
}
@@ -118,15 +128,17 @@ type Model struct {
serviceFormFocus int
serviceFormEditTarget string // "" = creating a new service
// groups: named policies that reference a subset of catalog hosts
groupsList list.Model
groupForm [2]textinput.Model // name, description
groupFormFocus int
groupFormEditTarget string // "" = creating a new group
selectedGroup string
groupHostsPickSel map[string]bool
groupHostsPickOrder []string
groupHostsPickCursor int
// groups: named policies that reference individual catalog services,
// picked via a host -> services tree (see groups.go)
groupsList list.Model
groupForm [2]textinput.Model // name, description
groupFormFocus int
groupFormEditTarget string // "" = creating a new group
selectedGroup string
groupPickRows []pickRow
groupPickServices map[string][]string // host -> its service names, snapshot at screen-entry
groupPickSel map[string]bool // key "host/service" -> selected
groupPickCursor int
exportSel map[string]bool
exportOrder []string
@@ -136,11 +148,19 @@ type Model struct {
exportPass string
exportErr string
configUpdateSel map[string]bool
configUpdateOrder []string
configUpdateCursor int
configUpdateRes []keymgr.ConfigOnlyResult
configUpdatePass string
configUpdateErr string
onlineSessions []audit.OnlineSession
recentEvents []audit.Event
quitReturnTo screen // screen to go back to if quit is cancelled
quitPending []string // usernames with no key issued yet, shown on scrQuitConfirm
quitStale []string // usernames whose config is out of date, shown on scrQuitConfirm
// generic confirmation for destructive actions (lock/delete user,
// delete host/service/group, export key rotation) — see confirm.go.
@@ -155,10 +175,6 @@ type Model struct {
confirmInput textinput.Model
confirmError string
configUpdateUser string
configUpdateArchive string
configUpdatePass string
width, height int
}
@@ -257,6 +273,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateExportSelect(msg)
case scrExportResult:
return m.updateExportResult(msg)
case scrConfigUpdateSelect:
return m.updateConfigUpdateSelect(msg)
case scrConfigUpdateResult:
return m.updateConfigUpdateResult(msg)
case scrAudit:
@@ -298,6 +316,8 @@ func (m Model) View() string {
body = m.viewExportSelect()
case scrExportResult:
body = m.viewExportResult()
case scrConfigUpdateSelect:
body = m.viewConfigUpdateSelect()
case scrConfigUpdateResult:
body = m.viewConfigUpdateResult()
case scrAudit:
+5 -2
View File
@@ -262,12 +262,14 @@ func (m Model) updateCatalogServices(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
st := m.loadState()
affected := st.UsernamesUsingHost(m.selectedHost)
affected := st.UsernamesUsingService(m.selectedHost, sel.id)
groups := st.GroupNamesReferencingService(m.selectedHost, sel.id)
msg := fmt.Sprintf(
"Удалить сервис %q у хоста %q?\n\n"+
"Он используется в группах: %v.\n"+
"Это затронет пользователей: %d — они немедленно потеряют доступ к этому\n"+
"сервису.",
sel.id, m.selectedHost, len(affected))
sel.id, m.selectedHost, groups, len(affected))
m = m.startConfirm(confirmDeleteService, sel.id, msg, scrCatalogServices)
return m, nil
}
@@ -331,6 +333,7 @@ func (m Model) updateServiceForm(msg tea.Msg) (tea.Model, tea.Cmd) {
return fmt.Errorf("сервис %q уже существует на этом хосте", name)
}
}
st.RenameServiceRefs(hostName, target, name)
}
for i := range h.Services {
if h.Services[i].Name == target {
+178
View File
@@ -0,0 +1,178 @@
package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/keymgr"
"ssh-access-tui/internal/model"
)
// initConfigUpdateSelect lists every already-issued active user, so the
// admin can force-refresh anyone's config even if it isn't detected as
// stale — the status annotation (see viewConfigUpdateSelect) shows which
// ones actually need it.
func (m *Model) initConfigUpdateSelect() {
st := m.loadState()
m.configUpdateSel = map[string]bool{}
m.configUpdateOrder = nil
for _, u := range st.Users {
if u.Status == model.StatusActive && u.PublicKey != "" {
m.configUpdateOrder = append(m.configUpdateOrder, u.Username)
}
}
m.configUpdateCursor = 0
m.configUpdateErr = ""
}
func (m Model) updateConfigUpdateSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrMenu
m.err, m.status = "", ""
return m, nil
case "up", "k":
if m.configUpdateCursor > 0 {
m.configUpdateCursor--
}
return m, nil
case "down", "j":
if m.configUpdateCursor < len(m.configUpdateOrder)-1 {
m.configUpdateCursor++
}
return m, nil
case " ":
if len(m.configUpdateOrder) > 0 {
u := m.configUpdateOrder[m.configUpdateCursor]
m.configUpdateSel[u] = !m.configUpdateSel[u]
}
return m, nil
case "a":
for _, u := range m.configUpdateOrder {
m.configUpdateSel[u] = true
}
return m, nil
case "enter":
var chosen []string
for _, u := range m.configUpdateOrder {
if m.configUpdateSel[u] {
chosen = append(chosen, u)
}
}
if len(chosen) == 0 {
m.err = "выберите хотя бы одного пользователя (space)"
return m, nil
}
return m.runConfigUpdate(chosen)
}
}
return m, nil
}
func (m Model) viewConfigUpdateSelect() string {
st := m.loadState()
body := "Обновить конфиги — выберите пользователей (ключи не трогает, общий пароль на партию)\n\n"
if len(m.configUpdateOrder) == 0 {
body += warnStyle.Render("Нет пользователей с уже выданным ключом.") + "\n"
}
for i, uname := range m.configUpdateOrder {
cursor := " "
if i == m.configUpdateCursor {
cursor = "> "
}
check := "[ ]"
if m.configUpdateSel[uname] {
check = "[x]"
}
status := okStyle.Render("конфиг актуален")
if u := st.FindUser(uname); u != nil && st.IsConfigStale(u) {
status = warnStyle.Render("конфиг устарел — доступ изменился")
}
body += fmt.Sprintf("%s%s %-20s %s\n", cursor, check, uname, status)
}
body += "\n[space] отметить [a] отметить всех [enter] сгенерировать [esc] назад"
return borderStyle.Render(body)
}
// runConfigUpdate is the lightweight alternative to a full export: when
// only group membership or the catalog's services changed, server-side
// access is already updated immediately (authorized_keys permitopen=) — this
// just hands the selected users a fresh client config reflecting that,
// without touching any key. See keymgr.BuildConfigOnlyArchive. Not
// destructive (no key is rotated, nothing existing breaks), so it runs
// without a confirmation screen regardless of batch size.
func (m Model) runConfigUpdate(usernames []string) (tea.Model, tea.Cmd) {
password, err := keymgr.GenerateBatchPassword(20)
if err != nil {
m.err = err.Error()
return m, nil
}
st := m.loadState()
var results []keymgr.ConfigOnlyResult
var failures []string
for _, username := range usernames {
u := st.FindUser(username)
if u == nil {
failures = append(failures, username+": не найден в состоянии")
continue
}
if u.PublicKey == "" {
failures = append(failures, username+": ещё нет ключа, используйте «Экспорт архивов»")
continue
}
forwards := st.AllowedForwards(u)
res, err := keymgr.BuildConfigOnlyArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards)
if err != nil {
failures = append(failures, username+": "+err.Error())
continue
}
u.ForwardsHash = model.HashForwards(forwards)
results = append(results, *res)
m.logAudit(username, "admin", "config-only update issued (key unchanged)")
}
if err := m.st.WithLock(func() error {
return m.st.Save(st, fmt.Sprintf("config-update: обновлены конфиги для %v", usernames))
}); err != nil {
failures = append(failures, "сохранение состояния: "+err.Error())
}
m.configUpdateRes = results
m.configUpdatePass = password
m.configUpdateErr = ""
for _, f := range failures {
m.configUpdateErr += f + "\n"
}
m.screen = scrConfigUpdateResult
return m, nil
}
func (m Model) updateConfigUpdateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if key.String() == "esc" || key.String() == "enter" {
m.screen = scrMenu
m.err, m.status = "", ""
return m, nil
}
}
return m, nil
}
func (m Model) viewConfigUpdateResult() string {
body := "Готово — ключи НЕ менялись, обновлён только config.\n\n"
body += "Пароль архива (один на всю партию, покажется только сейчас!):\n\n"
body += titleStyle.Render(m.configUpdatePass) + "\n\n"
body += warnStyle.Render("Сообщите пароль каждому получателю отдельным от архива каналом.") + "\n\n"
for _, r := range m.configUpdateRes {
body += fmt.Sprintf(" %-20s -> %s\n", r.Username, r.ArchivePath)
}
if m.configUpdateErr != "" {
body += "\n" + errStyle.Render("Ошибки:\n"+m.configUpdateErr)
}
body += "\n[enter/esc] в меню"
return borderStyle.Render(body)
}
+5 -2
View File
@@ -175,7 +175,8 @@ func (m Model) doDeleteHost(hostName string) (tea.Model, tea.Cmd) {
return m, nil
}
// doDeleteService removes a service from m.selectedHost, then resyncs
// doDeleteService removes a service from m.selectedHost, drops it from
// every group that referenced this exact service, and resyncs
// authorized_keys for every user that lost access as a result.
func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) {
hostName := m.selectedHost
@@ -184,13 +185,15 @@ func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) {
if h == nil {
return fmt.Errorf("host %s not found", hostName)
}
affected := st.UsernamesUsingService(hostName, serviceName)
for i := range h.Services {
if h.Services[i].Name == serviceName {
h.Services = append(h.Services[:i], h.Services[i+1:]...)
break
}
}
return resyncUsernames(st, st.UsernamesUsingHost(hostName))
st.RemoveServiceRefs(hostName, serviceName)
return resyncUsernames(st, affected)
})
m.screen = scrCatalogServices
m.refreshCatalogServicesList()
+1 -59
View File
@@ -129,6 +129,7 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) {
}
u.PublicKey = res.PublicKeyLine
u.Fingerprint = res.Fingerprint
u.ForwardsHash = model.HashForwards(forwards)
results = append(results, *res)
m.logAudit(username, "admin", "key issued/rotated, fingerprint="+res.Fingerprint)
}
@@ -149,65 +150,6 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) {
return m, nil
}
// runConfigUpdate is the lightweight alternative to a full export: when only
// a user's group membership changed, their access is already updated
// server-side immediately (authorized_keys permitopen=) — this just hands
// them a fresh client config reflecting that, without touching their key at
// all. See keymgr.BuildConfigOnlyArchive.
func (m Model) runConfigUpdate(username string) (tea.Model, tea.Cmd) {
st := m.loadState()
u := st.FindUser(username)
if u == nil {
m.err = fmt.Sprintf("пользователь %q не найден", username)
return m, nil
}
if u.PublicKey == "" {
m.err = fmt.Sprintf("у пользователя %q ещё нет ключа — используйте «Экспорт архивов»", username)
return m, nil
}
password, err := keymgr.GenerateBatchPassword(20)
if err != nil {
m.err = err.Error()
return m, nil
}
forwards := st.AllowedForwards(u)
res, err := keymgr.BuildConfigOnlyArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards)
if err != nil {
m.err = err.Error()
return m, nil
}
m.logAudit(username, "admin", "config-only update issued (key unchanged)")
m.configUpdateUser = username
m.configUpdateArchive = res.ArchivePath
m.configUpdatePass = password
m.err = ""
m.screen = scrConfigUpdateResult
return m, nil
}
func (m Model) updateConfigUpdateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if key.String() == "esc" || key.String() == "enter" {
m.screen = scrUsers
m.err, m.status = "", ""
return m, nil
}
}
return m, nil
}
func (m Model) viewConfigUpdateResult() string {
body := fmt.Sprintf("Конфиг для %s обновлён — ключ НЕ менялся.\n\n", m.configUpdateUser)
body += "Пароль архива (покажется только сейчас):\n\n"
body += titleStyle.Render(m.configUpdatePass) + "\n\n"
body += warnStyle.Render("Сообщите пароль отдельным от архива каналом.") + "\n\n"
body += fmt.Sprintf("Архив: %s\n\n", m.configUpdateArchive)
body += "Пользователю нужно только заменить файл config тем, что внутри — ключ и\n"
body += "остальные файлы трогать не нужно.\n"
body += "\n[enter/esc] в меню"
return borderStyle.Render(body)
}
func (m Model) viewExportSelect() string {
st := m.loadState()
body := "Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n"
+93 -31
View File
@@ -2,6 +2,7 @@ package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
@@ -14,14 +15,18 @@ func (m *Model) refreshGroupsList() {
st := m.loadState()
items := make([]list.Item, 0, len(st.Groups))
for _, g := range st.Groups {
desc := fmt.Sprintf("%d хостов", len(g.Hosts))
hosts := map[string]bool{}
for _, ref := range g.Services {
hosts[ref.Host] = true
}
desc := fmt.Sprintf("%d сервисов на %d хостах", len(g.Services), len(hosts))
if g.Description != "" {
desc += " — " + g.Description
}
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: desc})
}
m.groupsList = newList(items,
"Группы [n]новая [e]изменить [enter]хосты группы [d]удалить [esc]назад",
"Группы [n]новая [e]изменить [enter]сервисы группы [d]удалить [esc]назад",
m.groupsList.Width(), m.groupsList.Height())
}
@@ -73,17 +78,25 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
m.selectedGroup = sel.id
st := m.loadState()
g := st.FindGroup(sel.id)
m.groupHostsPickSel = map[string]bool{}
m.groupPickSel = map[string]bool{}
if g != nil {
for _, hn := range g.Hosts {
m.groupHostsPickSel[hn] = true
for _, ref := range g.Services {
m.groupPickSel[ref.Host+"/"+ref.Service] = true
}
}
m.groupHostsPickOrder = nil
m.groupPickRows = nil
m.groupPickServices = map[string][]string{}
for _, h := range st.Hosts {
m.groupHostsPickOrder = append(m.groupHostsPickOrder, h.Name)
m.groupPickRows = append(m.groupPickRows, pickRow{isHeader: true, host: h.Name})
var svcNames []string
for _, svc := range h.Services {
m.groupPickRows = append(m.groupPickRows, pickRow{host: h.Name, service: svc.Name})
svcNames = append(svcNames, svc.Name)
}
m.groupPickServices[h.Name] = svcNames
}
m.groupHostsPickCursor = 0
m.groupPickCursor = 0
m.screen = scrGroupHostsPick
return m, nil
case "d":
@@ -180,7 +193,18 @@ func (m Model) viewGroupForm() string {
return borderStyle.Render(body)
}
// --- pick which catalog hosts belong to the selected group ---
// --- pick which catalog services belong to the selected group, shown as a
// host -> services tree: a host row toggles all of its services at once,
// a service row toggles just that one, so access is granted per-service
// (not blanket per-host) while still being quick to pick a whole host. ---
// pickRow is one line of the host/service picker tree: either a host
// header (isHeader, service == "") or a service leaf under that host.
type pickRow struct {
isHeader bool
host string
service string
}
func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
@@ -190,35 +214,55 @@ func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshGroupsList()
return m, nil
case "up", "k":
if m.groupHostsPickCursor > 0 {
m.groupHostsPickCursor--
if m.groupPickCursor > 0 {
m.groupPickCursor--
}
return m, nil
case "down", "j":
if m.groupHostsPickCursor < len(m.groupHostsPickOrder)-1 {
m.groupHostsPickCursor++
if m.groupPickCursor < len(m.groupPickRows)-1 {
m.groupPickCursor++
}
return m, nil
case " ":
if len(m.groupHostsPickOrder) > 0 {
h := m.groupHostsPickOrder[m.groupHostsPickCursor]
m.groupHostsPickSel[h] = !m.groupHostsPickSel[h]
if len(m.groupPickRows) == 0 {
return m, nil
}
row := m.groupPickRows[m.groupPickCursor]
if row.isHeader {
allSelected := true
for _, svc := range m.groupPickServices[row.host] {
if !m.groupPickSel[row.host+"/"+svc] {
allSelected = false
break
}
}
for _, svc := range m.groupPickServices[row.host] {
m.groupPickSel[row.host+"/"+svc] = !allSelected
}
} else {
key := row.host + "/" + row.service
m.groupPickSel[key] = !m.groupPickSel[key]
}
return m, nil
case "enter":
var hosts []string
for _, h := range m.groupHostsPickOrder {
if m.groupHostsPickSel[h] {
hosts = append(hosts, h)
var refs []model.ServiceRef
for key, on := range m.groupPickSel {
if !on {
continue
}
host, svc, found := strings.Cut(key, "/")
if !found {
continue
}
refs = append(refs, model.ServiceRef{Host: host, Service: svc})
}
groupName := m.selectedGroup
m.mutate("изменён список хостов группы "+groupName, func(st *model.State) error {
m.mutate("изменён список сервисов группы "+groupName, func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
g.Hosts = hosts
g.Services = refs
return resyncGroupUsers(st, groupName)
})
m.screen = scrGroups
@@ -230,21 +274,39 @@ func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Model) viewGroupHostsPick() string {
body := fmt.Sprintf("Какие хосты каталога входят в группу %q\n\n", m.selectedGroup)
if len(m.groupHostsPickOrder) == 0 {
body := fmt.Sprintf("Какие сервисы каталога входят в группу %q\n\n", m.selectedGroup)
if len(m.groupPickRows) == 0 {
body += warnStyle.Render("В каталоге ещё нет хостов — сначала добавьте их в разделе «Хосты и сервисы».") + "\n"
}
for i, h := range m.groupHostsPickOrder {
for i, row := range m.groupPickRows {
cursor := " "
if i == m.groupHostsPickCursor {
if i == m.groupPickCursor {
cursor = "> "
}
check := "[ ]"
if m.groupHostsPickSel[h] {
check = "[x]"
if row.isHeader {
svcs := m.groupPickServices[row.host]
selected := 0
for _, svc := range svcs {
if m.groupPickSel[row.host+"/"+svc] {
selected++
}
}
check := "[ ]"
switch {
case len(svcs) > 0 && selected == len(svcs):
check = "[x]"
case selected > 0:
check = "[~]"
}
body += fmt.Sprintf("%s%s %s\n", cursor, check, row.host)
} else {
check := "[ ]"
if m.groupPickSel[row.host+"/"+row.service] {
check = "[x]"
}
body += fmt.Sprintf("%s %s %s\n", cursor, check, row.service)
}
body += fmt.Sprintf("%s%s %s\n", cursor, check, h)
}
body += "\n[space] отметить [enter] сохранить [esc] назад"
body += "\n[space] отметить (на хосте — все его сервисы) [enter] сохранить [esc] назад"
return borderStyle.Render(body)
}
+55 -23
View File
@@ -1,6 +1,8 @@
package ui
import (
"fmt"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)
@@ -9,8 +11,9 @@ func newMenuList() list.Model {
items := []list.Item{
simpleItem{id: "users", title: "Пользователи", desc: "Создать, заблокировать, удалить учётные записи"},
simpleItem{id: "catalog", title: "Хосты и сервисы", desc: "Общий каталог: адреса и порты, один раз на всех, группы просто выбирают из него"},
simpleItem{id: "groups", title: "Группы", desc: "Какие хосты из каталога входят в каждую группу"},
simpleItem{id: "groups", title: "Группы", desc: "Какие сервисы из каталога входят в каждую группу"},
simpleItem{id: "export", title: "Экспорт архивов", desc: "Сгенерировать ключи и .7z архивы для доставки пользователям"},
simpleItem{id: "configupdate", title: "Обновить конфиги", desc: "Перевыпустить только config (без ключа) для тех, у кого изменился доступ"},
simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"},
simpleItem{id: "quit", title: "Выход", desc: ""},
}
@@ -39,6 +42,9 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
case "export":
m.screen = scrExportSelect
m.initExportSelect()
case "configupdate":
m.screen = scrConfigUpdateSelect
m.initConfigUpdateSelect()
case "audit":
m.screen = scrAudit
m.refreshAudit()
@@ -55,34 +61,47 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
// viewMenu appends a live summary of pending follow-up work (users with no
// key yet, users whose config no longer matches their real access) below
// the menu, recomputed on every render — so it can never go stale itself.
func (m Model) viewMenu() string {
return borderStyle.Render(m.menu.View())
body := m.menu.View()
neverExported, stale := m.quitWarnings()
if len(neverExported) == 0 && len(stale) == 0 {
return borderStyle.Render(body)
}
notice := ""
if len(neverExported) > 0 {
notice += warnStyle.Render(fmt.Sprintf("Без ключа: %d (см. «Экспорт архивов»)", len(neverExported))) + "\n"
}
if len(stale) > 0 {
notice += warnStyle.Render(fmt.Sprintf("Устаревший конфиг: %d (см. «Обновить конфиги»)", len(stale))) + "\n"
}
return borderStyle.Render(body + "\n" + notice)
}
// unexportedUsers returns active users who have never had a key issued
// (empty PublicKey) — created but forgotten before "Экспорт архивов".
func unexportedUsers(m *Model) []string {
// quitWarnings splits active users into the two categories worth warning
// about before quitting: those who never got a key at all, and those whose
// access changed since their last config (full export or config-only
// update) — the latter are running with an out-of-date client config that
// won't reach everything they're now allowed to.
func (m *Model) quitWarnings() (neverExported, stale []string) {
st := m.loadState()
var out []string
for _, u := range st.Users {
if u.PublicKey == "" {
out = append(out, u.Username)
}
}
return out
return st.NeverExportedUsernames(), st.StaleConfigUsernames()
}
// attemptQuit is the only path that actually quits from the menu ("q" or
// selecting "Выход"): if there are users created but never exported, it
// warns instead of quitting straight away, since it's easy to create a
// batch of users, get interrupted, and forget the export step entirely.
// ctrl+c bypasses this deliberately, as an always-available hard exit.
// selecting "Выход"): if there are users created but never exported, or
// users whose config fell out of date, it warns instead of quitting
// straight away — both are easy to lose track of mid-session. ctrl+c
// bypasses this deliberately, as an always-available hard exit.
func (m Model) attemptQuit() (tea.Model, tea.Cmd) {
pending := unexportedUsers(&m)
if len(pending) == 0 {
neverExported, stale := m.quitWarnings()
if len(neverExported) == 0 && len(stale) == 0 {
return m, tea.Quit
}
m.quitPending = pending
m.quitPending = neverExported
m.quitStale = stale
m.quitReturnTo = scrMenu
m.screen = scrQuitConfirm
return m, nil
@@ -102,11 +121,24 @@ func (m Model) updateQuitConfirm(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Model) viewQuitConfirm() string {
body := warnStyle.Render("Есть пользователи, для которых ещё не сгенерирован ключ (не были в «Экспорт архивов»):") + "\n\n"
for _, u := range m.quitPending {
body += " - " + u + "\n"
body := ""
if len(m.quitPending) > 0 {
body += warnStyle.Render("Без выданного ключа (не были в «Экспорт архивов»):") + "\n"
for _, u := range m.quitPending {
body += " - " + u + "\n"
}
body += "\n"
}
body += "\nБез экспорта у них нет ключа и архива для передачи — доступ не работает.\n"
if len(m.quitStale) > 0 {
body += warnStyle.Render("Доступ изменился, но конфиг у пользователя старый (см. «Обновить конфиги»):") + "\n"
for _, u := range m.quitStale {
body += " - " + u + "\n"
}
body += "\n"
}
body += "Без этого у части пользователей либо нет доступа вообще, либо их текущий\n"
body += "config не соответствует реальным правам (не хватает новых сервисов, или\n"
body += "остались лишние, которых уже нет).\n"
body += "\n[enter/y] выйти всё равно [esc/n] вернуться"
return borderStyle.Render(body)
}
+1 -1
View File
@@ -128,7 +128,7 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
if !ok {
return m, nil
}
return m.runConfigUpdate(sel.id)
return m.runConfigUpdate([]string{sel.id})
}
}
var cmd tea.Cmd