Files
ssh-access-tui/docs/ARCHITECTURE.md
T
2026-07-22 14:14:53 +03:00

12 KiB
Raw Blame History

Архитектура и модель угроз

Почему 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 не удаляются автоматически после доставки — администратор должен удалить их вручную, когда получатель подтвердил получение.
  • Полностью параллельная работа нескольких админов не поддерживается (см. «Конкурентность»).