# Архитектура и модель угроз ## Почему TUI, а не веб Веб-панель для управления пользователями требует бэкенда с root-правами (создание Linux-аккаунтов, правка `authorized_keys`, `sshd_config`), слушающего порт. Это новый привилегированный сетевой процесс на хосте, который и так является входной точкой для множества людей: своя аутентификация/сессии/CSRF, TLS-сертификаты, обновления фреймворка — то есть дополнительная поверхность атаки, не связанная с самой задачей. TUI работает поверх уже существующего SSH+`sudo`-доступа администратора к этому хосту — доступа, который и так есть и уже находится под контролем (ключи, MFA, аудит доступа к самому jump-хосту). Новых портов, новых механизмов аутентификации и нового ПО, которое надо патчить, не появляется. Цена этого решения: нет привычного браузерного UX, и параллельная работа нескольких админов не даётся бесплатно фреймворком — её пришлось реализовать вручную (см. «Конкурентность» ниже). Для небольшой команды администраторов это разумный компромисс. ## Модель доступа: каталог хостов → группы → Forward Хосты и их сервисы — **общий каталог** (`State.Hosts`), определённый один раз, а не заново в каждой группе. Хост (`Host`) — это адрес (например, `172.16.1.20`) с описанием; на одном хосте может быть несколько сервисов (`Service`) — независимых TCP-портов: `ssh` на 22, какое-то приложение на 7888, и так далее. Группа (`Group`) не хранит хосты — она хранит список **ссылок на конкретные сервисы** каталога (`Group.Services []ServiceRef`, каждая — пара `{Host, Service}`). Доступ раздаётся по сервису, а не по хосту целиком: группа может включать `db1/ssh`, не давая заодно `db1/web-admin` того же хоста. UI показывает выбор как дерево "хост → сервисы" (`internal/ui/groups.go`), где отметка на строке хоста — это просто ярлык "выбрать все его сервисы", а не отдельный уровень доступа в модели. Если одни и те же хосты/сервисы нужны нескольким группам — они описываются один раз в каталоге, а группы просто отмечают нужные сервисы, вместо повторного набора адресов и портов. Пользователь может состоять в нескольких группах; его реальный эффективный доступ — объединение сервисов, на которые ссылаются все его группы, это и называется `Forward` (`internal/model/types.go`, `State.AllowedForwards`). Ссылочная целостность каталога держится в модели, а не проверяется вручную в UI: переименование хоста или сервиса обновляет `ServiceRef` во всех группах (`State.RenameHost`, `State.RenameServiceRefs`); удаление хоста или одного его сервиса снимает соответствующие `ServiceRef` со всех групп (`State.DeleteHost`, `State.RemoveServiceRefs`) — переименование/удаление одного сервиса никогда не задевает остальные сервисы того же хоста. Ключевой момент: и в `LocalForward`, и в `ProxyJump`-режиме клиентского конфига (`model.User.ProxyJump`, см. ниже) канал до целевого хоста — это всегда один и тот же серверный примитив, `direct-tcpip` через jump-хост: пользователь никогда не получает интерактивный шелл на целевом хосте через jump, только форвардинг порта до него. В `LocalForward`-режиме он открывает одно соединение к самому jump-хосту и локально пробрасывает порты к каждому нужному сервису через это соединение; в `ProxyJump`-режиме клиентский `ssh` сам открывает по одному `direct-tcpip`-каналу через то же соединение к jump-хосту на время каждой команды (`ssh `), без постоянно висящего туннеля и без локальных портов — разница только в том, как клиент организует один и тот же тип канала, а не в том, что jump-хост разрешает. Не важно, слушает ли на целевом порту настоящий SSH (22) или произвольный TCP-сервис (7888) — для форвардинга это не имеет значения, оба варианта — это просто "порт → host:port через jump". ## Как на самом деле работает ограничение "только прыжок/форвардинг" Это два независимых слоя, и путать их нельзя: 1. **Сервер (реальная граница безопасности).** Каждому ключу в `authorized_keys` пользователя приписаны опции: ``` restrict,port-forwarding,permitopen="172.16.1.20:22",permitopen="172.16.1.20:7888",permitopen="172.16.1.21:22" ssh-ed25519 AAAA... user@jump ``` Один `permitopen=` на каждый `Forward` пользователя (`internal/sysadm/authorizedkeys.go`, `BuildKeyOptions`) — не важно, сколько сервисов на одном хосте или сколько разных хостов используют один и тот же номер порта, каждая пара `host:port` разрешается независимо. `restrict` разом отключает pty, X11-forwarding, agent-forwarding, user-rc и port-forwarding; `port-forwarding` точечно возвращает именно форвардинг (не интерактивный доступ). Это гарантия на уровне протокола 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`, который уезжает пользователю в архиве, зависит от `model.User.ProxyJump` (переключается чекбоксом на экране групп пользователя, `internal/ui/users.go`) и генерируется одним из двух рендереров (`internal/keymgr/clientconfig.go`), но оба **одинаково не являются границей безопасности**: - `false` (по умолчанию, `LocalForward`) — один блок `Host jump` с набором строк `LocalForward <локальный_порт> :`, одно всегда открытое соединение сразу даёт локальные порты на все сервисы (`RenderClientConfig`). - `true` (`ProxyJump`) — отдельный блок `Host ` на каждый разрешённый сервис с `ProxyJump jump` вместо `HostName jump`, без локальных портов и без постоянно открытого соединения — каждый вызов `ssh ` сам поднимает и закрывает канал по требованию (`RenderClientConfigProxyJump`). Ни один из двух вариантов ничего не запрещает: пользователь может отредактировать любой из них или вызвать `ssh` руками с любым хостом. Единственная причина, по которой это не работает, — сервер отклонит `permitopen`, которого нет в списке, и это ограничение **идентично для обоих режимов**: `BuildUserArchive`/`BuildConfigOnlyArchive` в обоих случаях берут один и тот же `[]model.Forward` (`State.AllowedForwards`) и не трогают `internal/sysadm/authorizedkeys.go` — переключение `ProxyJump` меняет только то, как выглядит клиентский конфиг, а не какие `permitopen=` попадают в `authorized_keys`. Список форвардов в клиентском конфиге (в любом из двух форматов) должен генерироваться из того же источника правды (группы в `state.yaml`), что и `permitopen=`, — иначе конфиг и реальные права разойдутся. Переключение режима у уже существующего пользователя не применяется автоматически: `ForwardsHash`/`IsConfigStale` отслеживают только изменения состава форвардов, а не смену стиля конфига (само переключение `ProxyJump` без изменения групп не помечает конфиг как устаревший) — администратор должен вручную прогнать «Обновить конфиги» после переключения, если хочет, чтобы у пользователя оказался конфиг нового формата. Reverse/remote forwarding (`-R`) в этой модели не выдаётся вообще (`AllowStreamLocalForwarding no`, никаких `permitlisten=`) — по вашему же решению при проектировании: этот джамп-хост используется только для входа внутрь сети, не для проброса портов наружу из неё. ## Коллизии локальных портов Пользователю нужно пробросить сразу много сервисов за одно соединение (например, 10 хостов, на каждом порт 22 и порт 7888 — 20 форвардов сразу). Локальный порт **никогда не равен удалённому порту** — не только чтобы избежать коллизий между несколькими хостами с одинаковым портом (обычная ситуация: 22 или 7888 у всех десяти), но и потому, что многие удалённые порты привилегированные (`< 1024`, в первую очередь сам ssh на 22) — слушателя на таком порту локально можно открыть только с правами администратора, которых у обычного пользователя на его Windows-машине нет. `internal/keymgr/portalloc.go` (`AllocateLocalPorts`) поэтому просто выдаёт локальные порты последовательно из диапазона `20000+`, без попытки переиспользовать номер удалённого порта. Присвоение детерминировано (зависит только от порядка и состава форвардов пользователя), поэтому повторный экспорт архива с тем же набором хостов даёт те же локальные порты. Итоговое соответствие "локальный порт → сервис" попадает в `INSTRUCTIONS.txt` внутри архива пользователя. ## Отслеживание устаревших конфигов Права на сервере (`permitopen=` в `authorized_keys`) обновляются немедленно при любом изменении групп/каталога — но клиентский `config` пользователя лежит у него на компьютере и сам не меняется. Чтобы админ не забывал, кому нужно прислать новый `config`, каждому пользователю в состоянии хранится `ForwardsHash` (`internal/model/hash.go`) — детерминированный (порядок форвардов не важен, список сортируется перед хешированием) хэш набора `Forward`, который был у него на момент последнего экспорта или "обновить конфиг". `State.IsConfigStale` просто сравнивает этот сохранённый хэш с хэшем текущего `AllowedForwards(u)`: если они разошлись — где-то в группах/каталоге, от которых зависит доступ этого пользователя, что-то поменялось, а он ещё не получил обновлённый `config`. Это используется в двух местах: в главном меню (живой счётчик под списком пунктов, пересчитывается на каждый кадр — `internal/ui/menu.go`, `viewMenu`) и на экране подтверждения выхода (`attemptQuit`/ `viewQuitConfirm`), отдельно от пользователей без ключа вообще (`State.NeverExportedUsernames`). "Обновить конфиги" (`internal/ui/configupdate.go`) — пакетный аналог экспорта, но без переиздания ключа: строит только `config`+`INSTRUCTIONS.txt` для каждого выбранного пользователя (`keymgr.BuildConfigOnlyArchive`) и обновляет его `ForwardsHash`, ничего не трогая в `authorized_keys` и не требуя подтверждения строкой — это не деструктивное действие. ## Жизненный цикл приватного ключа Приватный ключ **никогда не хранится на диске дольше одной операции экспорта**: генерируется во временный каталог (`scratch_dir`), упаковывается в зашифрованный `.7z`, после чего каталог затирается (`shred -u`) и удаляется — см. `internal/keymgr/pack.go`. В `state.yaml` (и, соответственно, в git-бэкапе) хранится только **публичная** часть (`PublicKey`) и fingerprint. Это осознанное решение: кража бэкапа состояния не даёт приватных ключей ни одного пользователя. Восстановление после потери хоста — это перевыпуск новых ключей всем (дёшево), а не восстановление старых приватных ключей (было бы дорого и опасно как хранимый секрет). Пароль архива — один на партию экспорта (не постоянный, не общий "навсегда"), генерируется криптографически случайно при каждом экспорте и показывается администратору один раз. Компрометация этого пароля актуальна только для ключей, выпущенных в конкретной партии, — передавайте его получателям отдельным от архива каналом (голосом, в личку, не в общий чат). ## Подтверждение деструктивных действий Блокировка/удаление пользователя, удаление хоста/сервиса/группы и перевыпуск уже выданного ключа (экспорт поверх существующего) — все эти действия либо необратимы, либо мгновенно и широко режут доступ, поэтому подтверждаются не клавишей (`enter`/`y`, которую легко нажать рефлекторно), а вводом заново сгенерированной случайной строки (`internal/ui/confirm.go`, `generateConfirmToken` — 5 латинских букв, дефис, 3 цифры). Неверный ввод просто сбрасывает поле с пояснением и оставляет на этом же экране; выйти без последствий можно только через `esc`. Выдача **нового** ключа пользователю, у которого его ещё не было, ничего не ломает и подтверждения не требует — трение нужно только там, где есть что сломать. **Готча при добавлении новых экранов-списков:** `bubbles/list` по умолчанию сам перехватывает `q` и мгновенно возвращает `tea.Quit`, в обход всей этой логики — на любом экране, где необработанная клавиша проваливается в `list.Model.Update`. `internal/ui/app.go` (`newList`) вызывает `l.DisableQuitKeybindings()` на каждом создаваемом списке ровно из-за этого — при добавлении нового списка мимо `newList` эта защита пропадает незаметно (сам список продолжит работать, просто `q` будет закрывать программу без единого предупреждения). Обнаружено и подтверждено тестом именно в такой форме: экран без нужного `case "q"` в своём `Update`. ## Бэкап и аварийное восстановление Каталог состояния (`/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; в текущем виде это не реализовано осознанно (см. вопрос при проектировании). ## Известные ограничения / что дальше - Обновление клиентского конфига пользователя при появлении нового сервиса/хоста в его группе — отдельный ручной шаг («обновить конфиг», не требует переиздания ключа); права доступа на сервере (`permitopen=`) обновляются немедленно и автоматически в любом случае, независимо от того, обновил ли пользователь свой локальный `config`. - Архивы в `export_dir` не удаляются автоматически после доставки — администратор должен удалить их вручную, когда получатель подтвердил получение. - Полностью параллельная работа нескольких админов не поддерживается (см. «Конкурентность»). - Переключение `ProxyJump`/`LocalForward` у уже существующего пользователя не переиздаёт и не помечает его конфиг устаревшим автоматически (см. выше, «Клиент (только удобство)») — после переключения нужно вручную прогнать «Обновить конфиги» для этого пользователя, иначе у него на диске останется конфиг старого формата (что ничего не ломает: доступ на сервере не зависит от этого режима, только удобство пользователя).