diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md index 335eec4..38319f7 100644 --- a/docs/ADMIN_GUIDE.md +++ b/docs/ADMIN_GUIDE.md @@ -16,7 +16,16 @@ sudo ssh-access-tui перезапишет чужие изменения. Навигация везде: стрелки/`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: новый сотрудник, у которого уже есть роль/группа доступа Обычно сначала настраивают группу (один раз), потом заводят пользователей в -неё пачками. +неё пачками. Иерархия: **группа** → **хосты** → **сервисы (порты) на хосте** +— на одном хосте может быть несколько сервисов (например, `ssh` на 22 и +`web-api` на 7888), каждый добавляется отдельно. 1. **Группы и хосты** → `n` → ввести имя группы (например `team-billing`) → `enter`. 2. Выбрать группу → `enter`, попасть в список хостов группы (изначально пуст). -3. `n` → заполнить: имя хоста (короткий алиас, например `db-prod-1`), адрес - (как виден с jump-хоста), порт, описание (необязательно) → `tab` между - полями → `enter` сохранить. Повторить для каждого внутреннего хоста, - который должен быть доступен этой группе. -4. `esc` дважды — назад в главное меню. -5. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин) +3. `n` → заполнить: имя хоста (короткое, например `db-prod-1`), адрес (как + виден с jump-хоста) → `tab` между полями → `enter`. Сразу после этого + попадаете в список сервисов этого хоста (тоже изначально пуст). +4. `n` → заполнить сервис: имя (например `ssh` или `web-api`), порт на + хосте (например `22` или `7888`), описание (необязательно) → `enter`. + Повторите `n` столько раз, сколько сервисов есть на этом хосте. +5. `esc` — назад к списку хостов группы; повторите шаги 3-4 для остальных + хостов группы, затем `esc` дважды — назад в главное меню. +6. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин) и комментарий (например ФИО) → `enter`. -6. Отметить `space` нужные группы (можно несколько — эффективный список - хостов пользователя = объединение хостов всех отмеченных групп) → `enter`. +7. Отметить `space` нужные группы (можно несколько — эффективный доступ + пользователя = объединение сервисов всех хостов всех отмеченных групп) → + `enter`. Это уже создаёт системный Linux-аккаунт (без пароля, без интерактивного шелла) и запись в состоянии. **Ключ ещё не выдан** — статус в списке пользователей покажет «ключ не выдан». -7. **Экспорт архивов** → отметить `space` только что созданного пользователя - (или `a` — отметить всех, удобно при пакетном найме) → `enter`. -8. Инструмент сгенерирует ключ, пропишет `permitopen=` под текущие хосты - группы, соберёт `.7z` и покажет **один общий пароль на всю партию** — он +8. **Экспорт архивов** → отметить `space` только что созданного пользователя + (или `a` — отметить всех, удобно при пакетном найме) → `enter`. Список + сразу показывает у каждого пользователя, будет ли выдан новый ключ или + существующий будет **перевыпущен** (актуально, если случайно попадёт в + выборку уже работающий пользователь). +9. Экран подтверждения: для тех, у кого ключ уже был, явно предупредит, что + старый архив перестанет работать — только после `enter`/`y` (либо `esc`/`n` + для отмены, тогда не создаётся и не меняется вообще ничего) инструмент + сгенерирует ключ, пропишет `permitopen=` под текущие сервисы группы и + соберёт `.7z`, показав **один общий пароль на всю партию** — он показывается один раз, запишите/скопируйте сразу. -9. Передайте каждому пользователю его архив (файл, путь показан на экране, - лежит в `export_dir`) любым каналом, а пароль от архива — **отдельным** - каналом (голосом, в личку) и по возможности не всем одним и тем же - сообщением в общем чате. -10. Пользователь распаковывает архив на Windows по инструкции - `INSTRUCTIONS.txt` внутри архива (она уже сгенерирована под его - конкретные разрешённые хосты). +10. Передайте каждому пользователю его архив (файл, путь показан на экране, + лежит в `export_dir`) любым каналом, а пароль от архива — **отдельным** + каналом (голосом, в личку) и по возможности не всем одним и тем же + сообщением в общем чате. +11. Пользователь распаковывает архив на Windows по инструкции + `INSTRUCTIONS.txt` внутри архива — она уже на простом русском языке, + без технических терминов: просто список "локальный адрес → какой это + сервис → куда он реально ведёт", составленный под конкретные разрешённые + сервисы этого пользователя. -## Сценарий 2: добавили новый разрешённый хост существующей группе +## Сценарий 1а: изменить группы уже существующего пользователя + +**Пользователи** → выбрать пользователя → `g` → отметить/снять `space` +нужные группы → `enter`. Если у пользователя уже выдан ключ, инструмент +сразу пересоберёт его `permitopen=` под новый набор сервисов — доступ +меняется немедленно, без переотправки архива и **без замены ключа** +(изменение групп никогда не ротирует ключ). + +Если хотите, чтобы у пользователя появился ещё и обновлённый клиентский +`config` с новыми `LocalForward` для новых сервисов (иначе он про них +просто не узнает, хотя доступ уже есть) — **Пользователи** → выбрать +пользователя → `c` («обновить конфиг»). Это генерирует маленький архив +только с `config` + `INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже +есть и остаётся тем же), пользователю нужно только подменить файл `config`. +Не требует подтверждения — ключ и доступ этим действием не меняются, это не +деструктивно. + +Полный экспорт (со сценария 1, шаги 8-9) тоже сработает и даст то же самое +плюс заново выпустит ключ — используйте `c`, когда переиздание ключа не +нужно (обычный случай при простом изменении групп). + +## Сценарий 2: добавили новый сервис/хост существующей группе 1. **Группы и хосты** → выбрать группу → `enter`. -2. `n` → заполнить новый хост → `enter`. +2. Либо `n` — новый хост (затем добавить ему хотя бы один сервис), либо + выбрать существующий хост → `enter` → `n` — новый сервис на нём. Это сразу обновляет `permitopen=` в `authorized_keys` **всех активных пользователей** этой группы — доступ появляется немедленно, без каких-либо действий на стороне пользователя. Клиентский конфиг пользователя (его `~/.ssh/config`) при этом не обновляется автоматически — если хотите, чтобы -у пользователя появился готовый алиас для нового хоста, а не только "голый" -доступ, повторно прогоните его через **Экспорт архивов** (это заодно -ротирует его ключ — считайте это плановой ротацией, не проблемой). +у пользователя появилась готовая строка `LocalForward` для нового сервиса, а +не только "голый" доступ на сервере, используйте **Пользователи** → `c` +(«обновить конфиг») — ключ при этом не меняется (см. сценарий 1а). -Удаление хоста из группы (`d` в списке хостов) действует так же немедленно -и в обратную сторону. +Удаление сервиса или целого хоста (`d` в соответствующем списке) — оба +действия **деструктивны** (мгновенно отбирают доступ у всех пользователей +группы) и требуют подтверждения: экран покажет, что именно удаляется и +скольких пользователей это коснётся, `enter`/`y` подтверждает, `esc`/`n` +отменяет без каких-либо изменений. ## Сценарий 3: сотрудник уволился -1. **Пользователи** → выбрать пользователя → `l` (заблокировать). +1. **Пользователи** → выбрать пользователя → `l` (заблокировать) → экран + подтверждения объяснит, что произойдёт → `enter`/`y`. -Это одновременно: блокирует пароль аккаунта, устанавливает немедленный срок -истечения аккаунта (`chage -E 1`) и комментирует его ключ в +Блокировка — это одновременно: блокирует пароль аккаунта, устанавливает +немедленный срок истечения аккаунта (`chage -E 1`) и комментирует его ключ в `authorized_keys` — три независимых механизма, чтобы блокировка не зависела -от одного из них. Статус в списке сменится на `locked`. +от одного из них. Статус в списке сменится на `locked`. Это **обратимо**. Если сотрудник вернулся — `u` (разблокировать) на том же пользователе: снимает блокировку аккаунта и восстанавливает его ключ в `authorized_keys` -с текущими правами групп. +с текущими правами групп. Разблокировка не деструктивна — подтверждения не +требует, срабатывает сразу. -Полное удаление аккаунта (`d`) — необратимо (`userdel -r`, домашняя папка -стирается), используйте для окончательного оффбординга, не для временной -блокировки. +Полное удаление аккаунта (`d`) — **необратимо** (`userdel -r`, домашняя папка +стирается) и тоже требует подтверждения (экран явно предупредит, что это +необратимо и что для временного отключения лучше использовать блокировку). +Используйте удаление только для окончательного оффбординга, не для +временной блокировки. ## Сценарий 4: проверить, кто сейчас подключён / посмотреть историю @@ -125,9 +181,11 @@ Linux-аккаунты для активных пользователей, вы ## Что не делает эта версия (нужно руками) - Не удаляет `.7z` из `export_dir` после доставки — сделайте это сами, - когда получатель подтвердил, что распаковал архив. + когда получатель подтвердил, что распаковал архив (это касается и + архивов от «обновить конфиг», хоть они и не содержат ключ). - Не обновляет клиентский `~/.ssh/config` пользователя автоматически при - появлении нового хоста в его группе — только права доступа на сервере - обновляются немедленно (см. сценарий 2). + появлении нового сервиса/хоста в его группе — это ручной шаг («обновить + конфиг», сценарий 1а), права доступа на сервере при этом обновляются + немедленно и без участия администратора (см. сценарий 2). - Не поддерживает одновременную работу нескольких администраторов — рассчитан на одного за раз (см. «Конкурентность» в ARCHITECTURE.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bfe8b1d..8096cf9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` пользователя приписаны опции: ``` - 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, - user-rc и port-forwarding; `port-forwarding` точечно возвращает именно - форвардинг (не интерактивный доступ), а `permitopen=` разрешает - форвардинг только к перечисленным `host:port` — это гарантия на уровне - протокола SSH, не зависящая от того, что написано в клиентском конфиге. - Shell пользователя — `/usr/sbin/nologin`: любой запрос shell/exec-канала - (обычный интерактивный `ssh user@jump`) обрывается им же, а - direct-tcpip-каналы (прыжок/форвардинг) идут отдельным путём и - регулируются исключительно `permitopen=`. + Один `permitopen=` на каждый `Forward` пользователя (`internal/sysadm/authorizedkeys.go`, + `BuildKeyOptions`) — не важно, сколько сервисов на одном хосте или сколько + разных хостов используют один и тот же номер порта, каждая пара + `host:port` разрешается независимо. `restrict` разом отключает pty, + X11-forwarding, agent-forwarding, user-rc и port-forwarding; + `port-forwarding` точечно возвращает именно форвардинг (не интерактивный + доступ). Это гарантия на уровне протокола SSH, не зависящая от того, что + написано в клиентском конфиге. Shell пользователя — `/usr/sbin/nologin`: + любой запрос shell/exec-канала (обычный интерактивный `ssh user@jump`) + обрывается им же, а direct-tcpip-каналы (форвардинг) идут отдельным путём + и регулируются исключительно `permitopen=`. Дополнительно в `Match Group ssh-jump-users` (`internal/sysadm/sshdconfig.go`) продублированы базовые ограничения (`PermitTTY no`, `X11Forwarding no`, @@ -47,11 +67,12 @@ TUI работает поверх уже существующего SSH+`sudo`- повреждены/потеряны, а не основной механизм. 2. **Клиент (только удобство).** Файл `~/.ssh/config`, который уезжает - пользователю в архиве, — это алиасы (`Host db-prod-1` → `ProxyJump jump`). + пользователю в архиве, — это один блок `Host jump` с набором строк + `LocalForward <локальный_порт> :` (`internal/keymgr/clientconfig.go`). Он ничего не запрещает: пользователь может отредактировать его или вызвать `ssh` руками с любым хостом. Единственная причина, по которой это не работает, — сервер отклонит `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` внутри архива пользователя. + ## Жизненный цикл приватного ключа Приватный ключ **никогда не хранится на диске дольше одной операции diff --git a/internal/keymgr/clientconfig.go b/internal/keymgr/clientconfig.go index 4cb1097..7cdb08f 100644 --- a/internal/keymgr/clientconfig.go +++ b/internal/keymgr/clientconfig.go @@ -3,42 +3,37 @@ package keymgr import ( "fmt" "strings" - - "ssh-access-tui/internal/model" ) // RenderClientConfig produces an OpenSSH client config (works identically on -// Windows' built-in OpenSSH client, macOS, and Linux) that gives the user a -// "jump" alias plus one alias per host they're allowed to reach. +// Windows' built-in OpenSSH client, macOS, and Linux). It defines exactly +// 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 // user from editing it or invoking ssh by hand. The actual restriction to -// these hosts is enforced server-side via authorized_keys permitopen= -// (see internal/sysadm/authorizedkeys.go) independent of what this file says. -func RenderClientConfig(jumpHost string, jumpPort int, username, keyFileName string, hosts []model.Host) string { +// these destinations is enforced server-side via authorized_keys +// permitopen= (see internal/sysadm/authorizedkeys.go) independent of what +// this file says. +func RenderClientConfig(jumpHost string, jumpPort int, username, keyFileName string, forwards []AllocatedForward) string { var b strings.Builder 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, " HostName %s\n", jumpHost) fmt.Fprintf(&b, " Port %d\n", jumpPort) fmt.Fprintf(&b, " User %s\n", username) 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") - if h.Description != "" { - fmt.Fprintf(&b, " # %s\n", h.Description) + fmt.Fprintf(&b, " IdentitiesOnly yes\n") + for _, f := range forwards { + 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() } diff --git a/internal/keymgr/configonly.go b/internal/keymgr/configonly.go new file mode 100644 index 0000000..d5384b2 --- /dev/null +++ b/internal/keymgr/configonly.go @@ -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 +} diff --git a/internal/keymgr/instructions.go b/internal/keymgr/instructions.go index 3b40c99..a16d4d6 100644 --- a/internal/keymgr/instructions.go +++ b/internal/keymgr/instructions.go @@ -4,79 +4,128 @@ import ( "fmt" "strings" "time" - - "ssh-access-tui/internal/model" ) func genTimestamp() string { return time.Now().UTC().Format("2006-01-02 15:04 UTC") } -// RenderInstructions produces a plain-text README aimed at a Windows user -// whose only guaranteed tool is the OpenSSH client bundled with Windows -// 10/11 ("Optional Features" -> OpenSSH Client, or already present). -func RenderInstructions(username, jumpHost, keyFileName string, hosts []model.Host) string { +// RenderInstructions produces a plain-Russian, no-jargon README for a +// non-technical Windows user whose only guaranteed tool is the OpenSSH +// client bundled with Windows 10/11. Deliberately says nothing about +// "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 - b.WriteString("SSH ACCESS — SETUP INSTRUCTIONS (Windows)\n") - b.WriteString("==========================================\n\n") - fmt.Fprintf(&b, "Account: %s\n", username) - fmt.Fprintf(&b, "Jump host: %s\n\n", jumpHost) + b.WriteString("КАК ПОДКЛЮЧИТЬСЯ (Windows)\n") + b.WriteString("===========================\n\n") + fmt.Fprintf(&b, "Ваш логин: %s\n\n", username) - b.WriteString("This archive contains:\n") - fmt.Fprintf(&b, " - %s your PRIVATE key (keep secret, do not share or send onward)\n", keyFileName) - fmt.Fprintf(&b, " - %s.pub the matching public key (for reference only)\n", keyFileName) - b.WriteString(" - config SSH client config with ready-made host shortcuts\n") - b.WriteString(" - INSTRUCTIONS.txt this file\n\n") + b.WriteString("В этом архиве 4 файла:\n") + fmt.Fprintf(&b, " - %s — ваш секретный ключ (никому не передавайте и не пересылайте)\n", keyFileName) + fmt.Fprintf(&b, " - %s.pub — открытая часть ключа (можно не трогать)\n", keyFileName) + b.WriteString(" - config — настройка подключения\n") + b.WriteString(" - INSTRUCTIONS.txt — этот файл\n\n") - b.WriteString("1. Check you have the SSH client\n") - b.WriteString(" Open PowerShell and run:\n") - b.WriteString(" ssh -V\n") - b.WriteString(" If this fails: Settings -> Apps -> Optional Features -> Add a feature ->\n") - b.WriteString(" \"OpenSSH Client\" -> Install. (Windows 10 1809+/11 ship this by default.)\n\n") + b.WriteString("ШАГ 1. Проверьте, что на компьютере есть SSH\n") + b.WriteString("--------------------------------------------\n") + b.WriteString("Откройте программу PowerShell (найдите её через Пуск) и введите:\n\n") + b.WriteString(" ssh -V\n\n") + b.WriteString("Если появилась ошибка — зайдите в Параметры Windows -> Приложения ->\n") + b.WriteString("Дополнительные компоненты -> Добавить компонент -> найдите «OpenSSH Client»\n") + b.WriteString("-> Установить.\n\n") - b.WriteString("2. Put the files in place\n") - b.WriteString(" Create the folder (if it doesn't exist) and copy the 3 files into it:\n") - b.WriteString(" %USERPROFILE%\\.ssh\\\n") - b.WriteString(" i.e. C:\\Users\\\\.ssh\\\n") - b.WriteString(" The \"config\" file should be merged with (or placed as) that exact filename,\n") - b.WriteString(" with no file extension: C:\\Users\\\\.ssh\\config\n\n") + b.WriteString("ШАГ 2. Разложите файлы по нужной папке\n") + b.WriteString("---------------------------------------\n") + b.WriteString("Создайте (если её ещё нет) папку:\n\n") + b.WriteString(" C:\\Users\\ВАШЕ_ИМЯ\\.ssh\\\n\n") + fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName) + b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\n\n") - b.WriteString("3. Fix the private key permissions (Windows OpenSSH refuses keys that are\n") - b.WriteString(" readable by other users). In PowerShell:\n") - fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /inheritance:r\n", keyFileName) - fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /grant:r \"$env:USERNAME:(R)\"\n\n", keyFileName) + b.WriteString("ШАГ 3. Настройте доступ к ключу\n") + b.WriteString("---------------------------------\n") + b.WriteString("В PowerShell по очереди выполните две команды (замените ничего не нужно,\n") + b.WriteString("копируйте как есть):\n\n") + fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /inheritance:r\n", keyFileName) + fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /grant:r \"$env:USERNAME:(R)\"\n\n", keyFileName) - b.WriteString("4. Connect\n") - b.WriteString(" You have shortcuts for each host you're allowed to reach. To open a local\n") - b.WriteString(" port forward to one of them (example: a web service on port 8443):\n\n") - if len(hosts) > 0 { - h := hosts[0] - fmt.Fprintf(&b, " ssh -N -L 8443:localhost:%d %s\n", h.Port, h.Name) - b.WriteString(" Then browse to https://localhost:8443 (or whatever local port you chose)\n") - b.WriteString(" while that command keeps running. Ctrl+C to stop.\n\n") - } else { - b.WriteString(" ssh -N -L :localhost: \n\n") - } + b.WriteString("ШАГ 4. Подключитесь\n") + b.WriteString("---------------------\n") + b.WriteString("В PowerShell введите команду:\n\n") + b.WriteString(" ssh -N jump\n\n") + b.WriteString("Окно «зависнет» и ничего не будет происходить — это нормально, так и должно\n") + b.WriteString("быть. НЕ ЗАКРЫВАЙТЕ это окно — пока оно открыто, работают все сервисы из\n") + b.WriteString("таблицы ниже. Закрыли окно (или выключили компьютер) — доступ пропал, просто\n") + b.WriteString("откройте заново командой выше, когда снова понадобится.\n\n") - b.WriteString(" Your allowed hosts (aliases from the config file):\n") - for _, h := range hosts { - fmt.Fprintf(&b, " - %-20s -> %s:%d", h.Name, h.Address, h.Port) - if h.Description != "" { - fmt.Fprintf(&b, " (%s)", h.Description) + b.WriteString("ШАГ 5. Пользуйтесь\n") + b.WriteString("--------------------\n") + b.WriteString("Эта настройка открывает у вас на компьютере (локально) следующие адреса.\n") + b.WriteString("В программе, которая должна подключиться к сервису (браузер, клиент базы\n") + 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("5. You will NOT get an interactive shell\n") - b.WriteString(" Running plain \"ssh jump\" will connect and immediately close — this account\n") - b.WriteString(" is jump/port-forwarding only, by design. Use the -L/-D forwarding commands\n") - b.WriteString(" above (via the host aliases, which already route through the jump host).\n\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") + b.WriteString("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n") + b.WriteString("------------------------\n") + b.WriteString("Обратитесь к администратору, который выдал вам этот архив. Возможно,\n") + b.WriteString("изменился список сервисов — тогда вам пришлют обновлённый архив.\n") 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() +} diff --git a/internal/keymgr/pack.go b/internal/keymgr/pack.go index 42fb9e5..95bd9be 100644 --- a/internal/keymgr/pack.go +++ b/internal/keymgr/pack.go @@ -23,7 +23,7 @@ type BuildResult struct { // under outDir, and then wipes the scratch directory (including the // plaintext private key) from disk. password is shared across one export // 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) if err := os.MkdirAll(userScratch, 0o700); err != nil { 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) - 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 { 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 { return nil, fmt.Errorf("write instructions: %w", err) } diff --git a/internal/keymgr/portalloc.go b/internal/keymgr/portalloc.go new file mode 100644 index 0000000..39e772b --- /dev/null +++ b/internal/keymgr/portalloc.go @@ -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 +} diff --git a/internal/model/types.go b/internal/model/types.go index 8c0f476..613565c 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -1,20 +1,46 @@ // 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 -import "time" +import ( + "fmt" + "regexp" + "time" +) -// Host is one jump-target reachable via port forwarding through the bastion. -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 - Port int `yaml:"port"` // TCP port allowed for forwarding +// nameRe restricts identifiers that end up as ssh_config tokens or +// authorized_keys comments: no spaces, no shell/config metacharacters. +var nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// ValidName reports whether s is safe to use as a group/host/service name: +// 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"` } +// 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"). -// Users are assigned to one or more groups; their effective allowed hosts are -// the union of all hosts in their groups. +// Users are assigned to one or more groups; their effective allowed +// forwards are the union of all services on all hosts in their groups. type Group struct { Name string `yaml:"name"` Hosts []Host `yaml:"hosts"` @@ -66,49 +92,48 @@ func (s *State) FindUser(username string) *User { return nil } -// AllowedHosts returns the de-duplicated union of hosts across all groups -// the user belongs to, sorted by "name" for stable authorized_keys output. -func (s *State) AllowedHosts(u *User) []Host { - seen := map[string]Host{} - order := []string{} +// Forward is one flattened (host, service) pair a user is allowed to reach. +// This is the actual unit both server-side enforcement (authorized_keys +// "permitopen=") and the client config (one "LocalForward" line per +// Forward) are built from. +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 { g := s.FindGroup(gname) if g == nil { continue } for _, h := range g.Hosts { - key := h.Address + ":" + itoa(h.Port) - if _, ok := seen[key]; !ok { - order = append(order, key) + for _, svc := range h.Services { + key := fmt.Sprintf("%s:%d", h.Address, svc.Port) + if seen[key] { + continue + } + seen[key] = true + out = append(out, Forward{ + HostName: h.Name, + Address: h.Address, + Port: svc.Port, + ServiceName: svc.Name, + Description: svc.Description, + }) } - seen[key] = h } } - out := make([]Host, 0, len(order)) - for _, k := range order { - out = append(out, seen[k]) - } 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:]) -} diff --git a/internal/sysadm/authorizedkeys.go b/internal/sysadm/authorizedkeys.go index d44cfbd..ed8af06 100644 --- a/internal/sysadm/authorizedkeys.go +++ b/internal/sysadm/authorizedkeys.go @@ -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: // - restrict disables pty, X11, agent-forwarding, user-rc, etc. // - port-forwarding re-enables (only) local/dynamic forwarding -// - permitopen=... whitelists exactly the host:port pairs this key -// may forward to — this is what makes "only these hosts" a protocol-level -// guarantee rather than a client-side convention. +// - permitopen=... whitelists exactly the address:port pairs this key +// may forward to — this is what makes "only these destinations" a +// 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. -func BuildKeyOptions(hosts []model.Host) string { - if len(hosts) == 0 { - // No hosts assigned yet: restrict everything, permit nothing, so the - // key authenticates but cannot open forwarding channels anywhere. +func BuildKeyOptions(forwards []model.Forward) string { + if len(forwards) == 0 { + // No forwards assigned yet: restrict everything, permit nothing, so + // the key authenticates but cannot open forwarding channels anywhere. return "restrict" } opts := []string{"restrict", "port-forwarding"} - for _, h := range hosts { - opts = append(opts, fmt.Sprintf(`permitopen="%s:%s"`, h.Address, strconv.Itoa(h.Port))) + for _, f := range forwards { + opts = append(opts, fmt.Sprintf(`permitopen="%s:%s"`, f.Address, strconv.Itoa(f.Port))) } return strings.Join(opts, ",") } @@ -40,7 +43,7 @@ func BuildKeyOptions(hosts []model.Host) string { // WriteAuthorizedKeys writes the single managed key line (with its // permitopen restrictions) into ~/.ssh/authorized_keys, replacing // 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) if err != nil { 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 { 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" akPath := filepath.Join(sshDir, "authorized_keys") diff --git a/internal/ui/app.go b/internal/ui/app.go index ca21eab..7a71073 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -32,9 +32,28 @@ const ( scrGroups scrGroupHosts scrHostNew + scrHostServices + scrServiceNew scrExportSelect + scrExportConfirm scrExportResult + scrConfigUpdateResult 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 ( @@ -59,24 +78,31 @@ type Model struct { usersList list.Model - userNewName textinput.Model - userNewComment textinput.Model - userNewFocus int - userNewGroupsSel map[string]bool - userNewGroupsOrder []string - userNewCursor int + userNewName textinput.Model + userNewComment textinput.Model + userNewFocus int + userNewGroupsSel map[string]bool + userNewGroupsOrder []string + userNewCursor int + userGroupsEditTarget string // non-empty: editing this existing user's groups instead of creating a new user groupsList list.Model groupNewName textinput.Model awaitingGroup bool selectedGroup string hostsList list.Model - hostForm [4]textinput.Model // name, address, port, description + hostForm [2]textinput.Model // name, address hostFormFocus int + selectedHost string + servicesList list.Model + serviceForm [3]textinput.Model // name, port, description + serviceFormFocus int + exportSel map[string]bool exportOrder []string exportCursor int + exportChosen []string // confirmed selection, pending scrExportConfirm -> runExport exportRes []keymgr.BuildResult exportPass string exportErr string @@ -84,6 +110,23 @@ type Model struct { onlineSessions []audit.OnlineSession 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 } @@ -100,6 +143,7 @@ func New(cfg config.Config, st *store.Store) Model { m.usersList = emptyList("Пользователи") m.groupsList = emptyList("Группы доступа") m.hostsList = emptyList("Хосты") + m.servicesList = emptyList("Сервисы") return m } @@ -153,6 +197,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.usersList.SetSize(listW, listH) m.groupsList.SetSize(listW, listH) m.hostsList.SetSize(listW, listH) + m.servicesList.SetSize(listW, listH) return m, nil case tea.KeyMsg: if msg.String() == "ctrl+c" { @@ -175,12 +220,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateGroupHosts(msg) case scrHostNew: return m.updateHostNew(msg) + case scrHostServices: + return m.updateHostServices(msg) + case scrServiceNew: + return m.updateServiceNew(msg) case scrExportSelect: return m.updateExportSelect(msg) + case scrExportConfirm: + return m.updateExportConfirm(msg) case scrExportResult: return m.updateExportResult(msg) + case scrConfigUpdateResult: + return m.updateConfigUpdateResult(msg) case scrAudit: return m.updateAudit(msg) + case scrQuitConfirm: + return m.updateQuitConfirm(msg) + case scrConfirmAction: + return m.updateConfirmAction(msg) } return m, nil } @@ -202,12 +259,24 @@ func (m Model) View() string { body = m.viewGroupHosts() case scrHostNew: body = m.viewHostNew() + case scrHostServices: + body = m.viewHostServices() + case scrServiceNew: + body = m.viewServiceNew() case scrExportSelect: body = m.viewExportSelect() + case scrExportConfirm: + body = m.viewExportConfirm() case scrExportResult: body = m.viewExportResult() + case scrConfigUpdateResult: + body = m.viewConfigUpdateResult() case scrAudit: body = m.viewAudit() + case scrQuitConfirm: + body = m.viewQuitConfirm() + case scrConfirmAction: + body = m.viewConfirmAction() } footer := "" diff --git a/internal/ui/confirm.go b/internal/ui/confirm.go new file mode 100644 index 0000000..e7bbfd8 --- /dev/null +++ b/internal/ui/confirm.go @@ -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 +} diff --git a/internal/ui/export.go b/internal/ui/export.go index e12e2e3..7482453 100644 --- a/internal/ui/export.go +++ b/internal/ui/export.go @@ -62,12 +62,54 @@ func (m Model) updateExportSelect(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = "выберите хотя бы одного пользователя (space)" return m, nil } - return m.runExport(chosen) + m.exportChosen = chosen + m.screen = scrExportConfirm + m.err = "" + return m, nil } } return m, nil } +// updateExportConfirm guards against the accidental case this screen exists +// for: selecting users who already have a key issued re-exports them, which +// generates a brand new keypair and immediately invalidates whatever archive +// they were already given (their old private key stops matching +// authorized_keys). Nothing is generated or written until this is confirmed. +func (m Model) updateExportConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "esc", "n": + m.screen = scrExportSelect + return m, nil + case "enter", "y": + return m.runExport(m.exportChosen) + } + } + return m, nil +} + +func (m Model) viewExportConfirm() string { + st := m.loadState() + body := "Подтвердите экспорт\n\n" + anyRotate := false + for _, username := range m.exportChosen { + u := st.FindUser(username) + if u != nil && u.PublicKey != "" { + body += fmt.Sprintf(" %-20s %s\n", username, warnStyle.Render("ключ уже выдан — будет ПЕРЕВЫПУЩЕН, старый архив перестанет работать")) + anyRotate = true + } else { + body += fmt.Sprintf(" %-20s новый ключ\n", username) + } + } + if anyRotate { + body += "\n" + warnStyle.Render("Внимание: для отмеченных выше пользователей текущий ключ будет заменён.") + "\n" + body += warnStyle.Render("Все, у кого сейчас работает старый архив, потеряют доступ, пока им не выдадут новый.") + "\n" + } + body += "\n[enter/y] подтвердить и сгенерировать [esc/n] отмена, ничего не создано" + return borderStyle.Render(body) +} + // runExport performs the actual batch export: for each chosen user it // (re)generates a keypair, rewrites authorized_keys with that user's // currently-allowed hosts, builds the encrypted archive, and records the new @@ -91,13 +133,13 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) { failures = append(failures, username+": не найден в состоянии") continue } - hosts := st.AllowedHosts(u) - res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, hosts) + forwards := st.AllowedForwards(u) + res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards) if err != nil { failures = append(failures, username+": "+err.Error()) continue } - 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()) continue } @@ -123,23 +165,87 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) { 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 { - body := fmt.Sprintf("Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n") + st := m.loadState() + body := "Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n" if len(m.exportOrder) == 0 { body += warnStyle.Render("Нет активных пользователей. Сначала создайте пользователя.") + "\n" } - for i, u := range m.exportOrder { + for i, uname := range m.exportOrder { cursor := " " if i == m.exportCursor { cursor = "> " } check := "[ ]" - if m.exportSel[u] { + if m.exportSel[uname] { 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 += fmt.Sprintf("%s%s %-20s %s\n", cursor, check, uname, status) } - body += "\n[space] отметить [a] отметить всех [enter] сгенерировать архивы [esc] назад" + body += "\n[space] отметить [a] отметить всех [enter] далее (спросит подтверждение) [esc] назад" return borderStyle.Render(body) } diff --git a/internal/ui/groups.go b/internal/ui/groups.go index d51d1f0..0e6b86f 100644 --- a/internal/ui/groups.go +++ b/internal/ui/groups.go @@ -12,6 +12,13 @@ import ( "ssh-access-tui/internal/sysadm" ) +// validNameErr renders a consistent error for group/host/service names that +// fail model.ValidName — these names end up as ssh_config/authorized_keys +// tokens, where spaces or "#" would corrupt the generated file. +func validNameErr(kind, name string) string { + return fmt.Sprintf("%s %q: имя не должно содержать пробелов и спецсимволов (разрешены латинские буквы, цифры, точка, подчёркивание, дефис)", kind, name) +} + func (m *Model) refreshGroupsList() { st := m.loadState() items := make([]list.Item, 0, len(st.Groups)) @@ -40,6 +47,10 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = "имя группы не может быть пустым" return m, nil } + if !model.ValidName(name) { + m.err = validNameErr("группа", name) + return m, nil + } st := m.loadState() if st.FindGroup(name) != nil { m.err = fmt.Sprintf("группа %q уже существует", name) @@ -106,20 +117,20 @@ func (m *Model) refreshHostsList() { items = append(items, simpleItem{ id: 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.Title = fmt.Sprintf("Хосты группы %q [n]добавить [d]удалить [esc]назад", m.selectedGroup) + l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [enter]сервисы хоста [d]удалить [esc]назад", m.selectedGroup) l.SetShowStatusBar(false) m.hostsList = l } // resyncGroupUsers rewrites authorized_keys permitopen= restrictions for // every active user whose groups include groupName, reflecting the group's -// current host list — the only place server-side enforcement is updated -// when the allowed-host list changes over time. +// current hosts/services — the only place server-side enforcement is +// updated when the allowed destinations change over time. func resyncGroupUsers(st *model.State, groupName string) error { for i := range st.Users { u := &st.Users[i] @@ -136,7 +147,7 @@ func resyncGroupUsers(st *model.State, groupName string) error { if !inGroup { 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) } } @@ -155,33 +166,46 @@ func (m Model) updateGroupHosts(msg tea.Msg) (tea.Model, tea.Cmd) { for i := range m.hostForm { 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[2].Placeholder = "порт, например 5432" - m.hostForm[3].Placeholder = "описание (необязательно)" m.hostFormFocus = 0 m.hostForm[0].Focus() + m.err = "" + return m, nil + case "enter": + sel, ok := m.hostsList.SelectedItem().(simpleItem) + if !ok { + return m, nil + } + m.selectedHost = sel.id + m.screen = scrHostServices + m.refreshServicesList() return m, nil case "d": sel, ok := m.hostsList.SelectedItem().(simpleItem) if !ok { return m, nil } + st := m.loadState() groupName := m.selectedGroup - m.mutate(fmt.Sprintf("удалён хост %s из группы %s", sel.id, 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 == sel.id { - g.Hosts = append(g.Hosts[:i], g.Hosts[i+1:]...) + nServices := 0 + if g := st.FindGroup(groupName); g != nil { + for _, h := range g.Hosts { + if h.Name == sel.id { + nServices = len(h.Services) break } } - return resyncGroupUsers(st, groupName) - }) - m.refreshHostsList() + } + m.pendingConfirm = confirmDeleteHost + m.confirmTarget = sel.id + m.confirmReturnTo = scrGroupHosts + m.confirmMessage = fmt.Sprintf( + "Удалить хост %q (и все его сервисы: %d шт.) из группы %q?\n\n"+ + "Все пользователи этой группы немедленно потеряют доступ ко всем сервисам\n"+ + "этого хоста — permitopen убирается из authorized_keys сразу же.", + sel.id, nServices, groupName) + m.screen = scrConfirmAction return m, nil } } @@ -208,19 +232,17 @@ func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": name := m.hostForm[0].Value() addr := m.hostForm[1].Value() - portStr := m.hostForm[2].Value() - desc := m.hostForm[3].Value() - if name == "" || addr == "" || portStr == "" { - m.err = "имя, адрес и порт обязательны" + if name == "" || addr == "" { + m.err = "имя и адрес обязательны" return m, nil } - port, err := strconv.Atoi(portStr) - if err != nil || port <= 0 || port > 65535 { - m.err = "порт должен быть числом от 1 до 65535" + if !model.ValidName(name) { + m.err = validNameErr("хост", name) return m, nil } groupName := m.selectedGroup m.err = "" + var created bool m.mutate(fmt.Sprintf("добавлен хост %s в группу %s", name, groupName), func(st *model.State) error { g := st.FindGroup(groupName) 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) } } - g.Hosts = append(g.Hosts, model.Host{Name: name, Address: addr, Port: port, Description: desc}) - return resyncGroupUsers(st, groupName) + g.Hosts = append(g.Hosts, model.Host{Name: name, Address: addr}) + created = true + return nil }) - m.screen = scrGroupHosts - m.refreshHostsList() + if !created { + return m, nil + } + m.selectedHost = name + m.screen = scrHostServices + m.refreshServicesList() + m.status = fmt.Sprintf("хост %s добавлен — теперь добавьте на нём хотя бы один сервис (порт)", name) return m, nil } } @@ -245,7 +273,143 @@ func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) viewHostNew() string { - body := fmt.Sprintf("Новый хост в группе %q\n\nИмя: %s\nАдрес: %s\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()) + body := fmt.Sprintf("Новый хост в группе %q\n\nИмя: %s\nАдрес: %s\n\n[tab] след. поле [enter] сохранить и перейти к сервисам [esc] отмена", + m.selectedGroup, m.hostForm[0].View(), m.hostForm[1].View()) + return borderStyle.Render(body) +} + +// --- services within a host --- + +func (m *Model) refreshServicesList() { + st := m.loadState() + g := st.FindGroup(m.selectedGroup) + var items []list.Item + if g != nil { + for _, h := range g.Hosts { + if h.Name != m.selectedHost { + continue + } + for _, svc := range h.Services { + items = append(items, simpleItem{ + id: svc.Name, + title: fmt.Sprintf("%s (порт %d)", svc.Name, svc.Port), + desc: svc.Description, + }) + } + } + } + l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height()) + l.Title = fmt.Sprintf("Сервисы хоста %q [n]добавить [d]удалить [esc]назад", m.selectedHost) + l.SetShowStatusBar(false) + m.servicesList = l +} + +func (m Model) updateHostServices(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "esc": + m.screen = scrGroupHosts + m.refreshHostsList() + return m, nil + case "n": + m.screen = scrServiceNew + for i := range m.serviceForm { + m.serviceForm[i] = textinput.New() + } + m.serviceForm[0].Placeholder = "имя сервиса, например ssh или web-api (без пробелов)" + m.serviceForm[1].Placeholder = "порт на хосте, например 22 или 7888" + m.serviceForm[2].Placeholder = "описание (необязательно)" + m.serviceFormFocus = 0 + m.serviceForm[0].Focus() + m.err = "" + return m, nil + case "d": + sel, ok := m.servicesList.SelectedItem().(simpleItem) + if !ok { + return m, nil + } + groupName, hostName := m.selectedGroup, m.selectedHost + m.pendingConfirm = confirmDeleteService + m.confirmTarget = sel.id + m.confirmReturnTo = scrHostServices + m.confirmMessage = fmt.Sprintf( + "Удалить сервис %q у хоста %q (группа %q)?\n\n"+ + "Все пользователи этой группы немедленно потеряют доступ к этому сервису —\n"+ + "permitopen убирается из authorized_keys сразу же.", + sel.id, hostName, groupName) + m.screen = scrConfirmAction + return m, nil + } + } + var cmd tea.Cmd + m.servicesList, cmd = m.servicesList.Update(msg) + return m, cmd +} + +func (m Model) viewHostServices() string { + return borderStyle.Render(m.servicesList.View()) +} + +func (m Model) updateServiceNew(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "esc": + m.screen = scrHostServices + return m, nil + case "tab", "down": + m.serviceForm[m.serviceFormFocus].Blur() + m.serviceFormFocus = (m.serviceFormFocus + 1) % len(m.serviceForm) + m.serviceForm[m.serviceFormFocus].Focus() + return m, nil + case "enter": + name := m.serviceForm[0].Value() + portStr := m.serviceForm[1].Value() + desc := m.serviceForm[2].Value() + if name == "" || portStr == "" { + m.err = "имя и порт обязательны" + return m, nil + } + if !model.ValidName(name) { + m.err = validNameErr("сервис", name) + return m, nil + } + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 || port > 65535 { + m.err = "порт должен быть числом от 1 до 65535" + return m, nil + } + groupName, hostName := m.selectedGroup, m.selectedHost + m.err = "" + m.mutate(fmt.Sprintf("добавлен сервис %s (порт %d) хосту %s", name, port, hostName), func(st *model.State) error { + g := st.FindGroup(groupName) + if g == nil { + return fmt.Errorf("group %s not found", groupName) + } + for hi := range g.Hosts { + if g.Hosts[hi].Name != hostName { + continue + } + for _, svc := range g.Hosts[hi].Services { + if svc.Name == name { + return fmt.Errorf("service %q already exists on host %q", name, hostName) + } + } + g.Hosts[hi].Services = append(g.Hosts[hi].Services, model.Service{Name: name, Port: port, Description: desc}) + } + return resyncGroupUsers(st, groupName) + }) + 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) } diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 0020b99..d668fb7 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -42,11 +42,11 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { m.screen = scrAudit m.refreshAudit() case "quit": - return m, tea.Quit + return m.attemptQuit() } return m, nil case "q": - return m, tea.Quit + return m.attemptQuit() } } var cmd tea.Cmd @@ -57,3 +57,55 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) viewMenu() string { 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) +} diff --git a/internal/ui/users.go b/internal/ui/users.go index 9f2b25a..1cf73e2 100644 --- a/internal/ui/users.go +++ b/internal/ui/users.go @@ -24,7 +24,7 @@ func (m *Model) refreshUsersList() { 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.Title = "Пользователи [n]новый [l]заблокировать [u]разблокировать [d]удалить [esc]назад" + l.Title = "Пользователи [n]новый [g]группы [c]обновить конфиг [l]заблокировать [u]разблокировать [d]удалить [esc]назад" l.SetShowStatusBar(false) m.usersList = l } @@ -46,68 +46,95 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) { m.userNewFocus = 0 m.err, m.status = "", "" 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) if !ok { return m, nil } username := sel.id - switch key.String() { - case "l": - if err := sysadm.LockSystemUser(username); err != nil { - m.err = err.Error() - 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") - case "u": - if err := sysadm.UnlockSystemUser(username); err != nil { - m.err = err.Error() - return m, nil - } - 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.Status = model.StatusActive - u.LockedAt = nil - if u.PublicKey != "" { - if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedHosts(u)); err != nil { - return err - } - } - return nil - }) - 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.pendingConfirm = confirmLockUser + m.confirmTarget = username + m.confirmReturnTo = scrUsers + 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 } + username := sel.id + if err := sysadm.UnlockSystemUser(username); err != nil { + m.err = err.Error() + return m, nil + } + 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.Status = model.StatusActive + u.LockedAt = nil + if u.PublicKey != "" { + if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedForwards(u)); err != nil { + return err + } + } + return nil + }) + m.logAudit(username, "admin", "unlocked") m.refreshUsersList() 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 @@ -184,11 +211,20 @@ func (m Model) viewUserNew() string { // --- 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) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "esc": - m.screen = scrUserNew + if m.userGroupsEditTarget != "" { + m.userGroupsEditTarget = "" + m.screen = scrUsers + } else { + m.screen = scrUserNew + } return m, nil case "up", "k": if m.userNewCursor > 0 { @@ -207,14 +243,37 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case "enter": - username := m.userNewName.Value() - comment := m.userNewComment.Value() var groups []string for _, g := range m.userNewGroupsOrder { if m.userNewGroupsSel[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 { m.err = err.Error() m.screen = scrUsers @@ -242,7 +301,13 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) { } 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 { body += warnStyle.Render("Группы ещё не созданы — сначала создайте хотя бы одну в разделе «Группы и хосты».") + "\n" } @@ -257,6 +322,6 @@ func (m Model) viewUserNewGroups() string { } 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) } diff --git a/ssh-access-tui b/ssh-access-tui index 683a733..5de114f 100755 Binary files a/ssh-access-tui and b/ssh-access-tui differ