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 }