From dfce272dd4b4ca01da78309fbc768eeaa31d850f Mon Sep 17 00:00:00 2001 From: Nicholas Whitehead Date: Fri, 22 May 2026 13:15:17 -0400 Subject: [PATCH 1/5] env-vars: Added environment variable expansion for tunnel Host field in config --- internal/config/config.go | 5 ++ test/e2e/config_test.go | 104 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index e99c9be..1baba43 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,11 @@ func Load() (*Config, error) { } } + // Expand environment variable references in Host fields + for i := range cfg.Tunnels { + cfg.Tunnels[i].Host = os.Expand(cfg.Tunnels[i].Host, os.Getenv) + } + // Create a map of tunnel names to tunnel pointers for easy lookup later m, err := buildTunnelsMap(cfg.Tunnels) if err != nil { diff --git a/test/e2e/config_test.go b/test/e2e/config_test.go index 42e0660..981e441 100644 --- a/test/e2e/config_test.go +++ b/test/e2e/config_test.go @@ -2,8 +2,11 @@ package e2e import ( "os" + "path/filepath" "strings" "testing" + + config_pkg "github.com/alebeck/boring/internal/config" ) func TestConfigCreate(t *testing.T) { @@ -119,3 +122,104 @@ func TestInvalidGroup(t *testing.T) { t.Errorf("output did not indicate invalid group name: %s", out) } } + +func TestEnvVarExpansionInHost(t *testing.T) { + // Create a config file with environment variable references in the host field + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.toml") + + cfgContent := `[[tunnels]] +name = "envtest" +host = "${BORING_TEST_HOST}" +local = 49711 +remote = "localhost:49712" + +[[tunnels]] +name = "envtest2" +host = "${BORING_TEST_USER}@${BORING_TEST_HOST}" +local = 49713 +remote = "localhost:49714" + +[[tunnels]] +name = "noenv" +host = "static-host.example.com" +local = 49715 +remote = "localhost:49716" +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Set environment variables + t.Setenv("BORING_TEST_HOST", "myserver.example.com") + t.Setenv("BORING_TEST_USER", "testuser") + + // Override the config path and load + t.Setenv("BORING_CONFIG", cfgPath) + + // We need to reload the config path since init() already ran + origPath := config_pkg.Path + config_pkg.Path = cfgPath + defer func() { config_pkg.Path = origPath }() + + cfg, err := config_pkg.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + tests := []struct { + name string + expectedHost string + }{ + {"envtest", "myserver.example.com"}, + {"envtest2", "testuser@myserver.example.com"}, + {"noenv", "static-host.example.com"}, + } + + for _, tt := range tests { + desc, ok := cfg.TunnelsMap[tt.name] + if !ok { + t.Errorf("tunnel %q not found in config", tt.name) + continue + } + if desc.Host != tt.expectedHost { + t.Errorf("tunnel %q: expected host %q, got %q", tt.name, tt.expectedHost, desc.Host) + } + } +} + +func TestEnvVarExpansionUnsetVar(t *testing.T) { + // When an env var is not set, os.Expand replaces it with empty string + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.toml") + + cfgContent := `[[tunnels]] +name = "unsetenv" +host = "${BORING_UNSET_VAR_12345}" +local = 49711 +remote = "localhost:49712" +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Make sure the variable is not set + os.Unsetenv("BORING_UNSET_VAR_12345") + + origPath := config_pkg.Path + config_pkg.Path = cfgPath + defer func() { config_pkg.Path = origPath }() + + cfg, err := config_pkg.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + desc, ok := cfg.TunnelsMap["unsetenv"] + if !ok { + t.Fatal("tunnel 'unsetenv' not found") + } + if desc.Host != "" { + t.Errorf("expected empty host for unset env var, got %q", desc.Host) + } +} From 03bda639bd9a2d2157157414a29f439c459a6e4c Mon Sep 17 00:00:00 2001 From: Nicholas Whitehead Date: Tue, 26 May 2026 12:33:13 -0400 Subject: [PATCH 2/5] Added expansion support for all string and StringOrInt tunnel fields --- internal/config/config.go | 32 ++++- test/e2e/config_test.go | 262 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 1baba43..ea35214 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,9 +69,22 @@ func Load() (*Config, error) { } } - // Expand environment variable references in Host fields + // Expand environment variable references in these fields: + // Host + // Name + // LocalAddress + // RemoteAddress + // User + // IdentityFile + // Group for i := range cfg.Tunnels { - cfg.Tunnels[i].Host = os.Expand(cfg.Tunnels[i].Host, os.Getenv) + cfg.Tunnels[i].Host = os.Expand(cfg.Tunnels[i].Host, expandWithDefault) + cfg.Tunnels[i].Name = os.Expand(cfg.Tunnels[i].Name, expandWithDefault) + cfg.Tunnels[i].LocalAddress = tunnel.StringOrInt(os.Expand(cfg.Tunnels[i].LocalAddress.String(), expandWithDefault)) + cfg.Tunnels[i].RemoteAddress = tunnel.StringOrInt(os.Expand(cfg.Tunnels[i].RemoteAddress.String(), expandWithDefault)) + cfg.Tunnels[i].User = os.Expand(cfg.Tunnels[i].User, expandWithDefault) + cfg.Tunnels[i].IdentityFile = os.Expand(cfg.Tunnels[i].IdentityFile, expandWithDefault) + cfg.Tunnels[i].Group = os.Expand(cfg.Tunnels[i].Group, expandWithDefault) } // Create a map of tunnel names to tunnel pointers for easy lookup later @@ -132,3 +145,18 @@ func specialPrefix(s string) bool { func containsGlob(s string) bool { return strings.ContainsAny(s, "*?[") } + +// expandWithDefault resolves an environment variable reference, supporting +// the ${VAR:-default} syntax. If the variable is unset or empty and a default +// is provided after ":-", the default value is returned. +func expandWithDefault(key string) string { + if idx := strings.Index(key, ":-"); idx != -1 { + varName := key[:idx] + defaultVal := key[idx+2:] + if val := os.Getenv(varName); val != "" { + return val + } + return defaultVal + } + return os.Getenv(key) +} diff --git a/test/e2e/config_test.go b/test/e2e/config_test.go index 981e441..3a1ef95 100644 --- a/test/e2e/config_test.go +++ b/test/e2e/config_test.go @@ -223,3 +223,265 @@ remote = "localhost:49712" t.Errorf("expected empty host for unset env var, got %q", desc.Host) } } + +func TestEnvVarExpansionWithDefault(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.toml") + + cfgContent := `[[tunnels]] +name = "withdefault" +host = "${BORING_TEST_DEFHOST:-fallback.example.com}" +local = 49711 +remote = "localhost:49712" + +[[tunnels]] +name = "overridden" +host = "${BORING_TEST_DEFHOST2:-fallback2.example.com}" +local = 49713 +remote = "localhost:49714" + +[[tunnels]] +name = "multidefault" +host = "${BORING_TEST_DEFUSER:-admin}@${BORING_TEST_DEFHOST3:-default-host.local}" +local = 49715 +remote = "localhost:49716" + +[[tunnels]] +name = "emptydefault" +host = "${BORING_TEST_UNSET_XXX:-}" +local = 49717 +remote = "localhost:49718" +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // BORING_TEST_DEFHOST is NOT set, so default should be used + os.Unsetenv("BORING_TEST_DEFHOST") + + // BORING_TEST_DEFHOST2 IS set, so it should override the default + t.Setenv("BORING_TEST_DEFHOST2", "real-host.example.com") + + // Neither BORING_TEST_DEFUSER nor BORING_TEST_DEFHOST3 are set + os.Unsetenv("BORING_TEST_DEFUSER") + os.Unsetenv("BORING_TEST_DEFHOST3") + + // BORING_TEST_UNSET_XXX is not set, default is empty string + os.Unsetenv("BORING_TEST_UNSET_XXX") + + origPath := config_pkg.Path + config_pkg.Path = cfgPath + defer func() { config_pkg.Path = origPath }() + + cfg, err := config_pkg.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + tests := []struct { + name string + expectedHost string + }{ + {"withdefault", "fallback.example.com"}, + {"overridden", "real-host.example.com"}, + {"multidefault", "admin@default-host.local"}, + {"emptydefault", ""}, + } + + for _, tt := range tests { + desc, ok := cfg.TunnelsMap[tt.name] + if !ok { + t.Errorf("tunnel %q not found in config", tt.name) + continue + } + if desc.Host != tt.expectedHost { + t.Errorf("tunnel %q: expected host %q, got %q", tt.name, tt.expectedHost, desc.Host) + } + } +} + +func TestEnvVarExpansionAllFields(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.toml") + + cfgContent := `[[tunnels]] +name = "${BORING_TEST_NAME}" +host = "${BORING_TEST_HOST}" +local = "${BORING_TEST_LOCAL}" +remote = "${BORING_TEST_REMOTE}" +user = "${BORING_TEST_USER}" +identity = "${BORING_TEST_IDENTITY}" +group = "${BORING_TEST_GROUP}" +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Set all environment variables + t.Setenv("BORING_TEST_NAME", "mytunnel") + t.Setenv("BORING_TEST_HOST", "myhost.example.com") + t.Setenv("BORING_TEST_LOCAL", "localhost:5432") + t.Setenv("BORING_TEST_REMOTE", "dbserver:5432") + t.Setenv("BORING_TEST_USER", "dbuser") + t.Setenv("BORING_TEST_IDENTITY", "/home/user/.ssh/id_rsa") + t.Setenv("BORING_TEST_GROUP", "databases") + + origPath := config_pkg.Path + config_pkg.Path = cfgPath + defer func() { config_pkg.Path = origPath }() + + cfg, err := config_pkg.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + desc, ok := cfg.TunnelsMap["mytunnel"] + if !ok { + t.Fatal("tunnel 'mytunnel' not found in config") + } + + if desc.Name != "mytunnel" { + t.Errorf("Name: expected %q, got %q", "mytunnel", desc.Name) + } + if desc.Host != "myhost.example.com" { + t.Errorf("Host: expected %q, got %q", "myhost.example.com", desc.Host) + } + if desc.LocalAddress.String() != "localhost:5432" { + t.Errorf("LocalAddress: expected %q, got %q", "localhost:5432", desc.LocalAddress.String()) + } + if desc.RemoteAddress.String() != "dbserver:5432" { + t.Errorf("RemoteAddress: expected %q, got %q", "dbserver:5432", desc.RemoteAddress.String()) + } + if desc.User != "dbuser" { + t.Errorf("User: expected %q, got %q", "dbuser", desc.User) + } + if desc.IdentityFile != "/home/user/.ssh/id_rsa" { + t.Errorf("IdentityFile: expected %q, got %q", "/home/user/.ssh/id_rsa", desc.IdentityFile) + } + if desc.Group != "databases" { + t.Errorf("Group: expected %q, got %q", "databases", desc.Group) + } +} + +func TestEnvVarExpansionAllFieldsWithDefaults(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.toml") + + cfgContent := `[[tunnels]] +name = "${BORING_TEST_NAME2:-defaulttunnel}" +host = "${BORING_TEST_HOST2:-default.example.com}" +local = "${BORING_TEST_LOCAL2:-8080}" +remote = "${BORING_TEST_REMOTE2:-localhost:80}" +user = "${BORING_TEST_USER2:-defaultuser}" +identity = "${BORING_TEST_IDENTITY2:-~/.ssh/id_ed25519}" +group = "${BORING_TEST_GROUP2:-defaultgroup}" +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Unset all variables so defaults are used + os.Unsetenv("BORING_TEST_NAME2") + os.Unsetenv("BORING_TEST_HOST2") + os.Unsetenv("BORING_TEST_LOCAL2") + os.Unsetenv("BORING_TEST_REMOTE2") + os.Unsetenv("BORING_TEST_USER2") + os.Unsetenv("BORING_TEST_IDENTITY2") + os.Unsetenv("BORING_TEST_GROUP2") + + origPath := config_pkg.Path + config_pkg.Path = cfgPath + defer func() { config_pkg.Path = origPath }() + + cfg, err := config_pkg.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + desc, ok := cfg.TunnelsMap["defaulttunnel"] + if !ok { + t.Fatal("tunnel 'defaulttunnel' not found in config") + } + + if desc.Name != "defaulttunnel" { + t.Errorf("Name: expected %q, got %q", "defaulttunnel", desc.Name) + } + if desc.Host != "default.example.com" { + t.Errorf("Host: expected %q, got %q", "default.example.com", desc.Host) + } + if desc.LocalAddress.String() != "8080" { + t.Errorf("LocalAddress: expected %q, got %q", "8080", desc.LocalAddress.String()) + } + if desc.RemoteAddress.String() != "localhost:80" { + t.Errorf("RemoteAddress: expected %q, got %q", "localhost:80", desc.RemoteAddress.String()) + } + if desc.User != "defaultuser" { + t.Errorf("User: expected %q, got %q", "defaultuser", desc.User) + } + if desc.IdentityFile != "~/.ssh/id_ed25519" { + t.Errorf("IdentityFile: expected %q, got %q", "~/.ssh/id_ed25519", desc.IdentityFile) + } + if desc.Group != "defaultgroup" { + t.Errorf("Group: expected %q, got %q", "defaultgroup", desc.Group) + } +} + +func TestEnvVarExpansionMixedFields(t *testing.T) { + // Test a mix of env vars set and defaults used + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.toml") + + cfgContent := `[[tunnels]] +name = "mixedtest" +host = "${BORING_MIX_HOST:-fallback.local}" +local = "${BORING_MIX_LOCAL:-9999}" +remote = "${BORING_MIX_REMOTE}" +user = "${BORING_MIX_USER:-fallbackuser}" +identity = "${BORING_MIX_IDENTITY}" +group = "${BORING_MIX_GROUP:-fallbackgroup}" +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Set some variables, leave others unset + t.Setenv("BORING_MIX_HOST", "realhost.example.com") // overrides default + os.Unsetenv("BORING_MIX_LOCAL") // uses default + t.Setenv("BORING_MIX_REMOTE", "realremote:443") // no default, set + os.Unsetenv("BORING_MIX_USER") // uses default + os.Unsetenv("BORING_MIX_IDENTITY") // no default, empty + t.Setenv("BORING_MIX_GROUP", "realgroup") // overrides default + + origPath := config_pkg.Path + config_pkg.Path = cfgPath + defer func() { config_pkg.Path = origPath }() + + cfg, err := config_pkg.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + desc, ok := cfg.TunnelsMap["mixedtest"] + if !ok { + t.Fatal("tunnel 'mixedtest' not found in config") + } + + if desc.Host != "realhost.example.com" { + t.Errorf("Host: expected %q, got %q", "realhost.example.com", desc.Host) + } + if desc.LocalAddress.String() != "9999" { + t.Errorf("LocalAddress: expected %q, got %q", "9999", desc.LocalAddress.String()) + } + if desc.RemoteAddress.String() != "realremote:443" { + t.Errorf("RemoteAddress: expected %q, got %q", "realremote:443", desc.RemoteAddress.String()) + } + if desc.User != "fallbackuser" { + t.Errorf("User: expected %q, got %q", "fallbackuser", desc.User) + } + if desc.IdentityFile != "" { + t.Errorf("IdentityFile: expected empty string, got %q", desc.IdentityFile) + } + if desc.Group != "realgroup" { + t.Errorf("Group: expected %q, got %q", "realgroup", desc.Group) + } +} From 0a98b373f1afe9d7f4accc4968265c4de9cceba5 Mon Sep 17 00:00:00 2001 From: Alexander Becker Date: Sun, 31 May 2026 10:52:09 +0100 Subject: [PATCH 3/5] Remove Name, Group from env var expansion --- internal/config/config.go | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 132d07c..5959d34 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,22 +69,15 @@ func Load() (*Config, error) { } } - // Expand environment variable references in these fields: - // Host - // Name - // LocalAddress - // RemoteAddress - // User - // IdentityFile - // Group + // Expand environment variables for a pre-defined set of fields + expand := func(s string) string { return os.Expand(s, expandWithDefault) } for i := range cfg.Tunnels { - cfg.Tunnels[i].Host = os.Expand(cfg.Tunnels[i].Host, expandWithDefault) - cfg.Tunnels[i].Name = os.Expand(cfg.Tunnels[i].Name, expandWithDefault) - cfg.Tunnels[i].LocalAddress = tunnel.StringOrInt(os.Expand(cfg.Tunnels[i].LocalAddress.String(), expandWithDefault)) - cfg.Tunnels[i].RemoteAddress = tunnel.StringOrInt(os.Expand(cfg.Tunnels[i].RemoteAddress.String(), expandWithDefault)) - cfg.Tunnels[i].User = os.Expand(cfg.Tunnels[i].User, expandWithDefault) - cfg.Tunnels[i].IdentityFile = os.Expand(cfg.Tunnels[i].IdentityFile, expandWithDefault) - cfg.Tunnels[i].Group = os.Expand(cfg.Tunnels[i].Group, expandWithDefault) + t := &cfg.Tunnels[i] + t.Host = expand(t.Host) + t.User = expand(t.User) + t.IdentityFile = expand(t.IdentityFile) + t.LocalAddress = tunnel.StringOrInt(expand(t.LocalAddress.String())) + t.RemoteAddress = tunnel.StringOrInt(expand(t.RemoteAddress.String())) } // Create a map of tunnel names to tunnel pointers for easy lookup later @@ -150,9 +143,7 @@ func containsGlob(s string) bool { // the ${VAR:-default} syntax. If the variable is unset or empty and a default // is provided after ":-", the default value is returned. func expandWithDefault(key string) string { - if idx := strings.Index(key, ":-"); idx != -1 { - varName := key[:idx] - defaultVal := key[idx+2:] + if varName, defaultVal, found := strings.Cut(key, ":-"); found { if val := os.Getenv(varName); val != "" { return val } From 8ab9a70fb7fecd5bcabc6f3f54e8045d93056e5a Mon Sep 17 00:00:00 2001 From: Alexander Becker Date: Sun, 31 May 2026 10:52:40 +0100 Subject: [PATCH 4/5] Use fixtures for env var expansion tests --- internal/config/config_test.go | 81 ++++ test/e2e/config_test.go | 366 ------------------ test/testdata/config/expand/defaults.toml | 12 + .../config/expand/literal_fields.toml | 5 + test/testdata/config/expand/unset.toml | 4 + test/testdata/config/expand/vars.toml | 8 + 6 files changed, 110 insertions(+), 366 deletions(-) create mode 100644 test/testdata/config/expand/defaults.toml create mode 100644 test/testdata/config/expand/literal_fields.toml create mode 100644 test/testdata/config/expand/unset.toml create mode 100644 test/testdata/config/expand/vars.toml diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1d6d56d..9de0522 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -19,3 +19,84 @@ func TestSpecialPrefixEmpty(t *testing.T) { t.Error(`specialPrefix("") = true, want false`) } } + +func loadFixture(t *testing.T, path string) *Config { + orig := Path + t.Cleanup(func() { Path = orig }) + Path = path + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + return cfg +} + +func TestExpandEnvVars(t *testing.T) { + t.Setenv("TEST_HOST", "example.com") + t.Setenv("TEST_USER", "alice") + t.Setenv("TEST_IDENTITY", "/keys/id_ed25519") + t.Setenv("TEST_LOCAL", "9000") + t.Setenv("TEST_REMOTE", "localhost:8080") + + cfg := loadFixture(t, "../../test/testdata/config/expand/vars.toml") + + tun := cfg.Tunnels[0] + if tun.Host != "example.com" { + t.Errorf("Host = %q, want %q", tun.Host, "example.com") + } + if tun.User != "alice" { + t.Errorf("User = %q, want %q", tun.User, "alice") + } + if tun.IdentityFile != "/keys/id_ed25519" { + t.Errorf("IdentityFile = %q, want %q", tun.IdentityFile, "/keys/id_ed25519") + } + if tun.LocalAddress.String() != "9000" { + t.Errorf("LocalAddress = %q, want %q", tun.LocalAddress.String(), "9000") + } + if tun.RemoteAddress.String() != "localhost:8080" { + t.Errorf("RemoteAddress = %q, want %q", tun.RemoteAddress.String(), "localhost:8080") + } +} + +func TestExpandDefault(t *testing.T) { + t.Setenv("TEST_SET", "real") + t.Setenv("TEST_EMPTY", "") + // TEST_UNSET is unset + + cfg := loadFixture(t, "../../test/testdata/config/expand/defaults.toml") + + cases := map[string]string{ + "set": "real", + "empty": "fallback", + "unset": "fallback", + } + for name, want := range cases { + if got := cfg.TunnelsMap[name].Host; got != want { + t.Errorf("tunnel %q Host = %q, want %q", name, got, want) + } + } +} + +func TestExpandUnsetIsEmpty(t *testing.T) { + // A referenced-but-unset variable with no default expands to "" + cfg := loadFixture(t, "../../test/testdata/config/expand/unset.toml") + + if got := cfg.Tunnels[0].Host; got != "" { + t.Errorf("Host = %q, want empty string", got) + } +} + +func TestExpandFieldsNotExpanded(t *testing.T) { + // Name and Group are identifiers and are intentionally not expanded + t.Setenv("TEST_VAR", "expanded") + + cfg := loadFixture(t, "../../test/testdata/config/expand/literal_fields.toml") + + tun := cfg.Tunnels[0] + if tun.Name != "tun_$TEST_VAR" { + t.Errorf("Name = %q, want it left literal", tun.Name) + } + if tun.Group != "grp_$TEST_VAR" { + t.Errorf("Group = %q, want it left literal", tun.Group) + } +} diff --git a/test/e2e/config_test.go b/test/e2e/config_test.go index 5d02b6f..5e9db9c 100644 --- a/test/e2e/config_test.go +++ b/test/e2e/config_test.go @@ -2,11 +2,8 @@ package e2e import ( "os" - "path/filepath" "strings" "testing" - - config_pkg "github.com/alebeck/boring/internal/config" ) func TestConfigCreate(t *testing.T) { @@ -122,366 +119,3 @@ func TestInvalidGroup(t *testing.T) { t.Errorf("output did not indicate invalid group name: %s", out) } } - -func TestEnvVarExpansionInHost(t *testing.T) { - // Create a config file with environment variable references in the host field - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "config.toml") - - cfgContent := `[[tunnels]] -name = "envtest" -host = "${BORING_TEST_HOST}" -local = 49711 -remote = "localhost:49712" - -[[tunnels]] -name = "envtest2" -host = "${BORING_TEST_USER}@${BORING_TEST_HOST}" -local = 49713 -remote = "localhost:49714" - -[[tunnels]] -name = "noenv" -host = "static-host.example.com" -local = 49715 -remote = "localhost:49716" -` - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { - t.Fatalf("failed to write config file: %v", err) - } - - // Set environment variables - t.Setenv("BORING_TEST_HOST", "myserver.example.com") - t.Setenv("BORING_TEST_USER", "testuser") - - // Override the config path and load - t.Setenv("BORING_CONFIG", cfgPath) - - // We need to reload the config path since init() already ran - origPath := config_pkg.Path - config_pkg.Path = cfgPath - defer func() { config_pkg.Path = origPath }() - - cfg, err := config_pkg.Load() - if err != nil { - t.Fatalf("failed to load config: %v", err) - } - - tests := []struct { - name string - expectedHost string - }{ - {"envtest", "myserver.example.com"}, - {"envtest2", "testuser@myserver.example.com"}, - {"noenv", "static-host.example.com"}, - } - - for _, tt := range tests { - desc, ok := cfg.TunnelsMap[tt.name] - if !ok { - t.Errorf("tunnel %q not found in config", tt.name) - continue - } - if desc.Host != tt.expectedHost { - t.Errorf("tunnel %q: expected host %q, got %q", tt.name, tt.expectedHost, desc.Host) - } - } -} - -func TestEnvVarExpansionUnsetVar(t *testing.T) { - // When an env var is not set, os.Expand replaces it with empty string - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "config.toml") - - cfgContent := `[[tunnels]] -name = "unsetenv" -host = "${BORING_UNSET_VAR_12345}" -local = 49711 -remote = "localhost:49712" -` - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { - t.Fatalf("failed to write config file: %v", err) - } - - // Make sure the variable is not set - os.Unsetenv("BORING_UNSET_VAR_12345") - - origPath := config_pkg.Path - config_pkg.Path = cfgPath - defer func() { config_pkg.Path = origPath }() - - cfg, err := config_pkg.Load() - if err != nil { - t.Fatalf("failed to load config: %v", err) - } - - desc, ok := cfg.TunnelsMap["unsetenv"] - if !ok { - t.Fatal("tunnel 'unsetenv' not found") - } - if desc.Host != "" { - t.Errorf("expected empty host for unset env var, got %q", desc.Host) - } -} - -func TestEnvVarExpansionWithDefault(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "config.toml") - - cfgContent := `[[tunnels]] -name = "withdefault" -host = "${BORING_TEST_DEFHOST:-fallback.example.com}" -local = 49711 -remote = "localhost:49712" - -[[tunnels]] -name = "overridden" -host = "${BORING_TEST_DEFHOST2:-fallback2.example.com}" -local = 49713 -remote = "localhost:49714" - -[[tunnels]] -name = "multidefault" -host = "${BORING_TEST_DEFUSER:-admin}@${BORING_TEST_DEFHOST3:-default-host.local}" -local = 49715 -remote = "localhost:49716" - -[[tunnels]] -name = "emptydefault" -host = "${BORING_TEST_UNSET_XXX:-}" -local = 49717 -remote = "localhost:49718" -` - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { - t.Fatalf("failed to write config file: %v", err) - } - - // BORING_TEST_DEFHOST is NOT set, so default should be used - os.Unsetenv("BORING_TEST_DEFHOST") - - // BORING_TEST_DEFHOST2 IS set, so it should override the default - t.Setenv("BORING_TEST_DEFHOST2", "real-host.example.com") - - // Neither BORING_TEST_DEFUSER nor BORING_TEST_DEFHOST3 are set - os.Unsetenv("BORING_TEST_DEFUSER") - os.Unsetenv("BORING_TEST_DEFHOST3") - - // BORING_TEST_UNSET_XXX is not set, default is empty string - os.Unsetenv("BORING_TEST_UNSET_XXX") - - origPath := config_pkg.Path - config_pkg.Path = cfgPath - defer func() { config_pkg.Path = origPath }() - - cfg, err := config_pkg.Load() - if err != nil { - t.Fatalf("failed to load config: %v", err) - } - - tests := []struct { - name string - expectedHost string - }{ - {"withdefault", "fallback.example.com"}, - {"overridden", "real-host.example.com"}, - {"multidefault", "admin@default-host.local"}, - {"emptydefault", ""}, - } - - for _, tt := range tests { - desc, ok := cfg.TunnelsMap[tt.name] - if !ok { - t.Errorf("tunnel %q not found in config", tt.name) - continue - } - if desc.Host != tt.expectedHost { - t.Errorf("tunnel %q: expected host %q, got %q", tt.name, tt.expectedHost, desc.Host) - } - } -} - -func TestEnvVarExpansionAllFields(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "config.toml") - - cfgContent := `[[tunnels]] -name = "${BORING_TEST_NAME}" -host = "${BORING_TEST_HOST}" -local = "${BORING_TEST_LOCAL}" -remote = "${BORING_TEST_REMOTE}" -user = "${BORING_TEST_USER}" -identity = "${BORING_TEST_IDENTITY}" -group = "${BORING_TEST_GROUP}" -` - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { - t.Fatalf("failed to write config file: %v", err) - } - - // Set all environment variables - t.Setenv("BORING_TEST_NAME", "mytunnel") - t.Setenv("BORING_TEST_HOST", "myhost.example.com") - t.Setenv("BORING_TEST_LOCAL", "localhost:5432") - t.Setenv("BORING_TEST_REMOTE", "dbserver:5432") - t.Setenv("BORING_TEST_USER", "dbuser") - t.Setenv("BORING_TEST_IDENTITY", "/home/user/.ssh/id_rsa") - t.Setenv("BORING_TEST_GROUP", "databases") - - origPath := config_pkg.Path - config_pkg.Path = cfgPath - defer func() { config_pkg.Path = origPath }() - - cfg, err := config_pkg.Load() - if err != nil { - t.Fatalf("failed to load config: %v", err) - } - - desc, ok := cfg.TunnelsMap["mytunnel"] - if !ok { - t.Fatal("tunnel 'mytunnel' not found in config") - } - - if desc.Name != "mytunnel" { - t.Errorf("Name: expected %q, got %q", "mytunnel", desc.Name) - } - if desc.Host != "myhost.example.com" { - t.Errorf("Host: expected %q, got %q", "myhost.example.com", desc.Host) - } - if desc.LocalAddress.String() != "localhost:5432" { - t.Errorf("LocalAddress: expected %q, got %q", "localhost:5432", desc.LocalAddress.String()) - } - if desc.RemoteAddress.String() != "dbserver:5432" { - t.Errorf("RemoteAddress: expected %q, got %q", "dbserver:5432", desc.RemoteAddress.String()) - } - if desc.User != "dbuser" { - t.Errorf("User: expected %q, got %q", "dbuser", desc.User) - } - if desc.IdentityFile != "/home/user/.ssh/id_rsa" { - t.Errorf("IdentityFile: expected %q, got %q", "/home/user/.ssh/id_rsa", desc.IdentityFile) - } - if desc.Group != "databases" { - t.Errorf("Group: expected %q, got %q", "databases", desc.Group) - } -} - -func TestEnvVarExpansionAllFieldsWithDefaults(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "config.toml") - - cfgContent := `[[tunnels]] -name = "${BORING_TEST_NAME2:-defaulttunnel}" -host = "${BORING_TEST_HOST2:-default.example.com}" -local = "${BORING_TEST_LOCAL2:-8080}" -remote = "${BORING_TEST_REMOTE2:-localhost:80}" -user = "${BORING_TEST_USER2:-defaultuser}" -identity = "${BORING_TEST_IDENTITY2:-~/.ssh/id_ed25519}" -group = "${BORING_TEST_GROUP2:-defaultgroup}" -` - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { - t.Fatalf("failed to write config file: %v", err) - } - - // Unset all variables so defaults are used - os.Unsetenv("BORING_TEST_NAME2") - os.Unsetenv("BORING_TEST_HOST2") - os.Unsetenv("BORING_TEST_LOCAL2") - os.Unsetenv("BORING_TEST_REMOTE2") - os.Unsetenv("BORING_TEST_USER2") - os.Unsetenv("BORING_TEST_IDENTITY2") - os.Unsetenv("BORING_TEST_GROUP2") - - origPath := config_pkg.Path - config_pkg.Path = cfgPath - defer func() { config_pkg.Path = origPath }() - - cfg, err := config_pkg.Load() - if err != nil { - t.Fatalf("failed to load config: %v", err) - } - - desc, ok := cfg.TunnelsMap["defaulttunnel"] - if !ok { - t.Fatal("tunnel 'defaulttunnel' not found in config") - } - - if desc.Name != "defaulttunnel" { - t.Errorf("Name: expected %q, got %q", "defaulttunnel", desc.Name) - } - if desc.Host != "default.example.com" { - t.Errorf("Host: expected %q, got %q", "default.example.com", desc.Host) - } - if desc.LocalAddress.String() != "8080" { - t.Errorf("LocalAddress: expected %q, got %q", "8080", desc.LocalAddress.String()) - } - if desc.RemoteAddress.String() != "localhost:80" { - t.Errorf("RemoteAddress: expected %q, got %q", "localhost:80", desc.RemoteAddress.String()) - } - if desc.User != "defaultuser" { - t.Errorf("User: expected %q, got %q", "defaultuser", desc.User) - } - if desc.IdentityFile != "~/.ssh/id_ed25519" { - t.Errorf("IdentityFile: expected %q, got %q", "~/.ssh/id_ed25519", desc.IdentityFile) - } - if desc.Group != "defaultgroup" { - t.Errorf("Group: expected %q, got %q", "defaultgroup", desc.Group) - } -} - -func TestEnvVarExpansionMixedFields(t *testing.T) { - // Test a mix of env vars set and defaults used - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "config.toml") - - cfgContent := `[[tunnels]] -name = "mixedtest" -host = "${BORING_MIX_HOST:-fallback.local}" -local = "${BORING_MIX_LOCAL:-9999}" -remote = "${BORING_MIX_REMOTE}" -user = "${BORING_MIX_USER:-fallbackuser}" -identity = "${BORING_MIX_IDENTITY}" -group = "${BORING_MIX_GROUP:-fallbackgroup}" -` - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { - t.Fatalf("failed to write config file: %v", err) - } - - // Set some variables, leave others unset - t.Setenv("BORING_MIX_HOST", "realhost.example.com") // overrides default - os.Unsetenv("BORING_MIX_LOCAL") // uses default - t.Setenv("BORING_MIX_REMOTE", "realremote:443") // no default, set - os.Unsetenv("BORING_MIX_USER") // uses default - os.Unsetenv("BORING_MIX_IDENTITY") // no default, empty - t.Setenv("BORING_MIX_GROUP", "realgroup") // overrides default - - origPath := config_pkg.Path - config_pkg.Path = cfgPath - defer func() { config_pkg.Path = origPath }() - - cfg, err := config_pkg.Load() - if err != nil { - t.Fatalf("failed to load config: %v", err) - } - - desc, ok := cfg.TunnelsMap["mixedtest"] - if !ok { - t.Fatal("tunnel 'mixedtest' not found in config") - } - - if desc.Host != "realhost.example.com" { - t.Errorf("Host: expected %q, got %q", "realhost.example.com", desc.Host) - } - if desc.LocalAddress.String() != "9999" { - t.Errorf("LocalAddress: expected %q, got %q", "9999", desc.LocalAddress.String()) - } - if desc.RemoteAddress.String() != "realremote:443" { - t.Errorf("RemoteAddress: expected %q, got %q", "realremote:443", desc.RemoteAddress.String()) - } - if desc.User != "fallbackuser" { - t.Errorf("User: expected %q, got %q", "fallbackuser", desc.User) - } - if desc.IdentityFile != "" { - t.Errorf("IdentityFile: expected empty string, got %q", desc.IdentityFile) - } - if desc.Group != "realgroup" { - t.Errorf("Group: expected %q, got %q", "realgroup", desc.Group) - } -} diff --git a/test/testdata/config/expand/defaults.toml b/test/testdata/config/expand/defaults.toml new file mode 100644 index 0000000..ac87a58 --- /dev/null +++ b/test/testdata/config/expand/defaults.toml @@ -0,0 +1,12 @@ +# ${VAR:-fallback} syntax: TEST_SET is set, TEST_EMPTY is empty, TEST_UNSET is unset +[[tunnels]] +name = "set" +host = "${TEST_SET:-fallback}" + +[[tunnels]] +name = "empty" +host = "${TEST_EMPTY:-fallback}" + +[[tunnels]] +name = "unset" +host = "${TEST_UNSET:-fallback}" diff --git a/test/testdata/config/expand/literal_fields.toml b/test/testdata/config/expand/literal_fields.toml new file mode 100644 index 0000000..3653522 --- /dev/null +++ b/test/testdata/config/expand/literal_fields.toml @@ -0,0 +1,5 @@ +# name and group are identifiers and are NOT expanded, so $TEST_VAR stays literal +[[tunnels]] +name = "tun_$TEST_VAR" +group = "grp_$TEST_VAR" +host = "example.com" diff --git a/test/testdata/config/expand/unset.toml b/test/testdata/config/expand/unset.toml new file mode 100644 index 0000000..833c5a9 --- /dev/null +++ b/test/testdata/config/expand/unset.toml @@ -0,0 +1,4 @@ +# TEST_NOPE is unset and has no default, so it should expand to an empty string +[[tunnels]] +name = "tun" +host = "${TEST_NOPE}" diff --git a/test/testdata/config/expand/vars.toml b/test/testdata/config/expand/vars.toml new file mode 100644 index 0000000..d6c1673 --- /dev/null +++ b/test/testdata/config/expand/vars.toml @@ -0,0 +1,8 @@ +# Expanded against TEST_HOST, TEST_USER, TEST_IDENTITY, TEST_LOCAL, TEST_REMOTE +[[tunnels]] +name = "tun" +host = "${TEST_HOST}" +user = "$TEST_USER" +identity = "${TEST_IDENTITY}" +local = "${TEST_LOCAL}" +remote = "${TEST_REMOTE}" From 84458d33dddfafbcd06a9f3eaa5c616dd0741ef9 Mon Sep 17 00:00:00 2001 From: Alexander Becker Date: Tue, 16 Jun 2026 21:50:36 +0100 Subject: [PATCH 5/5] Support Port env variable expansion --- internal/config/config.go | 1 + internal/config/config_test.go | 4 ++++ internal/ssh_config/ssh_config_test.go | 20 ++++++++++++++++++++ internal/tunnel/tunnel.go | 8 +++++--- test/testdata/config/expand/vars.toml | 3 ++- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5959d34..ead9f11 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,6 +76,7 @@ func Load() (*Config, error) { t.Host = expand(t.Host) t.User = expand(t.User) t.IdentityFile = expand(t.IdentityFile) + t.Port = tunnel.StringOrInt(expand(t.Port.String())) t.LocalAddress = tunnel.StringOrInt(expand(t.LocalAddress.String())) t.RemoteAddress = tunnel.StringOrInt(expand(t.RemoteAddress.String())) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9de0522..e19c8fc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -35,6 +35,7 @@ func TestExpandEnvVars(t *testing.T) { t.Setenv("TEST_HOST", "example.com") t.Setenv("TEST_USER", "alice") t.Setenv("TEST_IDENTITY", "/keys/id_ed25519") + t.Setenv("TEST_PORT", "2222") t.Setenv("TEST_LOCAL", "9000") t.Setenv("TEST_REMOTE", "localhost:8080") @@ -50,6 +51,9 @@ func TestExpandEnvVars(t *testing.T) { if tun.IdentityFile != "/keys/id_ed25519" { t.Errorf("IdentityFile = %q, want %q", tun.IdentityFile, "/keys/id_ed25519") } + if tun.Port.String() != "2222" { + t.Errorf("Port = %q, want %q", tun.Port.String(), "2222") + } if tun.LocalAddress.String() != "9000" { t.Errorf("LocalAddress = %q, want %q", tun.LocalAddress.String(), "9000") } diff --git a/internal/ssh_config/ssh_config_test.go b/internal/ssh_config/ssh_config_test.go index e6a2cb0..1aa0011 100644 --- a/internal/ssh_config/ssh_config_test.go +++ b/internal/ssh_config/ssh_config_test.go @@ -125,3 +125,23 @@ func TestLoadIdentityMissing(t *testing.T) { t.Fatalf("expected failure, got s=%v fp=%q ok=%v", s, fp, ok) } } + +// When no Port is specified in the SSH config, the default of 22 must be used. +func TestParseSSHConfigDefaultPort(t *testing.T) { + cfg := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(cfg, []byte("Host myhost\n\tHostName example.com\n\tUser bob\n"), 0o600); err != nil { + t.Fatal(err) + } + + old := overrideConfig + overrideConfig = cfg + t.Cleanup(func() { overrideConfig = old }) + + sc, err := ParseSSHConfig("myhost", "bob") + if err != nil { + t.Fatal(err) + } + if sc.Port != 22 { + t.Errorf("Port = %d, want 22", sc.Port) + } +} diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index a5f53da..46c1fc1 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -31,7 +31,7 @@ type Desc struct { Host string `toml:"host" json:"host"` User string `toml:"user" json:"user"` IdentityFile string `toml:"identity" json:"identity"` - Port int `toml:"port" json:"port"` + Port StringOrInt `toml:"port" json:"port"` KeepAlive *int `toml:"keep_alive" json:"keep_alive"` Group string `toml:"group" json:"group"` Mode Mode `toml:"mode" json:"mode"` @@ -104,8 +104,10 @@ func (t *Tunnel) prepare() error { if t.User != "" { sc.User = t.User } - if t.Port != 0 { - sc.Port = t.Port + if t.Port != "" { + if sc.Port, err = strconv.Atoi(t.Port.String()); err != nil { + return fmt.Errorf("invalid port %q", t.Port) + } } if t.IdentityFile != "" { sc.IdentityFiles = []string{t.IdentityFile} diff --git a/test/testdata/config/expand/vars.toml b/test/testdata/config/expand/vars.toml index d6c1673..a0f62f8 100644 --- a/test/testdata/config/expand/vars.toml +++ b/test/testdata/config/expand/vars.toml @@ -1,8 +1,9 @@ -# Expanded against TEST_HOST, TEST_USER, TEST_IDENTITY, TEST_LOCAL, TEST_REMOTE +# Expanded against TEST_HOST, TEST_USER, TEST_IDENTITY, TEST_PORT, TEST_LOCAL, TEST_REMOTE [[tunnels]] name = "tun" host = "${TEST_HOST}" user = "$TEST_USER" identity = "${TEST_IDENTITY}" +port = "${TEST_PORT}" local = "${TEST_LOCAL}" remote = "${TEST_REMOTE}"