416 lines
13 KiB
Go
416 lines
13 KiB
Go
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"
|
|
)
|
|
|
|
// 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))
|
|
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
|
|
}
|
|
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)
|
|
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 сервисов", h.Address, len(h.Services)),
|
|
})
|
|
}
|
|
}
|
|
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
|
|
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 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]
|
|
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.AllowedForwards(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.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
|
|
nServices := 0
|
|
if g := st.FindGroup(groupName); g != nil {
|
|
for _, h := range g.Hosts {
|
|
if h.Name == sel.id {
|
|
nServices = len(h.Services)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
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()
|
|
if name == "" || addr == "" {
|
|
m.err = "имя и адрес обязательны"
|
|
return m, nil
|
|
}
|
|
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 {
|
|
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})
|
|
created = true
|
|
return nil
|
|
})
|
|
if !created {
|
|
return m, nil
|
|
}
|
|
m.selectedHost = name
|
|
m.screen = scrHostServices
|
|
m.refreshServicesList()
|
|
m.status = fmt.Sprintf("хост %s добавлен — теперь добавьте на нём хотя бы один сервис (порт)", name)
|
|
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\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)
|
|
}
|