This commit is contained in:
redlineo committed 2026-07-22 17:13:35 +03:00
1 parent e39ad275b5
commit 3d7e3a88f2
14 files changed
+710 -234

No files matched your search

+53 -12
View File
@@ -25,20 +25,26 @@ TUI работает поверх уже существующего SSH+`sudo`-
раз, а не заново в каждой группе. Хост (`Host`) — это адрес (например,
`172.16.1.20`) с описанием; на одном хосте может быть несколько сервисов
(`Service`) — независимых TCP-портов: `ssh` на 22, какое-то приложение на
7888, и так далее. Группа (`Group`) не хранит хосты сама — она хранит
только список **имён** хостов из каталога (`Group.Hosts []string`),
которые в неё входят (все сервисы такого хоста включаются целиком). Если
одни и те же хосты/сервисы нужны нескольким группам — они описываются один
раз в каталоге, а группы просто отмечают их галочками, вместо повторного
набора адресов и портов.
7888, и так далее. Группа (`Group`) не хранит хосты — она хранит список
**ссылок на конкретные сервисы** каталога (`Group.Services []ServiceRef`,
каждая — пара `{Host, Service}`). Доступ раздаётся по сервису, а не по
хосту целиком: группа может включать `db1/ssh`, не давая заодно
`db1/web-admin` того же хоста. UI показывает выбор как дерево "хост →
сервисы" (`internal/ui/groups.go`), где отметка на строке хоста — это
просто ярлык "выбрать все его сервисы", а не отдельный уровень доступа в
модели. Если одни и те же хосты/сервисы нужны нескольким группам — они
описываются один раз в каталоге, а группы просто отмечают нужные сервисы,
вместо повторного набора адресов и портов.
Пользователь может состоять в нескольких группах; его реальный эффективный
доступ — объединение сервисов всех хостов всех его групп, это и называется
`Forward` (`internal/model/types.go`, `State.AllowedForwards`). Переименование
хоста автоматически обновляет ссылки на него во всех группах
(`State.RenameHost`); удаление хоста снимает его со всех групп, которые его
использовали (`State.DeleteHost`) — ссылочная целостность каталога держится
в модели, а не проверяется вручную в UI.
доступ — объединение сервисов, на которые ссылаются все его группы, это и
называется `Forward` (`internal/model/types.go`, `State.AllowedForwards`).
Ссылочная целостность каталога держится в модели, а не проверяется вручную
в UI: переименование хоста или сервиса обновляет `ServiceRef` во всех
группах (`State.RenameHost`, `State.RenameServiceRefs`); удаление хоста или
одного его сервиса снимает соответствующие `ServiceRef` со всех групп
(`State.DeleteHost`, `State.RemoveServiceRefs`) — переименование/удаление
одного сервиса никогда не задевает остальные сервисы того же хоста.
Ключевой момент: пользователь **никогда не подключается по SSH к целевому
хосту напрямую** (ни через `ProxyJump`, ни как-то ещё) — он открывает одно
@@ -110,6 +116,31 @@ Reverse/remote forwarding (`-R`) в этой модели не выдаётся
порты. Итоговое соответствие "локальный порт → сервис" попадает в
`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` и не требуя
подтверждения строкой — это не деструктивное действие.
## Жизненный цикл приватного ключа
Приватный ключ **никогда не хранится на диске дольше одной операции
@@ -141,6 +172,16 @@ fingerprint. Это осознанное решение: кража бэкапа
у которого его ещё не было, ничего не ломает и подтверждения не требует —
трение нужно только там, где есть что сломать.
**Готча при добавлении новых экранов-списков:** `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-репозиторий.