212 lines
6.5 KiB
Go
212 lines
6.5 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, then 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)
|
|
}
|
|
for i := range h.Services {
|
|
if h.Services[i].Name == serviceName {
|
|
h.Services = append(h.Services[:i], h.Services[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
return resyncUsernames(st, st.UsernamesUsingHost(hostName))
|
|
})
|
|
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
|
|
}
|