rc
This commit is contained in:
1 parent
e39ad275b5
commit
3d7e3a88f2
14 files changed
+710
-234
No files matched your search
+33
-13
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user