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

174 lines
12 KiB
Markdown

# 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.
## 6. Recommended path (phased) — v0.8
**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**.