init
This commit is contained in:
commit
23c7133fa9
33 files changed
+2896
No files matched your search
@@ -0,0 +1,220 @@
|
||||
// Package ui implements the interactive terminal UI: a single bubbletea
|
||||
// Model with a small screen stack, backed by the real system/store/keymgr
|
||||
// operations — every action taken here (create user, lock, edit group
|
||||
// hosts, export) performs the actual privileged operation immediately, then
|
||||
// records it to the audit log and (for state changes) commits it to the
|
||||
// git-backed state store.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"ssh-access-tui/internal/audit"
|
||||
"ssh-access-tui/internal/config"
|
||||
"ssh-access-tui/internal/keymgr"
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/store"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
scrMenu screen = iota
|
||||
scrUsers
|
||||
scrUserNew
|
||||
scrUserNewGroups
|
||||
scrGroups
|
||||
scrGroupHosts
|
||||
scrHostNew
|
||||
scrExportSelect
|
||||
scrExportResult
|
||||
scrAudit
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Padding(0, 1)
|
||||
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
|
||||
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2)
|
||||
)
|
||||
|
||||
// Model is the root bubbletea model for the whole application.
|
||||
type Model struct {
|
||||
cfg config.Config
|
||||
st *store.Store
|
||||
|
||||
screen screen
|
||||
err string
|
||||
status string
|
||||
|
||||
menu list.Model
|
||||
|
||||
usersList list.Model
|
||||
|
||||
userNewName textinput.Model
|
||||
userNewComment textinput.Model
|
||||
userNewFocus int
|
||||
userNewGroupsSel map[string]bool
|
||||
userNewGroupsOrder []string
|
||||
userNewCursor int
|
||||
|
||||
groupsList list.Model
|
||||
groupNewName textinput.Model
|
||||
awaitingGroup bool
|
||||
selectedGroup string
|
||||
hostsList list.Model
|
||||
hostForm [4]textinput.Model // name, address, port, description
|
||||
hostFormFocus int
|
||||
|
||||
exportSel map[string]bool
|
||||
exportOrder []string
|
||||
exportCursor int
|
||||
exportRes []keymgr.BuildResult
|
||||
exportPass string
|
||||
exportErr string
|
||||
|
||||
onlineSessions []audit.OnlineSession
|
||||
recentEvents []audit.Event
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
// New builds the initial Model rooted at the given config and store.
|
||||
//
|
||||
// All list.Model fields are initialized here via list.New (not left as the
|
||||
// zero value), even for screens the user hasn't visited yet: bubbletea
|
||||
// delivers a WindowSizeMsg immediately on startup, which fans out to
|
||||
// SetSize on every list in Update — and list.Model.SetSize on an
|
||||
// uninitialized zero-value list panics (nil pagination model).
|
||||
func New(cfg config.Config, st *store.Store) Model {
|
||||
m := Model{cfg: cfg, st: st}
|
||||
m.menu = newMenuList()
|
||||
m.usersList = emptyList("Пользователи")
|
||||
m.groupsList = emptyList("Группы доступа")
|
||||
m.hostsList = emptyList("Хосты")
|
||||
return m
|
||||
}
|
||||
|
||||
func emptyList(title string) list.Model {
|
||||
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
||||
l.Title = title
|
||||
l.SetShowStatusBar(false)
|
||||
return l
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m *Model) loadState() *model.State {
|
||||
st, err := m.st.Load()
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
return &model.State{}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (m *Model) mutate(commitMsg string, fn func(st *model.State) error) {
|
||||
err := m.st.WithLock(func() error {
|
||||
st, err := m.st.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(st); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.st.Save(st, commitMsg)
|
||||
})
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
} else {
|
||||
m.err = ""
|
||||
m.status = commitMsg
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) logAudit(user, action, detail string) {
|
||||
_ = audit.Append(m.cfg.AuditLog, audit.Event{Time: time.Now(), User: user, Action: action, Detail: detail})
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
listW, listH := msg.Width-4, msg.Height-8
|
||||
m.menu.SetSize(listW, listH)
|
||||
m.usersList.SetSize(listW, listH)
|
||||
m.groupsList.SetSize(listW, listH)
|
||||
m.hostsList.SetSize(listW, listH)
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
switch m.screen {
|
||||
case scrMenu:
|
||||
return m.updateMenu(msg)
|
||||
case scrUsers:
|
||||
return m.updateUsers(msg)
|
||||
case scrUserNew:
|
||||
return m.updateUserNew(msg)
|
||||
case scrUserNewGroups:
|
||||
return m.updateUserNewGroups(msg)
|
||||
case scrGroups:
|
||||
return m.updateGroups(msg)
|
||||
case scrGroupHosts:
|
||||
return m.updateGroupHosts(msg)
|
||||
case scrHostNew:
|
||||
return m.updateHostNew(msg)
|
||||
case scrExportSelect:
|
||||
return m.updateExportSelect(msg)
|
||||
case scrExportResult:
|
||||
return m.updateExportResult(msg)
|
||||
case scrAudit:
|
||||
return m.updateAudit(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
var body string
|
||||
switch m.screen {
|
||||
case scrMenu:
|
||||
body = m.viewMenu()
|
||||
case scrUsers:
|
||||
body = m.viewUsers()
|
||||
case scrUserNew:
|
||||
body = m.viewUserNew()
|
||||
case scrUserNewGroups:
|
||||
body = m.viewUserNewGroups()
|
||||
case scrGroups:
|
||||
body = m.viewGroups()
|
||||
case scrGroupHosts:
|
||||
body = m.viewGroupHosts()
|
||||
case scrHostNew:
|
||||
body = m.viewHostNew()
|
||||
case scrExportSelect:
|
||||
body = m.viewExportSelect()
|
||||
case scrExportResult:
|
||||
body = m.viewExportResult()
|
||||
case scrAudit:
|
||||
body = m.viewAudit()
|
||||
}
|
||||
|
||||
footer := ""
|
||||
if m.err != "" {
|
||||
footer = errStyle.Render("ошибка: " + m.err)
|
||||
} else if m.status != "" {
|
||||
footer = okStyle.Render(m.status)
|
||||
}
|
||||
return fmt.Sprintf("%s\n\n%s\n", body, footer)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"ssh-access-tui/internal/audit"
|
||||
)
|
||||
|
||||
func (m *Model) refreshAudit() {
|
||||
st := m.loadState()
|
||||
managed := map[string]bool{}
|
||||
for _, u := range st.Users {
|
||||
managed[u.Username] = true
|
||||
}
|
||||
sessions, err := audit.ListOnline(managed)
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
}
|
||||
m.onlineSessions = sessions
|
||||
|
||||
events, err := audit.ReadRecent(m.cfg.AuditLog, 25)
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
}
|
||||
m.recentEvents = events
|
||||
}
|
||||
|
||||
func (m Model) updateAudit(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 "r":
|
||||
m.refreshAudit()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) viewAudit() string {
|
||||
body := "Сейчас в сети (сессии с активным sshd-процессом):\n\n"
|
||||
if len(m.onlineSessions) == 0 {
|
||||
body += " (никого)\n"
|
||||
}
|
||||
for _, s := range m.onlineSessions {
|
||||
body += fmt.Sprintf(" %-16s pid=%-8d в сети %s\n", s.User, s.PID, s.Elapsed)
|
||||
}
|
||||
|
||||
body += "\nПоследние события (журнал подключений):\n\n"
|
||||
if len(m.recentEvents) == 0 {
|
||||
body += " (пока пусто — фоновый сборщик журнала ещё не запущен или не было событий)\n"
|
||||
}
|
||||
for i := len(m.recentEvents) - 1; i >= 0; i-- {
|
||||
e := m.recentEvents[i]
|
||||
line := fmt.Sprintf(" %s %-10s %-16s", e.Time.Local().Format("2006-01-02 15:04:05"), e.Action, e.User)
|
||||
if e.SrcIP != "" {
|
||||
line += " from " + e.SrcIP
|
||||
}
|
||||
if e.Detail != "" {
|
||||
line += " " + e.Detail
|
||||
}
|
||||
body += line + "\n"
|
||||
}
|
||||
|
||||
body += "\n[r] обновить [esc] назад"
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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
|
||||
}
|
||||
return m.runExport(chosen)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
hosts := st.AllowedHosts(u)
|
||||
res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, hosts)
|
||||
if err != nil {
|
||||
failures = append(failures, username+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if err := sysadm.WriteAuthorizedKeys(username, res.PublicKeyLine, hosts); 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
|
||||
}
|
||||
|
||||
func (m Model) viewExportSelect() string {
|
||||
body := fmt.Sprintf("Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n")
|
||||
if len(m.exportOrder) == 0 {
|
||||
body += warnStyle.Render("Нет активных пользователей. Сначала создайте пользователя.") + "\n"
|
||||
}
|
||||
for i, u := range m.exportOrder {
|
||||
cursor := " "
|
||||
if i == m.exportCursor {
|
||||
cursor = "> "
|
||||
}
|
||||
check := "[ ]"
|
||||
if m.exportSel[u] {
|
||||
check = "[x]"
|
||||
}
|
||||
body += fmt.Sprintf("%s%s %s\n", cursor, check, u)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/sysadm"
|
||||
)
|
||||
|
||||
func (m *Model) refreshGroupsList() {
|
||||
st := m.loadState()
|
||||
items := make([]list.Item, 0, len(st.Groups))
|
||||
for _, g := range st.Groups {
|
||||
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: fmt.Sprintf("%d хостов", len(g.Hosts))})
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), m.groupsList.Width(), m.groupsList.Height())
|
||||
l.Title = "Группы доступа [n]новая группа [enter]хосты группы [esc]назад"
|
||||
l.SetShowStatusBar(false)
|
||||
m.groupsList = l
|
||||
m.awaitingGroup = false
|
||||
}
|
||||
|
||||
// contains a small state flag reused from Model: awaitingGroup + groupNewName input
|
||||
|
||||
func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.awaitingGroup {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.awaitingGroup = false
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.groupNewName.Value()
|
||||
if name == "" {
|
||||
m.err = "имя группы не может быть пустым"
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
if st.FindGroup(name) != nil {
|
||||
m.err = fmt.Sprintf("группа %q уже существует", name)
|
||||
return m, nil
|
||||
}
|
||||
m.mutate("создана группа "+name, func(st *model.State) error {
|
||||
st.Groups = append(st.Groups, model.Group{Name: name})
|
||||
return nil
|
||||
})
|
||||
m.awaitingGroup = false
|
||||
m.refreshGroupsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.groupNewName, cmd = m.groupNewName.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrMenu
|
||||
m.err, m.status = "", ""
|
||||
return m, nil
|
||||
case "n":
|
||||
m.awaitingGroup = true
|
||||
m.groupNewName = textinput.New()
|
||||
m.groupNewName.Placeholder = "имя группы, например team-billing"
|
||||
m.groupNewName.Focus()
|
||||
m.err = ""
|
||||
return m, nil
|
||||
case "enter":
|
||||
sel, ok := m.groupsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedGroup = sel.id
|
||||
m.screen = scrGroupHosts
|
||||
m.refreshHostsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.groupsList, cmd = m.groupsList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewGroups() string {
|
||||
if m.awaitingGroup {
|
||||
return borderStyle.Render("Новая группа:\n\n" + m.groupNewName.View() + "\n\n[enter] создать [esc] отмена")
|
||||
}
|
||||
return borderStyle.Render(m.groupsList.View())
|
||||
}
|
||||
|
||||
// --- hosts within a group ---
|
||||
|
||||
func (m *Model) refreshHostsList() {
|
||||
st := m.loadState()
|
||||
g := st.FindGroup(m.selectedGroup)
|
||||
var items []list.Item
|
||||
if g != nil {
|
||||
for _, h := range g.Hosts {
|
||||
items = append(items, simpleItem{
|
||||
id: h.Name,
|
||||
title: h.Name,
|
||||
desc: fmt.Sprintf("%s:%d %s", h.Address, h.Port, h.Description),
|
||||
})
|
||||
}
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
|
||||
l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [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.
|
||||
func resyncGroupUsers(st *model.State, groupName string) error {
|
||||
for i := range st.Users {
|
||||
u := &st.Users[i]
|
||||
if u.PublicKey == "" || u.Status != model.StatusActive {
|
||||
continue
|
||||
}
|
||||
inGroup := false
|
||||
for _, g := range u.Groups {
|
||||
if g == groupName {
|
||||
inGroup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inGroup {
|
||||
continue
|
||||
}
|
||||
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedHosts(u)); err != nil {
|
||||
return fmt.Errorf("resync %s: %w", u.Username, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) updateGroupHosts(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrGroups
|
||||
m.refreshGroupsList()
|
||||
return m, nil
|
||||
case "n":
|
||||
m.screen = scrHostNew
|
||||
for i := range m.hostForm {
|
||||
m.hostForm[i] = textinput.New()
|
||||
}
|
||||
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()
|
||||
return m, nil
|
||||
case "d":
|
||||
sel, ok := m.hostsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
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:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return resyncGroupUsers(st, groupName)
|
||||
})
|
||||
m.refreshHostsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.hostsList, cmd = m.hostsList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewGroupHosts() string {
|
||||
return borderStyle.Render(m.hostsList.View())
|
||||
}
|
||||
|
||||
func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrGroupHosts
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.hostForm[m.hostFormFocus].Blur()
|
||||
m.hostFormFocus = (m.hostFormFocus + 1) % len(m.hostForm)
|
||||
m.hostForm[m.hostFormFocus].Focus()
|
||||
return m, nil
|
||||
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 = "имя, адрес и порт обязательны"
|
||||
return m, nil
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
m.err = "порт должен быть числом от 1 до 65535"
|
||||
return m, nil
|
||||
}
|
||||
groupName := m.selectedGroup
|
||||
m.err = ""
|
||||
m.mutate(fmt.Sprintf("добавлен хост %s в группу %s", name, groupName), func(st *model.State) error {
|
||||
g := st.FindGroup(groupName)
|
||||
if g == nil {
|
||||
return fmt.Errorf("group %s not found", groupName)
|
||||
}
|
||||
for _, h := range g.Hosts {
|
||||
if h.Name == name {
|
||||
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)
|
||||
})
|
||||
m.screen = scrGroupHosts
|
||||
m.refreshHostsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.hostForm[m.hostFormFocus], cmd = m.hostForm[m.hostFormFocus].Update(msg)
|
||||
return m, 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())
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ui
|
||||
|
||||
// simpleItem is a minimal bubbles/list.DefaultItem implementation reused
|
||||
// across every list screen (users, groups, hosts, export selection) so we
|
||||
// don't need a bespoke type per data kind.
|
||||
type simpleItem struct {
|
||||
id, title, desc string
|
||||
}
|
||||
|
||||
func (i simpleItem) Title() string { return i.title }
|
||||
func (i simpleItem) Description() string { return i.desc }
|
||||
func (i simpleItem) FilterValue() string { return i.title }
|
||||
@@ -0,0 +1,59 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func newMenuList() list.Model {
|
||||
items := []list.Item{
|
||||
simpleItem{id: "users", title: "Пользователи", desc: "Создать, заблокировать, удалить учётные записи"},
|
||||
simpleItem{id: "groups", title: "Группы и хосты", desc: "Кому какие хосты разрешены для прыжка/форвардинга"},
|
||||
simpleItem{id: "export", title: "Экспорт архивов", desc: "Сгенерировать ключи и .7z архивы для доставки пользователям"},
|
||||
simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"},
|
||||
simpleItem{id: "quit", title: "Выход", desc: ""},
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
|
||||
l.Title = "ssh-access-tui"
|
||||
l.SetShowStatusBar(false)
|
||||
return l
|
||||
}
|
||||
|
||||
func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "enter":
|
||||
sel, ok := m.menu.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
m.err, m.status = "", ""
|
||||
switch sel.id {
|
||||
case "users":
|
||||
m.screen = scrUsers
|
||||
m.refreshUsersList()
|
||||
case "groups":
|
||||
m.screen = scrGroups
|
||||
m.refreshGroupsList()
|
||||
case "export":
|
||||
m.screen = scrExportSelect
|
||||
m.initExportSelect()
|
||||
case "audit":
|
||||
m.screen = scrAudit
|
||||
m.refreshAudit()
|
||||
case "quit":
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.menu, cmd = m.menu.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewMenu() string {
|
||||
return borderStyle.Render(m.menu.View())
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/sysadm"
|
||||
)
|
||||
|
||||
func (m *Model) refreshUsersList() {
|
||||
st := m.loadState()
|
||||
items := make([]list.Item, 0, len(st.Users))
|
||||
for _, u := range st.Users {
|
||||
fp := u.Fingerprint
|
||||
if fp == "" {
|
||||
fp = "ключ не выдан"
|
||||
}
|
||||
desc := fmt.Sprintf("%s | группы: %v | %s", u.Status, u.Groups, fp)
|
||||
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.SetShowStatusBar(false)
|
||||
m.usersList = l
|
||||
}
|
||||
|
||||
func (m Model) updateUsers(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 "n":
|
||||
m.screen = scrUserNew
|
||||
m.userNewName = textinput.New()
|
||||
m.userNewName.Placeholder = "имя пользователя (латиница, как логин Linux)"
|
||||
m.userNewName.Focus()
|
||||
m.userNewComment = textinput.New()
|
||||
m.userNewComment.Placeholder = "комментарий (например ФИО) — необязательно"
|
||||
m.userNewFocus = 0
|
||||
m.err, m.status = "", ""
|
||||
return m, nil
|
||||
case "l", "u", "d":
|
||||
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.refreshUsersList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.usersList, cmd = m.usersList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewUsers() string {
|
||||
return borderStyle.Render(m.usersList.View())
|
||||
}
|
||||
|
||||
// --- new user wizard: step 1, username/comment ---
|
||||
|
||||
func (m Model) updateUserNew(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrUsers
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.userNewFocus = (m.userNewFocus + 1) % 2
|
||||
m.applyUserNewFocus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.userNewName.Value()
|
||||
if name == "" {
|
||||
m.err = "имя пользователя не может быть пустым"
|
||||
return m, nil
|
||||
}
|
||||
if sysadm.UserExists(name) {
|
||||
m.err = fmt.Sprintf("системный пользователь %q уже существует", name)
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
if st.FindUser(name) != nil {
|
||||
m.err = fmt.Sprintf("пользователь %q уже есть в состоянии tool'а", name)
|
||||
return m, nil
|
||||
}
|
||||
m.err = ""
|
||||
m.userNewGroupsSel = map[string]bool{}
|
||||
m.userNewGroupsOrder = nil
|
||||
for _, g := range st.Groups {
|
||||
m.userNewGroupsOrder = append(m.userNewGroupsOrder, g.Name)
|
||||
}
|
||||
m.userNewCursor = 0
|
||||
m.screen = scrUserNewGroups
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
if m.userNewFocus == 0 {
|
||||
m.userNewName, cmd = m.userNewName.Update(msg)
|
||||
} else {
|
||||
m.userNewComment, cmd = m.userNewComment.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) applyUserNewFocus() {
|
||||
if m.userNewFocus == 0 {
|
||||
m.userNewName.Focus()
|
||||
m.userNewComment.Blur()
|
||||
} else {
|
||||
m.userNewName.Blur()
|
||||
m.userNewComment.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) viewUserNew() string {
|
||||
body := fmt.Sprintf("Новый пользователь\n\nИмя: %s\nКомментарий: %s\n\n[tab] переключить поле [enter] далее: выбор групп [esc] отмена",
|
||||
m.userNewName.View(), m.userNewComment.View())
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
|
||||
// --- new user wizard: step 2, group checkboxes ---
|
||||
|
||||
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
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
if m.userNewCursor > 0 {
|
||||
m.userNewCursor--
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.userNewCursor < len(m.userNewGroupsOrder)-1 {
|
||||
m.userNewCursor++
|
||||
}
|
||||
return m, nil
|
||||
case " ":
|
||||
if len(m.userNewGroupsOrder) > 0 {
|
||||
g := m.userNewGroupsOrder[m.userNewCursor]
|
||||
m.userNewGroupsSel[g] = !m.userNewGroupsSel[g]
|
||||
}
|
||||
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 err := sysadm.CreateSystemUser(username); err != nil {
|
||||
m.err = err.Error()
|
||||
m.screen = scrUsers
|
||||
m.refreshUsersList()
|
||||
return m, nil
|
||||
}
|
||||
m.mutate("создан пользователь "+username, func(st *model.State) error {
|
||||
st.Users = append(st.Users, model.User{
|
||||
Username: username,
|
||||
Groups: groups,
|
||||
Status: model.StatusActive,
|
||||
Comment: comment,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
m.logAudit(username, "admin", "created, groups="+fmt.Sprint(groups))
|
||||
m.status = fmt.Sprintf("пользователь %s создан. Ключ ещё не выдан — сгенерируйте его в разделе «Экспорт архивов».", username)
|
||||
m.screen = scrUsers
|
||||
m.refreshUsersList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) viewUserNewGroups() string {
|
||||
body := "Группы доступа для нового пользователя (union хостов всех выбранных групп)\n\n"
|
||||
if len(m.userNewGroupsOrder) == 0 {
|
||||
body += warnStyle.Render("Группы ещё не созданы — сначала создайте хотя бы одну в разделе «Группы и хосты».") + "\n"
|
||||
}
|
||||
for i, g := range m.userNewGroupsOrder {
|
||||
cursor := " "
|
||||
if i == m.userNewCursor {
|
||||
cursor = "> "
|
||||
}
|
||||
check := "[ ]"
|
||||
if m.userNewGroupsSel[g] {
|
||||
check = "[x]"
|
||||
}
|
||||
body += fmt.Sprintf("%s%s %s\n", cursor, check, g)
|
||||
}
|
||||
body += "\n[space] отметить [enter] создать пользователя [esc] назад"
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
Reference in new issue
Block a user