Skip to content

feat: allow remote hostnames in ssh forward ports#742

Open
davzucky wants to merge 6 commits intoskevetter:mainfrom
davzucky:feat/ssh-forward-port-hostnames
Open

feat: allow remote hostnames in ssh forward ports#742
davzucky wants to merge 6 commits intoskevetter:mainfrom
davzucky:feat/ssh-forward-port-hostnames

Conversation

@davzucky
Copy link
Copy Markdown

@davzucky davzucky commented Apr 17, 2026

Summary

  • move SSH port-spec parsing into pkg/port so --forward-ports and --reverse-forward-ports share the same parser
  • allow hostnames and service names on the dial side while keeping the listen side limited to localhost or literal IPs
  • move parser coverage into pkg/port, keep compose coverage for forwarding to nginx:8080, and document hostname examples for devpod ssh

Testing

  • PATH=\"/home/david/.local/share/mise/installs/go/1.24.2/bin:$PATH\" /home/david/.local/share/mise/installs/go/1.24.2/bin/go test ./pkg/port ./cmd
  • PATH=\"/home/david/.local/share/mise/installs/go/1.24.2/bin:$PATH\" /home/david/.local/share/mise/installs/go/1.24.2/bin/go test ./e2e/tests/up-docker-compose -run TestDoesNotExist

Closes #741

Summary by CodeRabbit

  • New Features

    • Port forwarding accepts service names, enabling forwarding to workspace services by name as well as host:port.
  • Improvements

    • Clarified CLI help for --forward-ports and --reverse-forward-ports with examples.
    • More robust port-forward orchestration, error propagation, and timeout handling for SSH forwarding.
  • Documentation

    • Added docs describing --forward-ports and --reverse-forward-ports semantics and examples.
  • Tests

    • Added unit and e2e tests covering port-spec parsing, TCP/UNIX sockets, and forwarding behavior.

This keeps reverse forwarding unchanged while letting forward-only port mappings target service names and other remote hostnames that resolve inside the workspace runtime.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds remote service-name/hostname support to SSH forward-port specs, refactors port-spec parsing and port-forward orchestration into a shared runner, updates CLI help and docs, and adds unit and e2e tests covering hostname-based forwarding and orchestration behaviors.

Changes

Cohort / File(s) Summary
Port spec parser & tests
pkg/port/parse.go, pkg/port/parse_test.go
Refactors parsing to return a structured split result and pass address options; introduces hostname-aware interpretation for 3-part forward specs (numeric-first → middle token is remote host/service); differentiates TCP vs unix handling and tightens validation. Adds comprehensive unit tests for valid and error cases.
SSH CLI port-forwarding
cmd/ssh.go, cmd/ssh_test.go
Updates --forward-ports/--reverse-forward-ports help text to document service-name/hostname targets and directionality. Replaces per-direction goroutine/error handling with a shared runPortForwards using portForwardConfig, sync.WaitGroup, and consolidated error reporting. Adds tests for timeout parsing and runPortForwards behaviors.
End-to-end tests
e2e/tests/up-docker-compose/up_docker_compose.go
Adds a Ginkgo spec exercising devpod ssh --forward-ports against a docker-compose nginx service, allocating an ephemeral local port, validating proxied HTTP response, and asserting cancellation/termination semantics.
Documentation
docs/pages/developing-in-workspaces/connect-to-a-workspace.mdx
Documents --forward-ports and --reverse-forward-ports semantics with examples showing workspace-resolvable service names and reverse-forwarding to host/service names.

Sequence Diagram(s)

sequenceDiagram
    rect rgba(20,116,240,0.5)
    participant LocalCLI as Local devpod CLI
    end
    rect rgba(34,197,94,0.5)
    participant SSHMgr as runPortForwards / SSH manager
    end
    rect rgba(236,72,153,0.5)
    participant RemoteWS as Remote Workspace / Container
    end
    rect rgba(250,204,21,0.5)
    participant TargetSvc as Target Service (e.g., nginx)
    end

    LocalCLI->>SSHMgr: start with `--forward-ports` spec (e.g., 18080:nginx:8080)
    SSHMgr->>RemoteWS: establish SSH tunnel / request remote connect to "nginx:8080"
    RemoteWS->>TargetSvc: resolve "nginx" and dial :8080
    TargetSvc-->>RemoteWS: respond (HTTP)
    RemoteWS-->>SSHMgr: forward traffic back through tunnel
    SSHMgr-->>LocalCLI: expose local port (18080) with proxied traffic
    Note over LocalCLI,TargetSvc: Client requests to localhost:18080 reach TargetSvc via SSH + remote DNS resolution
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

size/l

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: allow remote hostnames in ssh forward ports' accurately and concisely describes the main feature addition of enabling hostname support in SSH port forwarding.
Linked Issues check ✅ Passed The PR fully addresses all coding requirements from issue #741: hostname/service-name support for --forward-ports with localPort:remoteHost:remotePort and localIP:localPort:remoteHost:remotePort syntax, numeric-first 3-part parsing rule, and --reverse-forward-ports left unchanged.
Out of Scope Changes check ✅ Passed All changes align with the PR scope: parser refactoring into pkg/port, documentation updates, e2e test for forward-ports with hostname targets, and unit test coverage. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
cmd/ssh_forward_ports.go (3)

35-50: Error wrapper says "container address" in an SSH-forwarding context.

newForwardPortMapping wraps the remote-side error as "parse container address: ...". That terminology is carried over from port.ParsePortSpec, but the whole point of this new parser is that the remote side is a host/service, not necessarily a container. Consider "parse remote address" to match the rest of this file's naming (parseForwardRemoteAddress).

Proposed diff
-	containerAddress, err := parseForwardRemoteAddress(remoteHost, remotePort)
-	if err != nil {
-		return port.Mapping{}, fmt.Errorf("parse container address: %w", err)
-	}
+	remoteAddress, err := parseForwardRemoteAddress(remoteHost, remotePort)
+	if err != nil {
+		return port.Mapping{}, fmt.Errorf("parse remote address: %w", err)
+	}
@@
-		Container: containerAddress,
+		Container: remoteAddress,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/ssh_forward_ports.go` around lines 35 - 50, newForwardPortMapping
currently wraps errors from parseForwardRemoteAddress using the phrase "parse
container address"; update the error message to reflect SSH-forwarding
terminology by returning fmt.Errorf("parse remote address: %w", err) instead of
"parse container address: %w". Locate this change in the newForwardPortMapping
function where containerAddress is set from parseForwardRemoteAddress and adjust
the error wrapper to "parse remote address" to match parseForwardRemoteAddress
and the file's naming.

83-86: isPortNumber doesn't validate range (0–65535).

strconv.Atoi accepts "0", negatives, and values > 65535. Downstream net.JoinHostPort will happily accept any string, and SSH forwarding will fail later with a less clear error. This matches the pre-existing behavior of pkg/port.toAddress, so not a regression — but since you're already building a dedicated parser, a range check here would give users better diagnostics. Optional.

Proposed tightening
 func isPortNumber(raw string) bool {
-	_, err := strconv.Atoi(raw)
-	return err == nil
+	n, err := strconv.Atoi(raw)
+	return err == nil && n >= 0 && n <= 65535
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/ssh_forward_ports.go` around lines 83 - 86, The isPortNumber helper
currently only checks that strconv.Atoi(raw) succeeds but does not validate the
port range; update isPortNumber to parse the string as an integer and ensure the
value is within 0..65535 (e.g., use strconv.Atoi or strconv.ParseUint and then
verify 0 <= port && port <= 65535) so callers like net.JoinHostPort/SSH
forwarding get an immediate, clear validation error; keep the function name
isPortNumber and return true only when the numeric range check passes.

24-32: 4-part branch: consider treating empty local host the same as the 3-part numeric case.

In the 3-part numeric path, newForwardPortMapping("", ...) happily defaults the local bind to localhost (via parseForwardTCPAddress). In the 4-part path, an explicitly empty first segment (:8080:nginx:80) is rejected with "local host is empty". Not wrong, but slightly asymmetric. If the intent is to always require explicitness in 4-part form, a brief comment on line 25 would help; otherwise defaulting to localhost would be more consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/ssh_forward_ports.go` around lines 24 - 32, The 4-part branch currently
rejects an empty local host but the 3-part numeric path allows an empty host and
defaults to localhost via parseForwardTCPAddress called by
newForwardPortMapping; remove the explicit error check in the case 4 branch and
instead pass parts[0], parts[1], parts[2], parts[3] to newForwardPortMapping (so
an empty parts[0] will be handled consistently by
newForwardPortMapping/parseForwardTCPAddress), or if you prefer to keep the
rejection, add a short comment explaining the intentional asymmetry next to the
check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@e2e/tests/up-docker-compose/up_docker_compose.go`:
- Around line 183-186: The current pattern uses net.Listen to get a free port
then closes the listener (listener.Close()) and reuses localPort, which
introduces a TOCTOU race; to fix it eliminate the race by keeping the listener
open and handing the active listener (or its file descriptor) directly to the
code that will serve SSH rather than closing and re-binding — modify the code
around net.Listen, listener, localPort and the devpod ssh invocation so the
produced listener is passed through to the server routine (or use the listener's
FD transfer) instead of closing and re-opening the port.

---

Nitpick comments:
In `@cmd/ssh_forward_ports.go`:
- Around line 35-50: newForwardPortMapping currently wraps errors from
parseForwardRemoteAddress using the phrase "parse container address"; update the
error message to reflect SSH-forwarding terminology by returning
fmt.Errorf("parse remote address: %w", err) instead of "parse container address:
%w". Locate this change in the newForwardPortMapping function where
containerAddress is set from parseForwardRemoteAddress and adjust the error
wrapper to "parse remote address" to match parseForwardRemoteAddress and the
file's naming.
- Around line 83-86: The isPortNumber helper currently only checks that
strconv.Atoi(raw) succeeds but does not validate the port range; update
isPortNumber to parse the string as an integer and ensure the value is within
0..65535 (e.g., use strconv.Atoi or strconv.ParseUint and then verify 0 <= port
&& port <= 65535) so callers like net.JoinHostPort/SSH forwarding get an
immediate, clear validation error; keep the function name isPortNumber and
return true only when the numeric range check passes.
- Around line 24-32: The 4-part branch currently rejects an empty local host but
the 3-part numeric path allows an empty host and defaults to localhost via
parseForwardTCPAddress called by newForwardPortMapping; remove the explicit
error check in the case 4 branch and instead pass parts[0], parts[1], parts[2],
parts[3] to newForwardPortMapping (so an empty parts[0] will be handled
consistently by newForwardPortMapping/parseForwardTCPAddress), or if you prefer
to keep the rejection, add a short comment explaining the intentional asymmetry
next to the check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9ef32169-ad95-4a2c-ad62-c1612708f883

📥 Commits

Reviewing files that changed from the base of the PR and between f6c3ab2 and e110964.

📒 Files selected for processing (4)
  • cmd/ssh.go
  • cmd/ssh_forward_ports.go
  • cmd/ssh_forward_ports_test.go
  • e2e/tests/up-docker-compose/up_docker_compose.go

Comment on lines +183 to +186
listener, err := net.Listen("tcp", "127.0.0.1:0")
framework.ExpectNoError(err)
localPort := listener.Addr().(*net.TCPAddr).Port
framework.ExpectNoError(listener.Close())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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 devpod ssh binds. In practice this is fine for e2e and matches common Go test patterns, so no change required — just flagging it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/tests/up-docker-compose/up_docker_compose.go` around lines 183 - 186, The
current pattern uses net.Listen to get a free port then closes the listener
(listener.Close()) and reuses localPort, which introduces a TOCTOU race; to fix
it eliminate the race by keeping the listener open and handing the active
listener (or its file descriptor) directly to the code that will serve SSH
rather than closing and re-binding — modify the code around net.Listen,
listener, localPort and the devpod ssh invocation so the produced listener is
passed through to the server routine (or use the listener's FD transfer) instead
of closing and re-opening the port.

Format the new forward-port parser as expected by golangci-lint-fmt and satisfy lint in the compose e2e by handling the HTTP response close and documenting the controlled exec arguments.
Comment thread cmd/ssh_forward_ports.go Outdated
"github.com/skevetter/devpod/pkg/port"
)

func parseForwardPortSpec(raw string) (port.Mapping, error) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmd has historically been abused as a dumping ground. Let's move this over pkg if needed.

Also, is this logic a duplicate of

func splitParts(rawport string) (string, string, string, string, error) {

Copy link
Copy Markdown
Author

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/port and removed the cmd-local parser. The final shape now uses a single shared parser for both --forward-ports and --reverse-forward-ports: the listen side stays strict, while the dial side accepts hostnames and service names.

Move the parser into pkg/port so both ssh forwarding directions share the same listen-versus-target rules while allowing service names on the dial side and documenting the new behavior.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/port/parse.go (1)

91-103: ⚠️ Potential issue | 🟡 Minor

Reject empty Unix socket paths during parsing.

toUnixAddress currently accepts an empty port/socket path, so malformed specs like "", ":8080", or "8080:" can parse as Unix socket mappings and fail later in forwarding.

🛡️ Proposed validation
 func toUnixAddress(ip, port string, opts addressOptions) (Address, error) {
+	if port == "" {
+		return Address{}, fmt.Errorf("unix socket path is empty")
+	}
+
 	if opts.requireHost && ip == "" {
 		return Address{}, fmt.Errorf("%s is empty", opts.emptyHostLabel)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/port/parse.go` around lines 91 - 103, toUnixAddress currently permits an
empty port/socket path which lets malformed specs parse as a unix Address;
update to validate that port is non-empty and return an error when it is empty
(use the existing opts.emptyHostLabel for the error message so behavior is
consistent with the requireHost check). Specifically, inside toUnixAddress (and
still honoring opts.requireHost/opts.emptyHostLabel), add a guard that if port
== "" return Address{} with fmt.Errorf("%s is empty", opts.emptyHostLabel)
instead of constructing a unix Address with an empty path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/ssh.go`:
- Around line 465-482: The goroutine that calls config.forwardFn sometimes
returns without sending anything to errChan on clean exits (nil or io.EOF),
causing the receive at "return <-errChan" to hang; modify the goroutine (the
anonymous func invoked with portMapping, mapping) so that whenever
config.forwardFn returns nil or io.EOF it still signals completion to errChan
(for example send nil or a sentinel error like nil) and only send a wrapped
error when err != nil && !errors.Is(err, io.EOF); alternatively implement a
WaitGroup and close errChan after all goroutines finish and change the receiver
to read from the closed channel—reference config.forwardFn, errChan, the
anonymous goroutine and portMapping/mapping to locate where to apply the change.

---

Outside diff comments:
In `@pkg/port/parse.go`:
- Around line 91-103: toUnixAddress currently permits an empty port/socket path
which lets malformed specs parse as a unix Address; update to validate that port
is non-empty and return an error when it is empty (use the existing
opts.emptyHostLabel for the error message so behavior is consistent with the
requireHost check). Specifically, inside toUnixAddress (and still honoring
opts.requireHost/opts.emptyHostLabel), add a guard that if port == "" return
Address{} with fmt.Errorf("%s is empty", opts.emptyHostLabel) instead of
constructing a unix Address with an empty path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d66353ea-efd6-4745-b67a-be2e16a4eaed

📥 Commits

Reviewing files that changed from the base of the PR and between e110964 and 7897d98.

📒 Files selected for processing (5)
  • cmd/ssh.go
  • docs/pages/developing-in-workspaces/connect-to-a-workspace.mdx
  • e2e/tests/up-docker-compose/up_docker_compose.go
  • pkg/port/parse.go
  • pkg/port/parse_test.go
✅ Files skipped from review due to trivial changes (1)
  • docs/pages/developing-in-workspaces/connect-to-a-workspace.mdx
🚧 Files skipped from review as they are similar to previous changes (1)
  • e2e/tests/up-docker-compose/up_docker_compose.go

Comment thread cmd/ssh.go Outdated
Wait for all forwarding goroutines to finish so clean exits cannot hang the ssh command, and reject empty unix socket paths during port spec parsing with regression tests for both cases.
@davzucky
Copy link
Copy Markdown
Author

Addressed the latest review feedback in 6b88d4a:

  • runPortForwards now waits for all forwarding goroutines and closes the result channel so clean exits cannot hang
  • pkg/port now rejects empty unix socket paths during parsing
  • added regression tests for both behaviors

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
cmd/ssh_test.go (1)

65-88: Consider extending coverage to error and EOF paths.

The new test only validates the happy path. Given the recent hang fix (WaitGroup + close(errChan)) and the intentional io.EOF tolerance in runPortForwards, it would be valuable to add table-driven cases that:

  • return io.EOF from forwardFn and assert NoError (regression guard for the clean-shutdown fix referenced in past reviews),
  • return a real error and assert it is wrapped with "error forwarding ..." and propagated,
  • pass an invalid mapping (e.g., "") and assert a parse error wrapped with "parse port mapping",
  • use multiple mappings with mixed outcomes to exercise the concurrency and the first-error return.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/ssh_test.go` around lines 65 - 88, Add table-driven tests for
SSHCmd.runPortForwards to cover EOF and error paths: create cases that (1) have
forwardFn return io.EOF and assert no error (to guard the EOF-tolerance
behavior), (2) have forwardFn return a non-nil error and assert the returned
error is wrapped with "error forwarding ..." and propagated, (3) provide an
invalid mapping like "" and assert a parse error wrapped with "parse port
mapping", and (4) use multiple mappings where one succeeds and another returns
an error to exercise concurrency and first-error return; reference SSHCmd,
runPortForwards, portForwardConfig, mappings, forwardFn and ensure tests assert
expected wrapping/propagation semantics and that the errChan/WaitGroup shutdown
path behaves correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/ssh.go`:
- Around line 446-449: forwardTimeout currently shadows the parsed timeout
variable so it always returns zero; change the inner declaration in function
forwardTimeout (replace "timeout, err :=
time.ParseDuration(cmd.ForwardPortsTimeout)" with an assignment that sets the
outer timeout variable or simply return the parsed duration) so the successfully
parsed duration is actually returned; ensure runPortForwards (and its calls into
devssh.PortForward and devssh.ReversePortForward) receives and uses the non-zero
timeout from forwardTimeout.
- Around line 451-485: runPortForwards currently launches forwarder goroutines
as it parses each port spec using port.ParsePortSpec, which can cause orphaned
in-flight forwarders if a later parse fails; to fix, parse and validate all
entries from config.mappings up front (call port.ParsePortSpec for each into a
slice of port.Mapping) before spawning any goroutines, or alternatively create a
cancellable child context (ctxChild, cancel := context.WithCancel(ctx)) and if
any parse error occurs call cancel(), then ensure you still wait on waitGroup
and spawn the close-on-wait goroutine before returning; update uses of
mapping/portMapping, waitGroup, errChan and forwardFn in runPortForwards
accordingly so no listener is left running on early return.

---

Nitpick comments:
In `@cmd/ssh_test.go`:
- Around line 65-88: Add table-driven tests for SSHCmd.runPortForwards to cover
EOF and error paths: create cases that (1) have forwardFn return io.EOF and
assert no error (to guard the EOF-tolerance behavior), (2) have forwardFn return
a non-nil error and assert the returned error is wrapped with "error forwarding
..." and propagated, (3) provide an invalid mapping like "" and assert a parse
error wrapped with "parse port mapping", and (4) use multiple mappings where one
succeeds and another returns an error to exercise concurrency and first-error
return; reference SSHCmd, runPortForwards, portForwardConfig, mappings,
forwardFn and ensure tests assert expected wrapping/propagation semantics and
that the errChan/WaitGroup shutdown path behaves correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 174c244c-099f-4f09-be70-09b41d854785

📥 Commits

Reviewing files that changed from the base of the PR and between 7897d98 and 6b88d4a.

📒 Files selected for processing (4)
  • cmd/ssh.go
  • cmd/ssh_test.go
  • pkg/port/parse.go
  • pkg/port/parse_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • pkg/port/parse_test.go
  • pkg/port/parse.go

Comment thread cmd/ssh.go
Comment thread cmd/ssh.go Outdated
Return the parsed forward timeout value, validate all port mappings before any forwarders start, and add focused regression coverage for timeout parsing, EOF handling, parse failures, and mixed multi-mapping outcomes.
@github-actions github-actions Bot added size/xl and removed size/l labels Apr 21, 2026
@coderabbitai coderabbitai Bot added the size/m label Apr 21, 2026
Extract port mapping parsing to reduce command complexity, split the forwarding regression coverage into smaller focused tests, and make the multi-mapping error case deterministic under concurrency.
@github-actions github-actions Bot removed the size/m label Apr 21, 2026
@coderabbitai coderabbitai Bot added the size/l label Apr 21, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
cmd/ssh.go (1)

446-451: Include the failing mapping in parse errors.

With multiple -L/-R values, parse port mapping: ... does not identify which spec failed. Include portMapping in the wrapper.

Small diagnostic improvement
 		mapping, err := port.ParsePortSpec(portMapping)
 		if err != nil {
-			return nil, fmt.Errorf("parse port mapping: %w", err)
+			return nil, fmt.Errorf("parse port mapping %q: %w", portMapping, err)
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/ssh.go` around lines 446 - 451, The parsePortForwards function currently
wraps port.ParsePortSpec errors without the failing spec; change the error
wrapper to include the portMapping value so callers can see which -L/-R string
failed (e.g., replace fmt.Errorf("parse port mapping: %w", err) with a wrapper
that includes portMapping). Update the error message produced in
parsePortForwards (referencing the portMapping variable and the
port.ParsePortSpec call) to include the spec text and the original error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/ssh.go`:
- Around line 119-121: The reverse-forward flag registration currently exposes
expanded port.ParsePortSpec behavior and mentions service names/hostnames;
change cmd.ReverseForwardPorts handling to use the legacy/direction-aware parser
used for reverse bindings (route through the strict reverse parser instead of
port.ParsePortSpec) and update the flag registration (StringArrayVarP for
cmd.ReverseForwardPorts) help text to match the original legacy semantics
(remove service-name/hostname wording and limit to the legacy reverse-forward
syntax). Ensure the code that parses cmd.ReverseForwardPorts calls the
reverse-specific parsing function (e.g., the legacy ParseReversePortSpec or
equivalent direction-aware parser) and not the generic port.ParsePortSpec so
reverse forwarding remains restricted to the original behavior.
- Around line 463-524: Wrap the incoming ctx in a cancellable child (ctx, cancel
:= context.WithCancel(ctx)) at the top of runPortForwards and pass that child
ctx into config.forwardFn so all forwarder goroutines see cancellation; in the
per-goroutine error handling, when you detect a real error (err != nil &&
!errors.Is(err, io.EOF)) send the formatted error to errChan and call cancel()
to cancel sibling forwarders; in the receiving loop, capture the first error but
continue draining errChan until it is closed by the waitGroup goroutine, then
return the first non-nil error (ensuring cancel is deferred/called to release
resources when leaving).

---

Nitpick comments:
In `@cmd/ssh.go`:
- Around line 446-451: The parsePortForwards function currently wraps
port.ParsePortSpec errors without the failing spec; change the error wrapper to
include the portMapping value so callers can see which -L/-R string failed
(e.g., replace fmt.Errorf("parse port mapping: %w", err) with a wrapper that
includes portMapping). Update the error message produced in parsePortForwards
(referencing the portMapping variable and the port.ParsePortSpec call) to
include the spec text and the original error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4d6db67f-d3b1-44f2-bbc7-8fa7eee898cf

📥 Commits

Reviewing files that changed from the base of the PR and between 392a962 and 62d12e9.

📒 Files selected for processing (2)
  • cmd/ssh.go
  • cmd/ssh_test.go
✅ Files skipped from review due to trivial changes (1)
  • cmd/ssh_test.go

Comment thread cmd/ssh.go
Comment on lines 119 to +121
StringArrayVarP(&cmd.ReverseForwardPorts, "reverse-forward-ports", "R", []string{},
"Specifies that connections to the given TCP port or Unix socket on the local (client) "+
"host are to be reverse forwarded to the given host and port, or Unix socket, on the remote side.")
"Specifies that connections to the given TCP port or Unix socket on the remote side "+
"are to be reverse forwarded to the given local host, service name, and port, or Unix socket.")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep --reverse-forward-ports on the legacy parser/contract.

reverseForwardPorts now shares the expanded port.ParsePortSpec path, and the help text advertises service names for -R. That appears to broaden reverse forwarding even though the PR scope says hostname/service-name support is forward-only. Please route -R through a strict/direction-aware parser and keep the reverse help text aligned with the legacy behavior.

Possible structure
 type portForwardConfig struct {
 	mappings    []string
 	logTemplate string
 	forwardFn   portForwardFunc
+	parseFn     func(string) (port.Mapping, error)
 }

 func (cmd *SSHCmd) reverseForwardPorts(
 	ctx context.Context,
 	containerClient *ssh.Client,
 	log log.Logger,
 ) error {
 	return cmd.runPortForwards(ctx, containerClient, portForwardConfig{
 		mappings:    cmd.ReverseForwardPorts,
 		logTemplate: "Reverse forwarding remote %s/%s to local %s/%s",
 		forwardFn:   devssh.ReversePortForward,
+		parseFn:     port.ParseReversePortSpec, // strict parser preserving legacy -R behavior
 	}, log)
 }

 func (cmd *SSHCmd) forwardPorts(
 	ctx context.Context,
 	containerClient *ssh.Client,
 	log log.Logger,
 ) error {
 	return cmd.runPortForwards(ctx, containerClient, portForwardConfig{
 		mappings:    cmd.ForwardPorts,
 		logTemplate: "Forwarding local %s/%s to remote %s/%s",
 		forwardFn:   devssh.PortForward,
+		parseFn:     port.ParsePortSpec,
 	}, log)
 }

Also applies to: 405-409

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/ssh.go` around lines 119 - 121, The reverse-forward flag registration
currently exposes expanded port.ParsePortSpec behavior and mentions service
names/hostnames; change cmd.ReverseForwardPorts handling to use the
legacy/direction-aware parser used for reverse bindings (route through the
strict reverse parser instead of port.ParsePortSpec) and update the flag
registration (StringArrayVarP for cmd.ReverseForwardPorts) help text to match
the original legacy semantics (remove service-name/hostname wording and limit to
the legacy reverse-forward syntax). Ensure the code that parses
cmd.ReverseForwardPorts calls the reverse-specific parsing function (e.g., the
legacy ParseReversePortSpec or equivalent direction-aware parser) and not the
generic port.ParsePortSpec so reverse forwarding remains restricted to the
original behavior.

Comment thread cmd/ssh.go
Comment on lines +463 to 524
func (cmd *SSHCmd) runPortForwards(
ctx context.Context,
containerClient *ssh.Client,
log log.Logger,
config portForwardConfig,
logger log.Logger,
) error {
timeout, err := cmd.forwardTimeout(log)
timeout, err := cmd.forwardTimeout(logger)
if err != nil {
return fmt.Errorf("parse forward ports timeout: %w", err)
}

errChan := make(chan error, len(cmd.ForwardPorts))
for _, portMapping := range cmd.ForwardPorts {
mapping, err := port.ParsePortSpec(portMapping)
if err != nil {
return fmt.Errorf("parse port mapping: %w", err)
}
parsedMappings, err := parsePortForwards(config.mappings)
if err != nil {
return err
}

errChan := make(chan error, len(parsedMappings))
var waitGroup sync.WaitGroup
for _, parsedMapping := range parsedMappings {
portMapping, mapping := parsedMapping.spec, parsedMapping.mapping

// start the forwarding
log.Infof(
"Forwarding local %s/%s to remote %s/%s",
logger.Infof(
config.logTemplate,
mapping.Host.Protocol,
mapping.Host.Address,
mapping.Container.Protocol,
mapping.Container.Address,
)
go func(portMapping string) {
err := devssh.PortForward(
waitGroup.Add(1)
go func(portMapping string, mapping port.Mapping) {
defer waitGroup.Done()

err := config.forwardFn(
ctx,
containerClient,
mapping.Host.Protocol,
mapping.Host.Address,
mapping.Container.Protocol,
mapping.Container.Address,
timeout,
log,
logger,
)
if !errors.Is(io.EOF, err) {
if err != nil && !errors.Is(err, io.EOF) {
errChan <- fmt.Errorf("error forwarding %s: %w", portMapping, err)
}
}(portMapping)
}(portMapping, mapping)
}

go func() {
waitGroup.Wait()
close(errChan)
}()

for err := range errChan {
if err != nil {
return err
}
}

return <-errChan
return nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cancel sibling forwarders before returning the first runtime error.

If one forwarding goroutine fails, Line 519 returns immediately while the other forwarders keep using the caller’s ctx and may keep listeners open. Use a child context, cancel it on the first real error, then drain the channel until the WaitGroup closes it.

Proposed cleanup on first error
 func (cmd *SSHCmd) runPortForwards(
 	ctx context.Context,
 	containerClient *ssh.Client,
 	config portForwardConfig,
 	logger log.Logger,
 ) error {
 	timeout, err := cmd.forwardTimeout(logger)
 	if err != nil {
 		return fmt.Errorf("parse forward ports timeout: %w", err)
 	}

 	parsedMappings, err := parsePortForwards(config.mappings)
 	if err != nil {
 		return err
 	}
+
+	forwardCtx, cancel := context.WithCancel(ctx)
+	defer cancel()

 	errChan := make(chan error, len(parsedMappings))
 	var waitGroup sync.WaitGroup
 	for _, parsedMapping := range parsedMappings {
 		portMapping, mapping := parsedMapping.spec, parsedMapping.mapping

@@
 			defer waitGroup.Done()

 			err := config.forwardFn(
-				ctx,
+				forwardCtx,
 				containerClient,
 				mapping.Host.Protocol,
 				mapping.Host.Address,
 				mapping.Container.Protocol,
@@
 		}(portMapping, mapping)
 	}

 	go func() {
 		waitGroup.Wait()
 		close(errChan)
 	}()

+	var firstErr error
 	for err := range errChan {
-		if err != nil {
-			return err
+		if err != nil && firstErr == nil {
+			firstErr = err
+			cancel()
 		}
 	}

-	return nil
+	return firstErr
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (cmd *SSHCmd) runPortForwards(
ctx context.Context,
containerClient *ssh.Client,
log log.Logger,
config portForwardConfig,
logger log.Logger,
) error {
timeout, err := cmd.forwardTimeout(log)
timeout, err := cmd.forwardTimeout(logger)
if err != nil {
return fmt.Errorf("parse forward ports timeout: %w", err)
}
errChan := make(chan error, len(cmd.ForwardPorts))
for _, portMapping := range cmd.ForwardPorts {
mapping, err := port.ParsePortSpec(portMapping)
if err != nil {
return fmt.Errorf("parse port mapping: %w", err)
}
parsedMappings, err := parsePortForwards(config.mappings)
if err != nil {
return err
}
errChan := make(chan error, len(parsedMappings))
var waitGroup sync.WaitGroup
for _, parsedMapping := range parsedMappings {
portMapping, mapping := parsedMapping.spec, parsedMapping.mapping
// start the forwarding
log.Infof(
"Forwarding local %s/%s to remote %s/%s",
logger.Infof(
config.logTemplate,
mapping.Host.Protocol,
mapping.Host.Address,
mapping.Container.Protocol,
mapping.Container.Address,
)
go func(portMapping string) {
err := devssh.PortForward(
waitGroup.Add(1)
go func(portMapping string, mapping port.Mapping) {
defer waitGroup.Done()
err := config.forwardFn(
ctx,
containerClient,
mapping.Host.Protocol,
mapping.Host.Address,
mapping.Container.Protocol,
mapping.Container.Address,
timeout,
log,
logger,
)
if !errors.Is(io.EOF, err) {
if err != nil && !errors.Is(err, io.EOF) {
errChan <- fmt.Errorf("error forwarding %s: %w", portMapping, err)
}
}(portMapping)
}(portMapping, mapping)
}
go func() {
waitGroup.Wait()
close(errChan)
}()
for err := range errChan {
if err != nil {
return err
}
}
return <-errChan
return nil
}
func (cmd *SSHCmd) runPortForwards(
ctx context.Context,
containerClient *ssh.Client,
config portForwardConfig,
logger log.Logger,
) error {
timeout, err := cmd.forwardTimeout(logger)
if err != nil {
return fmt.Errorf("parse forward ports timeout: %w", err)
}
parsedMappings, err := parsePortForwards(config.mappings)
if err != nil {
return err
}
forwardCtx, cancel := context.WithCancel(ctx)
defer cancel()
errChan := make(chan error, len(parsedMappings))
var waitGroup sync.WaitGroup
for _, parsedMapping := range parsedMappings {
portMapping, mapping := parsedMapping.spec, parsedMapping.mapping
// start the forwarding
logger.Infof(
config.logTemplate,
mapping.Host.Protocol,
mapping.Host.Address,
mapping.Container.Protocol,
mapping.Container.Address,
)
waitGroup.Add(1)
go func(portMapping string, mapping port.Mapping) {
defer waitGroup.Done()
err := config.forwardFn(
forwardCtx,
containerClient,
mapping.Host.Protocol,
mapping.Host.Address,
mapping.Container.Protocol,
mapping.Container.Address,
timeout,
logger,
)
if err != nil && !errors.Is(err, io.EOF) {
errChan <- fmt.Errorf("error forwarding %s: %w", portMapping, err)
}
}(portMapping, mapping)
}
go func() {
waitGroup.Wait()
close(errChan)
}()
var firstErr error
for err := range errChan {
if err != nil && firstErr == nil {
firstErr = err
cancel()
}
}
return firstErr
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/ssh.go` around lines 463 - 524, Wrap the incoming ctx in a cancellable
child (ctx, cancel := context.WithCancel(ctx)) at the top of runPortForwards and
pass that child ctx into config.forwardFn so all forwarder goroutines see
cancellation; in the per-goroutine error handling, when you detect a real error
(err != nil && !errors.Is(err, io.EOF)) send the formatted error to errChan and
call cancel() to cancel sibling forwarders; in the receiving loop, capture the
first error but continue draining errChan until it is closed by the waitGroup
goroutine, then return the first non-nil error (ensuring cancel is
deferred/called to release resources when leaving).

@davzucky davzucky requested a review from skevetter April 23, 2026 00:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow remote hostnames in devpod ssh --forward-ports

2 participants