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

+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)
}