commit 23c7133fa98dc91eae7c00dcb1fbce4c6ac4f255 Author: redlineo Date: Wed Jul 22 14:14:53 2026 +0300 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..97b5ba3 --- /dev/null +++ b/README.md @@ -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 +``` + +## Запуск + +```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/ руководство администратора, архитектура/модель угроз +``` diff --git a/cmd/ssh-access-tui/main.go b/cmd/ssh-access-tui/main.go new file mode 100644 index 0000000..ad0f6ed --- /dev/null +++ b/cmd/ssh-access-tui/main.go @@ -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) +} diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md new file mode 100644 index 0000000..335eec4 --- /dev/null +++ b/docs/ADMIN_GUIDE.md @@ -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). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..bfe8b1d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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` не удаляются автоматически после доставки — + администратор должен удалить их вручную, когда получатель подтвердил + получение. +- Полностью параллельная работа нескольких админов не поддерживается + (см. «Конкурентность»). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9525e8e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0c483b2 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..db810ed --- /dev/null +++ b/internal/audit/audit.go @@ -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() +} diff --git a/internal/audit/sessions.go b/internal/audit/sessions.go new file mode 100644 index 0000000..08f588f --- /dev/null +++ b/internal/audit/sessions.go @@ -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: [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 +} diff --git a/internal/audit/tail.go b/internal/audit/tail.go new file mode 100644 index 0000000..16f8c2a --- /dev/null +++ b/internal/audit/tail.go @@ -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() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a67e36d --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/keymgr/clientconfig.go b/internal/keymgr/clientconfig.go new file mode 100644 index 0000000..4cb1097 --- /dev/null +++ b/internal/keymgr/clientconfig.go @@ -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() +} diff --git a/internal/keymgr/instructions.go b/internal/keymgr/instructions.go new file mode 100644 index 0000000..3b40c99 --- /dev/null +++ b/internal/keymgr/instructions.go @@ -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\\\\.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\\\\.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 :localhost: \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() +} diff --git a/internal/keymgr/keygen.go b/internal/keymgr/keygen.go new file mode 100644 index 0000000..eddec4c --- /dev/null +++ b/internal/keymgr/keygen.go @@ -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 +} diff --git a/internal/keymgr/pack.go b/internal/keymgr/pack.go new file mode 100644 index 0000000..42fb9e5 --- /dev/null +++ b/internal/keymgr/pack.go @@ -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) +} diff --git a/internal/keymgr/password.go b/internal/keymgr/password.go new file mode 100644 index 0000000..59a9925 --- /dev/null +++ b/internal/keymgr/password.go @@ -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 +} diff --git a/internal/model/types.go b/internal/model/types.go new file mode 100644 index 0000000..8c0f476 --- /dev/null +++ b/internal/model/types.go @@ -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:]) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..12e2778 --- /dev/null +++ b/internal/store/store.go @@ -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 diff --git a/internal/sysadm/authorizedkeys.go b/internal/sysadm/authorizedkeys.go new file mode 100644 index 0000000..d44cfbd --- /dev/null +++ b/internal/sysadm/authorizedkeys.go @@ -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 ~/.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 +} diff --git a/internal/sysadm/sshdconfig.go b/internal/sysadm/sshdconfig.go new file mode 100644 index 0000000..f7cbb0f --- /dev/null +++ b/internal/sysadm/sshdconfig.go @@ -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 +} diff --git a/internal/sysadm/users.go b/internal/sysadm/users.go new file mode 100644 index 0000000..9f14e39 --- /dev/null +++ b/internal/sysadm/users.go @@ -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 +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..ca21eab --- /dev/null +++ b/internal/ui/app.go @@ -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) +} diff --git a/internal/ui/auditview.go b/internal/ui/auditview.go new file mode 100644 index 0000000..326ad6e --- /dev/null +++ b/internal/ui/auditview.go @@ -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) +} diff --git a/internal/ui/export.go b/internal/ui/export.go new file mode 100644 index 0000000..e12e2e3 --- /dev/null +++ b/internal/ui/export.go @@ -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) +} diff --git a/internal/ui/groups.go b/internal/ui/groups.go new file mode 100644 index 0000000..d51d1f0 --- /dev/null +++ b/internal/ui/groups.go @@ -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) +} diff --git a/internal/ui/item.go b/internal/ui/item.go new file mode 100644 index 0000000..77288c0 --- /dev/null +++ b/internal/ui/item.go @@ -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 } diff --git a/internal/ui/menu.go b/internal/ui/menu.go new file mode 100644 index 0000000..0020b99 --- /dev/null +++ b/internal/ui/menu.go @@ -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()) +} diff --git a/internal/ui/users.go b/internal/ui/users.go new file mode 100644 index 0000000..9f2b25a --- /dev/null +++ b/internal/ui/users.go @@ -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) +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..969f4d0 --- /dev/null +++ b/scripts/install.sh @@ -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 " +echo "Инструкция для админа: $SRC_DIR/docs/ADMIN_GUIDE.md" diff --git a/scripts/ssh-access-tui-audit.service b/scripts/ssh-access-tui-audit.service new file mode 100644 index 0000000..b92d654 --- /dev/null +++ b/scripts/ssh-access-tui-audit.service @@ -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 diff --git a/scripts/ssh-access-tui-backup.service b/scripts/ssh-access-tui-backup.service new file mode 100644 index 0000000..0e5dfdc --- /dev/null +++ b/scripts/ssh-access-tui-backup.service @@ -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 diff --git a/scripts/ssh-access-tui-backup.sh b/scripts/ssh-access-tui-backup.sh new file mode 100755 index 0000000..ed72fe3 --- /dev/null +++ b/scripts/ssh-access-tui-backup.sh @@ -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 + exit 1 +fi + +git push origin HEAD:refs/heads/main +echo "ssh-access-tui-backup: pushed $STATE_DIR to origin at $(date -Is)" diff --git a/scripts/ssh-access-tui-backup.timer b/scripts/ssh-access-tui-backup.timer new file mode 100644 index 0000000..ff89ef7 --- /dev/null +++ b/scripts/ssh-access-tui-backup.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Run ssh-access-tui state backup hourly + +[Timer] +OnCalendar=hourly +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ssh-access-tui b/ssh-access-tui new file mode 100755 index 0000000..683a733 Binary files /dev/null and b/ssh-access-tui differ