292 lines
26 KiB
Markdown
292 lines
26 KiB
Markdown
# Архитектура и модель угроз
|
||
|
||
## Почему 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` у уже существующего пользователя
|
||
не переиздаёт и не помечает его конфиг устаревшим автоматически (см.
|
||
выше, «Клиент (только удобство)») — после переключения нужно вручную
|
||
прогнать «Обновить конфиги» для этого пользователя, иначе у него на
|
||
диске останется конфиг старого формата (что ничего не ломает: доступ на
|
||
сервере не зависит от этого режима, только удобство пользователя).
|