diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md index 43c30fe..b47fa94 100644 --- a/docs/ADMIN_GUIDE.md +++ b/docs/ADMIN_GUIDE.md @@ -17,10 +17,22 @@ sudo ssh-access-tui Навигация везде: стрелки/`j`/`k` — перемещение по списку, `enter` — выбрать/подтвердить, `esc` — назад, `ctrl+c` — жёсткий выход в любой момент -(без каких-либо предупреждений — только для аварийных случаев). Обычный -выход через пункт меню «Выход» или `q` в главном меню предупредит, если -есть созданные, но ни разу не экспортированные пользователи (у них ещё нет -ключа) — можно выйти всё равно (`enter`/`y`) или вернуться (`esc`/`n`). +(без каких-либо предупреждений — только для аварийных случаев). `q` как +быстрый выход работает **только в главном меню** (на остальных экранах он +ничего не делает — «esc» уводит назад). Обычный выход (пункт «Выход» или +`q` в главном меню) предупредит, если есть: + +- пользователи, для которых ни разу не сгенерирован ключ (не были в + «Экспорт архивов»); +- пользователи, у которых **изменился доступ, но конфиг у них старый** + (кто-то поменял группы/каталог после их последнего экспорта или + обновления конфига) — им нужно прислать новый `config` через «Обновить + конфиги», иначе их локальный конфиг не соответствует их реальным правам. + +В обоих случаях можно выйти всё равно (`enter`/`y`) или вернуться +(`esc`/`n`). Главное меню всегда показывает актуальное количество таких +пользователей под списком пунктов, если оно не нулевое — не нужно ждать +выхода, чтобы это заметить. **Любое деструктивное действие** (блокировка/удаление пользователя, удаление хоста/сервиса/группы, перевыпуск уже выданного ключа) требует не @@ -36,8 +48,9 @@ sudo ssh-access-tui ``` Пользователи — создать / заблокировать / разблокировать / удалить Хосты и сервисы — общий каталог адресов и портов (один раз на всех) -Группы — какие хосты из каталога входят в каждую группу +Группы — какие сервисы из каталога входят в каждую группу Экспорт архивов — сгенерировать ключи и .7z для доставки +Обновить конфиги — перевыпустить config (без ключа) тем, у кого изменился доступ Аудит / кто в сети — активные сессии и журнал подключений Выход ``` @@ -49,12 +62,13 @@ sudo ssh-access-tui ## Модель: каталог → группы → пользователи Хосты и их сервисы (порты) описываются **один раз** в общем каталоге -(«Хосты и сервисы»), а не заново для каждой группы. Группа — это просто -отметка галочками, какие хосты каталога в неё входят (все сервисы такого -хоста включаются целиком). Если у вас в разных группах фигурируют одни и те +(«Хосты и сервисы»), а не заново для каждой группы. Доступ раздаётся **по +конкретным сервисам**, не по хосту целиком: группа выбирает нужные сервисы +из дерева «хост → его сервисы» — можно взять только `ssh` этого хоста, не +трогая его же `web-admin`. Если у вас в разных группах фигурируют одни и те же хосты/сервисы — их не нужно набивать повторно, только отметить нужные для очередной группы. Пользователь состоит в одной или нескольких группах; -его реальный доступ — объединение сервисов всех хостов всех его групп. +его реальный доступ — объединение сервисов, выбранных во всех его группах. ## Сценарий 1: новый сотрудник, у которого уже есть роль/группа доступа @@ -74,8 +88,12 @@ sudo ssh-access-tui автоматически обновятся). 4. **Группы** → `n` → имя группы (например `team-billing`), описание (необязательно) → `enter`. -5. Выбрать группу → `enter` → отметить `space` нужные хосты из каталога → - `enter` сохранить. Список сервисов этих хостов достаётся группе целиком. +5. Выбрать группу → `enter` — откроется дерево «хост → его сервисы». + `space` на строке сервиса отмечает именно его; `space` на строке хоста + (заголовок) отмечает/снимает сразу все его сервисы — удобно, когда нужен + весь хост, но при желании можно оставить только часть его сервисов. + `[x]` — выбрано всё, `[~]` — выбрано частично, `[ ]` — ничего. `enter` + сохранить. 6. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин) и комментарий (например ФИО) → `enter`. 7. Отметить `space` нужные группы (можно несколько) → `enter`. @@ -113,15 +131,26 @@ sudo ssh-access-tui Если хотите, чтобы у пользователя появился ещё и обновлённый клиентский `config` с новыми адресами для новых сервисов (иначе он про них просто не узнает, хотя доступ уже есть) — **Пользователи** → выбрать пользователя → -`c` («обновить конфиг»). Это генерирует маленький архив только с `config` + -`INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже есть и остаётся тем -же), пользователю нужно только подменить файл `config`. Не требует -подтверждения — ключ и доступ этим действием не меняются, это не +`c` («обновить конфиг»), либо (то же самое, но сразу для многих) +**Обновить конфиги** в главном меню. Это генерирует архив только с +`config` + `INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже есть и +остаётся тем же), пользователю нужно только подменить файл `config`. Не +требует подтверждения — ключ и доступ этим действием не меняются, это не деструктивно. -Полный экспорт (сценарий 1, шаги 8-9) тоже сработает и даст то же самое -плюс заново выпустит ключ (с подтверждением строкой, раз это перевыпуск) — -используйте `c`, когда переиздание ключа не нужно. +**Обновить конфиги** в главном меню — то же самое, что `c`, но пакетно: +список покажет каждого уже выпущенного пользователя с пометкой «конфиг +актуален» или «конфиг устарел — доступ изменился» (см. ниже, как это +отслеживается), можно отметить `space` только устаревших или `a` — всех, и +получить один пароль на всю партию, как при обычном экспорте. + +Инструмент сам запоминает, для какого набора сервисов последний раз +выдавался config каждому пользователю (полным экспортом или через +«обновить конфиг»), и сравнивает с тем, что должно быть сейчас — отсюда и +берётся статус «устарел». Полный экспорт (сценарий 1, шаги 8-9) тоже +обновляет этот статус (и заодно перевыпускает ключ, с подтверждением +строкой) — используйте `c`/«Обновить конфиги», когда переиздание ключа не +нужно. ## Сценарий 2: добавили новый сервис/хост, поменялся адрес или порт @@ -129,26 +158,32 @@ sudo ssh-access-tui `enter` → `n`. - Новый хост: **Хосты и сервисы** → `n`, затем добавить ему сервисы. - Хост сменил адрес, сервис — порт, нужно поправить описание, или - переименовать: `e` на нужной записи. Переименование хоста автоматически - обновляет ссылки на него во всех группах, которые его используют — группы - не «отваливаются» молча. + переименовать: `e` на нужной записи. Переименование хоста или сервиса + автоматически обновляет ссылки на него во всех группах, которые его + используют — группы не «отваливаются» молча и не теряют доступ из-за + простого переименования. Любое из этих изменений сразу обновляет `permitopen=` в `authorized_keys` -**всех активных пользователей всех групп**, ссылающихся на этот хост, — +**всех активных пользователей всех групп**, ссылающихся на этот сервис, — доступ появляется немедленно, без каких-либо действий на стороне -пользователя. Клиентский конфиг пользователя (его `~/.ssh/config`) при этом -не обновляется автоматически — для этого используйте **Пользователи** → `c` -(«обновить конфиг», см. сценарий 1а). +пользователя. Их клиентский конфиг (`~/.ssh/config`) при этом не +обновляется автоматически — их локальный файл начинает отставать от +реального доступа (инструмент это заметит и покажет как «конфиг устарел», +см. выше); чтобы прислать актуальный конфиг — **Обновить конфиги** или `c` +(см. сценарий 1а). -**Группы** → выбрать группу → `enter` — там же можно снять/добавить -привязку группы к хостам каталога (не создавая и не удаляя сами хосты). +**Группы** → выбрать группу → `enter` — то же дерево «хост → сервисы», там +же можно снять/добавить сервисы группе (не создавая и не удаляя сами +хосты/сервисы в каталоге). Удаление хоста из каталога, сервиса на хосте или целой группы (`d` в соответствующем списке) — все три действия **деструктивны** (мгновенно отбирают доступ у всех затронутых пользователей) и требуют набрать случайную строку подтверждения: экран покажет, что именно удаляется, сколько пользователей это коснётся, и в каких группах используется -(для хоста/сервиса). +(для хоста/сервиса). Удаление хоста снимает с групп все его сервисы разом; +удаление одного сервиса снимает только его, остальные сервисы того же +хоста в группах остаются. ## Сценарий 3: сотрудник уволился @@ -202,8 +237,10 @@ sudo ssh-access-tui когда получатель подтвердил, что распаковал архив (это касается и архивов от «обновить конфиг», хоть они и не содержат ключ). - Не обновляет клиентский `~/.ssh/config` пользователя автоматически при - появлении нового сервиса/хоста в его группе — это ручной шаг («обновить - конфиг», сценарий 1а), права доступа на сервере при этом обновляются - немедленно и без участия администратора (см. сценарий 2). + появлении нового сервиса/хоста в его группе — это ручной шаг («Обновить + конфиги», сценарий 1а), хоть инструмент и отслеживает, кому именно он + нужен (главное меню и предупреждение при выходе). Права доступа на + сервере при этом обновляются немедленно и без участия администратора + (см. сценарий 2). - Не поддерживает одновременную работу нескольких администраторов — рассчитан на одного за раз (см. «Конкурентность» в ARCHITECTURE.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 65f992e..d2087a6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,20 +25,26 @@ TUI работает поверх уже существующего SSH+`sudo`- раз, а не заново в каждой группе. Хост (`Host`) — это адрес (например, `172.16.1.20`) с описанием; на одном хосте может быть несколько сервисов (`Service`) — независимых TCP-портов: `ssh` на 22, какое-то приложение на -7888, и так далее. Группа (`Group`) не хранит хосты сама — она хранит -только список **имён** хостов из каталога (`Group.Hosts []string`), -которые в неё входят (все сервисы такого хоста включаются целиком). Если -одни и те же хосты/сервисы нужны нескольким группам — они описываются один -раз в каталоге, а группы просто отмечают их галочками, вместо повторного -набора адресов и портов. +7888, и так далее. Группа (`Group`) не хранит хосты — она хранит список +**ссылок на конкретные сервисы** каталога (`Group.Services []ServiceRef`, +каждая — пара `{Host, Service}`). Доступ раздаётся по сервису, а не по +хосту целиком: группа может включать `db1/ssh`, не давая заодно +`db1/web-admin` того же хоста. UI показывает выбор как дерево "хост → +сервисы" (`internal/ui/groups.go`), где отметка на строке хоста — это +просто ярлык "выбрать все его сервисы", а не отдельный уровень доступа в +модели. Если одни и те же хосты/сервисы нужны нескольким группам — они +описываются один раз в каталоге, а группы просто отмечают нужные сервисы, +вместо повторного набора адресов и портов. Пользователь может состоять в нескольких группах; его реальный эффективный -доступ — объединение сервисов всех хостов всех его групп, это и называется -`Forward` (`internal/model/types.go`, `State.AllowedForwards`). Переименование -хоста автоматически обновляет ссылки на него во всех группах -(`State.RenameHost`); удаление хоста снимает его со всех групп, которые его -использовали (`State.DeleteHost`) — ссылочная целостность каталога держится -в модели, а не проверяется вручную в UI. +доступ — объединение сервисов, на которые ссылаются все его группы, это и +называется `Forward` (`internal/model/types.go`, `State.AllowedForwards`). +Ссылочная целостность каталога держится в модели, а не проверяется вручную +в UI: переименование хоста или сервиса обновляет `ServiceRef` во всех +группах (`State.RenameHost`, `State.RenameServiceRefs`); удаление хоста или +одного его сервиса снимает соответствующие `ServiceRef` со всех групп +(`State.DeleteHost`, `State.RemoveServiceRefs`) — переименование/удаление +одного сервиса никогда не задевает остальные сервисы того же хоста. Ключевой момент: пользователь **никогда не подключается по SSH к целевому хосту напрямую** (ни через `ProxyJump`, ни как-то ещё) — он открывает одно @@ -110,6 +116,31 @@ Reverse/remote forwarding (`-R`) в этой модели не выдаётся порты. Итоговое соответствие "локальный порт → сервис" попадает в `INSTRUCTIONS.txt` внутри архива пользователя. +## Отслеживание устаревших конфигов + +Права на сервере (`permitopen=` в `authorized_keys`) обновляются немедленно +при любом изменении групп/каталога — но клиентский `config` пользователя +лежит у него на компьютере и сам не меняется. Чтобы админ не забывал, кому +нужно прислать новый `config`, каждому пользователю в состоянии хранится +`ForwardsHash` (`internal/model/hash.go`) — детерминированный (порядок +форвардов не важен, список сортируется перед хешированием) хэш набора +`Forward`, который был у него на момент последнего экспорта или "обновить +конфиг". `State.IsConfigStale` просто сравнивает этот сохранённый хэш с +хэшем текущего `AllowedForwards(u)`: если они разошлись — где-то в +группах/каталоге, от которых зависит доступ этого пользователя, что-то +поменялось, а он ещё не получил обновлённый `config`. + +Это используется в двух местах: в главном меню (живой счётчик под списком +пунктов, пересчитывается на каждый кадр — `internal/ui/menu.go`, +`viewMenu`) и на экране подтверждения выхода (`attemptQuit`/ +`viewQuitConfirm`), отдельно от пользователей без ключа вообще +(`State.NeverExportedUsernames`). "Обновить конфиги" +(`internal/ui/configupdate.go`) — пакетный аналог экспорта, но без +переиздания ключа: строит только `config`+`INSTRUCTIONS.txt` для каждого +выбранного пользователя (`keymgr.BuildConfigOnlyArchive`) и обновляет его +`ForwardsHash`, ничего не трогая в `authorized_keys` и не требуя +подтверждения строкой — это не деструктивное действие. + ## Жизненный цикл приватного ключа Приватный ключ **никогда не хранится на диске дольше одной операции @@ -141,6 +172,16 @@ fingerprint. Это осознанное решение: кража бэкапа у которого его ещё не было, ничего не ломает и подтверждения не требует — трение нужно только там, где есть что сломать. +**Готча при добавлении новых экранов-списков:** `bubbles/list` по умолчанию +сам перехватывает `q` и мгновенно возвращает `tea.Quit`, в обход всей этой +логики — на любом экране, где необработанная клавиша проваливается в +`list.Model.Update`. `internal/ui/app.go` (`newList`) вызывает +`l.DisableQuitKeybindings()` на каждом создаваемом списке ровно из-за +этого — при добавлении нового списка мимо `newList` эта защита пропадает +незаметно (сам список продолжит работать, просто `q` будет закрывать +программу без единого предупреждения). Обнаружено и подтверждено тестом +именно в такой форме: экран без нужного `case "q"` в своём `Update`. + ## Бэкап и аварийное восстановление Каталог состояния (`/var/lib/ssh-access-tui/state`) — git-репозиторий. diff --git a/internal/keymgr/instructions.go b/internal/keymgr/instructions.go index 5724f33..a904bfb 100644 --- a/internal/keymgr/instructions.go +++ b/internal/keymgr/instructions.go @@ -35,7 +35,17 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName) b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\n\n") - b.WriteString("ШАГ 2. Подключитесь\n") + b.WriteString("ШАГ 2. Настройте доступ к файлу ключа\n") + b.WriteString("----------------------------------------\n") + b.WriteString("Windows откажется использовать секретный ключ, если его может прочитать\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("Если файлы лежат не в стандартной папке .ssh, а где-то ещё — замените путь\n") + b.WriteString("в обеих командах на реальный путь к файлу ключа (в кавычках, как выше).\n\n") + + b.WriteString("ШАГ 3. Подключитесь\n") b.WriteString("---------------------\n") b.WriteString("В PowerShell введите команду:\n\n") b.WriteString(" ssh -N jump\n\n") @@ -44,7 +54,7 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar b.WriteString("таблицы ниже. Закрыли окно (или выключили компьютер) — доступ пропал, просто\n") b.WriteString("откройте заново командой выше, когда снова понадобится.\n\n") - b.WriteString("ШАГ 3. Пользуйтесь\n") + b.WriteString("ШАГ 4. Пользуйтесь\n") b.WriteString("--------------------\n") b.WriteString("Эта настройка открывает у вас на компьютере (локально) следующие адреса.\n") b.WriteString("В программе, которая должна подключиться к сервису (браузер, клиент базы\n") @@ -71,8 +81,13 @@ func RenderInstructions(username, keyFileName string, forwards []AllocatedForwar b.WriteString("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n") b.WriteString("------------------------\n") - b.WriteString("Обратитесь к администратору, который выдал вам этот архив. Возможно,\n") - b.WriteString("изменился список сервисов — тогда вам пришлют обновлённый архив.\n") + b.WriteString("Написано «This account is currently not available» и соединение сразу\n") + b.WriteString("обрывается? Это нормально, если вы вводили команду БЕЗ \"-N\" (например,\n") + b.WriteString("просто \"ssh jump\") — обычный вход сюда специально отключён, используйте\n") + b.WriteString("именно команду из шага 3 (\"ssh -N jump\").\n\n") + b.WriteString("Если не работает именно шаг 3 или шаг 4 — обратитесь к администратору,\n") + b.WriteString("который выдал вам этот архив. Возможно, изменился список сервисов — тогда\n") + b.WriteString("вам пришлют обновлённый архив.\n") return b.String() } diff --git a/internal/model/hash.go b/internal/model/hash.go new file mode 100644 index 0000000..0bf4bee --- /dev/null +++ b/internal/model/hash.go @@ -0,0 +1,62 @@ +package model + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" +) + +// HashForwards returns a stable, order-independent digest of a Forward set, +// used to detect whether a user's already-delivered client config still +// matches their current access. Sorted before hashing so that reordering +// the underlying groups/hosts/services (without actually changing what's +// granted) never produces a spurious "stale" result. +func HashForwards(forwards []Forward) string { + lines := make([]string, len(forwards)) + for i, f := range forwards { + lines[i] = fmt.Sprintf("%s|%s|%d|%s|%s", f.HostName, f.Address, f.Port, f.ServiceName, f.Description) + } + sort.Strings(lines) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return hex.EncodeToString(sum[:]) +} + +// IsConfigStale reports whether u's live AllowedForwards no longer matches +// the set they were handed in their last config (full export or +// config-only update). Users who never had a key issued are not "stale" — +// that's a distinct, more basic state (see State.NeverExportedUsernames). +func (s *State) IsConfigStale(u *User) bool { + if u.PublicKey == "" { + return false + } + return u.ForwardsHash != HashForwards(s.AllowedForwards(u)) +} + +// NeverExportedUsernames returns active users who have never had a key +// issued at all (empty PublicKey) — created but forgotten before "Экспорт +// архивов". +func (s *State) NeverExportedUsernames() []string { + var out []string + for _, u := range s.Users { + if u.Status == StatusActive && u.PublicKey == "" { + out = append(out, u.Username) + } + } + return out +} + +// StaleConfigUsernames returns active, already-issued users whose local +// config no longer matches their real access (see IsConfigStale) — they +// need "Обновить конфиги", not a full re-export. +func (s *State) StaleConfigUsernames() []string { + var out []string + for i := range s.Users { + u := &s.Users[i] + if u.Status == StatusActive && u.PublicKey != "" && s.IsConfigStale(u) { + out = append(out, u.Username) + } + } + return out +} diff --git a/internal/model/types.go b/internal/model/types.go index 165c09a..6c96103 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -1,10 +1,9 @@ // Package model defines the data types persisted as the tool's state. // // Hosts (each offering one or more TCP services) form a shared catalog, -// defined once; groups reference a subset of that catalog by name rather -// than redefining hosts/services per group — this matters when the same -// hosts/services are reused across many groups; and users are assigned to -// one or more groups. +// defined once; groups reference individual services of that catalog (not +// whole hosts) by name, so access can be scoped per-service rather than +// per-host; and users are assigned to one or more groups. package model import ( @@ -20,7 +19,8 @@ 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. +// space or "#" would corrupt the generated file. Names also never contain +// "/", which ServiceRef keys (see below) rely on as a safe separator. func ValidName(s string) bool { return nameRe.MatchString(s) } @@ -36,8 +36,8 @@ type Service struct { // Host is a catalog entry: one jump-target reachable via port forwarding // through the bastion, defined once and referenced by any number of groups -// (see Group.Hosts) — hosts/services shared across many groups only need to -// be entered a single time. +// (see Group.Services) — hosts/services shared across many groups only +// need to be entered a single time. type Host struct { Name string `yaml:"name"` // short identifier, e.g. "db-prod-1" Address string `yaml:"address"` // hostname or IP as seen from the jump host @@ -45,14 +45,22 @@ type Host struct { Services []Service `yaml:"services"` } -// Group is a named access policy referencing a subset of catalog hosts (by -// name — all of that host's services are included). Users are assigned to -// one or more groups; their effective allowed forwards are the union of -// services on all hosts referenced by all their groups. +// ServiceRef points at one specific service of one specific catalog host. +// Groups grant access per-service, not per-host: a group can include just +// "db1/ssh" without also granting "db1/web-admin". +type ServiceRef struct { + Host string `yaml:"host"` + Service string `yaml:"service"` +} + +// Group is a named access policy referencing a subset of catalog services +// (by host+service name). Users are assigned to one or more groups; their +// effective allowed forwards are the union of all services referenced by +// all their groups. type Group struct { - Name string `yaml:"name"` - Description string `yaml:"description,omitempty"` - Hosts []string `yaml:"hosts"` // names referencing State.Hosts + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` + Services []ServiceRef `yaml:"services"` } // UserStatus is the lifecycle state of a managed account. @@ -69,10 +77,15 @@ type User struct { Groups []string `yaml:"groups"` Status UserStatus `yaml:"status"` Fingerprint string `yaml:"fingerprint"` // SHA256 fingerprint of the current authorized public key - PublicKey string `yaml:"public_key,omitempty"` // full "ssh-ed25519 AAAA... comment" line; safe to store/back up (public), lets us rewrite authorized_keys (e.g. after a group's host list changes) without re-issuing the keypair - Comment string `yaml:"comment,omitempty"` - CreatedAt time.Time `yaml:"created_at"` - LockedAt *time.Time `yaml:"locked_at,omitempty"` + PublicKey string `yaml:"public_key,omitempty"` // full "ssh-ed25519 AAAA... comment" line; safe to store/back up (public), lets us rewrite authorized_keys (e.g. after a group's referenced services change) without re-issuing the keypair + // ForwardsHash is HashForwards(AllowedForwards(u)) as of the last time + // this user was handed a config (full export or config-only update). If + // it no longer matches the live computation, their local ssh config is + // stale — see IsConfigStale. + ForwardsHash string `yaml:"forwards_hash,omitempty"` + Comment string `yaml:"comment,omitempty"` + CreatedAt time.Time `yaml:"created_at"` + LockedAt *time.Time `yaml:"locked_at,omitempty"` } // State is the full persisted configuration of the tool. @@ -125,9 +138,9 @@ type Forward struct { } // AllowedForwards returns the de-duplicated union of (host, service) pairs -// across all catalog hosts referenced by all of the user's groups, in -// stable encounter order. Two services that resolve to the same -// address:port (even from different groups) collapse to a single Forward. +// across every ServiceRef of every group the user belongs to, in stable +// encounter order. Two services that resolve to the same address:port +// (even from different groups) collapse to a single Forward. func (s *State) AllowedForwards(u *User) []Forward { seen := map[string]bool{} var out []Forward @@ -136,36 +149,60 @@ func (s *State) AllowedForwards(u *User) []Forward { if g == nil { continue } - for _, hostName := range g.Hosts { - h := s.FindHost(hostName) + for _, ref := range g.Services { + h := s.FindHost(ref.Host) if h == nil { continue } - for _, svc := range h.Services { - key := fmt.Sprintf("%s:%d", h.Address, svc.Port) - if seen[key] { - continue + var svc *Service + for i := range h.Services { + if h.Services[i].Name == ref.Service { + svc = &h.Services[i] + break } - seen[key] = true - out = append(out, Forward{ - HostName: h.Name, - Address: h.Address, - Port: svc.Port, - ServiceName: svc.Name, - Description: svc.Description, - }) + } + if svc == nil { + continue + } + 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, + }) + } + } + return out +} + +// GroupNamesReferencingHost returns every group that references at least +// one service of hostName. +func (s *State) GroupNamesReferencingHost(hostName string) []string { + var out []string + for _, g := range s.Groups { + for _, ref := range g.Services { + if ref.Host == hostName { + out = append(out, g.Name) + break } } } return out } -// GroupNamesReferencingHost returns every group that includes hostName. -func (s *State) GroupNamesReferencingHost(hostName string) []string { +// GroupNamesReferencingService returns every group that references this +// exact (host, service) pair. +func (s *State) GroupNamesReferencingService(hostName, serviceName string) []string { var out []string for _, g := range s.Groups { - for _, h := range g.Hosts { - if h == hostName { + for _, ref := range g.Services { + if ref.Host == hostName && ref.Service == serviceName { out = append(out, g.Name) break } @@ -188,12 +225,12 @@ func (s *State) UsernamesInGroup(groupName string) []string { return out } -// UsernamesUsingHost returns every user reachable transitively through any -// group that references hostName (deduplicated). -func (s *State) UsernamesUsingHost(hostName string) []string { +// usernamesInAnyGroup returns the deduplicated union of UsernamesInGroup +// across the given group names. +func (s *State) usernamesInAnyGroup(groupNames []string) []string { seen := map[string]bool{} var out []string - for _, gname := range s.GroupNamesReferencingHost(hostName) { + for _, gname := range groupNames { for _, uname := range s.UsernamesInGroup(gname) { if !seen[uname] { seen[uname] = true @@ -204,6 +241,18 @@ func (s *State) UsernamesUsingHost(hostName string) []string { return out } +// UsernamesUsingHost returns every user reachable transitively through any +// group that references any service of hostName (deduplicated). +func (s *State) UsernamesUsingHost(hostName string) []string { + return s.usernamesInAnyGroup(s.GroupNamesReferencingHost(hostName)) +} + +// UsernamesUsingService returns every user reachable transitively through +// any group that references this exact (host, service) pair (deduplicated). +func (s *State) UsernamesUsingService(hostName, serviceName string) []string { + return s.usernamesInAnyGroup(s.GroupNamesReferencingService(hostName, serviceName)) +} + // RenameGroup renames a group and updates every user's membership reference // so nobody silently falls out of a group just because it was renamed. func (s *State) RenameGroup(oldName, newName string) { @@ -240,24 +289,26 @@ func (s *State) DeleteGroup(name string) { } } -// RenameHost renames a catalog host and updates every group's reference to -// it, so a rename doesn't silently drop the host out of groups using it. +// RenameHost renames a catalog host and updates every group's ServiceRefs +// pointing at it, so a rename doesn't silently drop the host's services out +// of groups using them. func (s *State) RenameHost(oldName, newName string) { if h := s.FindHost(oldName); h != nil { h.Name = newName } for i := range s.Groups { - for j, hn := range s.Groups[i].Hosts { - if hn == oldName { - s.Groups[i].Hosts[j] = newName + for j := range s.Groups[i].Services { + if s.Groups[i].Services[j].Host == oldName { + s.Groups[i].Services[j].Host = newName } } } } -// DeleteHost removes a host from the catalog and from every group that -// referenced it. Caller is responsible for resyncing authorized_keys for -// any affected user (see UsernamesUsingHost, called before this). +// DeleteHost removes a host from the catalog and drops every group's +// ServiceRefs that pointed at any of its services. Caller is responsible +// for resyncing authorized_keys for any affected user (see +// UsernamesUsingHost, called before this). func (s *State) DeleteHost(name string) { for i := range s.Hosts { if s.Hosts[i].Name == name { @@ -266,12 +317,42 @@ func (s *State) DeleteHost(name string) { } } for i := range s.Groups { - var kept []string - for _, hn := range s.Groups[i].Hosts { - if hn != name { - kept = append(kept, hn) + var kept []ServiceRef + for _, ref := range s.Groups[i].Services { + if ref.Host != name { + kept = append(kept, ref) } } - s.Groups[i].Hosts = kept + s.Groups[i].Services = kept + } +} + +// RenameServiceRefs updates every group's ServiceRef pointing at +// (hostName, oldService) to (hostName, newService), so renaming a service +// doesn't silently drop it out of groups using it. +func (s *State) RenameServiceRefs(hostName, oldService, newService string) { + for i := range s.Groups { + for j := range s.Groups[i].Services { + ref := &s.Groups[i].Services[j] + if ref.Host == hostName && ref.Service == oldService { + ref.Service = newService + } + } + } +} + +// RemoveServiceRefs drops every group's ServiceRef pointing at +// (hostName, serviceName). Caller is responsible for resyncing +// authorized_keys for any affected user (see UsernamesUsingService, called +// before this). +func (s *State) RemoveServiceRefs(hostName, serviceName string) { + for i := range s.Groups { + var kept []ServiceRef + for _, ref := range s.Groups[i].Services { + if !(ref.Host == hostName && ref.Service == serviceName) { + kept = append(kept, ref) + } + } + s.Groups[i].Services = kept } } diff --git a/internal/ui/app.go b/internal/ui/app.go index ae3ff73..55f5646 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -38,6 +38,7 @@ const ( scrGroupHostsPick scrExportSelect scrExportResult + scrConfigUpdateSelect scrConfigUpdateResult scrAudit scrQuitConfirm @@ -83,6 +84,15 @@ func newList(items []list.Item, title string, w, h int) list.Model { l.Title = title l.Styles.Title = listTitleStyle l.SetShowStatusBar(false) + // bubbles/list binds "q" (and ForceQuit) to return tea.Quit directly, + // bypassing whatever screen it's on. Every screen except the main menu + // falls through unhandled keys straight into the list's own Update, so + // without this, pressing "q" on the Users/Groups/Catalog screens would + // quit the whole program immediately — skipping attemptQuit's warnings + // about unexported or stale-config users entirely. The menu handles "q" + // itself before reaching the list, so disabling it here is harmless + // there and load-bearing everywhere else. + l.DisableQuitKeybindings() return l } @@ -118,15 +128,17 @@ type Model struct { serviceFormFocus int serviceFormEditTarget string // "" = creating a new service - // groups: named policies that reference a subset of catalog hosts - groupsList list.Model - groupForm [2]textinput.Model // name, description - groupFormFocus int - groupFormEditTarget string // "" = creating a new group - selectedGroup string - groupHostsPickSel map[string]bool - groupHostsPickOrder []string - groupHostsPickCursor int + // groups: named policies that reference individual catalog services, + // picked via a host -> services tree (see groups.go) + groupsList list.Model + groupForm [2]textinput.Model // name, description + groupFormFocus int + groupFormEditTarget string // "" = creating a new group + selectedGroup string + groupPickRows []pickRow + groupPickServices map[string][]string // host -> its service names, snapshot at screen-entry + groupPickSel map[string]bool // key "host/service" -> selected + groupPickCursor int exportSel map[string]bool exportOrder []string @@ -136,11 +148,19 @@ type Model struct { exportPass string exportErr string + configUpdateSel map[string]bool + configUpdateOrder []string + configUpdateCursor int + configUpdateRes []keymgr.ConfigOnlyResult + configUpdatePass string + configUpdateErr string + 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 + quitStale []string // usernames whose config is out of date, shown on scrQuitConfirm // generic confirmation for destructive actions (lock/delete user, // delete host/service/group, export key rotation) — see confirm.go. @@ -155,10 +175,6 @@ type Model struct { confirmInput textinput.Model confirmError string - configUpdateUser string - configUpdateArchive string - configUpdatePass string - width, height int } @@ -257,6 +273,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateExportSelect(msg) case scrExportResult: return m.updateExportResult(msg) + case scrConfigUpdateSelect: + return m.updateConfigUpdateSelect(msg) case scrConfigUpdateResult: return m.updateConfigUpdateResult(msg) case scrAudit: @@ -298,6 +316,8 @@ func (m Model) View() string { body = m.viewExportSelect() case scrExportResult: body = m.viewExportResult() + case scrConfigUpdateSelect: + body = m.viewConfigUpdateSelect() case scrConfigUpdateResult: body = m.viewConfigUpdateResult() case scrAudit: diff --git a/internal/ui/catalog.go b/internal/ui/catalog.go index e0b0729..b491d22 100644 --- a/internal/ui/catalog.go +++ b/internal/ui/catalog.go @@ -262,12 +262,14 @@ func (m Model) updateCatalogServices(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } st := m.loadState() - affected := st.UsernamesUsingHost(m.selectedHost) + affected := st.UsernamesUsingService(m.selectedHost, sel.id) + groups := st.GroupNamesReferencingService(m.selectedHost, sel.id) msg := fmt.Sprintf( "Удалить сервис %q у хоста %q?\n\n"+ + "Он используется в группах: %v.\n"+ "Это затронет пользователей: %d — они немедленно потеряют доступ к этому\n"+ "сервису.", - sel.id, m.selectedHost, len(affected)) + sel.id, m.selectedHost, groups, len(affected)) m = m.startConfirm(confirmDeleteService, sel.id, msg, scrCatalogServices) return m, nil } @@ -331,6 +333,7 @@ func (m Model) updateServiceForm(msg tea.Msg) (tea.Model, tea.Cmd) { return fmt.Errorf("сервис %q уже существует на этом хосте", name) } } + st.RenameServiceRefs(hostName, target, name) } for i := range h.Services { if h.Services[i].Name == target { diff --git a/internal/ui/configupdate.go b/internal/ui/configupdate.go new file mode 100644 index 0000000..e676b72 --- /dev/null +++ b/internal/ui/configupdate.go @@ -0,0 +1,178 @@ +package ui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + + "ssh-access-tui/internal/keymgr" + "ssh-access-tui/internal/model" +) + +// initConfigUpdateSelect lists every already-issued active user, so the +// admin can force-refresh anyone's config even if it isn't detected as +// stale — the status annotation (see viewConfigUpdateSelect) shows which +// ones actually need it. +func (m *Model) initConfigUpdateSelect() { + st := m.loadState() + m.configUpdateSel = map[string]bool{} + m.configUpdateOrder = nil + for _, u := range st.Users { + if u.Status == model.StatusActive && u.PublicKey != "" { + m.configUpdateOrder = append(m.configUpdateOrder, u.Username) + } + } + m.configUpdateCursor = 0 + m.configUpdateErr = "" +} + +func (m Model) updateConfigUpdateSelect(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "esc": + m.screen = scrMenu + m.err, m.status = "", "" + return m, nil + case "up", "k": + if m.configUpdateCursor > 0 { + m.configUpdateCursor-- + } + return m, nil + case "down", "j": + if m.configUpdateCursor < len(m.configUpdateOrder)-1 { + m.configUpdateCursor++ + } + return m, nil + case " ": + if len(m.configUpdateOrder) > 0 { + u := m.configUpdateOrder[m.configUpdateCursor] + m.configUpdateSel[u] = !m.configUpdateSel[u] + } + return m, nil + case "a": + for _, u := range m.configUpdateOrder { + m.configUpdateSel[u] = true + } + return m, nil + case "enter": + var chosen []string + for _, u := range m.configUpdateOrder { + if m.configUpdateSel[u] { + chosen = append(chosen, u) + } + } + if len(chosen) == 0 { + m.err = "выберите хотя бы одного пользователя (space)" + return m, nil + } + return m.runConfigUpdate(chosen) + } + } + return m, nil +} + +func (m Model) viewConfigUpdateSelect() string { + st := m.loadState() + body := "Обновить конфиги — выберите пользователей (ключи не трогает, общий пароль на партию)\n\n" + if len(m.configUpdateOrder) == 0 { + body += warnStyle.Render("Нет пользователей с уже выданным ключом.") + "\n" + } + for i, uname := range m.configUpdateOrder { + cursor := " " + if i == m.configUpdateCursor { + cursor = "> " + } + check := "[ ]" + if m.configUpdateSel[uname] { + check = "[x]" + } + status := okStyle.Render("конфиг актуален") + if u := st.FindUser(uname); u != nil && st.IsConfigStale(u) { + status = warnStyle.Render("конфиг устарел — доступ изменился") + } + body += fmt.Sprintf("%s%s %-20s %s\n", cursor, check, uname, status) + } + body += "\n[space] отметить [a] отметить всех [enter] сгенерировать [esc] назад" + return borderStyle.Render(body) +} + +// runConfigUpdate is the lightweight alternative to a full export: when +// only group membership or the catalog's services changed, server-side +// access is already updated immediately (authorized_keys permitopen=) — this +// just hands the selected users a fresh client config reflecting that, +// without touching any key. See keymgr.BuildConfigOnlyArchive. Not +// destructive (no key is rotated, nothing existing breaks), so it runs +// without a confirmation screen regardless of batch size. +func (m Model) runConfigUpdate(usernames []string) (tea.Model, tea.Cmd) { + password, err := keymgr.GenerateBatchPassword(20) + if err != nil { + m.err = err.Error() + return m, nil + } + + st := m.loadState() + var results []keymgr.ConfigOnlyResult + var failures []string + + for _, username := range usernames { + u := st.FindUser(username) + if u == nil { + failures = append(failures, username+": не найден в состоянии") + continue + } + if u.PublicKey == "" { + failures = append(failures, username+": ещё нет ключа, используйте «Экспорт архивов»") + continue + } + 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 { + failures = append(failures, username+": "+err.Error()) + continue + } + u.ForwardsHash = model.HashForwards(forwards) + results = append(results, *res) + m.logAudit(username, "admin", "config-only update issued (key unchanged)") + } + + if err := m.st.WithLock(func() error { + return m.st.Save(st, fmt.Sprintf("config-update: обновлены конфиги для %v", usernames)) + }); err != nil { + failures = append(failures, "сохранение состояния: "+err.Error()) + } + + m.configUpdateRes = results + m.configUpdatePass = password + m.configUpdateErr = "" + for _, f := range failures { + m.configUpdateErr += f + "\n" + } + 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 = scrMenu + m.err, m.status = "", "" + return m, nil + } + } + return m, nil +} + +func (m Model) viewConfigUpdateResult() string { + body := "Готово — ключи НЕ менялись, обновлён только config.\n\n" + body += "Пароль архива (один на всю партию, покажется только сейчас!):\n\n" + body += titleStyle.Render(m.configUpdatePass) + "\n\n" + body += warnStyle.Render("Сообщите пароль каждому получателю отдельным от архива каналом.") + "\n\n" + for _, r := range m.configUpdateRes { + body += fmt.Sprintf(" %-20s -> %s\n", r.Username, r.ArchivePath) + } + if m.configUpdateErr != "" { + body += "\n" + errStyle.Render("Ошибки:\n"+m.configUpdateErr) + } + body += "\n[enter/esc] в меню" + return borderStyle.Render(body) +} diff --git a/internal/ui/confirm.go b/internal/ui/confirm.go index 952a2cd..7527e20 100644 --- a/internal/ui/confirm.go +++ b/internal/ui/confirm.go @@ -175,7 +175,8 @@ func (m Model) doDeleteHost(hostName string) (tea.Model, tea.Cmd) { return m, nil } -// doDeleteService removes a service from m.selectedHost, then resyncs +// doDeleteService removes a service from m.selectedHost, drops it from +// every group that referenced this exact service, and resyncs // authorized_keys for every user that lost access as a result. func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) { hostName := m.selectedHost @@ -184,13 +185,15 @@ func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) { if h == nil { return fmt.Errorf("host %s not found", hostName) } + affected := st.UsernamesUsingService(hostName, serviceName) for i := range h.Services { if h.Services[i].Name == serviceName { h.Services = append(h.Services[:i], h.Services[i+1:]...) break } } - return resyncUsernames(st, st.UsernamesUsingHost(hostName)) + st.RemoveServiceRefs(hostName, serviceName) + return resyncUsernames(st, affected) }) m.screen = scrCatalogServices m.refreshCatalogServicesList() diff --git a/internal/ui/export.go b/internal/ui/export.go index f8dfc32..8ef1403 100644 --- a/internal/ui/export.go +++ b/internal/ui/export.go @@ -129,6 +129,7 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) { } u.PublicKey = res.PublicKeyLine u.Fingerprint = res.Fingerprint + u.ForwardsHash = model.HashForwards(forwards) results = append(results, *res) m.logAudit(username, "admin", "key issued/rotated, fingerprint="+res.Fingerprint) } @@ -149,65 +150,6 @@ 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 { st := m.loadState() body := "Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n" diff --git a/internal/ui/groups.go b/internal/ui/groups.go index 3ccc3ab..1af4189 100644 --- a/internal/ui/groups.go +++ b/internal/ui/groups.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "strings" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" @@ -14,14 +15,18 @@ func (m *Model) refreshGroupsList() { st := m.loadState() items := make([]list.Item, 0, len(st.Groups)) for _, g := range st.Groups { - desc := fmt.Sprintf("%d хостов", len(g.Hosts)) + hosts := map[string]bool{} + for _, ref := range g.Services { + hosts[ref.Host] = true + } + desc := fmt.Sprintf("%d сервисов на %d хостах", len(g.Services), len(hosts)) if g.Description != "" { desc += " — " + g.Description } items = append(items, simpleItem{id: g.Name, title: g.Name, desc: desc}) } m.groupsList = newList(items, - "Группы [n]новая [e]изменить [enter]хосты группы [d]удалить [esc]назад", + "Группы [n]новая [e]изменить [enter]сервисы группы [d]удалить [esc]назад", m.groupsList.Width(), m.groupsList.Height()) } @@ -73,17 +78,25 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedGroup = sel.id st := m.loadState() g := st.FindGroup(sel.id) - m.groupHostsPickSel = map[string]bool{} + + m.groupPickSel = map[string]bool{} if g != nil { - for _, hn := range g.Hosts { - m.groupHostsPickSel[hn] = true + for _, ref := range g.Services { + m.groupPickSel[ref.Host+"/"+ref.Service] = true } } - m.groupHostsPickOrder = nil + m.groupPickRows = nil + m.groupPickServices = map[string][]string{} for _, h := range st.Hosts { - m.groupHostsPickOrder = append(m.groupHostsPickOrder, h.Name) + m.groupPickRows = append(m.groupPickRows, pickRow{isHeader: true, host: h.Name}) + var svcNames []string + for _, svc := range h.Services { + m.groupPickRows = append(m.groupPickRows, pickRow{host: h.Name, service: svc.Name}) + svcNames = append(svcNames, svc.Name) + } + m.groupPickServices[h.Name] = svcNames } - m.groupHostsPickCursor = 0 + m.groupPickCursor = 0 m.screen = scrGroupHostsPick return m, nil case "d": @@ -180,7 +193,18 @@ func (m Model) viewGroupForm() string { return borderStyle.Render(body) } -// --- pick which catalog hosts belong to the selected group --- +// --- pick which catalog services belong to the selected group, shown as a +// host -> services tree: a host row toggles all of its services at once, +// a service row toggles just that one, so access is granted per-service +// (not blanket per-host) while still being quick to pick a whole host. --- + +// pickRow is one line of the host/service picker tree: either a host +// header (isHeader, service == "") or a service leaf under that host. +type pickRow struct { + isHeader bool + host string + service string +} func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { @@ -190,35 +214,55 @@ func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshGroupsList() return m, nil case "up", "k": - if m.groupHostsPickCursor > 0 { - m.groupHostsPickCursor-- + if m.groupPickCursor > 0 { + m.groupPickCursor-- } return m, nil case "down", "j": - if m.groupHostsPickCursor < len(m.groupHostsPickOrder)-1 { - m.groupHostsPickCursor++ + if m.groupPickCursor < len(m.groupPickRows)-1 { + m.groupPickCursor++ } return m, nil case " ": - if len(m.groupHostsPickOrder) > 0 { - h := m.groupHostsPickOrder[m.groupHostsPickCursor] - m.groupHostsPickSel[h] = !m.groupHostsPickSel[h] + if len(m.groupPickRows) == 0 { + return m, nil + } + row := m.groupPickRows[m.groupPickCursor] + if row.isHeader { + allSelected := true + for _, svc := range m.groupPickServices[row.host] { + if !m.groupPickSel[row.host+"/"+svc] { + allSelected = false + break + } + } + for _, svc := range m.groupPickServices[row.host] { + m.groupPickSel[row.host+"/"+svc] = !allSelected + } + } else { + key := row.host + "/" + row.service + m.groupPickSel[key] = !m.groupPickSel[key] } return m, nil case "enter": - var hosts []string - for _, h := range m.groupHostsPickOrder { - if m.groupHostsPickSel[h] { - hosts = append(hosts, h) + var refs []model.ServiceRef + for key, on := range m.groupPickSel { + if !on { + continue } + host, svc, found := strings.Cut(key, "/") + if !found { + continue + } + refs = append(refs, model.ServiceRef{Host: host, Service: svc}) } groupName := m.selectedGroup - m.mutate("изменён список хостов группы "+groupName, func(st *model.State) error { + m.mutate("изменён список сервисов группы "+groupName, func(st *model.State) error { g := st.FindGroup(groupName) if g == nil { return fmt.Errorf("group %s not found", groupName) } - g.Hosts = hosts + g.Services = refs return resyncGroupUsers(st, groupName) }) m.screen = scrGroups @@ -230,21 +274,39 @@ func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) viewGroupHostsPick() string { - body := fmt.Sprintf("Какие хосты каталога входят в группу %q\n\n", m.selectedGroup) - if len(m.groupHostsPickOrder) == 0 { + body := fmt.Sprintf("Какие сервисы каталога входят в группу %q\n\n", m.selectedGroup) + if len(m.groupPickRows) == 0 { body += warnStyle.Render("В каталоге ещё нет хостов — сначала добавьте их в разделе «Хосты и сервисы».") + "\n" } - for i, h := range m.groupHostsPickOrder { + for i, row := range m.groupPickRows { cursor := " " - if i == m.groupHostsPickCursor { + if i == m.groupPickCursor { cursor = "> " } - check := "[ ]" - if m.groupHostsPickSel[h] { - check = "[x]" + if row.isHeader { + svcs := m.groupPickServices[row.host] + selected := 0 + for _, svc := range svcs { + if m.groupPickSel[row.host+"/"+svc] { + selected++ + } + } + check := "[ ]" + switch { + case len(svcs) > 0 && selected == len(svcs): + check = "[x]" + case selected > 0: + check = "[~]" + } + body += fmt.Sprintf("%s%s %s\n", cursor, check, row.host) + } else { + check := "[ ]" + if m.groupPickSel[row.host+"/"+row.service] { + check = "[x]" + } + body += fmt.Sprintf("%s %s %s\n", cursor, check, row.service) } - body += fmt.Sprintf("%s%s %s\n", cursor, check, h) } - body += "\n[space] отметить [enter] сохранить [esc] назад" + body += "\n[space] отметить (на хосте — все его сервисы) [enter] сохранить [esc] назад" return borderStyle.Render(body) } diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 94a693a..edcc518 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -1,6 +1,8 @@ package ui import ( + "fmt" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) @@ -9,8 +11,9 @@ func newMenuList() list.Model { items := []list.Item{ simpleItem{id: "users", title: "Пользователи", desc: "Создать, заблокировать, удалить учётные записи"}, simpleItem{id: "catalog", title: "Хосты и сервисы", desc: "Общий каталог: адреса и порты, один раз на всех, группы просто выбирают из него"}, - simpleItem{id: "groups", title: "Группы", desc: "Какие хосты из каталога входят в каждую группу"}, + simpleItem{id: "groups", title: "Группы", desc: "Какие сервисы из каталога входят в каждую группу"}, simpleItem{id: "export", title: "Экспорт архивов", desc: "Сгенерировать ключи и .7z архивы для доставки пользователям"}, + simpleItem{id: "configupdate", title: "Обновить конфиги", desc: "Перевыпустить только config (без ключа) для тех, у кого изменился доступ"}, simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"}, simpleItem{id: "quit", title: "Выход", desc: ""}, } @@ -39,6 +42,9 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { case "export": m.screen = scrExportSelect m.initExportSelect() + case "configupdate": + m.screen = scrConfigUpdateSelect + m.initConfigUpdateSelect() case "audit": m.screen = scrAudit m.refreshAudit() @@ -55,34 +61,47 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// viewMenu appends a live summary of pending follow-up work (users with no +// key yet, users whose config no longer matches their real access) below +// the menu, recomputed on every render — so it can never go stale itself. func (m Model) viewMenu() string { - return borderStyle.Render(m.menu.View()) + body := m.menu.View() + neverExported, stale := m.quitWarnings() + if len(neverExported) == 0 && len(stale) == 0 { + return borderStyle.Render(body) + } + notice := "" + if len(neverExported) > 0 { + notice += warnStyle.Render(fmt.Sprintf("Без ключа: %d (см. «Экспорт архивов»)", len(neverExported))) + "\n" + } + if len(stale) > 0 { + notice += warnStyle.Render(fmt.Sprintf("Устаревший конфиг: %d (см. «Обновить конфиги»)", len(stale))) + "\n" + } + return borderStyle.Render(body + "\n" + notice) } -// unexportedUsers returns active users who have never had a key issued -// (empty PublicKey) — created but forgotten before "Экспорт архивов". -func unexportedUsers(m *Model) []string { +// quitWarnings splits active users into the two categories worth warning +// about before quitting: those who never got a key at all, and those whose +// access changed since their last config (full export or config-only +// update) — the latter are running with an out-of-date client config that +// won't reach everything they're now allowed to. +func (m *Model) quitWarnings() (neverExported, stale []string) { st := m.loadState() - var out []string - for _, u := range st.Users { - if u.PublicKey == "" { - out = append(out, u.Username) - } - } - return out + return st.NeverExportedUsernames(), st.StaleConfigUsernames() } // 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. +// selecting "Выход"): if there are users created but never exported, or +// users whose config fell out of date, it warns instead of quitting +// straight away — both are easy to lose track of mid-session. 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 { + neverExported, stale := m.quitWarnings() + if len(neverExported) == 0 && len(stale) == 0 { return m, tea.Quit } - m.quitPending = pending + m.quitPending = neverExported + m.quitStale = stale m.quitReturnTo = scrMenu m.screen = scrQuitConfirm return m, nil @@ -102,11 +121,24 @@ func (m Model) updateQuitConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) viewQuitConfirm() string { - body := warnStyle.Render("Есть пользователи, для которых ещё не сгенерирован ключ (не были в «Экспорт архивов»):") + "\n\n" - for _, u := range m.quitPending { - body += " - " + u + "\n" + body := "" + if len(m.quitPending) > 0 { + body += warnStyle.Render("Без выданного ключа (не были в «Экспорт архивов»):") + "\n" + for _, u := range m.quitPending { + body += " - " + u + "\n" + } + body += "\n" } - body += "\nБез экспорта у них нет ключа и архива для передачи — доступ не работает.\n" + if len(m.quitStale) > 0 { + body += warnStyle.Render("Доступ изменился, но конфиг у пользователя старый (см. «Обновить конфиги»):") + "\n" + for _, u := range m.quitStale { + body += " - " + u + "\n" + } + body += "\n" + } + body += "Без этого у части пользователей либо нет доступа вообще, либо их текущий\n" + body += "config не соответствует реальным правам (не хватает новых сервисов, или\n" + body += "остались лишние, которых уже нет).\n" body += "\n[enter/y] выйти всё равно [esc/n] вернуться" return borderStyle.Render(body) } diff --git a/internal/ui/users.go b/internal/ui/users.go index a626ebd..4ffc2d4 100644 --- a/internal/ui/users.go +++ b/internal/ui/users.go @@ -128,7 +128,7 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) { if !ok { return m, nil } - return m.runConfigUpdate(sel.id) + return m.runConfigUpdate([]string{sel.id}) } } var cmd tea.Cmd diff --git a/ssh-access-tui b/ssh-access-tui index 44943db..5a43107 100755 Binary files a/ssh-access-tui and b/ssh-access-tui differ