Files
2026-07-23 12:38:13 +03:00

292 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура и модель угроз
## Почему 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 <alias>`), без
постоянно висящего туннеля и без локальных портов — разница только в том,
как клиент организует один и тот же тип канала, а не в том, что 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 <локальный_порт> <host>:<port>`, одно
всегда открытое соединение сразу даёт локальные порты на все сервисы
(`RenderClientConfig`).
- `true` (`ProxyJump`) — отдельный блок `Host <alias>` на каждый
разрешённый сервис с `ProxyJump jump` вместо `HostName jump`, без
локальных портов и без постоянно открытого соединения — каждый вызов
`ssh <alias>` сам поднимает и закрывает канал по требованию
(`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` у уже существующего пользователя
не переиздаёт и не помечает его конфиг устаревшим автоматически (см.
выше, «Клиент (только удобство)») — после переключения нужно вручную
прогнать «Обновить конфиги» для этого пользователя, иначе у него на
диске останется конфиг старого формата (что ничего не ломает: доступ на
сервере не зависит от этого режима, только удобство пользователя).