From 7f0e0fdb232c2718ad51dbe1561e6a6ec1a15de5 Mon Sep 17 00:00:00 2001 From: redlineo Date: Thu, 23 Jul 2026 12:38:13 +0300 Subject: [PATCH] add ProxyJump function --- README.md | 23 +- docs/ADMIN_GUIDE.md | 27 +- docs/ARCHITECTURE.md | 70 +++- internal/keymgr/clientconfig.go | 57 +++ internal/keymgr/clientconfig_test.go | 168 +++++++++ internal/keymgr/configonly.go | 15 +- internal/keymgr/configonly_test.go | 85 +++++ internal/keymgr/instructions.go | 106 ++++++ internal/keymgr/instructions_test.go | 55 +++ internal/keymgr/pack.go | 18 +- internal/keymgr/pack_test.go | 101 ++++++ internal/model/types.go | 17 +- internal/ui/app.go | 1 + internal/ui/configupdate.go | 2 +- internal/ui/export.go | 2 +- internal/ui/users.go | 27 +- outlook-bulk-sender/.gitignore | 18 + .../BulkArchiveMailer.AddIn.csproj | 150 ++++++++ .../BulkArchiveMailer.AddIn/Globals.cs | 14 + .../BulkArchiveMailer.AddIn/MailSender.cs | 99 +++++ .../Properties/AssemblyInfo.cs | 19 + .../BulkArchiveMailer.AddIn/Ribbon.xml | 19 + .../RibbonController.cs | 95 +++++ .../SignatureReader.cs | 123 +++++++ .../ThisAddIn.Designer.cs | 24 ++ .../BulkArchiveMailer.AddIn/ThisAddIn.cs | 32 ++ .../UI/PreviewForm.Designer.cs | 283 +++++++++++++++ .../BulkArchiveMailer.AddIn/UI/PreviewForm.cs | 340 ++++++++++++++++++ .../UI/SettingsForm.Designer.cs | 318 ++++++++++++++++ .../UI/SettingsForm.cs | 190 ++++++++++ .../BulkArchiveMailer.AddIn/app.config | 14 + .../BulkArchiveMailer.Core.Tests.csproj | 33 ++ .../FilenameParserTests.cs | 25 ++ .../BulkArchiveMailer.Core/AppSettings.cs | 260 ++++++++++++++ .../BulkArchiveMailer.Core/ArchiveScanner.cs | 141 ++++++++ .../BulkArchiveMailer.Core.csproj | 61 ++++ .../EmailAddressBuilder.cs | 36 ++ .../BulkArchiveMailer.Core/FilenameParser.cs | 75 ++++ .../BulkArchiveMailer.Core/MailTemplates.cs | 37 ++ .../Properties/AssemblyInfo.cs | 19 + .../BulkArchiveMailer.Core/SendJob.cs | 76 ++++ .../BulkArchiveMailer.Core/SendLogStore.cs | 287 +++++++++++++++ .../BulkArchiveMailer.Core/TemplateEngine.cs | 62 ++++ .../config.default.json | 16 + outlook-bulk-sender/BulkArchiveMailer.sln | 34 ++ outlook-bulk-sender/README.md | 124 +++++++ outlook-bulk-sender/docs/BUILD.md | 121 +++++++ ssh-access-tui | Bin 5484728 -> 7516450 bytes 48 files changed, 3875 insertions(+), 44 deletions(-) create mode 100644 internal/keymgr/clientconfig_test.go create mode 100644 internal/keymgr/configonly_test.go create mode 100644 internal/keymgr/instructions_test.go create mode 100644 internal/keymgr/pack_test.go create mode 100644 outlook-bulk-sender/.gitignore create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn.csproj create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/Globals.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/MailSender.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/Properties/AssemblyInfo.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/Ribbon.xml create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/RibbonController.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/SignatureReader.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/ThisAddIn.Designer.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/ThisAddIn.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.Designer.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/app.config create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core.Tests/BulkArchiveMailer.Core.Tests.csproj create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core.Tests/FilenameParserTests.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/ArchiveScanner.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/BulkArchiveMailer.Core.csproj create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/EmailAddressBuilder.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/FilenameParser.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplates.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/Properties/AssemblyInfo.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/SendJob.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/SendLogStore.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/TemplateEngine.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json create mode 100644 outlook-bulk-sender/BulkArchiveMailer.sln create mode 100644 outlook-bulk-sender/README.md create mode 100644 outlook-bulk-sender/docs/BUILD.md diff --git a/README.md b/README.md index 2f0354b..1e49da1 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,21 @@ # ssh-access-tui Консольный (TUI) инструмент для администрирования Linux jump-хоста: заводит -пользователей, у которых есть **только** проброс портов (`LocalForward`, -одно соединение сразу открывает все разрешённые сервисы) к явно -разрешённому списку внутренних хостов — без интерактивного шелла. Хосты и -сервисы описываются один раз в общем каталоге, группы просто выбирают из -него. Генерирует и раздаёт зашифрованные архивы с ключом/конфигом/ -инструкцией, блокирует уволенных, пишет журнал подключений, хранит всю -конфигурацию в git для бэкапа/переезда, и требует явное подтверждение -(ввод случайной строки) для любого деструктивного действия. +пользователей, у которых есть **только** проброс портов к явно разрешённому +списку внутренних хостов — без интерактивного шелла. Клиентский конфиг +выдаётся в одном из двух режимов на выбор администратора (переключается на +пользователя, по умолчанию — первый): `LocalForward` (одно всегда открытое +соединение `jump` сразу открывает все разрешённые сервисы на локальных +портах) или `ProxyJump` (отдельный `Host`-алиас на каждый сервис, `ssh +<алиас>` по требованию, без постоянно открытого туннеля и локальных портов). +Оба режима — только формат клиентского конфига; на сервере в обоих случаях +одинаково работает `permitopen=`-ограничение в `authorized_keys` (см. +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)). Хосты и сервисы описываются +один раз в общем каталоге, группы просто выбирают из него. Генерирует и +раздаёт зашифрованные архивы с ключом/конфигом/инструкцией, блокирует +уволенных, пишет журнал подключений, хранит всю конфигурацию в git для +бэкапа/переезда, и требует явное подтверждение (ввод случайной строки) для +любого деструктивного действия. Почему не веб-панель: инструмент требует root (создание пользователей, правка `authorized_keys` и `sshd_config`). Веб-бэкенд с такими правами — diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md index b47fa94..1372787 100644 --- a/docs/ADMIN_GUIDE.md +++ b/docs/ADMIN_GUIDE.md @@ -96,7 +96,16 @@ sudo ssh-access-tui сохранить. 6. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин) и комментарий (например ФИО) → `enter`. -7. Отметить `space` нужные группы (можно несколько) → `enter`. +7. Отметить `space` нужные группы (можно несколько). Ниже списка групп на + этом же экране — чекбокс «ProxyJump вместо LocalForward»: по умолчанию + выключен (клиентский конфиг — один `Host jump` с `LocalForward` на + каждый сервис, всегда открытое соединение); включите его, если вместо + этого нужен конфиг с отдельным `Host ` на каждый сервис + (`ProxyJump jump`, обычная команда `ssh ` без постоянно + открытого туннеля и без локальных портов) — например, для пользователей + с привычными SSH-клиентами не-Windows или когда локальные порты + неудобны. Права доступа на сервере не зависят от этого выбора — это + только формат выдаваемого файла `config`. → `enter`. Это уже создаёт системный Linux-аккаунт (без пароля, без интерактивного шелла) и запись в состоянии. **Ключ ещё не выдан** — статус в списке @@ -128,11 +137,21 @@ sudo ssh-access-tui меняется немедленно, без переотправки архива и **без замены ключа** (изменение групп никогда не ротирует ключ). +На этом же экране есть чекбокс «ProxyJump вместо LocalForward» (см. +сценарий 1, шаг 7) — можно переключить режим клиентского конфига и +существующему пользователю. В отличие от смены групп, переключение этого +чекбокса **не применяется автоматически**: инструмент не отслеживает смену +стиля конфига как «устаревший» (только смену состава сервисов), поэтому +после переключения нужно вручную прогнать «Обновить конфиги» (см. ниже), +иначе у пользователя на диске просто останется конфиг в старом формате — +доступ на сервере это никак не ограничивает. + Если хотите, чтобы у пользователя появился ещё и обновлённый клиентский `config` с новыми адресами для новых сервисов (иначе он про них просто не -узнает, хотя доступ уже есть) — **Пользователи** → выбрать пользователя → -`c` («обновить конфиг»), либо (то же самое, но сразу для многих) -**Обновить конфиги** в главном меню. Это генерирует архив только с +узнает, хотя доступ уже есть) — или чтобы применить только что +переключённый режим ProxyJump/LocalForward — **Пользователи** → выбрать +пользователя → `c` («обновить конфиг»), либо (то же самое, но сразу для +многих) **Обновить конфиги** в главном меню. Это генерирует архив только с `config` + `INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже есть и остаётся тем же), пользователю нужно только подменить файл `config`. Не требует подтверждения — ключ и доступ этим действием не меняются, это не diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d2087a6..23ce478 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,13 +46,20 @@ TUI работает поверх уже существующего SSH+`sudo`- (`State.DeleteHost`, `State.RemoveServiceRefs`) — переименование/удаление одного сервиса никогда не задевает остальные сервисы того же хоста. -Ключевой момент: пользователь **никогда не подключается по SSH к целевому -хосту напрямую** (ни через `ProxyJump`, ни как-то ещё) — он открывает одно -соединение к самому jump-хосту и локально пробрасывает (`LocalForward`) -порты к каждому нужному сервису через это соединение. Не важно, слушает ли -на целевом порту настоящий SSH (22) или произвольный TCP-сервис (7888) — -для форвардинга это не имеет значения, оба варианта — это просто -"локальный порт → host:port через jump". +Ключевой момент: и в `LocalForward`, и в `ProxyJump`-режиме клиентского +конфига (`model.User.ProxyJump`, см. ниже) канал до целевого хоста — +это всегда один и тот же серверный примитив, `direct-tcpip` через +jump-хост: пользователь никогда не получает интерактивный шелл на целевом +хосте через jump, только форвардинг порта до него. В `LocalForward`-режиме +он открывает одно соединение к самому jump-хосту и локально пробрасывает +порты к каждому нужному сервису через это соединение; в `ProxyJump`-режиме +клиентский `ssh` сам открывает по одному `direct-tcpip`-каналу через то же +соединение к jump-хосту на время каждой команды (`ssh `), без +постоянно висящего туннеля и без локальных портов — разница только в том, +как клиент организует один и тот же тип канала, а не в том, что jump-хост +разрешает. Не важно, слушает ли на целевом порту настоящий SSH (22) или +произвольный TCP-сервис (7888) — для форвардинга это не имеет значения, +оба варианта — это просто "порт → host:port через jump". ## Как на самом деле работает ограничение "только прыжок/форвардинг" @@ -84,14 +91,41 @@ TUI работает поверх уже существующего SSH+`sudo`- повреждены/потеряны, а не основной механизм. 2. **Клиент (только удобство).** Файл `~/.ssh/config`, который уезжает - пользователю в архиве, — это один блок `Host jump` с набором строк - `LocalForward <локальный_порт> :` (`internal/keymgr/clientconfig.go`). - Он ничего не запрещает: пользователь может отредактировать его или - вызвать `ssh` руками с любым хостом. Единственная причина, по которой это - не работает, — сервер отклонит `permitopen`, которого нет в списке. - Список форвардов в клиентском конфиге должен генерироваться из того же - источника правды (группы в `state.yaml`), что и `permitopen=`, — иначе - конфиг и реальные права разойдутся. + пользователю в архиве, зависит от `model.User.ProxyJump` (переключается + чекбоксом на экране групп пользователя, `internal/ui/users.go`) и + генерируется одним из двух рендереров + (`internal/keymgr/clientconfig.go`), но оба **одинаково не являются + границей безопасности**: + - `false` (по умолчанию, `LocalForward`) — один блок `Host jump` с + набором строк `LocalForward <локальный_порт> :`, одно + всегда открытое соединение сразу даёт локальные порты на все сервисы + (`RenderClientConfig`). + - `true` (`ProxyJump`) — отдельный блок `Host ` на каждый + разрешённый сервис с `ProxyJump jump` вместо `HostName jump`, без + локальных портов и без постоянно открытого соединения — каждый вызов + `ssh ` сам поднимает и закрывает канал по требованию + (`RenderClientConfigProxyJump`). + + Ни один из двух вариантов ничего не запрещает: пользователь может + отредактировать любой из них или вызвать `ssh` руками с любым хостом. + Единственная причина, по которой это не работает, — сервер отклонит + `permitopen`, которого нет в списке, и это ограничение **идентично для + обоих режимов**: `BuildUserArchive`/`BuildConfigOnlyArchive` в обоих + случаях берут один и тот же `[]model.Forward` (`State.AllowedForwards`) + и не трогают `internal/sysadm/authorizedkeys.go` — переключение + `ProxyJump` меняет только то, как выглядит клиентский конфиг, а не какие + `permitopen=` попадают в `authorized_keys`. Список форвардов в + клиентском конфиге (в любом из двух форматов) должен генерироваться из + того же источника правды (группы в `state.yaml`), что и `permitopen=`, — + иначе конфиг и реальные права разойдутся. + + Переключение режима у уже существующего пользователя не применяется + автоматически: `ForwardsHash`/`IsConfigStale` отслеживают только + изменения состава форвардов, а не смену стиля конфига (само + переключение `ProxyJump` без изменения групп не помечает конфиг как + устаревший) — администратор должен вручную прогнать «Обновить конфиги» + после переключения, если хочет, чтобы у пользователя оказался конфиг + нового формата. Reverse/remote forwarding (`-R`) в этой модели не выдаётся вообще (`AllowStreamLocalForwarding no`, никаких `permitlisten=`) — по вашему же @@ -249,3 +283,9 @@ session" вместо тихой порчи данных. Если реальн получение. - Полностью параллельная работа нескольких админов не поддерживается (см. «Конкурентность»). +- Переключение `ProxyJump`/`LocalForward` у уже существующего пользователя + не переиздаёт и не помечает его конфиг устаревшим автоматически (см. + выше, «Клиент (только удобство)») — после переключения нужно вручную + прогнать «Обновить конфиги» для этого пользователя, иначе у него на + диске останется конфиг старого формата (что ничего не ломает: доступ на + сервере не зависит от этого режима, только удобство пользователя). diff --git a/internal/keymgr/clientconfig.go b/internal/keymgr/clientconfig.go index 7cdb08f..3eff66e 100644 --- a/internal/keymgr/clientconfig.go +++ b/internal/keymgr/clientconfig.go @@ -3,6 +3,8 @@ package keymgr import ( "fmt" "strings" + + "ssh-access-tui/internal/model" ) // RenderClientConfig produces an OpenSSH client config (works identically on @@ -37,3 +39,58 @@ func RenderClientConfig(jumpHost string, jumpPort int, username, keyFileName str } return b.String() } + +// RenderClientConfigProxyJump is the ProxyJump-mode counterpart of +// RenderClientConfig: instead of one "jump" connection carrying a +// LocalForward per service, each allowed service gets its own "Host " +// block that reaches it by proxying through "jump" (ProxyJump), so the user +// runs "ssh " directly per service rather than opening one +// always-open tunnel and pointing local tools at 127.0.0.1:. +// +// Same non-boundary caveat as RenderClientConfig: nothing here is enforced — +// the actual restriction to these destinations is authorized_keys +// permitopen= (see internal/sysadm/authorizedkeys.go), unaffected by which +// client config style is handed out. +func RenderClientConfigProxyJump(jumpHost string, jumpPort int, username, keyFileName string, forwards []model.Forward) 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 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") + + aliases := hostAliases(forwards) + for i, f := range forwards { + fmt.Fprintf(&b, "Host %s\n", aliases[i]) + fmt.Fprintf(&b, " HostName %s\n", f.Address) + fmt.Fprintf(&b, " Port %d\n", f.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\n") + } + return b.String() +} + +// hostAliases assigns each forward a unique ssh_config "Host" alias: just +// the host name when it's the only service of that host present, otherwise +// "-" so multiple services on one host don't collide. +func hostAliases(forwards []model.Forward) []string { + countByHost := map[string]int{} + for _, f := range forwards { + countByHost[f.HostName]++ + } + out := make([]string, len(forwards)) + for i, f := range forwards { + if countByHost[f.HostName] > 1 { + out[i] = f.HostName + "-" + f.ServiceName + } else { + out[i] = f.HostName + } + } + return out +} diff --git a/internal/keymgr/clientconfig_test.go b/internal/keymgr/clientconfig_test.go new file mode 100644 index 0000000..e4a1492 --- /dev/null +++ b/internal/keymgr/clientconfig_test.go @@ -0,0 +1,168 @@ +package keymgr + +import ( + "strings" + "testing" + + "ssh-access-tui/internal/model" +) + +func TestHostAliases(t *testing.T) { + tests := []struct { + name string + forwards []model.Forward + want []string + }{ + { + name: "empty", + forwards: nil, + want: []string{}, + }, + { + name: "single service per host uses bare host name", + forwards: []model.Forward{ + {HostName: "db-prod-1", ServiceName: "ssh"}, + {HostName: "web-1", ServiceName: "https"}, + }, + want: []string{"db-prod-1", "web-1"}, + }, + { + name: "multiple services on same host get host-service alias", + forwards: []model.Forward{ + {HostName: "db-prod-1", ServiceName: "ssh"}, + {HostName: "db-prod-1", ServiceName: "web-admin"}, + }, + want: []string{"db-prod-1-ssh", "db-prod-1-web-admin"}, + }, + { + name: "mixed: one host with a single service, another with several", + forwards: []model.Forward{ + {HostName: "db-prod-1", ServiceName: "ssh"}, + {HostName: "db-prod-1", ServiceName: "web-admin"}, + {HostName: "cache-1", ServiceName: "redis"}, + }, + want: []string{"db-prod-1-ssh", "db-prod-1-web-admin", "cache-1"}, + }, + { + name: "order is preserved, not sorted", + forwards: []model.Forward{ + {HostName: "zzz-host", ServiceName: "ssh"}, + {HostName: "aaa-host", ServiceName: "ssh"}, + }, + want: []string{"zzz-host", "aaa-host"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hostAliases(tt.forwards) + if len(got) != len(tt.want) { + t.Fatalf("hostAliases() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("hostAliases()[%d] = %q, want %q (full: got=%v want=%v)", i, got[i], tt.want[i], got, tt.want) + } + } + }) + } +} + +func TestRenderClientConfigProxyJump(t *testing.T) { + forwards := []model.Forward{ + {HostName: "db-prod-1", Address: "10.0.0.5", Port: 22, ServiceName: "ssh", Description: "primary db"}, + {HostName: "db-prod-1", Address: "10.0.0.5", Port: 8080, ServiceName: "web-admin"}, + {HostName: "cache-1", Address: "10.0.0.9", Port: 6379, ServiceName: "redis"}, + } + + out := RenderClientConfigProxyJump("bastion.example.com", 2222, "a-makarov", "a-makarov_jump_key", forwards) + + // The one always-present "jump" block. + for _, want := range []string{ + "Host jump\n", + " HostName bastion.example.com\n", + " Port 2222\n", + " User a-makarov\n", + " IdentityFile ~/.ssh/a-makarov_jump_key\n", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing jump block line %q\nfull output:\n%s", want, out) + } + } + + // LocalForward is the LocalForward-mode marker; it must never appear here. + if strings.Contains(out, "LocalForward") { + t.Errorf("ProxyJump-mode output must not contain LocalForward, got:\n%s", out) + } + + // Every forward gets its own Host block, with the expected alias + // (deduplicated per hostAliases), ProxyJump jump, and no local port. + wantBlocks := []struct { + alias string + addr string + port string + }{ + {"db-prod-1-ssh", "10.0.0.5", "22"}, + {"db-prod-1-web-admin", "10.0.0.5", "8080"}, + {"cache-1", "10.0.0.9", "6379"}, + } + for _, wb := range wantBlocks { + if !strings.Contains(out, "Host "+wb.alias+"\n") { + t.Errorf("output missing %q Host block, got:\n%s", wb.alias, out) + } + } + if !strings.Contains(out, " ProxyJump jump\n") { + t.Errorf("output missing ProxyJump jump directive, got:\n%s", out) + } + proxyJumpCount := strings.Count(out, "ProxyJump jump") + if proxyJumpCount != len(forwards) { + t.Errorf("expected %d ProxyJump jump directives (one per forward), got %d", len(forwards), proxyJumpCount) + } + identitiesOnlyCount := strings.Count(out, "IdentitiesOnly yes") + // One for the "jump" block plus one per forward block. + if identitiesOnlyCount != len(forwards)+1 { + t.Errorf("expected %d IdentitiesOnly yes lines, got %d", len(forwards)+1, identitiesOnlyCount) + } +} + +func TestRenderClientConfigProxyJump_NoForwards(t *testing.T) { + out := RenderClientConfigProxyJump("bastion.example.com", 22, "nobody", "nobody_jump_key", nil) + + if !strings.Contains(out, "Host jump\n") { + t.Errorf("expected jump block even with no forwards, got:\n%s", out) + } + if strings.Contains(out, "ProxyJump jump") { + t.Errorf("no forwards means no per-service blocks, but found ProxyJump jump in:\n%s", out) + } +} + +// TestRenderClientConfig_LocalForwardModeUnaffected pins down that the +// pre-existing LocalForward-mode renderer still behaves as before adding +// ProxyJump mode alongside it (regression guard). +func TestRenderClientConfig_LocalForwardModeUnaffected(t *testing.T) { + forwards := []AllocatedForward{ + {Forward: model.Forward{HostName: "db-prod-1", Address: "10.0.0.5", Port: 22, ServiceName: "ssh", Description: "primary db"}, LocalPort: 20000}, + {Forward: model.Forward{HostName: "cache-1", Address: "10.0.0.9", Port: 6379, ServiceName: "redis"}, LocalPort: 20001}, + } + + out := RenderClientConfig("bastion.example.com", 2222, "a-makarov", "a-makarov_jump_key", forwards) + + if strings.Contains(out, "ProxyJump") { + t.Errorf("LocalForward-mode output must not contain ProxyJump, got:\n%s", out) + } + if !strings.Contains(out, "Host jump\n") { + t.Errorf("expected single jump block, got:\n%s", out) + } + if strings.Contains(out, "Host db-prod-1\n") || strings.Contains(out, "Host cache-1\n") { + t.Errorf("LocalForward mode must not emit per-service Host blocks, got:\n%s", out) + } + wantLines := []string{ + " LocalForward 20000 10.0.0.5:22 # db-prod-1/ssh: primary db\n", + " LocalForward 20001 10.0.0.9:6379 # cache-1/redis\n", + } + for _, want := range wantLines { + if !strings.Contains(out, want) { + t.Errorf("missing expected LocalForward line %q, got:\n%s", want, out) + } + } +} diff --git a/internal/keymgr/configonly.go b/internal/keymgr/configonly.go index d5384b2..5276076 100644 --- a/internal/keymgr/configonly.go +++ b/internal/keymgr/configonly.go @@ -28,7 +28,7 @@ type ConfigOnlyResult struct { // 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) { +func BuildConfigOnlyArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward, proxyJump bool) (*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) @@ -36,14 +36,21 @@ func BuildConfigOnlyArchive(scratchDir, outDir, jumpHost string, jumpPort int, u defer Wipe(userScratch) keyFileName := username + "_jump_key" - allocated := AllocateLocalPorts(forwards) - cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated) + var cfg, instr string + if proxyJump { + cfg = RenderClientConfigProxyJump(jumpHost, jumpPort, username, keyFileName, forwards) + instr = RenderConfigUpdateInstructionsProxyJump(username, forwards) + } else { + allocated := AllocateLocalPorts(forwards) + cfg = RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated) + instr = RenderConfigUpdateInstructions(username, 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) } diff --git a/internal/keymgr/configonly_test.go b/internal/keymgr/configonly_test.go new file mode 100644 index 0000000..af25eaf --- /dev/null +++ b/internal/keymgr/configonly_test.go @@ -0,0 +1,85 @@ +package keymgr + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "ssh-access-tui/internal/model" +) + +// TestBuildConfigOnlyArchive_ProxyJumpSelectsRenderer mirrors +// TestBuildUserArchive_ProxyJumpSelectsRenderer for the config-only refresh +// path (no keypair generated here, just config + instructions). +func TestBuildConfigOnlyArchive_ProxyJumpSelectsRenderer(t *testing.T) { + if _, err := exec.LookPath("7z"); err != nil { + t.Skip("7z not available in PATH") + } + + forwards := []model.Forward{ + {HostName: "cache-1", Address: "10.0.0.9", Port: 6379, ServiceName: "redis"}, + } + + tests := []struct { + name string + proxyJump bool + }{ + {"LocalForward mode", false}, + {"ProxyJump mode", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scratch := t.TempDir() + outDir := t.TempDir() + username := "test-user" + password := "TestPassw0rd123" + + res, err := BuildConfigOnlyArchive(scratch, outDir, "bastion.example.com", 2222, username, password, forwards, tt.proxyJump) + if err != nil { + t.Fatalf("BuildConfigOnlyArchive: %v", err) + } + if _, err := os.Stat(res.ArchivePath); err != nil { + t.Fatalf("archive not found on disk: %v", err) + } + + if entries, err := os.ReadDir(filepath.Join(scratch, username+"_configonly")); err == nil { + t.Fatalf("expected scratch dir to be wiped, still has entries: %v", entries) + } + + extractedDir := extract7z(t, res.ArchivePath, password) + cfgBytes, err := os.ReadFile(filepath.Join(extractedDir, "config")) + if err != nil { + t.Fatalf("read extracted config: %v", err) + } + cfg := string(cfgBytes) + + instrBytes, err := os.ReadFile(filepath.Join(extractedDir, "INSTRUCTIONS.txt")) + if err != nil { + t.Fatalf("read extracted instructions: %v", err) + } + instr := string(instrBytes) + + if tt.proxyJump { + if strings.Contains(cfg, "LocalForward") { + t.Errorf("proxyJump=true config must not contain LocalForward, got:\n%s", cfg) + } + if !strings.Contains(cfg, "ProxyJump jump") { + t.Errorf("proxyJump=true config must contain ProxyJump jump, got:\n%s", cfg) + } + if strings.Contains(instr, "127.0.0.1") { + t.Errorf("proxyJump=true instructions must not mention local ports, got:\n%s", instr) + } + } else { + if strings.Contains(cfg, "ProxyJump") { + t.Errorf("proxyJump=false config must not contain ProxyJump, got:\n%s", cfg) + } + if !strings.Contains(cfg, "LocalForward") { + t.Errorf("proxyJump=false config must contain LocalForward, got:\n%s", cfg) + } + } + }) + } +} diff --git a/internal/keymgr/instructions.go b/internal/keymgr/instructions.go index a904bfb..1294c04 100644 --- a/internal/keymgr/instructions.go +++ b/internal/keymgr/instructions.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" "time" + + "ssh-access-tui/internal/model" ) func genTimestamp() string { @@ -129,3 +131,107 @@ func RenderConfigUpdateInstructions(username string, forwards []AllocatedForward b.WriteString("Если что-то не работает — обратитесь к администратору.\n") return b.String() } + +// RenderInstructionsProxyJump is the ProxyJump-mode counterpart of +// RenderInstructions: same audience and same 4-file archive, but each +// service gets its own "Host " entry (see +// RenderClientConfigProxyJump) reached with a plain "ssh " instead of +// one always-open tunnel exposing local ports — steps 3/4 are adapted +// accordingly. +func RenderInstructionsProxyJump(username, keyFileName string, forwards []model.Forward) string { + var b strings.Builder + + b.WriteString("КАК ПОДКЛЮЧИТЬСЯ (Windows)\n") + b.WriteString("===========================\n\n") + fmt.Fprintf(&b, "Ваш логин: %s\n\n", username) + + 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. Разложите файлы по нужной папке\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("ШАГ 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("Никакое окно держать открытым не нужно. Когда понадобится один из сервисов\n") + b.WriteString("из списка ниже, просто выполните в PowerShell соответствующую ему команду\n") + b.WriteString("\"ssh <название>\" — она сама пройдёт через промежуточный узел, отдельно\n") + b.WriteString("ничего для этого включать не нужно.\n\n") + + b.WriteString("ШАГ 4. Ваши сервисы\n") + b.WriteString("---------------------\n") + + aliases := hostAliases(forwards) + for i, f := range forwards { + what := f.ServiceName + if f.Description != "" { + what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description) + } + fmt.Fprintf(&b, " %d. ssh %s\n", i+1, aliases[i]) + fmt.Fprintf(&b, " Это: %s\n", what) + fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port) + } + if len(forwards) == 0 { + b.WriteString(" (сервисы ещё не назначены — обратитесь к администратору)\n") + } + b.WriteString("\n") + + b.WriteString("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n") + b.WriteString("------------------------\n") + b.WriteString("Если команда из шага 4 сразу обрывается или пишет ошибку — обратитесь к\n") + b.WriteString("администратору, который выдал вам этот архив. Возможно, изменился список\n") + b.WriteString("сервисов — тогда вам пришлют обновлённый архив.\n") + + return b.String() +} + +// RenderConfigUpdateInstructionsProxyJump is the ProxyJump-mode counterpart +// of RenderConfigUpdateInstructions. +func RenderConfigUpdateInstructionsProxyJump(username string, forwards []model.Forward) 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") + aliases := hostAliases(forwards) + for i, f := range forwards { + what := f.ServiceName + if f.Description != "" { + what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description) + } + fmt.Fprintf(&b, " %d. ssh %s\n", i+1, aliases[i]) + 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/instructions_test.go b/internal/keymgr/instructions_test.go new file mode 100644 index 0000000..0d4c8ce --- /dev/null +++ b/internal/keymgr/instructions_test.go @@ -0,0 +1,55 @@ +package keymgr + +import ( + "strings" + "testing" + + "ssh-access-tui/internal/model" +) + +func TestRenderInstructionsProxyJump(t *testing.T) { + forwards := []model.Forward{ + {HostName: "db-prod-1", Address: "10.0.0.5", Port: 22, ServiceName: "ssh", Description: "primary db"}, + {HostName: "db-prod-1", Address: "10.0.0.5", Port: 8080, ServiceName: "web-admin"}, + } + + out := RenderInstructionsProxyJump("a-makarov", "a-makarov_jump_key", forwards) + + if !strings.Contains(out, "a-makarov") { + t.Errorf("expected username in output, got:\n%s", out) + } + // ProxyJump-mode instructions tell the user to run "ssh " per + // service, not to open one tunnel and point tools at a local port. + if strings.Contains(out, "127.0.0.1") { + t.Errorf("ProxyJump instructions must not reference local forwarded ports, got:\n%s", out) + } + if strings.Contains(out, "ssh -N jump") { + t.Errorf("ProxyJump instructions must not tell the user to open the always-on tunnel, got:\n%s", out) + } + for _, wantAlias := range []string{"ssh db-prod-1-ssh", "ssh db-prod-1-web-admin"} { + if !strings.Contains(out, wantAlias) { + t.Errorf("expected instructions to mention %q, got:\n%s", wantAlias, out) + } + } +} + +func TestRenderInstructionsProxyJump_NoForwards(t *testing.T) { + out := RenderInstructionsProxyJump("nobody", "nobody_jump_key", nil) + if !strings.Contains(out, "обратитесь к администратору") { + t.Errorf("expected a no-services fallback message, got:\n%s", out) + } +} + +func TestRenderConfigUpdateInstructionsProxyJump(t *testing.T) { + forwards := []model.Forward{ + {HostName: "cache-1", Address: "10.0.0.9", Port: 6379, ServiceName: "redis"}, + } + out := RenderConfigUpdateInstructionsProxyJump("a-makarov", forwards) + + if strings.Contains(out, "127.0.0.1") { + t.Errorf("ProxyJump config-update instructions must not reference local ports, got:\n%s", out) + } + if !strings.Contains(out, "ssh cache-1") { + t.Errorf("expected instructions to mention the cache-1 alias, got:\n%s", out) + } +} diff --git a/internal/keymgr/pack.go b/internal/keymgr/pack.go index 95bd9be..da977c0 100644 --- a/internal/keymgr/pack.go +++ b/internal/keymgr/pack.go @@ -22,8 +22,10 @@ type BuildResult struct { // directory, packs everything into a single AES-256-encrypted 7z archive // 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, forwards []model.Forward) (*BuildResult, error) { +// batch and must be communicated to the recipient out-of-band. proxyJump +// selects the client config/instructions style (see model.User.ProxyJump); +// when true, local ports are never allocated at all — they're not needed. +func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward, proxyJump bool) (*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,14 +38,20 @@ func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, usernam } keyFileName := filepath.Base(kp.PrivateKeyPath) - allocated := AllocateLocalPorts(forwards) + var cfg, instr string + if proxyJump { + cfg = RenderClientConfigProxyJump(jumpHost, jumpPort, username, keyFileName, forwards) + instr = RenderInstructionsProxyJump(username, keyFileName, forwards) + } else { + allocated := AllocateLocalPorts(forwards) + cfg = RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated) + instr = RenderInstructions(username, keyFileName, allocated) + } - 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, 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/pack_test.go b/internal/keymgr/pack_test.go new file mode 100644 index 0000000..5598438 --- /dev/null +++ b/internal/keymgr/pack_test.go @@ -0,0 +1,101 @@ +package keymgr + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "ssh-access-tui/internal/model" +) + +// extract7z unpacks archivePath (encrypted with password) into a fresh temp +// dir and returns that dir. Skips the test if 7z is unavailable rather than +// failing, since this is exercising an external tool, not this package. +func extract7z(t *testing.T, archivePath, password string) string { + t.Helper() + if _, err := exec.LookPath("7z"); err != nil { + t.Skip("7z not available in PATH") + } + dest := t.TempDir() + cmd := exec.Command("7z", "x", "-p"+password, "-o"+dest, archivePath) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("7z x: %v: %s", err, out) + } + return dest +} + +// TestBuildUserArchive_ProxyJumpSelectsRenderer is a smoke test verifying +// that BuildUserArchive's new proxyJump parameter actually switches which +// client config / instructions renderer gets packed, end to end through the +// real ssh-keygen/7z tools (see internal/keymgr package doc: the private key +// only exists transiently on disk for this one operation). +func TestBuildUserArchive_ProxyJumpSelectsRenderer(t *testing.T) { + if _, err := exec.LookPath("ssh-keygen"); err != nil { + t.Skip("ssh-keygen not available in PATH") + } + if _, err := exec.LookPath("7z"); err != nil { + t.Skip("7z not available in PATH") + } + + forwards := []model.Forward{ + {HostName: "db-prod-1", Address: "10.0.0.5", Port: 22, ServiceName: "ssh"}, + } + + tests := []struct { + name string + proxyJump bool + }{ + {"LocalForward mode", false}, + {"ProxyJump mode", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scratch := t.TempDir() + outDir := t.TempDir() + username := "test-user" + password := "TestPassw0rd123" + + res, err := BuildUserArchive(scratch, outDir, "bastion.example.com", 2222, username, password, forwards, tt.proxyJump) + if err != nil { + t.Fatalf("BuildUserArchive: %v", err) + } + if res.ArchivePath == "" { + t.Fatalf("expected non-empty archive path") + } + if _, err := os.Stat(res.ArchivePath); err != nil { + t.Fatalf("archive not found on disk: %v", err) + } + + // Scratch dir must be wiped (private key must not linger). + if entries, err := os.ReadDir(filepath.Join(scratch, username)); err == nil { + t.Fatalf("expected scratch dir to be wiped, still has entries: %v", entries) + } + + extractedDir := extract7z(t, res.ArchivePath, password) + cfgBytes, err := os.ReadFile(filepath.Join(extractedDir, "config")) + if err != nil { + t.Fatalf("read extracted config: %v", err) + } + cfg := string(cfgBytes) + + if tt.proxyJump { + if strings.Contains(cfg, "LocalForward") { + t.Errorf("proxyJump=true archive must not contain LocalForward, got config:\n%s", cfg) + } + if !strings.Contains(cfg, "ProxyJump jump") { + t.Errorf("proxyJump=true archive must contain ProxyJump jump, got config:\n%s", cfg) + } + } else { + if strings.Contains(cfg, "ProxyJump") { + t.Errorf("proxyJump=false archive must not contain ProxyJump, got config:\n%s", cfg) + } + if !strings.Contains(cfg, "LocalForward") { + t.Errorf("proxyJump=false archive must contain LocalForward, got config:\n%s", cfg) + } + } + }) + } +} diff --git a/internal/model/types.go b/internal/model/types.go index 6c96103..6774fb0 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -82,10 +82,19 @@ type User struct { // 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"` + ForwardsHash string `yaml:"forwards_hash,omitempty"` + Comment string `yaml:"comment,omitempty"` + // ProxyJump selects the client config style delivered to this user: when + // true, each allowed service gets its own "Host " ssh_config block + // proxied through "jump" via ProxyJump (plain "ssh "); when false + // (the default, preserving existing state.yaml behavior), the client + // config instead lists one LocalForward per service on a single always-open + // "jump" connection. Purely a client-config/instructions choice — the + // server-side authorized_keys permitopen= restriction is identical either + // way (see internal/sysadm/authorizedkeys.go). + ProxyJump bool `yaml:"proxy_jump,omitempty"` + CreatedAt time.Time `yaml:"created_at"` + LockedAt *time.Time `yaml:"locked_at,omitempty"` } // State is the full persisted configuration of the tool. diff --git a/internal/ui/app.go b/internal/ui/app.go index 55f5646..870f4fd 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -116,6 +116,7 @@ type Model struct { userNewGroupsOrder []string userNewCursor int userGroupsEditTarget string // non-empty: editing this existing user's groups instead of creating a new user + userNewProxyJump bool // ProxyJump vs LocalForward client config mode, toggled alongside groups on the same screen // catalog: hosts and their services, shared across groups catalogHostsList list.Model diff --git a/internal/ui/configupdate.go b/internal/ui/configupdate.go index e676b72..347d2c5 100644 --- a/internal/ui/configupdate.go +++ b/internal/ui/configupdate.go @@ -125,7 +125,7 @@ func (m Model) runConfigUpdate(usernames []string) (tea.Model, tea.Cmd) { continue } forwards := st.AllowedForwards(u) - res, err := keymgr.BuildConfigOnlyArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards) + res, err := keymgr.BuildConfigOnlyArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards, u.ProxyJump) if err != nil { failures = append(failures, username+": "+err.Error()) continue diff --git a/internal/ui/export.go b/internal/ui/export.go index 8ef1403..056f1be 100644 --- a/internal/ui/export.go +++ b/internal/ui/export.go @@ -118,7 +118,7 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) { continue } forwards := st.AllowedForwards(u) - res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards) + res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards, u.ProxyJump) if err != nil { failures = append(failures, username+": "+err.Error()) continue diff --git a/internal/ui/users.go b/internal/ui/users.go index 2d4036f..62e75dd 100644 --- a/internal/ui/users.go +++ b/internal/ui/users.go @@ -65,6 +65,7 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) { } m.userNewCursor = 0 m.userGroupsEditTarget = sel.id + m.userNewProxyJump = u.ProxyJump m.err, m.status = "", "" m.screen = scrUserNewGroups return m, nil @@ -173,6 +174,7 @@ func (m Model) updateUserNew(msg tea.Msg) (tea.Model, tea.Cmd) { m.userNewGroupsOrder = append(m.userNewGroupsOrder, g.Name) } m.userNewCursor = 0 + m.userNewProxyJump = false m.screen = scrUserNewGroups return m, nil } @@ -225,12 +227,16 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case "down", "j": - if m.userNewCursor < len(m.userNewGroupsOrder)-1 { + // +1 for the trailing ProxyJump toggle row, always present after + // the group checkboxes (see viewUserNewGroups). + if m.userNewCursor < len(m.userNewGroupsOrder) { m.userNewCursor++ } return m, nil case " ": - if len(m.userNewGroupsOrder) > 0 { + if m.userNewCursor == len(m.userNewGroupsOrder) { + m.userNewProxyJump = !m.userNewProxyJump + } else if len(m.userNewGroupsOrder) > 0 { g := m.userNewGroupsOrder[m.userNewCursor] m.userNewGroupsSel[g] = !m.userNewGroupsSel[g] } @@ -245,12 +251,14 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) { if m.userGroupsEditTarget != "" { username := m.userGroupsEditTarget + proxyJump := m.userNewProxyJump 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 + u.ProxyJump = proxyJump if u.PublicKey != "" && u.Status == model.StatusActive { if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedForwards(u)); err != nil { return err @@ -260,6 +268,9 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) { }) m.logAudit(username, "admin", "groups changed to "+fmt.Sprint(groups)) m.userGroupsEditTarget = "" + if m.err == "" { + m.status = "готово. Если менялся режим ProxyJump/LocalForward или доступ — выдайте пользователю новый конфиг в «Обновить конфиги»." + } m.screen = scrUsers m.refreshUsersList() return m, nil @@ -279,6 +290,7 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) { Groups: groups, Status: model.StatusActive, Comment: comment, + ProxyJump: m.userNewProxyJump, CreatedAt: time.Now(), }) return nil @@ -315,6 +327,17 @@ func (m Model) viewUserNewGroups() string { } body += fmt.Sprintf("%s%s %s\n", cursor, check, g) } + + proxyCursor := " " + if m.userNewCursor == len(m.userNewGroupsOrder) { + proxyCursor = "> " + } + proxyCheck := "[ ]" + if m.userNewProxyJump { + proxyCheck = "[x]" + } + body += fmt.Sprintf("\n%s%s ProxyJump вместо LocalForward (отдельная команда ssh на каждый сервис, без локальных портов)\n", proxyCursor, proxyCheck) + body += fmt.Sprintf("\n[space] отметить [enter] %s [esc] назад", confirmHint) return borderStyle.Render(body) } diff --git a/outlook-bulk-sender/.gitignore b/outlook-bulk-sender/.gitignore new file mode 100644 index 0000000..238c40f --- /dev/null +++ b/outlook-bulk-sender/.gitignore @@ -0,0 +1,18 @@ +# Build output +bin/ +obj/ + +# VSTO/ClickOnce artifacts generated at build time (manifests, publish output) +*.vsto +*.manifest +publish/ + +# VS user-specific files +*.suo +*.user +*.userosscache +*.sln.docstates +.vs/ + +# Test results +TestResults/ diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn.csproj b/outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn.csproj new file mode 100644 index 0000000..66b234e --- /dev/null +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn.csproj @@ -0,0 +1,150 @@ + + + + 17.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + Debug + AnyCPU + {F0475AB7-87AA-4E6B-9BF9-1779AD59912E} + Library + BulkArchiveMailer.AddIn + BulkArchiveMailer.AddIn + v4.8 + 512 + {BAA0C2D2-18E2-41B9-852F-F413020CAA33};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + 7.3 + + + Outlook + false + 3 + true + true + + + false + false + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + false + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + false + + + + + + + + + + + + + False + + + False + + + + False + + + False + + + False + + + False + + + False + + + False + + + + + {96DFBF08-8BCA-4A4E-BDF8-3F013965E30E} + BulkArchiveMailer.Core + + + + + + + + + + ThisAddIn.cs + + + Form + + + SettingsForm.cs + + + Form + + + PreviewForm.cs + + + + + + + + + + + + + + diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/Globals.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/Globals.cs new file mode 100644 index 0000000..fd7ba73 --- /dev/null +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/Globals.cs @@ -0,0 +1,14 @@ +namespace BulkArchiveMailer.AddIn +{ + /// + /// Static access point to the running add-in instance, matching the + /// shape Visual Studio normally generates for VSTO projects (hand + /// written here for the same reason as ThisAddIn.Designer.cs - see + /// its header comment). + /// + internal static class Globals + { + internal static ThisAddIn ThisAddIn => + (ThisAddIn)Microsoft.Office.Tools.Outlook.OutlookAddIn.GetInstance(typeof(ThisAddIn)); + } +} diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/MailSender.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/MailSender.cs new file mode 100644 index 0000000..31e06df --- /dev/null +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/MailSender.cs @@ -0,0 +1,99 @@ +using System; +using System.Runtime.InteropServices; +using BulkArchiveMailer.Core; +using Outlook = Microsoft.Office.Interop.Outlook; + +namespace BulkArchiveMailer.AddIn +{ + /// Per-job result of a send attempt, returned by MailSender so the caller can log + report it without any of this class's own exceptions ever escaping. + public sealed class MailSendOutcome + { + public SendJob Job { get; set; } + public bool Success { get; set; } + public string ErrorMessage { get; set; } + public bool WasDraft { get; set; } + } + + /// + /// Creates and sends (or, in test mode, saves as a Draft) one Outlook + /// mail item per SendJob via Interop. This is the only file that + /// actually creates/sends a MailItem - everything else builds up to + /// calling SendOne. + /// + /// SendOne never throws: every failure is caught and reported back as + /// a failed MailSendOutcome, so a caller looping over a batch can + /// always move on to the next job without a try/catch of its own. + /// + public sealed class MailSender + { + private readonly Outlook.Application application; + private readonly string signatureHtml; + + /// Live Outlook.Application (from ThisAddIn.Application) used to create mail items. + /// + /// HTML of the operator's default signature, already read from + /// disk by SignatureReader (Outlook does not auto-insert a + /// signature on programmatically created items - it must be + /// appended to HTMLBody by hand). May be null/empty if no + /// signature is configured; the mail is then sent without one. + /// + public MailSender(Outlook.Application application, string signatureHtml) + { + this.application = application ?? throw new ArgumentNullException(nameof(application)); + this.signatureHtml = signatureHtml ?? string.Empty; + } + + /// Row to send. Caller is responsible for only passing selectable (Ready/AlreadySent), checked rows - this method does not re-validate Status. + /// When true, the mail item is Save()'d (left in Drafts) instead of Send()'d - the safety net for trying the tool out before trusting it with real recipients. + public MailSendOutcome SendOne(SendJob job, bool testMode) + { + if (job == null) + { + throw new ArgumentNullException(nameof(job)); + } + + Outlook.MailItem mailItem = null; + try + { + mailItem = (Outlook.MailItem)application.CreateItem(Outlook.OlItemType.olMailItem); + mailItem.To = job.Email; + mailItem.Subject = job.Subject; + + var body = job.BodyHtml ?? string.Empty; + if (!string.IsNullOrEmpty(signatureHtml)) + { + body += "

" + signatureHtml; + } + + mailItem.HTMLBody = body; + + if (!string.IsNullOrEmpty(job.ArchivePath)) + { + mailItem.Attachments.Add(job.ArchivePath, Outlook.OlAttachmentType.olByValue, Type.Missing, Type.Missing); + } + + if (testMode) + { + mailItem.Save(); + } + else + { + mailItem.Send(); + } + + return new MailSendOutcome { Job = job, Success = true, WasDraft = testMode }; + } + catch (Exception ex) + { + return new MailSendOutcome { Job = job, Success = false, ErrorMessage = ex.Message, WasDraft = testMode }; + } + finally + { + if (mailItem != null) + { + Marshal.ReleaseComObject(mailItem); + } + } + } + } +} diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/Properties/AssemblyInfo.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..06e5ce7 --- /dev/null +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/Properties/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Resources; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("BulkArchiveMailer.AddIn")] +[assembly: AssemblyDescription("VSTO Outlook add-in: sends ssh-access-tui's per-user .7z archives via a ribbon button.")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("BulkArchiveMailer.AddIn")] +[assembly: AssemblyCopyright("")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] +[assembly: NeutralResourcesLanguage("ru")] + +[assembly: ComVisible(false)] +[assembly: Guid("7a1c9e2a-4f9a-4d6a-9b8e-1c2d3e4f5a6b")] + +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/Ribbon.xml b/outlook-bulk-sender/BulkArchiveMailer.AddIn/Ribbon.xml new file mode 100644 index 0000000..69d93f5 --- /dev/null +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/Ribbon.xml @@ -0,0 +1,19 @@ + + + + + + + +