add ProxyJump function
This commit is contained in:
1 parent
56e20e5464
commit
7f0e0fdb23
48 files changed
+3875
-44
No files matched your search
@@ -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 <alias>"
|
||||
// block that reaches it by proxying through "jump" (ProxyJump), so the user
|
||||
// runs "ssh <alias>" directly per service rather than opening one
|
||||
// always-open tunnel and pointing local tools at 127.0.0.1:<port>.
|
||||
//
|
||||
// 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
|
||||
// "<host>-<service>" 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 <alias>" entry (see
|
||||
// RenderClientConfigProxyJump) reached with a plain "ssh <alias>" 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()
|
||||
}
|
||||
@@ -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 <alias>" 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)
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -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 <alias>" ssh_config block
|
||||
// proxied through "jump" via ProxyJump (plain "ssh <alias>"); 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
-2
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user