rc
This commit is contained in:
1 parent
e39ad275b5
commit
3d7e3a88f2
14 files changed
+685
-209
No files matched your search
+68
-31
@@ -17,10 +17,22 @@ sudo ssh-access-tui
|
|||||||
|
|
||||||
Навигация везде: стрелки/`j`/`k` — перемещение по списку, `enter` —
|
Навигация везде: стрелки/`j`/`k` — перемещение по списку, `enter` —
|
||||||
выбрать/подтвердить, `esc` — назад, `ctrl+c` — жёсткий выход в любой момент
|
выбрать/подтвердить, `esc` — назад, `ctrl+c` — жёсткий выход в любой момент
|
||||||
(без каких-либо предупреждений — только для аварийных случаев). Обычный
|
(без каких-либо предупреждений — только для аварийных случаев). `q` как
|
||||||
выход через пункт меню «Выход» или `q` в главном меню предупредит, если
|
быстрый выход работает **только в главном меню** (на остальных экранах он
|
||||||
есть созданные, но ни разу не экспортированные пользователи (у них ещё нет
|
ничего не делает — «esc» уводит назад). Обычный выход (пункт «Выход» или
|
||||||
ключа) — можно выйти всё равно (`enter`/`y`) или вернуться (`esc`/`n`).
|
`q` в главном меню) предупредит, если есть:
|
||||||
|
|
||||||
|
- пользователи, для которых ни разу не сгенерирован ключ (не были в
|
||||||
|
«Экспорт архивов»);
|
||||||
|
- пользователи, у которых **изменился доступ, но конфиг у них старый**
|
||||||
|
(кто-то поменял группы/каталог после их последнего экспорта или
|
||||||
|
обновления конфига) — им нужно прислать новый `config` через «Обновить
|
||||||
|
конфиги», иначе их локальный конфиг не соответствует их реальным правам.
|
||||||
|
|
||||||
|
В обоих случаях можно выйти всё равно (`enter`/`y`) или вернуться
|
||||||
|
(`esc`/`n`). Главное меню всегда показывает актуальное количество таких
|
||||||
|
пользователей под списком пунктов, если оно не нулевое — не нужно ждать
|
||||||
|
выхода, чтобы это заметить.
|
||||||
|
|
||||||
**Любое деструктивное действие** (блокировка/удаление пользователя,
|
**Любое деструктивное действие** (блокировка/удаление пользователя,
|
||||||
удаление хоста/сервиса/группы, перевыпуск уже выданного ключа) требует не
|
удаление хоста/сервиса/группы, перевыпуск уже выданного ключа) требует не
|
||||||
@@ -36,8 +48,9 @@ sudo ssh-access-tui
|
|||||||
```
|
```
|
||||||
Пользователи — создать / заблокировать / разблокировать / удалить
|
Пользователи — создать / заблокировать / разблокировать / удалить
|
||||||
Хосты и сервисы — общий каталог адресов и портов (один раз на всех)
|
Хосты и сервисы — общий каталог адресов и портов (один раз на всех)
|
||||||
Группы — какие хосты из каталога входят в каждую группу
|
Группы — какие сервисы из каталога входят в каждую группу
|
||||||
Экспорт архивов — сгенерировать ключи и .7z для доставки
|
Экспорт архивов — сгенерировать ключи и .7z для доставки
|
||||||
|
Обновить конфиги — перевыпустить config (без ключа) тем, у кого изменился доступ
|
||||||
Аудит / кто в сети — активные сессии и журнал подключений
|
Аудит / кто в сети — активные сессии и журнал подключений
|
||||||
Выход
|
Выход
|
||||||
```
|
```
|
||||||
@@ -49,12 +62,13 @@ sudo ssh-access-tui
|
|||||||
## Модель: каталог → группы → пользователи
|
## Модель: каталог → группы → пользователи
|
||||||
|
|
||||||
Хосты и их сервисы (порты) описываются **один раз** в общем каталоге
|
Хосты и их сервисы (порты) описываются **один раз** в общем каталоге
|
||||||
(«Хосты и сервисы»), а не заново для каждой группы. Группа — это просто
|
(«Хосты и сервисы»), а не заново для каждой группы. Доступ раздаётся **по
|
||||||
отметка галочками, какие хосты каталога в неё входят (все сервисы такого
|
конкретным сервисам**, не по хосту целиком: группа выбирает нужные сервисы
|
||||||
хоста включаются целиком). Если у вас в разных группах фигурируют одни и те
|
из дерева «хост → его сервисы» — можно взять только `ssh` этого хоста, не
|
||||||
|
трогая его же `web-admin`. Если у вас в разных группах фигурируют одни и те
|
||||||
же хосты/сервисы — их не нужно набивать повторно, только отметить нужные
|
же хосты/сервисы — их не нужно набивать повторно, только отметить нужные
|
||||||
для очередной группы. Пользователь состоит в одной или нескольких группах;
|
для очередной группы. Пользователь состоит в одной или нескольких группах;
|
||||||
его реальный доступ — объединение сервисов всех хостов всех его групп.
|
его реальный доступ — объединение сервисов, выбранных во всех его группах.
|
||||||
|
|
||||||
## Сценарий 1: новый сотрудник, у которого уже есть роль/группа доступа
|
## Сценарий 1: новый сотрудник, у которого уже есть роль/группа доступа
|
||||||
|
|
||||||
@@ -74,8 +88,12 @@ sudo ssh-access-tui
|
|||||||
автоматически обновятся).
|
автоматически обновятся).
|
||||||
4. **Группы** → `n` → имя группы (например `team-billing`), описание
|
4. **Группы** → `n` → имя группы (например `team-billing`), описание
|
||||||
(необязательно) → `enter`.
|
(необязательно) → `enter`.
|
||||||
5. Выбрать группу → `enter` → отметить `space` нужные хосты из каталога →
|
5. Выбрать группу → `enter` — откроется дерево «хост → его сервисы».
|
||||||
`enter` сохранить. Список сервисов этих хостов достаётся группе целиком.
|
`space` на строке сервиса отмечает именно его; `space` на строке хоста
|
||||||
|
(заголовок) отмечает/снимает сразу все его сервисы — удобно, когда нужен
|
||||||
|
весь хост, но при желании можно оставить только часть его сервисов.
|
||||||
|
`[x]` — выбрано всё, `[~]` — выбрано частично, `[ ]` — ничего. `enter`
|
||||||
|
сохранить.
|
||||||
6. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин)
|
6. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин)
|
||||||
и комментарий (например ФИО) → `enter`.
|
и комментарий (например ФИО) → `enter`.
|
||||||
7. Отметить `space` нужные группы (можно несколько) → `enter`.
|
7. Отметить `space` нужные группы (можно несколько) → `enter`.
|
||||||
@@ -113,15 +131,26 @@ sudo ssh-access-tui
|
|||||||
Если хотите, чтобы у пользователя появился ещё и обновлённый клиентский
|
Если хотите, чтобы у пользователя появился ещё и обновлённый клиентский
|
||||||
`config` с новыми адресами для новых сервисов (иначе он про них просто не
|
`config` с новыми адресами для новых сервисов (иначе он про них просто не
|
||||||
узнает, хотя доступ уже есть) — **Пользователи** → выбрать пользователя →
|
узнает, хотя доступ уже есть) — **Пользователи** → выбрать пользователя →
|
||||||
`c` («обновить конфиг»). Это генерирует маленький архив только с `config` +
|
`c` («обновить конфиг»), либо (то же самое, но сразу для многих)
|
||||||
`INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже есть и остаётся тем
|
**Обновить конфиги** в главном меню. Это генерирует архив только с
|
||||||
же), пользователю нужно только подменить файл `config`. Не требует
|
`config` + `INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже есть и
|
||||||
подтверждения — ключ и доступ этим действием не меняются, это не
|
остаётся тем же), пользователю нужно только подменить файл `config`. Не
|
||||||
|
требует подтверждения — ключ и доступ этим действием не меняются, это не
|
||||||
деструктивно.
|
деструктивно.
|
||||||
|
|
||||||
Полный экспорт (сценарий 1, шаги 8-9) тоже сработает и даст то же самое
|
**Обновить конфиги** в главном меню — то же самое, что `c`, но пакетно:
|
||||||
плюс заново выпустит ключ (с подтверждением строкой, раз это перевыпуск) —
|
список покажет каждого уже выпущенного пользователя с пометкой «конфиг
|
||||||
используйте `c`, когда переиздание ключа не нужно.
|
актуален» или «конфиг устарел — доступ изменился» (см. ниже, как это
|
||||||
|
отслеживается), можно отметить `space` только устаревших или `a` — всех, и
|
||||||
|
получить один пароль на всю партию, как при обычном экспорте.
|
||||||
|
|
||||||
|
Инструмент сам запоминает, для какого набора сервисов последний раз
|
||||||
|
выдавался config каждому пользователю (полным экспортом или через
|
||||||
|
«обновить конфиг»), и сравнивает с тем, что должно быть сейчас — отсюда и
|
||||||
|
берётся статус «устарел». Полный экспорт (сценарий 1, шаги 8-9) тоже
|
||||||
|
обновляет этот статус (и заодно перевыпускает ключ, с подтверждением
|
||||||
|
строкой) — используйте `c`/«Обновить конфиги», когда переиздание ключа не
|
||||||
|
нужно.
|
||||||
|
|
||||||
## Сценарий 2: добавили новый сервис/хост, поменялся адрес или порт
|
## Сценарий 2: добавили новый сервис/хост, поменялся адрес или порт
|
||||||
|
|
||||||
@@ -129,26 +158,32 @@ sudo ssh-access-tui
|
|||||||
`enter` → `n`.
|
`enter` → `n`.
|
||||||
- Новый хост: **Хосты и сервисы** → `n`, затем добавить ему сервисы.
|
- Новый хост: **Хосты и сервисы** → `n`, затем добавить ему сервисы.
|
||||||
- Хост сменил адрес, сервис — порт, нужно поправить описание, или
|
- Хост сменил адрес, сервис — порт, нужно поправить описание, или
|
||||||
переименовать: `e` на нужной записи. Переименование хоста автоматически
|
переименовать: `e` на нужной записи. Переименование хоста или сервиса
|
||||||
обновляет ссылки на него во всех группах, которые его используют — группы
|
автоматически обновляет ссылки на него во всех группах, которые его
|
||||||
не «отваливаются» молча.
|
используют — группы не «отваливаются» молча и не теряют доступ из-за
|
||||||
|
простого переименования.
|
||||||
|
|
||||||
Любое из этих изменений сразу обновляет `permitopen=` в `authorized_keys`
|
Любое из этих изменений сразу обновляет `permitopen=` в `authorized_keys`
|
||||||
**всех активных пользователей всех групп**, ссылающихся на этот хост, —
|
**всех активных пользователей всех групп**, ссылающихся на этот сервис, —
|
||||||
доступ появляется немедленно, без каких-либо действий на стороне
|
доступ появляется немедленно, без каких-либо действий на стороне
|
||||||
пользователя. Клиентский конфиг пользователя (его `~/.ssh/config`) при этом
|
пользователя. Их клиентский конфиг (`~/.ssh/config`) при этом не
|
||||||
не обновляется автоматически — для этого используйте **Пользователи** → `c`
|
обновляется автоматически — их локальный файл начинает отставать от
|
||||||
(«обновить конфиг», см. сценарий 1а).
|
реального доступа (инструмент это заметит и покажет как «конфиг устарел»,
|
||||||
|
см. выше); чтобы прислать актуальный конфиг — **Обновить конфиги** или `c`
|
||||||
|
(см. сценарий 1а).
|
||||||
|
|
||||||
**Группы** → выбрать группу → `enter` — там же можно снять/добавить
|
**Группы** → выбрать группу → `enter` — то же дерево «хост → сервисы», там
|
||||||
привязку группы к хостам каталога (не создавая и не удаляя сами хосты).
|
же можно снять/добавить сервисы группе (не создавая и не удаляя сами
|
||||||
|
хосты/сервисы в каталоге).
|
||||||
|
|
||||||
Удаление хоста из каталога, сервиса на хосте или целой группы (`d` в
|
Удаление хоста из каталога, сервиса на хосте или целой группы (`d` в
|
||||||
соответствующем списке) — все три действия **деструктивны** (мгновенно
|
соответствующем списке) — все три действия **деструктивны** (мгновенно
|
||||||
отбирают доступ у всех затронутых пользователей) и требуют набрать
|
отбирают доступ у всех затронутых пользователей) и требуют набрать
|
||||||
случайную строку подтверждения: экран покажет, что именно удаляется,
|
случайную строку подтверждения: экран покажет, что именно удаляется,
|
||||||
сколько пользователей это коснётся, и в каких группах используется
|
сколько пользователей это коснётся, и в каких группах используется
|
||||||
(для хоста/сервиса).
|
(для хоста/сервиса). Удаление хоста снимает с групп все его сервисы разом;
|
||||||
|
удаление одного сервиса снимает только его, остальные сервисы того же
|
||||||
|
хоста в группах остаются.
|
||||||
|
|
||||||
## Сценарий 3: сотрудник уволился
|
## Сценарий 3: сотрудник уволился
|
||||||
|
|
||||||
@@ -202,8 +237,10 @@ sudo ssh-access-tui
|
|||||||
когда получатель подтвердил, что распаковал архив (это касается и
|
когда получатель подтвердил, что распаковал архив (это касается и
|
||||||
архивов от «обновить конфиг», хоть они и не содержат ключ).
|
архивов от «обновить конфиг», хоть они и не содержат ключ).
|
||||||
- Не обновляет клиентский `~/.ssh/config` пользователя автоматически при
|
- Не обновляет клиентский `~/.ssh/config` пользователя автоматически при
|
||||||
появлении нового сервиса/хоста в его группе — это ручной шаг («обновить
|
появлении нового сервиса/хоста в его группе — это ручной шаг («Обновить
|
||||||
конфиг», сценарий 1а), права доступа на сервере при этом обновляются
|
конфиги», сценарий 1а), хоть инструмент и отслеживает, кому именно он
|
||||||
немедленно и без участия администратора (см. сценарий 2).
|
нужен (главное меню и предупреждение при выходе). Права доступа на
|
||||||
|
сервере при этом обновляются немедленно и без участия администратора
|
||||||
|
(см. сценарий 2).
|
||||||
- Не поддерживает одновременную работу нескольких администраторов —
|
- Не поддерживает одновременную работу нескольких администраторов —
|
||||||
рассчитан на одного за раз (см. «Конкурентность» в ARCHITECTURE.md).
|
рассчитан на одного за раз (см. «Конкурентность» в ARCHITECTURE.md).
|
||||||
+53
-12
@@ -25,20 +25,26 @@ TUI работает поверх уже существующего SSH+`sudo`-
|
|||||||
раз, а не заново в каждой группе. Хост (`Host`) — это адрес (например,
|
раз, а не заново в каждой группе. Хост (`Host`) — это адрес (например,
|
||||||
`172.16.1.20`) с описанием; на одном хосте может быть несколько сервисов
|
`172.16.1.20`) с описанием; на одном хосте может быть несколько сервисов
|
||||||
(`Service`) — независимых TCP-портов: `ssh` на 22, какое-то приложение на
|
(`Service`) — независимых TCP-портов: `ssh` на 22, какое-то приложение на
|
||||||
7888, и так далее. Группа (`Group`) не хранит хосты сама — она хранит
|
7888, и так далее. Группа (`Group`) не хранит хосты — она хранит список
|
||||||
только список **имён** хостов из каталога (`Group.Hosts []string`),
|
**ссылок на конкретные сервисы** каталога (`Group.Services []ServiceRef`,
|
||||||
которые в неё входят (все сервисы такого хоста включаются целиком). Если
|
каждая — пара `{Host, Service}`). Доступ раздаётся по сервису, а не по
|
||||||
одни и те же хосты/сервисы нужны нескольким группам — они описываются один
|
хосту целиком: группа может включать `db1/ssh`, не давая заодно
|
||||||
раз в каталоге, а группы просто отмечают их галочками, вместо повторного
|
`db1/web-admin` того же хоста. UI показывает выбор как дерево "хост →
|
||||||
набора адресов и портов.
|
сервисы" (`internal/ui/groups.go`), где отметка на строке хоста — это
|
||||||
|
просто ярлык "выбрать все его сервисы", а не отдельный уровень доступа в
|
||||||
|
модели. Если одни и те же хосты/сервисы нужны нескольким группам — они
|
||||||
|
описываются один раз в каталоге, а группы просто отмечают нужные сервисы,
|
||||||
|
вместо повторного набора адресов и портов.
|
||||||
|
|
||||||
Пользователь может состоять в нескольких группах; его реальный эффективный
|
Пользователь может состоять в нескольких группах; его реальный эффективный
|
||||||
доступ — объединение сервисов всех хостов всех его групп, это и называется
|
доступ — объединение сервисов, на которые ссылаются все его группы, это и
|
||||||
`Forward` (`internal/model/types.go`, `State.AllowedForwards`). Переименование
|
называется `Forward` (`internal/model/types.go`, `State.AllowedForwards`).
|
||||||
хоста автоматически обновляет ссылки на него во всех группах
|
Ссылочная целостность каталога держится в модели, а не проверяется вручную
|
||||||
(`State.RenameHost`); удаление хоста снимает его со всех групп, которые его
|
в UI: переименование хоста или сервиса обновляет `ServiceRef` во всех
|
||||||
использовали (`State.DeleteHost`) — ссылочная целостность каталога держится
|
группах (`State.RenameHost`, `State.RenameServiceRefs`); удаление хоста или
|
||||||
в модели, а не проверяется вручную в UI.
|
одного его сервиса снимает соответствующие `ServiceRef` со всех групп
|
||||||
|
(`State.DeleteHost`, `State.RemoveServiceRefs`) — переименование/удаление
|
||||||
|
одного сервиса никогда не задевает остальные сервисы того же хоста.
|
||||||
|
|
||||||
Ключевой момент: пользователь **никогда не подключается по SSH к целевому
|
Ключевой момент: пользователь **никогда не подключается по SSH к целевому
|
||||||
хосту напрямую** (ни через `ProxyJump`, ни как-то ещё) — он открывает одно
|
хосту напрямую** (ни через `ProxyJump`, ни как-то ещё) — он открывает одно
|
||||||
@@ -110,6 +116,31 @@ Reverse/remote forwarding (`-R`) в этой модели не выдаётся
|
|||||||
порты. Итоговое соответствие "локальный порт → сервис" попадает в
|
порты. Итоговое соответствие "локальный порт → сервис" попадает в
|
||||||
`INSTRUCTIONS.txt` внутри архива пользователя.
|
`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-репозиторий.
|
Каталог состояния (`/var/lib/ssh-access-tui/state`) — git-репозиторий.
|
||||||
|
|||||||
@@ -35,7 +35,17 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
|
|||||||
fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName)
|
fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName)
|
||||||
b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\n\n")
|
b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\n\n")
|
||||||
|
|
||||||
b.WriteString("ШАГ 2. Подключитесь\n")
|
b.WriteString("ШАГ 2. Настройте доступ к файлу ключа\n")
|
||||||
|
b.WriteString("----------------------------------------\n")
|
||||||
|
b.WriteString("Windows откажется использовать секретный ключ, если его может прочитать\n")
|
||||||
|
b.WriteString("кто-то ещё, кроме вас. Откройте PowerShell и выполните по очереди (это две\n")
|
||||||
|
b.WriteString("отдельные команды, копируйте и выполняйте одну за другой):\n\n")
|
||||||
|
fmt.Fprintf(&b, " icacls \"$env:USERPROFILE\\.ssh\\%s\" /inheritance:r\n", keyFileName)
|
||||||
|
fmt.Fprintf(&b, " icacls \"$env:USERPROFILE\\.ssh\\%s\" /grant:r \"${env:USERNAME}:(R)\"\n\n", keyFileName)
|
||||||
|
b.WriteString("Если файлы лежат не в стандартной папке .ssh, а где-то ещё — замените путь\n")
|
||||||
|
b.WriteString("в обеих командах на реальный путь к файлу ключа (в кавычках, как выше).\n\n")
|
||||||
|
|
||||||
|
b.WriteString("ШАГ 3. Подключитесь\n")
|
||||||
b.WriteString("---------------------\n")
|
b.WriteString("---------------------\n")
|
||||||
b.WriteString("В PowerShell введите команду:\n\n")
|
b.WriteString("В PowerShell введите команду:\n\n")
|
||||||
b.WriteString(" ssh -N jump\n\n")
|
b.WriteString(" ssh -N jump\n\n")
|
||||||
@@ -44,7 +54,7 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
|
|||||||
b.WriteString("таблицы ниже. Закрыли окно (или выключили компьютер) — доступ пропал, просто\n")
|
b.WriteString("таблицы ниже. Закрыли окно (или выключили компьютер) — доступ пропал, просто\n")
|
||||||
b.WriteString("откройте заново командой выше, когда снова понадобится.\n\n")
|
b.WriteString("откройте заново командой выше, когда снова понадобится.\n\n")
|
||||||
|
|
||||||
b.WriteString("ШАГ 3. Пользуйтесь\n")
|
b.WriteString("ШАГ 4. Пользуйтесь\n")
|
||||||
b.WriteString("--------------------\n")
|
b.WriteString("--------------------\n")
|
||||||
b.WriteString("Эта настройка открывает у вас на компьютере (локально) следующие адреса.\n")
|
b.WriteString("Эта настройка открывает у вас на компьютере (локально) следующие адреса.\n")
|
||||||
b.WriteString("В программе, которая должна подключиться к сервису (браузер, клиент базы\n")
|
b.WriteString("В программе, которая должна подключиться к сервису (браузер, клиент базы\n")
|
||||||
@@ -71,8 +81,13 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
|
|||||||
|
|
||||||
b.WriteString("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n")
|
b.WriteString("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n")
|
||||||
b.WriteString("------------------------\n")
|
b.WriteString("------------------------\n")
|
||||||
b.WriteString("Обратитесь к администратору, который выдал вам этот архив. Возможно,\n")
|
b.WriteString("Написано «This account is currently not available» и соединение сразу\n")
|
||||||
b.WriteString("изменился список сервисов — тогда вам пришлют обновлённый архив.\n")
|
b.WriteString("обрывается? Это нормально, если вы вводили команду БЕЗ \"-N\" (например,\n")
|
||||||
|
b.WriteString("просто \"ssh jump\") — обычный вход сюда специально отключён, используйте\n")
|
||||||
|
b.WriteString("именно команду из шага 3 (\"ssh -N jump\").\n\n")
|
||||||
|
b.WriteString("Если не работает именно шаг 3 или шаг 4 — обратитесь к администратору,\n")
|
||||||
|
b.WriteString("который выдал вам этот архив. Возможно, изменился список сервисов — тогда\n")
|
||||||
|
b.WriteString("вам пришлют обновлённый архив.\n")
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashForwards returns a stable, order-independent digest of a Forward set,
|
||||||
|
// used to detect whether a user's already-delivered client config still
|
||||||
|
// matches their current access. Sorted before hashing so that reordering
|
||||||
|
// the underlying groups/hosts/services (without actually changing what's
|
||||||
|
// granted) never produces a spurious "stale" result.
|
||||||
|
func HashForwards(forwards []Forward) string {
|
||||||
|
lines := make([]string, len(forwards))
|
||||||
|
for i, f := range forwards {
|
||||||
|
lines[i] = fmt.Sprintf("%s|%s|%d|%s|%s", f.HostName, f.Address, f.Port, f.ServiceName, f.Description)
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConfigStale reports whether u's live AllowedForwards no longer matches
|
||||||
|
// the set they were handed in their last config (full export or
|
||||||
|
// config-only update). Users who never had a key issued are not "stale" —
|
||||||
|
// that's a distinct, more basic state (see State.NeverExportedUsernames).
|
||||||
|
func (s *State) IsConfigStale(u *User) bool {
|
||||||
|
if u.PublicKey == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return u.ForwardsHash != HashForwards(s.AllowedForwards(u))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeverExportedUsernames returns active users who have never had a key
|
||||||
|
// issued at all (empty PublicKey) — created but forgotten before "Экспорт
|
||||||
|
// архивов".
|
||||||
|
func (s *State) NeverExportedUsernames() []string {
|
||||||
|
var out []string
|
||||||
|
for _, u := range s.Users {
|
||||||
|
if u.Status == StatusActive && u.PublicKey == "" {
|
||||||
|
out = append(out, u.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaleConfigUsernames returns active, already-issued users whose local
|
||||||
|
// config no longer matches their real access (see IsConfigStale) — they
|
||||||
|
// need "Обновить конфиги", not a full re-export.
|
||||||
|
func (s *State) StaleConfigUsernames() []string {
|
||||||
|
var out []string
|
||||||
|
for i := range s.Users {
|
||||||
|
u := &s.Users[i]
|
||||||
|
if u.Status == StatusActive && u.PublicKey != "" && s.IsConfigStale(u) {
|
||||||
|
out = append(out, u.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+121
-40
@@ -1,10 +1,9 @@
|
|||||||
// Package model defines the data types persisted as the tool's state.
|
// Package model defines the data types persisted as the tool's state.
|
||||||
//
|
//
|
||||||
// Hosts (each offering one or more TCP services) form a shared catalog,
|
// Hosts (each offering one or more TCP services) form a shared catalog,
|
||||||
// defined once; groups reference a subset of that catalog by name rather
|
// defined once; groups reference individual services of that catalog (not
|
||||||
// than redefining hosts/services per group — this matters when the same
|
// whole hosts) by name, so access can be scoped per-service rather than
|
||||||
// hosts/services are reused across many groups; and users are assigned to
|
// per-host; and users are assigned to one or more groups.
|
||||||
// one or more groups.
|
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -20,7 +19,8 @@ var nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
|||||||
// ValidName reports whether s is safe to use as a group/host/service name:
|
// ValidName reports whether s is safe to use as a group/host/service name:
|
||||||
// letters, digits, dot, underscore, hyphen, no spaces. Names end up as
|
// letters, digits, dot, underscore, hyphen, no spaces. Names end up as
|
||||||
// ssh_config comments/tokens and authorized_keys metadata, where a stray
|
// ssh_config comments/tokens and authorized_keys metadata, where a stray
|
||||||
// space or "#" would corrupt the generated file.
|
// space or "#" would corrupt the generated file. Names also never contain
|
||||||
|
// "/", which ServiceRef keys (see below) rely on as a safe separator.
|
||||||
func ValidName(s string) bool {
|
func ValidName(s string) bool {
|
||||||
return nameRe.MatchString(s)
|
return nameRe.MatchString(s)
|
||||||
}
|
}
|
||||||
@@ -36,8 +36,8 @@ type Service struct {
|
|||||||
|
|
||||||
// Host is a catalog entry: one jump-target reachable via port forwarding
|
// Host is a catalog entry: one jump-target reachable via port forwarding
|
||||||
// through the bastion, defined once and referenced by any number of groups
|
// through the bastion, defined once and referenced by any number of groups
|
||||||
// (see Group.Hosts) — hosts/services shared across many groups only need to
|
// (see Group.Services) — hosts/services shared across many groups only
|
||||||
// be entered a single time.
|
// need to be entered a single time.
|
||||||
type Host struct {
|
type Host struct {
|
||||||
Name string `yaml:"name"` // short identifier, e.g. "db-prod-1"
|
Name string `yaml:"name"` // short identifier, e.g. "db-prod-1"
|
||||||
Address string `yaml:"address"` // hostname or IP as seen from the jump host
|
Address string `yaml:"address"` // hostname or IP as seen from the jump host
|
||||||
@@ -45,14 +45,22 @@ type Host struct {
|
|||||||
Services []Service `yaml:"services"`
|
Services []Service `yaml:"services"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group is a named access policy referencing a subset of catalog hosts (by
|
// ServiceRef points at one specific service of one specific catalog host.
|
||||||
// name — all of that host's services are included). Users are assigned to
|
// Groups grant access per-service, not per-host: a group can include just
|
||||||
// one or more groups; their effective allowed forwards are the union of
|
// "db1/ssh" without also granting "db1/web-admin".
|
||||||
// services on all hosts referenced by all their groups.
|
type ServiceRef struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Service string `yaml:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group is a named access policy referencing a subset of catalog services
|
||||||
|
// (by host+service name). Users are assigned to one or more groups; their
|
||||||
|
// effective allowed forwards are the union of all services referenced by
|
||||||
|
// all their groups.
|
||||||
type Group struct {
|
type Group struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Description string `yaml:"description,omitempty"`
|
Description string `yaml:"description,omitempty"`
|
||||||
Hosts []string `yaml:"hosts"` // names referencing State.Hosts
|
Services []ServiceRef `yaml:"services"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserStatus is the lifecycle state of a managed account.
|
// UserStatus is the lifecycle state of a managed account.
|
||||||
@@ -69,7 +77,12 @@ type User struct {
|
|||||||
Groups []string `yaml:"groups"`
|
Groups []string `yaml:"groups"`
|
||||||
Status UserStatus `yaml:"status"`
|
Status UserStatus `yaml:"status"`
|
||||||
Fingerprint string `yaml:"fingerprint"` // SHA256 fingerprint of the current authorized public key
|
Fingerprint string `yaml:"fingerprint"` // SHA256 fingerprint of the current authorized public key
|
||||||
PublicKey string `yaml:"public_key,omitempty"` // full "ssh-ed25519 AAAA... comment" line; safe to store/back up (public), lets us rewrite authorized_keys (e.g. after a group's host list changes) without re-issuing the keypair
|
PublicKey string `yaml:"public_key,omitempty"` // full "ssh-ed25519 AAAA... comment" line; safe to store/back up (public), lets us rewrite authorized_keys (e.g. after a group's referenced services change) without re-issuing the keypair
|
||||||
|
// ForwardsHash is HashForwards(AllowedForwards(u)) as of the last time
|
||||||
|
// this user was handed a config (full export or config-only update). If
|
||||||
|
// it no longer matches the live computation, their local ssh config is
|
||||||
|
// stale — see IsConfigStale.
|
||||||
|
ForwardsHash string `yaml:"forwards_hash,omitempty"`
|
||||||
Comment string `yaml:"comment,omitempty"`
|
Comment string `yaml:"comment,omitempty"`
|
||||||
CreatedAt time.Time `yaml:"created_at"`
|
CreatedAt time.Time `yaml:"created_at"`
|
||||||
LockedAt *time.Time `yaml:"locked_at,omitempty"`
|
LockedAt *time.Time `yaml:"locked_at,omitempty"`
|
||||||
@@ -125,9 +138,9 @@ type Forward struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AllowedForwards returns the de-duplicated union of (host, service) pairs
|
// AllowedForwards returns the de-duplicated union of (host, service) pairs
|
||||||
// across all catalog hosts referenced by all of the user's groups, in
|
// across every ServiceRef of every group the user belongs to, in stable
|
||||||
// stable encounter order. Two services that resolve to the same
|
// encounter order. Two services that resolve to the same address:port
|
||||||
// address:port (even from different groups) collapse to a single Forward.
|
// (even from different groups) collapse to a single Forward.
|
||||||
func (s *State) AllowedForwards(u *User) []Forward {
|
func (s *State) AllowedForwards(u *User) []Forward {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var out []Forward
|
var out []Forward
|
||||||
@@ -136,12 +149,21 @@ func (s *State) AllowedForwards(u *User) []Forward {
|
|||||||
if g == nil {
|
if g == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, hostName := range g.Hosts {
|
for _, ref := range g.Services {
|
||||||
h := s.FindHost(hostName)
|
h := s.FindHost(ref.Host)
|
||||||
if h == nil {
|
if h == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, svc := range h.Services {
|
var svc *Service
|
||||||
|
for i := range h.Services {
|
||||||
|
if h.Services[i].Name == ref.Service {
|
||||||
|
svc = &h.Services[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if svc == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
key := fmt.Sprintf("%s:%d", h.Address, svc.Port)
|
key := fmt.Sprintf("%s:%d", h.Address, svc.Port)
|
||||||
if seen[key] {
|
if seen[key] {
|
||||||
continue
|
continue
|
||||||
@@ -156,16 +178,31 @@ func (s *State) AllowedForwards(u *User) []Forward {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupNamesReferencingHost returns every group that references at least
|
||||||
|
// one service of hostName.
|
||||||
|
func (s *State) GroupNamesReferencingHost(hostName string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, g := range s.Groups {
|
||||||
|
for _, ref := range g.Services {
|
||||||
|
if ref.Host == hostName {
|
||||||
|
out = append(out, g.Name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupNamesReferencingHost returns every group that includes hostName.
|
// GroupNamesReferencingService returns every group that references this
|
||||||
func (s *State) GroupNamesReferencingHost(hostName string) []string {
|
// exact (host, service) pair.
|
||||||
|
func (s *State) GroupNamesReferencingService(hostName, serviceName string) []string {
|
||||||
var out []string
|
var out []string
|
||||||
for _, g := range s.Groups {
|
for _, g := range s.Groups {
|
||||||
for _, h := range g.Hosts {
|
for _, ref := range g.Services {
|
||||||
if h == hostName {
|
if ref.Host == hostName && ref.Service == serviceName {
|
||||||
out = append(out, g.Name)
|
out = append(out, g.Name)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -188,12 +225,12 @@ func (s *State) UsernamesInGroup(groupName string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsernamesUsingHost returns every user reachable transitively through any
|
// usernamesInAnyGroup returns the deduplicated union of UsernamesInGroup
|
||||||
// group that references hostName (deduplicated).
|
// across the given group names.
|
||||||
func (s *State) UsernamesUsingHost(hostName string) []string {
|
func (s *State) usernamesInAnyGroup(groupNames []string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var out []string
|
var out []string
|
||||||
for _, gname := range s.GroupNamesReferencingHost(hostName) {
|
for _, gname := range groupNames {
|
||||||
for _, uname := range s.UsernamesInGroup(gname) {
|
for _, uname := range s.UsernamesInGroup(gname) {
|
||||||
if !seen[uname] {
|
if !seen[uname] {
|
||||||
seen[uname] = true
|
seen[uname] = true
|
||||||
@@ -204,6 +241,18 @@ func (s *State) UsernamesUsingHost(hostName string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UsernamesUsingHost returns every user reachable transitively through any
|
||||||
|
// group that references any service of hostName (deduplicated).
|
||||||
|
func (s *State) UsernamesUsingHost(hostName string) []string {
|
||||||
|
return s.usernamesInAnyGroup(s.GroupNamesReferencingHost(hostName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsernamesUsingService returns every user reachable transitively through
|
||||||
|
// any group that references this exact (host, service) pair (deduplicated).
|
||||||
|
func (s *State) UsernamesUsingService(hostName, serviceName string) []string {
|
||||||
|
return s.usernamesInAnyGroup(s.GroupNamesReferencingService(hostName, serviceName))
|
||||||
|
}
|
||||||
|
|
||||||
// RenameGroup renames a group and updates every user's membership reference
|
// RenameGroup renames a group and updates every user's membership reference
|
||||||
// so nobody silently falls out of a group just because it was renamed.
|
// so nobody silently falls out of a group just because it was renamed.
|
||||||
func (s *State) RenameGroup(oldName, newName string) {
|
func (s *State) RenameGroup(oldName, newName string) {
|
||||||
@@ -240,24 +289,26 @@ func (s *State) DeleteGroup(name string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenameHost renames a catalog host and updates every group's reference to
|
// RenameHost renames a catalog host and updates every group's ServiceRefs
|
||||||
// it, so a rename doesn't silently drop the host out of groups using it.
|
// pointing at it, so a rename doesn't silently drop the host's services out
|
||||||
|
// of groups using them.
|
||||||
func (s *State) RenameHost(oldName, newName string) {
|
func (s *State) RenameHost(oldName, newName string) {
|
||||||
if h := s.FindHost(oldName); h != nil {
|
if h := s.FindHost(oldName); h != nil {
|
||||||
h.Name = newName
|
h.Name = newName
|
||||||
}
|
}
|
||||||
for i := range s.Groups {
|
for i := range s.Groups {
|
||||||
for j, hn := range s.Groups[i].Hosts {
|
for j := range s.Groups[i].Services {
|
||||||
if hn == oldName {
|
if s.Groups[i].Services[j].Host == oldName {
|
||||||
s.Groups[i].Hosts[j] = newName
|
s.Groups[i].Services[j].Host = newName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteHost removes a host from the catalog and from every group that
|
// DeleteHost removes a host from the catalog and drops every group's
|
||||||
// referenced it. Caller is responsible for resyncing authorized_keys for
|
// ServiceRefs that pointed at any of its services. Caller is responsible
|
||||||
// any affected user (see UsernamesUsingHost, called before this).
|
// for resyncing authorized_keys for any affected user (see
|
||||||
|
// UsernamesUsingHost, called before this).
|
||||||
func (s *State) DeleteHost(name string) {
|
func (s *State) DeleteHost(name string) {
|
||||||
for i := range s.Hosts {
|
for i := range s.Hosts {
|
||||||
if s.Hosts[i].Name == name {
|
if s.Hosts[i].Name == name {
|
||||||
@@ -266,12 +317,42 @@ func (s *State) DeleteHost(name string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i := range s.Groups {
|
for i := range s.Groups {
|
||||||
var kept []string
|
var kept []ServiceRef
|
||||||
for _, hn := range s.Groups[i].Hosts {
|
for _, ref := range s.Groups[i].Services {
|
||||||
if hn != name {
|
if ref.Host != name {
|
||||||
kept = append(kept, hn)
|
kept = append(kept, ref)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.Groups[i].Hosts = kept
|
s.Groups[i].Services = kept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameServiceRefs updates every group's ServiceRef pointing at
|
||||||
|
// (hostName, oldService) to (hostName, newService), so renaming a service
|
||||||
|
// doesn't silently drop it out of groups using it.
|
||||||
|
func (s *State) RenameServiceRefs(hostName, oldService, newService string) {
|
||||||
|
for i := range s.Groups {
|
||||||
|
for j := range s.Groups[i].Services {
|
||||||
|
ref := &s.Groups[i].Services[j]
|
||||||
|
if ref.Host == hostName && ref.Service == oldService {
|
||||||
|
ref.Service = newService
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveServiceRefs drops every group's ServiceRef pointing at
|
||||||
|
// (hostName, serviceName). Caller is responsible for resyncing
|
||||||
|
// authorized_keys for any affected user (see UsernamesUsingService, called
|
||||||
|
// before this).
|
||||||
|
func (s *State) RemoveServiceRefs(hostName, serviceName string) {
|
||||||
|
for i := range s.Groups {
|
||||||
|
var kept []ServiceRef
|
||||||
|
for _, ref := range s.Groups[i].Services {
|
||||||
|
if !(ref.Host == hostName && ref.Service == serviceName) {
|
||||||
|
kept = append(kept, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Groups[i].Services = kept
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+28
-8
@@ -38,6 +38,7 @@ const (
|
|||||||
scrGroupHostsPick
|
scrGroupHostsPick
|
||||||
scrExportSelect
|
scrExportSelect
|
||||||
scrExportResult
|
scrExportResult
|
||||||
|
scrConfigUpdateSelect
|
||||||
scrConfigUpdateResult
|
scrConfigUpdateResult
|
||||||
scrAudit
|
scrAudit
|
||||||
scrQuitConfirm
|
scrQuitConfirm
|
||||||
@@ -83,6 +84,15 @@ func newList(items []list.Item, title string, w, h int) list.Model {
|
|||||||
l.Title = title
|
l.Title = title
|
||||||
l.Styles.Title = listTitleStyle
|
l.Styles.Title = listTitleStyle
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
|
// bubbles/list binds "q" (and ForceQuit) to return tea.Quit directly,
|
||||||
|
// bypassing whatever screen it's on. Every screen except the main menu
|
||||||
|
// falls through unhandled keys straight into the list's own Update, so
|
||||||
|
// without this, pressing "q" on the Users/Groups/Catalog screens would
|
||||||
|
// quit the whole program immediately — skipping attemptQuit's warnings
|
||||||
|
// about unexported or stale-config users entirely. The menu handles "q"
|
||||||
|
// itself before reaching the list, so disabling it here is harmless
|
||||||
|
// there and load-bearing everywhere else.
|
||||||
|
l.DisableQuitKeybindings()
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,15 +128,17 @@ type Model struct {
|
|||||||
serviceFormFocus int
|
serviceFormFocus int
|
||||||
serviceFormEditTarget string // "" = creating a new service
|
serviceFormEditTarget string // "" = creating a new service
|
||||||
|
|
||||||
// groups: named policies that reference a subset of catalog hosts
|
// groups: named policies that reference individual catalog services,
|
||||||
|
// picked via a host -> services tree (see groups.go)
|
||||||
groupsList list.Model
|
groupsList list.Model
|
||||||
groupForm [2]textinput.Model // name, description
|
groupForm [2]textinput.Model // name, description
|
||||||
groupFormFocus int
|
groupFormFocus int
|
||||||
groupFormEditTarget string // "" = creating a new group
|
groupFormEditTarget string // "" = creating a new group
|
||||||
selectedGroup string
|
selectedGroup string
|
||||||
groupHostsPickSel map[string]bool
|
groupPickRows []pickRow
|
||||||
groupHostsPickOrder []string
|
groupPickServices map[string][]string // host -> its service names, snapshot at screen-entry
|
||||||
groupHostsPickCursor int
|
groupPickSel map[string]bool // key "host/service" -> selected
|
||||||
|
groupPickCursor int
|
||||||
|
|
||||||
exportSel map[string]bool
|
exportSel map[string]bool
|
||||||
exportOrder []string
|
exportOrder []string
|
||||||
@@ -136,11 +148,19 @@ type Model struct {
|
|||||||
exportPass string
|
exportPass string
|
||||||
exportErr string
|
exportErr string
|
||||||
|
|
||||||
|
configUpdateSel map[string]bool
|
||||||
|
configUpdateOrder []string
|
||||||
|
configUpdateCursor int
|
||||||
|
configUpdateRes []keymgr.ConfigOnlyResult
|
||||||
|
configUpdatePass string
|
||||||
|
configUpdateErr string
|
||||||
|
|
||||||
onlineSessions []audit.OnlineSession
|
onlineSessions []audit.OnlineSession
|
||||||
recentEvents []audit.Event
|
recentEvents []audit.Event
|
||||||
|
|
||||||
quitReturnTo screen // screen to go back to if quit is cancelled
|
quitReturnTo screen // screen to go back to if quit is cancelled
|
||||||
quitPending []string // usernames with no key issued yet, shown on scrQuitConfirm
|
quitPending []string // usernames with no key issued yet, shown on scrQuitConfirm
|
||||||
|
quitStale []string // usernames whose config is out of date, shown on scrQuitConfirm
|
||||||
|
|
||||||
// generic confirmation for destructive actions (lock/delete user,
|
// generic confirmation for destructive actions (lock/delete user,
|
||||||
// delete host/service/group, export key rotation) — see confirm.go.
|
// delete host/service/group, export key rotation) — see confirm.go.
|
||||||
@@ -155,10 +175,6 @@ type Model struct {
|
|||||||
confirmInput textinput.Model
|
confirmInput textinput.Model
|
||||||
confirmError string
|
confirmError string
|
||||||
|
|
||||||
configUpdateUser string
|
|
||||||
configUpdateArchive string
|
|
||||||
configUpdatePass string
|
|
||||||
|
|
||||||
width, height int
|
width, height int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +273,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updateExportSelect(msg)
|
return m.updateExportSelect(msg)
|
||||||
case scrExportResult:
|
case scrExportResult:
|
||||||
return m.updateExportResult(msg)
|
return m.updateExportResult(msg)
|
||||||
|
case scrConfigUpdateSelect:
|
||||||
|
return m.updateConfigUpdateSelect(msg)
|
||||||
case scrConfigUpdateResult:
|
case scrConfigUpdateResult:
|
||||||
return m.updateConfigUpdateResult(msg)
|
return m.updateConfigUpdateResult(msg)
|
||||||
case scrAudit:
|
case scrAudit:
|
||||||
@@ -298,6 +316,8 @@ func (m Model) View() string {
|
|||||||
body = m.viewExportSelect()
|
body = m.viewExportSelect()
|
||||||
case scrExportResult:
|
case scrExportResult:
|
||||||
body = m.viewExportResult()
|
body = m.viewExportResult()
|
||||||
|
case scrConfigUpdateSelect:
|
||||||
|
body = m.viewConfigUpdateSelect()
|
||||||
case scrConfigUpdateResult:
|
case scrConfigUpdateResult:
|
||||||
body = m.viewConfigUpdateResult()
|
body = m.viewConfigUpdateResult()
|
||||||
case scrAudit:
|
case scrAudit:
|
||||||
|
|||||||
@@ -262,12 +262,14 @@ func (m Model) updateCatalogServices(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
st := m.loadState()
|
st := m.loadState()
|
||||||
affected := st.UsernamesUsingHost(m.selectedHost)
|
affected := st.UsernamesUsingService(m.selectedHost, sel.id)
|
||||||
|
groups := st.GroupNamesReferencingService(m.selectedHost, sel.id)
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Удалить сервис %q у хоста %q?\n\n"+
|
"Удалить сервис %q у хоста %q?\n\n"+
|
||||||
|
"Он используется в группах: %v.\n"+
|
||||||
"Это затронет пользователей: %d — они немедленно потеряют доступ к этому\n"+
|
"Это затронет пользователей: %d — они немедленно потеряют доступ к этому\n"+
|
||||||
"сервису.",
|
"сервису.",
|
||||||
sel.id, m.selectedHost, len(affected))
|
sel.id, m.selectedHost, groups, len(affected))
|
||||||
m = m.startConfirm(confirmDeleteService, sel.id, msg, scrCatalogServices)
|
m = m.startConfirm(confirmDeleteService, sel.id, msg, scrCatalogServices)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -331,6 +333,7 @@ func (m Model) updateServiceForm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return fmt.Errorf("сервис %q уже существует на этом хосте", name)
|
return fmt.Errorf("сервис %q уже существует на этом хосте", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
st.RenameServiceRefs(hostName, target, name)
|
||||||
}
|
}
|
||||||
for i := range h.Services {
|
for i := range h.Services {
|
||||||
if h.Services[i].Name == target {
|
if h.Services[i].Name == target {
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"ssh-access-tui/internal/keymgr"
|
||||||
|
"ssh-access-tui/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initConfigUpdateSelect lists every already-issued active user, so the
|
||||||
|
// admin can force-refresh anyone's config even if it isn't detected as
|
||||||
|
// stale — the status annotation (see viewConfigUpdateSelect) shows which
|
||||||
|
// ones actually need it.
|
||||||
|
func (m *Model) initConfigUpdateSelect() {
|
||||||
|
st := m.loadState()
|
||||||
|
m.configUpdateSel = map[string]bool{}
|
||||||
|
m.configUpdateOrder = nil
|
||||||
|
for _, u := range st.Users {
|
||||||
|
if u.Status == model.StatusActive && u.PublicKey != "" {
|
||||||
|
m.configUpdateOrder = append(m.configUpdateOrder, u.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.configUpdateCursor = 0
|
||||||
|
m.configUpdateErr = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateConfigUpdateSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch key.String() {
|
||||||
|
case "esc":
|
||||||
|
m.screen = scrMenu
|
||||||
|
m.err, m.status = "", ""
|
||||||
|
return m, nil
|
||||||
|
case "up", "k":
|
||||||
|
if m.configUpdateCursor > 0 {
|
||||||
|
m.configUpdateCursor--
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "down", "j":
|
||||||
|
if m.configUpdateCursor < len(m.configUpdateOrder)-1 {
|
||||||
|
m.configUpdateCursor++
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case " ":
|
||||||
|
if len(m.configUpdateOrder) > 0 {
|
||||||
|
u := m.configUpdateOrder[m.configUpdateCursor]
|
||||||
|
m.configUpdateSel[u] = !m.configUpdateSel[u]
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "a":
|
||||||
|
for _, u := range m.configUpdateOrder {
|
||||||
|
m.configUpdateSel[u] = true
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
var chosen []string
|
||||||
|
for _, u := range m.configUpdateOrder {
|
||||||
|
if m.configUpdateSel[u] {
|
||||||
|
chosen = append(chosen, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(chosen) == 0 {
|
||||||
|
m.err = "выберите хотя бы одного пользователя (space)"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.runConfigUpdate(chosen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewConfigUpdateSelect() string {
|
||||||
|
st := m.loadState()
|
||||||
|
body := "Обновить конфиги — выберите пользователей (ключи не трогает, общий пароль на партию)\n\n"
|
||||||
|
if len(m.configUpdateOrder) == 0 {
|
||||||
|
body += warnStyle.Render("Нет пользователей с уже выданным ключом.") + "\n"
|
||||||
|
}
|
||||||
|
for i, uname := range m.configUpdateOrder {
|
||||||
|
cursor := " "
|
||||||
|
if i == m.configUpdateCursor {
|
||||||
|
cursor = "> "
|
||||||
|
}
|
||||||
|
check := "[ ]"
|
||||||
|
if m.configUpdateSel[uname] {
|
||||||
|
check = "[x]"
|
||||||
|
}
|
||||||
|
status := okStyle.Render("конфиг актуален")
|
||||||
|
if u := st.FindUser(uname); u != nil && st.IsConfigStale(u) {
|
||||||
|
status = warnStyle.Render("конфиг устарел — доступ изменился")
|
||||||
|
}
|
||||||
|
body += fmt.Sprintf("%s%s %-20s %s\n", cursor, check, uname, status)
|
||||||
|
}
|
||||||
|
body += "\n[space] отметить [a] отметить всех [enter] сгенерировать [esc] назад"
|
||||||
|
return borderStyle.Render(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runConfigUpdate is the lightweight alternative to a full export: when
|
||||||
|
// only group membership or the catalog's services changed, server-side
|
||||||
|
// access is already updated immediately (authorized_keys permitopen=) — this
|
||||||
|
// just hands the selected users a fresh client config reflecting that,
|
||||||
|
// without touching any key. See keymgr.BuildConfigOnlyArchive. Not
|
||||||
|
// destructive (no key is rotated, nothing existing breaks), so it runs
|
||||||
|
// without a confirmation screen regardless of batch size.
|
||||||
|
func (m Model) runConfigUpdate(usernames []string) (tea.Model, tea.Cmd) {
|
||||||
|
password, err := keymgr.GenerateBatchPassword(20)
|
||||||
|
if err != nil {
|
||||||
|
m.err = err.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
st := m.loadState()
|
||||||
|
var results []keymgr.ConfigOnlyResult
|
||||||
|
var failures []string
|
||||||
|
|
||||||
|
for _, username := range usernames {
|
||||||
|
u := st.FindUser(username)
|
||||||
|
if u == nil {
|
||||||
|
failures = append(failures, username+": не найден в состоянии")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u.PublicKey == "" {
|
||||||
|
failures = append(failures, username+": ещё нет ключа, используйте «Экспорт архивов»")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
forwards := st.AllowedForwards(u)
|
||||||
|
res, err := keymgr.BuildConfigOnlyArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards)
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, username+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u.ForwardsHash = model.HashForwards(forwards)
|
||||||
|
results = append(results, *res)
|
||||||
|
m.logAudit(username, "admin", "config-only update issued (key unchanged)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.st.WithLock(func() error {
|
||||||
|
return m.st.Save(st, fmt.Sprintf("config-update: обновлены конфиги для %v", usernames))
|
||||||
|
}); err != nil {
|
||||||
|
failures = append(failures, "сохранение состояния: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
m.configUpdateRes = results
|
||||||
|
m.configUpdatePass = password
|
||||||
|
m.configUpdateErr = ""
|
||||||
|
for _, f := range failures {
|
||||||
|
m.configUpdateErr += f + "\n"
|
||||||
|
}
|
||||||
|
m.screen = scrConfigUpdateResult
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateConfigUpdateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if key.String() == "esc" || key.String() == "enter" {
|
||||||
|
m.screen = scrMenu
|
||||||
|
m.err, m.status = "", ""
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewConfigUpdateResult() string {
|
||||||
|
body := "Готово — ключи НЕ менялись, обновлён только config.\n\n"
|
||||||
|
body += "Пароль архива (один на всю партию, покажется только сейчас!):\n\n"
|
||||||
|
body += titleStyle.Render(m.configUpdatePass) + "\n\n"
|
||||||
|
body += warnStyle.Render("Сообщите пароль каждому получателю отдельным от архива каналом.") + "\n\n"
|
||||||
|
for _, r := range m.configUpdateRes {
|
||||||
|
body += fmt.Sprintf(" %-20s -> %s\n", r.Username, r.ArchivePath)
|
||||||
|
}
|
||||||
|
if m.configUpdateErr != "" {
|
||||||
|
body += "\n" + errStyle.Render("Ошибки:\n"+m.configUpdateErr)
|
||||||
|
}
|
||||||
|
body += "\n[enter/esc] в меню"
|
||||||
|
return borderStyle.Render(body)
|
||||||
|
}
|
||||||
@@ -175,7 +175,8 @@ func (m Model) doDeleteHost(hostName string) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// doDeleteService removes a service from m.selectedHost, then resyncs
|
// doDeleteService removes a service from m.selectedHost, drops it from
|
||||||
|
// every group that referenced this exact service, and resyncs
|
||||||
// authorized_keys for every user that lost access as a result.
|
// authorized_keys for every user that lost access as a result.
|
||||||
func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) {
|
func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) {
|
||||||
hostName := m.selectedHost
|
hostName := m.selectedHost
|
||||||
@@ -184,13 +185,15 @@ func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) {
|
|||||||
if h == nil {
|
if h == nil {
|
||||||
return fmt.Errorf("host %s not found", hostName)
|
return fmt.Errorf("host %s not found", hostName)
|
||||||
}
|
}
|
||||||
|
affected := st.UsernamesUsingService(hostName, serviceName)
|
||||||
for i := range h.Services {
|
for i := range h.Services {
|
||||||
if h.Services[i].Name == serviceName {
|
if h.Services[i].Name == serviceName {
|
||||||
h.Services = append(h.Services[:i], h.Services[i+1:]...)
|
h.Services = append(h.Services[:i], h.Services[i+1:]...)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resyncUsernames(st, st.UsernamesUsingHost(hostName))
|
st.RemoveServiceRefs(hostName, serviceName)
|
||||||
|
return resyncUsernames(st, affected)
|
||||||
})
|
})
|
||||||
m.screen = scrCatalogServices
|
m.screen = scrCatalogServices
|
||||||
m.refreshCatalogServicesList()
|
m.refreshCatalogServicesList()
|
||||||
|
|||||||
+1
-59
@@ -129,6 +129,7 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
u.PublicKey = res.PublicKeyLine
|
u.PublicKey = res.PublicKeyLine
|
||||||
u.Fingerprint = res.Fingerprint
|
u.Fingerprint = res.Fingerprint
|
||||||
|
u.ForwardsHash = model.HashForwards(forwards)
|
||||||
results = append(results, *res)
|
results = append(results, *res)
|
||||||
m.logAudit(username, "admin", "key issued/rotated, fingerprint="+res.Fingerprint)
|
m.logAudit(username, "admin", "key issued/rotated, fingerprint="+res.Fingerprint)
|
||||||
}
|
}
|
||||||
@@ -149,65 +150,6 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runConfigUpdate is the lightweight alternative to a full export: when only
|
|
||||||
// a user's group membership changed, their access is already updated
|
|
||||||
// server-side immediately (authorized_keys permitopen=) — this just hands
|
|
||||||
// them a fresh client config reflecting that, without touching their key at
|
|
||||||
// all. See keymgr.BuildConfigOnlyArchive.
|
|
||||||
func (m Model) runConfigUpdate(username string) (tea.Model, tea.Cmd) {
|
|
||||||
st := m.loadState()
|
|
||||||
u := st.FindUser(username)
|
|
||||||
if u == nil {
|
|
||||||
m.err = fmt.Sprintf("пользователь %q не найден", username)
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if u.PublicKey == "" {
|
|
||||||
m.err = fmt.Sprintf("у пользователя %q ещё нет ключа — используйте «Экспорт архивов»", username)
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
password, err := keymgr.GenerateBatchPassword(20)
|
|
||||||
if err != nil {
|
|
||||||
m.err = err.Error()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
forwards := st.AllowedForwards(u)
|
|
||||||
res, err := keymgr.BuildConfigOnlyArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards)
|
|
||||||
if err != nil {
|
|
||||||
m.err = err.Error()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.logAudit(username, "admin", "config-only update issued (key unchanged)")
|
|
||||||
m.configUpdateUser = username
|
|
||||||
m.configUpdateArchive = res.ArchivePath
|
|
||||||
m.configUpdatePass = password
|
|
||||||
m.err = ""
|
|
||||||
m.screen = scrConfigUpdateResult
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) updateConfigUpdateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
|
||||||
if key.String() == "esc" || key.String() == "enter" {
|
|
||||||
m.screen = scrUsers
|
|
||||||
m.err, m.status = "", ""
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) viewConfigUpdateResult() string {
|
|
||||||
body := fmt.Sprintf("Конфиг для %s обновлён — ключ НЕ менялся.\n\n", m.configUpdateUser)
|
|
||||||
body += "Пароль архива (покажется только сейчас):\n\n"
|
|
||||||
body += titleStyle.Render(m.configUpdatePass) + "\n\n"
|
|
||||||
body += warnStyle.Render("Сообщите пароль отдельным от архива каналом.") + "\n\n"
|
|
||||||
body += fmt.Sprintf("Архив: %s\n\n", m.configUpdateArchive)
|
|
||||||
body += "Пользователю нужно только заменить файл config тем, что внутри — ключ и\n"
|
|
||||||
body += "остальные файлы трогать не нужно.\n"
|
|
||||||
body += "\n[enter/esc] в меню"
|
|
||||||
return borderStyle.Render(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) viewExportSelect() string {
|
func (m Model) viewExportSelect() string {
|
||||||
st := m.loadState()
|
st := m.loadState()
|
||||||
body := "Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n"
|
body := "Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n"
|
||||||
|
|||||||
+91
-29
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
@@ -14,14 +15,18 @@ func (m *Model) refreshGroupsList() {
|
|||||||
st := m.loadState()
|
st := m.loadState()
|
||||||
items := make([]list.Item, 0, len(st.Groups))
|
items := make([]list.Item, 0, len(st.Groups))
|
||||||
for _, g := range st.Groups {
|
for _, g := range st.Groups {
|
||||||
desc := fmt.Sprintf("%d хостов", len(g.Hosts))
|
hosts := map[string]bool{}
|
||||||
|
for _, ref := range g.Services {
|
||||||
|
hosts[ref.Host] = true
|
||||||
|
}
|
||||||
|
desc := fmt.Sprintf("%d сервисов на %d хостах", len(g.Services), len(hosts))
|
||||||
if g.Description != "" {
|
if g.Description != "" {
|
||||||
desc += " — " + g.Description
|
desc += " — " + g.Description
|
||||||
}
|
}
|
||||||
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: desc})
|
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: desc})
|
||||||
}
|
}
|
||||||
m.groupsList = newList(items,
|
m.groupsList = newList(items,
|
||||||
"Группы [n]новая [e]изменить [enter]хосты группы [d]удалить [esc]назад",
|
"Группы [n]новая [e]изменить [enter]сервисы группы [d]удалить [esc]назад",
|
||||||
m.groupsList.Width(), m.groupsList.Height())
|
m.groupsList.Width(), m.groupsList.Height())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,17 +78,25 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.selectedGroup = sel.id
|
m.selectedGroup = sel.id
|
||||||
st := m.loadState()
|
st := m.loadState()
|
||||||
g := st.FindGroup(sel.id)
|
g := st.FindGroup(sel.id)
|
||||||
m.groupHostsPickSel = map[string]bool{}
|
|
||||||
|
m.groupPickSel = map[string]bool{}
|
||||||
if g != nil {
|
if g != nil {
|
||||||
for _, hn := range g.Hosts {
|
for _, ref := range g.Services {
|
||||||
m.groupHostsPickSel[hn] = true
|
m.groupPickSel[ref.Host+"/"+ref.Service] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.groupHostsPickOrder = nil
|
m.groupPickRows = nil
|
||||||
|
m.groupPickServices = map[string][]string{}
|
||||||
for _, h := range st.Hosts {
|
for _, h := range st.Hosts {
|
||||||
m.groupHostsPickOrder = append(m.groupHostsPickOrder, h.Name)
|
m.groupPickRows = append(m.groupPickRows, pickRow{isHeader: true, host: h.Name})
|
||||||
|
var svcNames []string
|
||||||
|
for _, svc := range h.Services {
|
||||||
|
m.groupPickRows = append(m.groupPickRows, pickRow{host: h.Name, service: svc.Name})
|
||||||
|
svcNames = append(svcNames, svc.Name)
|
||||||
}
|
}
|
||||||
m.groupHostsPickCursor = 0
|
m.groupPickServices[h.Name] = svcNames
|
||||||
|
}
|
||||||
|
m.groupPickCursor = 0
|
||||||
m.screen = scrGroupHostsPick
|
m.screen = scrGroupHostsPick
|
||||||
return m, nil
|
return m, nil
|
||||||
case "d":
|
case "d":
|
||||||
@@ -180,7 +193,18 @@ func (m Model) viewGroupForm() string {
|
|||||||
return borderStyle.Render(body)
|
return borderStyle.Render(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- pick which catalog hosts belong to the selected group ---
|
// --- pick which catalog services belong to the selected group, shown as a
|
||||||
|
// host -> services tree: a host row toggles all of its services at once,
|
||||||
|
// a service row toggles just that one, so access is granted per-service
|
||||||
|
// (not blanket per-host) while still being quick to pick a whole host. ---
|
||||||
|
|
||||||
|
// pickRow is one line of the host/service picker tree: either a host
|
||||||
|
// header (isHeader, service == "") or a service leaf under that host.
|
||||||
|
type pickRow struct {
|
||||||
|
isHeader bool
|
||||||
|
host string
|
||||||
|
service string
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
@@ -190,35 +214,55 @@ func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshGroupsList()
|
m.refreshGroupsList()
|
||||||
return m, nil
|
return m, nil
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.groupHostsPickCursor > 0 {
|
if m.groupPickCursor > 0 {
|
||||||
m.groupHostsPickCursor--
|
m.groupPickCursor--
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
if m.groupHostsPickCursor < len(m.groupHostsPickOrder)-1 {
|
if m.groupPickCursor < len(m.groupPickRows)-1 {
|
||||||
m.groupHostsPickCursor++
|
m.groupPickCursor++
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case " ":
|
case " ":
|
||||||
if len(m.groupHostsPickOrder) > 0 {
|
if len(m.groupPickRows) == 0 {
|
||||||
h := m.groupHostsPickOrder[m.groupHostsPickCursor]
|
return m, nil
|
||||||
m.groupHostsPickSel[h] = !m.groupHostsPickSel[h]
|
}
|
||||||
|
row := m.groupPickRows[m.groupPickCursor]
|
||||||
|
if row.isHeader {
|
||||||
|
allSelected := true
|
||||||
|
for _, svc := range m.groupPickServices[row.host] {
|
||||||
|
if !m.groupPickSel[row.host+"/"+svc] {
|
||||||
|
allSelected = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, svc := range m.groupPickServices[row.host] {
|
||||||
|
m.groupPickSel[row.host+"/"+svc] = !allSelected
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
key := row.host + "/" + row.service
|
||||||
|
m.groupPickSel[key] = !m.groupPickSel[key]
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "enter":
|
case "enter":
|
||||||
var hosts []string
|
var refs []model.ServiceRef
|
||||||
for _, h := range m.groupHostsPickOrder {
|
for key, on := range m.groupPickSel {
|
||||||
if m.groupHostsPickSel[h] {
|
if !on {
|
||||||
hosts = append(hosts, h)
|
continue
|
||||||
}
|
}
|
||||||
|
host, svc, found := strings.Cut(key, "/")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refs = append(refs, model.ServiceRef{Host: host, Service: svc})
|
||||||
}
|
}
|
||||||
groupName := m.selectedGroup
|
groupName := m.selectedGroup
|
||||||
m.mutate("изменён список хостов группы "+groupName, func(st *model.State) error {
|
m.mutate("изменён список сервисов группы "+groupName, func(st *model.State) error {
|
||||||
g := st.FindGroup(groupName)
|
g := st.FindGroup(groupName)
|
||||||
if g == nil {
|
if g == nil {
|
||||||
return fmt.Errorf("group %s not found", groupName)
|
return fmt.Errorf("group %s not found", groupName)
|
||||||
}
|
}
|
||||||
g.Hosts = hosts
|
g.Services = refs
|
||||||
return resyncGroupUsers(st, groupName)
|
return resyncGroupUsers(st, groupName)
|
||||||
})
|
})
|
||||||
m.screen = scrGroups
|
m.screen = scrGroups
|
||||||
@@ -230,21 +274,39 @@ func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewGroupHostsPick() string {
|
func (m Model) viewGroupHostsPick() string {
|
||||||
body := fmt.Sprintf("Какие хосты каталога входят в группу %q\n\n", m.selectedGroup)
|
body := fmt.Sprintf("Какие сервисы каталога входят в группу %q\n\n", m.selectedGroup)
|
||||||
if len(m.groupHostsPickOrder) == 0 {
|
if len(m.groupPickRows) == 0 {
|
||||||
body += warnStyle.Render("В каталоге ещё нет хостов — сначала добавьте их в разделе «Хосты и сервисы».") + "\n"
|
body += warnStyle.Render("В каталоге ещё нет хостов — сначала добавьте их в разделе «Хосты и сервисы».") + "\n"
|
||||||
}
|
}
|
||||||
for i, h := range m.groupHostsPickOrder {
|
for i, row := range m.groupPickRows {
|
||||||
cursor := " "
|
cursor := " "
|
||||||
if i == m.groupHostsPickCursor {
|
if i == m.groupPickCursor {
|
||||||
cursor = "> "
|
cursor = "> "
|
||||||
}
|
}
|
||||||
|
if row.isHeader {
|
||||||
|
svcs := m.groupPickServices[row.host]
|
||||||
|
selected := 0
|
||||||
|
for _, svc := range svcs {
|
||||||
|
if m.groupPickSel[row.host+"/"+svc] {
|
||||||
|
selected++
|
||||||
|
}
|
||||||
|
}
|
||||||
check := "[ ]"
|
check := "[ ]"
|
||||||
if m.groupHostsPickSel[h] {
|
switch {
|
||||||
|
case len(svcs) > 0 && selected == len(svcs):
|
||||||
|
check = "[x]"
|
||||||
|
case selected > 0:
|
||||||
|
check = "[~]"
|
||||||
|
}
|
||||||
|
body += fmt.Sprintf("%s%s %s\n", cursor, check, row.host)
|
||||||
|
} else {
|
||||||
|
check := "[ ]"
|
||||||
|
if m.groupPickSel[row.host+"/"+row.service] {
|
||||||
check = "[x]"
|
check = "[x]"
|
||||||
}
|
}
|
||||||
body += fmt.Sprintf("%s%s %s\n", cursor, check, h)
|
body += fmt.Sprintf("%s %s %s\n", cursor, check, row.service)
|
||||||
}
|
}
|
||||||
body += "\n[space] отметить [enter] сохранить [esc] назад"
|
}
|
||||||
|
body += "\n[space] отметить (на хосте — все его сервисы) [enter] сохранить [esc] назад"
|
||||||
return borderStyle.Render(body)
|
return borderStyle.Render(body)
|
||||||
}
|
}
|
||||||
+53
-21
@@ -1,6 +1,8 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
@@ -9,8 +11,9 @@ func newMenuList() list.Model {
|
|||||||
items := []list.Item{
|
items := []list.Item{
|
||||||
simpleItem{id: "users", title: "Пользователи", desc: "Создать, заблокировать, удалить учётные записи"},
|
simpleItem{id: "users", title: "Пользователи", desc: "Создать, заблокировать, удалить учётные записи"},
|
||||||
simpleItem{id: "catalog", title: "Хосты и сервисы", desc: "Общий каталог: адреса и порты, один раз на всех, группы просто выбирают из него"},
|
simpleItem{id: "catalog", title: "Хосты и сервисы", desc: "Общий каталог: адреса и порты, один раз на всех, группы просто выбирают из него"},
|
||||||
simpleItem{id: "groups", title: "Группы", desc: "Какие хосты из каталога входят в каждую группу"},
|
simpleItem{id: "groups", title: "Группы", desc: "Какие сервисы из каталога входят в каждую группу"},
|
||||||
simpleItem{id: "export", title: "Экспорт архивов", desc: "Сгенерировать ключи и .7z архивы для доставки пользователям"},
|
simpleItem{id: "export", title: "Экспорт архивов", desc: "Сгенерировать ключи и .7z архивы для доставки пользователям"},
|
||||||
|
simpleItem{id: "configupdate", title: "Обновить конфиги", desc: "Перевыпустить только config (без ключа) для тех, у кого изменился доступ"},
|
||||||
simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"},
|
simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"},
|
||||||
simpleItem{id: "quit", title: "Выход", desc: ""},
|
simpleItem{id: "quit", title: "Выход", desc: ""},
|
||||||
}
|
}
|
||||||
@@ -39,6 +42,9 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "export":
|
case "export":
|
||||||
m.screen = scrExportSelect
|
m.screen = scrExportSelect
|
||||||
m.initExportSelect()
|
m.initExportSelect()
|
||||||
|
case "configupdate":
|
||||||
|
m.screen = scrConfigUpdateSelect
|
||||||
|
m.initConfigUpdateSelect()
|
||||||
case "audit":
|
case "audit":
|
||||||
m.screen = scrAudit
|
m.screen = scrAudit
|
||||||
m.refreshAudit()
|
m.refreshAudit()
|
||||||
@@ -55,34 +61,47 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viewMenu appends a live summary of pending follow-up work (users with no
|
||||||
|
// key yet, users whose config no longer matches their real access) below
|
||||||
|
// the menu, recomputed on every render — so it can never go stale itself.
|
||||||
func (m Model) viewMenu() string {
|
func (m Model) viewMenu() string {
|
||||||
return borderStyle.Render(m.menu.View())
|
body := m.menu.View()
|
||||||
|
neverExported, stale := m.quitWarnings()
|
||||||
|
if len(neverExported) == 0 && len(stale) == 0 {
|
||||||
|
return borderStyle.Render(body)
|
||||||
|
}
|
||||||
|
notice := ""
|
||||||
|
if len(neverExported) > 0 {
|
||||||
|
notice += warnStyle.Render(fmt.Sprintf("Без ключа: %d (см. «Экспорт архивов»)", len(neverExported))) + "\n"
|
||||||
|
}
|
||||||
|
if len(stale) > 0 {
|
||||||
|
notice += warnStyle.Render(fmt.Sprintf("Устаревший конфиг: %d (см. «Обновить конфиги»)", len(stale))) + "\n"
|
||||||
|
}
|
||||||
|
return borderStyle.Render(body + "\n" + notice)
|
||||||
}
|
}
|
||||||
|
|
||||||
// unexportedUsers returns active users who have never had a key issued
|
// quitWarnings splits active users into the two categories worth warning
|
||||||
// (empty PublicKey) — created but forgotten before "Экспорт архивов".
|
// about before quitting: those who never got a key at all, and those whose
|
||||||
func unexportedUsers(m *Model) []string {
|
// access changed since their last config (full export or config-only
|
||||||
|
// update) — the latter are running with an out-of-date client config that
|
||||||
|
// won't reach everything they're now allowed to.
|
||||||
|
func (m *Model) quitWarnings() (neverExported, stale []string) {
|
||||||
st := m.loadState()
|
st := m.loadState()
|
||||||
var out []string
|
return st.NeverExportedUsernames(), st.StaleConfigUsernames()
|
||||||
for _, u := range st.Users {
|
|
||||||
if u.PublicKey == "" {
|
|
||||||
out = append(out, u.Username)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// attemptQuit is the only path that actually quits from the menu ("q" or
|
// attemptQuit is the only path that actually quits from the menu ("q" or
|
||||||
// selecting "Выход"): if there are users created but never exported, it
|
// selecting "Выход"): if there are users created but never exported, or
|
||||||
// warns instead of quitting straight away, since it's easy to create a
|
// users whose config fell out of date, it warns instead of quitting
|
||||||
// batch of users, get interrupted, and forget the export step entirely.
|
// straight away — both are easy to lose track of mid-session. ctrl+c
|
||||||
// ctrl+c bypasses this deliberately, as an always-available hard exit.
|
// bypasses this deliberately, as an always-available hard exit.
|
||||||
func (m Model) attemptQuit() (tea.Model, tea.Cmd) {
|
func (m Model) attemptQuit() (tea.Model, tea.Cmd) {
|
||||||
pending := unexportedUsers(&m)
|
neverExported, stale := m.quitWarnings()
|
||||||
if len(pending) == 0 {
|
if len(neverExported) == 0 && len(stale) == 0 {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
m.quitPending = pending
|
m.quitPending = neverExported
|
||||||
|
m.quitStale = stale
|
||||||
m.quitReturnTo = scrMenu
|
m.quitReturnTo = scrMenu
|
||||||
m.screen = scrQuitConfirm
|
m.screen = scrQuitConfirm
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -102,11 +121,24 @@ func (m Model) updateQuitConfirm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewQuitConfirm() string {
|
func (m Model) viewQuitConfirm() string {
|
||||||
body := warnStyle.Render("Есть пользователи, для которых ещё не сгенерирован ключ (не были в «Экспорт архивов»):") + "\n\n"
|
body := ""
|
||||||
|
if len(m.quitPending) > 0 {
|
||||||
|
body += warnStyle.Render("Без выданного ключа (не были в «Экспорт архивов»):") + "\n"
|
||||||
for _, u := range m.quitPending {
|
for _, u := range m.quitPending {
|
||||||
body += " - " + u + "\n"
|
body += " - " + u + "\n"
|
||||||
}
|
}
|
||||||
body += "\nБез экспорта у них нет ключа и архива для передачи — доступ не работает.\n"
|
body += "\n"
|
||||||
|
}
|
||||||
|
if len(m.quitStale) > 0 {
|
||||||
|
body += warnStyle.Render("Доступ изменился, но конфиг у пользователя старый (см. «Обновить конфиги»):") + "\n"
|
||||||
|
for _, u := range m.quitStale {
|
||||||
|
body += " - " + u + "\n"
|
||||||
|
}
|
||||||
|
body += "\n"
|
||||||
|
}
|
||||||
|
body += "Без этого у части пользователей либо нет доступа вообще, либо их текущий\n"
|
||||||
|
body += "config не соответствует реальным правам (не хватает новых сервисов, или\n"
|
||||||
|
body += "остались лишние, которых уже нет).\n"
|
||||||
body += "\n[enter/y] выйти всё равно [esc/n] вернуться"
|
body += "\n[enter/y] выйти всё равно [esc/n] вернуться"
|
||||||
return borderStyle.Render(body)
|
return borderStyle.Render(body)
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m.runConfigUpdate(sel.id)
|
return m.runConfigUpdate([]string{sel.id})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
|||||||
Binary file not shown.
Reference in new issue
Block a user