Files
2026-07-22 17:13:35 +03:00

215 lines
6.7 KiB
Go

package ui
import (
"crypto/rand"
"fmt"
"math/big"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/model"
"ssh-access-tui/internal/sysadm"
)
const (
confirmTokenLetters = "abcdefghijklmnopqrstuvwxyz"
confirmTokenDigits = "0123456789"
)
// generateConfirmToken returns a short, freshly random string the admin
// must type back exactly to carry out a destructive action — e.g.
// "kqzvm-482". Confirming with a fixed keypress (enter/y) can fire from a
// reflexive/habitual keypress; typing back a token that's different every
// time cannot.
func generateConfirmToken() string {
letters := make([]byte, 5)
for i := range letters {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(confirmTokenLetters))))
letters[i] = confirmTokenLetters[n.Int64()]
}
digits := make([]byte, 3)
for i := range digits {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(confirmTokenDigits))))
digits[i] = confirmTokenDigits[n.Int64()]
}
return string(letters) + "-" + string(digits)
}
// startConfirm switches to the generic confirmation screen: kind identifies
// what executeConfirmedAction should do once the token is typed back
// correctly, target is the entity being acted on (username/host/service/
// group name — unused for confirmExportRotate, which instead reads
// m.exportChosen), message explains the consequences, and returnTo is where
// esc/cancel goes back to.
func (m Model) startConfirm(kind confirmKind, target, message string, returnTo screen) Model {
m.pendingConfirm = kind
m.confirmTarget = target
m.confirmMessage = message
m.confirmReturnTo = returnTo
m.confirmToken = generateConfirmToken()
ti := textinput.New()
ti.Placeholder = "введите строку выше"
ti.CharLimit = 32
ti.Focus()
m.confirmInput = ti
m.confirmError = ""
m.screen = scrConfirmAction
return m
}
func (m Model) updateConfirmAction(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.pendingConfirm = confirmNone
m.screen = m.confirmReturnTo
return m, nil
case "enter":
if strings.TrimSpace(m.confirmInput.Value()) != m.confirmToken {
m.confirmError = "не совпадает со строкой выше — попробуйте ещё раз (или esc для отмены)"
m.confirmInput.SetValue("")
return m, nil
}
return m.executeConfirmedAction()
}
}
var cmd tea.Cmd
m.confirmInput, cmd = m.confirmInput.Update(msg)
return m, cmd
}
func (m Model) viewConfirmAction() string {
body := warnStyle.Render("ТРЕБУЕТСЯ ПОДТВЕРЖДЕНИЕ") + "\n\n"
body += m.confirmMessage + "\n\n"
body += "Чтобы подтвердить, наберите ровно эту строку:\n\n"
body += " " + titleStyle.Render(m.confirmToken) + "\n\n"
body += m.confirmInput.View() + "\n"
if m.confirmError != "" {
body += "\n" + errStyle.Render(m.confirmError) + "\n"
}
body += "\n[enter] проверить [esc] отмена, ничего не изменится"
return borderStyle.Render(body)
}
func (m Model) executeConfirmedAction() (tea.Model, tea.Cmd) {
kind := m.pendingConfirm
target := m.confirmTarget
m.pendingConfirm = confirmNone
switch kind {
case confirmLockUser:
return m.doLockUser(target)
case confirmDeleteUser:
return m.doDeleteUser(target)
case confirmDeleteHost:
return m.doDeleteHost(target)
case confirmDeleteService:
return m.doDeleteService(target)
case confirmDeleteGroup:
return m.doDeleteGroup(target)
case confirmExportRotate:
return m.runExport(m.exportChosen)
}
m.screen = m.confirmReturnTo
return m, nil
}
func (m Model) doLockUser(username string) (tea.Model, tea.Cmd) {
if err := sysadm.LockSystemUser(username); err != nil {
m.err = err.Error()
m.screen = scrUsers
return m, nil
}
_ = sysadm.DisableAuthorizedKeys(username)
m.mutate("заблокирован пользователь "+username, func(st *model.State) error {
u := st.FindUser(username)
if u == nil {
return fmt.Errorf("user %s not found in state", username)
}
now := time.Now()
u.Status = model.StatusLocked
u.LockedAt = &now
return nil
})
m.logAudit(username, "admin", "locked")
m.screen = scrUsers
m.refreshUsersList()
return m, nil
}
func (m Model) doDeleteUser(username string) (tea.Model, tea.Cmd) {
if err := sysadm.DeleteSystemUser(username); err != nil {
m.err = err.Error()
m.screen = scrUsers
return m, nil
}
m.mutate("удалён пользователь "+username, func(st *model.State) error {
for i := range st.Users {
if st.Users[i].Username == username {
st.Users = append(st.Users[:i], st.Users[i+1:]...)
break
}
}
return nil
})
m.logAudit(username, "admin", "deleted")
m.screen = scrUsers
m.refreshUsersList()
return m, nil
}
// doDeleteHost removes a catalog host (and its services) entirely, dropping
// it from every group that referenced it, and resyncs authorized_keys for
// every user that lost access as a result.
func (m Model) doDeleteHost(hostName string) (tea.Model, tea.Cmd) {
m.mutate("удалён хост "+hostName+" из каталога", func(st *model.State) error {
affected := st.UsernamesUsingHost(hostName)
st.DeleteHost(hostName)
return resyncUsernames(st, affected)
})
m.screen = scrCatalogHosts
m.refreshCatalogHostsList()
return m, nil
}
// 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
m.mutate(fmt.Sprintf("удалён сервис %s у хоста %s", serviceName, hostName), func(st *model.State) error {
h := st.FindHost(hostName)
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
}
}
st.RemoveServiceRefs(hostName, serviceName)
return resyncUsernames(st, affected)
})
m.screen = scrCatalogServices
m.refreshCatalogServicesList()
return m, nil
}
// doDeleteGroup removes a group and its membership from every user who had
// it, then resyncs authorized_keys for everyone who lost access.
func (m Model) doDeleteGroup(groupName string) (tea.Model, tea.Cmd) {
m.mutate("удалена группа "+groupName, func(st *model.State) error {
affected := st.UsernamesInGroup(groupName)
st.DeleteGroup(groupName)
return resyncUsernames(st, affected)
})
m.screen = scrGroups
m.refreshGroupsList()
return m, nil
}