package ui import ( "fmt" tea "github.com/charmbracelet/bubbletea" "ssh-access-tui/internal/keymgr" "ssh-access-tui/internal/model" "ssh-access-tui/internal/sysadm" ) func (m *Model) initExportSelect() { st := m.loadState() m.exportSel = map[string]bool{} m.exportOrder = nil for _, u := range st.Users { if u.Status == model.StatusActive { m.exportOrder = append(m.exportOrder, u.Username) } } m.exportCursor = 0 m.exportErr = "" } func (m Model) updateExportSelect(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.exportCursor > 0 { m.exportCursor-- } return m, nil case "down", "j": if m.exportCursor < len(m.exportOrder)-1 { m.exportCursor++ } return m, nil case " ": if len(m.exportOrder) > 0 { u := m.exportOrder[m.exportCursor] m.exportSel[u] = !m.exportSel[u] } return m, nil case "a": for _, u := range m.exportOrder { m.exportSel[u] = true } return m, nil case "enter": var chosen []string for _, u := range m.exportOrder { if m.exportSel[u] { chosen = append(chosen, u) } } if len(chosen) == 0 { m.err = "выберите хотя бы одного пользователя (space)" return m, nil } m.exportChosen = chosen m.screen = scrExportConfirm m.err = "" return m, nil } } return m, nil } // updateExportConfirm guards against the accidental case this screen exists // for: selecting users who already have a key issued re-exports them, which // generates a brand new keypair and immediately invalidates whatever archive // they were already given (their old private key stops matching // authorized_keys). Nothing is generated or written until this is confirmed. func (m Model) updateExportConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "esc", "n": m.screen = scrExportSelect return m, nil case "enter", "y": return m.runExport(m.exportChosen) } } return m, nil } func (m Model) viewExportConfirm() string { st := m.loadState() body := "Подтвердите экспорт\n\n" anyRotate := false for _, username := range m.exportChosen { u := st.FindUser(username) if u != nil && u.PublicKey != "" { body += fmt.Sprintf(" %-20s %s\n", username, warnStyle.Render("ключ уже выдан — будет ПЕРЕВЫПУЩЕН, старый архив перестанет работать")) anyRotate = true } else { body += fmt.Sprintf(" %-20s новый ключ\n", username) } } if anyRotate { body += "\n" + warnStyle.Render("Внимание: для отмеченных выше пользователей текущий ключ будет заменён.") + "\n" body += warnStyle.Render("Все, у кого сейчас работает старый архив, потеряют доступ, пока им не выдадут новый.") + "\n" } body += "\n[enter/y] подтвердить и сгенерировать [esc/n] отмена, ничего не создано" return borderStyle.Render(body) } // runExport performs the actual batch export: for each chosen user it // (re)generates a keypair, rewrites authorized_keys with that user's // currently-allowed hosts, builds the encrypted archive, and records the new // public key/fingerprint in state. Re-exporting an existing user rotates // their key — that is deliberate: it is also how a config update ("new host // added to their group") gets delivered to the client side. func (m Model) runExport(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.BuildResult var failures []string for _, username := range usernames { u := st.FindUser(username) if u == nil { failures = append(failures, username+": не найден в состоянии") continue } forwards := st.AllowedForwards(u) res, err := keymgr.BuildUserArchive(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 } if err := sysadm.WriteAuthorizedKeys(username, res.PublicKeyLine, forwards); err != nil { failures = append(failures, username+": authorized_keys: "+err.Error()) continue } u.PublicKey = res.PublicKeyLine u.Fingerprint = res.Fingerprint results = append(results, *res) m.logAudit(username, "admin", "key issued/rotated, fingerprint="+res.Fingerprint) } if err := m.st.WithLock(func() error { return m.st.Save(st, fmt.Sprintf("export: выпущены/обновлены ключи для %v", usernames)) }); err != nil { failures = append(failures, "сохранение состояния: "+err.Error()) } m.exportRes = results m.exportPass = password m.exportErr = "" for _, f := range failures { m.exportErr += f + "\n" } m.screen = scrExportResult 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" if len(m.exportOrder) == 0 { body += warnStyle.Render("Нет активных пользователей. Сначала создайте пользователя.") + "\n" } for i, uname := range m.exportOrder { cursor := " " if i == m.exportCursor { cursor = "> " } check := "[ ]" if m.exportSel[uname] { check = "[x]" } status := "ключ не выдан — новый" if u := st.FindUser(uname); u != nil && u.PublicKey != "" { 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) } func (m Model) updateExportResult(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) viewExportResult() string { body := "Готово. Пароль архива (один на всю партию, покажется только сейчас!):\n\n" body += titleStyle.Render(m.exportPass) + "\n\n" body += warnStyle.Render("Сообщите этот пароль каждому получателю отдельным каналом (не тем же, что архив).") + "\n\n" for _, r := range m.exportRes { body += fmt.Sprintf(" %-20s -> %s (fingerprint %s)\n", r.Username, r.ArchivePath, r.Fingerprint) } if m.exportErr != "" { body += "\n" + errStyle.Render("Ошибки:\n"+m.exportErr) } body += "\n[enter/esc] в меню" return borderStyle.Render(body) }