This commit is contained in:
redlineo committed 2026-07-22 14:14:53 +03:00
commit 23c7133fa9
33 files changed
+2896

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
# ssh-access-tui
Консольный (TUI) инструмент для администрирования Linux jump-хоста: заводит
пользователей, у которых есть **только** проброс портов (`-L`/`-D`) и прыжок
(`ProxyJump`) к явно разрешённому списку внутренних хостов — без
интерактивного шелла. Управляет группами доступа, генерирует и раздаёт
зашифрованные архивы с ключом/конфигом/инструкцией, блокирует уволенных,
пишет журнал подключений и хранит всю конфигурацию в git для бэкапа/переезда.
Почему не веб-панель: инструмент требует root (создание пользователей,
правка `authorized_keys` и `sshd_config`). Веб-бэкенд с такими правами —
новый слушающий процесс с TLS/сессиями/CSRF на и так критичном хосте. TUI
работает поверх уже существующего SSH+sudo доступа админа и не добавляет
новой сетевой поверхности атаки.
## Требования на jump-хосте
- Linux с systemd и OpenSSH ≥ 7.2 (`restrict`, `permitopen=` в
`authorized_keys`; актуальные Debian/Ubuntu/RHEL этому условию отвечают).
- `ssh-keygen`, `7z` (пакет `p7zip-full`), `git` — устанавливаются через
системный пакетный менеджер.
- Go ≥ 1.24 — только для сборки бинарника (можно собрать на другой машине).
## Установка
```sh
sudo apt-get install -y p7zip-full git # или аналог для вашего дистрибутива
sudo ./scripts/install.sh
```
Скрипт собирает бинарник, ставит systemd-юниты (сборщик журнала подключений
и часовой git-бэкап состояния), создаёт каталоги `/var/lib/ssh-access-tui/*`
и `Match`-блок в `/etc/ssh/sshd_config.d/50-ssh-access-tui.conf`.
После установки настройте git-бэкап на внешний репозиторий (см.
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#бэкап-и-аварийное-восстановление)):
```sh
git -C /var/lib/ssh-access-tui/state remote add origin <ssh-или-https-url>
```
## Запуск
```sh
sudo ssh-access-tui
```
Полное руководство по экранам и типовым сценариям — в
[docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
Обоснование архитектурных решений и модель угроз — в
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Структура репозитория
```
cmd/ssh-access-tui/ точка входа (TUI, install, audit-tail)
internal/model/ типы данных (Group, Host, User, State)
internal/store/ YAML-состояние в git-репозитории, файловая блокировка
internal/sysadm/ useradd/usermod, authorized_keys, sshd Match-блок
internal/keymgr/ генерация ключей, клиентский config, .7z архив
internal/audit/ журнал подключений (JSONL) + "кто сейчас в сети"
internal/ui/ bubbletea TUI
scripts/ install.sh, systemd-юниты, скрипт бэкапа
docs/ руководство администратора, архитектура/модель угроз
```
+123
View File
@@ -0,0 +1,123 @@
// Command ssh-access-tui is a terminal UI for administering a Linux jump
// host: creating jump/forward-only user accounts, scoping each one's
// allowed destination hosts via SSH group membership, exporting encrypted
// key/config archives for delivery, locking departed users, and viewing who
// is currently connected.
package main
import (
"flag"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/audit"
"ssh-access-tui/internal/config"
"ssh-access-tui/internal/store"
"ssh-access-tui/internal/sysadm"
"ssh-access-tui/internal/ui"
)
const defaultConfigPath = "/etc/ssh-access-tui/config.yaml"
func main() {
configPath := flag.String("config", defaultConfigPath, "путь к конфигурации")
flag.Parse()
args := flag.Args()
sub := ""
if len(args) > 0 {
sub = args[0]
}
cfg, err := config.Load(*configPath)
if err != nil {
fatal(err)
}
switch sub {
case "install":
runInstall(*configPath, cfg)
case "audit-tail":
if err := audit.TailJournal(cfg.AuditLog); err != nil {
fatal(err)
}
default:
runTUI(cfg)
}
}
func runTUI(cfg config.Config) {
if err := sysadm.RequireRoot(); err != nil {
fatal(fmt.Errorf("%w (управление пользователями и authorized_keys требует привилегий root)", err))
}
if cfg.JumpHost == "" {
fatal(fmt.Errorf("jump_host не задан в %s — укажите публичный адрес этого хоста для генерации клиентских конфигов", defaultConfigPath))
}
st, err := store.New(cfg.StateDir)
if err != nil {
fatal(err)
}
p := tea.NewProgram(ui.New(cfg, st), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fatal(err)
}
}
func runInstall(configPath string, cfg config.Config) {
if err := sysadm.RequireRoot(); err != nil {
fatal(err)
}
if err := os.MkdirAll(cfg.StateDir, 0o750); err != nil {
fatal(err)
}
if err := os.MkdirAll(cfg.ExportDir, 0o750); err != nil {
fatal(err)
}
if err := os.MkdirAll(cfg.ScratchDir, 0o700); err != nil {
fatal(err)
}
if err := os.MkdirAll(dirOf(cfg.AuditLog), 0o750); err != nil {
fatal(err)
}
if err := os.MkdirAll(dirOf(configPath), 0o755); err != nil {
fatal(err)
}
if cfg.JumpHost == "" {
fmt.Print("Публичный адрес этого jump-хоста (как к нему подключаются пользователи): ")
fmt.Scanln(&cfg.JumpHost)
}
if err := config.Save(configPath, cfg); err != nil {
fatal(err)
}
if err := sysadm.EnsureJumpGroup(); err != nil {
fatal(err)
}
if err := sysadm.InstallSSHDConfig(); err != nil {
fatal(err)
}
if !sysadm.DropInIncluded() {
fmt.Println("ВНИМАНИЕ: /etc/ssh/sshd_config не похож на включающий sshd_config.d/*.conf.")
fmt.Println("Добавьте туда вручную строку: Include /etc/ssh/sshd_config.d/*.conf")
}
fmt.Println("Установка завершена.")
fmt.Printf("Конфигурация: %s\n", configPath)
fmt.Printf("Состояние (git-репозиторий): %s\n", cfg.StateDir)
fmt.Printf("Архивы экспорта: %s\n", cfg.ExportDir)
fmt.Println("Не забудьте включить фоновый сборщик журнала: systemctl enable --now ssh-access-tui-audit.service")
}
func dirOf(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' {
return path[:i]
}
}
return "."
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "ssh-access-tui:", err)
os.Exit(1)
}
+133
View File
@@ -0,0 +1,133 @@
# Руководство администратора
Практическое руководство по экранам `ssh-access-tui` и типовым сценариям.
Архитектурные решения и модель угроз — в [ARCHITECTURE.md](ARCHITECTURE.md).
## Запуск
```sh
sudo ssh-access-tui
```
Требует root (создаёт/блокирует системных пользователей, правит
`authorized_keys`). Инструмент рассчитан на **одного** администратора
одновременно на хосте — если кто-то ещё уже открыл TUI, вторая сессия
получит ошибку про занятую блокировку состояния (`state.lock`), а не молча
перезапишет чужие изменения.
Навигация везде: стрелки/`j`/`k` — перемещение по списку, `enter` —
выбрать/подтвердить, `esc` — назад, `ctrl+c` — выход в любой момент.
## Главное меню
```
Пользователи — создать / заблокировать / разблокировать / удалить
Группы и хосты — какие внутренние хосты кому разрешены
Экспорт архивов — сгенерировать ключи и .7z для доставки
Аудит / кто в сети — активные сессии и журнал подключений
Выход
```
## Сценарий 1: новый сотрудник, у которого уже есть роль/группа доступа
Обычно сначала настраивают группу (один раз), потом заводят пользователей в
неё пачками.
1. **Группы и хосты** → `n` → ввести имя группы (например `team-billing`) →
`enter`.
2. Выбрать группу → `enter`, попасть в список хостов группы (изначально
пуст).
3. `n` → заполнить: имя хоста (короткий алиас, например `db-prod-1`), адрес
(как виден с jump-хоста), порт, описание (необязательно) → `tab` между
полями → `enter` сохранить. Повторить для каждого внутреннего хоста,
который должен быть доступен этой группе.
4. `esc` дважды — назад в главное меню.
5. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин)
и комментарий (например ФИО) → `enter`.
6. Отметить `space` нужные группы (можно несколько — эффективный список
хостов пользователя = объединение хостов всех отмеченных групп) → `enter`.
Это уже создаёт системный Linux-аккаунт (без пароля, без интерактивного
шелла) и запись в состоянии. **Ключ ещё не выдан** — статус в списке
пользователей покажет «ключ не выдан».
7. **Экспорт архивов** → отметить `space` только что созданного пользователя
(или `a` — отметить всех, удобно при пакетном найме) → `enter`.
8. Инструмент сгенерирует ключ, пропишет `permitopen=` под текущие хосты
группы, соберёт `.7z` и покажет **один общий пароль на всю партию** — он
показывается один раз, запишите/скопируйте сразу.
9. Передайте каждому пользователю его архив (файл, путь показан на экране,
лежит в `export_dir`) любым каналом, а пароль от архива — **отдельным**
каналом (голосом, в личку) и по возможности не всем одним и тем же
сообщением в общем чате.
10. Пользователь распаковывает архив на Windows по инструкции
`INSTRUCTIONS.txt` внутри архива (она уже сгенерирована под его
конкретные разрешённые хосты).
## Сценарий 2: добавили новый разрешённый хост существующей группе
1. **Группы и хосты** → выбрать группу → `enter`.
2. `n` → заполнить новый хост → `enter`.
Это сразу обновляет `permitopen=` в `authorized_keys` **всех активных
пользователей** этой группы — доступ появляется немедленно, без каких-либо
действий на стороне пользователя. Клиентский конфиг пользователя (его
`~/.ssh/config`) при этом не обновляется автоматически — если хотите, чтобы
у пользователя появился готовый алиас для нового хоста, а не только "голый"
доступ, повторно прогоните его через **Экспорт архивов** (это заодно
ротирует его ключ — считайте это плановой ротацией, не проблемой).
Удаление хоста из группы (`d` в списке хостов) действует так же немедленно
и в обратную сторону.
## Сценарий 3: сотрудник уволился
1. **Пользователи** → выбрать пользователя → `l` (заблокировать).
Это одновременно: блокирует пароль аккаунта, устанавливает немедленный срок
истечения аккаунта (`chage -E 1`) и комментирует его ключ в
`authorized_keys` — три независимых механизма, чтобы блокировка не зависела
от одного из них. Статус в списке сменится на `locked`.
Если сотрудник вернулся — `u` (разблокировать) на том же пользователе:
снимает блокировку аккаунта и восстанавливает его ключ в `authorized_keys`
с текущими правами групп.
Полное удаление аккаунта (`d`) — необратимо (`userdel -r`, домашняя папка
стирается), используйте для окончательного оффбординга, не для временной
блокировки.
## Сценарий 4: проверить, кто сейчас подключён / посмотреть историю
**Аудит / кто в сети** показывает:
- «Сейчас в сети» — список активных SSH-сессий (по процессам `sshd:
user [priv]`, а не по `who`, потому что форвардинг-сессии без
интерактивного терминала не создают обычных login-записей);
- «Последние события» — журнал подключений/отключений, который в фоне
собирает `ssh-access-tui-audit.service` из `journalctl`, независимо от
ротации системного журнала.
`r` — обновить оба списка. Если журнал подключений пуст, хотя пользователи
явно заходили — проверьте, что фоновый сервис запущен:
`systemctl status ssh-access-tui-audit.service`.
## Сценарий 5: перенос на новый хост / хост умер
См. подробности в [ARCHITECTURE.md](ARCHITECTURE.md#бэкап-и-аварийное-восстановление).
Коротко: состояние (`groups`, `users`, публичные ключи, статусы) —
git-репозиторий, бэкапится часовым таймером в удалённый git. На новом хосте:
поднять `ssh-access-tui`, влить `state.yaml` из бэкапа, создать системные
Linux-аккаунты для активных пользователей, выпустить им новые ключи через
**Экспорт архивов** (старые приватные ключи специально нигде не хранятся —
это не баг, см. архитектурный документ).
## Что не делает эта версия (нужно руками)
- Не удаляет `.7z` из `export_dir` после доставки — сделайте это сами,
когда получатель подтвердил, что распаковал архив.
- Не обновляет клиентский `~/.ssh/config` пользователя автоматически при
появлении нового хоста в его группе — только права доступа на сервере
обновляются немедленно (см. сценарий 2).
- Не поддерживает одновременную работу нескольких администраторов —
рассчитан на одного за раз (см. «Конкурентность» в ARCHITECTURE.md).
+148
View File
@@ -0,0 +1,148 @@
# Архитектура и модель угроз
## Почему TUI, а не веб
Веб-панель для управления пользователями требует бэкенда с root-правами
(создание Linux-аккаунтов, правка `authorized_keys`, `sshd_config`),
слушающего порт. Это новый привилегированный сетевой процесс на хосте,
который и так является входной точкой для множества людей: своя
аутентификация/сессии/CSRF, TLS-сертификаты, обновления фреймворка — то есть
дополнительная поверхность атаки, не связанная с самой задачей.
TUI работает поверх уже существующего SSH+`sudo`-доступа администратора к
этому хосту — доступа, который и так есть и уже находится под контролем
(ключи, MFA, аудит доступа к самому jump-хосту). Новых портов, новых
механизмов аутентификации и нового ПО, которое надо патчить, не появляется.
Цена этого решения: нет привычного браузерного UX, и параллельная работа
нескольких админов не даётся бесплатно фреймворком — её пришлось
реализовать вручную (см. «Конкурентность» ниже). Для небольшой команды
администраторов это разумный компромисс.
## Как на самом деле работает ограничение "только прыжок/форвардинг"
Это два независимых слоя, и путать их нельзя:
1. **Сервер (реальная граница безопасности).** Каждому ключу в
`authorized_keys` пользователя приписаны опции:
```
restrict,port-forwarding,permitopen="10.0.1.5:5432",permitopen="10.0.2.9:443" ssh-ed25519 AAAA... user@jump
```
`restrict` разом отключает pty, X11-forwarding, agent-forwarding,
user-rc и port-forwarding; `port-forwarding` точечно возвращает именно
форвардинг (не интерактивный доступ), а `permitopen=` разрешает
форвардинг только к перечисленным `host:port` — это гарантия на уровне
протокола SSH, не зависящая от того, что написано в клиентском конфиге.
Shell пользователя — `/usr/sbin/nologin`: любой запрос shell/exec-канала
(обычный интерактивный `ssh user@jump`) обрывается им же, а
direct-tcpip-каналы (прыжок/форвардинг) идут отдельным путём и
регулируются исключительно `permitopen=`.
Дополнительно в `Match Group ssh-jump-users` (`internal/sysadm/sshdconfig.go`)
продублированы базовые ограничения (`PermitTTY no`, `X11Forwarding no`,
`AllowStreamLocalForwarding no`, локальный, не reverse, forwarding) —
это defense-in-depth на случай, если опции конкретного ключа окажутся
повреждены/потеряны, а не основной механизм.
2. **Клиент (только удобство).** Файл `~/.ssh/config`, который уезжает
пользователю в архиве, — это алиасы (`Host db-prod-1` → `ProxyJump jump`).
Он ничего не запрещает: пользователь может отредактировать его или
вызвать `ssh` руками с любым хостом. Единственная причина, по которой это
не работает, — сервер отклонит `permitopen`, которого нет в списке.
Список хостов в клиентском конфиге должен генерироваться из того же
источника правды (группы в `state.yaml`), что и `permitopen=`, — иначе
конфиг и реальные права разойдутся.
Reverse/remote forwarding (`-R`) в этой модели не выдаётся вообще
(`AllowStreamLocalForwarding no`, никаких `permitlisten=`) — по вашему же
решению при проектировании: этот джамп-хост используется только для входа
внутрь сети, не для проброса портов наружу из неё.
## Жизненный цикл приватного ключа
Приватный ключ **никогда не хранится на диске дольше одной операции
экспорта**: генерируется во временный каталог (`scratch_dir`), упаковывается
в зашифрованный `.7z`, после чего каталог затирается (`shred -u`) и
удаляется — см. `internal/keymgr/pack.go`. В `state.yaml` (и, соответственно,
в git-бэкапе) хранится только **публичная** часть (`PublicKey`) и
fingerprint. Это осознанное решение: кража бэкапа состояния не даёт
приватных ключей ни одного пользователя. Восстановление после потери хоста —
это перевыпуск новых ключей всем (дёшево), а не восстановление старых
приватных ключей (было бы дорого и опасно как хранимый секрет).
Пароль архива — один на партию экспорта (не постоянный, не общий "навсегда"),
генерируется криптографически случайно при каждом экспорте и показывается
администратору один раз. Компрометация этого пароля актуальна только для
ключей, выпущенных в конкретной партии, — передавайте его получателям
отдельным от архива каналом (голосом, в личку, не в общий чат).
## Бэкап и аварийное восстановление
Каталог состояния (`/var/lib/ssh-access-tui/state`) — git-репозиторий.
Каждое изменение (создание пользователя, добавление хоста в группу,
блокировка) — отдельный коммит с сообщением, что и кто сделал. Это даёт:
- бэкап конфигурации (`groups`, `users`, публичные ключи, статусы) — просто
`git push` в удалённый репозиторий (см. `scripts/ssh-access-tui-backup.sh`
+ таймер `ssh-access-tui-backup.timer`, включается инсталлятором);
- историю изменений и `git diff`/`git log` для аудита "кто и когда поменял
разрешённые хосты";
- откат: `git revert`/`git checkout` на предыдущую версию `state.yaml`
при ошибочном изменении (после отката нужно вручную прогнать `resync`
через экран групп, чтобы `authorized_keys` подтянул откаченные права).
При потере хоста: поднимаете новый, разворачиваете `ssh-access-tui`,
клонируете `state.yaml` из бэкапа в `state_dir`, затем для каждого
активного пользователя — заново `useradd` (можно скриптом по списку из
`state.yaml`) и экспорт нового ключа через TUI (старые приватные ключи не
восстановятся и не должны — это ожидаемо, см. выше).
Каталог экспортированных архивов (`export_dir`) **не входит** в этот бэкап и
не должен: это .7z с приватными ключами (пусть и зашифрованными), их место —
у получателей, а не в ещё одной копии на диске/в бэкапе. После подтверждённой
доставки архив стоит удалить с jump-хоста вручную (это сейчас ручной шаг,
см. «Известные ограничения»).
## Журналирование и "кто в сети"
Форвардинг-сессии без pty не создают записей `utmp`, поэтому `who`/`loginctl`
их не видят. Источники правды:
- `LogLevel VERBOSE` в sshd (ставится инсталлятором) — в журнале видно
`Accepted publickey for user from ip`, закрытие сессии и (на VERBOSE)
канал `direct-tcpip` с реальным адресом назначения;
- фоновый systemd-сервис `ssh-access-tui-audit.service`
(`internal/audit/tail.go`) следит за `journalctl -u ssh -f` и пишет
нормализованные события подключения/отключения в
`/var/lib/ssh-access-tui/audit/audit.jsonl` — независимо от ротации
журнала systemd;
- "кто сейчас в сети" — не `who`, а список процессов `sshd: user [priv]`
(`internal/audit/sessions.go`), которые существуют для каждой
аутентифицированной сессии независимо от наличия pty.
## Конкурентность
Инструмент рассчитан на одного администратора одновременно на хосте.
`internal/store.Store.WithLock` берёт эксклюзивную файловую блокировку
(`state.lock`) на время загрузки+изменения+сохранения `state.yaml`: вторая
параллельная сессия получит явную ошибку "state is locked by another
session" вместо тихой порчи данных. Если реально нужно несколько
одновременно работающих админов — потребуется более тонкая блокировка на
уровне отдельных записей и явный git-merge flow; в текущем виде это не
реализовано осознанно (см. вопрос при проектировании).
## Известные ограничения / что дальше
- Обновление клиентского конфига пользователя при появлении **нового**
разрешённого хоста в его группе требует повторного экспорта архива
(что заодно ротирует ключ пользователя — так и задумано: ротация ключа
время от времени — отдельная хорошая практика, просто в MVP она не
отделена от "обновить конфиг"). Удаление хоста из группы вступает в силу
немедленно на сервере (permitopen), без участия клиента.
- Архивы в `export_dir` не удаляются автоматически после доставки —
администратор должен удалить их вручную, когда получатель подтвердил
получение.
- Полностью параллельная работа нескольких админов не поддерживается
(см. «Конкурентность»).
+38
View File
@@ -0,0 +1,38 @@
module ssh-access-tui
go 1.25
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gofrs/flock v0.13.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+80
View File
@@ -0,0 +1,80 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+75
View File
@@ -0,0 +1,75 @@
// Package audit maintains an append-only record of connect/disconnect
// events (independent of journald, which may rotate) and answers "who is
// online right now" — which for jump/forward-only sessions is not visible
// via `who`/`loginctl`, since those rely on pty allocation that this tool
// deliberately disables.
package audit
import (
"bufio"
"encoding/json"
"fmt"
"os"
"time"
"github.com/gofrs/flock"
)
// Event is one line of the audit trail.
type Event struct {
Time time.Time `json:"time"`
User string `json:"user"`
Action string `json:"action"` // "connect", "disconnect", "admin"
Detail string `json:"detail,omitempty"`
SrcIP string `json:"src_ip,omitempty"`
}
// Append adds one event to the JSONL audit log at path, under a file lock
// so the interactive TUI and the background journal-tailer (see tail.go)
// can write concurrently without interleaving corrupt lines.
func Append(path string, ev Event) error {
fl := flock.New(path + ".lock")
if err := fl.Lock(); err != nil {
return fmt.Errorf("lock audit log: %w", err)
}
defer fl.Unlock()
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return fmt.Errorf("open audit log: %w", err)
}
defer f.Close()
data, err := json.Marshal(ev)
if err != nil {
return err
}
_, err = f.Write(append(data, '\n'))
return err
}
// ReadRecent returns the last n events from the audit log, oldest first.
func ReadRecent(path string, n int) ([]Event, error) {
f, err := os.Open(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
var all []Event
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
var ev Event
if err := json.Unmarshal(sc.Bytes(), &ev); err == nil {
all = append(all, ev)
}
}
if len(all) > n {
all = all[len(all)-n:]
}
return all, sc.Err()
}
+48
View File
@@ -0,0 +1,48 @@
package audit
import (
"fmt"
"os/exec"
"strconv"
"strings"
)
// OnlineSession is one currently-connected sshd process for a managed user.
type OnlineSession struct {
User string
PID int
Elapsed string // e.g. "01:23:45" as reported by ps etime
}
// ListOnline reports users currently connected. Port-forwarding-only
// sessions never allocate a pty, so they are invisible to `who`/`loginctl`;
// what *is* always true is that an authenticated connection keeps a
// "sshd: <user> [priv]"-style privilege-separated process alive for as long
// as the session lasts, so we read that from `ps` instead.
func ListOnline(managedUsers map[string]bool) ([]OnlineSession, error) {
out, err := exec.Command("ps", "-eo", "user:32,pid,etime,args", "--no-headers").Output()
if err != nil {
return nil, fmt.Errorf("ps: %w", err)
}
var sessions []OnlineSession
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
user, pidStr, etime := fields[0], fields[1], fields[2]
args := strings.Join(fields[3:], " ")
if !strings.Contains(args, "sshd:") {
continue
}
if !managedUsers[user] {
continue
}
pid, err := strconv.Atoi(pidStr)
if err != nil {
continue
}
sessions = append(sessions, OnlineSession{User: user, PID: pid, Elapsed: etime})
}
return sessions, nil
}
+50
View File
@@ -0,0 +1,50 @@
package audit
import (
"bufio"
"fmt"
"os/exec"
"regexp"
"time"
)
func now() time.Time { return time.Now() }
var (
reAccepted = regexp.MustCompile(`Accepted publickey for (\S+) from (\S+) port \d+`)
reDisconnect = regexp.MustCompile(`Disconnected from user (\S+) (\S+) port \d+`)
reSessClosed = regexp.MustCompile(`session closed for user (\S+)`)
)
// TailJournal runs indefinitely (intended to be run under systemd — see
// scripts/ssh-access-tui-audit.service), following sshd's journal output and
// appending normalized connect/disconnect events to the audit log at
// logPath. This is what makes "who logged in/out" survive independently of
// journald's own retention/rotation policy.
func TailJournal(logPath string) error {
cmd := exec.Command("journalctl", "-u", "ssh", "-u", "sshd", "-f", "-n", "0", "-o", "cat")
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("journalctl pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start journalctl: %w", err)
}
sc := bufio.NewScanner(stdout)
for sc.Scan() {
line := sc.Text()
switch {
case reAccepted.MatchString(line):
m := reAccepted.FindStringSubmatch(line)
_ = Append(logPath, Event{Time: now(), User: m[1], Action: "connect", SrcIP: m[2]})
case reDisconnect.MatchString(line):
m := reDisconnect.FindStringSubmatch(line)
_ = Append(logPath, Event{Time: now(), User: m[1], Action: "disconnect", SrcIP: m[2]})
case reSessClosed.MatchString(line):
m := reSessClosed.FindStringSubmatch(line)
_ = Append(logPath, Event{Time: now(), User: m[1], Action: "disconnect"})
}
}
return cmd.Wait()
}
+62
View File
@@ -0,0 +1,62 @@
// Package config holds the small set of deployment-specific settings the
// tool needs: how this box is reached from the outside, and where its state
// lives on disk.
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Config is the tool's deployment configuration, normally loaded from
// /etc/ssh-access-tui/config.yaml (see scripts/install.sh).
type Config struct {
// JumpHost is the address end users' ssh clients connect to — usually
// this box's public hostname/IP, used only to render client configs.
JumpHost string `yaml:"jump_host"`
JumpPort int `yaml:"jump_port"`
StateDir string `yaml:"state_dir"` // git-backed YAML state (groups/users)
ExportDir string `yaml:"export_dir"` // where finished .7z archives are written
ScratchDir string `yaml:"scratch_dir"` // ephemeral working dir, wiped after each export
AuditLog string `yaml:"audit_log"` // append-only JSONL connect/disconnect log
}
// Default returns sane defaults for an install following scripts/install.sh.
func Default() Config {
return Config{
JumpPort: 22,
StateDir: "/var/lib/ssh-access-tui/state",
ExportDir: "/var/lib/ssh-access-tui/exports",
ScratchDir: "/var/lib/ssh-access-tui/scratch",
AuditLog: "/var/lib/ssh-access-tui/audit/audit.jsonl",
}
}
// Load reads path if it exists, overlaying it on Default(); missing fields
// keep their default. A missing file is not an error (first run).
func Load(path string) (Config, error) {
cfg := Default()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return cfg, nil
}
if err != nil {
return cfg, fmt.Errorf("read config %s: %w", path, err)
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse config %s: %w", path, err)
}
return cfg, nil
}
// Save writes cfg to path as YAML, creating parent directories as needed.
func Save(path string, cfg Config) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
+44
View File
@@ -0,0 +1,44 @@
package keymgr
import (
"fmt"
"strings"
"ssh-access-tui/internal/model"
)
// RenderClientConfig produces an OpenSSH client config (works identically on
// Windows' built-in OpenSSH client, macOS, and Linux) that gives the user a
// "jump" alias plus one alias per host they're allowed to reach.
//
// This file is convenience only, not a security boundary: nothing stops a
// user from editing it or invoking ssh by hand. The actual restriction to
// these hosts is enforced server-side via authorized_keys permitopen=
// (see internal/sysadm/authorizedkeys.go) independent of what this file says.
func RenderClientConfig(jumpHost string, jumpPort int, username, keyFileName string, hosts []model.Host) string {
var b strings.Builder
fmt.Fprintf(&b, "# ssh-access-tui client config for %s\n", username)
fmt.Fprintf(&b, "# Generated %s. Regenerated whenever your allowed host list changes.\n\n", genTimestamp())
fmt.Fprintf(&b, "Host jump\n")
fmt.Fprintf(&b, " HostName %s\n", jumpHost)
fmt.Fprintf(&b, " Port %d\n", jumpPort)
fmt.Fprintf(&b, " User %s\n", username)
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
fmt.Fprintf(&b, " IdentitiesOnly yes\n\n")
for _, h := range hosts {
fmt.Fprintf(&b, "Host %s\n", h.Name)
fmt.Fprintf(&b, " HostName %s\n", h.Address)
fmt.Fprintf(&b, " Port %d\n", h.Port)
fmt.Fprintf(&b, " User %s\n", username)
fmt.Fprintf(&b, " ProxyJump jump\n")
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
fmt.Fprintf(&b, " IdentitiesOnly yes\n")
if h.Description != "" {
fmt.Fprintf(&b, " # %s\n", h.Description)
}
b.WriteString("\n")
}
return b.String()
}
+82
View File
@@ -0,0 +1,82 @@
package keymgr
import (
"fmt"
"strings"
"time"
"ssh-access-tui/internal/model"
)
func genTimestamp() string {
return time.Now().UTC().Format("2006-01-02 15:04 UTC")
}
// RenderInstructions produces a plain-text README aimed at a Windows user
// whose only guaranteed tool is the OpenSSH client bundled with Windows
// 10/11 ("Optional Features" -> OpenSSH Client, or already present).
func RenderInstructions(username, jumpHost, keyFileName string, hosts []model.Host) string {
var b strings.Builder
b.WriteString("SSH ACCESS — SETUP INSTRUCTIONS (Windows)\n")
b.WriteString("==========================================\n\n")
fmt.Fprintf(&b, "Account: %s\n", username)
fmt.Fprintf(&b, "Jump host: %s\n\n", jumpHost)
b.WriteString("This archive contains:\n")
fmt.Fprintf(&b, " - %s your PRIVATE key (keep secret, do not share or send onward)\n", keyFileName)
fmt.Fprintf(&b, " - %s.pub the matching public key (for reference only)\n", keyFileName)
b.WriteString(" - config SSH client config with ready-made host shortcuts\n")
b.WriteString(" - INSTRUCTIONS.txt this file\n\n")
b.WriteString("1. Check you have the SSH client\n")
b.WriteString(" Open PowerShell and run:\n")
b.WriteString(" ssh -V\n")
b.WriteString(" If this fails: Settings -> Apps -> Optional Features -> Add a feature ->\n")
b.WriteString(" \"OpenSSH Client\" -> Install. (Windows 10 1809+/11 ship this by default.)\n\n")
b.WriteString("2. Put the files in place\n")
b.WriteString(" Create the folder (if it doesn't exist) and copy the 3 files into it:\n")
b.WriteString(" %USERPROFILE%\\.ssh\\\n")
b.WriteString(" i.e. C:\\Users\\<you>\\.ssh\\\n")
b.WriteString(" The \"config\" file should be merged with (or placed as) that exact filename,\n")
b.WriteString(" with no file extension: C:\\Users\\<you>\\.ssh\\config\n\n")
b.WriteString("3. Fix the private key permissions (Windows OpenSSH refuses keys that are\n")
b.WriteString(" readable by other users). In PowerShell:\n")
fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /inheritance:r\n", keyFileName)
fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /grant:r \"$env:USERNAME:(R)\"\n\n", keyFileName)
b.WriteString("4. Connect\n")
b.WriteString(" You have shortcuts for each host you're allowed to reach. To open a local\n")
b.WriteString(" port forward to one of them (example: a web service on port 8443):\n\n")
if len(hosts) > 0 {
h := hosts[0]
fmt.Fprintf(&b, " ssh -N -L 8443:localhost:%d %s\n", h.Port, h.Name)
b.WriteString(" Then browse to https://localhost:8443 (or whatever local port you chose)\n")
b.WriteString(" while that command keeps running. Ctrl+C to stop.\n\n")
} else {
b.WriteString(" ssh -N -L <local-port>:localhost:<remote-port> <host-alias>\n\n")
}
b.WriteString(" Your allowed hosts (aliases from the config file):\n")
for _, h := range hosts {
fmt.Fprintf(&b, " - %-20s -> %s:%d", h.Name, h.Address, h.Port)
if h.Description != "" {
fmt.Fprintf(&b, " (%s)", h.Description)
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString("5. You will NOT get an interactive shell\n")
b.WriteString(" Running plain \"ssh jump\" will connect and immediately close — this account\n")
b.WriteString(" is jump/port-forwarding only, by design. Use the -L/-D forwarding commands\n")
b.WriteString(" above (via the host aliases, which already route through the jump host).\n\n")
b.WriteString("If access stops working, contact your administrator — your account may have\n")
b.WriteString("been locked, or the allowed host list may have changed (ask for an updated\n")
b.WriteString("config in that case).\n")
return b.String()
}
+66
View File
@@ -0,0 +1,66 @@
// Package keymgr generates per-user SSH keypairs and packages them, together
// with a ready-to-use client config and Windows-oriented instructions, into
// a password-protected 7z archive for delivery.
//
// The private key only ever exists on disk for the lifetime of one export
// operation: generated into a temp directory, packed into the archive, then
// securely wiped (see Wipe in pack.go). It is never written into the state
// store and therefore never ends up in a backup.
package keymgr
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// KeyPair is the result of generating one user's SSH keypair.
type KeyPair struct {
PrivateKeyPath string
PublicKeyLine string // full "ssh-ed25519 AAAA... comment" line for authorized_keys
Fingerprint string // "SHA256:xxxxx" as reported by ssh-keygen -lf
}
// GeneratePair creates a new passphrase-less ed25519 keypair for username in
// dir. The key itself has no passphrase because the archive it ships in is
// already encrypted (see pack.go); a second passphrase would only add
// friction without a corresponding threat it defends against once the
// archive's own encryption and the server-side no-shell restriction are in
// place.
func GeneratePair(dir, username string) (*KeyPair, error) {
priv := filepath.Join(dir, username+"_jump_key")
pub := priv + ".pub"
cmd := exec.Command("ssh-keygen",
"-t", "ed25519",
"-N", "", // no passphrase
"-C", username+"@jump",
"-f", priv,
)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("ssh-keygen: %w: %s", err, out)
}
pubData, err := os.ReadFile(pub)
if err != nil {
return nil, fmt.Errorf("read public key: %w", err)
}
fpOut, err := exec.Command("ssh-keygen", "-lf", pub).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("fingerprint: %w: %s", err, fpOut)
}
fields := strings.Fields(string(fpOut))
fingerprint := ""
if len(fields) >= 2 {
fingerprint = fields[1]
}
return &KeyPair{
PrivateKeyPath: priv,
PublicKeyLine: strings.TrimSpace(string(pubData)),
Fingerprint: fingerprint,
}, nil
}
+89
View File
@@ -0,0 +1,89 @@
package keymgr
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"ssh-access-tui/internal/model"
)
// BuildResult describes one user's finished delivery archive.
type BuildResult struct {
Username string
ArchivePath string
Fingerprint string
PublicKeyLine string // capture before the scratch dir (and its .pub file) is wiped
}
// BuildUserArchive generates a fresh keypair for username, writes the
// client config and Windows instructions alongside it in a scratch
// directory, packs everything into a single AES-256-encrypted 7z archive
// under outDir, and then wipes the scratch directory (including the
// plaintext private key) from disk. password is shared across one export
// batch and must be communicated to the recipient out-of-band.
func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, hosts []model.Host) (*BuildResult, error) {
userScratch := filepath.Join(scratchDir, username)
if err := os.MkdirAll(userScratch, 0o700); err != nil {
return nil, fmt.Errorf("create scratch dir: %w", err)
}
defer Wipe(userScratch)
kp, err := GeneratePair(userScratch, username)
if err != nil {
return nil, fmt.Errorf("generate keypair: %w", err)
}
keyFileName := filepath.Base(kp.PrivateKeyPath)
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, hosts)
if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil {
return nil, fmt.Errorf("write config: %w", err)
}
instr := RenderInstructions(username, jumpHost, keyFileName, hosts)
if err := os.WriteFile(filepath.Join(userScratch, "INSTRUCTIONS.txt"), []byte(instr), 0o600); err != nil {
return nil, fmt.Errorf("write instructions: %w", err)
}
if err := os.MkdirAll(outDir, 0o750); err != nil {
return nil, fmt.Errorf("create output dir: %w", err)
}
archivePath := filepath.Join(outDir, username+"_ssh_access.7z")
_ = os.Remove(archivePath) // 7z refuses to overwrite silently-appends; start clean
cmd := exec.Command("7z", "a",
"-p"+password,
"-mhe=on", // encrypt file names/headers too, not just contents
"-mx=9",
archivePath,
filepath.Join(userScratch, keyFileName),
filepath.Join(userScratch, keyFileName+".pub"),
filepath.Join(userScratch, "config"),
filepath.Join(userScratch, "INSTRUCTIONS.txt"),
)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("7z: %w: %s", err, out)
}
return &BuildResult{
Username: username,
ArchivePath: archivePath,
Fingerprint: kp.Fingerprint,
PublicKeyLine: kp.PublicKeyLine,
}, nil
}
// Wipe securely removes a scratch directory (best-effort shred of file
// contents, then unlink) so the private key does not linger in free disk
// space or in any backup that might sweep the tool's working directory.
func Wipe(dir string) {
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
_ = exec.Command("shred", "-u", "-n", "1", path).Run()
return nil
})
_ = os.RemoveAll(dir)
}
+30
View File
@@ -0,0 +1,30 @@
package keymgr
import (
"crypto/rand"
"math/big"
)
// charset avoids visually ambiguous characters (0/O, 1/l/I) since this
// password is normally read aloud or typed by hand on the recipient side.
const passwordCharset = "23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz"
// GenerateBatchPassword returns a cryptographically random password shared
// by every archive produced in one export batch. It is shown to the admin
// exactly once (see the export result screen) and must be relayed to each
// recipient over a channel separate from the archive itself.
func GenerateBatchPassword(length int) (string, error) {
if length <= 0 {
length = 20
}
out := make([]byte, length)
max := big.NewInt(int64(len(passwordCharset)))
for i := range out {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
out[i] = passwordCharset[n.Int64()]
}
return string(out), nil
}
+114
View File
@@ -0,0 +1,114 @@
// Package model defines the data types persisted as the tool's state:
// hosts grouped into access groups, and users assigned to those groups.
package model
import "time"
// Host is one jump-target reachable via port forwarding through the bastion.
type Host struct {
Name string `yaml:"name"` // short identifier, e.g. "db-prod-1"
Address string `yaml:"address"` // hostname or IP as seen from the jump host
Port int `yaml:"port"` // TCP port allowed for forwarding
Description string `yaml:"description,omitempty"`
}
// Group bundles hosts that share the same access policy (e.g. "team-billing").
// Users are assigned to one or more groups; their effective allowed hosts are
// the union of all hosts in their groups.
type Group struct {
Name string `yaml:"name"`
Hosts []Host `yaml:"hosts"`
}
// UserStatus is the lifecycle state of a managed account.
type UserStatus string
const (
StatusActive UserStatus = "active"
StatusLocked UserStatus = "locked"
)
// User is one managed Linux/SSH account on the jump host.
type User struct {
Username string `yaml:"username"`
Groups []string `yaml:"groups"`
Status UserStatus `yaml:"status"`
Fingerprint string `yaml:"fingerprint"` // SHA256 fingerprint of the current authorized public key
PublicKey string `yaml:"public_key,omitempty"` // full "ssh-ed25519 AAAA... comment" line; safe to store/back up (public), lets us rewrite authorized_keys (e.g. after a group's host list changes) without re-issuing the keypair
Comment string `yaml:"comment,omitempty"`
CreatedAt time.Time `yaml:"created_at"`
LockedAt *time.Time `yaml:"locked_at,omitempty"`
}
// State is the full persisted configuration of the tool.
type State struct {
Groups []Group `yaml:"groups"`
Users []User `yaml:"users"`
}
// FindGroup returns a pointer to the group with the given name, or nil.
func (s *State) FindGroup(name string) *Group {
for i := range s.Groups {
if s.Groups[i].Name == name {
return &s.Groups[i]
}
}
return nil
}
// FindUser returns a pointer to the user with the given username, or nil.
func (s *State) FindUser(username string) *User {
for i := range s.Users {
if s.Users[i].Username == username {
return &s.Users[i]
}
}
return nil
}
// AllowedHosts returns the de-duplicated union of hosts across all groups
// the user belongs to, sorted by "name" for stable authorized_keys output.
func (s *State) AllowedHosts(u *User) []Host {
seen := map[string]Host{}
order := []string{}
for _, gname := range u.Groups {
g := s.FindGroup(gname)
if g == nil {
continue
}
for _, h := range g.Hosts {
key := h.Address + ":" + itoa(h.Port)
if _, ok := seen[key]; !ok {
order = append(order, key)
}
seen[key] = h
}
}
out := make([]Host, 0, len(order))
for _, k := range order {
out = append(out, seen[k])
}
return out
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+153
View File
@@ -0,0 +1,153 @@
// Package store persists model.State to disk as YAML under a file lock and
// keeps a git history of every change, so the whole configuration (groups,
// hosts, user metadata) can be restored on a fresh host after a disaster.
//
// Deliberately NOT stored here: private keys. Those are generated, packed
// into an export archive, and wiped from disk in the same operation — only
// public-key fingerprints are kept in state, so a stolen backup cannot leak
// anyone's private key.
package store
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/gofrs/flock"
"gopkg.in/yaml.v3"
"ssh-access-tui/internal/model"
)
const (
stateFileName = "state.yaml"
lockFileName = "state.lock"
)
// Store wraps a state directory (git repo) holding state.yaml.
type Store struct {
Dir string
}
// New returns a Store rooted at dir, creating the directory and git repo
// (via Init) if they do not exist yet.
func New(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create state dir: %w", err)
}
s := &Store{Dir: dir}
if err := s.ensureGitRepo(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) statePath() string { return filepath.Join(s.Dir, stateFileName) }
func (s *Store) lockPath() string { return filepath.Join(s.Dir, lockFileName) }
func (s *Store) ensureGitRepo() error {
if _, err := os.Stat(filepath.Join(s.Dir, ".git")); err == nil {
return nil
}
cmd := exec.Command("git", "init", "-q", s.Dir)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git init: %w: %s", err, out)
}
// Local commit identity so this works even with no global gitconfig.
_ = runGit(s.Dir, "config", "user.name", "ssh-access-tui")
_ = runGit(s.Dir, "config", "user.email", "ssh-access-tui@localhost")
return nil
}
// Load reads state.yaml, returning an empty State if it does not exist yet.
// Callers should hold a lock (see WithLock) around Load+mutate+Save.
func (s *Store) Load() (*model.State, error) {
data, err := os.ReadFile(s.statePath())
if os.IsNotExist(err) {
return &model.State{}, nil
}
if err != nil {
return nil, fmt.Errorf("read state: %w", err)
}
var st model.State
if err := yaml.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("parse state: %w", err)
}
return &st, nil
}
// Save writes state.yaml and commits the change to the local git repo with
// the given message (should describe the admin action, e.g. "add user bob").
func (s *Store) Save(st *model.State, commitMsg string) error {
data, err := yaml.Marshal(st)
if err != nil {
return fmt.Errorf("marshal state: %w", err)
}
tmp := s.statePath() + ".tmp"
if err := os.WriteFile(tmp, data, 0o640); err != nil {
return fmt.Errorf("write state: %w", err)
}
if err := os.Rename(tmp, s.statePath()); err != nil {
return fmt.Errorf("commit state file: %w", err)
}
if err := runGit(s.Dir, "add", stateFileName); err != nil {
return fmt.Errorf("git add: %w", err)
}
// A no-op commit (nothing changed) is not an error.
if err := runGit(s.Dir, "commit", "-q", "-m", commitMsg); err != nil {
if !isNothingToCommit(err) {
return fmt.Errorf("git commit: %w", err)
}
}
return nil
}
// WithLock takes an exclusive file lock for the duration of fn, protecting
// against a second concurrent admin session corrupting state.yaml. The tool
// is designed for one admin operating at a time; this lock turns an
// accidental second session into a clear "someone else is editing" error
// instead of silent data loss.
func (s *Store) WithLock(fn func() error) error {
fl := flock.New(s.lockPath())
locked, err := fl.TryLock()
if err != nil {
return fmt.Errorf("acquire lock: %w", err)
}
if !locked {
return fmt.Errorf("state is locked by another ssh-access-tui session (lock file: %s)", s.lockPath())
}
defer fl.Unlock()
return fn()
}
func runGit(dir string, args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%v: %s", err, out)
}
return nil
}
func isNothingToCommit(err error) bool {
// git exits non-zero with "nothing to commit" when the working tree is
// unchanged; treat that as success rather than surfacing it as failure.
return err != nil && (contains(err.Error(), "nothing to commit") || contains(err.Error(), "nothing added"))
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (func() bool {
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
})()
}
// Now is a var so tests can override it; production code just wants time.Now.
var Now = time.Now
+112
View File
@@ -0,0 +1,112 @@
package sysadm
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"ssh-access-tui/internal/model"
)
// managedMarker delimits the block we own inside authorized_keys, so the
// file can in principle carry other content without us clobbering it.
const managedMarker = "# ssh-access-tui managed key — do not edit by hand, use the TUI"
// BuildKeyOptions renders the authorized_keys option string that is the
// actual security boundary for a jump/forward-only account:
// - restrict disables pty, X11, agent-forwarding, user-rc, etc.
// - port-forwarding re-enables (only) local/dynamic forwarding
// - permitopen=... whitelists exactly the host:port pairs this key
// may forward to — this is what makes "only these hosts" a protocol-level
// guarantee rather than a client-side convention.
//
// Reverse forwarding (-R) is intentionally never granted here.
func BuildKeyOptions(hosts []model.Host) string {
if len(hosts) == 0 {
// No hosts assigned yet: restrict everything, permit nothing, so the
// key authenticates but cannot open forwarding channels anywhere.
return "restrict"
}
opts := []string{"restrict", "port-forwarding"}
for _, h := range hosts {
opts = append(opts, fmt.Sprintf(`permitopen="%s:%s"`, h.Address, strconv.Itoa(h.Port)))
}
return strings.Join(opts, ",")
}
// WriteAuthorizedKeys writes the single managed key line (with its
// permitopen restrictions) into ~<username>/.ssh/authorized_keys, replacing
// any previous ssh-access-tui-managed line for that user.
func WriteAuthorizedKeys(username, pubKeyLine string, hosts []model.Host) error {
u, err := user.Lookup(username)
if err != nil {
return fmt.Errorf("lookup user %s: %w", username, err)
}
sshDir := filepath.Join(u.HomeDir, ".ssh")
if err := os.MkdirAll(sshDir, 0o700); err != nil {
return fmt.Errorf("create .ssh dir: %w", err)
}
line := fmt.Sprintf("%s %s", BuildKeyOptions(hosts), strings.TrimSpace(pubKeyLine))
content := managedMarker + "\n" + line + "\n"
akPath := filepath.Join(sshDir, "authorized_keys")
if err := os.WriteFile(akPath, []byte(content), 0o600); err != nil {
return fmt.Errorf("write authorized_keys: %w", err)
}
if err := chownRecursive(sshDir, u); err != nil {
return fmt.Errorf("chown .ssh dir: %w", err)
}
return nil
}
// DisableAuthorizedKeys comments out the managed key so the account cannot
// authenticate at all, without deleting the record of what the key was
// (useful when re-enabling a returning employee later).
func DisableAuthorizedKeys(username string) error {
u, err := user.Lookup(username)
if err != nil {
return fmt.Errorf("lookup user %s: %w", username, err)
}
akPath := filepath.Join(u.HomeDir, ".ssh", "authorized_keys")
data, err := os.ReadFile(akPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read authorized_keys: %w", err)
}
lines := strings.Split(string(data), "\n")
for i, l := range lines {
if strings.HasPrefix(l, "restrict") {
lines[i] = "# LOCKED: " + l
}
}
return os.WriteFile(akPath, []byte(strings.Join(lines, "\n")), 0o600)
}
func chownRecursive(dir string, u *user.User) error {
uid, err := strconv.Atoi(u.Uid)
if err != nil {
return err
}
gid, err := strconv.Atoi(u.Gid)
if err != nil {
return err
}
if err := os.Chown(dir, uid, gid); err != nil {
return err
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range entries {
if err := os.Chown(filepath.Join(dir, e.Name()), uid, gid); err != nil {
return err
}
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package sysadm
import (
"fmt"
"os"
"os/exec"
)
// dropInPath is the sshd config snippet this tool owns. Modern OpenSSH
// (Debian/Ubuntu/RHEL defaults) includes /etc/ssh/sshd_config.d/*.conf from
// the main sshd_config automatically; DropInIncluded checks that assumption.
const dropInPath = "/etc/ssh/sshd_config.d/50-ssh-access-tui.conf"
const dropInTemplate = `# Managed by ssh-access-tui. Do not edit by hand — re-run
# "ssh-access-tui install" to regenerate this file.
#
# Baseline (defense-in-depth) restrictions for all jump/forward-only
# accounts. The authoritative per-user host whitelist lives in each
# account's authorized_keys "permitopen=" options, not here — this block
# only backstops that in case a key's options were ever mis-set.
Match Group ` + JumpGroup + `
PermitTTY no
X11Forwarding no
AllowAgentForwarding no
AllowStreamLocalForwarding no
AllowTcpForwarding local
PermitTunnel no
Match all
# VERBOSE logging records which destination host:port each forwarded
# channel was opened to — needed for the audit trail of "who connected
# where", since plain port-forward sessions don't show up in ` + "`who`" + `.
LogLevel VERBOSE
`
// DropInIncluded reports whether the main sshd_config appears to include
// the sshd_config.d drop-in directory. If not, the caller should tell the
// admin to add "Include /etc/ssh/sshd_config.d/*.conf" manually.
func DropInIncluded() bool {
data, err := os.ReadFile("/etc/ssh/sshd_config")
if err != nil {
return false
}
return contains(string(data), "sshd_config.d")
}
// InstallSSHDConfig writes the managed drop-in, validates it with
// `sshd -t`, and reloads the running sshd. It refuses to reload (leaving the
// previous config live) if validation fails.
func InstallSSHDConfig() error {
if err := RequireRoot(); err != nil {
return err
}
if err := os.WriteFile(dropInPath, []byte(dropInTemplate), 0o644); err != nil {
return fmt.Errorf("write %s: %w", dropInPath, err)
}
if out, err := exec.Command("sshd", "-t").CombinedOutput(); err != nil {
return fmt.Errorf("sshd config now invalid, NOT reloaded — fix and rerun: %s", out)
}
if err := reloadSSHD(); err != nil {
return fmt.Errorf("config valid but reload failed: %w", err)
}
return nil
}
func reloadSSHD() error {
// Service name differs across distros; try both, ignore first failure.
if err := exec.Command("systemctl", "reload", "ssh").Run(); err == nil {
return nil
}
return exec.Command("systemctl", "reload", "sshd").Run()
}
func contains(s, substr string) bool {
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
// Package sysadm performs the actual privileged system operations: creating
// and locking Linux accounts, writing authorized_keys with the restrictions
// that make an account jump/forward-only, and installing the sshd Match
// block that provides defense-in-depth on top of those per-key options.
//
// Everything here shells out to standard coreutils/openssh tools rather than
// re-implementing user management, so behavior matches what any admin
// inspecting the box by hand would expect.
package sysadm
import (
"fmt"
"os"
"os/exec"
"os/user"
)
// JumpGroup is the Linux group every managed account is placed in. The sshd
// Match block in sshdconfig.go applies baseline restrictions to this group
// as defense-in-depth alongside the per-key authorized_keys options.
const JumpGroup = "ssh-jump-users"
// NologinShell is assigned to every managed account. Combined with
// authorized_keys "restrict,port-forwarding", this is what actually blocks
// interactive shell access: any session/exec channel request runs nologin,
// while direct-tcpip (jump/forward) channels are handled separately by sshd
// and are governed by the permitopen list, not by the login shell.
var NologinShell = firstExisting([]string{"/usr/sbin/nologin", "/sbin/nologin", "/bin/false"})
func firstExisting(paths []string) string {
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p
}
}
return "/bin/false"
}
// RequireRoot fails fast with a clear message instead of letting individual
// useradd/usermod calls fail one by one with confusing permission errors.
func RequireRoot() error {
if os.Geteuid() != 0 {
return fmt.Errorf("this operation requires root; run ssh-access-tui with sudo")
}
return nil
}
// EnsureJumpGroup creates the shared group for managed accounts if missing.
func EnsureJumpGroup() error {
if _, err := user.LookupGroup(JumpGroup); err == nil {
return nil
}
return runCmd("groupadd", JumpGroup)
}
// UserExists reports whether a Linux account with this username exists.
func UserExists(username string) bool {
_, err := user.Lookup(username)
return err == nil
}
// CreateSystemUser provisions a new Linux account for username: no password
// login, no interactive shell, home directory for the .ssh dir, and
// membership in JumpGroup for the sshd-side baseline restrictions.
func CreateSystemUser(username string) error {
if err := RequireRoot(); err != nil {
return err
}
if UserExists(username) {
return fmt.Errorf("system user %q already exists", username)
}
if err := EnsureJumpGroup(); err != nil {
return fmt.Errorf("ensure jump group: %w", err)
}
if err := runCmd("useradd",
"--create-home",
"--shell", NologinShell,
"--comment", "managed by ssh-access-tui",
"--groups", JumpGroup,
username,
); err != nil {
return fmt.Errorf("useradd %s: %w", username, err)
}
// No password login is possible at all (key-only account).
if err := runCmd("passwd", "-l", username); err != nil {
return fmt.Errorf("lock password for %s: %w", username, err)
}
return nil
}
// LockSystemUser disables an account belonging to a departed user. It layers
// three independent mechanisms so the lock does not depend on any single one:
// password lock, immediate account expiry, and (by the caller, separately)
// removal of the key from authorized_keys.
func LockSystemUser(username string) error {
if err := RequireRoot(); err != nil {
return err
}
if err := runCmd("usermod", "-L", username); err != nil {
return fmt.Errorf("lock account %s: %w", username, err)
}
if err := runCmd("chage", "-E", "1", username); err != nil {
return fmt.Errorf("expire account %s: %w", username, err)
}
return nil
}
// UnlockSystemUser reverses LockSystemUser.
func UnlockSystemUser(username string) error {
if err := RequireRoot(); err != nil {
return err
}
if err := runCmd("usermod", "-U", username); err != nil {
return fmt.Errorf("unlock account %s: %w", username, err)
}
if err := runCmd("chage", "-E", "-1", username); err != nil {
return fmt.Errorf("un-expire account %s: %w", username, err)
}
return nil
}
// DeleteSystemUser fully removes the Linux account and its home directory.
// Use for permanent offboarding cleanup, distinct from LockSystemUser which
// is the reversible day-to-day "employee left" action.
func DeleteSystemUser(username string) error {
if err := RequireRoot(); err != nil {
return err
}
return runCmd("userdel", "-r", username)
}
func runCmd(name string, args ...string) error {
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s %v: %w: %s", name, args, err, out)
}
return nil
}
+220
View File
@@ -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)
}
+72
View File
@@ -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)
}
+169
View File
@@ -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)
}
+251
View File
@@ -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)
}
+12
View File
@@ -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 }
+59
View File
@@ -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())
}
+262
View File
@@ -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)
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Installs ssh-access-tui on a Linux jump host from a prebuilt binary
# (no Go toolchain on the production host, deliberately — build once in
# CI/dev, ship the binary): checks its two external dependencies
# (ssh-keygen, 7z), installs the journal-tailer and backup systemd units,
# and runs the tool's own "install" step (sshd Match block + directories).
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Run as root (sudo $0)." >&2
exit 1
fi
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN_PATH="/usr/local/bin/ssh-access-tui"
BACKUP_SCRIPT_PATH="/usr/local/bin/ssh-access-tui-backup.sh"
if ! command -v ssh-keygen >/dev/null 2>&1; then
echo "ssh-keygen not found — install openssh-client." >&2
exit 1
fi
if ! command -v 7z >/dev/null 2>&1; then
echo "7z not found — install p7zip-full (apt) or p7zip (rpm)." >&2
exit 1
fi
if ! command -v git >/dev/null 2>&1; then
echo "git not found — required for state versioning/backup." >&2
exit 1
fi
if [ ! -x "$SRC_DIR/ssh-access-tui" ]; then
echo "Prebuilt binary not found at $SRC_DIR/ssh-access-tui." >&2
echo "Build it elsewhere (dev machine/CI): CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ssh-access-tui ./cmd/ssh-access-tui" >&2
echo "then copy it next to this script and re-run. This script never builds on the production host." >&2
exit 1
fi
install -m 0750 "$SRC_DIR/ssh-access-tui" "$BIN_PATH"
install -m 0750 "$SRC_DIR/scripts/ssh-access-tui-backup.sh" "$BACKUP_SCRIPT_PATH"
install -m 0644 "$SRC_DIR/scripts/ssh-access-tui-audit.service" /etc/systemd/system/ssh-access-tui-audit.service
install -m 0644 "$SRC_DIR/scripts/ssh-access-tui-backup.service" /etc/systemd/system/ssh-access-tui-backup.service
install -m 0644 "$SRC_DIR/scripts/ssh-access-tui-backup.timer" /etc/systemd/system/ssh-access-tui-backup.timer
systemctl daemon-reload
"$BIN_PATH" install
systemctl enable --now ssh-access-tui-audit.service
systemctl enable --now ssh-access-tui-backup.timer
echo
echo "Установка завершена."
echo "Запуск TUI: sudo ssh-access-tui"
echo "Настройте git-бэкап: git -C /var/lib/ssh-access-tui/state remote add origin <url>"
echo "Инструкция для админа: $SRC_DIR/docs/ADMIN_GUIDE.md"
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=ssh-access-tui audit journal tailer (connect/disconnect log)
After=network.target sshd.service ssh.service
Wants=ssh.service
[Service]
Type=simple
ExecStart=/usr/local/bin/ssh-access-tui audit-tail
Restart=always
RestartSec=5
User=root
# Read-only access to system state is enough; this process only appends to
# the audit log and reads the systemd journal.
ProtectSystem=strict
ReadWritePaths=/var/lib/ssh-access-tui/audit
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
+7
View File
@@ -0,0 +1,7 @@
[Unit]
Description=ssh-access-tui state backup (git push)
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ssh-access-tui-backup.sh
User=root
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Pushes the git-backed state repo (groups, users, host permissions, audit
# metadata) to a remote so a dead/lost jump host does not mean re-creating
# every user and permission by hand. Deliberately does NOT touch the export
# archive directory: those .7z files contain (encrypted) private keys and
# should be deleted from the jump host once delivered, not retained/backed up.
set -euo pipefail
STATE_DIR="${1:-/var/lib/ssh-access-tui/state}"
if [ ! -d "$STATE_DIR/.git" ]; then
echo "ssh-access-tui-backup: $STATE_DIR is not a git repo yet — run the tool at least once first." >&2
exit 1
fi
cd "$STATE_DIR"
if ! git remote get-url origin >/dev/null 2>&1; then
cat >&2 <<EOF
No git remote 'origin' is configured for $STATE_DIR.
This is local-only right now: a disk failure on this host would lose it.
Configure a remote (a private repo, or a git server on separate storage) with:
git -C $STATE_DIR remote add origin <url>
EOF
exit 1
fi
git push origin HEAD:refs/heads/main
echo "ssh-access-tui-backup: pushed $STATE_DIR to origin at $(date -Is)"
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Run ssh-access-tui state backup hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
Executable
BIN
View File
Binary file not shown.