Skip to content

feat(remote-ssh): forward remote ports to this machine - #2407

Closed
bobleer wants to merge 1 commit into
GCWing:mainfrom
bobleer:bob/bitfun-remote-port-mapping-86c8bd
Closed

bobleer wants to merge 1 commit into
GCWing:mainfrom
bobleer:bob/bitfun-remote-port-mapping-86c8bd

Conversation

@bobleer

@bobleer bobleer commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Starting a server on an SSH host left it unreachable from the machine running BitFun. PortForwardManager existed but was a placeholder: it recorded forwards in a HashMap and logged "Port forwarding is not fully implemented - connections will not be forwarded".

This implements local (-L) forwarding for real, and exposes it as a manual mapping UI — nothing is ever forwarded automatically.

Each forward binds a listener and splices accepted connections onto a direct-tcpip channel, so the remote sshd dials the service and both directions are copied. That primitive was already in production use for ProxyJump chains and container sshd probing; this puts it behind a managed listener.

Type and Areas

Type: Feature

Areas: Rust core (services-integrations/remote_ssh), desktop/Tauri commands, web UI, locales

Motivation / Impact

The remote endpoint is a forward's identity; the local port is only an allocation. A requested local port that is already taken is replaced rather than refused, and the port originally asked for is returned with the mapping so the UI can say "local 3000 was in use, bound 3001 instead" rather than silently handing over a different address. The table keeps "remote port" and "local address" in separate columns for the same reason — they routinely differ. Privileged ports (<1024) fall back the same way instead of surfacing a bare permission error.

Three failure modes worth calling out for review:

  1. Reconnect. Channels are opened through SSHConnectionManager per connection rather than against a cached Arc<Handle>, so a forward survives an automatic reconnect (reconnect installs a new handle). That same path would let a forward nobody stopped dial back out to a host the user explicitly disconnected from — so each connection checks liveness first, and ssh_disconnect / ssh_disconnect_all tear down the forwards they carried.
  2. Local Docker. A local Docker workspace is reached by docker exec and has no SSH session to carry a channel. It is rejected when the mapping is added, not on the first request.
  3. Exposure. Forwards bind loopback only. Republishing a remote service to the local network is behind an explicit opt-in checkbox with a warning.

Also adds remote listening-port discovery (ss, then lsof, then netstat — that order matters, since macOS netstat -p means "protocol") so users can pick a real port instead of guessing. A marker line names the dialect so the parser doesn't have to guess. Discovery forwards nothing on its own.

Reverse (-R) forwarding keeps its entry point but now fails loudly instead of silently succeeding; it needs tcpip-forward global requests and a forwarded-tcpip channel handler that the session handler does not carry.

Verification

AI-assisted. Testing level: lightly tested — unit coverage is thorough, but the change has not been run against a live SSH host, so the accept → direct-tcpip → copy_bidirectional data path is unverified end to end. Reviewers should weigh that.

Check Result
cargo check -p bitfun-services-integrations --features remote-ssh-concrete pass
cargo check -p bitfun-services-integrations --features remote-ssh (stub build) pass
cargo test -p bitfun-services-integrations --features remote-ssh-concrete 147 lib + 14 contract tests pass
cargo clippy -p bitfun-services-integrations --features remote-ssh-concrete --all-targets no warnings in new code
cargo check -p bitfun-desktop pass
pnpm --dir src/web-ui run type-check pass
pnpm --dir src/web-ui exec eslint (changed files) pass
pnpm --dir src/web-ui exec vitest run src/features/ssh-remote 30 pass (8 new)
pnpm run i18n:audit pass
pnpm run theme:color-audit pass
pnpm run check:repo-hygiene pass
pnpm run fmt:rs applied

Two checks I could not run, both pre-existing and unrelated (each verified by stashing this branch's changes and reproducing):

  • cargo test -p bitfun-desktop — blocked by a compile error in src/apps/desktop/src/logging.rs:786 (format_args! temporary dropped while borrowed) in an existing #[cfg(test)] block. The bitfun-desktop lib target compiles clean.
  • pnpm run check:core-boundaries — fails inside a git worktree because BitFun-Installer/src-tauri/Cargo.toml resolves the workspace to the main repo root.

Reviewer Notes

New unit coverage worth a look: the bind-fallback path, the three listening-port dialect parsers (ss / netstat / lsof, including shell-banner tolerance and dual-stack row merging), and the wire-shape contract tests for the new camelCase DTOs.

The duplicate check, bind, and registry insert are held under one write lock, so two concurrent requests for the same remote endpoint cannot both bind. Concurrency per forward is capped at 256 via a semaphore that queues rather than drops, and the accept loop's permit wait is under the same cancellation as accept so a stopped forward does not linger.

last_error is the forward's health signal and self-heals: it clears after any connection completes normally, so creating a mapping before starting the remote service does not leave a permanent error badge.

UI screenshots are not included — the dialog has not been exercised against a live host.

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable (ssh.portForward.* in zh-CN / zh-TW / en-US).

Starting a server on an SSH host left it unreachable from here: the
`PortForwardManager` only recorded forwards in a HashMap and logged
"not fully implemented - connections will not be forwarded".

Implement local (`-L`) forwarding for real. Each forward binds a
listener and splices accepted connections onto a `direct-tcpip`
channel, so the remote sshd dials the service and both directions are
copied.

The remote endpoint is a forward's identity; the local port is only an
allocation. A requested local port that is taken is replaced rather
than refused, and the port originally asked for comes back with the
mapping so the UI can say the address moved instead of silently
handing over a different one. Privileged ports fall back the same way.

Three failure modes worth naming:

- Channels are opened through SSHConnectionManager per connection
  instead of against a cached handle, so a forward survives an
  automatic reconnect. That same path would let a forward nobody
  stopped dial back out to a host the user explicitly disconnected
  from, so each connection checks liveness first, and disconnect tears
  down the forwards it carried.
- A local Docker workspace has no SSH session to carry a channel. It
  is rejected when the mapping is added rather than on the first
  request.
- Forwards bind loopback only. Republishing a remote service to the
  local network stays behind an explicit opt-in.

Add remote listening-port discovery (ss, then lsof, then netstat, with
a marker line naming the dialect) so users can pick a real port
instead of guessing one. Discovery forwards nothing on its own.

Expose it through a manual dialog reached from the workspace menu
while connected: nothing is ever forwarded automatically. The table
keeps the remote port and the local address in separate columns
because they routinely differ.

Reverse forwarding keeps its entry point but now fails loudly; it
needs `tcpip-forward` global requests and a `forwarded-tcpip` handler
that the session handler does not carry.
@bobleer bobleer closed this Aug 21, 2026
@bobleer
bobleer deleted the bob/bitfun-remote-port-mapping-86c8bd branch September 24, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant