Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/agent/container/credentials_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}),
}
}
Expand Down
130 changes: 130 additions & 0 deletions e2e/tests/ssh/ports_attributes_test.go
Original file line number Diff line number Diff line change
@@ -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",
)
},
)
})
17 changes: 17 additions & 0 deletions e2e/tests/ssh/testdata/ports-attributes/.devcontainer.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
34 changes: 34 additions & 0 deletions e2e/tests/ssh/testdata/ports-attributes/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package main

import (
"fmt"
"net"
"os"
)

func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: server <port>")
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)
}
}
65 changes: 48 additions & 17 deletions pkg/devcontainer/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading