-
Notifications
You must be signed in to change notification settings - Fork 23
feat: allow remote hostnames in ssh forward ports #742
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
e110964
293955a
7897d98
6b88d4a
392a962
62d12e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "net" | ||
| "strconv" | ||
| "strings" | ||
|
|
||
| "github.com/skevetter/devpod/pkg/port" | ||
| ) | ||
|
|
||
| func parseForwardPortSpec(raw string) (port.Mapping, error) { | ||
| parts := strings.Split(raw, ":") | ||
|
|
||
| switch len(parts) { | ||
| case 1, 2: | ||
| return port.ParsePortSpec(raw) | ||
| case 3: | ||
| if !isPortNumber(parts[0]) { | ||
| return port.ParsePortSpec(raw) | ||
| } | ||
|
|
||
| return newForwardPortMapping("", parts[0], parts[1], parts[2]) | ||
| case 4: | ||
| if parts[0] == "" { | ||
| return port.Mapping{}, fmt.Errorf("local host is empty") | ||
| } | ||
|
|
||
| return newForwardPortMapping(parts[0], parts[1], parts[2], parts[3]) | ||
| default: | ||
| return port.Mapping{}, fmt.Errorf("unexpected port format: %s", raw) | ||
| } | ||
| } | ||
|
|
||
| func newForwardPortMapping(localHost, localPort, remoteHost, remotePort string) (port.Mapping, error) { | ||
| hostAddress, err := parseForwardLocalAddress(localHost, localPort) | ||
| if err != nil { | ||
| return port.Mapping{}, fmt.Errorf("parse host address: %w", err) | ||
| } | ||
|
|
||
| containerAddress, err := parseForwardRemoteAddress(remoteHost, remotePort) | ||
| if err != nil { | ||
| return port.Mapping{}, fmt.Errorf("parse container address: %w", err) | ||
| } | ||
|
|
||
| return port.Mapping{ | ||
| Host: hostAddress, | ||
| Container: containerAddress, | ||
| }, nil | ||
| } | ||
|
|
||
| func parseForwardLocalAddress(host, rawPort string) (port.Address, error) { | ||
| return parseForwardTCPAddress(host, rawPort, false) | ||
| } | ||
|
|
||
| func parseForwardRemoteAddress(host, rawPort string) (port.Address, error) { | ||
| if host == "" { | ||
| return port.Address{}, fmt.Errorf("remote host is empty") | ||
| } | ||
|
|
||
| return parseForwardTCPAddress(host, rawPort, true) | ||
| } | ||
|
|
||
| func parseForwardTCPAddress(host, rawPort string, allowHostnames bool) (port.Address, error) { | ||
| if !isPortNumber(rawPort) { | ||
| return port.Address{}, fmt.Errorf("invalid port %s", rawPort) | ||
| } | ||
|
|
||
| if host == "" { | ||
| host = "localhost" | ||
| } | ||
|
|
||
| if !allowHostnames && host != "localhost" && net.ParseIP(host) == nil { | ||
| return port.Address{}, fmt.Errorf("not an ip address %s", host) | ||
| } | ||
|
|
||
| return port.Address{ | ||
| Protocol: "tcp", | ||
| Address: net.JoinHostPort(host, rawPort), | ||
| }, nil | ||
| } | ||
|
|
||
| func isPortNumber(raw string) bool { | ||
| _, err := strconv.Atoi(raw) | ||
| return err == nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/skevetter/devpod/pkg/port" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestParseForwardPortSpec_ServiceNameTarget(t *testing.T) { | ||
| mapping, err := parseForwardPortSpec("8080:nginx:80") | ||
| require.NoError(t, err) | ||
| assert.Equal(t, "tcp", mapping.Host.Protocol) | ||
| assert.Equal(t, "localhost:8080", mapping.Host.Address) | ||
| assert.Equal(t, "tcp", mapping.Container.Protocol) | ||
| assert.Equal(t, "nginx:80", mapping.Container.Address) | ||
| } | ||
|
|
||
| func TestParseForwardPortSpec_ServiceNameTargetWithLocalBindHost(t *testing.T) { | ||
| mapping, err := parseForwardPortSpec("127.0.0.1:8080:nginx:80") | ||
| require.NoError(t, err) | ||
| assert.Equal(t, "127.0.0.1:8080", mapping.Host.Address) | ||
| assert.Equal(t, "nginx:80", mapping.Container.Address) | ||
| } | ||
|
|
||
| func TestParseForwardPortSpec_PreservesLocalBindDisambiguation(t *testing.T) { | ||
| mapping, err := parseForwardPortSpec("localhost:8080:80") | ||
| require.NoError(t, err) | ||
| assert.Equal(t, "localhost:8080", mapping.Host.Address) | ||
| assert.Equal(t, "localhost:80", mapping.Container.Address) | ||
| } | ||
|
|
||
| func TestParseForwardPortSpec_AllowsRemoteIPTargets(t *testing.T) { | ||
| mapping, err := parseForwardPortSpec("8080:10.0.0.2:80") | ||
| require.NoError(t, err) | ||
| assert.Equal(t, "localhost:8080", mapping.Host.Address) | ||
| assert.Equal(t, "10.0.0.2:80", mapping.Container.Address) | ||
| } | ||
|
|
||
| func TestParseForwardPortSpec_RejectsNonIPLocalBindHost(t *testing.T) { | ||
| _, err := parseForwardPortSpec("app:8080:nginx:80") | ||
| require.Error(t, err) | ||
| assert.ErrorContains(t, err, "not an ip address app") | ||
| } | ||
|
|
||
| func TestParseForwardPortSpec_RejectsEmptyRemoteHost(t *testing.T) { | ||
| _, err := parseForwardPortSpec("8080::80") | ||
| require.Error(t, err) | ||
| assert.ErrorContains(t, err, "remote host is empty") | ||
| } | ||
|
|
||
| func TestParseForwardPortSpec_DelegatesUnixSocketMappings(t *testing.T) { | ||
| mapping, err := parseForwardPortSpec("/tmp/local.sock:/tmp/remote.sock") | ||
| require.NoError(t, err) | ||
| assert.Equal(t, port.Mapping{ | ||
| Host: port.Address{Protocol: "unix", Address: "/tmp/local.sock"}, | ||
| Container: port.Address{Protocol: "unix", Address: "/tmp/remote.sock"}, | ||
| }, mapping) | ||
| } | ||
|
|
||
| func TestParseForwardPortSpec_DoesNotChangeSharedParser(t *testing.T) { | ||
| _, err := port.ParsePortSpec("8080:nginx:80") | ||
| require.Error(t, err) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |
| "context" | ||
| "fmt" | ||
| "io" | ||
| "net" | ||
| "net/http" | ||
| "os" | ||
| "os/exec" | ||
|
|
@@ -161,6 +162,76 @@ | |
| )) | ||
| }, ginkgo.SpecTimeout(framework.GetTimeout())) | ||
|
|
||
| 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()) | ||
|
Comment on lines
+183
to
+186
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dynamic port allocation has a minor TOCTOU window, but acceptable for e2e. Closing the listener and reusing the port has a brief race where another process could grab it before 🤖 Prompt for AI Agents |
||
|
|
||
| done := make(chan error) | ||
| sshContext, sshCancel := context.WithCancel(context.Background()) | ||
| go func() { | ||
| cmd := exec.CommandContext( | ||
| sshContext, | ||
| filepath.Join(tc.f.DevpodBinDir, tc.f.DevpodBinName), | ||
| "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 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.GetTimeout())) | ||
|
|
||
| ginkgo.It("features", func(ctx context.Context) { | ||
| tempDir, workspace, err := tc.setupAndStartWorkspace( | ||
| ctx, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cmdhas historically been abused as a dumping ground. Let's move this overpkgif needed.Also, is this logic a duplicate of
devpod/pkg/port/parse.go
Line 70 in f6c3ab2
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. I moved the parsing into
pkg/portand removed thecmd-local parser. The final shape now uses a single shared parser for both--forward-portsand--reverse-forward-ports: the listen side stays strict, while the dial side accepts hostnames and service names.