v0.2.0
This commit is contained in:
1 parent
23c7133fa9
commit
8c1b93c14e
16 files changed
+1154
-262
No files matched your search
+94
-36
@@ -16,7 +16,16 @@ sudo ssh-access-tui
|
|||||||
перезапишет чужие изменения.
|
перезапишет чужие изменения.
|
||||||
|
|
||||||
Навигация везде: стрелки/`j`/`k` — перемещение по списку, `enter` —
|
Навигация везде: стрелки/`j`/`k` — перемещение по списку, `enter` —
|
||||||
выбрать/подтвердить, `esc` — назад, `ctrl+c` — выход в любой момент.
|
выбрать/подтвердить, `esc` — назад, `ctrl+c` — жёсткий выход в любой момент
|
||||||
|
(без каких-либо предупреждений — только для аварийных случаев). Обычный
|
||||||
|
выход через пункт меню «Выход» или `q` в главном меню предупредит, если
|
||||||
|
есть созданные, но ни разу не экспортированные пользователи (у них ещё нет
|
||||||
|
ключа) — можно выйти всё равно (`enter`/`y`) или вернуться (`esc`/`n`).
|
||||||
|
|
||||||
|
Любое деструктивное действие (блокировка/удаление пользователя, удаление
|
||||||
|
хоста или сервиса) сначала показывает отдельный экран подтверждения с
|
||||||
|
пояснением последствий — ничего не происходит до `enter`/`y`; `esc`/`n`
|
||||||
|
отменяет без каких-либо изменений.
|
||||||
|
|
||||||
## Главное меню
|
## Главное меню
|
||||||
|
|
||||||
@@ -28,74 +37,121 @@ sudo ssh-access-tui
|
|||||||
Выход
|
Выход
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Имена групп, хостов и сервисов должны быть без пробелов (латиница, цифры,
|
||||||
|
`. _ -`) — они попадают в конфиг и в `authorized_keys`; инструмент откажет с
|
||||||
|
понятной ошибкой, если ввести имя с пробелом.
|
||||||
|
|
||||||
## Сценарий 1: новый сотрудник, у которого уже есть роль/группа доступа
|
## Сценарий 1: новый сотрудник, у которого уже есть роль/группа доступа
|
||||||
|
|
||||||
Обычно сначала настраивают группу (один раз), потом заводят пользователей в
|
Обычно сначала настраивают группу (один раз), потом заводят пользователей в
|
||||||
неё пачками.
|
неё пачками. Иерархия: **группа** → **хосты** → **сервисы (порты) на хосте**
|
||||||
|
— на одном хосте может быть несколько сервисов (например, `ssh` на 22 и
|
||||||
|
`web-api` на 7888), каждый добавляется отдельно.
|
||||||
|
|
||||||
1. **Группы и хосты** → `n` → ввести имя группы (например `team-billing`) →
|
1. **Группы и хосты** → `n` → ввести имя группы (например `team-billing`) →
|
||||||
`enter`.
|
`enter`.
|
||||||
2. Выбрать группу → `enter`, попасть в список хостов группы (изначально
|
2. Выбрать группу → `enter`, попасть в список хостов группы (изначально
|
||||||
пуст).
|
пуст).
|
||||||
3. `n` → заполнить: имя хоста (короткий алиас, например `db-prod-1`), адрес
|
3. `n` → заполнить: имя хоста (короткое, например `db-prod-1`), адрес (как
|
||||||
(как виден с jump-хоста), порт, описание (необязательно) → `tab` между
|
виден с jump-хоста) → `tab` между полями → `enter`. Сразу после этого
|
||||||
полями → `enter` сохранить. Повторить для каждого внутреннего хоста,
|
попадаете в список сервисов этого хоста (тоже изначально пуст).
|
||||||
который должен быть доступен этой группе.
|
4. `n` → заполнить сервис: имя (например `ssh` или `web-api`), порт на
|
||||||
4. `esc` дважды — назад в главное меню.
|
хосте (например `22` или `7888`), описание (необязательно) → `enter`.
|
||||||
5. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин)
|
Повторите `n` столько раз, сколько сервисов есть на этом хосте.
|
||||||
|
5. `esc` — назад к списку хостов группы; повторите шаги 3-4 для остальных
|
||||||
|
хостов группы, затем `esc` дважды — назад в главное меню.
|
||||||
|
6. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин)
|
||||||
и комментарий (например ФИО) → `enter`.
|
и комментарий (например ФИО) → `enter`.
|
||||||
6. Отметить `space` нужные группы (можно несколько — эффективный список
|
7. Отметить `space` нужные группы (можно несколько — эффективный доступ
|
||||||
хостов пользователя = объединение хостов всех отмеченных групп) → `enter`.
|
пользователя = объединение сервисов всех хостов всех отмеченных групп) →
|
||||||
|
`enter`.
|
||||||
|
|
||||||
Это уже создаёт системный Linux-аккаунт (без пароля, без интерактивного
|
Это уже создаёт системный Linux-аккаунт (без пароля, без интерактивного
|
||||||
шелла) и запись в состоянии. **Ключ ещё не выдан** — статус в списке
|
шелла) и запись в состоянии. **Ключ ещё не выдан** — статус в списке
|
||||||
пользователей покажет «ключ не выдан».
|
пользователей покажет «ключ не выдан».
|
||||||
|
|
||||||
7. **Экспорт архивов** → отметить `space` только что созданного пользователя
|
8. **Экспорт архивов** → отметить `space` только что созданного пользователя
|
||||||
(или `a` — отметить всех, удобно при пакетном найме) → `enter`.
|
(или `a` — отметить всех, удобно при пакетном найме) → `enter`. Список
|
||||||
8. Инструмент сгенерирует ключ, пропишет `permitopen=` под текущие хосты
|
сразу показывает у каждого пользователя, будет ли выдан новый ключ или
|
||||||
группы, соберёт `.7z` и покажет **один общий пароль на всю партию** — он
|
существующий будет **перевыпущен** (актуально, если случайно попадёт в
|
||||||
|
выборку уже работающий пользователь).
|
||||||
|
9. Экран подтверждения: для тех, у кого ключ уже был, явно предупредит, что
|
||||||
|
старый архив перестанет работать — только после `enter`/`y` (либо `esc`/`n`
|
||||||
|
для отмены, тогда не создаётся и не меняется вообще ничего) инструмент
|
||||||
|
сгенерирует ключ, пропишет `permitopen=` под текущие сервисы группы и
|
||||||
|
соберёт `.7z`, показав **один общий пароль на всю партию** — он
|
||||||
показывается один раз, запишите/скопируйте сразу.
|
показывается один раз, запишите/скопируйте сразу.
|
||||||
9. Передайте каждому пользователю его архив (файл, путь показан на экране,
|
10. Передайте каждому пользователю его архив (файл, путь показан на экране,
|
||||||
лежит в `export_dir`) любым каналом, а пароль от архива — **отдельным**
|
лежит в `export_dir`) любым каналом, а пароль от архива — **отдельным**
|
||||||
каналом (голосом, в личку) и по возможности не всем одним и тем же
|
каналом (голосом, в личку) и по возможности не всем одним и тем же
|
||||||
сообщением в общем чате.
|
сообщением в общем чате.
|
||||||
10. Пользователь распаковывает архив на Windows по инструкции
|
11. Пользователь распаковывает архив на Windows по инструкции
|
||||||
`INSTRUCTIONS.txt` внутри архива (она уже сгенерирована под его
|
`INSTRUCTIONS.txt` внутри архива — она уже на простом русском языке,
|
||||||
конкретные разрешённые хосты).
|
без технических терминов: просто список "локальный адрес → какой это
|
||||||
|
сервис → куда он реально ведёт", составленный под конкретные разрешённые
|
||||||
|
сервисы этого пользователя.
|
||||||
|
|
||||||
## Сценарий 2: добавили новый разрешённый хост существующей группе
|
## Сценарий 1а: изменить группы уже существующего пользователя
|
||||||
|
|
||||||
|
**Пользователи** → выбрать пользователя → `g` → отметить/снять `space`
|
||||||
|
нужные группы → `enter`. Если у пользователя уже выдан ключ, инструмент
|
||||||
|
сразу пересоберёт его `permitopen=` под новый набор сервисов — доступ
|
||||||
|
меняется немедленно, без переотправки архива и **без замены ключа**
|
||||||
|
(изменение групп никогда не ротирует ключ).
|
||||||
|
|
||||||
|
Если хотите, чтобы у пользователя появился ещё и обновлённый клиентский
|
||||||
|
`config` с новыми `LocalForward` для новых сервисов (иначе он про них
|
||||||
|
просто не узнает, хотя доступ уже есть) — **Пользователи** → выбрать
|
||||||
|
пользователя → `c` («обновить конфиг»). Это генерирует маленький архив
|
||||||
|
только с `config` + `INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже
|
||||||
|
есть и остаётся тем же), пользователю нужно только подменить файл `config`.
|
||||||
|
Не требует подтверждения — ключ и доступ этим действием не меняются, это не
|
||||||
|
деструктивно.
|
||||||
|
|
||||||
|
Полный экспорт (со сценария 1, шаги 8-9) тоже сработает и даст то же самое
|
||||||
|
плюс заново выпустит ключ — используйте `c`, когда переиздание ключа не
|
||||||
|
нужно (обычный случай при простом изменении групп).
|
||||||
|
|
||||||
|
## Сценарий 2: добавили новый сервис/хост существующей группе
|
||||||
|
|
||||||
1. **Группы и хосты** → выбрать группу → `enter`.
|
1. **Группы и хосты** → выбрать группу → `enter`.
|
||||||
2. `n` → заполнить новый хост → `enter`.
|
2. Либо `n` — новый хост (затем добавить ему хотя бы один сервис), либо
|
||||||
|
выбрать существующий хост → `enter` → `n` — новый сервис на нём.
|
||||||
|
|
||||||
Это сразу обновляет `permitopen=` в `authorized_keys` **всех активных
|
Это сразу обновляет `permitopen=` в `authorized_keys` **всех активных
|
||||||
пользователей** этой группы — доступ появляется немедленно, без каких-либо
|
пользователей** этой группы — доступ появляется немедленно, без каких-либо
|
||||||
действий на стороне пользователя. Клиентский конфиг пользователя (его
|
действий на стороне пользователя. Клиентский конфиг пользователя (его
|
||||||
`~/.ssh/config`) при этом не обновляется автоматически — если хотите, чтобы
|
`~/.ssh/config`) при этом не обновляется автоматически — если хотите, чтобы
|
||||||
у пользователя появился готовый алиас для нового хоста, а не только "голый"
|
у пользователя появилась готовая строка `LocalForward` для нового сервиса, а
|
||||||
доступ, повторно прогоните его через **Экспорт архивов** (это заодно
|
не только "голый" доступ на сервере, используйте **Пользователи** → `c`
|
||||||
ротирует его ключ — считайте это плановой ротацией, не проблемой).
|
(«обновить конфиг») — ключ при этом не меняется (см. сценарий 1а).
|
||||||
|
|
||||||
Удаление хоста из группы (`d` в списке хостов) действует так же немедленно
|
Удаление сервиса или целого хоста (`d` в соответствующем списке) — оба
|
||||||
и в обратную сторону.
|
действия **деструктивны** (мгновенно отбирают доступ у всех пользователей
|
||||||
|
группы) и требуют подтверждения: экран покажет, что именно удаляется и
|
||||||
|
скольких пользователей это коснётся, `enter`/`y` подтверждает, `esc`/`n`
|
||||||
|
отменяет без каких-либо изменений.
|
||||||
|
|
||||||
## Сценарий 3: сотрудник уволился
|
## Сценарий 3: сотрудник уволился
|
||||||
|
|
||||||
1. **Пользователи** → выбрать пользователя → `l` (заблокировать).
|
1. **Пользователи** → выбрать пользователя → `l` (заблокировать) → экран
|
||||||
|
подтверждения объяснит, что произойдёт → `enter`/`y`.
|
||||||
|
|
||||||
Это одновременно: блокирует пароль аккаунта, устанавливает немедленный срок
|
Блокировка — это одновременно: блокирует пароль аккаунта, устанавливает
|
||||||
истечения аккаунта (`chage -E 1`) и комментирует его ключ в
|
немедленный срок истечения аккаунта (`chage -E 1`) и комментирует его ключ в
|
||||||
`authorized_keys` — три независимых механизма, чтобы блокировка не зависела
|
`authorized_keys` — три независимых механизма, чтобы блокировка не зависела
|
||||||
от одного из них. Статус в списке сменится на `locked`.
|
от одного из них. Статус в списке сменится на `locked`. Это **обратимо**.
|
||||||
|
|
||||||
Если сотрудник вернулся — `u` (разблокировать) на том же пользователе:
|
Если сотрудник вернулся — `u` (разблокировать) на том же пользователе:
|
||||||
снимает блокировку аккаунта и восстанавливает его ключ в `authorized_keys`
|
снимает блокировку аккаунта и восстанавливает его ключ в `authorized_keys`
|
||||||
с текущими правами групп.
|
с текущими правами групп. Разблокировка не деструктивна — подтверждения не
|
||||||
|
требует, срабатывает сразу.
|
||||||
|
|
||||||
Полное удаление аккаунта (`d`) — необратимо (`userdel -r`, домашняя папка
|
Полное удаление аккаунта (`d`) — **необратимо** (`userdel -r`, домашняя папка
|
||||||
стирается), используйте для окончательного оффбординга, не для временной
|
стирается) и тоже требует подтверждения (экран явно предупредит, что это
|
||||||
блокировки.
|
необратимо и что для временного отключения лучше использовать блокировку).
|
||||||
|
Используйте удаление только для окончательного оффбординга, не для
|
||||||
|
временной блокировки.
|
||||||
|
|
||||||
## Сценарий 4: проверить, кто сейчас подключён / посмотреть историю
|
## Сценарий 4: проверить, кто сейчас подключён / посмотреть историю
|
||||||
|
|
||||||
@@ -125,9 +181,11 @@ Linux-аккаунты для активных пользователей, вы
|
|||||||
## Что не делает эта версия (нужно руками)
|
## Что не делает эта версия (нужно руками)
|
||||||
|
|
||||||
- Не удаляет `.7z` из `export_dir` после доставки — сделайте это сами,
|
- Не удаляет `.7z` из `export_dir` после доставки — сделайте это сами,
|
||||||
когда получатель подтвердил, что распаковал архив.
|
когда получатель подтвердил, что распаковал архив (это касается и
|
||||||
|
архивов от «обновить конфиг», хоть они и не содержат ключ).
|
||||||
- Не обновляет клиентский `~/.ssh/config` пользователя автоматически при
|
- Не обновляет клиентский `~/.ssh/config` пользователя автоматически при
|
||||||
появлении нового хоста в его группе — только права доступа на сервере
|
появлении нового сервиса/хоста в его группе — это ручной шаг («обновить
|
||||||
обновляются немедленно (см. сценарий 2).
|
конфиг», сценарий 1а), права доступа на сервере при этом обновляются
|
||||||
|
немедленно и без участия администратора (см. сценарий 2).
|
||||||
- Не поддерживает одновременную работу нескольких администраторов —
|
- Не поддерживает одновременную работу нескольких администраторов —
|
||||||
рассчитан на одного за раз (см. «Конкурентность» в ARCHITECTURE.md).
|
рассчитан на одного за раз (см. «Конкурентность» в ARCHITECTURE.md).
|
||||||
+49
-12
@@ -19,6 +19,24 @@ TUI работает поверх уже существующего SSH+`sudo`-
|
|||||||
реализовать вручную (см. «Конкурентность» ниже). Для небольшой команды
|
реализовать вручную (см. «Конкурентность» ниже). Для небольшой команды
|
||||||
администраторов это разумный компромисс.
|
администраторов это разумный компромисс.
|
||||||
|
|
||||||
|
## Модель доступа: Host → Service → Forward
|
||||||
|
|
||||||
|
Хост (`Host`) — это адрес (например, `172.16.1.20`). На одном хосте может
|
||||||
|
быть несколько сервисов (`Service`) — независимых TCP-портов: `ssh` на 22,
|
||||||
|
какое-то приложение на 7888, и так далее. Группа (`Group`) — это набор
|
||||||
|
хостов (со всеми их сервисами), пользователь может состоять в нескольких
|
||||||
|
группах; его реальный эффективный доступ — объединение сервисов всех хостов
|
||||||
|
всех его групп, это и называется `Forward` (`internal/model/types.go`,
|
||||||
|
`State.AllowedForwards`).
|
||||||
|
|
||||||
|
Ключевой момент: пользователь **никогда не подключается по SSH к целевому
|
||||||
|
хосту напрямую** (ни через `ProxyJump`, ни как-то ещё) — он открывает одно
|
||||||
|
соединение к самому jump-хосту и локально пробрасывает (`LocalForward`)
|
||||||
|
порты к каждому нужному сервису через это соединение. Не важно, слушает ли
|
||||||
|
на целевом порту настоящий SSH (22) или произвольный TCP-сервис (7888) —
|
||||||
|
для форвардинга это не имеет значения, оба варианта — это просто
|
||||||
|
"локальный порт → host:port через jump".
|
||||||
|
|
||||||
## Как на самом деле работает ограничение "только прыжок/форвардинг"
|
## Как на самом деле работает ограничение "только прыжок/форвардинг"
|
||||||
|
|
||||||
Это два независимых слоя, и путать их нельзя:
|
Это два независимых слоя, и путать их нельзя:
|
||||||
@@ -27,18 +45,20 @@ TUI работает поверх уже существующего SSH+`sudo`-
|
|||||||
`authorized_keys` пользователя приписаны опции:
|
`authorized_keys` пользователя приписаны опции:
|
||||||
|
|
||||||
```
|
```
|
||||||
restrict,port-forwarding,permitopen="10.0.1.5:5432",permitopen="10.0.2.9:443" ssh-ed25519 AAAA... user@jump
|
restrict,port-forwarding,permitopen="172.16.1.20:22",permitopen="172.16.1.20:7888",permitopen="172.16.1.21:22" ssh-ed25519 AAAA... user@jump
|
||||||
```
|
```
|
||||||
|
|
||||||
`restrict` разом отключает pty, X11-forwarding, agent-forwarding,
|
Один `permitopen=` на каждый `Forward` пользователя (`internal/sysadm/authorizedkeys.go`,
|
||||||
user-rc и port-forwarding; `port-forwarding` точечно возвращает именно
|
`BuildKeyOptions`) — не важно, сколько сервисов на одном хосте или сколько
|
||||||
форвардинг (не интерактивный доступ), а `permitopen=` разрешает
|
разных хостов используют один и тот же номер порта, каждая пара
|
||||||
форвардинг только к перечисленным `host:port` — это гарантия на уровне
|
`host:port` разрешается независимо. `restrict` разом отключает pty,
|
||||||
протокола SSH, не зависящая от того, что написано в клиентском конфиге.
|
X11-forwarding, agent-forwarding, user-rc и port-forwarding;
|
||||||
Shell пользователя — `/usr/sbin/nologin`: любой запрос shell/exec-канала
|
`port-forwarding` точечно возвращает именно форвардинг (не интерактивный
|
||||||
(обычный интерактивный `ssh user@jump`) обрывается им же, а
|
доступ). Это гарантия на уровне протокола SSH, не зависящая от того, что
|
||||||
direct-tcpip-каналы (прыжок/форвардинг) идут отдельным путём и
|
написано в клиентском конфиге. Shell пользователя — `/usr/sbin/nologin`:
|
||||||
регулируются исключительно `permitopen=`.
|
любой запрос shell/exec-канала (обычный интерактивный `ssh user@jump`)
|
||||||
|
обрывается им же, а direct-tcpip-каналы (форвардинг) идут отдельным путём
|
||||||
|
и регулируются исключительно `permitopen=`.
|
||||||
|
|
||||||
Дополнительно в `Match Group ssh-jump-users` (`internal/sysadm/sshdconfig.go`)
|
Дополнительно в `Match Group ssh-jump-users` (`internal/sysadm/sshdconfig.go`)
|
||||||
продублированы базовые ограничения (`PermitTTY no`, `X11Forwarding no`,
|
продублированы базовые ограничения (`PermitTTY no`, `X11Forwarding no`,
|
||||||
@@ -47,11 +67,12 @@ TUI работает поверх уже существующего SSH+`sudo`-
|
|||||||
повреждены/потеряны, а не основной механизм.
|
повреждены/потеряны, а не основной механизм.
|
||||||
|
|
||||||
2. **Клиент (только удобство).** Файл `~/.ssh/config`, который уезжает
|
2. **Клиент (только удобство).** Файл `~/.ssh/config`, который уезжает
|
||||||
пользователю в архиве, — это алиасы (`Host db-prod-1` → `ProxyJump jump`).
|
пользователю в архиве, — это один блок `Host jump` с набором строк
|
||||||
|
`LocalForward <локальный_порт> <host>:<port>` (`internal/keymgr/clientconfig.go`).
|
||||||
Он ничего не запрещает: пользователь может отредактировать его или
|
Он ничего не запрещает: пользователь может отредактировать его или
|
||||||
вызвать `ssh` руками с любым хостом. Единственная причина, по которой это
|
вызвать `ssh` руками с любым хостом. Единственная причина, по которой это
|
||||||
не работает, — сервер отклонит `permitopen`, которого нет в списке.
|
не работает, — сервер отклонит `permitopen`, которого нет в списке.
|
||||||
Список хостов в клиентском конфиге должен генерироваться из того же
|
Список форвардов в клиентском конфиге должен генерироваться из того же
|
||||||
источника правды (группы в `state.yaml`), что и `permitopen=`, — иначе
|
источника правды (группы в `state.yaml`), что и `permitopen=`, — иначе
|
||||||
конфиг и реальные права разойдутся.
|
конфиг и реальные права разойдутся.
|
||||||
|
|
||||||
@@ -60,6 +81,22 @@ Reverse/remote forwarding (`-R`) в этой модели не выдаётся
|
|||||||
решению при проектировании: этот джамп-хост используется только для входа
|
решению при проектировании: этот джамп-хост используется только для входа
|
||||||
внутрь сети, не для проброса портов наружу из неё.
|
внутрь сети, не для проброса портов наружу из неё.
|
||||||
|
|
||||||
|
## Коллизии локальных портов
|
||||||
|
|
||||||
|
Пользователю нужно пробросить сразу много сервисов за одно соединение
|
||||||
|
(например, 10 хостов, на каждом порт 22 и порт 7888 — 20 форвардов сразу).
|
||||||
|
Локально на его машине два разных удалённых назначения не могут занимать
|
||||||
|
один и тот же локальный порт одновременно, а несколько хостов с одинаковым
|
||||||
|
портом (22 или 7888 у всех десяти) — обычная ситуация. Поэтому локальный
|
||||||
|
порт **не всегда равен удалённому**: `internal/keymgr/portalloc.go`
|
||||||
|
(`AllocateLocalPorts`) для каждого форварда сначала пробует занять локальный
|
||||||
|
порт, совпадающий с удалённым (удобно и предсказуемо, когда порт встречается
|
||||||
|
один раз), а при конфликте — берёт следующий свободный из диапазона `20000+`.
|
||||||
|
Присвоение детерминировано (зависит только от порядка и состава форвардов
|
||||||
|
пользователя), поэтому повторный экспорт архива с тем же набором хостов даёт
|
||||||
|
те же локальные порты. Итоговое соответствие "локальный порт → сервис"
|
||||||
|
попадает в `INSTRUCTIONS.txt` внутри архива пользователя.
|
||||||
|
|
||||||
## Жизненный цикл приватного ключа
|
## Жизненный цикл приватного ключа
|
||||||
|
|
||||||
Приватный ключ **никогда не хранится на диске дольше одной операции
|
Приватный ключ **никогда не хранится на диске дольше одной операции
|
||||||
|
|||||||
@@ -3,42 +3,37 @@ package keymgr
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ssh-access-tui/internal/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderClientConfig produces an OpenSSH client config (works identically on
|
// RenderClientConfig produces an OpenSSH client config (works identically on
|
||||||
// Windows' built-in OpenSSH client, macOS, and Linux) that gives the user a
|
// Windows' built-in OpenSSH client, macOS, and Linux). It defines exactly
|
||||||
// "jump" alias plus one alias per host they're allowed to reach.
|
// one connection, "jump", carrying one LocalForward per allowed service —
|
||||||
|
// the user opens it once (see INSTRUCTIONS.txt) and every service becomes
|
||||||
|
// reachable at once on the listed local ports, for as long as that one
|
||||||
|
// connection stays open.
|
||||||
//
|
//
|
||||||
// This file is convenience only, not a security boundary: nothing stops a
|
// This file is convenience only, not a security boundary: nothing stops a
|
||||||
// user from editing it or invoking ssh by hand. The actual restriction to
|
// user from editing it or invoking ssh by hand. The actual restriction to
|
||||||
// these hosts is enforced server-side via authorized_keys permitopen=
|
// these destinations is enforced server-side via authorized_keys
|
||||||
// (see internal/sysadm/authorizedkeys.go) independent of what this file says.
|
// permitopen= (see internal/sysadm/authorizedkeys.go) independent of what
|
||||||
func RenderClientConfig(jumpHost string, jumpPort int, username, keyFileName string, hosts []model.Host) string {
|
// this file says.
|
||||||
|
func RenderClientConfig(jumpHost string, jumpPort int, username, keyFileName string, forwards []AllocatedForward) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "# ssh-access-tui client config for %s\n", username)
|
fmt.Fprintf(&b, "# ssh-access-tui client config for %s\n", username)
|
||||||
fmt.Fprintf(&b, "# Generated %s. Regenerated whenever your allowed host list changes.\n\n", genTimestamp())
|
fmt.Fprintf(&b, "# Generated %s. Regenerated whenever your allowed services change.\n\n", genTimestamp())
|
||||||
|
|
||||||
fmt.Fprintf(&b, "Host jump\n")
|
fmt.Fprintf(&b, "Host jump\n")
|
||||||
fmt.Fprintf(&b, " HostName %s\n", jumpHost)
|
fmt.Fprintf(&b, " HostName %s\n", jumpHost)
|
||||||
fmt.Fprintf(&b, " Port %d\n", jumpPort)
|
fmt.Fprintf(&b, " Port %d\n", jumpPort)
|
||||||
fmt.Fprintf(&b, " User %s\n", username)
|
fmt.Fprintf(&b, " User %s\n", username)
|
||||||
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
|
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
|
||||||
fmt.Fprintf(&b, " IdentitiesOnly yes\n\n")
|
|
||||||
|
|
||||||
for _, h := range hosts {
|
|
||||||
fmt.Fprintf(&b, "Host %s\n", h.Name)
|
|
||||||
fmt.Fprintf(&b, " HostName %s\n", h.Address)
|
|
||||||
fmt.Fprintf(&b, " Port %d\n", h.Port)
|
|
||||||
fmt.Fprintf(&b, " User %s\n", username)
|
|
||||||
fmt.Fprintf(&b, " ProxyJump jump\n")
|
|
||||||
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
|
|
||||||
fmt.Fprintf(&b, " IdentitiesOnly yes\n")
|
fmt.Fprintf(&b, " IdentitiesOnly yes\n")
|
||||||
if h.Description != "" {
|
for _, f := range forwards {
|
||||||
fmt.Fprintf(&b, " # %s\n", h.Description)
|
comment := f.HostName + "/" + f.ServiceName
|
||||||
|
if f.Description != "" {
|
||||||
|
comment += ": " + f.Description
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
fmt.Fprintf(&b, " LocalForward %d %s:%d # %s\n", f.LocalPort, f.Address, f.Port, comment)
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package keymgr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"ssh-access-tui/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigOnlyResult describes a lightweight config-refresh archive: no
|
||||||
|
// keypair is generated or touched.
|
||||||
|
type ConfigOnlyResult struct {
|
||||||
|
Username string
|
||||||
|
ArchivePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildConfigOnlyArchive packages just a fresh client config + instructions
|
||||||
|
// (no key files at all) reflecting the user's CURRENT allowed forwards.
|
||||||
|
//
|
||||||
|
// This exists for the common case where only a user's group membership
|
||||||
|
// changed: server-side access is already updated immediately (see
|
||||||
|
// sysadm.WriteAuthorizedKeys / resyncGroupUsers, called the moment groups
|
||||||
|
// change), and their existing private key is still perfectly valid — only
|
||||||
|
// the local ssh config listing which services are reachable is now stale.
|
||||||
|
// Reissuing a whole new keypair for that would be needless churn (and
|
||||||
|
// needless key material handed around). The config's IdentityFile line
|
||||||
|
// references the same deterministic filename GeneratePair always used
|
||||||
|
// (username + "_jump_key"), so it matches the key the user already has.
|
||||||
|
func BuildConfigOnlyArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward) (*ConfigOnlyResult, error) {
|
||||||
|
userScratch := filepath.Join(scratchDir, username+"_configonly")
|
||||||
|
if err := os.MkdirAll(userScratch, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("create scratch dir: %w", err)
|
||||||
|
}
|
||||||
|
defer Wipe(userScratch)
|
||||||
|
|
||||||
|
keyFileName := username + "_jump_key"
|
||||||
|
allocated := AllocateLocalPorts(forwards)
|
||||||
|
|
||||||
|
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated)
|
||||||
|
if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("write config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
instr := RenderConfigUpdateInstructions(username, allocated)
|
||||||
|
if err := os.WriteFile(filepath.Join(userScratch, "INSTRUCTIONS.txt"), []byte(instr), 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("write instructions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(outDir, 0o750); err != nil {
|
||||||
|
return nil, fmt.Errorf("create output dir: %w", err)
|
||||||
|
}
|
||||||
|
archivePath := filepath.Join(outDir, username+"_config_update.7z")
|
||||||
|
_ = os.Remove(archivePath)
|
||||||
|
|
||||||
|
cmd := exec.Command("7z", "a",
|
||||||
|
"-p"+password,
|
||||||
|
"-mhe=on",
|
||||||
|
"-mx=9",
|
||||||
|
archivePath,
|
||||||
|
filepath.Join(userScratch, "config"),
|
||||||
|
filepath.Join(userScratch, "INSTRUCTIONS.txt"),
|
||||||
|
)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("7z: %w: %s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ConfigOnlyResult{Username: username, ArchivePath: archivePath}, nil
|
||||||
|
}
|
||||||
+102
-53
@@ -4,79 +4,128 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ssh-access-tui/internal/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func genTimestamp() string {
|
func genTimestamp() string {
|
||||||
return time.Now().UTC().Format("2006-01-02 15:04 UTC")
|
return time.Now().UTC().Format("2006-01-02 15:04 UTC")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderInstructions produces a plain-text README aimed at a Windows user
|
// RenderInstructions produces a plain-Russian, no-jargon README for a
|
||||||
// whose only guaranteed tool is the OpenSSH client bundled with Windows
|
// non-technical Windows user whose only guaranteed tool is the OpenSSH
|
||||||
// 10/11 ("Optional Features" -> OpenSSH Client, or already present).
|
// client bundled with Windows 10/11. Deliberately says nothing about
|
||||||
func RenderInstructions(username, jumpHost, keyFileName string, hosts []model.Host) string {
|
// "rights", "forwarding", "jump hosts" etc — just: run this one command and
|
||||||
|
// keep it open, then these local ports lead to these services.
|
||||||
|
func RenderInstructions(username, keyFileName string, forwards []AllocatedForward) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
b.WriteString("SSH ACCESS — SETUP INSTRUCTIONS (Windows)\n")
|
b.WriteString("КАК ПОДКЛЮЧИТЬСЯ (Windows)\n")
|
||||||
b.WriteString("==========================================\n\n")
|
b.WriteString("===========================\n\n")
|
||||||
fmt.Fprintf(&b, "Account: %s\n", username)
|
fmt.Fprintf(&b, "Ваш логин: %s\n\n", username)
|
||||||
fmt.Fprintf(&b, "Jump host: %s\n\n", jumpHost)
|
|
||||||
|
|
||||||
b.WriteString("This archive contains:\n")
|
b.WriteString("В этом архиве 4 файла:\n")
|
||||||
fmt.Fprintf(&b, " - %s your PRIVATE key (keep secret, do not share or send onward)\n", keyFileName)
|
fmt.Fprintf(&b, " - %s — ваш секретный ключ (никому не передавайте и не пересылайте)\n", keyFileName)
|
||||||
fmt.Fprintf(&b, " - %s.pub the matching public key (for reference only)\n", keyFileName)
|
fmt.Fprintf(&b, " - %s.pub — открытая часть ключа (можно не трогать)\n", keyFileName)
|
||||||
b.WriteString(" - config SSH client config with ready-made host shortcuts\n")
|
b.WriteString(" - config — настройка подключения\n")
|
||||||
b.WriteString(" - INSTRUCTIONS.txt this file\n\n")
|
b.WriteString(" - INSTRUCTIONS.txt — этот файл\n\n")
|
||||||
|
|
||||||
b.WriteString("1. Check you have the SSH client\n")
|
b.WriteString("ШАГ 1. Проверьте, что на компьютере есть SSH\n")
|
||||||
b.WriteString(" Open PowerShell and run:\n")
|
b.WriteString("--------------------------------------------\n")
|
||||||
b.WriteString(" ssh -V\n")
|
b.WriteString("Откройте программу PowerShell (найдите её через Пуск) и введите:\n\n")
|
||||||
b.WriteString(" If this fails: Settings -> Apps -> Optional Features -> Add a feature ->\n")
|
b.WriteString(" ssh -V\n\n")
|
||||||
b.WriteString(" \"OpenSSH Client\" -> Install. (Windows 10 1809+/11 ship this by default.)\n\n")
|
b.WriteString("Если появилась ошибка — зайдите в Параметры Windows -> Приложения ->\n")
|
||||||
|
b.WriteString("Дополнительные компоненты -> Добавить компонент -> найдите «OpenSSH Client»\n")
|
||||||
|
b.WriteString("-> Установить.\n\n")
|
||||||
|
|
||||||
b.WriteString("2. Put the files in place\n")
|
b.WriteString("ШАГ 2. Разложите файлы по нужной папке\n")
|
||||||
b.WriteString(" Create the folder (if it doesn't exist) and copy the 3 files into it:\n")
|
b.WriteString("---------------------------------------\n")
|
||||||
b.WriteString(" %USERPROFILE%\\.ssh\\\n")
|
b.WriteString("Создайте (если её ещё нет) папку:\n\n")
|
||||||
b.WriteString(" i.e. C:\\Users\\<you>\\.ssh\\\n")
|
b.WriteString(" C:\\Users\\ВАШЕ_ИМЯ\\.ssh\\\n\n")
|
||||||
b.WriteString(" The \"config\" file should be merged with (or placed as) that exact filename,\n")
|
fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName)
|
||||||
b.WriteString(" with no file extension: C:\\Users\\<you>\\.ssh\\config\n\n")
|
b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\n\n")
|
||||||
|
|
||||||
b.WriteString("3. Fix the private key permissions (Windows OpenSSH refuses keys that are\n")
|
b.WriteString("ШАГ 3. Настройте доступ к ключу\n")
|
||||||
b.WriteString(" readable by other users). In PowerShell:\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 /inheritance:r\n", keyFileName)
|
||||||
fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /grant:r \"$env:USERNAME:(R)\"\n\n", keyFileName)
|
fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /grant:r \"$env:USERNAME:(R)\"\n\n", keyFileName)
|
||||||
|
|
||||||
b.WriteString("4. Connect\n")
|
b.WriteString("ШАГ 4. Подключитесь\n")
|
||||||
b.WriteString(" You have shortcuts for each host you're allowed to reach. To open a local\n")
|
b.WriteString("---------------------\n")
|
||||||
b.WriteString(" port forward to one of them (example: a web service on port 8443):\n\n")
|
b.WriteString("В PowerShell введите команду:\n\n")
|
||||||
if len(hosts) > 0 {
|
b.WriteString(" ssh -N jump\n\n")
|
||||||
h := hosts[0]
|
b.WriteString("Окно «зависнет» и ничего не будет происходить — это нормально, так и должно\n")
|
||||||
fmt.Fprintf(&b, " ssh -N -L 8443:localhost:%d %s\n", h.Port, h.Name)
|
b.WriteString("быть. НЕ ЗАКРЫВАЙТЕ это окно — пока оно открыто, работают все сервисы из\n")
|
||||||
b.WriteString(" Then browse to https://localhost:8443 (or whatever local port you chose)\n")
|
b.WriteString("таблицы ниже. Закрыли окно (или выключили компьютер) — доступ пропал, просто\n")
|
||||||
b.WriteString(" while that command keeps running. Ctrl+C to stop.\n\n")
|
b.WriteString("откройте заново командой выше, когда снова понадобится.\n\n")
|
||||||
} else {
|
|
||||||
b.WriteString(" ssh -N -L <local-port>:localhost:<remote-port> <host-alias>\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(" Your allowed hosts (aliases from the config file):\n")
|
b.WriteString("ШАГ 5. Пользуйтесь\n")
|
||||||
for _, h := range hosts {
|
b.WriteString("--------------------\n")
|
||||||
fmt.Fprintf(&b, " - %-20s -> %s:%d", h.Name, h.Address, h.Port)
|
b.WriteString("Эта настройка открывает у вас на компьютере (локально) следующие адреса.\n")
|
||||||
if h.Description != "" {
|
b.WriteString("В программе, которая должна подключиться к сервису (браузер, клиент базы\n")
|
||||||
fmt.Fprintf(&b, " (%s)", h.Description)
|
b.WriteString("данных и т.п.), укажите вместо настоящего адреса тот, что указан ниже как\n")
|
||||||
|
b.WriteString("«localhost:порт» — и она подключится к нужному сервису через это соединение.\n\n")
|
||||||
|
|
||||||
|
// A numbered list rather than an aligned table: this file is opened in
|
||||||
|
// Notepad with whatever font the user has, so column alignment based on
|
||||||
|
// character counts (especially mixing Cyrillic and digits) can't be
|
||||||
|
// relied on to line up — a simple list reads correctly regardless.
|
||||||
|
for i, f := range forwards {
|
||||||
|
what := f.ServiceName
|
||||||
|
if f.Description != "" {
|
||||||
|
what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description)
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
fmt.Fprintf(&b, " %d. localhost:%d\n", i+1, f.LocalPort)
|
||||||
|
fmt.Fprintf(&b, " Это: %s\n", what)
|
||||||
|
fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port)
|
||||||
|
}
|
||||||
|
if len(forwards) == 0 {
|
||||||
|
b.WriteString(" (сервисы ещё не назначены — обратитесь к администратору)\n")
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
b.WriteString("5. You will NOT get an interactive shell\n")
|
b.WriteString("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n")
|
||||||
b.WriteString(" Running plain \"ssh jump\" will connect and immediately close — this account\n")
|
b.WriteString("------------------------\n")
|
||||||
b.WriteString(" is jump/port-forwarding only, by design. Use the -L/-D forwarding commands\n")
|
b.WriteString("Обратитесь к администратору, который выдал вам этот архив. Возможно,\n")
|
||||||
b.WriteString(" above (via the host aliases, which already route through the jump host).\n\n")
|
b.WriteString("изменился список сервисов — тогда вам пришлют обновлённый архив.\n")
|
||||||
|
|
||||||
b.WriteString("If access stops working, contact your administrator — your account may have\n")
|
|
||||||
b.WriteString("been locked, or the allowed host list may have changed (ask for an updated\n")
|
|
||||||
b.WriteString("config in that case).\n")
|
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderConfigUpdateInstructions is a short follow-up notice for a user who
|
||||||
|
// already completed the full setup once (RenderInstructions) and just needs
|
||||||
|
// to know their service list changed — their key did not change and needs
|
||||||
|
// no attention, only the config file is replaced.
|
||||||
|
func RenderConfigUpdateInstructions(username string, forwards []AllocatedForward) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString("ОБНОВЛЕНИЕ СПИСКА СЕРВИСОВ\n")
|
||||||
|
b.WriteString("===========================\n\n")
|
||||||
|
fmt.Fprintf(&b, "Ваш логин: %s\n\n", username)
|
||||||
|
b.WriteString("Ваш ключ НЕ менялся — трогать его не нужно, продолжайте пользоваться тем же.\n")
|
||||||
|
b.WriteString("Изменился только список сервисов, поэтому нужно заменить один файл.\n\n")
|
||||||
|
|
||||||
|
b.WriteString("Замените файл config (из этого архива) на:\n\n")
|
||||||
|
b.WriteString(" C:\\Users\\ВАШЕ_ИМЯ\\.ssh\\config\n\n")
|
||||||
|
b.WriteString("(тот же путь, что и раньше — просто перезапишите старый файл новым).\n\n")
|
||||||
|
|
||||||
|
b.WriteString("Дальше подключайтесь как обычно, командой:\n\n")
|
||||||
|
b.WriteString(" ssh -N jump\n\n")
|
||||||
|
|
||||||
|
b.WriteString("Актуальный список сервисов:\n\n")
|
||||||
|
for i, f := range forwards {
|
||||||
|
what := f.ServiceName
|
||||||
|
if f.Description != "" {
|
||||||
|
what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %d. localhost:%d\n", i+1, f.LocalPort)
|
||||||
|
fmt.Fprintf(&b, " Это: %s\n", what)
|
||||||
|
fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port)
|
||||||
|
}
|
||||||
|
if len(forwards) == 0 {
|
||||||
|
b.WriteString(" (сервисы не назначены — обратитесь к администратору)\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("Если что-то не работает — обратитесь к администратору.\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ type BuildResult struct {
|
|||||||
// under outDir, and then wipes the scratch directory (including the
|
// under outDir, and then wipes the scratch directory (including the
|
||||||
// plaintext private key) from disk. password is shared across one export
|
// plaintext private key) from disk. password is shared across one export
|
||||||
// batch and must be communicated to the recipient out-of-band.
|
// batch and must be communicated to the recipient out-of-band.
|
||||||
func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, hosts []model.Host) (*BuildResult, error) {
|
func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward) (*BuildResult, error) {
|
||||||
userScratch := filepath.Join(scratchDir, username)
|
userScratch := filepath.Join(scratchDir, username)
|
||||||
if err := os.MkdirAll(userScratch, 0o700); err != nil {
|
if err := os.MkdirAll(userScratch, 0o700); err != nil {
|
||||||
return nil, fmt.Errorf("create scratch dir: %w", err)
|
return nil, fmt.Errorf("create scratch dir: %w", err)
|
||||||
@@ -36,12 +36,14 @@ func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, usernam
|
|||||||
}
|
}
|
||||||
keyFileName := filepath.Base(kp.PrivateKeyPath)
|
keyFileName := filepath.Base(kp.PrivateKeyPath)
|
||||||
|
|
||||||
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, hosts)
|
allocated := AllocateLocalPorts(forwards)
|
||||||
|
|
||||||
|
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated)
|
||||||
if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil {
|
if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil {
|
||||||
return nil, fmt.Errorf("write config: %w", err)
|
return nil, fmt.Errorf("write config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
instr := RenderInstructions(username, jumpHost, keyFileName, hosts)
|
instr := RenderInstructions(username, keyFileName, allocated)
|
||||||
if err := os.WriteFile(filepath.Join(userScratch, "INSTRUCTIONS.txt"), []byte(instr), 0o600); err != nil {
|
if err := os.WriteFile(filepath.Join(userScratch, "INSTRUCTIONS.txt"), []byte(instr), 0o600); err != nil {
|
||||||
return nil, fmt.Errorf("write instructions: %w", err)
|
return nil, fmt.Errorf("write instructions: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package keymgr
|
||||||
|
|
||||||
|
import "ssh-access-tui/internal/model"
|
||||||
|
|
||||||
|
// AllocatedForward is a model.Forward together with the local port it will
|
||||||
|
// be bound to on the user's machine.
|
||||||
|
type AllocatedForward struct {
|
||||||
|
model.Forward
|
||||||
|
LocalPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
// localPortBase is where fallback local ports are handed out from once a
|
||||||
|
// forward's natural remote port is already taken locally by another
|
||||||
|
// forward. Chosen high enough to avoid colliding with well-known ports the
|
||||||
|
// user's own machine might already be using.
|
||||||
|
const localPortBase = 20000
|
||||||
|
|
||||||
|
// AllocateLocalPorts assigns every forward a local port, all of which the
|
||||||
|
// user opens at once over a single ssh connection (one LocalForward line
|
||||||
|
// per forward — see RenderClientConfig). This is the actual answer to
|
||||||
|
// "avoid collisions when forwarding everything simultaneously": many hosts
|
||||||
|
// commonly expose the same remote port (e.g. 22, or an app port like 7888
|
||||||
|
// repeated across ten hosts), but only one of them can bind to that same
|
||||||
|
// port number locally. Each forward keeps its remote port as the local
|
||||||
|
// port when that's still free (so the common case reads naturally, e.g.
|
||||||
|
// remote 7888 -> local 7888), and falls back to the next free port from
|
||||||
|
// localPortBase upward on collision. Assignment is a pure function of the
|
||||||
|
// input order and set, so re-exporting an unchanged host list reproduces
|
||||||
|
// the same local ports.
|
||||||
|
func AllocateLocalPorts(forwards []model.Forward) []AllocatedForward {
|
||||||
|
used := make(map[int]bool, len(forwards))
|
||||||
|
next := localPortBase
|
||||||
|
out := make([]AllocatedForward, 0, len(forwards))
|
||||||
|
for _, f := range forwards {
|
||||||
|
lp := f.Port
|
||||||
|
if lp <= 0 || lp > 65535 || used[lp] {
|
||||||
|
for used[next] {
|
||||||
|
next++
|
||||||
|
}
|
||||||
|
lp = next
|
||||||
|
next++
|
||||||
|
}
|
||||||
|
used[lp] = true
|
||||||
|
out = append(out, AllocatedForward{Forward: f, LocalPort: lp})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+68
-43
@@ -1,20 +1,46 @@
|
|||||||
// 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 grouped into access groups, and users assigned to those groups.
|
// hosts (each offering one or more TCP services) grouped into access
|
||||||
|
// groups, and users assigned to those groups.
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// Host is one jump-target reachable via port forwarding through the bastion.
|
// nameRe restricts identifiers that end up as ssh_config tokens or
|
||||||
type Host struct {
|
// authorized_keys comments: no spaces, no shell/config metacharacters.
|
||||||
Name string `yaml:"name"` // short identifier, e.g. "db-prod-1"
|
var nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||||
Address string `yaml:"address"` // hostname or IP as seen from the jump host
|
|
||||||
Port int `yaml:"port"` // TCP port allowed for forwarding
|
// ValidName reports whether s is safe to use as a group/host/service name:
|
||||||
|
// letters, digits, dot, underscore, hyphen, no spaces. Names end up as
|
||||||
|
// ssh_config comments/tokens and authorized_keys metadata, where a stray
|
||||||
|
// space or "#" would corrupt the generated file.
|
||||||
|
func ValidName(s string) bool {
|
||||||
|
return nameRe.MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service is one TCP port reachable on a Host — e.g. the host's sshd on
|
||||||
|
// port 22, or an unrelated application on port 7888. A single Host commonly
|
||||||
|
// exposes several of these.
|
||||||
|
type Service struct {
|
||||||
|
Name string `yaml:"name"` // short identifier, e.g. "ssh" or "web-api"
|
||||||
|
Port int `yaml:"port"` // remote TCP port on the host
|
||||||
Description string `yaml:"description,omitempty"`
|
Description string `yaml:"description,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host is one jump-target reachable via port forwarding through the
|
||||||
|
// bastion, identified by address and offering one or more Services.
|
||||||
|
type Host struct {
|
||||||
|
Name string `yaml:"name"` // short identifier, e.g. "db-prod-1"
|
||||||
|
Address string `yaml:"address"` // hostname or IP as seen from the jump host
|
||||||
|
Services []Service `yaml:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
// Group bundles hosts that share the same access policy (e.g. "team-billing").
|
// Group bundles hosts that share the same access policy (e.g. "team-billing").
|
||||||
// Users are assigned to one or more groups; their effective allowed hosts are
|
// Users are assigned to one or more groups; their effective allowed
|
||||||
// the union of all hosts in their groups.
|
// forwards are the union of all services on all hosts in their groups.
|
||||||
type Group struct {
|
type Group struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Hosts []Host `yaml:"hosts"`
|
Hosts []Host `yaml:"hosts"`
|
||||||
@@ -66,49 +92,48 @@ func (s *State) FindUser(username string) *User {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowedHosts returns the de-duplicated union of hosts across all groups
|
// Forward is one flattened (host, service) pair a user is allowed to reach.
|
||||||
// the user belongs to, sorted by "name" for stable authorized_keys output.
|
// This is the actual unit both server-side enforcement (authorized_keys
|
||||||
func (s *State) AllowedHosts(u *User) []Host {
|
// "permitopen=") and the client config (one "LocalForward" line per
|
||||||
seen := map[string]Host{}
|
// Forward) are built from.
|
||||||
order := []string{}
|
type Forward struct {
|
||||||
|
HostName string
|
||||||
|
Address string
|
||||||
|
Port int
|
||||||
|
ServiceName string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedForwards returns the de-duplicated union of (host, service) pairs
|
||||||
|
// across all groups the user belongs to, in stable encounter order (group
|
||||||
|
// order, then host order, then service order, as defined in state.yaml).
|
||||||
|
// Two services that resolve to the same address:port (even from different
|
||||||
|
// groups) collapse to a single Forward — forwarding the same destination
|
||||||
|
// twice would be meaningless.
|
||||||
|
func (s *State) AllowedForwards(u *User) []Forward {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []Forward
|
||||||
for _, gname := range u.Groups {
|
for _, gname := range u.Groups {
|
||||||
g := s.FindGroup(gname)
|
g := s.FindGroup(gname)
|
||||||
if g == nil {
|
if g == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, h := range g.Hosts {
|
for _, h := range g.Hosts {
|
||||||
key := h.Address + ":" + itoa(h.Port)
|
for _, svc := range h.Services {
|
||||||
if _, ok := seen[key]; !ok {
|
key := fmt.Sprintf("%s:%d", h.Address, svc.Port)
|
||||||
order = append(order, key)
|
if seen[key] {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
seen[key] = h
|
seen[key] = true
|
||||||
|
out = append(out, Forward{
|
||||||
|
HostName: h.Name,
|
||||||
|
Address: h.Address,
|
||||||
|
Port: svc.Port,
|
||||||
|
ServiceName: svc.Name,
|
||||||
|
Description: svc.Description,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out := make([]Host, 0, len(order))
|
|
||||||
for _, k := range order {
|
|
||||||
out = append(out, seen[k])
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func itoa(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
neg := n < 0
|
|
||||||
if neg {
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
var buf [20]byte
|
|
||||||
i := len(buf)
|
|
||||||
for n > 0 {
|
|
||||||
i--
|
|
||||||
buf[i] = byte('0' + n%10)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
i--
|
|
||||||
buf[i] = '-'
|
|
||||||
}
|
|
||||||
return string(buf[i:])
|
|
||||||
}
|
|
||||||
@@ -19,20 +19,23 @@ const managedMarker = "# ssh-access-tui managed key — do not edit by hand, use
|
|||||||
// actual security boundary for a jump/forward-only account:
|
// actual security boundary for a jump/forward-only account:
|
||||||
// - restrict disables pty, X11, agent-forwarding, user-rc, etc.
|
// - restrict disables pty, X11, agent-forwarding, user-rc, etc.
|
||||||
// - port-forwarding re-enables (only) local/dynamic forwarding
|
// - port-forwarding re-enables (only) local/dynamic forwarding
|
||||||
// - permitopen=... whitelists exactly the host:port pairs this key
|
// - permitopen=... whitelists exactly the address:port pairs this key
|
||||||
// may forward to — this is what makes "only these hosts" a protocol-level
|
// may forward to — this is what makes "only these destinations" a
|
||||||
// guarantee rather than a client-side convention.
|
// protocol-level guarantee rather than a client-side convention. Every
|
||||||
|
// service on every host the user's groups grant becomes one
|
||||||
|
// permitopen= entry here, independent of how many services share a
|
||||||
|
// host or how many hosts share a port number.
|
||||||
//
|
//
|
||||||
// Reverse forwarding (-R) is intentionally never granted here.
|
// Reverse forwarding (-R) is intentionally never granted here.
|
||||||
func BuildKeyOptions(hosts []model.Host) string {
|
func BuildKeyOptions(forwards []model.Forward) string {
|
||||||
if len(hosts) == 0 {
|
if len(forwards) == 0 {
|
||||||
// No hosts assigned yet: restrict everything, permit nothing, so the
|
// No forwards assigned yet: restrict everything, permit nothing, so
|
||||||
// key authenticates but cannot open forwarding channels anywhere.
|
// the key authenticates but cannot open forwarding channels anywhere.
|
||||||
return "restrict"
|
return "restrict"
|
||||||
}
|
}
|
||||||
opts := []string{"restrict", "port-forwarding"}
|
opts := []string{"restrict", "port-forwarding"}
|
||||||
for _, h := range hosts {
|
for _, f := range forwards {
|
||||||
opts = append(opts, fmt.Sprintf(`permitopen="%s:%s"`, h.Address, strconv.Itoa(h.Port)))
|
opts = append(opts, fmt.Sprintf(`permitopen="%s:%s"`, f.Address, strconv.Itoa(f.Port)))
|
||||||
}
|
}
|
||||||
return strings.Join(opts, ",")
|
return strings.Join(opts, ",")
|
||||||
}
|
}
|
||||||
@@ -40,7 +43,7 @@ func BuildKeyOptions(hosts []model.Host) string {
|
|||||||
// WriteAuthorizedKeys writes the single managed key line (with its
|
// WriteAuthorizedKeys writes the single managed key line (with its
|
||||||
// permitopen restrictions) into ~<username>/.ssh/authorized_keys, replacing
|
// permitopen restrictions) into ~<username>/.ssh/authorized_keys, replacing
|
||||||
// any previous ssh-access-tui-managed line for that user.
|
// any previous ssh-access-tui-managed line for that user.
|
||||||
func WriteAuthorizedKeys(username, pubKeyLine string, hosts []model.Host) error {
|
func WriteAuthorizedKeys(username, pubKeyLine string, forwards []model.Forward) error {
|
||||||
u, err := user.Lookup(username)
|
u, err := user.Lookup(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("lookup user %s: %w", username, err)
|
return fmt.Errorf("lookup user %s: %w", username, err)
|
||||||
@@ -49,7 +52,7 @@ func WriteAuthorizedKeys(username, pubKeyLine string, hosts []model.Host) error
|
|||||||
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
||||||
return fmt.Errorf("create .ssh dir: %w", err)
|
return fmt.Errorf("create .ssh dir: %w", err)
|
||||||
}
|
}
|
||||||
line := fmt.Sprintf("%s %s", BuildKeyOptions(hosts), strings.TrimSpace(pubKeyLine))
|
line := fmt.Sprintf("%s %s", BuildKeyOptions(forwards), strings.TrimSpace(pubKeyLine))
|
||||||
content := managedMarker + "\n" + line + "\n"
|
content := managedMarker + "\n" + line + "\n"
|
||||||
|
|
||||||
akPath := filepath.Join(sshDir, "authorized_keys")
|
akPath := filepath.Join(sshDir, "authorized_keys")
|
||||||
|
|||||||
+70
-1
@@ -32,9 +32,28 @@ const (
|
|||||||
scrGroups
|
scrGroups
|
||||||
scrGroupHosts
|
scrGroupHosts
|
||||||
scrHostNew
|
scrHostNew
|
||||||
|
scrHostServices
|
||||||
|
scrServiceNew
|
||||||
scrExportSelect
|
scrExportSelect
|
||||||
|
scrExportConfirm
|
||||||
scrExportResult
|
scrExportResult
|
||||||
|
scrConfigUpdateResult
|
||||||
scrAudit
|
scrAudit
|
||||||
|
scrQuitConfirm
|
||||||
|
scrConfirmAction
|
||||||
|
)
|
||||||
|
|
||||||
|
// confirmKind identifies which destructive action scrConfirmAction is
|
||||||
|
// guarding, so a single generic confirm screen can back every one of them
|
||||||
|
// instead of duplicating the "are you sure" plumbing per action.
|
||||||
|
type confirmKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
confirmNone confirmKind = iota
|
||||||
|
confirmLockUser
|
||||||
|
confirmDeleteUser
|
||||||
|
confirmDeleteHost
|
||||||
|
confirmDeleteService
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -65,18 +84,25 @@ type Model struct {
|
|||||||
userNewGroupsSel map[string]bool
|
userNewGroupsSel map[string]bool
|
||||||
userNewGroupsOrder []string
|
userNewGroupsOrder []string
|
||||||
userNewCursor int
|
userNewCursor int
|
||||||
|
userGroupsEditTarget string // non-empty: editing this existing user's groups instead of creating a new user
|
||||||
|
|
||||||
groupsList list.Model
|
groupsList list.Model
|
||||||
groupNewName textinput.Model
|
groupNewName textinput.Model
|
||||||
awaitingGroup bool
|
awaitingGroup bool
|
||||||
selectedGroup string
|
selectedGroup string
|
||||||
hostsList list.Model
|
hostsList list.Model
|
||||||
hostForm [4]textinput.Model // name, address, port, description
|
hostForm [2]textinput.Model // name, address
|
||||||
hostFormFocus int
|
hostFormFocus int
|
||||||
|
|
||||||
|
selectedHost string
|
||||||
|
servicesList list.Model
|
||||||
|
serviceForm [3]textinput.Model // name, port, description
|
||||||
|
serviceFormFocus int
|
||||||
|
|
||||||
exportSel map[string]bool
|
exportSel map[string]bool
|
||||||
exportOrder []string
|
exportOrder []string
|
||||||
exportCursor int
|
exportCursor int
|
||||||
|
exportChosen []string // confirmed selection, pending scrExportConfirm -> runExport
|
||||||
exportRes []keymgr.BuildResult
|
exportRes []keymgr.BuildResult
|
||||||
exportPass string
|
exportPass string
|
||||||
exportErr string
|
exportErr string
|
||||||
@@ -84,6 +110,23 @@ type Model struct {
|
|||||||
onlineSessions []audit.OnlineSession
|
onlineSessions []audit.OnlineSession
|
||||||
recentEvents []audit.Event
|
recentEvents []audit.Event
|
||||||
|
|
||||||
|
quitReturnTo screen // screen to go back to if quit is cancelled
|
||||||
|
quitPending []string // usernames with no key issued yet, shown on scrQuitConfirm
|
||||||
|
|
||||||
|
// generic confirmation for destructive actions (lock/delete user,
|
||||||
|
// delete host/service) — see confirm.go. confirmTarget holds the
|
||||||
|
// username/host/service name being acted on; for host/service deletion
|
||||||
|
// the group (and host, for a service) context is the already-present
|
||||||
|
// selectedGroup/selectedHost fields above.
|
||||||
|
pendingConfirm confirmKind
|
||||||
|
confirmTarget string
|
||||||
|
confirmMessage string
|
||||||
|
confirmReturnTo screen
|
||||||
|
|
||||||
|
configUpdateUser string
|
||||||
|
configUpdateArchive string
|
||||||
|
configUpdatePass string
|
||||||
|
|
||||||
width, height int
|
width, height int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +143,7 @@ func New(cfg config.Config, st *store.Store) Model {
|
|||||||
m.usersList = emptyList("Пользователи")
|
m.usersList = emptyList("Пользователи")
|
||||||
m.groupsList = emptyList("Группы доступа")
|
m.groupsList = emptyList("Группы доступа")
|
||||||
m.hostsList = emptyList("Хосты")
|
m.hostsList = emptyList("Хосты")
|
||||||
|
m.servicesList = emptyList("Сервисы")
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +197,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.usersList.SetSize(listW, listH)
|
m.usersList.SetSize(listW, listH)
|
||||||
m.groupsList.SetSize(listW, listH)
|
m.groupsList.SetSize(listW, listH)
|
||||||
m.hostsList.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" {
|
||||||
@@ -175,12 +220,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updateGroupHosts(msg)
|
return m.updateGroupHosts(msg)
|
||||||
case scrHostNew:
|
case scrHostNew:
|
||||||
return m.updateHostNew(msg)
|
return m.updateHostNew(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:
|
||||||
|
return m.updateConfigUpdateResult(msg)
|
||||||
case scrAudit:
|
case scrAudit:
|
||||||
return m.updateAudit(msg)
|
return m.updateAudit(msg)
|
||||||
|
case scrQuitConfirm:
|
||||||
|
return m.updateQuitConfirm(msg)
|
||||||
|
case scrConfirmAction:
|
||||||
|
return m.updateConfirmAction(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -202,12 +259,24 @@ func (m Model) View() string {
|
|||||||
body = m.viewGroupHosts()
|
body = m.viewGroupHosts()
|
||||||
case scrHostNew:
|
case scrHostNew:
|
||||||
body = m.viewHostNew()
|
body = m.viewHostNew()
|
||||||
|
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:
|
||||||
|
body = m.viewConfigUpdateResult()
|
||||||
case scrAudit:
|
case scrAudit:
|
||||||
body = m.viewAudit()
|
body = m.viewAudit()
|
||||||
|
case scrQuitConfirm:
|
||||||
|
body = m.viewQuitConfirm()
|
||||||
|
case scrConfirmAction:
|
||||||
|
body = m.viewConfirmAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
footer := ""
|
footer := ""
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"ssh-access-tui/internal/model"
|
||||||
|
"ssh-access-tui/internal/sysadm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateConfirmAction drives the single generic "are you sure" screen that
|
||||||
|
// backs every destructive action in the tool (lock/delete a user, delete a
|
||||||
|
// host or a service). Nothing the action does runs until this confirms it;
|
||||||
|
// esc/n cancels with zero effect.
|
||||||
|
func (m Model) updateConfirmAction(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch key.String() {
|
||||||
|
case "esc", "n":
|
||||||
|
m.pendingConfirm = confirmNone
|
||||||
|
m.screen = m.confirmReturnTo
|
||||||
|
return m, nil
|
||||||
|
case "enter", "y":
|
||||||
|
return m.executeConfirmedAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewConfirmAction() string {
|
||||||
|
body := warnStyle.Render("ТРЕБУЕТСЯ ПОДТВЕРЖДЕНИЕ") + "\n\n"
|
||||||
|
body += m.confirmMessage + "\n\n"
|
||||||
|
body += "[enter/y] подтвердить [esc/n] отмена, ничего не изменится"
|
||||||
|
return borderStyle.Render(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) executeConfirmedAction() (tea.Model, tea.Cmd) {
|
||||||
|
kind := m.pendingConfirm
|
||||||
|
target := m.confirmTarget
|
||||||
|
m.pendingConfirm = confirmNone
|
||||||
|
m.err, m.status = "", ""
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case confirmLockUser:
|
||||||
|
return m.doLockUser(target)
|
||||||
|
case confirmDeleteUser:
|
||||||
|
return m.doDeleteUser(target)
|
||||||
|
case confirmDeleteHost:
|
||||||
|
return m.doDeleteHost(target)
|
||||||
|
case confirmDeleteService:
|
||||||
|
return m.doDeleteService(target)
|
||||||
|
}
|
||||||
|
m.screen = m.confirmReturnTo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) doLockUser(username string) (tea.Model, tea.Cmd) {
|
||||||
|
if err := sysadm.LockSystemUser(username); err != nil {
|
||||||
|
m.err = err.Error()
|
||||||
|
m.screen = scrUsers
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
_ = sysadm.DisableAuthorizedKeys(username)
|
||||||
|
m.mutate("заблокирован пользователь "+username, func(st *model.State) error {
|
||||||
|
u := st.FindUser(username)
|
||||||
|
if u == nil {
|
||||||
|
return fmt.Errorf("user %s not found in state", username)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
u.Status = model.StatusLocked
|
||||||
|
u.LockedAt = &now
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
m.logAudit(username, "admin", "locked")
|
||||||
|
m.screen = scrUsers
|
||||||
|
m.refreshUsersList()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) doDeleteUser(username string) (tea.Model, tea.Cmd) {
|
||||||
|
if err := sysadm.DeleteSystemUser(username); err != nil {
|
||||||
|
m.err = err.Error()
|
||||||
|
m.screen = scrUsers
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.mutate("удалён пользователь "+username, func(st *model.State) error {
|
||||||
|
for i := range st.Users {
|
||||||
|
if st.Users[i].Username == username {
|
||||||
|
st.Users = append(st.Users[:i], st.Users[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
m.logAudit(username, "admin", "deleted")
|
||||||
|
m.screen = scrUsers
|
||||||
|
m.refreshUsersList()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doDeleteHost removes a host (and all its services) from m.selectedGroup,
|
||||||
|
// then resyncs authorized_keys for every affected user.
|
||||||
|
func (m Model) doDeleteHost(hostName string) (tea.Model, tea.Cmd) {
|
||||||
|
groupName := m.selectedGroup
|
||||||
|
m.mutate(fmt.Sprintf("удалён хост %s из группы %s", hostName, groupName), func(st *model.State) error {
|
||||||
|
g := st.FindGroup(groupName)
|
||||||
|
if g == nil {
|
||||||
|
return fmt.Errorf("group %s not found", groupName)
|
||||||
|
}
|
||||||
|
for i := range g.Hosts {
|
||||||
|
if g.Hosts[i].Name == hostName {
|
||||||
|
g.Hosts = append(g.Hosts[:i], g.Hosts[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resyncGroupUsers(st, groupName)
|
||||||
|
})
|
||||||
|
m.screen = scrGroupHosts
|
||||||
|
m.refreshHostsList()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doDeleteService removes a service from m.selectedHost within
|
||||||
|
// m.selectedGroup, then resyncs authorized_keys for every affected user.
|
||||||
|
func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) {
|
||||||
|
groupName, hostName := m.selectedGroup, m.selectedHost
|
||||||
|
m.mutate(fmt.Sprintf("удалён сервис %s у хоста %s", serviceName, hostName), func(st *model.State) error {
|
||||||
|
g := st.FindGroup(groupName)
|
||||||
|
if g == nil {
|
||||||
|
return fmt.Errorf("group %s not found", groupName)
|
||||||
|
}
|
||||||
|
for hi := range g.Hosts {
|
||||||
|
if g.Hosts[hi].Name != hostName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
svcs := g.Hosts[hi].Services
|
||||||
|
for si := range svcs {
|
||||||
|
if svcs[si].Name == serviceName {
|
||||||
|
g.Hosts[hi].Services = append(svcs[:si], svcs[si+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resyncGroupUsers(st, groupName)
|
||||||
|
})
|
||||||
|
m.screen = scrHostServices
|
||||||
|
m.refreshServicesList()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
+115
-9
@@ -62,12 +62,54 @@ func (m Model) updateExportSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.err = "выберите хотя бы одного пользователя (space)"
|
m.err = "выберите хотя бы одного пользователя (space)"
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m.runExport(chosen)
|
m.exportChosen = chosen
|
||||||
|
m.screen = scrExportConfirm
|
||||||
|
m.err = ""
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateExportConfirm guards against the accidental case this screen exists
|
||||||
|
// for: selecting users who already have a key issued re-exports them, which
|
||||||
|
// generates a brand new keypair and immediately invalidates whatever archive
|
||||||
|
// they were already given (their old private key stops matching
|
||||||
|
// authorized_keys). Nothing is generated or written until this is confirmed.
|
||||||
|
func (m Model) updateExportConfirm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch key.String() {
|
||||||
|
case "esc", "n":
|
||||||
|
m.screen = scrExportSelect
|
||||||
|
return m, nil
|
||||||
|
case "enter", "y":
|
||||||
|
return m.runExport(m.exportChosen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewExportConfirm() string {
|
||||||
|
st := m.loadState()
|
||||||
|
body := "Подтвердите экспорт\n\n"
|
||||||
|
anyRotate := false
|
||||||
|
for _, username := range m.exportChosen {
|
||||||
|
u := st.FindUser(username)
|
||||||
|
if u != nil && u.PublicKey != "" {
|
||||||
|
body += fmt.Sprintf(" %-20s %s\n", username, warnStyle.Render("ключ уже выдан — будет ПЕРЕВЫПУЩЕН, старый архив перестанет работать"))
|
||||||
|
anyRotate = true
|
||||||
|
} else {
|
||||||
|
body += fmt.Sprintf(" %-20s новый ключ\n", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anyRotate {
|
||||||
|
body += "\n" + warnStyle.Render("Внимание: для отмеченных выше пользователей текущий ключ будет заменён.") + "\n"
|
||||||
|
body += warnStyle.Render("Все, у кого сейчас работает старый архив, потеряют доступ, пока им не выдадут новый.") + "\n"
|
||||||
|
}
|
||||||
|
body += "\n[enter/y] подтвердить и сгенерировать [esc/n] отмена, ничего не создано"
|
||||||
|
return borderStyle.Render(body)
|
||||||
|
}
|
||||||
|
|
||||||
// runExport performs the actual batch export: for each chosen user it
|
// runExport performs the actual batch export: for each chosen user it
|
||||||
// (re)generates a keypair, rewrites authorized_keys with that user's
|
// (re)generates a keypair, rewrites authorized_keys with that user's
|
||||||
// currently-allowed hosts, builds the encrypted archive, and records the new
|
// currently-allowed hosts, builds the encrypted archive, and records the new
|
||||||
@@ -91,13 +133,13 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) {
|
|||||||
failures = append(failures, username+": не найден в состоянии")
|
failures = append(failures, username+": не найден в состоянии")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hosts := st.AllowedHosts(u)
|
forwards := st.AllowedForwards(u)
|
||||||
res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, hosts)
|
res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failures = append(failures, username+": "+err.Error())
|
failures = append(failures, username+": "+err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := sysadm.WriteAuthorizedKeys(username, res.PublicKeyLine, hosts); err != nil {
|
if err := sysadm.WriteAuthorizedKeys(username, res.PublicKeyLine, forwards); err != nil {
|
||||||
failures = append(failures, username+": authorized_keys: "+err.Error())
|
failures = append(failures, username+": authorized_keys: "+err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -123,23 +165,87 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runConfigUpdate is the lightweight alternative to a full export: when only
|
||||||
|
// a user's group membership changed, their access is already updated
|
||||||
|
// server-side immediately (authorized_keys permitopen=) — this just hands
|
||||||
|
// them a fresh client config reflecting that, without touching their key at
|
||||||
|
// all. See keymgr.BuildConfigOnlyArchive.
|
||||||
|
func (m Model) runConfigUpdate(username string) (tea.Model, tea.Cmd) {
|
||||||
|
st := m.loadState()
|
||||||
|
u := st.FindUser(username)
|
||||||
|
if u == nil {
|
||||||
|
m.err = fmt.Sprintf("пользователь %q не найден", username)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if u.PublicKey == "" {
|
||||||
|
m.err = fmt.Sprintf("у пользователя %q ещё нет ключа — используйте «Экспорт архивов»", username)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
password, err := keymgr.GenerateBatchPassword(20)
|
||||||
|
if err != nil {
|
||||||
|
m.err = err.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
forwards := st.AllowedForwards(u)
|
||||||
|
res, err := keymgr.BuildConfigOnlyArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards)
|
||||||
|
if err != nil {
|
||||||
|
m.err = err.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.logAudit(username, "admin", "config-only update issued (key unchanged)")
|
||||||
|
m.configUpdateUser = username
|
||||||
|
m.configUpdateArchive = res.ArchivePath
|
||||||
|
m.configUpdatePass = password
|
||||||
|
m.err = ""
|
||||||
|
m.screen = scrConfigUpdateResult
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateConfigUpdateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if key.String() == "esc" || key.String() == "enter" {
|
||||||
|
m.screen = scrUsers
|
||||||
|
m.err, m.status = "", ""
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewConfigUpdateResult() string {
|
||||||
|
body := fmt.Sprintf("Конфиг для %s обновлён — ключ НЕ менялся.\n\n", m.configUpdateUser)
|
||||||
|
body += "Пароль архива (покажется только сейчас):\n\n"
|
||||||
|
body += titleStyle.Render(m.configUpdatePass) + "\n\n"
|
||||||
|
body += warnStyle.Render("Сообщите пароль отдельным от архива каналом.") + "\n\n"
|
||||||
|
body += fmt.Sprintf("Архив: %s\n\n", m.configUpdateArchive)
|
||||||
|
body += "Пользователю нужно только заменить файл config тем, что внутри — ключ и\n"
|
||||||
|
body += "остальные файлы трогать не нужно.\n"
|
||||||
|
body += "\n[enter/esc] в меню"
|
||||||
|
return borderStyle.Render(body)
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) viewExportSelect() string {
|
func (m Model) viewExportSelect() string {
|
||||||
body := fmt.Sprintf("Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n")
|
st := m.loadState()
|
||||||
|
body := "Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n"
|
||||||
if len(m.exportOrder) == 0 {
|
if len(m.exportOrder) == 0 {
|
||||||
body += warnStyle.Render("Нет активных пользователей. Сначала создайте пользователя.") + "\n"
|
body += warnStyle.Render("Нет активных пользователей. Сначала создайте пользователя.") + "\n"
|
||||||
}
|
}
|
||||||
for i, u := range m.exportOrder {
|
for i, uname := range m.exportOrder {
|
||||||
cursor := " "
|
cursor := " "
|
||||||
if i == m.exportCursor {
|
if i == m.exportCursor {
|
||||||
cursor = "> "
|
cursor = "> "
|
||||||
}
|
}
|
||||||
check := "[ ]"
|
check := "[ ]"
|
||||||
if m.exportSel[u] {
|
if m.exportSel[uname] {
|
||||||
check = "[x]"
|
check = "[x]"
|
||||||
}
|
}
|
||||||
body += fmt.Sprintf("%s%s %s\n", cursor, check, u)
|
status := "ключ не выдан — новый"
|
||||||
|
if u := st.FindUser(uname); u != nil && u.PublicKey != "" {
|
||||||
|
status = warnStyle.Render("ключ уже выдан — будет ПЕРЕВЫПУЩЕН")
|
||||||
}
|
}
|
||||||
body += "\n[space] отметить [a] отметить всех [enter] сгенерировать архивы [esc] назад"
|
body += fmt.Sprintf("%s%s %-20s %s\n", cursor, check, uname, status)
|
||||||
|
}
|
||||||
|
body += "\n[space] отметить [a] отметить всех [enter] далее (спросит подтверждение) [esc] назад"
|
||||||
return borderStyle.Render(body)
|
return borderStyle.Render(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+196
-32
@@ -12,6 +12,13 @@ import (
|
|||||||
"ssh-access-tui/internal/sysadm"
|
"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))
|
||||||
@@ -40,6 +47,10 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.err = "имя группы не может быть пустым"
|
m.err = "имя группы не может быть пустым"
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if !model.ValidName(name) {
|
||||||
|
m.err = validNameErr("группа", name)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
st := m.loadState()
|
st := m.loadState()
|
||||||
if st.FindGroup(name) != nil {
|
if st.FindGroup(name) != nil {
|
||||||
m.err = fmt.Sprintf("группа %q уже существует", name)
|
m.err = fmt.Sprintf("группа %q уже существует", name)
|
||||||
@@ -106,20 +117,20 @@ func (m *Model) refreshHostsList() {
|
|||||||
items = append(items, simpleItem{
|
items = append(items, simpleItem{
|
||||||
id: h.Name,
|
id: h.Name,
|
||||||
title: h.Name,
|
title: h.Name,
|
||||||
desc: fmt.Sprintf("%s:%d %s", h.Address, h.Port, h.Description),
|
desc: fmt.Sprintf("%s — %d сервисов", h.Address, len(h.Services)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
|
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
|
||||||
l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [d]удалить [esc]назад", m.selectedGroup)
|
l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [enter]сервисы хоста [d]удалить [esc]назад", m.selectedGroup)
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
m.hostsList = l
|
m.hostsList = l
|
||||||
}
|
}
|
||||||
|
|
||||||
// resyncGroupUsers rewrites authorized_keys permitopen= restrictions for
|
// resyncGroupUsers rewrites authorized_keys permitopen= restrictions for
|
||||||
// every active user whose groups include groupName, reflecting the group's
|
// every active user whose groups include groupName, reflecting the group's
|
||||||
// current host list — the only place server-side enforcement is updated
|
// current hosts/services — the only place server-side enforcement is
|
||||||
// when the allowed-host list changes over time.
|
// updated when the allowed destinations change over time.
|
||||||
func resyncGroupUsers(st *model.State, groupName string) error {
|
func resyncGroupUsers(st *model.State, groupName string) error {
|
||||||
for i := range st.Users {
|
for i := range st.Users {
|
||||||
u := &st.Users[i]
|
u := &st.Users[i]
|
||||||
@@ -136,7 +147,7 @@ func resyncGroupUsers(st *model.State, groupName string) error {
|
|||||||
if !inGroup {
|
if !inGroup {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedHosts(u)); err != nil {
|
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedForwards(u)); err != nil {
|
||||||
return fmt.Errorf("resync %s: %w", u.Username, err)
|
return fmt.Errorf("resync %s: %w", u.Username, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,33 +166,46 @@ func (m Model) updateGroupHosts(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
for i := range m.hostForm {
|
for i := range m.hostForm {
|
||||||
m.hostForm[i] = textinput.New()
|
m.hostForm[i] = textinput.New()
|
||||||
}
|
}
|
||||||
m.hostForm[0].Placeholder = "короткое имя, например db-prod-1"
|
m.hostForm[0].Placeholder = "короткое имя, например db-prod-1 (без пробелов)"
|
||||||
m.hostForm[1].Placeholder = "адрес (hostname или IP), как виден с jump-хоста"
|
m.hostForm[1].Placeholder = "адрес (hostname или IP), как виден с jump-хоста"
|
||||||
m.hostForm[2].Placeholder = "порт, например 5432"
|
|
||||||
m.hostForm[3].Placeholder = "описание (необязательно)"
|
|
||||||
m.hostFormFocus = 0
|
m.hostFormFocus = 0
|
||||||
m.hostForm[0].Focus()
|
m.hostForm[0].Focus()
|
||||||
|
m.err = ""
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
sel, ok := m.hostsList.SelectedItem().(simpleItem)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.selectedHost = sel.id
|
||||||
|
m.screen = scrHostServices
|
||||||
|
m.refreshServicesList()
|
||||||
return m, nil
|
return m, nil
|
||||||
case "d":
|
case "d":
|
||||||
sel, ok := m.hostsList.SelectedItem().(simpleItem)
|
sel, ok := m.hostsList.SelectedItem().(simpleItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
st := m.loadState()
|
||||||
groupName := m.selectedGroup
|
groupName := m.selectedGroup
|
||||||
m.mutate(fmt.Sprintf("удалён хост %s из группы %s", sel.id, groupName), func(st *model.State) error {
|
nServices := 0
|
||||||
g := st.FindGroup(groupName)
|
if g := st.FindGroup(groupName); g != nil {
|
||||||
if g == nil {
|
for _, h := range g.Hosts {
|
||||||
return fmt.Errorf("group %s not found", groupName)
|
if h.Name == sel.id {
|
||||||
}
|
nServices = len(h.Services)
|
||||||
for i := range g.Hosts {
|
|
||||||
if g.Hosts[i].Name == sel.id {
|
|
||||||
g.Hosts = append(g.Hosts[:i], g.Hosts[i+1:]...)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resyncGroupUsers(st, groupName)
|
}
|
||||||
})
|
m.pendingConfirm = confirmDeleteHost
|
||||||
m.refreshHostsList()
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,19 +232,17 @@ func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "enter":
|
case "enter":
|
||||||
name := m.hostForm[0].Value()
|
name := m.hostForm[0].Value()
|
||||||
addr := m.hostForm[1].Value()
|
addr := m.hostForm[1].Value()
|
||||||
portStr := m.hostForm[2].Value()
|
if name == "" || addr == "" {
|
||||||
desc := m.hostForm[3].Value()
|
m.err = "имя и адрес обязательны"
|
||||||
if name == "" || addr == "" || portStr == "" {
|
|
||||||
m.err = "имя, адрес и порт обязательны"
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
port, err := strconv.Atoi(portStr)
|
if !model.ValidName(name) {
|
||||||
if err != nil || port <= 0 || port > 65535 {
|
m.err = validNameErr("хост", name)
|
||||||
m.err = "порт должен быть числом от 1 до 65535"
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
groupName := m.selectedGroup
|
groupName := m.selectedGroup
|
||||||
m.err = ""
|
m.err = ""
|
||||||
|
var created bool
|
||||||
m.mutate(fmt.Sprintf("добавлен хост %s в группу %s", name, groupName), func(st *model.State) error {
|
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 {
|
||||||
@@ -231,11 +253,17 @@ func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return fmt.Errorf("host %q already exists in group %q", name, groupName)
|
return fmt.Errorf("host %q already exists in group %q", name, groupName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
g.Hosts = append(g.Hosts, model.Host{Name: name, Address: addr, Port: port, Description: desc})
|
g.Hosts = append(g.Hosts, model.Host{Name: name, Address: addr})
|
||||||
return resyncGroupUsers(st, groupName)
|
created = true
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
m.screen = scrGroupHosts
|
if !created {
|
||||||
m.refreshHostsList()
|
return m, nil
|
||||||
|
}
|
||||||
|
m.selectedHost = name
|
||||||
|
m.screen = scrHostServices
|
||||||
|
m.refreshServicesList()
|
||||||
|
m.status = fmt.Sprintf("хост %s добавлен — теперь добавьте на нём хотя бы один сервис (порт)", name)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +273,143 @@ func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewHostNew() string {
|
func (m Model) viewHostNew() string {
|
||||||
body := fmt.Sprintf("Новый хост в группе %q\n\nИмя: %s\nАдрес: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
|
body := fmt.Sprintf("Новый хост в группе %q\n\nИмя: %s\nАдрес: %s\n\n[tab] след. поле [enter] сохранить и перейти к сервисам [esc] отмена",
|
||||||
m.selectedGroup, m.hostForm[0].View(), m.hostForm[1].View(), m.hostForm[2].View(), m.hostForm[3].View())
|
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)
|
||||||
|
})
|
||||||
|
m.screen = scrHostServices
|
||||||
|
m.refreshServicesList()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.serviceForm[m.serviceFormFocus], cmd = m.serviceForm[m.serviceFormFocus].Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewServiceNew() string {
|
||||||
|
body := fmt.Sprintf("Новый сервис на хосте %q\n\nИмя: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
|
||||||
|
m.selectedHost, m.serviceForm[0].View(), m.serviceForm[1].View(), m.serviceForm[2].View())
|
||||||
return borderStyle.Render(body)
|
return borderStyle.Render(body)
|
||||||
}
|
}
|
||||||
+54
-2
@@ -42,11 +42,11 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.screen = scrAudit
|
m.screen = scrAudit
|
||||||
m.refreshAudit()
|
m.refreshAudit()
|
||||||
case "quit":
|
case "quit":
|
||||||
return m, tea.Quit
|
return m.attemptQuit()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "q":
|
case "q":
|
||||||
return m, tea.Quit
|
return m.attemptQuit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
@@ -57,3 +57,55 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (m Model) viewMenu() string {
|
func (m Model) viewMenu() string {
|
||||||
return borderStyle.Render(m.menu.View())
|
return borderStyle.Render(m.menu.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unexportedUsers returns active users who have never had a key issued
|
||||||
|
// (empty PublicKey) — created but forgotten before "Экспорт архивов".
|
||||||
|
func unexportedUsers(m *Model) []string {
|
||||||
|
st := m.loadState()
|
||||||
|
var out []string
|
||||||
|
for _, u := range st.Users {
|
||||||
|
if u.PublicKey == "" {
|
||||||
|
out = append(out, u.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// attemptQuit is the only path that actually quits from the menu ("q" or
|
||||||
|
// selecting "Выход"): if there are users created but never exported, it
|
||||||
|
// warns instead of quitting straight away, since it's easy to create a
|
||||||
|
// batch of users, get interrupted, and forget the export step entirely.
|
||||||
|
// ctrl+c bypasses this deliberately, as an always-available hard exit.
|
||||||
|
func (m Model) attemptQuit() (tea.Model, tea.Cmd) {
|
||||||
|
pending := unexportedUsers(&m)
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
m.quitPending = pending
|
||||||
|
m.quitReturnTo = scrMenu
|
||||||
|
m.screen = scrQuitConfirm
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateQuitConfirm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch key.String() {
|
||||||
|
case "enter", "y":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "esc", "n":
|
||||||
|
m.screen = m.quitReturnTo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewQuitConfirm() string {
|
||||||
|
body := warnStyle.Render("Есть пользователи, для которых ещё не сгенерирован ключ (не были в «Экспорт архивов»):") + "\n\n"
|
||||||
|
for _, u := range m.quitPending {
|
||||||
|
body += " - " + u + "\n"
|
||||||
|
}
|
||||||
|
body += "\nБез экспорта у них нет ключа и архива для передачи — доступ не работает.\n"
|
||||||
|
body += "\n[enter/y] выйти всё равно [esc/n] вернуться"
|
||||||
|
return borderStyle.Render(body)
|
||||||
|
}
|
||||||
+105
-40
@@ -24,7 +24,7 @@ func (m *Model) refreshUsersList() {
|
|||||||
items = append(items, simpleItem{id: u.Username, title: u.Username, desc: desc})
|
items = append(items, simpleItem{id: u.Username, title: u.Username, desc: desc})
|
||||||
}
|
}
|
||||||
l := list.New(items, list.NewDefaultDelegate(), m.usersList.Width(), m.usersList.Height())
|
l := list.New(items, list.NewDefaultDelegate(), m.usersList.Width(), m.usersList.Height())
|
||||||
l.Title = "Пользователи [n]новый [l]заблокировать [u]разблокировать [d]удалить [esc]назад"
|
l.Title = "Пользователи [n]новый [g]группы [c]обновить конфиг [l]заблокировать [u]разблокировать [d]удалить [esc]назад"
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
m.usersList = l
|
m.usersList = l
|
||||||
}
|
}
|
||||||
@@ -46,31 +46,51 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.userNewFocus = 0
|
m.userNewFocus = 0
|
||||||
m.err, m.status = "", ""
|
m.err, m.status = "", ""
|
||||||
return m, nil
|
return m, nil
|
||||||
case "l", "u", "d":
|
case "g":
|
||||||
|
sel, ok := m.usersList.SelectedItem().(simpleItem)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
st := m.loadState()
|
||||||
|
u := st.FindUser(sel.id)
|
||||||
|
if u == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.userNewGroupsSel = map[string]bool{}
|
||||||
|
for _, g := range u.Groups {
|
||||||
|
m.userNewGroupsSel[g] = true
|
||||||
|
}
|
||||||
|
m.userNewGroupsOrder = nil
|
||||||
|
for _, g := range st.Groups {
|
||||||
|
m.userNewGroupsOrder = append(m.userNewGroupsOrder, g.Name)
|
||||||
|
}
|
||||||
|
m.userNewCursor = 0
|
||||||
|
m.userGroupsEditTarget = sel.id
|
||||||
|
m.err, m.status = "", ""
|
||||||
|
m.screen = scrUserNewGroups
|
||||||
|
return m, nil
|
||||||
|
case "l":
|
||||||
sel, ok := m.usersList.SelectedItem().(simpleItem)
|
sel, ok := m.usersList.SelectedItem().(simpleItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
username := sel.id
|
username := sel.id
|
||||||
switch key.String() {
|
m.pendingConfirm = confirmLockUser
|
||||||
case "l":
|
m.confirmTarget = username
|
||||||
if err := sysadm.LockSystemUser(username); err != nil {
|
m.confirmReturnTo = scrUsers
|
||||||
m.err = err.Error()
|
m.confirmMessage = fmt.Sprintf(
|
||||||
|
"Заблокировать пользователя %q?\n\n"+
|
||||||
|
"Аккаунт будет немедленно заблокирован (пароль отключён, срок действия\n"+
|
||||||
|
"истёк, ключ в authorized_keys закомментирован) — доступ пропадёт сразу.\n"+
|
||||||
|
"Это обратимо: позже можно разблокировать клавишей [u].", username)
|
||||||
|
m.screen = scrConfirmAction
|
||||||
|
return m, nil
|
||||||
|
case "u":
|
||||||
|
sel, ok := m.usersList.SelectedItem().(simpleItem)
|
||||||
|
if !ok {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
_ = sysadm.DisableAuthorizedKeys(username)
|
username := sel.id
|
||||||
m.mutate("заблокирован пользователь "+username, func(st *model.State) error {
|
|
||||||
u := st.FindUser(username)
|
|
||||||
if u == nil {
|
|
||||||
return fmt.Errorf("user %s not found in state", username)
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
u.Status = model.StatusLocked
|
|
||||||
u.LockedAt = &now
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
m.logAudit(username, "admin", "locked")
|
|
||||||
case "u":
|
|
||||||
if err := sysadm.UnlockSystemUser(username); err != nil {
|
if err := sysadm.UnlockSystemUser(username); err != nil {
|
||||||
m.err = err.Error()
|
m.err = err.Error()
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -83,31 +103,38 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
u.Status = model.StatusActive
|
u.Status = model.StatusActive
|
||||||
u.LockedAt = nil
|
u.LockedAt = nil
|
||||||
if u.PublicKey != "" {
|
if u.PublicKey != "" {
|
||||||
if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedHosts(u)); err != nil {
|
if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedForwards(u)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
m.logAudit(username, "admin", "unlocked")
|
m.logAudit(username, "admin", "unlocked")
|
||||||
case "d":
|
|
||||||
if err := sysadm.DeleteSystemUser(username); err != nil {
|
|
||||||
m.err = err.Error()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.mutate("удалён пользователь "+username, func(st *model.State) error {
|
|
||||||
for i := range st.Users {
|
|
||||||
if st.Users[i].Username == username {
|
|
||||||
st.Users = append(st.Users[:i], st.Users[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
m.logAudit(username, "admin", "deleted")
|
|
||||||
}
|
|
||||||
m.refreshUsersList()
|
m.refreshUsersList()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
case "d":
|
||||||
|
sel, ok := m.usersList.SelectedItem().(simpleItem)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
username := sel.id
|
||||||
|
m.pendingConfirm = confirmDeleteUser
|
||||||
|
m.confirmTarget = username
|
||||||
|
m.confirmReturnTo = scrUsers
|
||||||
|
m.confirmMessage = fmt.Sprintf(
|
||||||
|
"Удалить пользователя %q НЕОБРАТИМО?\n\n"+
|
||||||
|
"Будет выполнено userdel -r: системный аккаунт и его домашняя папка\n"+
|
||||||
|
"удаляются безвозвратно.\n"+
|
||||||
|
"Если нужно просто временно отключить доступ — используйте блокировку\n"+
|
||||||
|
"[l] вместо удаления, её можно отменить.", username)
|
||||||
|
m.screen = scrConfirmAction
|
||||||
|
return m, nil
|
||||||
|
case "c":
|
||||||
|
sel, ok := m.usersList.SelectedItem().(simpleItem)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.runConfigUpdate(sel.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
@@ -184,11 +211,20 @@ func (m Model) viewUserNew() string {
|
|||||||
|
|
||||||
// --- new user wizard: step 2, group checkboxes ---
|
// --- new user wizard: step 2, group checkboxes ---
|
||||||
|
|
||||||
|
// updateUserNewGroups drives the group-checkbox screen, shared by two
|
||||||
|
// flows: assigning groups while creating a brand new user (userGroupsEditTarget
|
||||||
|
// empty), and changing an existing user's groups later (userGroupsEditTarget
|
||||||
|
// set to their username, entered via "g" on the users list).
|
||||||
func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) updateUserNewGroups(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":
|
||||||
|
if m.userGroupsEditTarget != "" {
|
||||||
|
m.userGroupsEditTarget = ""
|
||||||
|
m.screen = scrUsers
|
||||||
|
} else {
|
||||||
m.screen = scrUserNew
|
m.screen = scrUserNew
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.userNewCursor > 0 {
|
if m.userNewCursor > 0 {
|
||||||
@@ -207,14 +243,37 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "enter":
|
case "enter":
|
||||||
username := m.userNewName.Value()
|
|
||||||
comment := m.userNewComment.Value()
|
|
||||||
var groups []string
|
var groups []string
|
||||||
for _, g := range m.userNewGroupsOrder {
|
for _, g := range m.userNewGroupsOrder {
|
||||||
if m.userNewGroupsSel[g] {
|
if m.userNewGroupsSel[g] {
|
||||||
groups = append(groups, g)
|
groups = append(groups, g)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.userGroupsEditTarget != "" {
|
||||||
|
username := m.userGroupsEditTarget
|
||||||
|
m.mutate("изменены группы пользователя "+username, func(st *model.State) error {
|
||||||
|
u := st.FindUser(username)
|
||||||
|
if u == nil {
|
||||||
|
return fmt.Errorf("user %s not found in state", username)
|
||||||
|
}
|
||||||
|
u.Groups = groups
|
||||||
|
if u.PublicKey != "" && u.Status == model.StatusActive {
|
||||||
|
if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedForwards(u)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
m.logAudit(username, "admin", "groups changed to "+fmt.Sprint(groups))
|
||||||
|
m.userGroupsEditTarget = ""
|
||||||
|
m.screen = scrUsers
|
||||||
|
m.refreshUsersList()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
username := m.userNewName.Value()
|
||||||
|
comment := m.userNewComment.Value()
|
||||||
if err := sysadm.CreateSystemUser(username); err != nil {
|
if err := sysadm.CreateSystemUser(username); err != nil {
|
||||||
m.err = err.Error()
|
m.err = err.Error()
|
||||||
m.screen = scrUsers
|
m.screen = scrUsers
|
||||||
@@ -242,7 +301,13 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewUserNewGroups() string {
|
func (m Model) viewUserNewGroups() string {
|
||||||
body := "Группы доступа для нового пользователя (union хостов всех выбранных групп)\n\n"
|
title := "Группы доступа для нового пользователя"
|
||||||
|
confirmHint := "создать пользователя"
|
||||||
|
if m.userGroupsEditTarget != "" {
|
||||||
|
title = fmt.Sprintf("Группы доступа пользователя %s", m.userGroupsEditTarget)
|
||||||
|
confirmHint = "сохранить"
|
||||||
|
}
|
||||||
|
body := title + " (эффективный доступ = объединение сервисов всех отмеченных групп)\n\n"
|
||||||
if len(m.userNewGroupsOrder) == 0 {
|
if len(m.userNewGroupsOrder) == 0 {
|
||||||
body += warnStyle.Render("Группы ещё не созданы — сначала создайте хотя бы одну в разделе «Группы и хосты».") + "\n"
|
body += warnStyle.Render("Группы ещё не созданы — сначала создайте хотя бы одну в разделе «Группы и хосты».") + "\n"
|
||||||
}
|
}
|
||||||
@@ -257,6 +322,6 @@ func (m Model) viewUserNewGroups() string {
|
|||||||
}
|
}
|
||||||
body += fmt.Sprintf("%s%s %s\n", cursor, check, g)
|
body += fmt.Sprintf("%s%s %s\n", cursor, check, g)
|
||||||
}
|
}
|
||||||
body += "\n[space] отметить [enter] создать пользователя [esc] назад"
|
body += fmt.Sprintf("\n[space] отметить [enter] %s [esc] назад", confirmHint)
|
||||||
return borderStyle.Render(body)
|
return borderStyle.Render(body)
|
||||||
}
|
}
|
||||||
Binary file not shown.
Reference in new issue
Block a user