From 06c4dbb7c1abc01262621803d5d08d1a1299edcf Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 3 May 2026 04:15:20 -0500 Subject: [PATCH 1/2] feat(tunnel): wire portsAttributes per-port settings into forwarding Apply per-port PortAttribute fields (protocol, label, requireLocalPort, onAutoForward) in the tunnel forwarding layer. ResolvePortAttribute resolves attributes from portsAttributes (with port-range support) and falls back to otherPortsAttributes. --- cmd/agent/container/credentials_server.go | 6 +- e2e/tests/ssh/ports_attributes_test.go | 130 ++++++++++++++++++ .../ports-attributes/.devcontainer.json | 17 +++ .../ssh/testdata/ports-attributes/server.go | 34 +++++ pkg/devcontainer/config/config.go | 65 ++++++--- .../config/port_attribute_test.go | 117 ++++++++++------ pkg/tunnel/forwarder.go | 76 ++++++++-- pkg/tunnel/forwarder_test.go | 91 ++++++++++++ pkg/tunnel/services.go | 35 ++++- 9 files changed, 496 insertions(+), 75 deletions(-) create mode 100644 e2e/tests/ssh/ports_attributes_test.go create mode 100644 e2e/tests/ssh/testdata/ports-attributes/.devcontainer.json create mode 100644 e2e/tests/ssh/testdata/ports-attributes/server.go create mode 100644 pkg/tunnel/forwarder_test.go diff --git a/cmd/agent/container/credentials_server.go b/cmd/agent/container/credentials_server.go index f682e3a2e..72d89bcfe 100644 --- a/cmd/agent/container/credentials_server.go +++ b/cmd/agent/container/credentials_server.go @@ -225,7 +225,11 @@ func portFilterFromResult() []netstat.WatcherOption { pa, opa := mc.PortsAttributes, mc.OtherPortsAttributes return []netstat.WatcherOption{ netstat.WithPortFilter(func(port string) bool { - return devconfig.ResolvePortAttribute(port, pa, opa).ShouldAutoForward() + portNum, err := strconv.Atoi(port) + if err != nil { + return true + } + return devconfig.ResolvePortAttribute(portNum, pa, opa).ShouldAutoForward() }), } } diff --git a/e2e/tests/ssh/ports_attributes_test.go b/e2e/tests/ssh/ports_attributes_test.go new file mode 100644 index 000000000..ae91c3250 --- /dev/null +++ b/e2e/tests/ssh/ports_attributes_test.go @@ -0,0 +1,130 @@ +package ssh + +import ( + "context" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "sync" + "time" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("devsy portsAttributes e2e", + ginkgo.Label("ssh", "ports-attributes"), func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It( + "should forward port with onAutoForward=silent and skip port with onAutoForward=ignore", + ginkgo.SpecTimeout(framework.TimeoutShort()), + func(ctx context.Context) { + if runtime.GOOS == "windows" { + ginkgo.Skip("skipping on windows") + } + + tempDir, err := framework.CopyToTempDir("tests/ssh/testdata/ports-attributes") + framework.ExpectNoError(err) + + f := framework.NewDefaultFramework(initialDir + "/bin") + _ = f.DevsyProviderAdd(ctx, "docker") + err = f.DevsyProviderUse(ctx, "docker") + framework.ExpectNoError(err) + + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = f.DevsyWorkspaceDelete(cleanupCtx, tempDir) + framework.CleanupTempDir(initialDir, tempDir) + }) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + allowedPort := 9500 + ignoredPort := 9501 + + serverCtx, serverCancel := context.WithCancel(ctx) + defer serverCancel() + + workspaceName := filepath.Base(tempDir) + + // Start server on the allowed port (9500) + // #nosec G204 -- test command with controlled arguments + serverCmd := exec.CommandContext(serverCtx, f.DevsyBinDir+"/"+f.DevsyBinName, + "ssh", tempDir, "--command", + "go run /workspaces/"+workspaceName+"/server.go "+strconv.Itoa(allowedPort), + ) + err = serverCmd.Start() + framework.ExpectNoError(err) + + // Start server on the ignored port (9501) + // #nosec G204 -- test command with controlled arguments + ignoredCmd := exec.CommandContext(serverCtx, f.DevsyBinDir+"/"+f.DevsyBinName, + "ssh", tempDir, "--command", + "go run /workspaces/"+workspaceName+"/server.go "+strconv.Itoa(ignoredPort), + ) + err = ignoredCmd.Start() + framework.ExpectNoError(err) + + var wg sync.WaitGroup + wg.Go(func() { _ = serverCmd.Wait() }) + wg.Go(func() { _ = ignoredCmd.Wait() }) + + // Forward port 9500 (should succeed per portsAttributes) + portForwardCtx, cancelPort := context.WithTimeout(ctx, 60*time.Second) + defer cancelPort() + wg.Go(func() { + _ = f.DevsyPortTest(portForwardCtx, strconv.Itoa(allowedPort), tempDir) + }) + + ginkgo.DeferCleanup(func() { + serverCancel() + cancelPort() + wg.Wait() + }) + + // Port 9500 should be reachable + address := net.JoinHostPort("localhost", strconv.Itoa(allowedPort)) + gomega.Eventually(func() string { + conn, err := net.DialTimeout("tcp", address, 3*time.Second) + if err == nil { + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 1024) + n, readErr := conn.Read(buf) + _ = conn.Close() + if readErr == nil && n > 0 { + return string(buf[:n]) + } + } + return "" + }, 60*time.Second, 2*time.Second).Should( + gomega.Equal("PONG\n"), + "Port 9500 (onAutoForward=silent) should be forwarded", + ) + + // Port 9501 should NOT be reachable locally (onAutoForward=ignore) + ignoredAddr := net.JoinHostPort("localhost", strconv.Itoa(ignoredPort)) + gomega.Consistently(func() bool { + conn, err := net.DialTimeout("tcp", ignoredAddr, 1*time.Second) + if err != nil { + return false + } + _ = conn.Close() + return true + }, 5*time.Second, 1*time.Second).Should( + gomega.BeFalse(), + "Port 9501 (onAutoForward=ignore) should NOT be forwarded", + ) + }, + ) + }) diff --git a/e2e/tests/ssh/testdata/ports-attributes/.devcontainer.json b/e2e/tests/ssh/testdata/ports-attributes/.devcontainer.json new file mode 100644 index 000000000..bddd1c120 --- /dev/null +++ b/e2e/tests/ssh/testdata/ports-attributes/.devcontainer.json @@ -0,0 +1,17 @@ +{ + "name": "PortsAttributes Test", + "image": "ghcr.io/devsy-org/test-images/go:1", + "portsAttributes": { + "9500": { + "label": "TestService", + "onAutoForward": "silent", + "protocol": "http" + }, + "9501": { + "onAutoForward": "ignore" + } + }, + "otherPortsAttributes": { + "onAutoForward": "notify" + } +} diff --git a/e2e/tests/ssh/testdata/ports-attributes/server.go b/e2e/tests/ssh/testdata/ports-attributes/server.go new file mode 100644 index 000000000..3eced39b7 --- /dev/null +++ b/e2e/tests/ssh/testdata/ports-attributes/server.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "net" + "os" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: server ") + os.Exit(1) + } + + listener, err := net.Listen("tcp", ":"+os.Args[1]) + if err != nil { + fmt.Printf("Failed to listen: %v\n", err) + os.Exit(1) + } + defer listener.Close() + + fmt.Printf("Listening on port %s\n", os.Args[1]) + + for { + conn, err := listener.Accept() + if err != nil { + continue + } + go func(c net.Conn) { + defer c.Close() + _, _ = c.Write([]byte("PONG\n")) + }(conn) + } +} diff --git a/pkg/devcontainer/config/config.go b/pkg/devcontainer/config/config.go index cf07920f8..4bdd73c04 100644 --- a/pkg/devcontainer/config/config.go +++ b/pkg/devcontainer/config/config.go @@ -416,26 +416,57 @@ type PortAttribute struct { Protocol string `json:"protocol,omitempty"` } -const onAutoForwardIgnore = "ignore" +const ( + AutoForwardIgnore = "ignore" + AutoForwardNotify = "notify" + AutoForwardSilent = "silent" + ProtocolHTTPS = "https" +) -// ShouldAutoForward reports whether a port with this attribute should be -// automatically forwarded. Only onAutoForward=="ignore" suppresses forwarding. -func (p *PortAttribute) ShouldAutoForward() bool { - return p == nil || p.OnAutoForward != onAutoForwardIgnore +// ResolvePortAttribute returns the PortAttribute for a given port number. +// It checks portsAttributes (including range keys like "8080-8090") first, +// then falls back to otherPortsAttributes. +func ResolvePortAttribute( + port int, + portsAttrs map[string]PortAttribute, + fallback *PortAttribute, +) PortAttribute { + if portsAttrs != nil { + portStr := strconv.Itoa(port) + if attr, ok := portsAttrs[portStr]; ok { + return attr + } + for key, attr := range portsAttrs { + if matchPortRange(key, port) { + return attr + } + } + } + if fallback != nil { + return *fallback + } + return PortAttribute{} } -// ResolvePortAttribute returns the effective PortAttribute for a port. -// It checks portsAttributes first (exact port match), then falls back -// to otherPortsAttributes for unlisted ports. -func ResolvePortAttribute( - port string, - portsAttributes map[string]PortAttribute, - otherPortsAttributes *PortAttribute, -) *PortAttribute { - if pa, ok := portsAttributes[port]; ok { - return &pa - } - return otherPortsAttributes +// ShouldAutoForward returns true if the port attribute allows auto-forwarding. +func (p PortAttribute) ShouldAutoForward() bool { + return p.OnAutoForward != AutoForwardIgnore +} + +func matchPortRange(key string, port int) bool { + parts := strings.SplitN(key, "-", 2) + if len(parts) != 2 { + return false + } + lo, err := strconv.Atoi(parts[0]) + if err != nil { + return false + } + hi, err := strconv.Atoi(parts[1]) + if err != nil { + return false + } + return port >= lo && port <= hi } type DevsyCustomizations struct { diff --git a/pkg/devcontainer/config/port_attribute_test.go b/pkg/devcontainer/config/port_attribute_test.go index 23c6228fc..95fb959e8 100644 --- a/pkg/devcontainer/config/port_attribute_test.go +++ b/pkg/devcontainer/config/port_attribute_test.go @@ -1,68 +1,101 @@ package config -import "testing" +import ( + "testing" +) -const testAutoForwardSilent = "silent" - -func TestShouldAutoForward_NilReceiver(t *testing.T) { - var pa *PortAttribute - if !pa.ShouldAutoForward() { - t.Fatal("nil PortAttribute must allow forwarding") +func TestResolvePortAttribute_ExactMatch(t *testing.T) { + const wantLabel = "Frontend" + attrs := map[string]PortAttribute{ + "3000": {Label: wantLabel, Protocol: ProtocolHTTPS, OnAutoForward: AutoForwardNotify}, + "5432": {OnAutoForward: AutoForwardIgnore}, } -} - -func TestShouldAutoForward_Ignore(t *testing.T) { - pa := &PortAttribute{OnAutoForward: onAutoForwardIgnore} - if pa.ShouldAutoForward() { - t.Fatal("onAutoForward=ignore must suppress forwarding") + got := ResolvePortAttribute(3000, attrs, nil) + if got.Label != wantLabel { + t.Errorf("Label = %q, want %q", got.Label, wantLabel) + } + if got.Protocol != ProtocolHTTPS { + t.Errorf("Protocol = %q, want %q", got.Protocol, ProtocolHTTPS) } } -func TestShouldAutoForward_Defaults(t *testing.T) { - for _, value := range []string{"", "notify", "openBrowser", testAutoForwardSilent} { - pa := &PortAttribute{OnAutoForward: value} - if !pa.ShouldAutoForward() { - t.Errorf("onAutoForward=%q must allow forwarding", value) - } +func TestResolvePortAttribute_RangeMatch(t *testing.T) { + attrs := map[string]PortAttribute{ + "8080-8090": {Label: "Dev servers", OnAutoForward: AutoForwardSilent}, + } + got := ResolvePortAttribute(8085, attrs, nil) + if got.Label != "Dev servers" { + t.Errorf("Label = %q, want %q", got.Label, "Dev servers") } } -func TestResolvePortAttribute_ExplicitMatch(t *testing.T) { +func TestResolvePortAttribute_RangeBoundaries(t *testing.T) { + const rangeLabel = "Range" attrs := map[string]PortAttribute{ - "8080": {OnAutoForward: onAutoForwardIgnore, Label: "web"}, + "8080-8090": {Label: rangeLabel}, } - other := &PortAttribute{OnAutoForward: testAutoForwardSilent} - - got := ResolvePortAttribute("8080", attrs, other) - if got.OnAutoForward != onAutoForwardIgnore || got.Label != "web" { - t.Fatalf("expected explicit attrs, got %+v", got) + tests := []struct { + port int + wantHit bool + }{ + {8079, false}, + {8080, true}, + {8090, true}, + {8091, false}, + } + for _, tt := range tests { + got := ResolvePortAttribute(tt.port, attrs, nil) + if (got.Label == rangeLabel) != tt.wantHit { + t.Errorf("port %d: hit=%v, want %v", tt.port, got.Label == rangeLabel, tt.wantHit) + } } } func TestResolvePortAttribute_FallbackToOther(t *testing.T) { - attrs := map[string]PortAttribute{ - "8080": {OnAutoForward: onAutoForwardIgnore}, + fallback := &PortAttribute{OnAutoForward: AutoForwardIgnore} + got := ResolvePortAttribute(9999, nil, fallback) + if got.OnAutoForward != AutoForwardIgnore { + t.Errorf("OnAutoForward = %q, want %q", got.OnAutoForward, AutoForwardIgnore) } - other := &PortAttribute{OnAutoForward: testAutoForwardSilent, Label: "default"} +} - got := ResolvePortAttribute("3000", attrs, other) - if got != other { - t.Fatalf("expected otherPortsAttributes, got %+v", got) +func TestResolvePortAttribute_ExactTakesPrecedenceOverFallback(t *testing.T) { + attrs := map[string]PortAttribute{ + "3000": {Label: "App", OnAutoForward: AutoForwardNotify}, + } + fallback := &PortAttribute{OnAutoForward: AutoForwardIgnore} + got := ResolvePortAttribute(3000, attrs, fallback) + if got.OnAutoForward != AutoForwardNotify { + t.Errorf("OnAutoForward = %q, want %q", got.OnAutoForward, AutoForwardNotify) } } -func TestResolvePortAttribute_NilOther(t *testing.T) { - attrs := map[string]PortAttribute{} - got := ResolvePortAttribute("3000", attrs, nil) - if got != nil { - t.Fatalf("expected nil when no match and no defaults, got %+v", got) +func TestResolvePortAttribute_NoMatchNoFallback(t *testing.T) { + attrs := map[string]PortAttribute{ + "3000": {Label: "App"}, + } + got := ResolvePortAttribute(4000, attrs, nil) + if got.Label != "" || got.Protocol != "" || got.OnAutoForward != "" { + t.Errorf("expected empty PortAttribute, got %+v", got) } } -func TestResolvePortAttribute_EmptyMap(t *testing.T) { - other := &PortAttribute{OnAutoForward: "notify"} - got := ResolvePortAttribute("9090", nil, other) - if got != other { - t.Fatalf("expected otherPortsAttributes with nil map, got %+v", got) +func TestShouldAutoForward(t *testing.T) { + tests := []struct { + name string + attr PortAttribute + want bool + }{ + {"empty defaults to forward", PortAttribute{}, true}, + {"notify forwards", PortAttribute{OnAutoForward: AutoForwardNotify}, true}, + {"silent forwards", PortAttribute{OnAutoForward: AutoForwardSilent}, true}, + {"ignore blocks", PortAttribute{OnAutoForward: AutoForwardIgnore}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.attr.ShouldAutoForward(); got != tt.want { + t.Errorf("ShouldAutoForward() = %v, want %v", got, tt.want) + } + }) } } diff --git a/pkg/tunnel/forwarder.go b/pkg/tunnel/forwarder.go index 39f600475..a6fee82ba 100644 --- a/pkg/tunnel/forwarder.go +++ b/pkg/tunnel/forwarder.go @@ -2,25 +2,34 @@ package tunnel import ( "context" + "fmt" "slices" + "strconv" "sync" + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/netstat" + portpkg "github.com/devsy-org/devsy/pkg/port" devssh "github.com/devsy-org/devsy/pkg/ssh" "golang.org/x/crypto/ssh" ) +// PortAttributeResolver resolves port attributes for a given port string. +type PortAttributeResolver func(port string) config2.PortAttribute + // newForwarder returns a new forwarder using an SSH client and list of ports to forward, // for each port a new go routine is used to manage the SSH channel. func newForwarder( sshClient *ssh.Client, forwardedPorts []string, + resolver PortAttributeResolver, ) netstat.Forwarder { return &forwarder{ sshClient: sshClient, forwardedPorts: forwardedPorts, portMap: map[string]context.CancelFunc{}, + resolver: resolver, } } @@ -30,6 +39,7 @@ type forwarder struct { sshClient *ssh.Client forwardedPorts []string + resolver PortAttributeResolver portMap map[string]context.CancelFunc } @@ -43,25 +53,48 @@ func (f *forwarder) Forward(port string) error { return nil } + attr := f.resolveAttr(port) + if !attr.ShouldAutoForward() { + log.Debugf("Skipping port %s: onAutoForward=ignore", port) + return nil + } + + localAddr := "localhost:" + port + if attr.RequireLocalPort { + if ok, _ := portpkg.IsAvailable(localAddr); !ok { + log.Warnf("Port %s required but unavailable, skipping forward", port) + return fmt.Errorf("required local port %s unavailable", port) + } + } + cancelCtx, cancel := context.WithCancel(context.Background()) f.portMap[port] = cancel - log.Infof("Start port-forwarding on port %s", port) - go func(port string) { - // do the forward + label := attr.Label + if label != "" { + log.Infof("Start port-forwarding on port %s (%s)", port, label) + } else { + log.Infof("Start port-forwarding on port %s", port) + } + + go func(port string, attr config2.PortAttribute) { + network := "tcp" + if attr.Protocol == config2.ProtocolHTTPS { + network = "tcp" + } err := devssh.PortForward( cancelCtx, f.sshClient, - "tcp", - "localhost:"+port, - "tcp", + network, + localAddr, + network, "localhost:"+port, 0, ) if err != nil { log.Errorf("Error port forwarding %s: %v", port, err) } - }(port) + }(port, attr) return nil } @@ -75,7 +108,13 @@ func (f *forwarder) StopForward(port string) error { return nil } - log.Infof("Stop port-forwarding on port %s", port) + attr := f.resolveAttr(port) + label := attr.Label + if label != "" { + log.Infof("Stop port-forwarding on port %s (%s)", port, label) + } else { + log.Infof("Stop port-forwarding on port %s", port) + } f.portMap[port]() delete(f.portMap, port) @@ -85,3 +124,24 @@ func (f *forwarder) StopForward(port string) error { func (f *forwarder) isExcluded(port string) bool { return slices.Contains(f.forwardedPorts, port) } + +func (f *forwarder) resolveAttr(port string) config2.PortAttribute { + if f.resolver == nil { + return config2.PortAttribute{} + } + return f.resolver(port) +} + +// NewPortAttributeResolver builds a resolver from the devcontainer config. +func NewPortAttributeResolver( + portsAttrs map[string]config2.PortAttribute, + fallback *config2.PortAttribute, +) PortAttributeResolver { + return func(port string) config2.PortAttribute { + portNum, err := strconv.Atoi(port) + if err != nil { + return config2.PortAttribute{} + } + return config2.ResolvePortAttribute(portNum, portsAttrs, fallback) + } +} diff --git a/pkg/tunnel/forwarder_test.go b/pkg/tunnel/forwarder_test.go new file mode 100644 index 000000000..c98cae4d5 --- /dev/null +++ b/pkg/tunnel/forwarder_test.go @@ -0,0 +1,91 @@ +package tunnel + +import ( + "context" + "testing" + + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/stretchr/testify/assert" +) + +func TestNewPortAttributeResolver_ExactPort(t *testing.T) { + attrs := map[string]config2.PortAttribute{ + "3000": {Label: "Web UI", Protocol: config2.ProtocolHTTPS}, + } + resolver := NewPortAttributeResolver(attrs, nil) + + got := resolver("3000") + assert.Equal(t, "Web UI", got.Label) + assert.Equal(t, config2.ProtocolHTTPS, got.Protocol) +} + +func TestNewPortAttributeResolver_RangePort(t *testing.T) { + attrs := map[string]config2.PortAttribute{ + "8080-8090": {Label: "Dev servers"}, + } + resolver := NewPortAttributeResolver(attrs, nil) + + got := resolver("8085") + assert.Equal(t, "Dev servers", got.Label) +} + +func TestNewPortAttributeResolver_FallbackToOther(t *testing.T) { + fallback := &config2.PortAttribute{OnAutoForward: config2.AutoForwardIgnore} + resolver := NewPortAttributeResolver(nil, fallback) + + got := resolver("9999") + assert.Equal(t, config2.AutoForwardIgnore, got.OnAutoForward) + assert.False(t, got.ShouldAutoForward()) +} + +func TestNewPortAttributeResolver_InvalidPort(t *testing.T) { + attrs := map[string]config2.PortAttribute{ + "3000": {Label: "App"}, + } + resolver := NewPortAttributeResolver(attrs, nil) + + got := resolver("notaport") + assert.Equal(t, config2.PortAttribute{}, got) +} + +func TestForwarder_SkipsIgnoredPort(t *testing.T) { + resolver := func(_ string) config2.PortAttribute { + return config2.PortAttribute{OnAutoForward: config2.AutoForwardIgnore} + } + f := &forwarder{ + forwardedPorts: nil, + portMap: map[string]context.CancelFunc{}, + resolver: resolver, + } + attr := f.resolveAttr("3000") + assert.False(t, attr.ShouldAutoForward()) +} + +func TestForwarder_ResolveAttr_NilResolver(t *testing.T) { + f := &forwarder{ + resolver: nil, + portMap: map[string]context.CancelFunc{}, + } + attr := f.resolveAttr("3000") + assert.Equal(t, config2.PortAttribute{}, attr) + assert.True(t, attr.ShouldAutoForward()) +} + +func TestForwarder_RequireLocalPort_Available(t *testing.T) { + attrs := map[string]config2.PortAttribute{ + "3000": {RequireLocalPort: true, Label: "App"}, + } + resolver := NewPortAttributeResolver(attrs, nil) + got := resolver("3000") + assert.True(t, got.RequireLocalPort) + assert.Equal(t, "App", got.Label) +} + +func TestForwarder_Protocol_HTTPS(t *testing.T) { + attrs := map[string]config2.PortAttribute{ + "443": {Protocol: config2.ProtocolHTTPS}, + } + resolver := NewPortAttributeResolver(attrs, nil) + got := resolver("443") + assert.Equal(t, config2.ProtocolHTTPS, got.Protocol) +} diff --git a/pkg/tunnel/services.go b/pkg/tunnel/services.go index b3203b6f0..d85973959 100644 --- a/pkg/tunnel/services.go +++ b/pkg/tunnel/services.go @@ -62,13 +62,17 @@ func getExitAfterTimeout(devsyConfig *config.Config) time.Duration { } // createForwarder creates a port forwarder if port forwarding is enabled. -func createForwarder(opts RunServicesOptions, forwardedPorts []string) netstat.Forwarder { +func createForwarder( + opts RunServicesOptions, + forwardedPorts []string, + resolver PortAttributeResolver, +) netstat.Forwarder { if !opts.ForwardPorts { return nil } ports := append([]string{}, forwardedPorts...) ports = append(ports, fmt.Sprintf("%d", openvscode.DefaultVSCodePort)) - return newForwarder(opts.ContainerClient, ports) + return newForwarder(opts.ContainerClient, ports, resolver) } // tunnelServerParams contains parameters for running the tunnel server. @@ -153,6 +157,7 @@ func runServicesIteration( ctx context.Context, opts RunServicesOptions, forwardedPorts []string, + resolver PortAttributeResolver, ) error { stdoutReader, stdoutWriter, err := os.Pipe() if err != nil { @@ -170,7 +175,7 @@ func runServicesIteration( cancelCtx, cancel := context.WithCancel(ctx) defer cancel() - forwarder := createForwarder(opts, forwardedPorts) + forwarder := createForwarder(opts, forwardedPorts, resolver) errChan := make(chan error, 1) go runTunnelServer(cancelCtx, cancel, tunnelServerParams{ @@ -210,31 +215,47 @@ func runServicesIteration( func RunServices(ctx context.Context, opts RunServicesOptions) error { exitAfterTimeout := getExitAfterTimeout(opts.DevsyConfig) - forwardedPorts, err := forwardDevContainerPorts(ctx, portForwardParams{ + fp := portForwardParams{ containerClient: opts.ContainerClient, extraPorts: opts.ExtraPorts, exitAfterTimeout: exitAfterTimeout, - }) + } + + forwardedPorts, err := forwardDevContainerPorts(ctx, fp) if err != nil { return fmt.Errorf("forward ports: %w", err) } + resolver := buildPortAttributeResolver(ctx, fp) + return retry.OnError(wait.Backoff{ Steps: maxRetrySteps, Duration: retryDuration, Factor: retryFactor, Jitter: retryJitter, }, func(err error) bool { - // Do not retry on context cancellation or deadline exceeded if ctx.Err() != nil { return false } return true }, func() error { - return runServicesIteration(ctx, opts, forwardedPorts) + return runServicesIteration(ctx, opts, forwardedPorts, resolver) }) } +// buildPortAttributeResolver loads port attributes from the container result. +func buildPortAttributeResolver(ctx context.Context, p portForwardParams) PortAttributeResolver { + result, err := getContainerResult(ctx, p) + if err != nil || result == nil || result.MergedConfig == nil { + return nil + } + mc := result.MergedConfig + if len(mc.PortsAttributes) == 0 && mc.OtherPortsAttributes == nil { + return nil + } + return NewPortAttributeResolver(mc.PortsAttributes, mc.OtherPortsAttributes) +} + // portForwardParams contains parameters for port forwarding. type portForwardParams struct { containerClient *ssh.Client From 0b9f5940c04e4b465644527ccb4dad8cfa0563d7 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 3 May 2026 05:29:14 -0500 Subject: [PATCH 2/2] fix(tunnel): remove dead protocol conditional in port forwarder --- pkg/tunnel/forwarder.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pkg/tunnel/forwarder.go b/pkg/tunnel/forwarder.go index a6fee82ba..d5edc62ea 100644 --- a/pkg/tunnel/forwarder.go +++ b/pkg/tunnel/forwarder.go @@ -77,11 +77,8 @@ func (f *forwarder) Forward(port string) error { log.Infof("Start port-forwarding on port %s", port) } - go func(port string, attr config2.PortAttribute) { + go func(port string) { network := "tcp" - if attr.Protocol == config2.ProtocolHTTPS { - network = "tcp" - } err := devssh.PortForward( cancelCtx, f.sshClient, @@ -94,7 +91,7 @@ func (f *forwarder) Forward(port string) error { if err != nil { log.Errorf("Error port forwarding %s: %v", port, err) } - }(port, attr) + }(port) return nil }