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
+1120 -659

No files matched your search

+8 -5
View File
@@ -1,11 +1,14 @@
# ssh-access-tui # ssh-access-tui
Консольный (TUI) инструмент для администрирования Linux jump-хоста: заводит Консольный (TUI) инструмент для администрирования Linux jump-хоста: заводит
пользователей, у которых есть **только** проброс портов (`-L`/`-D`) и прыжок пользователей, у которых есть **только** проброс портов (`LocalForward`,
(`ProxyJump`) к явно разрешённому списку внутренних хостов — без одно соединение сразу открывает все разрешённые сервисы) к явно
интерактивного шелла. Управляет группами доступа, генерирует и раздаёт разрешённому списку внутренних хостов — без интерактивного шелла. Хосты и
зашифрованные архивы с ключом/конфигом/инструкцией, блокирует уволенных, сервисы описываются один раз в общем каталоге, группы просто выбирают из
пишет журнал подключений и хранит всю конфигурацию в git для бэкапа/переезда. него. Генерирует и раздаёт зашифрованные архивы с ключом/конфигом/
инструкцией, блокирует уволенных, пишет журнал подключений, хранит всю
конфигурацию в git для бэкапа/переезда, и требует явное подтверждение
(ввод случайной строки) для любого деструктивного действия.
Почему не веб-панель: инструмент требует root (создание пользователей, Почему не веб-панель: инструмент требует root (создание пользователей,
правка `authorized_keys` и `sshd_config`). Веб-бэкенд с такими правами — правка `authorized_keys` и `sshd_config`). Веб-бэкенд с такими правами —
+96 -78
View File
@@ -22,74 +22,85 @@ sudo ssh-access-tui
есть созданные, но ни разу не экспортированные пользователи (у них ещё нет есть созданные, но ни разу не экспортированные пользователи (у них ещё нет
ключа) — можно выйти всё равно (`enter`/`y`) или вернуться (`esc`/`n`). ключа) — можно выйти всё равно (`enter`/`y`) или вернуться (`esc`/`n`).
Любое деструктивное действие (блокировка/удаление пользователя, удаление **Любое деструктивное действие** (блокировка/удаление пользователя,
хоста или сервиса) сначала показывает отдельный экран подтверждения с удаление хоста/сервиса/группы, перевыпуск уже выданного ключа) требует не
пояснением последствий — ничего не происходит до `enter`/`y`; `esc`/`n` `enter`/`y`, а **набрать вручную короткую случайную строку**, которую
отменяет без каких-либо изменений. инструмент показывает на экране подтверждения (например `kqzvm-482`) — она
каждый раз новая. Неверный ввод просто откатывает к полю ввода с пояснением
«не совпадает», ничего не меняя; `esc` отменяет действие полностью в любой
момент. Это защита именно от «промахнулся мимо enter» — набрать чужую
случайную строку по ошибке невозможно.
## Главное меню ## Главное меню
``` ```
Пользователи — создать / заблокировать / разблокировать / удалить Пользователи — создать / заблокировать / разблокировать / удалить
Группы и хосты — какие внутренние хосты кому разрешены Хосты и сервисы — общий каталог адресов и портов (один раз на всех)
Группы — какие хосты из каталога входят в каждую группу
Экспорт архивов — сгенерировать ключи и .7z для доставки Экспорт архивов — сгенерировать ключи и .7z для доставки
Аудит / кто в сети — активные сессии и журнал подключений Аудит / кто в сети — активные сессии и журнал подключений
Выход Выход
``` ```
Имена групп, хостов и сервисов должны быть без пробелов (латиница, цифры, Имена хостов, сервисов и групп должны быть без пробелов (латиница, цифры,
`. _ -`) — они попадают в конфиг и в `authorized_keys`; инструмент откажет с `. _ -`) — они попадают в конфиг и в `authorized_keys`; инструмент откажет с
понятной ошибкой, если ввести имя с пробелом. понятной ошибкой, если ввести имя с пробелом.
## Модель: каталог → группы → пользователи
Хосты и их сервисы (порты) описываются **один раз** в общем каталоге
(«Хосты и сервисы»), а не заново для каждой группы. Группа — это просто
отметка галочками, какие хосты каталога в неё входят (все сервисы такого
хоста включаются целиком). Если у вас в разных группах фигурируют одни и те
же хосты/сервисы — их не нужно набивать повторно, только отметить нужные
для очередной группы. Пользователь состоит в одной или нескольких группах;
его реальный доступ — объединение сервисов всех хостов всех его групп.
## Сценарий 1: новый сотрудник, у которого уже есть роль/группа доступа ## Сценарий 1: новый сотрудник, у которого уже есть роль/группа доступа
Обычно сначала настраивают группу (один раз), потом заводят пользователей в Обычно каталог и группы настраивают заранее (или один раз добавляют то,
неё пачками. Иерархия: **группа** → **хосты** → **сервисы (порты) на хосте** чего не хватает), потом заводят пользователей пачками.
— на одном хосте может быть несколько сервисов (например, `ssh` на 22 и
`web-api` на 7888), каждый добавляется отдельно.
1. **Группы и хосты** → `n` → ввести имя группы (например `team-billing`) → 1. **Хосты и сервисы** → `n` → имя хоста (например `db-prod-1`), адрес (как
`enter`. виден с jump-хоста), описание (необязательно) → `enter`. Сразу
2. Выбрать группу → `enter`, попасть в список хостов группы (изначально открывается список сервисов этого хоста (изначально пуст).
пуст). 2. `n` → имя сервиса (например `ssh` или `web-api`), порт на хосте
3. `n` → заполнить: имя хоста (короткое, например `db-prod-1`), адрес (как (например `22` или `7888`), описание (необязательно) → `enter`. Повторите
виден с jump-хоста) → `tab` между полями → `enter`. Сразу после этого `n` для каждого сервиса на этом хосте.
попадаете в список сервисов этого хоста (тоже изначально пуст). 3. `esc` — назад к списку хостов; повторите шаги 1-2 для остальных хостов,
4. `n` → заполнить сервис: имя (например `ssh` или `web-api`), порт на затем `esc` — в главное меню. Если хост/сервис уже существует и нужно
хосте (например `22` или `7888`), описание (необязательно) → `enter`. только поправить адрес/порт/описание — `e` вместо `n` на нужной записи
Повторите `n` столько раз, сколько сервисов есть на этом хосте. (можно поменять и имя — все группы, которые ссылались на старое имя,
5. `esc` — назад к списку хостов группы; повторите шаги 3-4 для остальных автоматически обновятся).
хостов группы, затем `esc` дважды — назад в главное меню. 4. **Группы** → `n` → имя группы (например `team-billing`), описание
(необязательно) → `enter`.
5. Выбрать группу → `enter` → отметить `space` нужные хосты из каталога →
`enter` сохранить. Список сервисов этих хостов достаётся группе целиком.
6. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин) 6. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин)
и комментарий (например ФИО) → `enter`. и комментарий (например ФИО) → `enter`.
7. Отметить `space` нужные группы (можно несколько — эффективный доступ 7. Отметить `space` нужные группы (можно несколько) → `enter`.
пользователя = объединение сервисов всех хостов всех отмеченных групп) →
`enter`.
Это уже создаёт системный Linux-аккаунт (без пароля, без интерактивного Это уже создаёт системный Linux-аккаунт (без пароля, без интерактивного
шелла) и запись в состоянии. **Ключ ещё не выдан** — статус в списке шелла) и запись в состоянии. **Ключ ещё не выдан** — статус в списке
пользователей покажет «ключ не выдан». пользователей покажет «ключ не выдан».
8. **Экспорт архивов** → отметить `space` только что созданного пользователя 8. **Экспорт архивов** → отметить `space` только что созданного пользователя
(или `a` — отметить всех, удобно при пакетном найме) → `enter`. Список (или `a` — отметить всех, удобно при пакетном найме) → `enter`. Для
сразу показывает у каждого пользователя, будет ли выдан новый ключ или пользователей без ключа архив генерируется сразу, без лишних вопросов —
существующий будет **перевыпущен** (актуально, если случайно попадёт в это не деструктивное действие. Инструмент сгенерирует ключ, пропишет
выборку уже работающий пользователь). `permitopen=` под текущие сервисы группы и соберёт `.7z`, показав **один
9. Экран подтверждения: для тех, у кого ключ уже был, явно предупредит, что общий пароль на всю партию** — он показывается один раз,
старый архив перестанет работать — только после `enter`/`y` (либо `esc`/`n` запишите/скопируйте сразу.
для отмены, тогда не создаётся и не меняется вообще ничего) инструмент 9. Передайте каждому пользователю его архив (файл, путь показан на экране,
сгенерирует ключ, пропишет `permitopen=` под текущие сервисы группы и
соберёт `.7z`, показав **один общий пароль на всю партию** — он
показывается один раз, запишите/скопируйте сразу.
10. Передайте каждому пользователю его архив (файл, путь показан на экране,
лежит в `export_dir`) любым каналом, а пароль от архива — **отдельным** лежит в `export_dir`) любым каналом, а пароль от архива — **отдельным**
каналом (голосом, в личку) и по возможности не всем одним и тем же каналом (голосом, в личку) и по возможности не всем одним и тем же
сообщением в общем чате. сообщением в общем чате.
11. Пользователь распаковывает архив на Windows по инструкции 10. Пользователь распаковывает архив на Windows по инструкции
`INSTRUCTIONS.txt` внутри архива — она уже на простом русском языке, `INSTRUCTIONS.txt` внутри архива — она на простом русском языке, без
без технических терминов: просто список "локальный адрес → какой это технических терминов: положить файлы в папку, ввести одну команду и
сервис → куда он реально ведёт", составленный под конкретные разрешённые держать окно открытым, дальше список «такой адрес у вас локально → что
сервисы этого пользователя. это → где реально находится», составленный под конкретные сервисы этого
пользователя.
## Сценарий 1а: изменить группы уже существующего пользователя ## Сценарий 1а: изменить группы уже существующего пользователя
@@ -100,42 +111,49 @@ sudo ssh-access-tui
(изменение групп никогда не ротирует ключ). (изменение групп никогда не ротирует ключ).
Если хотите, чтобы у пользователя появился ещё и обновлённый клиентский Если хотите, чтобы у пользователя появился ещё и обновлённый клиентский
`config` с новыми `LocalForward` для новых сервисов (иначе он про них `config` с новыми адресами для новых сервисов (иначе он про них просто не
просто не узнает, хотя доступ уже есть) — **Пользователи** → выбрать узнает, хотя доступ уже есть) — **Пользователи** → выбрать пользователя →
пользователя → `c` («обновить конфиг»). Это генерирует маленький архив `c` («обновить конфиг»). Это генерирует маленький архив только с `config` +
только с `config` + `INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже `INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже есть и остаётся тем
есть и остаётся тем же), пользователю нужно только подменить файл `config`. же), пользователю нужно только подменить файл `config`. Не требует
Не требует подтверждения — ключ и доступ этим действием не меняются, это не подтверждения — ключ и доступ этим действием не меняются, это не
деструктивно. деструктивно.
Полный экспорт (со сценария 1, шаги 8-9) тоже сработает и даст то же самое Полный экспорт (сценарий 1, шаги 8-9) тоже сработает и даст то же самое
плюс заново выпустит ключ — используйте `c`, когда переиздание ключа не плюс заново выпустит ключ (с подтверждением строкой, раз это перевыпуск) —
нужно (обычный случай при простом изменении групп). используйте `c`, когда переиздание ключа не нужно.
## Сценарий 2: добавили новый сервис/хост существующей группе ## Сценарий 2: добавили новый сервис/хост, поменялся адрес или порт
1. **Группы и хосты** → выбрать группу → `enter`. - Новый сервис на существующем хосте: **Хосты и сервисы** → выбрать хост →
2. Либо `n` — новый хост (затем добавить ему хотя бы один сервис), либо `enter` → `n`.
выбрать существующий хост → `enter` → `n` — новый сервис на нём. - Новый хост: **Хосты и сервисы** → `n`, затем добавить ему сервисы.
- Хост сменил адрес, сервис — порт, нужно поправить описание, или
переименовать: `e` на нужной записи. Переименование хоста автоматически
обновляет ссылки на него во всех группах, которые его используют — группы
не «отваливаются» молча.
Это сразу обновляет `permitopen=` в `authorized_keys` **всех активных Любое из этих изменений сразу обновляет `permitopen=` в `authorized_keys`
пользователей** этой группы — доступ появляется немедленно, без каких-либо **всех активных пользователей всех групп**, ссылающихся на этот хост, —
действий на стороне пользователя. Клиентский конфиг пользователя (его доступ появляется немедленно, без каких-либо действий на стороне
`~/.ssh/config`) при этом не обновляется автоматически — если хотите, чтобы пользователя. Клиентский конфиг пользователя (его `~/.ssh/config`) при этом
у пользователя появилась готовая строка `LocalForward` для нового сервиса, а не обновляется автоматически — для этого используйте **Пользователи** → `c`
не только "голый" доступ на сервере, используйте **Пользователи** → `c` («обновить конфиг», см. сценарий 1а).
(«обновить конфиг») — ключ при этом не меняется (см. сценарий 1а).
Удаление сервиса или целого хоста (`d` в соответствующем списке) — оба **Группы** → выбрать группу → `enter` — там же можно снять/добавить
действия **деструктивны** (мгновенно отбирают доступ у всех пользователей привязку группы к хостам каталога (не создавая и не удаляя сами хосты).
группы) и требуют подтверждения: экран покажет, что именно удаляется и
скольких пользователей это коснётся, `enter`/`y` подтверждает, `esc`/`n` Удаление хоста из каталога, сервиса на хосте или целой группы (`d` в
отменяет без каких-либо изменений. соответствующем списке) — все три действия **деструктивны** (мгновенно
отбирают доступ у всех затронутых пользователей) и требуют набрать
случайную строку подтверждения: экран покажет, что именно удаляется,
сколько пользователей это коснётся, и в каких группах используется
(для хоста/сервиса).
## Сценарий 3: сотрудник уволился ## Сценарий 3: сотрудник уволился
1. **Пользователи** → выбрать пользователя → `l` (заблокировать) → экран 1. **Пользователи** → выбрать пользователя → `l` (заблокировать) → набрать
подтверждения объяснит, что произойдёт → `enter`/`y`. строку подтверждения, которую покажет экран → `enter`.
Блокировка — это одновременно: блокирует пароль аккаунта, устанавливает Блокировка — это одновременно: блокирует пароль аккаунта, устанавливает
немедленный срок истечения аккаунта (`chage -E 1`) и комментирует его ключ в немедленный срок истечения аккаунта (`chage -E 1`) и комментирует его ключ в
@@ -148,10 +166,10 @@ sudo ssh-access-tui
требует, срабатывает сразу. требует, срабатывает сразу.
Полное удаление аккаунта (`d`) — **необратимо** (`userdel -r`, домашняя папка Полное удаление аккаунта (`d`) — **необратимо** (`userdel -r`, домашняя папка
стирается) и тоже требует подтверждения (экран явно предупредит, что это стирается) и тоже требует подтверждения строкой (экран явно предупредит,
необратимо и что для временного отключения лучше использовать блокировку). что это необратимо и что для временного отключения лучше использовать
Используйте удаление только для окончательного оффбординга, не для блокировку). Используйте удаление только для окончательного оффбординга, не
временной блокировки. для временной блокировки.
## Сценарий 4: проверить, кто сейчас подключён / посмотреть историю ## Сценарий 4: проверить, кто сейчас подключён / посмотреть историю
@@ -171,12 +189,12 @@ sudo ssh-access-tui
## Сценарий 5: перенос на новый хост / хост умер ## Сценарий 5: перенос на новый хост / хост умер
См. подробности в [ARCHITECTURE.md](ARCHITECTURE.md#бэкап-и-аварийное-восстановление). См. подробности в [ARCHITECTURE.md](ARCHITECTURE.md#бэкап-и-аварийное-восстановление).
Коротко: состояние (`groups`, `users`, публичные ключи, статусы) — Коротко: состояние (каталог хостов/сервисов, группы, пользователи,
git-репозиторий, бэкапится часовым таймером в удалённый git. На новом хосте: публичные ключи, статусы) — git-репозиторий, бэкапится часовым таймером в
поднять `ssh-access-tui`, влить `state.yaml` из бэкапа, создать системные удалённый git. На новом хосте: поднять `ssh-access-tui`, влить `state.yaml`
Linux-аккаунты для активных пользователей, выпустить им новые ключи через из бэкапа, создать системные Linux-аккаунты для активных пользователей,
**Экспорт архивов** (старые приватные ключи специально нигде не хранятся — выпустить им новые ключи через **Экспорт архивов** (старые приватные ключи
это не баг, см. архитектурный документ). специально нигде не хранятся — это не баг, см. архитектурный документ).
## Что не делает эта версия (нужно руками) ## Что не делает эта версия (нужно руками)
+50 -25
View File
@@ -19,15 +19,26 @@ TUI работает поверх уже существующего SSH+`sudo`-
реализовать вручную (см. «Конкурентность» ниже). Для небольшой команды реализовать вручную (см. «Конкурентность» ниже). Для небольшой команды
администраторов это разумный компромисс. администраторов это разумный компромисс.
## Модель доступа: Host → Service → Forward ## Модель доступа: каталог хостов → группы → Forward
Хост (`Host`) — это адрес (например, `172.16.1.20`). На одном хосте может Хосты и их сервисы — **общий каталог** (`State.Hosts`), определённый один
быть несколько сервисов (`Service`) — независимых TCP-портов: `ssh` на 22, раз, а не заново в каждой группе. Хост (`Host`) — это адрес (например,
какое-то приложение на 7888, и так далее. Группа (`Group`) — это набор `172.16.1.20`) с описанием; на одном хосте может быть несколько сервисов
хостов (со всеми их сервисами), пользователь может состоять в нескольких (`Service`) — независимых TCP-портов: `ssh` на 22, какое-то приложение на
группах; его реальный эффективный доступ — объединение сервисов всех хостов 7888, и так далее. Группа (`Group`) не хранит хосты сама — она хранит
всех его групп, это и называется `Forward` (`internal/model/types.go`, только список **имён** хостов из каталога (`Group.Hosts []string`),
`State.AllowedForwards`). которые в неё входят (все сервисы такого хоста включаются целиком). Если
одни и те же хосты/сервисы нужны нескольким группам — они описываются один
раз в каталоге, а группы просто отмечают их галочками, вместо повторного
набора адресов и портов.
Пользователь может состоять в нескольких группах; его реальный эффективный
доступ — объединение сервисов всех хостов всех его групп, это и называется
`Forward` (`internal/model/types.go`, `State.AllowedForwards`). Переименование
хоста автоматически обновляет ссылки на него во всех группах
(`State.RenameHost`); удаление хоста снимает его со всех групп, которые его
использовали (`State.DeleteHost`) — ссылочная целостность каталога держится
в модели, а не проверяется вручную в UI.
Ключевой момент: пользователь **никогда не подключается по SSH к целевому Ключевой момент: пользователь **никогда не подключается по SSH к целевому
хосту напрямую** (ни через `ProxyJump`, ни как-то ещё) — он открывает одно хосту напрямую** (ни через `ProxyJump`, ни как-то ещё) — он открывает одно
@@ -85,17 +96,19 @@ Reverse/remote forwarding (`-R`) в этой модели не выдаётся
Пользователю нужно пробросить сразу много сервисов за одно соединение Пользователю нужно пробросить сразу много сервисов за одно соединение
(например, 10 хостов, на каждом порт 22 и порт 7888 — 20 форвардов сразу). (например, 10 хостов, на каждом порт 22 и порт 7888 — 20 форвардов сразу).
Локально на его машине два разных удалённых назначения не могут занимать Локальный порт **никогда не равен удалённому порту** — не только чтобы
один и тот же локальный порт одновременно, а несколько хостов с одинаковым избежать коллизий между несколькими хостами с одинаковым портом (обычная
портом (22 или 7888 у всех десяти) — обычная ситуация. Поэтому локальный ситуация: 22 или 7888 у всех десяти), но и потому, что многие удалённые
порт **не всегда равен удалённому**: `internal/keymgr/portalloc.go` порты привилегированные (`< 1024`, в первую очередь сам ssh на 22) —
(`AllocateLocalPorts`) для каждого форварда сначала пробует занять локальный слушателя на таком порту локально можно открыть только с правами
порт, совпадающий с удалённым (удобно и предсказуемо, когда порт встречается администратора, которых у обычного пользователя на его Windows-машине нет.
один раз), а при конфликте — берёт следующий свободный из диапазона `20000+`. `internal/keymgr/portalloc.go` (`AllocateLocalPorts`) поэтому просто выдаёт
Присвоение детерминировано (зависит только от порядка и состава форвардов локальные порты последовательно из диапазона `20000+`, без попытки
пользователя), поэтому повторный экспорт архива с тем же набором хостов даёт переиспользовать номер удалённого порта. Присвоение детерминировано
те же локальные порты. Итоговое соответствие "локальный порт → сервис" (зависит только от порядка и состава форвардов пользователя), поэтому
попадает в `INSTRUCTIONS.txt` внутри архива пользователя. повторный экспорт архива с тем же набором хостов даёт те же локальные
порты. Итоговое соответствие "локальный порт → сервис" попадает в
`INSTRUCTIONS.txt` внутри архива пользователя.
## Жизненный цикл приватного ключа ## Жизненный цикл приватного ключа
@@ -115,6 +128,19 @@ fingerprint. Это осознанное решение: кража бэкапа
ключей, выпущенных в конкретной партии, — передавайте его получателям ключей, выпущенных в конкретной партии, — передавайте его получателям
отдельным от архива каналом (голосом, в личку, не в общий чат). отдельным от архива каналом (голосом, в личку, не в общий чат).
## Подтверждение деструктивных действий
Блокировка/удаление пользователя, удаление хоста/сервиса/группы и
перевыпуск уже выданного ключа (экспорт поверх существующего) — все эти
действия либо необратимы, либо мгновенно и широко режут доступ, поэтому
подтверждаются не клавишей (`enter`/`y`, которую легко нажать рефлекторно),
а вводом заново сгенерированной случайной строки (`internal/ui/confirm.go`,
`generateConfirmToken` — 5 латинских букв, дефис, 3 цифры). Неверный ввод
просто сбрасывает поле с пояснением и оставляет на этом же экране; выйти без
последствий можно только через `esc`. Выдача **нового** ключа пользователю,
у которого его ещё не было, ничего не ломает и подтверждения не требует —
трение нужно только там, где есть что сломать.
## Бэкап и аварийное восстановление ## Бэкап и аварийное восстановление
Каталог состояния (`/var/lib/ssh-access-tui/state`) — git-репозиторий. Каталог состояния (`/var/lib/ssh-access-tui/state`) — git-репозиторий.
@@ -172,12 +198,11 @@ session" вместо тихой порчи данных. Если реальн
## Известные ограничения / что дальше ## Известные ограничения / что дальше
- Обновление клиентского конфига пользователя при появлении **нового** - Обновление клиентского конфига пользователя при появлении нового
разрешённого хоста в его группе требует повторного экспорта архива сервиса/хоста в его группе — отдельный ручной шаг («обновить конфиг»,
(что заодно ротирует ключ пользователя — так и задумано: ротация ключа не требует переиздания ключа); права доступа на сервере (`permitopen=`)
время от времени — отдельная хорошая практика, просто в MVP она не обновляются немедленно и автоматически в любом случае, независимо от
отделена от "обновить конфиг"). Удаление хоста из группы вступает в силу того, обновил ли пользователь свой локальный `config`.
немедленно на сервере (permitopen), без участия клиента.
- Архивы в `export_dir` не удаляются автоматически после доставки — - Архивы в `export_dir` не удаляются автоматически после доставки —
администратор должен удалить их вручную, когда получатель подтвердил администратор должен удалить их вручную, когда получатель подтвердил
получение. получение.
+6 -21
View File
@@ -28,29 +28,14 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
b.WriteString(" - config — настройка подключения\n") b.WriteString(" - config — настройка подключения\n")
b.WriteString(" - INSTRUCTIONS.txt — этот файл\n\n") b.WriteString(" - INSTRUCTIONS.txt — этот файл\n\n")
b.WriteString("ШАГ 1. Проверьте, что на компьютере есть SSH\n") b.WriteString("ШАГ 1. Разложите файлы по нужной папке\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("---------------------------------------\n") b.WriteString("---------------------------------------\n")
b.WriteString("Создайте (если её ещё нет) папку:\n\n") b.WriteString("Создайте (если её ещё нет) папку:\n\n")
b.WriteString(" C:\\Users\\ВАШЕ_ИМЯ\\.ssh\\\n\n") b.WriteString(" C:\\Users\\ВАШЕ_ИМЯ\\.ssh\\\n\n")
fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName) fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName)
b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\n\n") b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\n\n")
b.WriteString("ШАГ 3. Настройте доступ к ключу\n") b.WriteString("ШАГ 2. Подключитесь\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("---------------------\n") b.WriteString("---------------------\n")
b.WriteString("В PowerShell введите команду:\n\n") b.WriteString("В PowerShell введите команду:\n\n")
b.WriteString(" ssh -N jump\n\n") b.WriteString(" ssh -N jump\n\n")
@@ -59,12 +44,12 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar
b.WriteString("таблицы ниже. Закрыли окно (или выключили компьютер) — доступ пропал, просто\n") b.WriteString("таблицы ниже. Закрыли окно (или выключили компьютер) — доступ пропал, просто\n")
b.WriteString("откройте заново командой выше, когда снова понадобится.\n\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("В программе, которая должна подключиться к сервису (браузер, клиент базы\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 // 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 // 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 != "" { if f.Description != "" {
what = fmt.Sprintf("%s (%s)", f.ServiceName, 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\n", what)
fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port) fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port)
} }
@@ -118,7 +103,7 @@ func RenderConfigUpdateInstructions(username string, forwards []AllocatedForward
if f.Description != "" { if f.Description != "" {
what = fmt.Sprintf("%s (%s)", f.ServiceName, 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\n", what)
fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port) fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port)
} }
+14 -24
View File
@@ -9,39 +9,29 @@ type AllocatedForward struct {
LocalPort int LocalPort int
} }
// localPortBase is where fallback local ports are handed out from once a // localPortBase is where every local port is handed out from. Forwards are
// forward's natural remote port is already taken locally by another // never given a local port equal to their remote port: many remote ports
// forward. Chosen high enough to avoid colliding with well-known ports the // worth forwarding are privileged (< 1024 — ssh's own 22 above all), and
// user's own machine might already be using. // 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 const localPortBase = 20000
// AllocateLocalPorts assigns every forward a local port, all of which the // AllocateLocalPorts assigns every forward a local port, all of which the
// user opens at once over a single ssh connection (one LocalForward line // user opens at once over a single ssh connection (one LocalForward line
// per forward — see RenderClientConfig). This is the actual answer to // per forward — see RenderClientConfig). Ports are handed out sequentially
// "avoid collisions when forwarding everything simultaneously": many hosts // from localPortBase, so many forwards sharing the same remote port (e.g.
// commonly expose the same remote port (e.g. 22, or an app port like 7888 // port 22 repeated across ten hosts) each still get a distinct, unprivileged
// repeated across ten hosts), but only one of them can bind to that same // local port. Assignment is a pure function of the input order, so
// port number locally. Each forward keeps its remote port as the local // re-exporting an unchanged host list reproduces the same local ports.
// 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.
func AllocateLocalPorts(forwards []model.Forward) []AllocatedForward { func AllocateLocalPorts(forwards []model.Forward) []AllocatedForward {
used := make(map[int]bool, len(forwards))
next := localPortBase next := localPortBase
out := make([]AllocatedForward, 0, len(forwards)) out := make([]AllocatedForward, 0, len(forwards))
for _, f := range forwards { for _, f := range forwards {
lp := f.Port out = append(out, AllocatedForward{Forward: f, LocalPort: next})
if lp <= 0 || lp > 65535 || used[lp] {
for used[next] {
next++ next++
} }
lp = next
next++
}
used[lp] = true
out = append(out, AllocatedForward{Forward: f, LocalPort: lp})
}
return out return out
} }
+153 -15
View File
@@ -1,6 +1,10 @@
// Package model defines the data types persisted as the tool's state: // Package model defines the data types persisted as the tool's state.
// hosts (each offering one or more TCP services) grouped into access //
// groups, and users assigned to those groups. // 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 package model
import ( import (
@@ -30,20 +34,25 @@ type Service struct {
Description string `yaml:"description,omitempty"` Description string `yaml:"description,omitempty"`
} }
// Host is one jump-target reachable via port forwarding through the // Host is a catalog entry: one jump-target reachable via port forwarding
// bastion, identified by address and offering one or more Services. // 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 { type Host struct {
Name string `yaml:"name"` // short identifier, e.g. "db-prod-1" Name string `yaml:"name"` // short identifier, e.g. "db-prod-1"
Address string `yaml:"address"` // hostname or IP as seen from the jump host Address string `yaml:"address"` // hostname or IP as seen from the jump host
Description string `yaml:"description,omitempty"`
Services []Service `yaml:"services"` Services []Service `yaml:"services"`
} }
// Group bundles hosts that share the same access policy (e.g. "team-billing"). // Group is a named access policy referencing a subset of catalog hosts (by
// Users are assigned to one or more groups; their effective allowed // name — all of that host's services are included). Users are assigned to
// forwards are the union of all services on all hosts in their groups. // one or more groups; their effective allowed forwards are the union of
// services on all hosts referenced by all their groups.
type Group struct { type Group struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Hosts []Host `yaml:"hosts"` Description string `yaml:"description,omitempty"`
Hosts []string `yaml:"hosts"` // names referencing State.Hosts
} }
// UserStatus is the lifecycle state of a managed account. // 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. // State is the full persisted configuration of the tool.
type State struct { type State struct {
Hosts []Host `yaml:"hosts"` // shared catalog
Groups []Group `yaml:"groups"` Groups []Group `yaml:"groups"`
Users []User `yaml:"users"` 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. // FindGroup returns a pointer to the group with the given name, or nil.
func (s *State) FindGroup(name string) *Group { func (s *State) FindGroup(name string) *Group {
for i := range s.Groups { for i := range s.Groups {
@@ -105,11 +125,9 @@ type Forward struct {
} }
// AllowedForwards returns the de-duplicated union of (host, service) pairs // AllowedForwards returns the de-duplicated union of (host, service) pairs
// across all groups the user belongs to, in stable encounter order (group // across all catalog hosts referenced by all of the user's groups, in
// order, then host order, then service order, as defined in state.yaml). // stable encounter order. Two services that resolve to the same
// Two services that resolve to the same address:port (even from different // address:port (even from different groups) collapse to a single Forward.
// groups) collapse to a single Forward — forwarding the same destination
// twice would be meaningless.
func (s *State) AllowedForwards(u *User) []Forward { func (s *State) AllowedForwards(u *User) []Forward {
seen := map[string]bool{} seen := map[string]bool{}
var out []Forward var out []Forward
@@ -118,7 +136,11 @@ func (s *State) AllowedForwards(u *User) []Forward {
if g == nil { if g == nil {
continue continue
} }
for _, h := range g.Hosts { for _, hostName := range g.Hosts {
h := s.FindHost(hostName)
if h == nil {
continue
}
for _, svc := range h.Services { for _, svc := range h.Services {
key := fmt.Sprintf("%s:%d", h.Address, svc.Port) key := fmt.Sprintf("%s:%d", h.Address, svc.Port)
if seen[key] { if seen[key] {
@@ -137,3 +159,119 @@ func (s *State) AllowedForwards(u *User) []Forward {
} }
return out 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
}
}
+87 -58
View File
@@ -29,13 +29,14 @@ const (
scrUsers scrUsers
scrUserNew scrUserNew
scrUserNewGroups scrUserNewGroups
scrCatalogHosts
scrHostForm
scrCatalogServices
scrServiceForm
scrGroups scrGroups
scrGroupHosts scrGroupForm
scrHostNew scrGroupHostsPick
scrHostServices
scrServiceNew
scrExportSelect scrExportSelect
scrExportConfirm
scrExportResult scrExportResult
scrConfigUpdateResult scrConfigUpdateResult
scrAudit scrAudit
@@ -54,17 +55,37 @@ const (
confirmDeleteUser confirmDeleteUser
confirmDeleteHost confirmDeleteHost
confirmDeleteService 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 ( var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Padding(0, 1) titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("5")).Padding(0, 1)
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("8")).Padding(1, 2)
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).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. // Model is the root bubbletea model for the whole application.
type Model struct { type Model struct {
cfg config.Config cfg config.Config
@@ -86,23 +107,31 @@ type Model struct {
userNewCursor int userNewCursor int
userGroupsEditTarget string // non-empty: editing this existing user's groups instead of creating a new user userGroupsEditTarget string // non-empty: editing this existing user's groups instead of creating a new user
groupsList list.Model // catalog: hosts and their services, shared across groups
groupNewName textinput.Model catalogHostsList list.Model
awaitingGroup bool hostForm [3]textinput.Model // name, address, description
selectedGroup string
hostsList list.Model
hostForm [2]textinput.Model // name, address
hostFormFocus int hostFormFocus int
hostFormEditTarget string // "" = creating a new host
selectedHost string selectedHost string // catalog host currently open in scrCatalogServices/scrServiceForm
servicesList list.Model catalogServicesList list.Model
serviceForm [3]textinput.Model // name, port, description serviceForm [3]textinput.Model // name, port, description
serviceFormFocus int serviceFormFocus int
serviceFormEditTarget string // "" = creating a new service
// 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 exportSel map[string]bool
exportOrder []string exportOrder []string
exportCursor int exportCursor int
exportChosen []string // confirmed selection, pending scrExportConfirm -> runExport exportChosen []string // selection pending confirmation (if any rotation) or already run
exportRes []keymgr.BuildResult exportRes []keymgr.BuildResult
exportPass string exportPass string
exportErr string exportErr string
@@ -114,14 +143,17 @@ type Model struct {
quitPending []string // usernames with no key issued yet, shown on scrQuitConfirm quitPending []string // usernames with no key issued yet, shown on scrQuitConfirm
// generic confirmation for destructive actions (lock/delete user, // generic confirmation for destructive actions (lock/delete user,
// delete host/service) — see confirm.go. confirmTarget holds the // delete host/service/group, export key rotation) — see confirm.go.
// username/host/service name being acted on; for host/service deletion // Confirming requires typing back a freshly generated token, not just
// the group (and host, for a service) context is the already-present // pressing enter/y, so a destructive action can never fire from a
// selectedGroup/selectedHost fields above. // reflexive keypress.
pendingConfirm confirmKind pendingConfirm confirmKind
confirmTarget string confirmTarget string
confirmMessage string confirmMessage string
confirmReturnTo screen confirmReturnTo screen
confirmToken string
confirmInput textinput.Model
confirmError string
configUpdateUser string configUpdateUser string
configUpdateArchive string configUpdateArchive string
@@ -140,20 +172,13 @@ type Model struct {
func New(cfg config.Config, st *store.Store) Model { func New(cfg config.Config, st *store.Store) Model {
m := Model{cfg: cfg, st: st} m := Model{cfg: cfg, st: st}
m.menu = newMenuList() m.menu = newMenuList()
m.usersList = emptyList("Пользователи") m.usersList = newList(nil, "Пользователи", 0, 0)
m.groupsList = emptyList("Группы доступа") m.catalogHostsList = newList(nil, "Хосты и сервисы", 0, 0)
m.hostsList = emptyList("Хосты") m.catalogServicesList = newList(nil, "Сервисы", 0, 0)
m.servicesList = emptyList("Сервисы") m.groupsList = newList(nil, "Группы", 0, 0)
return m 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) Init() tea.Cmd { return nil }
func (m *Model) loadState() *model.State { 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 listW, listH := msg.Width-4, msg.Height-8
m.menu.SetSize(listW, listH) m.menu.SetSize(listW, listH)
m.usersList.SetSize(listW, listH) m.usersList.SetSize(listW, listH)
m.catalogHostsList.SetSize(listW, listH)
m.catalogServicesList.SetSize(listW, listH)
m.groupsList.SetSize(listW, listH) m.groupsList.SetSize(listW, listH)
m.hostsList.SetSize(listW, listH)
m.servicesList.SetSize(listW, listH)
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
if msg.String() == "ctrl+c" { if msg.String() == "ctrl+c" {
@@ -214,20 +239,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateUserNew(msg) return m.updateUserNew(msg)
case scrUserNewGroups: case scrUserNewGroups:
return m.updateUserNewGroups(msg) 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: case scrGroups:
return m.updateGroups(msg) return m.updateGroups(msg)
case scrGroupHosts: case scrGroupForm:
return m.updateGroupHosts(msg) return m.updateGroupForm(msg)
case scrHostNew: case scrGroupHostsPick:
return m.updateHostNew(msg) return m.updateGroupHostsPick(msg)
case scrHostServices:
return m.updateHostServices(msg)
case scrServiceNew:
return m.updateServiceNew(msg)
case scrExportSelect: case scrExportSelect:
return m.updateExportSelect(msg) return m.updateExportSelect(msg)
case scrExportConfirm:
return m.updateExportConfirm(msg)
case scrExportResult: case scrExportResult:
return m.updateExportResult(msg) return m.updateExportResult(msg)
case scrConfigUpdateResult: case scrConfigUpdateResult:
@@ -253,20 +280,22 @@ func (m Model) View() string {
body = m.viewUserNew() body = m.viewUserNew()
case scrUserNewGroups: case scrUserNewGroups:
body = m.viewUserNewGroups() 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: case scrGroups:
body = m.viewGroups() body = m.viewGroups()
case scrGroupHosts: case scrGroupForm:
body = m.viewGroupHosts() body = m.viewGroupForm()
case scrHostNew: case scrGroupHostsPick:
body = m.viewHostNew() body = m.viewGroupHostsPick()
case scrHostServices:
body = m.viewHostServices()
case scrServiceNew:
body = m.viewServiceNew()
case scrExportSelect: case scrExportSelect:
body = m.viewExportSelect() body = m.viewExportSelect()
case scrExportConfirm:
body = m.viewExportConfirm()
case scrExportResult: case scrExportResult:
body = m.viewExportResult() body = m.viewExportResult()
case scrConfigUpdateResult: 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)
}
+105 -44
View File
@@ -1,37 +1,96 @@
package ui package ui
import ( import (
"crypto/rand"
"fmt" "fmt"
"math/big"
"strings"
"time" "time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/model" "ssh-access-tui/internal/model"
"ssh-access-tui/internal/sysadm" "ssh-access-tui/internal/sysadm"
) )
// updateConfirmAction drives the single generic "are you sure" screen that const (
// backs every destructive action in the tool (lock/delete a user, delete a confirmTokenLetters = "abcdefghijklmnopqrstuvwxyz"
// host or a service). Nothing the action does runs until this confirms it; confirmTokenDigits = "0123456789"
// esc/n cancels with zero effect. )
// 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) { func (m Model) updateConfirmAction(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok { if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() { switch key.String() {
case "esc", "n": case "esc":
m.pendingConfirm = confirmNone m.pendingConfirm = confirmNone
m.screen = m.confirmReturnTo m.screen = m.confirmReturnTo
return m, nil 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.executeConfirmedAction()
} }
} }
return m, nil var cmd tea.Cmd
m.confirmInput, cmd = m.confirmInput.Update(msg)
return m, cmd
} }
func (m Model) viewConfirmAction() string { func (m Model) viewConfirmAction() string {
body := warnStyle.Render("ТРЕБУЕТСЯ ПОДТВЕРЖДЕНИЕ") + "\n\n" body := warnStyle.Render("ТРЕБУЕТСЯ ПОДТВЕРЖДЕНИЕ") + "\n\n"
body += m.confirmMessage + "\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) return borderStyle.Render(body)
} }
@@ -39,7 +98,6 @@ func (m Model) executeConfirmedAction() (tea.Model, tea.Cmd) {
kind := m.pendingConfirm kind := m.pendingConfirm
target := m.confirmTarget target := m.confirmTarget
m.pendingConfirm = confirmNone m.pendingConfirm = confirmNone
m.err, m.status = "", ""
switch kind { switch kind {
case confirmLockUser: case confirmLockUser:
@@ -50,6 +108,10 @@ func (m Model) executeConfirmedAction() (tea.Model, tea.Cmd) {
return m.doDeleteHost(target) return m.doDeleteHost(target)
case confirmDeleteService: case confirmDeleteService:
return m.doDeleteService(target) return m.doDeleteService(target)
case confirmDeleteGroup:
return m.doDeleteGroup(target)
case confirmExportRotate:
return m.runExport(m.exportChosen)
} }
m.screen = m.confirmReturnTo m.screen = m.confirmReturnTo
return m, nil return m, nil
@@ -99,52 +161,51 @@ func (m Model) doDeleteUser(username string) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// doDeleteHost removes a host (and all its services) from m.selectedGroup, // doDeleteHost removes a catalog host (and its services) entirely, dropping
// then resyncs authorized_keys for every affected user. // 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) { func (m Model) doDeleteHost(hostName string) (tea.Model, tea.Cmd) {
groupName := m.selectedGroup m.mutate("удалён хост "+hostName+" из каталога", func(st *model.State) error {
m.mutate(fmt.Sprintf("удалён хост %s из группы %s", hostName, groupName), func(st *model.State) error { affected := st.UsernamesUsingHost(hostName)
g := st.FindGroup(groupName) st.DeleteHost(hostName)
if g == nil { return resyncUsernames(st, affected)
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.screen = scrGroupHosts m.screen = scrCatalogHosts
m.refreshHostsList() m.refreshCatalogHostsList()
return m, nil return m, nil
} }
// doDeleteService removes a service from m.selectedHost within // doDeleteService removes a service from m.selectedHost, then resyncs
// m.selectedGroup, then resyncs authorized_keys for every affected user. // authorized_keys for every user that lost access as a result.
func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) { func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) {
groupName, hostName := m.selectedGroup, m.selectedHost hostName := m.selectedHost
m.mutate(fmt.Sprintf("удалён сервис %s у хоста %s", serviceName, hostName), func(st *model.State) error { m.mutate(fmt.Sprintf("удалён сервис %s у хоста %s", serviceName, hostName), func(st *model.State) error {
g := st.FindGroup(groupName) h := st.FindHost(hostName)
if g == nil { if h == nil {
return fmt.Errorf("group %s not found", groupName) return fmt.Errorf("host %s not found", hostName)
} }
for hi := range g.Hosts { for i := range h.Services {
if g.Hosts[hi].Name != hostName { if h.Services[i].Name == serviceName {
continue h.Services = append(h.Services[:i], h.Services[i+1:]...)
}
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 break
} }
} }
} return resyncUsernames(st, st.UsernamesUsingHost(hostName))
return resyncGroupUsers(st, groupName)
}) })
m.screen = scrHostServices m.screen = scrCatalogServices
m.refreshServicesList() 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 return m, nil
} }
+23 -39
View File
@@ -63,51 +63,35 @@ func (m Model) updateExportSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.exportChosen = chosen m.exportChosen = chosen
m.screen = scrExportConfirm
m.err = "" m.err = ""
return m, nil
}
}
return m, nil
}
// updateExportConfirm guards against the accidental case this screen exists // Issuing a brand new key is not destructive — nothing existing
// for: selecting users who already have a key issued re-exports them, which // breaks — so it runs immediately. Re-exporting someone who
// generates a brand new keypair and immediately invalidates whatever archive // already has a key rotates it and invalidates their current
// they were already given (their old private key stops matching // archive, which does need the typed-token confirmation.
// 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() st := m.loadState()
body := "Подтвердите экспорт\n\n" var rotating []string
anyRotate := false for _, uname := range chosen {
for _, username := range m.exportChosen { if u := st.FindUser(uname); u != nil && u.PublicKey != "" {
u := st.FindUser(username) rotating = append(rotating, uname)
if u != nil && u.PublicKey != "" { }
body += fmt.Sprintf(" %-20s %s\n", username, warnStyle.Render("ключ уже выдан — будет ПЕРЕВЫПУЩЕН, старый архив перестанет работать")) }
anyRotate = true 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 { } else {
body += fmt.Sprintf(" %-20s новый ключ\n", username) confirmMsg += fmt.Sprintf(" %-20s новый ключ\n", uname)
} }
} }
if anyRotate { m = m.startConfirm(confirmExportRotate, "", confirmMsg, scrExportSelect)
body += "\n" + warnStyle.Render("Внимание: для отмеченных выше пользователей текущий ключ будет заменён.") + "\n" return m, nil
body += warnStyle.Render("Все, у кого сейчас работает старый архив, потеряют доступ, пока им не выдадут новый.") + "\n"
} }
body += "\n[enter/y] подтвердить и сгенерировать [esc/n] отмена, ничего не создано" }
return borderStyle.Render(body) return m, nil
} }
// runExport performs the actual batch export: for each chosen user it // runExport performs the actual batch export: for each chosen user it
@@ -245,7 +229,7 @@ func (m Model) viewExportSelect() string {
} }
body += fmt.Sprintf("%s%s %-20s %s\n", cursor, check, uname, status) 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) return borderStyle.Render(body)
} }
+170 -335
View File
@@ -2,74 +2,30 @@ package ui
import ( import (
"fmt" "fmt"
"strconv"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/model" "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() { func (m *Model) refreshGroupsList() {
st := m.loadState() st := m.loadState()
items := make([]list.Item, 0, len(st.Groups)) items := make([]list.Item, 0, len(st.Groups))
for _, g := range st.Groups { for _, g := range st.Groups {
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
} }
l := list.New(items, list.NewDefaultDelegate(), m.groupsList.Width(), m.groupsList.Height()) items = append(items, simpleItem{id: g.Name, title: g.Name, desc: desc})
l.Title = "Группы доступа [n]новая группа [enter]хосты группы [esc]назад" }
l.SetShowStatusBar(false) m.groupsList = newList(items,
m.groupsList = l "Группы [n]новая [e]изменить [enter]хосты группы [d]удалить [esc]назад",
m.awaitingGroup = false 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) { 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 { if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() { switch key.String() {
case "esc": case "esc":
@@ -77,11 +33,37 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err, m.status = "", "" m.err, m.status = "", ""
return m, nil return m, nil
case "n": case "n":
m.awaitingGroup = true m.groupFormEditTarget = ""
m.groupNewName = textinput.New() for i := range m.groupForm {
m.groupNewName.Placeholder = "имя группы, например team-billing" m.groupForm[i] = textinput.New()
m.groupNewName.Focus() }
m.groupForm[0].Placeholder = "имя группы, например team-billing"
m.groupForm[1].Placeholder = "описание (необязательно)"
m.groupFormFocus = 0
m.groupForm[0].Focus()
m.err = "" 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 return m, nil
case "enter": case "enter":
sel, ok := m.groupsList.SelectedItem().(simpleItem) sel, ok := m.groupsList.SelectedItem().(simpleItem)
@@ -89,8 +71,35 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.selectedGroup = sel.id m.selectedGroup = sel.id
m.screen = scrGroupHosts st := m.loadState()
m.refreshHostsList() 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 return m, nil
} }
} }
@@ -100,316 +109,142 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m Model) viewGroups() string { 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()) return borderStyle.Render(m.groupsList.View())
} }
// --- hosts within a group --- func (m Model) updateGroupForm(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
func (m *Model) refreshHostsList() { switch key.String() {
st := m.loadState() case "esc":
g := st.FindGroup(m.selectedGroup) m.screen = scrGroups
var items []list.Item return m, nil
if g != nil { case "tab", "down":
for _, h := range g.Hosts { m.groupForm[m.groupFormFocus].Blur()
items = append(items, simpleItem{ m.groupFormFocus = (m.groupFormFocus + 1) % len(m.groupForm)
id: h.Name, m.groupForm[m.groupFormFocus].Focus()
title: h.Name, return m, nil
desc: fmt.Sprintf("%s — %d сервисов", h.Address, len(h.Services)), case "enter":
name := m.groupForm[0].Value()
desc := m.groupForm[1].Value()
if name == "" {
m.err = "имя группы не может быть пустым"
return m, nil
}
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
} }
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height()) }
l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [enter]сервисы хоста [d]удалить [esc]назад", m.selectedGroup) var cmd tea.Cmd
l.SetShowStatusBar(false) m.groupForm[m.groupFormFocus], cmd = m.groupForm[m.groupFormFocus].Update(msg)
m.hostsList = l return m, cmd
} }
// resyncGroupUsers rewrites authorized_keys permitopen= restrictions for func (m Model) viewGroupForm() string {
// every active user whose groups include groupName, reflecting the group's title := "Новая группа"
// current hosts/services — the only place server-side enforcement is if m.groupFormEditTarget != "" {
// updated when the allowed destinations change over time. title = fmt.Sprintf("Изменить группу %q", m.groupFormEditTarget)
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 body := fmt.Sprintf("%s\n\nИмя: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
for _, g := range u.Groups { title, m.groupForm[0].View(), m.groupForm[1].View())
if g == groupName { return borderStyle.Render(body)
inGroup = true
break
}
}
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)
}
}
return nil
} }
func (m Model) updateGroupHosts(msg tea.Msg) (tea.Model, tea.Cmd) { // --- 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 { if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() { switch key.String() {
case "esc": case "esc":
m.screen = scrGroups m.screen = scrGroups
m.refreshGroupsList() m.refreshGroupsList()
return m, nil return m, nil
case "n": case "up", "k":
m.screen = scrHostNew if m.groupHostsPickCursor > 0 {
for i := range m.hostForm { m.groupHostsPickCursor--
m.hostForm[i] = textinput.New() }
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 return m, nil
case "enter": case "enter":
sel, ok := m.hostsList.SelectedItem().(simpleItem) var hosts []string
if !ok { for _, h := range m.groupHostsPickOrder {
return m, nil if m.groupHostsPickSel[h] {
hosts = append(hosts, h)
} }
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
}
}
}
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 groupName := m.selectedGroup
m.err = "" m.mutate("изменён список хостов группы "+groupName, func(st *model.State) error {
var created bool
m.mutate(fmt.Sprintf("добавлен хост %s в группу %s", name, groupName), func(st *model.State) error {
g := st.FindGroup(groupName) g := st.FindGroup(groupName)
if g == nil { if g == nil {
return fmt.Errorf("group %s not found", groupName) return fmt.Errorf("group %s not found", groupName)
} }
for _, h := range g.Hosts { g.Hosts = 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})
}
return resyncGroupUsers(st, groupName) return resyncGroupUsers(st, groupName)
}) })
m.screen = scrHostServices m.screen = scrGroups
m.refreshServicesList() m.refreshGroupsList()
return m, nil return m, nil
} }
} }
var cmd tea.Cmd return m, nil
m.serviceForm[m.serviceFormFocus], cmd = m.serviceForm[m.serviceFormFocus].Update(msg)
return m, cmd
} }
func (m Model) viewServiceNew() string { func (m Model) viewGroupHostsPick() string {
body := fmt.Sprintf("Новый сервис на хосте %q\n\nИмя: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена", body := fmt.Sprintf("Какие хосты каталога входят в группу %q\n\n", m.selectedGroup)
m.selectedHost, m.serviceForm[0].View(), m.serviceForm[1].View(), m.serviceForm[2].View()) 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) return borderStyle.Render(body)
} }
+6 -5
View File
@@ -8,15 +8,13 @@ import (
func newMenuList() list.Model { func newMenuList() list.Model {
items := []list.Item{ items := []list.Item{
simpleItem{id: "users", title: "Пользователи", desc: "Создать, заблокировать, удалить учётные записи"}, 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: "export", title: "Экспорт архивов", desc: "Сгенерировать ключи и .7z архивы для доставки пользователям"},
simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"}, simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"},
simpleItem{id: "quit", title: "Выход", desc: ""}, simpleItem{id: "quit", title: "Выход", desc: ""},
} }
l := list.New(items, list.NewDefaultDelegate(), 0, 0) return newList(items, "ssh-access-tui", 0, 0)
l.Title = "ssh-access-tui"
l.SetShowStatusBar(false)
return l
} }
func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { 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": case "users":
m.screen = scrUsers m.screen = scrUsers
m.refreshUsersList() m.refreshUsersList()
case "catalog":
m.screen = scrCatalogHosts
m.refreshCatalogHostsList()
case "groups": case "groups":
m.screen = scrGroups m.screen = scrGroups
m.refreshGroupsList() 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 return m, nil
} }
username := sel.id username := sel.id
m.pendingConfirm = confirmLockUser msg := fmt.Sprintf(
m.confirmTarget = username
m.confirmReturnTo = scrUsers
m.confirmMessage = fmt.Sprintf(
"Заблокировать пользователя %q?\n\n"+ "Заблокировать пользователя %q?\n\n"+
"Аккаунт будет немедленно заблокирован (пароль отключён, срок действия\n"+ "Аккаунт будет немедленно заблокирован (пароль отключён, срок действия\n"+
"истёк, ключ в authorized_keys закомментирован) — доступ пропадёт сразу.\n"+ "истёк, ключ в authorized_keys закомментирован) — доступ пропадёт сразу.\n"+
"Это обратимо: позже можно разблокировать клавишей [u].", username) "Это обратимо: позже можно разблокировать клавишей [u].", username)
m.screen = scrConfirmAction m = m.startConfirm(confirmLockUser, username, msg, scrUsers)
return m, nil return m, nil
case "u": case "u":
sel, ok := m.usersList.SelectedItem().(simpleItem) sel, ok := m.usersList.SelectedItem().(simpleItem)
@@ -118,16 +115,13 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
username := sel.id username := sel.id
m.pendingConfirm = confirmDeleteUser msg := fmt.Sprintf(
m.confirmTarget = username
m.confirmReturnTo = scrUsers
m.confirmMessage = fmt.Sprintf(
"Удалить пользователя %q НЕОБРАТИМО?\n\n"+ "Удалить пользователя %q НЕОБРАТИМО?\n\n"+
"Будет выполнено userdel -r: системный аккаунт и его домашняя папка\n"+ "Будет выполнено userdel -r: системный аккаунт и его домашняя папка\n"+
"удаляются безвозвратно.\n"+ "удаляются безвозвратно.\n"+
"Если нужно просто временно отключить доступ — используйте блокировку\n"+ "Если нужно просто временно отключить доступ — используйте блокировку\n"+
"[l] вместо удаления, её можно отменить.", username) "[l] вместо удаления, её можно отменить.", username)
m.screen = scrConfirmAction m = m.startConfirm(confirmDeleteUser, username, msg, scrUsers)
return m, nil return m, nil
case "c": case "c":
sel, ok := m.usersList.SelectedItem().(simpleItem) sel, ok := m.usersList.SelectedItem().(simpleItem)
BIN
View File
Binary file not shown.