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) }