feat(desktop,host-service): forward remote workspace ports to the local machine

Merged
YM
2mo ago
+3660-67

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/:port WebSocket route (packages/host-service/src/ports/tcp-forward-route.ts) behind the existing wsAuth. It checks portManager.getPortsByWorkspace(workspaceId) and then splices bytes to net.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 ForwardTransport interface 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 the portForwards tRPC router; listeners are dropped on before-quit.
  • Renderer: RemotePortForwarder (mounted in the dashboard layout) syncs the selected v2 workspace's remote ports, debounced 200 ms. PortForwardsProvider mirrors main's state; usePortOpenActions opens forwarded remote ports at their local port; rows show 3000 · forwarded, 3000 → localhost:54321, 3000 · local port busy with the two actions. setJwt pushes 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 busy because 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, lsof shows 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 /health for proto: 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 test for deriveForwardSyncInput and formatPortRowLabel (10 tests)
  • bun run test:integration:ports in packages/host-service (8 tests under node --test, since @hono/node-ws needs 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.kill uses today; blocks 127.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 -L with 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.