add edit and all in one menu for hosts and services

This commit is contained in:
redlineo committed 2026-07-22 15:54:44 +03:00
1 parent 8c1b93c14e
commit e39ad275b5
15 files changed
+1136 -675

No files matched your search

+8 -5
View File
@@ -1,11 +1,14 @@
# ssh-access-tui
Консольный (TUI) инструмент для администрирования Linux jump-хоста: заводит
пользователей, у которых есть **только** проброс портов (`-L`/`-D`) и прыжок
(`ProxyJump`) к явно разрешённому списку внутренних хостов — без
интерактивного шелла. Управляет группами доступа, генерирует и раздаёт
зашифрованные архивы с ключом/конфигом/инструкцией, блокирует уволенных,
пишет журнал подключений и хранит всю конфигурацию в git для бэкапа/переезда.
пользователей, у которых есть **только** проброс портов (`LocalForward`,
одно соединение сразу открывает все разрешённые сервисы) к явно
разрешённому списку внутренних хостов — без интерактивного шелла. Хосты и
сервисы описываются один раз в общем каталоге, группы просто выбирают из
него. Генерирует и раздаёт зашифрованные архивы с ключом/конфигом/
инструкцией, блокирует уволенных, пишет журнал подключений, хранит всю
конфигурацию в git для бэкапа/переезда, и требует явное подтверждение
(ввод случайной строки) для любого деструктивного действия.
Почему не веб-панель: инструмент требует root (создание пользователей,
правка `authorized_keys` и `sshd_config`). Веб-бэкенд с такими правами —
+99 -81
View File
@@ -22,74 +22,85 @@ sudo ssh-access-tui
есть созданные, но ни разу не экспортированные пользователи (у них ещё нет
ключа) — можно выйти всё равно (`enter`/`y`) или вернуться (`esc`/`n`).
Любое деструктивное действие (блокировка/удаление пользователя, удаление
хоста или сервиса) сначала показывает отдельный экран подтверждения с
пояснением последствий — ничего не происходит до `enter`/`y`; `esc`/`n`
отменяет без каких-либо изменений.
**Любое деструктивное действие** (блокировка/удаление пользователя,
удаление хоста/сервиса/группы, перевыпуск уже выданного ключа) требует не
`enter`/`y`, а **набрать вручную короткую случайную строку**, которую
инструмент показывает на экране подтверждения (например `kqzvm-482`) — она
каждый раз новая. Неверный ввод просто откатывает к полю ввода с пояснением
«не совпадает», ничего не меняя; `esc` отменяет действие полностью в любой
момент. Это защита именно от «промахнулся мимо enter» — набрать чужую
случайную строку по ошибке невозможно.
## Главное меню
```
Пользователи — создать / заблокировать / разблокировать / удалить
Группы и хосты — какие внутренние хосты кому разрешены
Хосты и сервисы — общий каталог адресов и портов (один раз на всех)
Группы — какие хосты из каталога входят в каждую группу
Экспорт архивов — сгенерировать ключи и .7z для доставки
Аудит / кто в сети — активные сессии и журнал подключений
Выход
```
Имена групп, хостов и сервисов должны быть без пробелов (латиница, цифры,
Имена хостов, сервисов и групп должны быть без пробелов (латиница, цифры,
`. _ -`) — они попадают в конфиг и в `authorized_keys`; инструмент откажет с
понятной ошибкой, если ввести имя с пробелом.
## Модель: каталог → группы → пользователи
Хосты и их сервисы (порты) описываются **один раз** в общем каталоге
(«Хосты и сервисы»), а не заново для каждой группы. Группа — это просто
отметка галочками, какие хосты каталога в неё входят (все сервисы такого
хоста включаются целиком). Если у вас в разных группах фигурируют одни и те
же хосты/сервисы — их не нужно набивать повторно, только отметить нужные
для очередной группы. Пользователь состоит в одной или нескольких группах;
его реальный доступ — объединение сервисов всех хостов всех его групп.
## Сценарий 1: новый сотрудник, у которого уже есть роль/группа доступа
Обычно сначала настраивают группу (один раз), потом заводят пользователей в
неё пачками. Иерархия: **группа** → **хосты** → **сервисы (порты) на хосте**
— на одном хосте может быть несколько сервисов (например, `ssh` на 22 и
`web-api` на 7888), каждый добавляется отдельно.
Обычно каталог и группы настраивают заранее (или один раз добавляют то,
чего не хватает), потом заводят пользователей пачками.
1. **Группы и хосты** → `n` → ввести имя группы (например `team-billing`) →
`enter`.
2. Выбрать группу → `enter`, попасть в список хостов группы (изначально
пуст).
3. `n` → заполнить: имя хоста (короткое, например `db-prod-1`), адрес (как
виден с jump-хоста) → `tab` между полями → `enter`. Сразу после этого
попадаете в список сервисов этого хоста (тоже изначально пуст).
4. `n` → заполнить сервис: имя (например `ssh` или `web-api`), порт на
хосте (например `22` или `7888`), описание (необязательно) → `enter`.
Повторите `n` столько раз, сколько сервисов есть на этом хосте.
5. `esc` — назад к списку хостов группы; повторите шаги 3-4 для остальных
хостов группы, затем `esc` дважды — назад в главное меню.
1. **Хосты и сервисы** → `n` → имя хоста (например `db-prod-1`), адрес (как
виден с jump-хоста), описание (необязательно) → `enter`. Сразу
открывается список сервисов этого хоста (изначально пуст).
2. `n` → имя сервиса (например `ssh` или `web-api`), порт на хосте
(например `22` или `7888`), описание (необязательно) → `enter`. Повторите
`n` для каждого сервиса на этом хосте.
3. `esc` — назад к списку хостов; повторите шаги 1-2 для остальных хостов,
затем `esc` — в главное меню. Если хост/сервис уже существует и нужно
только поправить адрес/порт/описание — `e` вместо `n` на нужной записи
(можно поменять и имя — все группы, которые ссылались на старое имя,
автоматически обновятся).
4. **Группы** → `n` → имя группы (например `team-billing`), описание
(необязательно) → `enter`.
5. Выбрать группу → `enter` → отметить `space` нужные хосты из каталога →
`enter` сохранить. Список сервисов этих хостов достаётся группе целиком.
6. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин)
и комментарий (например ФИО) → `enter`.
7. Отметить `space` нужные группы (можно несколько — эффективный доступ
пользователя = объединение сервисов всех хостов всех отмеченных групп) →
`enter`.
7. Отметить `space` нужные группы (можно несколько) → `enter`.
Это уже создаёт системный Linux-аккаунт (без пароля, без интерактивного
шелла) и запись в состоянии. **Ключ ещё не выдан** — статус в списке
пользователей покажет «ключ не выдан».
8. **Экспорт архивов** → отметить `space` только что созданного пользователя
(или `a` — отметить всех, удобно при пакетном найме) → `enter`. Список
сразу показывает у каждого пользователя, будет ли выдан новый ключ или
существующий будет **перевыпущен** (актуально, если случайно попадёт в
выборку уже работающий пользователь).
9. Экран подтверждения: для тех, у кого ключ уже был, явно предупредит, что
старый архив перестанет работать — только после `enter`/`y` (либо `esc`/`n`
для отмены, тогда не создаётся и не меняется вообще ничего) инструмент
сгенерирует ключ, пропишет `permitopen=` под текущие сервисы группы и
соберёт `.7z`, показав **один общий пароль на всю партию** — он
показывается один раз, запишите/скопируйте сразу.
10. Передайте каждому пользователю его архив (файл, путь показан на экране,
лежит в `export_dir`) любым каналом, а пароль от архива — **отдельным**
каналом (голосом, в личку) и по возможности не всем одним и тем же
сообщением в общем чате.
11. Пользователь распаковывает архив на Windows по инструкции
`INSTRUCTIONS.txt` внутри архива — она уже на простом русском языке,
без технических терминов: просто список "локальный адрес → какой это
сервис → куда он реально ведёт", составленный под конкретные разрешённые
сервисы этого пользователя.
(или `a` — отметить всех, удобно при пакетном найме) → `enter`. Для
пользователей без ключа архив генерируется сразу, без лишних вопросов —
это не деструктивное действие. Инструмент сгенерирует ключ, пропишет
`permitopen=` под текущие сервисы группы и соберёт `.7z`, показав **один
общий пароль на всю партию** — он показывается один раз,
запишите/скопируйте сразу.
9. Передайте каждому пользователю его архив (файл, путь показан на экране,
лежит в `export_dir`) любым каналом, а пароль от архива — **отдельным**
каналом (голосом, в личку) и по возможности не всем одним и тем же
сообщением в общем чате.
10. Пользователь распаковывает архив на Windows по инструкции
`INSTRUCTIONS.txt` внутри архива — она на простом русском языке, без
технических терминов: положить файлы в папку, ввести одну команду и
держать окно открытым, дальше список «такой адрес у вас локально → что
это → где реально находится», составленный под конкретные сервисы этого
пользователя.
## Сценарий 1а: изменить группы уже существующего пользователя
@@ -100,42 +111,49 @@ sudo ssh-access-tui
(изменение групп никогда не ротирует ключ).
Если хотите, чтобы у пользователя появился ещё и обновлённый клиентский
`config` с новыми `LocalForward` для новых сервисов (иначе он про них
просто не узнает, хотя доступ уже есть) — **Пользователи** → выбрать
пользователя → `c` («обновить конфиг»). Это генерирует маленький архив
только с `config` + `INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже
есть и остаётся тем же), пользователю нужно только подменить файл `config`.
Не требует подтверждения — ключ и доступ этим действием не меняются, это не
`config` с новыми адресами для новых сервисов (иначе он про них просто не
узнает, хотя доступ уже есть) — **Пользователи** → выбрать пользователя →
`c` («обновить конфиг»). Это генерирует маленький архив только с `config` +
`INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже есть и остаётся тем
же), пользователю нужно только подменить файл `config`. Не требует
подтверждения — ключ и доступ этим действием не меняются, это не
деструктивно.
Полный экспорт (со сценария 1, шаги 8-9) тоже сработает и даст то же самое
плюс заново выпустит ключ — используйте `c`, когда переиздание ключа не
нужно (обычный случай при простом изменении групп).
Полный экспорт (сценарий 1, шаги 8-9) тоже сработает и даст то же самое
плюс заново выпустит ключ (с подтверждением строкой, раз это перевыпуск) —
используйте `c`, когда переиздание ключа не нужно.
## Сценарий 2: добавили новый сервис/хост существующей группе
## Сценарий 2: добавили новый сервис/хост, поменялся адрес или порт
1. **Группы и хосты** → выбрать группу → `enter`.
2. Либо `n` — новый хост (затем добавить ему хотя бы один сервис), либо
выбрать существующий хост → `enter` → `n` — новый сервис на нём.
- Новый сервис на существующем хосте: **Хосты и сервисы** → выбрать хост →
`enter` → `n`.
- Новый хост: **Хосты и сервисы** → `n`, затем добавить ему сервисы.
- Хост сменил адрес, сервис — порт, нужно поправить описание, или
переименовать: `e` на нужной записи. Переименование хоста автоматически
обновляет ссылки на него во всех группах, которые его используют — группы
не «отваливаются» молча.
Это сразу обновляет `permitopen=` в `authorized_keys` **всех активных
пользователей** этой группы — доступ появляется немедленно, без каких-либо
действий на стороне пользователя. Клиентский конфиг пользователя (его
`~/.ssh/config`) при этом не обновляется автоматически — если хотите, чтобы
у пользователя появилась готовая строка `LocalForward` для нового сервиса, а
не только "голый" доступ на сервере, используйте **Пользователи** → `c`
(«обновить конфиг») — ключ при этом не меняется (см. сценарий 1а).
Любое из этих изменений сразу обновляет `permitopen=` в `authorized_keys`
**всех активных пользователей всех групп**, ссылающихся на этот хост, —
доступ появляется немедленно, без каких-либо действий на стороне
пользователя. Клиентский конфиг пользователя (его `~/.ssh/config`) при этом
не обновляется автоматически — для этого используйте **Пользователи** → `c`
(«обновить конфиг», см. сценарий 1а).
Удаление сервиса или целого хоста (`d` в соответствующем списке) — оба
действия **деструктивны** (мгновенно отбирают доступ у всех пользователей
группы) и требуют подтверждения: экран покажет, что именно удаляется и
скольких пользователей это коснётся, `enter`/`y` подтверждает, `esc`/`n`
отменяет без каких-либо изменений.
**Группы** → выбрать группу → `enter` — там же можно снять/добавить
привязку группы к хостам каталога (не создавая и не удаляя сами хосты).
Удаление хоста из каталога, сервиса на хосте или целой группы (`d` в
соответствующем списке) — все три действия **деструктивны** (мгновенно
отбирают доступ у всех затронутых пользователей) и требуют набрать
случайную строку подтверждения: экран покажет, что именно удаляется,
сколько пользователей это коснётся, и в каких группах используется
(для хоста/сервиса).
## Сценарий 3: сотрудник уволился
1. **Пользователи** → выбрать пользователя → `l` (заблокировать) → экран
подтверждения объяснит, что произойдёт → `enter`/`y`.
1. **Пользователи** → выбрать пользователя → `l` (заблокировать) → набрать
строку подтверждения, которую покажет экран → `enter`.
Блокировка — это одновременно: блокирует пароль аккаунта, устанавливает
немедленный срок истечения аккаунта (`chage -E 1`) и комментирует его ключ в
@@ -148,10 +166,10 @@ sudo ssh-access-tui
требует, срабатывает сразу.
Полное удаление аккаунта (`d`) — **необратимо** (`userdel -r`, домашняя папка
стирается) и тоже требует подтверждения (экран явно предупредит, что это
необратимо и что для временного отключения лучше использовать блокировку).
Используйте удаление только для окончательного оффбординга, не для
временной блокировки.
стирается) и тоже требует подтверждения строкой (экран явно предупредит,
что это необратимо и что для временного отключения лучше использовать
блокировку). Используйте удаление только для окончательного оффбординга, не
для временной блокировки.
## Сценарий 4: проверить, кто сейчас подключён / посмотреть историю
@@ -171,12 +189,12 @@ sudo ssh-access-tui
## Сценарий 5: перенос на новый хост / хост умер
См. подробности в [ARCHITECTURE.md](ARCHITECTURE.md#бэкап-и-аварийное-восстановление).
Коротко: состояние (`groups`, `users`, публичные ключи, статусы) —
git-репозиторий, бэкапится часовым таймером в удалённый git. На новом хосте:
поднять `ssh-access-tui`, влить `state.yaml` из бэкапа, создать системные
Linux-аккаунты для активных пользователей, выпустить им новые ключи через
**Экспорт архивов** (старые приватные ключи специально нигде не хранятся —
это не баг, см. архитектурный документ).
Коротко: состояние (каталог хостов/сервисов, группы, пользователи,
публичные ключи, статусы) — git-репозиторий, бэкапится часовым таймером в
удалённый git. На новом хосте: поднять `ssh-access-tui`, влить `state.yaml`
из бэкапа, создать системные Linux-аккаунты для активных пользователей,
выпустить им новые ключи через **Экспорт архивов** (старые приватные ключи
специально нигде не хранятся — это не баг, см. архитектурный документ).
## Что не делает эта версия (нужно руками)
+50 -25
View File
@@ -19,15 +19,26 @@ TUI работает поверх уже существующего SSH+`sudo`-
реализовать вручную (см. «Конкурентность» ниже). Для небольшой команды
администраторов это разумный компромисс.
## Модель доступа: Host → Service → Forward
## Модель доступа: каталог хостов → группы → Forward
Хост (`Host`) — это адрес (например, `172.16.1.20`). На одном хосте может
быть несколько сервисов (`Service`) — независимых TCP-портов: `ssh` на 22,
какое-то приложение на 7888, и так далее. Группа (`Group`) — это набор
хостов (со всеми их сервисами), пользователь может состоять в нескольких
группах; его реальный эффективный доступ — объединение сервисов всех хостов
всех его групп, это и называется `Forward` (`internal/model/types.go`,
`State.AllowedForwards`).
Хосты и их сервисы — **общий каталог** (`State.Hosts`), определённый один
раз, а не заново в каждой группе. Хост (`Host`) — это адрес (например,
`172.16.1.20`) с описанием; на одном хосте может быть несколько сервисов
(`Service`) — независимых TCP-портов: `ssh` на 22, какое-то приложение на
7888, и так далее. Группа (`Group`) не хранит хосты сама — она хранит
только список **имён** хостов из каталога (`Group.Hosts []string`),
которые в неё входят (все сервисы такого хоста включаются целиком). Если
одни и те же хосты/сервисы нужны нескольким группам — они описываются один
раз в каталоге, а группы просто отмечают их галочками, вместо повторного
набора адресов и портов.
Пользователь может состоять в нескольких группах; его реальный эффективный
доступ — объединение сервисов всех хостов всех его групп, это и называется
`Forward` (`internal/model/types.go`, `State.AllowedForwards`). Переименование
хоста автоматически обновляет ссылки на него во всех группах
(`State.RenameHost`); удаление хоста снимает его со всех групп, которые его
использовали (`State.DeleteHost`) — ссылочная целостность каталога держится
в модели, а не проверяется вручную в UI.
Ключевой момент: пользователь **никогда не подключается по SSH к целевому
хосту напрямую** (ни через `ProxyJump`, ни как-то ещё) — он открывает одно
@@ -85,17 +96,19 @@ Reverse/remote forwarding (`-R`) в этой модели не выдаётся
Пользователю нужно пробросить сразу много сервисов за одно соединение
(например, 10 хостов, на каждом порт 22 и порт 7888 — 20 форвардов сразу).
Локально на его машине два разных удалённых назначения не могут занимать
один и тот же локальный порт одновременно, а несколько хостов с одинаковым
портом (22 или 7888 у всех десяти) — обычная ситуация. Поэтому локальный
порт **не всегда равен удалённому**: `internal/keymgr/portalloc.go`
(`AllocateLocalPorts`) для каждого форварда сначала пробует занять локальный
порт, совпадающий с удалённым (удобно и предсказуемо, когда порт встречается
один раз), а при конфликте — берёт следующий свободный из диапазона `20000+`.
Присвоение детерминировано (зависит только от порядка и состава форвардов
пользователя), поэтому повторный экспорт архива с тем же набором хостов даёт
те же локальные порты. Итоговое соответствие "локальный порт → сервис"
попадает в `INSTRUCTIONS.txt` внутри архива пользователя.
Локальный порт **никогда не равен удалённому порту** — не только чтобы
избежать коллизий между несколькими хостами с одинаковым портом (обычная
ситуация: 22 или 7888 у всех десяти), но и потому, что многие удалённые
порты привилегированные (`< 1024`, в первую очередь сам ssh на 22) —
слушателя на таком порту локально можно открыть только с правами
администратора, которых у обычного пользователя на его Windows-машине нет.
`internal/keymgr/portalloc.go` (`AllocateLocalPorts`) поэтому просто выдаёт
локальные порты последовательно из диапазона `20000+`, без попытки
переиспользовать номер удалённого порта. Присвоение детерминировано
(зависит только от порядка и состава форвардов пользователя), поэтому
повторный экспорт архива с тем же набором хостов даёт те же локальные
порты. Итоговое соответствие "локальный порт → сервис" попадает в
`INSTRUCTIONS.txt` внутри архива пользователя.
## Жизненный цикл приватного ключа
@@ -115,6 +128,19 @@ fingerprint. Это осознанное решение: кража бэкапа
ключей, выпущенных в конкретной партии, — передавайте его получателям
отдельным от архива каналом (голосом, в личку, не в общий чат).
## Подтверждение деструктивных действий
Блокировка/удаление пользователя, удаление хоста/сервиса/группы и
перевыпуск уже выданного ключа (экспорт поверх существующего) — все эти
действия либо необратимы, либо мгновенно и широко режут доступ, поэтому
подтверждаются не клавишей (`enter`/`y`, которую легко нажать рефлекторно),
а вводом заново сгенерированной случайной строки (`internal/ui/confirm.go`,
`generateConfirmToken` — 5 латинских букв, дефис, 3 цифры). Неверный ввод
просто сбрасывает поле с пояснением и оставляет на этом же экране; выйти без
последствий можно только через `esc`. Выдача **нового** ключа пользователю,
у которого его ещё не было, ничего не ломает и подтверждения не требует —
трение нужно только там, где есть что сломать.
## Бэкап и аварийное восстановление
Каталог состояния (`/var/lib/ssh-access-tui/state`) — git-репозиторий.
@@ -172,12 +198,11 @@ session" вместо тихой порчи данных. Если реальн
## Известные ограничения / что дальше
- Обновление клиентского конфига пользователя при появлении **нового**
разрешённого хоста в его группе требует повторного экспорта архива
(что заодно ротирует ключ пользователя — так и задумано: ротация ключа
время от времени — отдельная хорошая практика, просто в MVP она не
отделена от "обновить конфиг"). Удаление хоста из группы вступает в силу
немедленно на сервере (permitopen), без участия клиента.
- Обновление клиентского конфига пользователя при появлении нового
сервиса/хоста в его группе — отдельный ручной шаг («обновить конфиг»,
не требует переиздания ключа); права доступа на сервере (`permitopen=`)
обновляются немедленно и автоматически в любом случае, независимо от
того, обновил ли пользователь свой локальный `config`.
- Архивы в `export_dir` не удаляются автоматически после доставки —
администратор должен удалить их вручную, когда получатель подтвердил
получение.
+6 -21
View File
@@ -28,29 +28,14 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
b.WriteString(" - config — настройка подключения\n")
b.WriteString(" - INSTRUCTIONS.txt — этот файл\n\n")
b.WriteString("ШАГ 1. Проверьте, что на компьютере есть SSH\n")
b.WriteString("--------------------------------------------\n")
b.WriteString("Откройте программу PowerShell (найдите её через Пуск) и введите:\n\n")
b.WriteString(" ssh -V\n\n")
b.WriteString("Если появилась ошибка — зайдите в Параметры Windows -> Приложения ->\n")
b.WriteString("Дополнительные компоненты -> Добавить компонент -> найдите «OpenSSH Client»\n")
b.WriteString("-> Установить.\n\n")
b.WriteString("ШАГ 2. Разложите файлы по нужной папке\n")
b.WriteString("ШАГ 1. Разложите файлы по нужной папке\n")
b.WriteString("---------------------------------------\n")
b.WriteString("Создайте (если её ещё нет) папку:\n\n")
b.WriteString(" C:\\Users\\ВАШЕ_ИМЯ\\.ssh\\\n\n")
fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName)
b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\n\n")
b.WriteString("ШАГ 3. Настройте доступ к ключу\n")
b.WriteString("---------------------------------\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("ШАГ 4. Подключитесь\n")
b.WriteString("ШАГ 2. Подключитесь\n")
b.WriteString("---------------------\n")
b.WriteString("В PowerShell введите команду:\n\n")
b.WriteString(" ssh -N jump\n\n")
@@ -59,12 +44,12 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
b.WriteString("таблицы ниже. Закрыли окно (или выключили компьютер) — доступ пропал, просто\n")
b.WriteString("откройте заново командой выше, когда снова понадобится.\n\n")
b.WriteString("ШАГ 5. Пользуйтесь\n")
b.WriteString("ШАГ 3. Пользуйтесь\n")
b.WriteString("--------------------\n")
b.WriteString("Эта настройка открывает у вас на компьютере (локально) следующие адреса.\n")
b.WriteString("В программе, которая должна подключиться к сервису (браузер, клиент базы\n")
b.WriteString("данных и т.п.), укажите вместо настоящего адреса тот, что указан ниже как\n")
b.WriteString("«localhost:порт» — и она подключится к нужному сервису через это соединение.\n\n")
b.WriteString("«127.0.0.1:порт» — и она подключится к нужному сервису через это соединение.\n\n")
// A numbered list rather than an aligned table: this file is opened in
// Notepad with whatever font the user has, so column alignment based on
@@ -75,7 +60,7 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
if f.Description != "" {
what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description)
}
fmt.Fprintf(&b, " %d. localhost:%d\n", i+1, f.LocalPort)
fmt.Fprintf(&b, " %d. 127.0.0.1:%d\n", i+1, f.LocalPort)
fmt.Fprintf(&b, " Это: %s\n", what)
fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port)
}
@@ -118,7 +103,7 @@ func RenderConfigUpdateInstructions(username string, forwards []AllocatedForward
if f.Description != "" {
what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description)
}
fmt.Fprintf(&b, " %d. localhost:%d\n", i+1, f.LocalPort)
fmt.Fprintf(&b, " %d. 127.0.0.1:%d\n", i+1, f.LocalPort)
fmt.Fprintf(&b, " Это: %s\n", what)
fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port)
}
+15 -25
View File
@@ -9,39 +9,29 @@ type AllocatedForward struct {
LocalPort int
}
// localPortBase is where fallback local ports are handed out from once a
// forward's natural remote port is already taken locally by another
// forward. Chosen high enough to avoid colliding with well-known ports the
// user's own machine might already be using.
// localPortBase is where every local port is handed out from. Forwards are
// never given a local port equal to their remote port: many remote ports
// worth forwarding are privileged (< 1024 — ssh's own 22 above all), and
// binding a listener on a privileged local port requires admin/root rights
// the end user does not have and should not need. Even for unprivileged
// remote ports, always starting from a fixed high range keeps assignment
// simple and collision-free without a "does this port happen to be free
// locally" special case.
const localPortBase = 20000
// AllocateLocalPorts assigns every forward a local port, all of which the
// user opens at once over a single ssh connection (one LocalForward line
// per forward — see RenderClientConfig). This is the actual answer to
// "avoid collisions when forwarding everything simultaneously": many hosts
// commonly expose the same remote port (e.g. 22, or an app port like 7888
// repeated across ten hosts), but only one of them can bind to that same
// port number locally. Each forward keeps its remote port as the local
// port when that's still free (so the common case reads naturally, e.g.
// remote 7888 -> local 7888), and falls back to the next free port from
// localPortBase upward on collision. Assignment is a pure function of the
// input order and set, so re-exporting an unchanged host list reproduces
// the same local ports.
// per forward — see RenderClientConfig). Ports are handed out sequentially
// from localPortBase, so many forwards sharing the same remote port (e.g.
// port 22 repeated across ten hosts) each still get a distinct, unprivileged
// local port. Assignment is a pure function of the input order, so
// re-exporting an unchanged host list reproduces the same local ports.
func AllocateLocalPorts(forwards []model.Forward) []AllocatedForward {
used := make(map[int]bool, len(forwards))
next := localPortBase
out := make([]AllocatedForward, 0, len(forwards))
for _, f := range forwards {
lp := f.Port
if lp <= 0 || lp > 65535 || used[lp] {
for used[next] {
next++
}
lp = next
next++
}
used[lp] = true
out = append(out, AllocatedForward{Forward: f, LocalPort: lp})
out = append(out, AllocatedForward{Forward: f, LocalPort: next})
next++
}
return out
}
+157 -19
View File
@@ -1,6 +1,10 @@
// Package model defines the data types persisted as the tool's state:
// hosts (each offering one or more TCP services) grouped into access
// groups, and users assigned to those groups.
// Package model defines the data types persisted as the tool's state.
//
// Hosts (each offering one or more TCP services) form a shared catalog,
// defined once; groups reference a subset of that catalog by name rather
// than redefining hosts/services per group — this matters when the same
// hosts/services are reused across many groups; and users are assigned to
// one or more groups.
package model
import (
@@ -30,20 +34,25 @@ type Service struct {
Description string `yaml:"description,omitempty"`
}
// Host is one jump-target reachable via port forwarding through the
// bastion, identified by address and offering one or more Services.
// Host is a catalog entry: one jump-target reachable via port forwarding
// through the bastion, defined once and referenced by any number of groups
// (see Group.Hosts) — hosts/services shared across many groups only need to
// be entered a single time.
type Host struct {
Name string `yaml:"name"` // short identifier, e.g. "db-prod-1"
Address string `yaml:"address"` // hostname or IP as seen from the jump host
Services []Service `yaml:"services"`
Name string `yaml:"name"` // short identifier, e.g. "db-prod-1"
Address string `yaml:"address"` // hostname or IP as seen from the jump host
Description string `yaml:"description,omitempty"`
Services []Service `yaml:"services"`
}
// Group bundles hosts that share the same access policy (e.g. "team-billing").
// Users are assigned to one or more groups; their effective allowed
// forwards are the union of all services on all hosts in their groups.
// Group is a named access policy referencing a subset of catalog hosts (by
// name — all of that host's services are included). Users are assigned to
// one or more groups; their effective allowed forwards are the union of
// services on all hosts referenced by all their groups.
type Group struct {
Name string `yaml:"name"`
Hosts []Host `yaml:"hosts"`
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
Hosts []string `yaml:"hosts"` // names referencing State.Hosts
}
// UserStatus is the lifecycle state of a managed account.
@@ -68,10 +77,21 @@ type User struct {
// State is the full persisted configuration of the tool.
type State struct {
Hosts []Host `yaml:"hosts"` // shared catalog
Groups []Group `yaml:"groups"`
Users []User `yaml:"users"`
}
// FindHost returns a pointer to the catalog host with the given name, or nil.
func (s *State) FindHost(name string) *Host {
for i := range s.Hosts {
if s.Hosts[i].Name == name {
return &s.Hosts[i]
}
}
return nil
}
// FindGroup returns a pointer to the group with the given name, or nil.
func (s *State) FindGroup(name string) *Group {
for i := range s.Groups {
@@ -105,11 +125,9 @@ type Forward struct {
}
// AllowedForwards returns the de-duplicated union of (host, service) pairs
// across all groups the user belongs to, in stable encounter order (group
// order, then host order, then service order, as defined in state.yaml).
// Two services that resolve to the same address:port (even from different
// groups) collapse to a single Forward — forwarding the same destination
// twice would be meaningless.
// across all catalog hosts referenced by all of the user's groups, in
// stable encounter order. Two services that resolve to the same
// address:port (even from different groups) collapse to a single Forward.
func (s *State) AllowedForwards(u *User) []Forward {
seen := map[string]bool{}
var out []Forward
@@ -118,7 +136,11 @@ func (s *State) AllowedForwards(u *User) []Forward {
if g == nil {
continue
}
for _, h := range g.Hosts {
for _, hostName := range g.Hosts {
h := s.FindHost(hostName)
if h == nil {
continue
}
for _, svc := range h.Services {
key := fmt.Sprintf("%s:%d", h.Address, svc.Port)
if seen[key] {
@@ -137,3 +159,119 @@ func (s *State) AllowedForwards(u *User) []Forward {
}
return out
}
// GroupNamesReferencingHost returns every group that includes hostName.
func (s *State) GroupNamesReferencingHost(hostName string) []string {
var out []string
for _, g := range s.Groups {
for _, h := range g.Hosts {
if h == hostName {
out = append(out, g.Name)
break
}
}
}
return out
}
// UsernamesInGroup returns every user currently a member of groupName.
func (s *State) UsernamesInGroup(groupName string) []string {
var out []string
for _, u := range s.Users {
for _, g := range u.Groups {
if g == groupName {
out = append(out, u.Username)
break
}
}
}
return out
}
// UsernamesUsingHost returns every user reachable transitively through any
// group that references hostName (deduplicated).
func (s *State) UsernamesUsingHost(hostName string) []string {
seen := map[string]bool{}
var out []string
for _, gname := range s.GroupNamesReferencingHost(hostName) {
for _, uname := range s.UsernamesInGroup(gname) {
if !seen[uname] {
seen[uname] = true
out = append(out, uname)
}
}
}
return out
}
// RenameGroup renames a group and updates every user's membership reference
// so nobody silently falls out of a group just because it was renamed.
func (s *State) RenameGroup(oldName, newName string) {
if g := s.FindGroup(oldName); g != nil {
g.Name = newName
}
for i := range s.Users {
for j, gn := range s.Users[i].Groups {
if gn == oldName {
s.Users[i].Groups[j] = newName
}
}
}
}
// DeleteGroup removes a group and drops the membership reference from every
// user who had it. Caller is responsible for resyncing authorized_keys for
// any affected user (see UsernamesInGroup, called before this).
func (s *State) DeleteGroup(name string) {
for i := range s.Groups {
if s.Groups[i].Name == name {
s.Groups = append(s.Groups[:i], s.Groups[i+1:]...)
break
}
}
for i := range s.Users {
var kept []string
for _, gn := range s.Users[i].Groups {
if gn != name {
kept = append(kept, gn)
}
}
s.Users[i].Groups = kept
}
}
// RenameHost renames a catalog host and updates every group's reference to
// it, so a rename doesn't silently drop the host out of groups using it.
func (s *State) RenameHost(oldName, newName string) {
if h := s.FindHost(oldName); h != nil {
h.Name = newName
}
for i := range s.Groups {
for j, hn := range s.Groups[i].Hosts {
if hn == oldName {
s.Groups[i].Hosts[j] = newName
}
}
}
}
// DeleteHost removes a host from the catalog and from every group that
// referenced it. Caller is responsible for resyncing authorized_keys for
// any affected user (see UsernamesUsingHost, called before this).
func (s *State) DeleteHost(name string) {
for i := range s.Hosts {
if s.Hosts[i].Name == name {
s.Hosts = append(s.Hosts[:i], s.Hosts[i+1:]...)
break
}
}
for i := range s.Groups {
var kept []string
for _, hn := range s.Groups[i].Hosts {
if hn != name {
kept = append(kept, hn)
}
}
s.Groups[i].Hosts = kept
}
}
+89 -60
View File
@@ -29,13 +29,14 @@ const (
scrUsers
scrUserNew
scrUserNewGroups
scrCatalogHosts
scrHostForm
scrCatalogServices
scrServiceForm
scrGroups
scrGroupHosts
scrHostNew
scrHostServices
scrServiceNew
scrGroupForm
scrGroupHostsPick
scrExportSelect
scrExportConfirm
scrExportResult
scrConfigUpdateResult
scrAudit
@@ -54,17 +55,37 @@ const (
confirmDeleteUser
confirmDeleteHost
confirmDeleteService
confirmDeleteGroup
confirmExportRotate
)
// Colors are deliberately ANSI-native (0-15) rather than fixed 256-color
// codes: those map to whatever palette the user's own terminal is themed
// with, so they stay legible in both light and dark terminals. The bubbles
// list widget's default title style paints a fixed 256-color background
// ("62", blue-purple) with near-white text, which reads badly in many
// terminal themes — newList below overrides it with a plain bold
// foreground and no background fill for the same reason.
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Padding(0, 1)
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("5")).Padding(0, 1)
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("8")).Padding(1, 2)
listTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")).Padding(0, 1)
)
// newList builds a list.Model with the corrected (readable) title style
// applied, so every screen gets it instead of bubbles' low-contrast default.
func newList(items []list.Item, title string, w, h int) list.Model {
l := list.New(items, list.NewDefaultDelegate(), w, h)
l.Title = title
l.Styles.Title = listTitleStyle
l.SetShowStatusBar(false)
return l
}
// Model is the root bubbletea model for the whole application.
type Model struct {
cfg config.Config
@@ -86,23 +107,31 @@ type Model struct {
userNewCursor int
userGroupsEditTarget string // non-empty: editing this existing user's groups instead of creating a new user
groupsList list.Model
groupNewName textinput.Model
awaitingGroup bool
selectedGroup string
hostsList list.Model
hostForm [2]textinput.Model // name, address
hostFormFocus int
// catalog: hosts and their services, shared across groups
catalogHostsList list.Model
hostForm [3]textinput.Model // name, address, description
hostFormFocus int
hostFormEditTarget string // "" = creating a new host
selectedHost string // catalog host currently open in scrCatalogServices/scrServiceForm
catalogServicesList list.Model
serviceForm [3]textinput.Model // name, port, description
serviceFormFocus int
serviceFormEditTarget string // "" = creating a new service
selectedHost string
servicesList list.Model
serviceForm [3]textinput.Model // name, port, description
serviceFormFocus int
// groups: named policies that reference a subset of catalog hosts
groupsList list.Model
groupForm [2]textinput.Model // name, description
groupFormFocus int
groupFormEditTarget string // "" = creating a new group
selectedGroup string
groupHostsPickSel map[string]bool
groupHostsPickOrder []string
groupHostsPickCursor int
exportSel map[string]bool
exportOrder []string
exportCursor int
exportChosen []string // confirmed selection, pending scrExportConfirm -> runExport
exportChosen []string // selection pending confirmation (if any rotation) or already run
exportRes []keymgr.BuildResult
exportPass string
exportErr string
@@ -114,14 +143,17 @@ type Model struct {
quitPending []string // usernames with no key issued yet, shown on scrQuitConfirm
// generic confirmation for destructive actions (lock/delete user,
// delete host/service) — see confirm.go. confirmTarget holds the
// username/host/service name being acted on; for host/service deletion
// the group (and host, for a service) context is the already-present
// selectedGroup/selectedHost fields above.
// delete host/service/group, export key rotation) — see confirm.go.
// Confirming requires typing back a freshly generated token, not just
// pressing enter/y, so a destructive action can never fire from a
// reflexive keypress.
pendingConfirm confirmKind
confirmTarget string
confirmMessage string
confirmReturnTo screen
confirmToken string
confirmInput textinput.Model
confirmError string
configUpdateUser string
configUpdateArchive string
@@ -140,20 +172,13 @@ type Model struct {
func New(cfg config.Config, st *store.Store) Model {
m := Model{cfg: cfg, st: st}
m.menu = newMenuList()
m.usersList = emptyList("Пользователи")
m.groupsList = emptyList("Группы доступа")
m.hostsList = emptyList("Хосты")
m.servicesList = emptyList("Сервисы")
m.usersList = newList(nil, "Пользователи", 0, 0)
m.catalogHostsList = newList(nil, "Хосты и сервисы", 0, 0)
m.catalogServicesList = newList(nil, "Сервисы", 0, 0)
m.groupsList = newList(nil, "Группы", 0, 0)
return m
}
func emptyList(title string) list.Model {
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.Title = title
l.SetShowStatusBar(false)
return l
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) loadState() *model.State {
@@ -195,9 +220,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
listW, listH := msg.Width-4, msg.Height-8
m.menu.SetSize(listW, listH)
m.usersList.SetSize(listW, listH)
m.catalogHostsList.SetSize(listW, listH)
m.catalogServicesList.SetSize(listW, listH)
m.groupsList.SetSize(listW, listH)
m.hostsList.SetSize(listW, listH)
m.servicesList.SetSize(listW, listH)
return m, nil
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
@@ -214,20 +239,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateUserNew(msg)
case scrUserNewGroups:
return m.updateUserNewGroups(msg)
case scrCatalogHosts:
return m.updateCatalogHosts(msg)
case scrHostForm:
return m.updateHostForm(msg)
case scrCatalogServices:
return m.updateCatalogServices(msg)
case scrServiceForm:
return m.updateServiceForm(msg)
case scrGroups:
return m.updateGroups(msg)
case scrGroupHosts:
return m.updateGroupHosts(msg)
case scrHostNew:
return m.updateHostNew(msg)
case scrHostServices:
return m.updateHostServices(msg)
case scrServiceNew:
return m.updateServiceNew(msg)
case scrGroupForm:
return m.updateGroupForm(msg)
case scrGroupHostsPick:
return m.updateGroupHostsPick(msg)
case scrExportSelect:
return m.updateExportSelect(msg)
case scrExportConfirm:
return m.updateExportConfirm(msg)
case scrExportResult:
return m.updateExportResult(msg)
case scrConfigUpdateResult:
@@ -253,20 +280,22 @@ func (m Model) View() string {
body = m.viewUserNew()
case scrUserNewGroups:
body = m.viewUserNewGroups()
case scrCatalogHosts:
body = m.viewCatalogHosts()
case scrHostForm:
body = m.viewHostForm()
case scrCatalogServices:
body = m.viewCatalogServices()
case scrServiceForm:
body = m.viewServiceForm()
case scrGroups:
body = m.viewGroups()
case scrGroupHosts:
body = m.viewGroupHosts()
case scrHostNew:
body = m.viewHostNew()
case scrHostServices:
body = m.viewHostServices()
case scrServiceNew:
body = m.viewServiceNew()
case scrGroupForm:
body = m.viewGroupForm()
case scrGroupHostsPick:
body = m.viewGroupHostsPick()
case scrExportSelect:
body = m.viewExportSelect()
case scrExportConfirm:
body = m.viewExportConfirm()
case scrExportResult:
body = m.viewExportResult()
case scrConfigUpdateResult:
+364
View File
@@ -0,0 +1,364 @@
package ui
import (
"fmt"
"strconv"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/model"
)
// validNameErr renders a consistent error for group/host/service names that
// fail model.ValidName — these names end up as ssh_config/authorized_keys
// tokens, where spaces or "#" would corrupt the generated file.
func validNameErr(kind, name string) string {
return fmt.Sprintf("%s %q: имя не должно содержать пробелов и спецсимволов (разрешены латинские буквы, цифры, точка, подчёркивание, дефис)", kind, name)
}
// --- catalog: hosts ---
func (m *Model) refreshCatalogHostsList() {
st := m.loadState()
items := make([]list.Item, 0, len(st.Hosts))
for _, h := range st.Hosts {
desc := fmt.Sprintf("%s — %d сервисов", h.Address, len(h.Services))
if h.Description != "" {
desc += " (" + h.Description + ")"
}
items = append(items, simpleItem{id: h.Name, title: h.Name, desc: desc})
}
m.catalogHostsList = newList(items,
"Хосты и сервисы [n]новый [e]изменить [enter]сервисы [d]удалить [esc]назад",
m.catalogHostsList.Width(), m.catalogHostsList.Height())
}
func (m Model) updateCatalogHosts(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 "n":
m.hostFormEditTarget = ""
for i := range m.hostForm {
m.hostForm[i] = textinput.New()
}
m.hostForm[0].Placeholder = "короткое имя, например db-prod-1 (без пробелов)"
m.hostForm[1].Placeholder = "адрес (hostname или IP), как виден с jump-хоста"
m.hostForm[2].Placeholder = "описание (необязательно)"
m.hostFormFocus = 0
m.hostForm[0].Focus()
m.err = ""
m.screen = scrHostForm
return m, nil
case "e":
sel, ok := m.catalogHostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
h := st.FindHost(sel.id)
if h == nil {
return m, nil
}
m.hostFormEditTarget = h.Name
for i := range m.hostForm {
m.hostForm[i] = textinput.New()
}
m.hostForm[0].SetValue(h.Name)
m.hostForm[1].SetValue(h.Address)
m.hostForm[2].SetValue(h.Description)
m.hostFormFocus = 0
m.hostForm[0].Focus()
m.err = ""
m.screen = scrHostForm
return m, nil
case "enter":
sel, ok := m.catalogHostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.selectedHost = sel.id
m.screen = scrCatalogServices
m.refreshCatalogServicesList()
return m, nil
case "d":
sel, ok := m.catalogHostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
affected := st.UsernamesUsingHost(sel.id)
groups := st.GroupNamesReferencingHost(sel.id)
msg := fmt.Sprintf(
"Удалить хост %q из каталога?\n\n"+
"Он используется в группах: %v.\n"+
"Это затронет пользователей: %d — они немедленно потеряют доступ ко всем\n"+
"сервисам этого хоста.",
sel.id, groups, len(affected))
m = m.startConfirm(confirmDeleteHost, sel.id, msg, scrCatalogHosts)
return m, nil
}
}
var cmd tea.Cmd
m.catalogHostsList, cmd = m.catalogHostsList.Update(msg)
return m, cmd
}
func (m Model) viewCatalogHosts() string {
return borderStyle.Render(m.catalogHostsList.View())
}
func (m Model) updateHostForm(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrCatalogHosts
return m, nil
case "tab", "down":
m.hostForm[m.hostFormFocus].Blur()
m.hostFormFocus = (m.hostFormFocus + 1) % len(m.hostForm)
m.hostForm[m.hostFormFocus].Focus()
return m, nil
case "enter":
name := m.hostForm[0].Value()
addr := m.hostForm[1].Value()
desc := m.hostForm[2].Value()
if name == "" || addr == "" {
m.err = "имя и адрес обязательны"
return m, nil
}
if !model.ValidName(name) {
m.err = validNameErr("хост", name)
return m, nil
}
target := m.hostFormEditTarget
m.err = ""
if target == "" {
m.mutate("добавлен хост "+name, func(st *model.State) error {
if st.FindHost(name) != nil {
return fmt.Errorf("хост %q уже существует", name)
}
st.Hosts = append(st.Hosts, model.Host{Name: name, Address: addr, Description: desc})
return nil
})
} else {
m.mutate("изменён хост "+target, func(st *model.State) error {
if name != target && st.FindHost(name) != nil {
return fmt.Errorf("хост %q уже существует", name)
}
if name != target {
st.RenameHost(target, name)
}
h := st.FindHost(name)
if h == nil {
return fmt.Errorf("host %s not found", name)
}
h.Address = addr
h.Description = desc
return resyncHostUsers(st, name)
})
}
m.selectedHost = name
m.screen = scrCatalogHosts
m.refreshCatalogHostsList()
return m, nil
}
}
var cmd tea.Cmd
m.hostForm[m.hostFormFocus], cmd = m.hostForm[m.hostFormFocus].Update(msg)
return m, cmd
}
func (m Model) viewHostForm() string {
title := "Новый хост в каталоге"
if m.hostFormEditTarget != "" {
title = fmt.Sprintf("Изменить хост %q", m.hostFormEditTarget)
}
body := fmt.Sprintf("%s\n\nИмя: %s\nАдрес: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
title, m.hostForm[0].View(), m.hostForm[1].View(), m.hostForm[2].View())
return borderStyle.Render(body)
}
// --- catalog: services within the selected host ---
func (m *Model) refreshCatalogServicesList() {
st := m.loadState()
h := st.FindHost(m.selectedHost)
var items []list.Item
if h != nil {
for _, svc := range h.Services {
items = append(items, simpleItem{
id: svc.Name,
title: fmt.Sprintf("%s (порт %d)", svc.Name, svc.Port),
desc: svc.Description,
})
}
}
m.catalogServicesList = newList(items,
fmt.Sprintf("Сервисы хоста %q [n]добавить [e]изменить [d]удалить [esc]назад", m.selectedHost),
m.catalogServicesList.Width(), m.catalogServicesList.Height())
}
func (m Model) updateCatalogServices(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrCatalogHosts
m.refreshCatalogHostsList()
return m, nil
case "n":
m.serviceFormEditTarget = ""
for i := range m.serviceForm {
m.serviceForm[i] = textinput.New()
}
m.serviceForm[0].Placeholder = "имя сервиса, например ssh или web-api (без пробелов)"
m.serviceForm[1].Placeholder = "порт на хосте, например 22 или 7888"
m.serviceForm[2].Placeholder = "описание (необязательно)"
m.serviceFormFocus = 0
m.serviceForm[0].Focus()
m.err = ""
m.screen = scrServiceForm
return m, nil
case "e":
sel, ok := m.catalogServicesList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
h := st.FindHost(m.selectedHost)
if h == nil {
return m, nil
}
var target *model.Service
for i := range h.Services {
if h.Services[i].Name == sel.id {
target = &h.Services[i]
break
}
}
if target == nil {
return m, nil
}
m.serviceFormEditTarget = target.Name
for i := range m.serviceForm {
m.serviceForm[i] = textinput.New()
}
m.serviceForm[0].SetValue(target.Name)
m.serviceForm[1].SetValue(strconv.Itoa(target.Port))
m.serviceForm[2].SetValue(target.Description)
m.serviceFormFocus = 0
m.serviceForm[0].Focus()
m.err = ""
m.screen = scrServiceForm
return m, nil
case "d":
sel, ok := m.catalogServicesList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
affected := st.UsernamesUsingHost(m.selectedHost)
msg := fmt.Sprintf(
"Удалить сервис %q у хоста %q?\n\n"+
"Это затронет пользователей: %d — они немедленно потеряют доступ к этому\n"+
"сервису.",
sel.id, m.selectedHost, len(affected))
m = m.startConfirm(confirmDeleteService, sel.id, msg, scrCatalogServices)
return m, nil
}
}
var cmd tea.Cmd
m.catalogServicesList, cmd = m.catalogServicesList.Update(msg)
return m, cmd
}
func (m Model) viewCatalogServices() string {
return borderStyle.Render(m.catalogServicesList.View())
}
func (m Model) updateServiceForm(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrCatalogServices
return m, nil
case "tab", "down":
m.serviceForm[m.serviceFormFocus].Blur()
m.serviceFormFocus = (m.serviceFormFocus + 1) % len(m.serviceForm)
m.serviceForm[m.serviceFormFocus].Focus()
return m, nil
case "enter":
name := m.serviceForm[0].Value()
portStr := m.serviceForm[1].Value()
desc := m.serviceForm[2].Value()
if name == "" || portStr == "" {
m.err = "имя и порт обязательны"
return m, nil
}
if !model.ValidName(name) {
m.err = validNameErr("сервис", name)
return m, nil
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
m.err = "порт должен быть числом от 1 до 65535"
return m, nil
}
hostName := m.selectedHost
target := m.serviceFormEditTarget
m.err = ""
m.mutate(fmt.Sprintf("сохранён сервис %s хоста %s", name, hostName), func(st *model.State) error {
h := st.FindHost(hostName)
if h == nil {
return fmt.Errorf("host %s not found", hostName)
}
if target == "" {
for _, svc := range h.Services {
if svc.Name == name {
return fmt.Errorf("сервис %q уже существует на этом хосте", name)
}
}
h.Services = append(h.Services, model.Service{Name: name, Port: port, Description: desc})
} else {
if name != target {
for _, svc := range h.Services {
if svc.Name == name {
return fmt.Errorf("сервис %q уже существует на этом хосте", name)
}
}
}
for i := range h.Services {
if h.Services[i].Name == target {
h.Services[i].Name = name
h.Services[i].Port = port
h.Services[i].Description = desc
break
}
}
}
return resyncHostUsers(st, hostName)
})
m.screen = scrCatalogServices
m.refreshCatalogServicesList()
return m, nil
}
}
var cmd tea.Cmd
m.serviceForm[m.serviceFormFocus], cmd = m.serviceForm[m.serviceFormFocus].Update(msg)
return m, cmd
}
func (m Model) viewServiceForm() string {
title := fmt.Sprintf("Новый сервис на хосте %q", m.selectedHost)
if m.serviceFormEditTarget != "" {
title = fmt.Sprintf("Изменить сервис %q на хосте %q", m.serviceFormEditTarget, m.selectedHost)
}
body := fmt.Sprintf("%s\n\nИмя: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
title, m.serviceForm[0].View(), m.serviceForm[1].View(), m.serviceForm[2].View())
return borderStyle.Render(body)
}
+106 -45
View File
@@ -1,37 +1,96 @@
package ui
import (
"crypto/rand"
"fmt"
"math/big"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/model"
"ssh-access-tui/internal/sysadm"
)
// updateConfirmAction drives the single generic "are you sure" screen that
// backs every destructive action in the tool (lock/delete a user, delete a
// host or a service). Nothing the action does runs until this confirms it;
// esc/n cancels with zero effect.
const (
confirmTokenLetters = "abcdefghijklmnopqrstuvwxyz"
confirmTokenDigits = "0123456789"
)
// generateConfirmToken returns a short, freshly random string the admin
// must type back exactly to carry out a destructive action — e.g.
// "kqzvm-482". Confirming with a fixed keypress (enter/y) can fire from a
// reflexive/habitual keypress; typing back a token that's different every
// time cannot.
func generateConfirmToken() string {
letters := make([]byte, 5)
for i := range letters {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(confirmTokenLetters))))
letters[i] = confirmTokenLetters[n.Int64()]
}
digits := make([]byte, 3)
for i := range digits {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(confirmTokenDigits))))
digits[i] = confirmTokenDigits[n.Int64()]
}
return string(letters) + "-" + string(digits)
}
// startConfirm switches to the generic confirmation screen: kind identifies
// what executeConfirmedAction should do once the token is typed back
// correctly, target is the entity being acted on (username/host/service/
// group name — unused for confirmExportRotate, which instead reads
// m.exportChosen), message explains the consequences, and returnTo is where
// esc/cancel goes back to.
func (m Model) startConfirm(kind confirmKind, target, message string, returnTo screen) Model {
m.pendingConfirm = kind
m.confirmTarget = target
m.confirmMessage = message
m.confirmReturnTo = returnTo
m.confirmToken = generateConfirmToken()
ti := textinput.New()
ti.Placeholder = "введите строку выше"
ti.CharLimit = 32
ti.Focus()
m.confirmInput = ti
m.confirmError = ""
m.screen = scrConfirmAction
return m
}
func (m Model) updateConfirmAction(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc", "n":
case "esc":
m.pendingConfirm = confirmNone
m.screen = m.confirmReturnTo
return m, nil
case "enter", "y":
case "enter":
if strings.TrimSpace(m.confirmInput.Value()) != m.confirmToken {
m.confirmError = "не совпадает со строкой выше — попробуйте ещё раз (или esc для отмены)"
m.confirmInput.SetValue("")
return m, nil
}
return m.executeConfirmedAction()
}
}
return m, nil
var cmd tea.Cmd
m.confirmInput, cmd = m.confirmInput.Update(msg)
return m, cmd
}
func (m Model) viewConfirmAction() string {
body := warnStyle.Render("ТРЕБУЕТСЯ ПОДТВЕРЖДЕНИЕ") + "\n\n"
body += m.confirmMessage + "\n\n"
body += "[enter/y] подтвердить [esc/n] отмена, ничего не изменится"
body += "Чтобы подтвердить, наберите ровно эту строку:\n\n"
body += " " + titleStyle.Render(m.confirmToken) + "\n\n"
body += m.confirmInput.View() + "\n"
if m.confirmError != "" {
body += "\n" + errStyle.Render(m.confirmError) + "\n"
}
body += "\n[enter] проверить [esc] отмена, ничего не изменится"
return borderStyle.Render(body)
}
@@ -39,7 +98,6 @@ func (m Model) executeConfirmedAction() (tea.Model, tea.Cmd) {
kind := m.pendingConfirm
target := m.confirmTarget
m.pendingConfirm = confirmNone
m.err, m.status = "", ""
switch kind {
case confirmLockUser:
@@ -50,6 +108,10 @@ func (m Model) executeConfirmedAction() (tea.Model, tea.Cmd) {
return m.doDeleteHost(target)
case confirmDeleteService:
return m.doDeleteService(target)
case confirmDeleteGroup:
return m.doDeleteGroup(target)
case confirmExportRotate:
return m.runExport(m.exportChosen)
}
m.screen = m.confirmReturnTo
return m, nil
@@ -99,52 +161,51 @@ func (m Model) doDeleteUser(username string) (tea.Model, tea.Cmd) {
return m, nil
}
// doDeleteHost removes a host (and all its services) from m.selectedGroup,
// then resyncs authorized_keys for every affected user.
// doDeleteHost removes a catalog host (and its services) entirely, dropping
// it from every group that referenced it, and resyncs authorized_keys for
// every user that lost access as a result.
func (m Model) doDeleteHost(hostName string) (tea.Model, tea.Cmd) {
groupName := m.selectedGroup
m.mutate(fmt.Sprintf("удалён хост %s из группы %s", hostName, groupName), func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
for i := range g.Hosts {
if g.Hosts[i].Name == hostName {
g.Hosts = append(g.Hosts[:i], g.Hosts[i+1:]...)
break
}
}
return resyncGroupUsers(st, groupName)
m.mutate("удалён хост "+hostName+" из каталога", func(st *model.State) error {
affected := st.UsernamesUsingHost(hostName)
st.DeleteHost(hostName)
return resyncUsernames(st, affected)
})
m.screen = scrGroupHosts
m.refreshHostsList()
m.screen = scrCatalogHosts
m.refreshCatalogHostsList()
return m, nil
}
// doDeleteService removes a service from m.selectedHost within
// m.selectedGroup, then resyncs authorized_keys for every affected user.
// doDeleteService removes a service from m.selectedHost, then resyncs
// authorized_keys for every user that lost access as a result.
func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) {
groupName, hostName := m.selectedGroup, m.selectedHost
hostName := m.selectedHost
m.mutate(fmt.Sprintf("удалён сервис %s у хоста %s", serviceName, hostName), func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
h := st.FindHost(hostName)
if h == nil {
return fmt.Errorf("host %s not found", hostName)
}
for hi := range g.Hosts {
if g.Hosts[hi].Name != hostName {
continue
}
svcs := g.Hosts[hi].Services
for si := range svcs {
if svcs[si].Name == serviceName {
g.Hosts[hi].Services = append(svcs[:si], svcs[si+1:]...)
break
}
for i := range h.Services {
if h.Services[i].Name == serviceName {
h.Services = append(h.Services[:i], h.Services[i+1:]...)
break
}
}
return resyncGroupUsers(st, groupName)
return resyncUsernames(st, st.UsernamesUsingHost(hostName))
})
m.screen = scrHostServices
m.refreshServicesList()
m.screen = scrCatalogServices
m.refreshCatalogServicesList()
return m, nil
}
// doDeleteGroup removes a group and its membership from every user who had
// it, then resyncs authorized_keys for everyone who lost access.
func (m Model) doDeleteGroup(groupName string) (tea.Model, tea.Cmd) {
m.mutate("удалена группа "+groupName, func(st *model.State) error {
affected := st.UsernamesInGroup(groupName)
st.DeleteGroup(groupName)
return resyncUsernames(st, affected)
})
m.screen = scrGroups
m.refreshGroupsList()
return m, nil
}
+25 -41
View File
@@ -63,53 +63,37 @@ func (m Model) updateExportSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.exportChosen = chosen
m.screen = scrExportConfirm
m.err = ""
// Issuing a brand new key is not destructive — nothing existing
// breaks — so it runs immediately. Re-exporting someone who
// already has a key rotates it and invalidates their current
// archive, which does need the typed-token confirmation.
st := m.loadState()
var rotating []string
for _, uname := range chosen {
if u := st.FindUser(uname); u != nil && u.PublicKey != "" {
rotating = append(rotating, uname)
}
}
if len(rotating) == 0 {
return m.runExport(chosen)
}
confirmMsg := "Будут перевыпущены ключи (старые архивы перестанут работать):\n\n"
for _, uname := range chosen {
if u := st.FindUser(uname); u != nil && u.PublicKey != "" {
confirmMsg += fmt.Sprintf(" %-20s ПЕРЕВЫПУСК\n", uname)
} else {
confirmMsg += fmt.Sprintf(" %-20s новый ключ\n", uname)
}
}
m = m.startConfirm(confirmExportRotate, "", confirmMsg, scrExportSelect)
return m, nil
}
}
return m, nil
}
// updateExportConfirm guards against the accidental case this screen exists
// for: selecting users who already have a key issued re-exports them, which
// generates a brand new keypair and immediately invalidates whatever archive
// they were already given (their old private key stops matching
// authorized_keys). Nothing is generated or written until this is confirmed.
func (m Model) updateExportConfirm(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc", "n":
m.screen = scrExportSelect
return m, nil
case "enter", "y":
return m.runExport(m.exportChosen)
}
}
return m, nil
}
func (m Model) viewExportConfirm() string {
st := m.loadState()
body := "Подтвердите экспорт\n\n"
anyRotate := false
for _, username := range m.exportChosen {
u := st.FindUser(username)
if u != nil && u.PublicKey != "" {
body += fmt.Sprintf(" %-20s %s\n", username, warnStyle.Render("ключ уже выдан — будет ПЕРЕВЫПУЩЕН, старый архив перестанет работать"))
anyRotate = true
} else {
body += fmt.Sprintf(" %-20s новый ключ\n", username)
}
}
if anyRotate {
body += "\n" + warnStyle.Render("Внимание: для отмеченных выше пользователей текущий ключ будет заменён.") + "\n"
body += warnStyle.Render("Все, у кого сейчас работает старый архив, потеряют доступ, пока им не выдадут новый.") + "\n"
}
body += "\n[enter/y] подтвердить и сгенерировать [esc/n] отмена, ничего не создано"
return borderStyle.Render(body)
}
// runExport performs the actual batch export: for each chosen user it
// (re)generates a keypair, rewrites authorized_keys with that user's
// currently-allowed hosts, builds the encrypted archive, and records the new
@@ -245,7 +229,7 @@ func (m Model) viewExportSelect() string {
}
body += fmt.Sprintf("%s%s %-20s %s\n", cursor, check, uname, status)
}
body += "\n[space] отметить [a] отметить всех [enter] далее (спросит подтверждение) [esc] назад"
body += "\n[space] отметить [a] отметить всех [enter] сгенерировать (перевыпуск попросит подтверждение) [esc] назад"
return borderStyle.Render(body)
}
+173 -338
View File
@@ -2,74 +2,30 @@ package ui
import (
"fmt"
"strconv"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/model"
"ssh-access-tui/internal/sysadm"
)
// validNameErr renders a consistent error for group/host/service names that
// fail model.ValidName — these names end up as ssh_config/authorized_keys
// tokens, where spaces or "#" would corrupt the generated file.
func validNameErr(kind, name string) string {
return fmt.Sprintf("%s %q: имя не должно содержать пробелов и спецсимволов (разрешены латинские буквы, цифры, точка, подчёркивание, дефис)", kind, name)
}
func (m *Model) refreshGroupsList() {
st := m.loadState()
items := make([]list.Item, 0, len(st.Groups))
for _, g := range st.Groups {
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: fmt.Sprintf("%d хостов", len(g.Hosts))})
desc := fmt.Sprintf("%d хостов", len(g.Hosts))
if g.Description != "" {
desc += " — " + g.Description
}
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: desc})
}
l := list.New(items, list.NewDefaultDelegate(), m.groupsList.Width(), m.groupsList.Height())
l.Title = "Группы доступа [n]новая группа [enter]хосты группы [esc]назад"
l.SetShowStatusBar(false)
m.groupsList = l
m.awaitingGroup = false
m.groupsList = newList(items,
"Группы [n]новая [e]изменить [enter]хосты группы [d]удалить [esc]назад",
m.groupsList.Width(), m.groupsList.Height())
}
// contains a small state flag reused from Model: awaitingGroup + groupNewName input
func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.awaitingGroup {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.awaitingGroup = false
return m, nil
case "enter":
name := m.groupNewName.Value()
if name == "" {
m.err = "имя группы не может быть пустым"
return m, nil
}
if !model.ValidName(name) {
m.err = validNameErr("группа", name)
return m, nil
}
st := m.loadState()
if st.FindGroup(name) != nil {
m.err = fmt.Sprintf("группа %q уже существует", name)
return m, nil
}
m.mutate("создана группа "+name, func(st *model.State) error {
st.Groups = append(st.Groups, model.Group{Name: name})
return nil
})
m.awaitingGroup = false
m.refreshGroupsList()
return m, nil
}
}
var cmd tea.Cmd
m.groupNewName, cmd = m.groupNewName.Update(msg)
return m, cmd
}
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
@@ -77,11 +33,37 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err, m.status = "", ""
return m, nil
case "n":
m.awaitingGroup = true
m.groupNewName = textinput.New()
m.groupNewName.Placeholder = "имя группы, например team-billing"
m.groupNewName.Focus()
m.groupFormEditTarget = ""
for i := range m.groupForm {
m.groupForm[i] = textinput.New()
}
m.groupForm[0].Placeholder = "имя группы, например team-billing"
m.groupForm[1].Placeholder = "описание (необязательно)"
m.groupFormFocus = 0
m.groupForm[0].Focus()
m.err = ""
m.screen = scrGroupForm
return m, nil
case "e":
sel, ok := m.groupsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
g := st.FindGroup(sel.id)
if g == nil {
return m, nil
}
m.groupFormEditTarget = g.Name
for i := range m.groupForm {
m.groupForm[i] = textinput.New()
}
m.groupForm[0].SetValue(g.Name)
m.groupForm[1].SetValue(g.Description)
m.groupFormFocus = 0
m.groupForm[0].Focus()
m.err = ""
m.screen = scrGroupForm
return m, nil
case "enter":
sel, ok := m.groupsList.SelectedItem().(simpleItem)
@@ -89,8 +71,35 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.selectedGroup = sel.id
m.screen = scrGroupHosts
m.refreshHostsList()
st := m.loadState()
g := st.FindGroup(sel.id)
m.groupHostsPickSel = map[string]bool{}
if g != nil {
for _, hn := range g.Hosts {
m.groupHostsPickSel[hn] = true
}
}
m.groupHostsPickOrder = nil
for _, h := range st.Hosts {
m.groupHostsPickOrder = append(m.groupHostsPickOrder, h.Name)
}
m.groupHostsPickCursor = 0
m.screen = scrGroupHostsPick
return m, nil
case "d":
sel, ok := m.groupsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
affected := st.UsernamesInGroup(sel.id)
msg := fmt.Sprintf(
"Удалить группу %q?\n\n"+
"Это затронет пользователей: %d — группа будет снята с них, и они\n"+
"немедленно потеряют доступ ко всем сервисам, которые давала только эта\n"+
"группа.",
sel.id, len(affected))
m = m.startConfirm(confirmDeleteGroup, sel.id, msg, scrGroups)
return m, nil
}
}
@@ -100,316 +109,142 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Model) viewGroups() string {
if m.awaitingGroup {
return borderStyle.Render("Новая группа:\n\n" + m.groupNewName.View() + "\n\n[enter] создать [esc] отмена")
}
return borderStyle.Render(m.groupsList.View())
}
// --- hosts within a group ---
func (m *Model) refreshHostsList() {
st := m.loadState()
g := st.FindGroup(m.selectedGroup)
var items []list.Item
if g != nil {
for _, h := range g.Hosts {
items = append(items, simpleItem{
id: h.Name,
title: h.Name,
desc: fmt.Sprintf("%s — %d сервисов", h.Address, len(h.Services)),
})
}
}
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [enter]сервисы хоста [d]удалить [esc]назад", m.selectedGroup)
l.SetShowStatusBar(false)
m.hostsList = l
}
// resyncGroupUsers rewrites authorized_keys permitopen= restrictions for
// every active user whose groups include groupName, reflecting the group's
// current hosts/services — the only place server-side enforcement is
// updated when the allowed destinations change over time.
func resyncGroupUsers(st *model.State, groupName string) error {
for i := range st.Users {
u := &st.Users[i]
if u.PublicKey == "" || u.Status != model.StatusActive {
continue
}
inGroup := false
for _, g := range u.Groups {
if g == groupName {
inGroup = true
break
func (m Model) updateGroupForm(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrGroups
return m, nil
case "tab", "down":
m.groupForm[m.groupFormFocus].Blur()
m.groupFormFocus = (m.groupFormFocus + 1) % len(m.groupForm)
m.groupForm[m.groupFormFocus].Focus()
return m, nil
case "enter":
name := m.groupForm[0].Value()
desc := m.groupForm[1].Value()
if name == "" {
m.err = "имя группы не может быть пустым"
return m, nil
}
}
if !inGroup {
continue
}
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedForwards(u)); err != nil {
return fmt.Errorf("resync %s: %w", u.Username, err)
if !model.ValidName(name) {
m.err = validNameErr("группа", name)
return m, nil
}
target := m.groupFormEditTarget
m.err = ""
if target == "" {
m.mutate("создана группа "+name, func(st *model.State) error {
if st.FindGroup(name) != nil {
return fmt.Errorf("группа %q уже существует", name)
}
st.Groups = append(st.Groups, model.Group{Name: name, Description: desc})
return nil
})
} else {
m.mutate("изменена группа "+target, func(st *model.State) error {
if name != target && st.FindGroup(name) != nil {
return fmt.Errorf("группа %q уже существует", name)
}
if name != target {
st.RenameGroup(target, name)
}
g := st.FindGroup(name)
if g == nil {
return fmt.Errorf("group %s not found", name)
}
g.Description = desc
return nil
})
}
m.screen = scrGroups
m.refreshGroupsList()
return m, nil
}
}
return nil
var cmd tea.Cmd
m.groupForm[m.groupFormFocus], cmd = m.groupForm[m.groupFormFocus].Update(msg)
return m, cmd
}
func (m Model) updateGroupHosts(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) viewGroupForm() string {
title := "Новая группа"
if m.groupFormEditTarget != "" {
title = fmt.Sprintf("Изменить группу %q", m.groupFormEditTarget)
}
body := fmt.Sprintf("%s\n\nИмя: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
title, m.groupForm[0].View(), m.groupForm[1].View())
return borderStyle.Render(body)
}
// --- pick which catalog hosts belong to the selected group ---
func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrGroups
m.refreshGroupsList()
return m, nil
case "n":
m.screen = scrHostNew
for i := range m.hostForm {
m.hostForm[i] = textinput.New()
case "up", "k":
if m.groupHostsPickCursor > 0 {
m.groupHostsPickCursor--
}
return m, nil
case "down", "j":
if m.groupHostsPickCursor < len(m.groupHostsPickOrder)-1 {
m.groupHostsPickCursor++
}
return m, nil
case " ":
if len(m.groupHostsPickOrder) > 0 {
h := m.groupHostsPickOrder[m.groupHostsPickCursor]
m.groupHostsPickSel[h] = !m.groupHostsPickSel[h]
}
m.hostForm[0].Placeholder = "короткое имя, например db-prod-1 (без пробелов)"
m.hostForm[1].Placeholder = "адрес (hostname или IP), как виден с jump-хоста"
m.hostFormFocus = 0
m.hostForm[0].Focus()
m.err = ""
return m, nil
case "enter":
sel, ok := m.hostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.selectedHost = sel.id
m.screen = scrHostServices
m.refreshServicesList()
return m, nil
case "d":
sel, ok := m.hostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
groupName := m.selectedGroup
nServices := 0
if g := st.FindGroup(groupName); g != nil {
for _, h := range g.Hosts {
if h.Name == sel.id {
nServices = len(h.Services)
break
}
var hosts []string
for _, h := range m.groupHostsPickOrder {
if m.groupHostsPickSel[h] {
hosts = append(hosts, h)
}
}
m.pendingConfirm = confirmDeleteHost
m.confirmTarget = sel.id
m.confirmReturnTo = scrGroupHosts
m.confirmMessage = fmt.Sprintf(
"Удалить хост %q (и все его сервисы: %d шт.) из группы %q?\n\n"+
"Все пользователи этой группы немедленно потеряют доступ ко всем сервисам\n"+
"этого хоста — permitopen убирается из authorized_keys сразу же.",
sel.id, nServices, groupName)
m.screen = scrConfirmAction
return m, nil
}
}
var cmd tea.Cmd
m.hostsList, cmd = m.hostsList.Update(msg)
return m, cmd
}
func (m Model) viewGroupHosts() string {
return borderStyle.Render(m.hostsList.View())
}
func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrGroupHosts
return m, nil
case "tab", "down":
m.hostForm[m.hostFormFocus].Blur()
m.hostFormFocus = (m.hostFormFocus + 1) % len(m.hostForm)
m.hostForm[m.hostFormFocus].Focus()
return m, nil
case "enter":
name := m.hostForm[0].Value()
addr := m.hostForm[1].Value()
if name == "" || addr == "" {
m.err = "имя и адрес обязательны"
return m, nil
}
if !model.ValidName(name) {
m.err = validNameErr("хост", name)
return m, nil
}
groupName := m.selectedGroup
m.err = ""
var created bool
m.mutate(fmt.Sprintf("добавлен хост %s в группу %s", name, groupName), func(st *model.State) error {
m.mutate("изменён список хостов группы "+groupName, func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
for _, h := range g.Hosts {
if h.Name == name {
return fmt.Errorf("host %q already exists in group %q", name, groupName)
}
}
g.Hosts = append(g.Hosts, model.Host{Name: name, Address: addr})
created = true
return nil
})
if !created {
return m, nil
}
m.selectedHost = name
m.screen = scrHostServices
m.refreshServicesList()
m.status = fmt.Sprintf("хост %s добавлен — теперь добавьте на нём хотя бы один сервис (порт)", name)
return m, nil
}
}
var cmd tea.Cmd
m.hostForm[m.hostFormFocus], cmd = m.hostForm[m.hostFormFocus].Update(msg)
return m, cmd
}
func (m Model) viewHostNew() string {
body := fmt.Sprintf("Новый хост в группе %q\n\nИмя: %s\nАдрес: %s\n\n[tab] след. поле [enter] сохранить и перейти к сервисам [esc] отмена",
m.selectedGroup, m.hostForm[0].View(), m.hostForm[1].View())
return borderStyle.Render(body)
}
// --- services within a host ---
func (m *Model) refreshServicesList() {
st := m.loadState()
g := st.FindGroup(m.selectedGroup)
var items []list.Item
if g != nil {
for _, h := range g.Hosts {
if h.Name != m.selectedHost {
continue
}
for _, svc := range h.Services {
items = append(items, simpleItem{
id: svc.Name,
title: fmt.Sprintf("%s (порт %d)", svc.Name, svc.Port),
desc: svc.Description,
})
}
}
}
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
l.Title = fmt.Sprintf("Сервисы хоста %q [n]добавить [d]удалить [esc]назад", m.selectedHost)
l.SetShowStatusBar(false)
m.servicesList = l
}
func (m Model) updateHostServices(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrGroupHosts
m.refreshHostsList()
return m, nil
case "n":
m.screen = scrServiceNew
for i := range m.serviceForm {
m.serviceForm[i] = textinput.New()
}
m.serviceForm[0].Placeholder = "имя сервиса, например ssh или web-api (без пробелов)"
m.serviceForm[1].Placeholder = "порт на хосте, например 22 или 7888"
m.serviceForm[2].Placeholder = "описание (необязательно)"
m.serviceFormFocus = 0
m.serviceForm[0].Focus()
m.err = ""
return m, nil
case "d":
sel, ok := m.servicesList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
groupName, hostName := m.selectedGroup, m.selectedHost
m.pendingConfirm = confirmDeleteService
m.confirmTarget = sel.id
m.confirmReturnTo = scrHostServices
m.confirmMessage = fmt.Sprintf(
"Удалить сервис %q у хоста %q (группа %q)?\n\n"+
"Все пользователи этой группы немедленно потеряют доступ к этому сервису —\n"+
"permitopen убирается из authorized_keys сразу же.",
sel.id, hostName, groupName)
m.screen = scrConfirmAction
return m, nil
}
}
var cmd tea.Cmd
m.servicesList, cmd = m.servicesList.Update(msg)
return m, cmd
}
func (m Model) viewHostServices() string {
return borderStyle.Render(m.servicesList.View())
}
func (m Model) updateServiceNew(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrHostServices
return m, nil
case "tab", "down":
m.serviceForm[m.serviceFormFocus].Blur()
m.serviceFormFocus = (m.serviceFormFocus + 1) % len(m.serviceForm)
m.serviceForm[m.serviceFormFocus].Focus()
return m, nil
case "enter":
name := m.serviceForm[0].Value()
portStr := m.serviceForm[1].Value()
desc := m.serviceForm[2].Value()
if name == "" || portStr == "" {
m.err = "имя и порт обязательны"
return m, nil
}
if !model.ValidName(name) {
m.err = validNameErr("сервис", name)
return m, nil
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
m.err = "порт должен быть числом от 1 до 65535"
return m, nil
}
groupName, hostName := m.selectedGroup, m.selectedHost
m.err = ""
m.mutate(fmt.Sprintf("добавлен сервис %s (порт %d) хосту %s", name, port, hostName), func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
for hi := range g.Hosts {
if g.Hosts[hi].Name != hostName {
continue
}
for _, svc := range g.Hosts[hi].Services {
if svc.Name == name {
return fmt.Errorf("service %q already exists on host %q", name, hostName)
}
}
g.Hosts[hi].Services = append(g.Hosts[hi].Services, model.Service{Name: name, Port: port, Description: desc})
}
g.Hosts = hosts
return resyncGroupUsers(st, groupName)
})
m.screen = scrHostServices
m.refreshServicesList()
m.screen = scrGroups
m.refreshGroupsList()
return m, nil
}
}
var cmd tea.Cmd
m.serviceForm[m.serviceFormFocus], cmd = m.serviceForm[m.serviceFormFocus].Update(msg)
return m, cmd
return m, nil
}
func (m Model) viewServiceNew() string {
body := fmt.Sprintf("Новый сервис на хосте %q\n\nИмя: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
m.selectedHost, m.serviceForm[0].View(), m.serviceForm[1].View(), m.serviceForm[2].View())
func (m Model) viewGroupHostsPick() string {
body := fmt.Sprintf("Какие хосты каталога входят в группу %q\n\n", m.selectedGroup)
if len(m.groupHostsPickOrder) == 0 {
body += warnStyle.Render("В каталоге ещё нет хостов — сначала добавьте их в разделе «Хосты и сервисы».") + "\n"
}
for i, h := range m.groupHostsPickOrder {
cursor := " "
if i == m.groupHostsPickCursor {
cursor = "> "
}
check := "[ ]"
if m.groupHostsPickSel[h] {
check = "[x]"
}
body += fmt.Sprintf("%s%s %s\n", cursor, check, h)
}
body += "\n[space] отметить [enter] сохранить [esc] назад"
return borderStyle.Render(body)
}
+6 -5
View File
@@ -8,15 +8,13 @@ import (
func newMenuList() list.Model {
items := []list.Item{
simpleItem{id: "users", title: "Пользователи", desc: "Создать, заблокировать, удалить учётные записи"},
simpleItem{id: "groups", title: "Группы и хосты", desc: "Кому какие хосты разрешены для прыжка/форвардинга"},
simpleItem{id: "catalog", title: "Хосты и сервисы", desc: "Общий каталог: адреса и порты, один раз на всех, группы просто выбирают из него"},
simpleItem{id: "groups", title: "Группы", desc: "Какие хосты из каталога входят в каждую группу"},
simpleItem{id: "export", title: "Экспорт архивов", desc: "Сгенерировать ключи и .7z архивы для доставки пользователям"},
simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"},
simpleItem{id: "quit", title: "Выход", desc: ""},
}
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "ssh-access-tui"
l.SetShowStatusBar(false)
return l
return newList(items, "ssh-access-tui", 0, 0)
}
func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -32,6 +30,9 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
case "users":
m.screen = scrUsers
m.refreshUsersList()
case "catalog":
m.screen = scrCatalogHosts
m.refreshCatalogHostsList()
case "groups":
m.screen = scrGroups
m.refreshGroupsList()
+34
View File
@@ -0,0 +1,34 @@
package ui
import (
"fmt"
"ssh-access-tui/internal/model"
"ssh-access-tui/internal/sysadm"
)
// resyncUsernames rewrites authorized_keys permitopen= restrictions for each
// named user (skipping anyone without a key yet or not active), reflecting
// whatever their current AllowedForwards computes to. This is the one place
// server-side enforcement gets updated whenever group membership, a group's
// referenced hosts, or a host's services change.
func resyncUsernames(st *model.State, usernames []string) error {
for _, uname := range usernames {
u := st.FindUser(uname)
if u == nil || u.PublicKey == "" || u.Status != model.StatusActive {
continue
}
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedForwards(u)); err != nil {
return fmt.Errorf("resync %s: %w", u.Username, err)
}
}
return nil
}
func resyncGroupUsers(st *model.State, groupName string) error {
return resyncUsernames(st, st.UsernamesInGroup(groupName))
}
func resyncHostUsers(st *model.State, hostName string) error {
return resyncUsernames(st, st.UsernamesUsingHost(hostName))
}
+4 -10
View File
@@ -75,15 +75,12 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
username := sel.id
m.pendingConfirm = confirmLockUser
m.confirmTarget = username
m.confirmReturnTo = scrUsers
m.confirmMessage = fmt.Sprintf(
msg := fmt.Sprintf(
"Заблокировать пользователя %q?\n\n"+
"Аккаунт будет немедленно заблокирован (пароль отключён, срок действия\n"+
"истёк, ключ в authorized_keys закомментирован) — доступ пропадёт сразу.\n"+
"Это обратимо: позже можно разблокировать клавишей [u].", username)
m.screen = scrConfirmAction
m = m.startConfirm(confirmLockUser, username, msg, scrUsers)
return m, nil
case "u":
sel, ok := m.usersList.SelectedItem().(simpleItem)
@@ -118,16 +115,13 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
username := sel.id
m.pendingConfirm = confirmDeleteUser
m.confirmTarget = username
m.confirmReturnTo = scrUsers
m.confirmMessage = fmt.Sprintf(
msg := fmt.Sprintf(
"Удалить пользователя %q НЕОБРАТИМО?\n\n"+
"Будет выполнено userdel -r: системный аккаунт и его домашняя папка\n"+
"удаляются безвозвратно.\n"+
"Если нужно просто временно отключить доступ — используйте блокировку\n"+
"[l] вместо удаления, её можно отменить.", username)
m.screen = scrConfirmAction
m = m.startConfirm(confirmDeleteUser, username, msg, scrUsers)
return m, nil
case "c":
sel, ok := m.usersList.SelectedItem().(simpleItem)
BIN
View File
Binary file not shown.