Files
web-terminal/docs/EXPLORE_REMOTE_ACCESS.md
Yaojia Wang 2af57e6686 feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
2026-07-02 06:10:16 +02:00

12 KiB

Using web-terminal off your LAN — exploration

Exploration only (not a committed plan). Produced by a multi-agent research pass (codebase-fit + security-bar verified against the repo; approach-landscape + relay-design synthesized). The crux is auth, not transport.

1. Why a bridge/rendezvous is needed (the NAT reality)

The host sits behind NAT — very likely CGNAT — so it has no public inbound reachability: nothing on the internet can open a connection to it, and you can't reliably port-forward through carrier-grade NAT. The fix is always the same shape: the host makes an outbound connection to an always-reachable middle point, your remote device connects to that same middle point, and it splices the two together. That "middle point" is your bridge. The only real question is who runs it — you (self-hosted relay / VPS) or a product built for this (Tailscale coordination+DERP, Cloudflare Tunnel). All three solve NAT identically via an outbound dial; they differ enormously in how much security-critical code you end up owning.

2. The real headline: off-LAN makes AUTH mandatory

The app today has zero authentication, by design. It hands a full interactive shell to anyone who can reach the port. On the LAN that's fine — the attacker must already be on your trusted WiFi, and Origin validation blocks the one browser attack (CSWSH). The moment the port is reachable off-LAN, "already on my WiFi" evaporates and the attacker becomes the entire internet plus bots that scan every new hostname within minutes. An unauthenticated shell reachable publicly is remote-code-execution-as-a-service — game over on the first hit.

Auth was an explicit v0.1 non-goal (TECH_DOC §7, CLAUDE.md). Off-LAN changes that decision, independent of transport. Two accepted-on-LAN risks become unacceptable:

  • No auth anywhere in src/ — only Origin checks, rate-limiters, VAPID push secrets exist.
  • Unauthenticated info-leak endpoints/sessions, /live-sessions, /live-sessions/:id/preview, /projects have no Origin guard by design (src/server.ts:246-250). /sessions returns Claude cwds + first ~120 chars of prompts + resumable session UUIDs; the preview route streams live screen contents. World-readable the instant a port is exposed.

Origin validation is not authentication. A public hostname is guessable; anyone who loads your real URL passes the Origin check. Keep it (still the CSWSH defense) but it gates no attacker who just visits the URL. isOriginAllowed (src/http/origin.ts:22-53) matches scheme+host+port strictly, default-denies undefined.

Frame the work as: "add an auth/identity layer in front of the shell." Transport is secondary — and the best transports hand you that auth layer for free.

3. Options spectrum

Option Reachability Who runs the middle Auth story Setup Cost Code changes
Tailscale tailnet RECOMMENDED Your enrolled devices only; port invisible to internet Tailscale coord; traffic E2E WireGuard Device enrollment (SSO-backed) — strong, built-in Low Free tier None — just add the MagicDNS origin to ALLOWED_ORIGINS
Tailscale Funnel Public HTTPS URL (anyone with link) Tailscale edge None — public ingress → back to needing app-auth Low Free Whitelist origin + build app-auth
Cloudflare Tunnel + Access (for a public URL) Public HTTPS URL gated by SSO Cloudflare edge (terminates TLS → sees bytes) Real SSO at edge (email OTP / Google / GitHub / TOTP) Low-Med Free (≤50 users) Whitelist origin; verify CF-Access-Jwt-Assertion
Self-hosted relay (your mental model) Whatever you allow You (TLS, auth, uptime, abuse) You build all of it High Small VPS Whitelist origin + app-auth + relay must enforce Origin
Naked port-forward DO NOT Public IP You + router App-auth on bare internet Med Highest risk Violates CLAUDE.md "never port-forward"

RECOMMENDED default: Tailscale. Already the deployment CLAUDE.md/TECH_DOC §7 names; solves auth + encryption + NAT in one; adds zero security code to a byte-shuttle designed to have none; the internet can't even see the port to scan it. Config-only (ALLOWED_ORIGINS merge at src/config.ts:214-223).

If you insist on a self-hosted bridge: Cloudflare Tunnel and Tailscale are "the server in the middle," done correctly, with TLS/auth/DoS-protection you'd otherwise reinvent. Build your own only if you refuse both a VPN client and a third-party edge.

4. Minimum security bar (regardless of transport) — the crux

Every item must hold before any off-LAN exposure:

  1. Auth in front of the shell — proxy identity (Tailscale/CF Access) or app-login. No unauthenticated path to the WS upgrade (src/server.ts:635).
  2. TLS / wss on every off-LAN hop — no plaintext ws:// leaving the host (keystrokes carry passwords/API keys/Claude tokens). Frontend already picks wss on HTTPS (public/terminal-session.ts:44-47, the M6 fix) → a TLS-terminating proxy makes this automatic, no code change.
  3. Keep Origin validation, whitelist the new hostname — as https://host with no :443 (isOriginAllowed compares .port strictly; standard ports parse to empty string, origin.ts:50). Forget it → every WS handshake 401s and every state-changing POST 403s.
  4. No bare port-forward — outbound tunnel/overlay only.
  5. Brute-force protection on the auth endpoint — reuse the per-IP sliding-window limiter (src/server.ts:104, e.g. 5/min/IP), crypto.timingSafeEqual (never ===), generic errors.
  6. Secrets in env only, validated at startup, never hardcoded/logged (VAPID precedent); hash tokens at rest, file mode 0600 (src/push/subscription-store.ts:21); scrub logs (push-service.ts:148).
  7. Auth audit log — login success/fail + IP + timestamp; every WS attach; every approve/reject.
  8. Token/session expiry + revocation path.
  9. Stay single-operator — this is "prove it's me/my device," NOT multi-tenant isolation.
  10. Gate the info-leak endpoints off-LAN (src/server.ts:246-250).

Two gotchas that bite:

  • Browsers can't set custom headers on a WS handshake. A WS token rides as a query param on the /term URL or an HttpOnly cookie validated in the upgrade handler. Query-string tokens leak via logs/history/Referer → prefer a cookie set by POST /login.
  • POST /login needs the same requireAllowedOrigin CSRF guard (src/server.ts:332-341) as other state-changing routes.

5. If self-hosted relay: architecture sketch

Browser ──wss──▶  Relay (your VPS, public IP, TLS + auth)  ◀──persistent outbound wss── Host (web-terminal)
                          │  splices byte streams verbatim  │
        remote device            rendezvous / control            behind NAT/CGNAT

The host holds a persistent outbound WS/TCP control connection to the relay; a remote client connects (and authenticates at the relay); the relay multiplexes a channel and pipes /term frames byte-for-byte between the two.

Why the app survives unchanged: the session model is fully transport-agnostic. sessionId is client-generated and carried in the first attach frame (src/protocol.ts); PTY lifecycle is decoupled from WS lifecycle; ring-buffer replay and multi-client mirror operate on the byte stream after the handshake. A relay that forwards bytes verbatim is invisible to this layer. BIND_HOST=0.0.0.0 (src/config.ts:248) already accepts a co-located agent's loopback forward.

Relay-specific traps (why it earns "High" effort):

  • The relay collapses the apparent client: req.socket.remoteAddress becomes the relay's IP, and unless the relay forwards the browser's real Origin verbatim, the check at src/server.ts:645-651 sees the relay's origin. So either the relay forwards the true Origin, or the relay itself becomes the CSWSH enforcement point. You can't have neither.
  • You now own the relay's TLS, auth/pairing, abuse control, DoS protection, uptime.

Build-vs-reuse verdict: DON'T hand-roll. If you truly want self-hosted, put a proven reverse-tunnel between host and VPS — cloudflared (self-hostable), frp, or chisel — and terminate TLS + enforce auth at the VPS (Caddy/nginx + auth, or an identity proxy). A bespoke Node relay re-implements WebSocket multiplexing, framing, TLS, and auth for no benefit. Effort: reuse-a-tunnel ≈ a day of ops + the same app-auth as Option C; bespoke relay ≈ 1-2 weeks + an indefinite security-maintenance tail.

Minimal app changes for any relay/tunnel path:

  • Whitelist the public origin via env merge — ALLOWED_ORIGINS=https://<name> (src/config.ts:214-223). Config only.
  • Add the auth gate: HTTP middleware at src/server.ts:229 (before express.static :243) and a token/cookie check inside the upgrade handler right after the Origin check :646, before wss.handleUpgrade :654. Auth enforced before handleUpgrade → the post-handshake stream, ring-buffer replay, and multi-client mirror are never touched. All invariants intact.

Phase 0 — Tailscale (do first; ship today). Install Tailscale on host + your devices; add the MagicDNS origin to ALLOWED_ORIGINS; optionally Tailscale Serve for TLS/wss.

  • Code changes: none (config only). Outcome: "usable off-LAN from my own devices," fully solved, threat model kept honest (only enrolled devices reach the port). For most of the stated use case, you can stop here.

Phase 1 — Off-LAN safety rail (small). A REMOTE_MODE=1 flag (src/config.ts:245) that refuses to start unless TLS + an auth gate are configured, so LAN-only stays the safe default and off-LAN can't be enabled by accident. Move the info-leak read routes behind the gate when remote mode is on.

Phase 2 — App-level auth gate (needed for a public URL without SSO, i.e. Funnel/self-hosted relay; worthwhile as defense-in-depth behind CF Access):

  • POST /login verifying an env secret with crypto.timingSafeEqual, rate-limited (src/server.ts:104), guarded by requireAllowedOrigin, setting an HttpOnly+Secure+SameSite=Strict cookie with expiry.
  • Cookie validated in the upgrade handler at src/server.ts:646 (before handleUpgrade) and by an HTTP middleware at src/server.ts:229.
  • New AUTH_TOKEN/session config in loadConfig. Start single shared token → per-device revocable tokens (hashed table + labels) if needed. Full TDD + brute-force + rotation tests.
  • Code changes: Medium-High. This is the security-critical bit — why Phase 0 is so attractive.

Phase 3 (optional) — Public URL for un-enrollable devices. Prefer Cloudflare Tunnel + Access (SSO at edge, ≤50 users free) over a relay; verify CF-Access-Jwt-Assertion so a tunnel-bypass ≠ instant shell. Self-hosted relay only if you reject both a VPN client and Cloudflare (still needs Phase 2).

7. Decisions for the user

  1. Whose devices? Only devices you own (→ Tailscale, zero code) — or a device you can't enroll (→ Cloudflare Tunnel + Access, or Funnel/relay + app-auth)? This one answer collapses most of the tree.
  2. Delegate auth to an identity proxy, or build it in the app? Delegating adds no security-critical code to a byte-shuttle designed to have none (favored by KISS/YAGNI for a single operator).
  3. Managed rendezvous vs self-hosted relay? Cloudflare/Tailscale are the middle server, with TLS/auth/abuse-control included. Self-hosting means owning all of that + uptime.
  4. Trust the proxy with plaintext? Cloudflare terminates TLS at its edge (can see terminal bytes). Tailscale is end-to-end WireGuard — if you don't want a third party seeing keystrokes, Tailscale wins.
  5. If app-auth is built: credential model? Single shared token (simplest) → per-device revocable tokens (real revocation) → SSO/TOTP (genuine 2FA). Pick the start and whether 2FA is in scope now.

Bottom line: the instinct ("I need a bridge") is half-right about NAT and wrong about building it — the bridge already exists as a product. Start with Tailscale (Phase 0, zero code); it solves NAT, TLS, and auth together and is already the project's recommended deployment. Reach for a public URL (Cloudflare Tunnel + Access) or a self-hosted relay only if you must open it on a device you can't enroll — and there, auth-before-handleUpgrade is non-negotiable.