This commit is contained in:
redlineo committed 2026-07-22 15:14:44 +03:00
1 parent 23c7133fa9
commit 8c1b93c14e
16 files changed
+1187 -295

No files matched your search

+76 -7
View File
@@ -32,9 +32,28 @@ const (
scrGroups
scrGroupHosts
scrHostNew
scrHostServices
scrServiceNew
scrExportSelect
scrExportConfirm
scrExportResult
scrConfigUpdateResult
scrAudit
scrQuitConfirm
scrConfirmAction
)
// confirmKind identifies which destructive action scrConfirmAction is
// guarding, so a single generic confirm screen can back every one of them
// instead of duplicating the "are you sure" plumbing per action.
type confirmKind int
const (
confirmNone confirmKind = iota
confirmLockUser
confirmDeleteUser
confirmDeleteHost
confirmDeleteService
)
var (
@@ -59,24 +78,31 @@ type Model struct {
usersList list.Model
userNewName textinput.Model
userNewComment textinput.Model
userNewFocus int
userNewGroupsSel map[string]bool
userNewGroupsOrder []string
userNewCursor int
userNewName textinput.Model
userNewComment textinput.Model
userNewFocus int
userNewGroupsSel map[string]bool
userNewGroupsOrder []string
userNewCursor int
userGroupsEditTarget string // non-empty: editing this existing user's groups instead of creating a new user
groupsList list.Model
groupNewName textinput.Model
awaitingGroup bool
selectedGroup string
hostsList list.Model
hostForm [4]textinput.Model // name, address, port, description
hostForm [2]textinput.Model // name, address
hostFormFocus int
selectedHost string
servicesList list.Model
serviceForm [3]textinput.Model // name, port, description
serviceFormFocus int
exportSel map[string]bool
exportOrder []string
exportCursor int
exportChosen []string // confirmed selection, pending scrExportConfirm -> runExport
exportRes []keymgr.BuildResult
exportPass string
exportErr string
@@ -84,6 +110,23 @@ type Model struct {
onlineSessions []audit.OnlineSession
recentEvents []audit.Event
quitReturnTo screen // screen to go back to if quit is cancelled
quitPending []string // usernames with no key issued yet, shown on scrQuitConfirm
// generic confirmation for destructive actions (lock/delete user,
// delete host/service) — see confirm.go. confirmTarget holds the
// username/host/service name being acted on; for host/service deletion
// the group (and host, for a service) context is the already-present
// selectedGroup/selectedHost fields above.
pendingConfirm confirmKind
confirmTarget string
confirmMessage string
confirmReturnTo screen
configUpdateUser string
configUpdateArchive string
configUpdatePass string
width, height int
}
@@ -100,6 +143,7 @@ func New(cfg config.Config, st *store.Store) Model {
m.usersList = emptyList("Пользователи")
m.groupsList = emptyList("Группы доступа")
m.hostsList = emptyList("Хосты")
m.servicesList = emptyList("Сервисы")
return m
}
@@ -153,6 +197,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.usersList.SetSize(listW, listH)
m.groupsList.SetSize(listW, listH)
m.hostsList.SetSize(listW, listH)
m.servicesList.SetSize(listW, listH)
return m, nil
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
@@ -175,12 +220,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateGroupHosts(msg)
case scrHostNew:
return m.updateHostNew(msg)
case scrHostServices:
return m.updateHostServices(msg)
case scrServiceNew:
return m.updateServiceNew(msg)
case scrExportSelect:
return m.updateExportSelect(msg)
case scrExportConfirm:
return m.updateExportConfirm(msg)
case scrExportResult:
return m.updateExportResult(msg)
case scrConfigUpdateResult:
return m.updateConfigUpdateResult(msg)
case scrAudit:
return m.updateAudit(msg)
case scrQuitConfirm:
return m.updateQuitConfirm(msg)
case scrConfirmAction:
return m.updateConfirmAction(msg)
}
return m, nil
}
@@ -202,12 +259,24 @@ func (m Model) View() string {
body = m.viewGroupHosts()
case scrHostNew:
body = m.viewHostNew()
case scrHostServices:
body = m.viewHostServices()
case scrServiceNew:
body = m.viewServiceNew()
case scrExportSelect:
body = m.viewExportSelect()
case scrExportConfirm:
body = m.viewExportConfirm()
case scrExportResult:
body = m.viewExportResult()
case scrConfigUpdateResult:
body = m.viewConfigUpdateResult()
case scrAudit:
body = m.viewAudit()
case scrQuitConfirm:
body = m.viewQuitConfirm()
case scrConfirmAction:
body = m.viewConfirmAction()
}
footer := ""
+150
View File
@@ -0,0 +1,150 @@
package ui
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/model"
"ssh-access-tui/internal/sysadm"
)
// updateConfirmAction drives the single generic "are you sure" screen that
// backs every destructive action in the tool (lock/delete a user, delete a
// host or a service). Nothing the action does runs until this confirms it;
// esc/n cancels with zero effect.
func (m Model) updateConfirmAction(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc", "n":
m.pendingConfirm = confirmNone
m.screen = m.confirmReturnTo
return m, nil
case "enter", "y":
return m.executeConfirmedAction()
}
}
return m, nil
}
func (m Model) viewConfirmAction() string {
body := warnStyle.Render("ТРЕБУЕТСЯ ПОДТВЕРЖДЕНИЕ") + "\n\n"
body += m.confirmMessage + "\n\n"
body += "[enter/y] подтвердить [esc/n] отмена, ничего не изменится"
return borderStyle.Render(body)
}
func (m Model) executeConfirmedAction() (tea.Model, tea.Cmd) {
kind := m.pendingConfirm
target := m.confirmTarget
m.pendingConfirm = confirmNone
m.err, m.status = "", ""
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)
}
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 host (and all its services) from m.selectedGroup,
// then resyncs authorized_keys for every affected user.
func (m Model) doDeleteHost(hostName string) (tea.Model, tea.Cmd) {
groupName := m.selectedGroup
m.mutate(fmt.Sprintf("удалён хост %s из группы %s", hostName, groupName), func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
for i := range g.Hosts {
if g.Hosts[i].Name == hostName {
g.Hosts = append(g.Hosts[:i], g.Hosts[i+1:]...)
break
}
}
return resyncGroupUsers(st, groupName)
})
m.screen = scrGroupHosts
m.refreshHostsList()
return m, nil
}
// doDeleteService removes a service from m.selectedHost within
// m.selectedGroup, then resyncs authorized_keys for every affected user.
func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) {
groupName, hostName := m.selectedGroup, m.selectedHost
m.mutate(fmt.Sprintf("удалён сервис %s у хоста %s", serviceName, hostName), func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
for hi := range g.Hosts {
if g.Hosts[hi].Name != hostName {
continue
}
svcs := g.Hosts[hi].Services
for si := range svcs {
if svcs[si].Name == serviceName {
g.Hosts[hi].Services = append(svcs[:si], svcs[si+1:]...)
break
}
}
}
return resyncGroupUsers(st, groupName)
})
m.screen = scrHostServices
m.refreshServicesList()
return m, nil
}
+115 -9
View File
@@ -62,12 +62,54 @@ func (m Model) updateExportSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = "выберите хотя бы одного пользователя (space)"
return m, nil
}
return m.runExport(chosen)
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
@@ -91,13 +133,13 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) {
failures = append(failures, username+": не найден в состоянии")
continue
}
hosts := st.AllowedHosts(u)
res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, hosts)
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, hosts); err != nil {
if err := sysadm.WriteAuthorizedKeys(username, res.PublicKeyLine, forwards); err != nil {
failures = append(failures, username+": authorized_keys: "+err.Error())
continue
}
@@ -123,23 +165,87 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) {
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 {
body := fmt.Sprintf("Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n")
st := m.loadState()
body := "Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n"
if len(m.exportOrder) == 0 {
body += warnStyle.Render("Нет активных пользователей. Сначала создайте пользователя.") + "\n"
}
for i, u := range m.exportOrder {
for i, uname := range m.exportOrder {
cursor := " "
if i == m.exportCursor {
cursor = "> "
}
check := "[ ]"
if m.exportSel[u] {
if m.exportSel[uname] {
check = "[x]"
}
body += fmt.Sprintf("%s%s %s\n", cursor, check, u)
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] назад"
body += "\n[space] отметить [a] отметить всех [enter] далее (спросит подтверждение) [esc] назад"
return borderStyle.Render(body)
}
+196 -32
View File
@@ -12,6 +12,13 @@ import (
"ssh-access-tui/internal/sysadm"
)
// validNameErr renders a consistent error for group/host/service names that
// fail model.ValidName — these names end up as ssh_config/authorized_keys
// tokens, where spaces or "#" would corrupt the generated file.
func validNameErr(kind, name string) string {
return fmt.Sprintf("%s %q: имя не должно содержать пробелов и спецсимволов (разрешены латинские буквы, цифры, точка, подчёркивание, дефис)", kind, name)
}
func (m *Model) refreshGroupsList() {
st := m.loadState()
items := make([]list.Item, 0, len(st.Groups))
@@ -40,6 +47,10 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = "имя группы не может быть пустым"
return m, nil
}
if !model.ValidName(name) {
m.err = validNameErr("группа", name)
return m, nil
}
st := m.loadState()
if st.FindGroup(name) != nil {
m.err = fmt.Sprintf("группа %q уже существует", name)
@@ -106,20 +117,20 @@ func (m *Model) refreshHostsList() {
items = append(items, simpleItem{
id: h.Name,
title: h.Name,
desc: fmt.Sprintf("%s:%d %s", h.Address, h.Port, h.Description),
desc: fmt.Sprintf("%s — %d сервисов", h.Address, len(h.Services)),
})
}
}
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [d]удалить [esc]назад", m.selectedGroup)
l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [enter]сервисы хоста [d]удалить [esc]назад", m.selectedGroup)
l.SetShowStatusBar(false)
m.hostsList = l
}
// resyncGroupUsers rewrites authorized_keys permitopen= restrictions for
// every active user whose groups include groupName, reflecting the group's
// current host list — the only place server-side enforcement is updated
// when the allowed-host list changes over time.
// current hosts/services — the only place server-side enforcement is
// updated when the allowed destinations change over time.
func resyncGroupUsers(st *model.State, groupName string) error {
for i := range st.Users {
u := &st.Users[i]
@@ -136,7 +147,7 @@ func resyncGroupUsers(st *model.State, groupName string) error {
if !inGroup {
continue
}
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedHosts(u)); err != nil {
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedForwards(u)); err != nil {
return fmt.Errorf("resync %s: %w", u.Username, err)
}
}
@@ -155,33 +166,46 @@ func (m Model) updateGroupHosts(msg tea.Msg) (tea.Model, tea.Cmd) {
for i := range m.hostForm {
m.hostForm[i] = textinput.New()
}
m.hostForm[0].Placeholder = "короткое имя, например db-prod-1"
m.hostForm[0].Placeholder = "короткое имя, например db-prod-1 (без пробелов)"
m.hostForm[1].Placeholder = "адрес (hostname или IP), как виден с jump-хоста"
m.hostForm[2].Placeholder = "порт, например 5432"
m.hostForm[3].Placeholder = "описание (необязательно)"
m.hostFormFocus = 0
m.hostForm[0].Focus()
m.err = ""
return m, nil
case "enter":
sel, ok := m.hostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.selectedHost = sel.id
m.screen = scrHostServices
m.refreshServicesList()
return m, nil
case "d":
sel, ok := m.hostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
groupName := m.selectedGroup
m.mutate(fmt.Sprintf("удалён хост %s из группы %s", sel.id, groupName), func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
for i := range g.Hosts {
if g.Hosts[i].Name == sel.id {
g.Hosts = append(g.Hosts[:i], g.Hosts[i+1:]...)
nServices := 0
if g := st.FindGroup(groupName); g != nil {
for _, h := range g.Hosts {
if h.Name == sel.id {
nServices = len(h.Services)
break
}
}
return resyncGroupUsers(st, groupName)
})
m.refreshHostsList()
}
m.pendingConfirm = confirmDeleteHost
m.confirmTarget = sel.id
m.confirmReturnTo = scrGroupHosts
m.confirmMessage = fmt.Sprintf(
"Удалить хост %q (и все его сервисы: %d шт.) из группы %q?\n\n"+
"Все пользователи этой группы немедленно потеряют доступ ко всем сервисам\n"+
"этого хоста — permitopen убирается из authorized_keys сразу же.",
sel.id, nServices, groupName)
m.screen = scrConfirmAction
return m, nil
}
}
@@ -208,19 +232,17 @@ func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
case "enter":
name := m.hostForm[0].Value()
addr := m.hostForm[1].Value()
portStr := m.hostForm[2].Value()
desc := m.hostForm[3].Value()
if name == "" || addr == "" || portStr == "" {
m.err = "имя, адрес и порт обязательны"
if name == "" || addr == "" {
m.err = "имя и адрес обязательны"
return m, nil
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
m.err = "порт должен быть числом от 1 до 65535"
if !model.ValidName(name) {
m.err = validNameErr("хост", name)
return m, nil
}
groupName := m.selectedGroup
m.err = ""
var created bool
m.mutate(fmt.Sprintf("добавлен хост %s в группу %s", name, groupName), func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
@@ -231,11 +253,17 @@ func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
return fmt.Errorf("host %q already exists in group %q", name, groupName)
}
}
g.Hosts = append(g.Hosts, model.Host{Name: name, Address: addr, Port: port, Description: desc})
return resyncGroupUsers(st, groupName)
g.Hosts = append(g.Hosts, model.Host{Name: name, Address: addr})
created = true
return nil
})
m.screen = scrGroupHosts
m.refreshHostsList()
if !created {
return m, nil
}
m.selectedHost = name
m.screen = scrHostServices
m.refreshServicesList()
m.status = fmt.Sprintf("хост %s добавлен — теперь добавьте на нём хотя бы один сервис (порт)", name)
return m, nil
}
}
@@ -245,7 +273,143 @@ func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Model) viewHostNew() string {
body := fmt.Sprintf("Новый хост в группе %q\n\nИмя: %s\nАдрес: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
m.selectedGroup, m.hostForm[0].View(), m.hostForm[1].View(), m.hostForm[2].View(), m.hostForm[3].View())
body := fmt.Sprintf("Новый хост в группе %q\n\nИмя: %s\nАдрес: %s\n\n[tab] след. поле [enter] сохранить и перейти к сервисам [esc] отмена",
m.selectedGroup, m.hostForm[0].View(), m.hostForm[1].View())
return borderStyle.Render(body)
}
// --- services within a host ---
func (m *Model) refreshServicesList() {
st := m.loadState()
g := st.FindGroup(m.selectedGroup)
var items []list.Item
if g != nil {
for _, h := range g.Hosts {
if h.Name != m.selectedHost {
continue
}
for _, svc := range h.Services {
items = append(items, simpleItem{
id: svc.Name,
title: fmt.Sprintf("%s (порт %d)", svc.Name, svc.Port),
desc: svc.Description,
})
}
}
}
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
l.Title = fmt.Sprintf("Сервисы хоста %q [n]добавить [d]удалить [esc]назад", m.selectedHost)
l.SetShowStatusBar(false)
m.servicesList = l
}
func (m Model) updateHostServices(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrGroupHosts
m.refreshHostsList()
return m, nil
case "n":
m.screen = scrServiceNew
for i := range m.serviceForm {
m.serviceForm[i] = textinput.New()
}
m.serviceForm[0].Placeholder = "имя сервиса, например ssh или web-api (без пробелов)"
m.serviceForm[1].Placeholder = "порт на хосте, например 22 или 7888"
m.serviceForm[2].Placeholder = "описание (необязательно)"
m.serviceFormFocus = 0
m.serviceForm[0].Focus()
m.err = ""
return m, nil
case "d":
sel, ok := m.servicesList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
groupName, hostName := m.selectedGroup, m.selectedHost
m.pendingConfirm = confirmDeleteService
m.confirmTarget = sel.id
m.confirmReturnTo = scrHostServices
m.confirmMessage = fmt.Sprintf(
"Удалить сервис %q у хоста %q (группа %q)?\n\n"+
"Все пользователи этой группы немедленно потеряют доступ к этому сервису —\n"+
"permitopen убирается из authorized_keys сразу же.",
sel.id, hostName, groupName)
m.screen = scrConfirmAction
return m, nil
}
}
var cmd tea.Cmd
m.servicesList, cmd = m.servicesList.Update(msg)
return m, cmd
}
func (m Model) viewHostServices() string {
return borderStyle.Render(m.servicesList.View())
}
func (m Model) updateServiceNew(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrHostServices
return m, nil
case "tab", "down":
m.serviceForm[m.serviceFormFocus].Blur()
m.serviceFormFocus = (m.serviceFormFocus + 1) % len(m.serviceForm)
m.serviceForm[m.serviceFormFocus].Focus()
return m, nil
case "enter":
name := m.serviceForm[0].Value()
portStr := m.serviceForm[1].Value()
desc := m.serviceForm[2].Value()
if name == "" || portStr == "" {
m.err = "имя и порт обязательны"
return m, nil
}
if !model.ValidName(name) {
m.err = validNameErr("сервис", name)
return m, nil
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
m.err = "порт должен быть числом от 1 до 65535"
return m, nil
}
groupName, hostName := m.selectedGroup, m.selectedHost
m.err = ""
m.mutate(fmt.Sprintf("добавлен сервис %s (порт %d) хосту %s", name, port, hostName), func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
for hi := range g.Hosts {
if g.Hosts[hi].Name != hostName {
continue
}
for _, svc := range g.Hosts[hi].Services {
if svc.Name == name {
return fmt.Errorf("service %q already exists on host %q", name, hostName)
}
}
g.Hosts[hi].Services = append(g.Hosts[hi].Services, model.Service{Name: name, Port: port, Description: desc})
}
return resyncGroupUsers(st, groupName)
})
m.screen = scrHostServices
m.refreshServicesList()
return m, nil
}
}
var cmd tea.Cmd
m.serviceForm[m.serviceFormFocus], cmd = m.serviceForm[m.serviceFormFocus].Update(msg)
return m, cmd
}
func (m Model) viewServiceNew() string {
body := fmt.Sprintf("Новый сервис на хосте %q\n\nИмя: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
m.selectedHost, m.serviceForm[0].View(), m.serviceForm[1].View(), m.serviceForm[2].View())
return borderStyle.Render(body)
}
+54 -2
View File
@@ -42,11 +42,11 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
m.screen = scrAudit
m.refreshAudit()
case "quit":
return m, tea.Quit
return m.attemptQuit()
}
return m, nil
case "q":
return m, tea.Quit
return m.attemptQuit()
}
}
var cmd tea.Cmd
@@ -57,3 +57,55 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) viewMenu() string {
return borderStyle.Render(m.menu.View())
}
// unexportedUsers returns active users who have never had a key issued
// (empty PublicKey) — created but forgotten before "Экспорт архивов".
func unexportedUsers(m *Model) []string {
st := m.loadState()
var out []string
for _, u := range st.Users {
if u.PublicKey == "" {
out = append(out, u.Username)
}
}
return out
}
// attemptQuit is the only path that actually quits from the menu ("q" or
// selecting "Выход"): if there are users created but never exported, it
// warns instead of quitting straight away, since it's easy to create a
// batch of users, get interrupted, and forget the export step entirely.
// ctrl+c bypasses this deliberately, as an always-available hard exit.
func (m Model) attemptQuit() (tea.Model, tea.Cmd) {
pending := unexportedUsers(&m)
if len(pending) == 0 {
return m, tea.Quit
}
m.quitPending = pending
m.quitReturnTo = scrMenu
m.screen = scrQuitConfirm
return m, nil
}
func (m Model) updateQuitConfirm(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "enter", "y":
return m, tea.Quit
case "esc", "n":
m.screen = m.quitReturnTo
return m, nil
}
}
return m, nil
}
func (m Model) viewQuitConfirm() string {
body := warnStyle.Render("Есть пользователи, для которых ещё не сгенерирован ключ (не были в «Экспорт архивов»):") + "\n\n"
for _, u := range m.quitPending {
body += " - " + u + "\n"
}
body += "\nБез экспорта у них нет ключа и архива для передачи — доступ не работает.\n"
body += "\n[enter/y] выйти всё равно [esc/n] вернуться"
return borderStyle.Render(body)
}
+125 -60
View File
@@ -24,7 +24,7 @@ func (m *Model) refreshUsersList() {
items = append(items, simpleItem{id: u.Username, title: u.Username, desc: desc})
}
l := list.New(items, list.NewDefaultDelegate(), m.usersList.Width(), m.usersList.Height())
l.Title = "Пользователи [n]новый [l]заблокировать [u]разблокировать [d]удалить [esc]назад"
l.Title = "Пользователи [n]новый [g]группы [c]обновить конфиг [l]заблокировать [u]разблокировать [d]удалить [esc]назад"
l.SetShowStatusBar(false)
m.usersList = l
}
@@ -46,68 +46,95 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
m.userNewFocus = 0
m.err, m.status = "", ""
return m, nil
case "l", "u", "d":
case "g":
sel, ok := m.usersList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
u := st.FindUser(sel.id)
if u == nil {
return m, nil
}
m.userNewGroupsSel = map[string]bool{}
for _, g := range u.Groups {
m.userNewGroupsSel[g] = true
}
m.userNewGroupsOrder = nil
for _, g := range st.Groups {
m.userNewGroupsOrder = append(m.userNewGroupsOrder, g.Name)
}
m.userNewCursor = 0
m.userGroupsEditTarget = sel.id
m.err, m.status = "", ""
m.screen = scrUserNewGroups
return m, nil
case "l":
sel, ok := m.usersList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
username := sel.id
switch key.String() {
case "l":
if err := sysadm.LockSystemUser(username); err != nil {
m.err = err.Error()
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")
case "u":
if err := sysadm.UnlockSystemUser(username); err != nil {
m.err = err.Error()
return m, nil
}
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)
}
u.Status = model.StatusActive
u.LockedAt = nil
if u.PublicKey != "" {
if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedHosts(u)); err != nil {
return err
}
}
return nil
})
m.logAudit(username, "admin", "unlocked")
case "d":
if err := sysadm.DeleteSystemUser(username); err != nil {
m.err = err.Error()
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.pendingConfirm = confirmLockUser
m.confirmTarget = username
m.confirmReturnTo = scrUsers
m.confirmMessage = fmt.Sprintf(
"Заблокировать пользователя %q?\n\n"+
"Аккаунт будет немедленно заблокирован (пароль отключён, срок действия\n"+
"истёк, ключ в authorized_keys закомментирован) — доступ пропадёт сразу.\n"+
"Это обратимо: позже можно разблокировать клавишей [u].", username)
m.screen = scrConfirmAction
return m, nil
case "u":
sel, ok := m.usersList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
username := sel.id
if err := sysadm.UnlockSystemUser(username); err != nil {
m.err = err.Error()
return m, nil
}
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)
}
u.Status = model.StatusActive
u.LockedAt = nil
if u.PublicKey != "" {
if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedForwards(u)); err != nil {
return err
}
}
return nil
})
m.logAudit(username, "admin", "unlocked")
m.refreshUsersList()
return m, nil
case "d":
sel, ok := m.usersList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
username := sel.id
m.pendingConfirm = confirmDeleteUser
m.confirmTarget = username
m.confirmReturnTo = scrUsers
m.confirmMessage = fmt.Sprintf(
"Удалить пользователя %q НЕОБРАТИМО?\n\n"+
"Будет выполнено userdel -r: системный аккаунт и его домашняя папка\n"+
"удаляются безвозвратно.\n"+
"Если нужно просто временно отключить доступ — используйте блокировку\n"+
"[l] вместо удаления, её можно отменить.", username)
m.screen = scrConfirmAction
return m, nil
case "c":
sel, ok := m.usersList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
return m.runConfigUpdate(sel.id)
}
}
var cmd tea.Cmd
@@ -184,11 +211,20 @@ func (m Model) viewUserNew() string {
// --- new user wizard: step 2, group checkboxes ---
// updateUserNewGroups drives the group-checkbox screen, shared by two
// flows: assigning groups while creating a brand new user (userGroupsEditTarget
// empty), and changing an existing user's groups later (userGroupsEditTarget
// set to their username, entered via "g" on the users list).
func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrUserNew
if m.userGroupsEditTarget != "" {
m.userGroupsEditTarget = ""
m.screen = scrUsers
} else {
m.screen = scrUserNew
}
return m, nil
case "up", "k":
if m.userNewCursor > 0 {
@@ -207,14 +243,37 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case "enter":
username := m.userNewName.Value()
comment := m.userNewComment.Value()
var groups []string
for _, g := range m.userNewGroupsOrder {
if m.userNewGroupsSel[g] {
groups = append(groups, g)
}
}
if m.userGroupsEditTarget != "" {
username := m.userGroupsEditTarget
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)
}
u.Groups = groups
if u.PublicKey != "" && u.Status == model.StatusActive {
if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedForwards(u)); err != nil {
return err
}
}
return nil
})
m.logAudit(username, "admin", "groups changed to "+fmt.Sprint(groups))
m.userGroupsEditTarget = ""
m.screen = scrUsers
m.refreshUsersList()
return m, nil
}
username := m.userNewName.Value()
comment := m.userNewComment.Value()
if err := sysadm.CreateSystemUser(username); err != nil {
m.err = err.Error()
m.screen = scrUsers
@@ -242,7 +301,13 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Model) viewUserNewGroups() string {
body := "Группы доступа для нового пользователя (union хостов всех выбранных групп)\n\n"
title := "Группы доступа для нового пользователя"
confirmHint := "создать пользователя"
if m.userGroupsEditTarget != "" {
title = fmt.Sprintf("Группы доступа пользователя %s", m.userGroupsEditTarget)
confirmHint = "сохранить"
}
body := title + " (эффективный доступ = объединение сервисов всех отмеченных групп)\n\n"
if len(m.userNewGroupsOrder) == 0 {
body += warnStyle.Render("Группы ещё не созданы — сначала создайте хотя бы одну в разделе «Группы и хосты».") + "\n"
}
@@ -257,6 +322,6 @@ func (m Model) viewUserNewGroups() string {
}
body += fmt.Sprintf("%s%s %s\n", cursor, check, g)
}
body += "\n[space] отметить [enter] создать пользователя [esc] назад"
body += fmt.Sprintf("\n[space] отметить [enter] %s [esc] назад", confirmHint)
return borderStyle.Render(body)
}