feat(desktop,host-service): forward remote workspace ports to the local machine
Merged
YM
2mo ago+3660+3660+0123456789012345678901234567890123456789-67-67-01234567890123456789
Links
- ExecPlan:
plans/done/20260824-1849-remote-port-forwarding.md - Docs already said this was on the roadmap:
apps/docs/content/docs/remote-workspaces.mdx("Ports on a remote host")
Summary
- Ports of a remote workspace now reach
localhost:<port>on the user's machine. Selecting the workspace starts the forwards; leaving it stops them. - A taken local port is reported as busy with two choices: stop the local process (only when Superset started it) or forward to another port. The desktop never remaps silently.
- Host-service only forwards ports the port scanner attributes to the requested workspace. Other services on the host stay unreachable.
Why / Context
The ports sidebar already detects ports on a remote host, but localhost:PORT on the desktop pointed at nothing; users had to open an SSH tunnel or a Tailscale route by hand. The docs called forwarding a roadmap item. This is a first, self-contained version of it: relay transport only, desktop only.
How It Works
- Host-service gets a
/tcp/:portWebSocket route (packages/host-service/src/ports/tcp-forward-route.ts) behind the existingwsAuth. It checksportManager.getPortsByWorkspace(workspaceId)and then splices bytes tonet.connect(127.0.0.1, port). Frames are binary and under the relay's 1 MiB limit; a slow peer pauses the socket. - relay2 needs no change. It already splices any
/hosts/:hostId/*WebSocket to the host verbatim through the per-stream dial-back. - Desktop main adds a
ForwardTransportinterface with one implementation,RelayForwardTransport(apps/desktop/src/main/lib/port-forward/).PortForwardManager.sync()is the single entry point: it stops forwards outside the requested set and starts the missing ones on the same port number. Each accepted local TCP connection becomes one relay stream. Exposed as theportForwardstRPC router; listeners are dropped onbefore-quit. - Renderer:
RemotePortForwarder(mounted in the dashboard layout) syncs the selected v2 workspace's remote ports, debounced 200 ms.PortForwardsProvidermirrors main's state;usePortOpenActionsopens forwarded remote ports at their local port; rows show3000 · forwarded,3000 → localhost:54321,3000 · local port busywith the two actions.setJwtpushes the relay JWT to main, which has no auth client of its own.
Manual QA Checklist
Done against a real remote host (Ubuntu, host-service 1.24.2 + this branch's bundle, relay2) from a dev desktop signed in to the production API:
- Open a remote workspace: its ports forward (
43104 active,8765 active,3000 busybecause a local process owned 3000) -
curl http://localhost:43104/on the Mac returns the page served on the host (HTTP 200, ~1 s per new connection) - Click a forwarded row: opens
localhost:<port>per the Settings → Links → Ports preference - "Use another port" on the busy row: label becomes
3000 → localhost:61559 - Leave the workspace: all forwards stop,
lsofshows the local port released; return: they restart - Ask for a port the workspace did not start: host-service closes with
1008 port not owned by workspace - A host on the v1 relay: rows show "Host relay does not support port forwarding (protocol v1)" (probe of
/healthforproto: 2) - Not done: "Stop local " path with a local Superset-owned port (the local collision in the test was a non-Superset process, so only "Use another port" was offered)
- Not done: WebSocket/HMR through the forward (the test server was a plain HTTP server; the tunnel is protocol-agnostic and the relay path already carries binary frames)
Testing
bun run typecheck(desktop, host-service)bun run lint(Biome on all changed files)bun test apps/desktop/src/main/lib/port-forward(7 tests: sync, switch, busy with owner, ephemeral retry, kill-and-restart, failed probe, stopAll)bun testforderiveForwardSyncInputandformatPortRowLabel(10 tests)bun run test:integration:portsinpackages/host-service(8 tests undernode --test, since@hono/node-wsneeds a real Node server): echo both ways, early frames, ownership refusal, missing workspaceId, refused upstream, text frames, teardown both directions
Design Decisions
- Raw TCP tunnel, not an HTTP proxy: dev servers use WebSockets for hot reload; an HTTP proxy would need separate upgrade handling and the relay's HTTP exchange buffers whole bodies. A byte tunnel makes HTTP, WebSocket, gRPC and databases behave the same.
- Transport interface with relay first: the relay works for every host with no setup. A Direct transport (Tailscale, WireGuard, VPN, LAN — no vendor plugin, just an address the host advertises) and an SSH transport plug into the same interface later. Decisions D-2 and D-8 in the plan record the intended auth model for Direct (user JWT, same access check as the relay).
- Only ports attributed to the workspace: same rule
ports.killuses today; blocks127.0.0.1:5432,6379, metadata endpoints unless the workspace runs them. - Same port number, never a silent remap: apps talk to each other by port number; the user must see and choose.
- Buffer from accept in main: under Bun, bytes on an accepted socket are lost until a consumer is attached (even after
pause()); Node keeps them. A bounded buffer from the first tick is also the right shape in production, since a browser sends its request right after connect.
Known Limitations
- Each new local TCP connection costs one relay dial-back (~1 s). Keep-alive connections are fine; tools that open a connection per request feel it.
- A refused upstream on the host (e.g. a loopback firewall) shows as a reset connection locally, and can surface as the relay's generic "Host did not answer" instead of the host's reason.
- Relay protocol v1 hosts are not supported (v1 sends client frames as text only). The v1 relay is deprecated and the cutover to relay2 is in progress.
- The relay sees the forwarded bytes, as it does for terminals today.
Follow-ups
- Direct transport (host-service listens on advertised interfaces with user-JWT auth; desktop prefers direct, falls back to relay)
- SSH transport (
ssh -N -Lwith the user's own config) - Per-connection failure reporting on the row; pre-dial one spare stream per forward to hide the dial-back latency
- CLI:
superset ports forward
Risks / Rollout
- Risk: the host route is new attack surface. It is gated on the host-service secret (relay dial-back) and on port ownership; it cannot reach ports the workspace did not start.
- Rollout: desktop and host-service must both carry this change for forwarding to work; older host-services return 404 on
/tcp/*, which the desktop shows as an error on the row. No data or schema changes. - Rollback: revert the three commits; nothing persists.
https://claude.ai/code/session_01NxTU235jowe5cpVsG47JqW
Summary by CodeRabbit
- New Features
- Added automatic port forwarding for remote workspaces.
- Forwarded ports use the same local port when available, with alternate mappings supported.
- Added shared tunnels that update automatically when switching workspaces.
- Added status indicators for active, busy, and failed forwards.
- Added controls to stop conflicting local processes or retry on another port.
- Bug Fixes
- Improved cleanup and recovery when forwarding sessions or connections close unexpectedly.
- Documentation
- Documented setup requirements, supported hosts, forwarding behavior, TCP limitations, and security considerations.