Files
ssh-access-tui/docs/ARCHITECTURE.md
T

18 KiB
Raw Blame History

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

Почему 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.Hosts []string), которые в неё входят (все сервисы такого хоста включаются целиком). Если одни и те же хосты/сервисы нужны нескольким группам — они описываются один раз в каталоге, а группы просто отмечают их галочками, вместо повторного набора адресов и портов.

Пользователь может состоять в нескольких группах; его реальный эффективный доступ — объединение сервисов всех хостов всех его групп, это и называется Forward (internal/model/types.go, State.AllowedForwards). Переименование хоста автоматически обновляет ссылки на него во всех группах (State.RenameHost); удаление хоста снимает его со всех групп, которые его использовали (State.DeleteHost) — ссылочная целостность каталога держится в модели, а не проверяется вручную в UI.

Ключевой момент: пользователь никогда не подключается по SSH к целевому хосту напрямую (ни через ProxyJump, ни как-то ещё) — он открывает одно соединение к самому jump-хосту и локально пробрасывает (LocalForward) порты к каждому нужному сервису через это соединение. Не важно, слушает ли на целевом порту настоящий 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, который уезжает пользователю в архиве, — это один блок Host jump с набором строк LocalForward <локальный_порт> <host>:<port> (internal/keymgr/clientconfig.go). Он ничего не запрещает: пользователь может отредактировать его или вызвать ssh руками с любым хостом. Единственная причина, по которой это не работает, — сервер отклонит permitopen, которого нет в списке. Список форвардов в клиентском конфиге должен генерироваться из того же источника правды (группы в state.yaml), что и permitopen=, — иначе конфиг и реальные права разойдутся.

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 внутри архива пользователя.

Жизненный цикл приватного ключа

Приватный ключ никогда не хранится на диске дольше одной операции экспорта: генерируется во временный каталог (scratch_dir), упаковывается в зашифрованный .7z, после чего каталог затирается (shred -u) и удаляется — см. internal/keymgr/pack.go. В state.yaml (и, соответственно, в git-бэкапе) хранится только публичная часть (PublicKey) и fingerprint. Это осознанное решение: кража бэкапа состояния не даёт приватных ключей ни одного пользователя. Восстановление после потери хоста — это перевыпуск новых ключей всем (дёшево), а не восстановление старых приватных ключей (было бы дорого и опасно как хранимый секрет).

Пароль архива — один на партию экспорта (не постоянный, не общий "навсегда"), генерируется криптографически случайно при каждом экспорте и показывается администратору один раз. Компрометация этого пароля актуальна только для ключей, выпущенных в конкретной партии, — передавайте его получателям отдельным от архива каналом (голосом, в личку, не в общий чат).

Подтверждение деструктивных действий

Блокировка/удаление пользователя, удаление хоста/сервиса/группы и перевыпуск уже выданного ключа (экспорт поверх существующего) — все эти действия либо необратимы, либо мгновенно и широко режут доступ, поэтому подтверждаются не клавишей (enter/y, которую легко нажать рефлекторно), а вводом заново сгенерированной случайной строки (internal/ui/confirm.go, generateConfirmToken — 5 латинских букв, дефис, 3 цифры). Неверный ввод просто сбрасывает поле с пояснением и оставляет на этом же экране; выйти без последствий можно только через esc. Выдача нового ключа пользователю, у которого его ещё не было, ничего не ломает и подтверждения не требует — трение нужно только там, где есть что сломать.

Бэкап и аварийное восстановление

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