diff --git a/internal/config/config.go b/internal/config/config.go index ec5d6da..ead9f11 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,18 @@ func Load() (*Config, error) { } } + // 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 { + t := &cfg.Tunnels[i] + 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())) + } + // Create a map of tunnel names to tunnel pointers for easy lookup later m, err := buildTunnelsMap(cfg.Tunnels) if err != nil { @@ -127,3 +139,16 @@ 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 varName, defaultVal, found := strings.Cut(key, ":-"); found { + if val := os.Getenv(varName); val != "" { + return val + } + return defaultVal + } + return os.Getenv(key) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1d6d56d..e19c8fc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -19,3 +19,88 @@ 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_PORT", "2222") + 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.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") + } + 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/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/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..a0f62f8 --- /dev/null +++ b/test/testdata/config/expand/vars.toml @@ -0,0 +1,9 @@ +# 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}"