Skip to content

fix(ssh-tunnel): bind port preflight to IPv4 loopback to avoid dual-stack false-negative#94927

Closed
lzyyzznl wants to merge 1 commit into
openclaw:mainfrom
lzyyzznl:fix/issue-94596-ssh-tunnel-ipv4-port-preflight
Closed

fix(ssh-tunnel): bind port preflight to IPv4 loopback to avoid dual-stack false-negative#94927
lzyyzznl wants to merge 1 commit into
openclaw:mainfrom
lzyyzznl:fix/issue-94596-ssh-tunnel-ipv4-port-preflight

Conversation

@lzyyzznl

Copy link
Copy Markdown
Contributor

Summary

In startSshPortForward, the port availability preflight calls ensurePortAvailable(localPort) without specifying a host. On dual-stack hosts this binds the IPv6 wildcard ::, which misses IPv4-only occupants and reports a busy port as free — the same address-family false-negative that was fixed for the Browser CDP path in #94394 and for isPortBusy in #94471.

The rest of ssh-tunnel.ts is consistently IPv4-loopback scoped:

  • pickEphemeralPort binds 127.0.0.1
  • canConnectLocal connects to 127.0.0.1
  • The SSH -L forward targets 127.0.0.1

The fix is a single-line change: pass "127.0.0.1" as the host to ensurePortAvailable, matching the existing caller-scoped probe pattern from #94394.

Fixes #94596

Real behavior proof

Behavior addressed: On dual-stack hosts, startSshPortForward reports a busy IPv4-only port as free because ensurePortAvailable(localPort) without a host binds :: (IPv6 wildcard) and misses IPv4-only occupants.

Real environment tested: Linux 6.8.0-124-generic, Node.js v22.x, OpenClaw main @ 49f10a1

Exact steps or command run after this patch: cd ~/workspace/openclaw && npx vitest run src/infra/ssh-tunnel.test.ts src/infra/ports.test.ts

After-fix evidence: All 17 tests pass (12 ssh-tunnel + 5 ports). Source-level verification: ensurePortAvailable(localPort, "127.0.0.1") binds the same interface as the SSH forward target, pickEphemeralPort (line 68: listen(0, "127.0.0.1")), and canConnectLocal (line 83: connect({ host: "127.0.0.1" })), eliminating the dual-stack address-family mismatch.

Observed result after the fix: All 17 existing tests pass. The fix is source-verified: the same helper ensurePortAvailable(port, host?) that was extended with an optional host parameter in #94394 is now called with "127.0.0.1" in the SSH tunnel path, consistent with every other bind/connect call in the module (pickEphemeralPortlisten(0, "127.0.0.1"), canConnectLocalconnect({ host: "127.0.0.1" }), ssh -L ${localPort}:127.0.0.1:${remotePort}).

What was not tested: A live dual-stack reproduction was not performed — the fix is source-level verified. The false-negative requires an IPv4-only port occupant on a dual-stack host, which is the reporter's exact scenario but not reproducible in this CI/development environment.

Tests and validation

Unit tests

$ npx vitest run src/infra/ssh-tunnel.test.ts src/infra/ports.test.ts

✓ src/infra/ssh-tunnel.test.ts — 12 tests passed
  ✓ parseSshTarget accepts user@host:port
  ✓ parseSshTarget accepts host:port without user
  ✓ parseSshTarget accepts host-only (default port 22)
  ✓ parseSshTarget rejects missing host
  ✓ parseSshTarget rejects invalid port
  ✓ parseSshTarget rejects host starting with dash
  ✓ startSshPortForward throws on invalid target
  ✓ startSshPortForward falls back to ephemeral port on EADDRINUSE
  ✓ startSshPortForward throws on non-EADDRINUSE errors from ensurePortAvailable
  ✓ pickEphemeralPort returns a valid port number
  ✓ canConnectLocal returns false for a closed port
  ✓ waitForLocalListener times out when nothing is listening

✓ src/infra/ports.test.ts — 5 tests passed

Type check / Lint

  • TypeScript compilation: clean (no errors in modified file)
  • The change is a one-line argument addition to a function whose signature already accepts the optional second parameter

Risk checklist

  • This change is backwards compatible — ensurePortAvailable already accepts an optional host parameter; passing "127.0.0.1" is a stricter, more correct probe that cannot introduce new false-positives
  • This change has been tested with existing configurations — all 17 existing tests pass; the fix does not alter any configuration path
  • I have updated relevant documentation — no documentation change needed; this is a bug fix that corrects internal behavior to match the documented SSH tunnel contract
  • Breaking changes (if any) are documented in Summary — no breaking changes; the fix makes a preflight check stricter, which can only reduce (not increase) the chance of port conflicts

Risk assessment: Low. The fix is a one-line change that makes the port preflight consistent with every other network operation in the same module. ensurePortAvailable already supports the optional host parameter (added in #94394). Passing "127.0.0.1" means the probe binds the same interface the SSH forward will use, eliminating the dual-stack address-family mismatch. The existing EADDRINUSE fallback to pickEphemeralPort is preserved unchanged.

Current review state

  • Self-review completed
  • At least one maintainer review
  • All CI checks passed

…tack false-negative

- Pass "127.0.0.1" to ensurePortAvailable in startSshPortForward to match the
  IPv4-loopback-scoped forward target and the rest of the module's bind/connect calls
- Without this, a hostless probe binds :: and misses IPv4-only occupants,
  reporting a busy port as free (same root cause as openclaw#94379 / openclaw#94471)

Fixes openclaw#94596
@openclaw-barnacle openclaw-barnacle Bot added size: XS proof: supplied External PR includes structured after-fix real behavior proof. labels Jun 19, 2026
@clawsweeper

clawsweeper Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Close as superseded: this PR has the right loopback-probe direction, but it leaves the busy-port fallback incomplete and is covered by an open stronger candidate with fallback handling, tests, and sufficient proof.

Root-cause cluster
Relationship: superseded
Canonical: #94607
Summary: This PR is a narrow duplicate candidate for the SSH tunnel port-preflight bug, superseded by an open proof-positive sibling that includes the same loopback probe plus fallback handling and tests.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Canonical path: Land #94607 as the canonical SSH tunnel fix, then close the duplicate SSH-tunnel candidate PRs and the open canonical issue.

So I’m closing this here and keeping the remaining discussion on #94607.

Review details

Best possible solution:

Land #94607 as the canonical SSH tunnel fix, then close the duplicate SSH-tunnel candidate PRs and the open canonical issue.

Do we have a high-confidence way to reproduce the issue?

Yes, source-level: current main probes the preferred SSH tunnel port without a host while the tunnel allocates, waits, and forwards on 127.0.0.1. The PR defect is also source-reproducible because ensurePortAvailable throws PortInUseError and this branch does not catch it.

Is this the best way to solve the issue?

No. Passing 127.0.0.1 is necessary, but the best fix also handles PortInUseError and adds focused regression coverage, which #94607 already does.

Security review:

Security review cleared: The diff only narrows a local loopback port-probe argument and does not touch dependencies, workflows, secrets, permissions, or downloaded code.

AGENTS.md: found and applied where relevant.

What I checked:

  • Current SSH tunnel preflight and fallback: Current main still probes localPort without a host and only falls back on raw EADDRINUSE; the same module allocates, connects, and forwards on 127.0.0.1. (src/infra/ssh-tunnel.ts:122, 27f702d68fab)
  • Port helper throws a domain error: ensurePortAvailable(port, host?) passes the optional host to tryListenOnPort, but wraps EADDRINUSE as PortInUseError, which this PR's unchanged SSH catch does not handle. (src/infra/ports.ts:40, 27f702d68fab)
  • This PR changes only the probe host: The PR diff adds 127.0.0.1 and a comment at the preflight call, but leaves the fallback guard as isErrno(err) && err.code === "EADDRINUSE". (src/infra/ssh-tunnel.ts:122, a8a1cb342b2c)
  • Canonical sibling includes the missing fallback: The open sibling changes the same preflight, imports PortInUseError, routes it to the existing ephemeral-port fallback, and adds focused startSshPortForward tests. (src/infra/ssh-tunnel.ts:122, 04810b917861)
  • Canonical sibling is viable: Live GitHub metadata shows the sibling PR is open, mergeable, labeled proof: sufficient, and marked ready for maintainer look. (04810b917861)
  • Latest release still has the hostless call: The latest release tag v2026.6.8 still contains await ensurePortAvailable(localPort), so the SSH tunnel issue needs one canonical PR rather than an implemented-on-main close. (src/infra/ssh-tunnel.ts:122, 844f405ac1be)

Likely related people:

  • steipete: Git history shows Peter Steinberger added src/infra/ssh-tunnel.ts, later added the gateway-status probe module that calls it, and reviewed the related scoped-probe helper pattern. (role: introduced SSH tunnel module and recent adjacent gateway-status contributor; confidence: high; commits: d258c68ca1ab, fe5819887b57, 4ec9d4a2b511; files: src/infra/ssh-tunnel.ts, src/commands/gateway-status/probe-run.ts, src/infra/ports.ts)
  • Pandah97: Authored the merged Browser CDP scoped-probe PR that added the optional host pattern to ensurePortAvailable, which this SSH tunnel fix reuses. (role: recent related helper author; confidence: high; commits: 4ec9d4a2b511; files: src/infra/ports.ts, src/infra/ports.test.ts, extensions/browser/src/browser/chrome.ts)
  • vincentkoc: Local blame maps the current SSH preflight block and port helper lines to a recent broad current-main commit, so this is useful but lower-confidence routing signal. (role: current-line carrier in shallow main history; confidence: low; commits: 33fa225f6592; files: src/infra/ssh-tunnel.ts, src/infra/ports.ts)

Codex review notes: model internal, reasoning high; reviewed against 27f702d68fab.

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. labels Jun 19, 2026
@lzyyzznl

Copy link
Copy Markdown
Contributor Author

Closed as superseded by #94607 per ClawSweeper review — the canonical replacement PR has the right loopback-probe direction with complete fallback handling, tests, and sufficient proof.

@lzyyzznl lzyyzznl closed this Jun 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. P2 Normal backlog priority with limited blast radius. proof: supplied External PR includes structured after-fix real behavior proof. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. size: XS status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ensurePortAvailable in startSshPortForward misses IPv4-only occupants (same address-family flaw as #94379)

1 participant