diff --git a/README.md b/README.md index 97b5ba3..2f0354b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # ssh-access-tui Консольный (TUI) инструмент для администрирования Linux jump-хоста: заводит -пользователей, у которых есть **только** проброс портов (`-L`/`-D`) и прыжок -(`ProxyJump`) к явно разрешённому списку внутренних хостов — без -интерактивного шелла. Управляет группами доступа, генерирует и раздаёт -зашифрованные архивы с ключом/конфигом/инструкцией, блокирует уволенных, -пишет журнал подключений и хранит всю конфигурацию в git для бэкапа/переезда. +пользователей, у которых есть **только** проброс портов (`LocalForward`, +одно соединение сразу открывает все разрешённые сервисы) к явно +разрешённому списку внутренних хостов — без интерактивного шелла. Хосты и +сервисы описываются один раз в общем каталоге, группы просто выбирают из +него. Генерирует и раздаёт зашифрованные архивы с ключом/конфигом/ +инструкцией, блокирует уволенных, пишет журнал подключений, хранит всю +конфигурацию в git для бэкапа/переезда, и требует явное подтверждение +(ввод случайной строки) для любого деструктивного действия. Почему не веб-панель: инструмент требует root (создание пользователей, правка `authorized_keys` и `sshd_config`). Веб-бэкенд с такими правами — diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md index 38319f7..43c30fe 100644 --- a/docs/ADMIN_GUIDE.md +++ b/docs/ADMIN_GUIDE.md @@ -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-аккаунты для активных пользователей, +выпустить им новые ключи через **Экспорт архивов** (старые приватные ключи +специально нигде не хранятся — это не баг, см. архитектурный документ). ## Что не делает эта версия (нужно руками) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8096cf9..65f992e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` не удаляются автоматически после доставки — администратор должен удалить их вручную, когда получатель подтвердил получение. diff --git a/internal/keymgr/instructions.go b/internal/keymgr/instructions.go index a16d4d6..5724f33 100644 --- a/internal/keymgr/instructions.go +++ b/internal/keymgr/instructions.go @@ -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) } diff --git a/internal/keymgr/portalloc.go b/internal/keymgr/portalloc.go index 39e772b..f4bdee0 100644 --- a/internal/keymgr/portalloc.go +++ b/internal/keymgr/portalloc.go @@ -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 } diff --git a/internal/model/types.go b/internal/model/types.go index 613565c..165c09a 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -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 + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 7a71073..ae3ff73 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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: diff --git a/internal/ui/catalog.go b/internal/ui/catalog.go new file mode 100644 index 0000000..e0b0729 --- /dev/null +++ b/internal/ui/catalog.go @@ -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) +} diff --git a/internal/ui/confirm.go b/internal/ui/confirm.go index e7bbfd8..952a2cd 100644 --- a/internal/ui/confirm.go +++ b/internal/ui/confirm.go @@ -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 } diff --git a/internal/ui/export.go b/internal/ui/export.go index 7482453..f8dfc32 100644 --- a/internal/ui/export.go +++ b/internal/ui/export.go @@ -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) } diff --git a/internal/ui/groups.go b/internal/ui/groups.go index 0e6b86f..3ccc3ab 100644 --- a/internal/ui/groups.go +++ b/internal/ui/groups.go @@ -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) } diff --git a/internal/ui/menu.go b/internal/ui/menu.go index d668fb7..94a693a 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -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() diff --git a/internal/ui/sync.go b/internal/ui/sync.go new file mode 100644 index 0000000..8c90376 --- /dev/null +++ b/internal/ui/sync.go @@ -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)) +} diff --git a/internal/ui/users.go b/internal/ui/users.go index 1cf73e2..a626ebd 100644 --- a/internal/ui/users.go +++ b/internal/ui/users.go @@ -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) diff --git a/ssh-access-tui b/ssh-access-tui index 5de114f..44943db 100755 Binary files a/ssh-access-tui and b/ssh-access-tui differ