260 lines
8.7 KiB
Go
260 lines
8.7 KiB
Go
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.err = ""
|
|
|
|
// Issuing a brand new key is not destructive — nothing existing
|
|
// breaks — so it runs immediately. Re-exporting someone who
|
|
// already has a key rotates it and invalidates their current
|
|
// archive, which does need the typed-token confirmation.
|
|
st := m.loadState()
|
|
var rotating []string
|
|
for _, uname := range chosen {
|
|
if u := st.FindUser(uname); u != nil && u.PublicKey != "" {
|
|
rotating = append(rotating, uname)
|
|
}
|
|
}
|
|
if len(rotating) == 0 {
|
|
return m.runExport(chosen)
|
|
}
|
|
confirmMsg := "Будут перевыпущены ключи (старые архивы перестанут работать):\n\n"
|
|
for _, uname := range chosen {
|
|
if u := st.FindUser(uname); u != nil && u.PublicKey != "" {
|
|
confirmMsg += fmt.Sprintf(" %-20s ПЕРЕВЫПУСК\n", uname)
|
|
} else {
|
|
confirmMsg += fmt.Sprintf(" %-20s новый ключ\n", uname)
|
|
}
|
|
}
|
|
m = m.startConfirm(confirmExportRotate, "", confirmMsg, scrExportSelect)
|
|
return m, nil
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|