From ab93dc852eb0c2546ac4af702a923841a03e1d94 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 18:31:19 -0500 Subject: [PATCH 1/3] feat(port): accept hostnames in SSH port forwarding address parsing The devcontainer spec's forwardPorts property supports host:port format with hostnames (e.g. "db:5432"), but toAddress rejected anything that wasn't a literal IP or "localhost". Remove the net.ParseIP gate so hostnames pass through to be resolved at connection time. Also update the splitParts 3-part disambiguation to use a numeric check instead of net.ParseIP, allowing hostname-based container addresses. --- pkg/port/parse.go | 23 ++--- pkg/port/parse_test.go | 224 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 pkg/port/parse_test.go diff --git a/pkg/port/parse.go b/pkg/port/parse.go index 3e44f84ae..8c0057943 100644 --- a/pkg/port/parse.go +++ b/pkg/port/parse.go @@ -2,7 +2,6 @@ package port import ( "fmt" - "net" "strconv" "strings" ) @@ -39,26 +38,21 @@ func ParsePortSpec(port string) (Mapping, error) { }, nil } -func toAddress(ip, port string) (Address, error) { - // check if port is integer +func toAddress(host, port string) (Address, error) { _, err := strconv.Atoi(port) if err == nil { - if ip == "" { - ip = "localhost" - } - - if ip != "localhost" && net.ParseIP(ip) == nil { - return Address{}, fmt.Errorf("not an ip address %s", ip) + if host == "" { + host = "localhost" } return Address{ Protocol: "tcp", - Address: ip + ":" + port, + Address: host + ":" + port, }, nil } - if ip != "" { - return Address{}, fmt.Errorf("unexpected ip address for unix socket: %s", ip) + if host != "" { + return Address{}, fmt.Errorf("unexpected host for unix socket: %s", host) } return Address{ @@ -78,7 +72,10 @@ func splitParts(rawport string) (string, string, string, string, error) { case 2: return "", parts[0], "", containerport, nil case 3: - if parts[1] == "localhost" || net.ParseIP(parts[1]) != nil { + // a:b:c — if middle token is non-numeric, it's a host/IP for the + // container side: hostPort:containerHost:containerPort. + // Otherwise it's hostHost:hostPort:containerPort. + if _, err := strconv.Atoi(parts[1]); err != nil { return "", parts[0], parts[1], containerport, nil } diff --git a/pkg/port/parse_test.go b/pkg/port/parse_test.go new file mode 100644 index 000000000..9927c2014 --- /dev/null +++ b/pkg/port/parse_test.go @@ -0,0 +1,224 @@ +package port + +import ( + "testing" +) + +func TestParsePortSpec_BasicPorts(t *testing.T) { + tests := []struct { + name string + input string + want Mapping + }{ + { + name: "bare port", + input: "8080", + want: Mapping{ + Host: Address{Protocol: "tcp", Address: "localhost:8080"}, + Container: Address{Protocol: "tcp", Address: "localhost:8080"}, + }, + }, + { + name: "host port to container port", + input: "8080:3000", + want: Mapping{ + Host: Address{Protocol: "tcp", Address: "localhost:8080"}, + Container: Address{Protocol: "tcp", Address: "localhost:3000"}, + }, + }, + { + name: "unix socket", + input: "/var/run/app.sock", + want: Mapping{ + Host: Address{Protocol: "unix", Address: "/var/run/app.sock"}, + Container: Address{Protocol: "unix", Address: "/var/run/app.sock"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePortSpec(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestParsePortSpec_Hostnames(t *testing.T) { + tests := []struct { + name string + input string + want Mapping + }{ + { + name: "IP host three-part", + input: "127.0.0.1:8080:3000", + want: Mapping{ + Host: Address{Protocol: "tcp", Address: "127.0.0.1:8080"}, + Container: Address{Protocol: "tcp", Address: "localhost:3000"}, + }, + }, + { + name: "localhost explicit three-part", + input: "localhost:8080:3000", + want: Mapping{ + Host: Address{Protocol: "tcp", Address: "localhost:8080"}, + Container: Address{Protocol: "tcp", Address: "localhost:3000"}, + }, + }, + { + name: "hostname host", + input: "database.internal:5432:5432", + want: Mapping{ + Host: Address{Protocol: "tcp", Address: "database.internal:5432"}, + Container: Address{Protocol: "tcp", Address: "localhost:5432"}, + }, + }, + { + name: "container hostname in three-part spec", + input: "8080:redis:6379", + want: Mapping{ + Host: Address{Protocol: "tcp", Address: "localhost:8080"}, + Container: Address{Protocol: "tcp", Address: "redis:6379"}, + }, + }, + { + name: "four-part full spec with hostnames", + input: "myhost:8080:mycontainer:3000", + want: Mapping{ + Host: Address{Protocol: "tcp", Address: "myhost:8080"}, + Container: Address{Protocol: "tcp", Address: "mycontainer:3000"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePortSpec(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestParsePortSpec_Errors(t *testing.T) { + _, err := ParsePortSpec("a:b:c:d:e") + if err == nil { + t.Fatal("expected error for too many parts") + } +} + +func TestToAddress_TCP(t *testing.T) { + tests := []struct { + name string + host string + port string + want Address + }{ + { + "empty host defaults to localhost", + "", + "8080", + Address{Protocol: "tcp", Address: "localhost:8080"}, + }, + {"localhost", "localhost", "3000", Address{Protocol: "tcp", Address: "localhost:3000"}}, + {"IP address", "192.168.1.1", "443", Address{Protocol: "tcp", Address: "192.168.1.1:443"}}, + { + "hostname", + "database.internal", + "5432", + Address{Protocol: "tcp", Address: "database.internal:5432"}, + }, + {"short hostname", "db", "5432", Address{Protocol: "tcp", Address: "db:5432"}}, + {"IPv6 address", "::1", "8080", Address{Protocol: "tcp", Address: "::1:8080"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toAddress(tt.host, tt.port) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestToAddress_Unix(t *testing.T) { + got, err := toAddress("", "/var/run/app.sock") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := Address{Protocol: "unix", Address: "/var/run/app.sock"} + if got != want { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestToAddress_HostWithUnixErrors(t *testing.T) { + _, err := toAddress("myhost", "/var/run/app.sock") + if err == nil { + t.Fatal("expected error for host with unix socket") + } +} + +func TestSplitParts(t *testing.T) { + type result struct{ hostIP, hostPort, contIP, contPort string } + + tests := []struct { + name string + input string + want result + }{ + {"single port", "8080", result{"", "8080", "", "8080"}}, + {"two parts", "8080:3000", result{"", "8080", "", "3000"}}, + {"three parts numeric middle", "myhost:8080:3000", result{"myhost", "8080", "", "3000"}}, + {"three parts hostname middle", "8080:redis:6379", result{"", "8080", "redis", "6379"}}, + { + "three parts IP middle", + "8080:192.168.1.1:3000", + result{"", "8080", "192.168.1.1", "3000"}, + }, + { + "three parts localhost middle", + "8080:localhost:3000", + result{"", "8080", "localhost", "3000"}, + }, + { + "four parts", + "myhost:8080:mycontainer:3000", + result{"myhost", "8080", "mycontainer", "3000"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hostIP, hostPort, contIP, contPort, err := splitParts(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := result{hostIP, hostPort, contIP, contPort} + if got != tt.want { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestSplitParts_Errors(t *testing.T) { + _, _, _, _, err := splitParts("a:b:c:d:e") + if err == nil { + t.Fatal("expected error for five parts") + } +} From e0df74f851ada567262788ce5200346a9156f613 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 18:43:02 -0500 Subject: [PATCH 2/3] test(e2e): add e2e test for SSH port forwarding with remote service names Verify that `--forward-ports :nginx:8080` correctly forwards traffic through a hostname-addressed compose service, exercising the hostname support added in the previous commit. --- .../up-docker-compose/up_docker_compose.go | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/e2e/tests/up-docker-compose/up_docker_compose.go b/e2e/tests/up-docker-compose/up_docker_compose.go index 7a98f8f59..171e0c407 100644 --- a/e2e/tests/up-docker-compose/up_docker_compose.go +++ b/e2e/tests/up-docker-compose/up_docker_compose.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "net" "net/http" "os" "os/exec" @@ -160,6 +161,77 @@ var _ = ginkgo.Describe( )) }, ginkgo.SpecTimeout(framework.TimeoutLong())) + ginkgo.It("ssh forward ports support remote service names", func(ctx context.Context) { + _, workspace, err := tc.setupAndStartWorkspace( + ctx, + "tests/up-docker-compose/testdata/docker-compose-forward-ports", + "--debug", + ) + framework.ExpectNoError(err) + + ids, err := findComposeContainer( + ctx, + tc.dockerHelper, + tc.composeHelper, + workspace.UID, + "app", + ) + framework.ExpectNoError(err) + gomega.Expect(ids).To(gomega.HaveLen(1), "1 compose container to be created") + + listener, err := net.Listen("tcp", "127.0.0.1:0") + framework.ExpectNoError(err) + localPort := listener.Addr().(*net.TCPAddr).Port + framework.ExpectNoError(listener.Close()) + + done := make(chan error) + sshContext, sshCancel := context.WithCancel(context.Background()) + go func() { + // #nosec G204 -- test command with controlled arguments + cmd := exec.CommandContext( + sshContext, + filepath.Join(tc.f.DevsyBinDir, tc.f.DevsyBinName), + "ssh", + "--forward-ports", + fmt.Sprintf("%d:nginx:8080", localPort), + workspace.ID, + ) + + if err := cmd.Start(); err != nil { + done <- err + return + } + + if err := cmd.Wait(); err != nil { + done <- err + return + } + + done <- nil + }() + + gomega.Eventually(func(g gomega.Gomega) { + response, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d", localPort)) + g.Expect(err).NotTo(gomega.HaveOccurred()) + defer func() { _ = response.Body.Close() }() + + body, err := io.ReadAll(response.Body) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(body).To(gomega.ContainSubstring("Thank you for using nginx.")) + }). + WithPolling(1 * time.Second). + WithTimeout(20 * time.Second). + Should(gomega.Succeed()) + + sshCancel() + err = <-done + + gomega.Expect(err).To(gomega.Or( + gomega.MatchError("signal: killed"), + gomega.MatchError(context.Canceled), + )) + }, ginkgo.SpecTimeout(framework.TimeoutLong())) + ginkgo.It("features", func(ctx context.Context) { tempDir, workspace, err := tc.setupAndStartWorkspace( ctx, From eb87fe3088bbc161580eb5f50f303e4237369db9 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 18:51:35 -0500 Subject: [PATCH 3/3] fix(port): resolve goconst lint violations in port parsing Extract repeated string literals into package-level constants to satisfy the goconst linter for new code introduced in the hostname support commit. --- pkg/port/parse.go | 4 ++- pkg/port/parse_test.go | 66 +++++++++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/pkg/port/parse.go b/pkg/port/parse.go index 8c0057943..bccd840de 100644 --- a/pkg/port/parse.go +++ b/pkg/port/parse.go @@ -6,6 +6,8 @@ import ( "strings" ) +const defaultHost = "localhost" + type Address struct { Protocol string Address string @@ -42,7 +44,7 @@ func toAddress(host, port string) (Address, error) { _, err := strconv.Atoi(port) if err == nil { if host == "" { - host = "localhost" + host = defaultHost } return Address{ diff --git a/pkg/port/parse_test.go b/pkg/port/parse_test.go index 9927c2014..44900e04f 100644 --- a/pkg/port/parse_test.go +++ b/pkg/port/parse_test.go @@ -4,6 +4,15 @@ import ( "testing" ) +const ( + protoTCP = "tcp" + protoUnix = "unix" + + addrLocalhost8080 = "localhost:8080" + addrLocalhost3000 = "localhost:3000" + testUnixSocket = "/var/run/app.sock" +) + func TestParsePortSpec_BasicPorts(t *testing.T) { tests := []struct { name string @@ -14,24 +23,24 @@ func TestParsePortSpec_BasicPorts(t *testing.T) { name: "bare port", input: "8080", want: Mapping{ - Host: Address{Protocol: "tcp", Address: "localhost:8080"}, - Container: Address{Protocol: "tcp", Address: "localhost:8080"}, + Host: Address{Protocol: protoTCP, Address: addrLocalhost8080}, + Container: Address{Protocol: protoTCP, Address: addrLocalhost8080}, }, }, { name: "host port to container port", input: "8080:3000", want: Mapping{ - Host: Address{Protocol: "tcp", Address: "localhost:8080"}, - Container: Address{Protocol: "tcp", Address: "localhost:3000"}, + Host: Address{Protocol: protoTCP, Address: addrLocalhost8080}, + Container: Address{Protocol: protoTCP, Address: addrLocalhost3000}, }, }, { name: "unix socket", - input: "/var/run/app.sock", + input: testUnixSocket, want: Mapping{ - Host: Address{Protocol: "unix", Address: "/var/run/app.sock"}, - Container: Address{Protocol: "unix", Address: "/var/run/app.sock"}, + Host: Address{Protocol: protoUnix, Address: testUnixSocket}, + Container: Address{Protocol: protoUnix, Address: testUnixSocket}, }, }, } @@ -59,40 +68,40 @@ func TestParsePortSpec_Hostnames(t *testing.T) { name: "IP host three-part", input: "127.0.0.1:8080:3000", want: Mapping{ - Host: Address{Protocol: "tcp", Address: "127.0.0.1:8080"}, - Container: Address{Protocol: "tcp", Address: "localhost:3000"}, + Host: Address{Protocol: protoTCP, Address: "127.0.0.1:8080"}, + Container: Address{Protocol: protoTCP, Address: addrLocalhost3000}, }, }, { name: "localhost explicit three-part", input: "localhost:8080:3000", want: Mapping{ - Host: Address{Protocol: "tcp", Address: "localhost:8080"}, - Container: Address{Protocol: "tcp", Address: "localhost:3000"}, + Host: Address{Protocol: protoTCP, Address: addrLocalhost8080}, + Container: Address{Protocol: protoTCP, Address: addrLocalhost3000}, }, }, { name: "hostname host", input: "database.internal:5432:5432", want: Mapping{ - Host: Address{Protocol: "tcp", Address: "database.internal:5432"}, - Container: Address{Protocol: "tcp", Address: "localhost:5432"}, + Host: Address{Protocol: protoTCP, Address: "database.internal:5432"}, + Container: Address{Protocol: protoTCP, Address: "localhost:5432"}, }, }, { name: "container hostname in three-part spec", input: "8080:redis:6379", want: Mapping{ - Host: Address{Protocol: "tcp", Address: "localhost:8080"}, - Container: Address{Protocol: "tcp", Address: "redis:6379"}, + Host: Address{Protocol: protoTCP, Address: addrLocalhost8080}, + Container: Address{Protocol: protoTCP, Address: "redis:6379"}, }, }, { name: "four-part full spec with hostnames", input: "myhost:8080:mycontainer:3000", want: Mapping{ - Host: Address{Protocol: "tcp", Address: "myhost:8080"}, - Container: Address{Protocol: "tcp", Address: "mycontainer:3000"}, + Host: Address{Protocol: protoTCP, Address: "myhost:8080"}, + Container: Address{Protocol: protoTCP, Address: "mycontainer:3000"}, }, }, } @@ -128,18 +137,21 @@ func TestToAddress_TCP(t *testing.T) { "empty host defaults to localhost", "", "8080", - Address{Protocol: "tcp", Address: "localhost:8080"}, + Address{Protocol: protoTCP, Address: addrLocalhost8080}, + }, + {defaultHost, defaultHost, "3000", Address{Protocol: protoTCP, Address: addrLocalhost3000}}, + { + "IP address", "192.168.1.1", "443", + Address{Protocol: protoTCP, Address: "192.168.1.1:443"}, }, - {"localhost", "localhost", "3000", Address{Protocol: "tcp", Address: "localhost:3000"}}, - {"IP address", "192.168.1.1", "443", Address{Protocol: "tcp", Address: "192.168.1.1:443"}}, { "hostname", "database.internal", "5432", - Address{Protocol: "tcp", Address: "database.internal:5432"}, + Address{Protocol: protoTCP, Address: "database.internal:5432"}, }, - {"short hostname", "db", "5432", Address{Protocol: "tcp", Address: "db:5432"}}, - {"IPv6 address", "::1", "8080", Address{Protocol: "tcp", Address: "::1:8080"}}, + {"short hostname", "db", "5432", Address{Protocol: protoTCP, Address: "db:5432"}}, + {"IPv6 address", "::1", "8080", Address{Protocol: protoTCP, Address: "::1:8080"}}, } for _, tt := range tests { @@ -156,18 +168,18 @@ func TestToAddress_TCP(t *testing.T) { } func TestToAddress_Unix(t *testing.T) { - got, err := toAddress("", "/var/run/app.sock") + got, err := toAddress("", testUnixSocket) if err != nil { t.Fatalf("unexpected error: %v", err) } - want := Address{Protocol: "unix", Address: "/var/run/app.sock"} + want := Address{Protocol: protoUnix, Address: testUnixSocket} if got != want { t.Errorf("got %+v, want %+v", got, want) } } func TestToAddress_HostWithUnixErrors(t *testing.T) { - _, err := toAddress("myhost", "/var/run/app.sock") + _, err := toAddress("myhost", testUnixSocket) if err == nil { t.Fatal("expected error for host with unix socket") } @@ -193,7 +205,7 @@ func TestSplitParts(t *testing.T) { { "three parts localhost middle", "8080:localhost:3000", - result{"", "8080", "localhost", "3000"}, + result{"", "8080", defaultHost, "3000"}, }, { "four parts",