Files
web-terminal/docs/EXPLORE_RELAY_SERVICE.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

240 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# web-terminal → Rendezvous-Relay Service: A Decision-Ready Product Exploration
*Turning a single-host byte-shuttle into a multi-tenant "bridge external devices to each customer's self-hosted terminal" service. This is exploration, not a commitment.*
---
## 0. LOCKED DECISIONS (2026-07-01) — prerequisites for the implementation plan
All six §8 decisions are now resolved by the user:
| # | Decision | Choice |
|---|----------|--------|
| 1 | Tunnel substrate | **frp for the MVP scaffold → self-built native WS mux for the real product.** E2E is the driver (frp can't cleanly host per-stream E2E), not billing. Coarse billing (hosts/viewers) works on frp; native mux is for E2E + per-stream control. |
| 2 | Relay trust model | **End-to-end encryption (E2E).** Relay is a *ciphertext-shuttle* — it never sees plaintext keystrokes/output. Steal the sshx crypto model (per-session key via authenticated X25519 ECDH through the relay, AEAD frames via Web Crypto). **The load-bearing security decision.** |
| 3 | Routing key | **Per-tenant subdomain** (`alice.term.<domain>`) — own browser origin per tenant → cookie/localStorage/`sessionId`/CORS isolation for free. Capability token layered on top (subdomain = which host; token = may-you + which PTY). No shared-origin path routing. |
| 4 | Product form | **Both** — open-source the relay + agent (free self-host = competitive necessity + funnel) AND run a hosted SaaS (revenue). Price on **paired hosts + concurrent viewers**, not bandwidth; Hetzner-class infra. |
| 5 | Auth model | Humans: **Passkey/WebAuthn primary** (TOTP fallback, never SMS; OIDC SSO for teams). Agents: **per-host Ed25519 key via single-use pairing code, mTLS with SPIFFE-style short-lived rotating certs** (DB stores only public keys). MVP may start with a shared `clientToken` gate but MUST reach real accounts before the first paid signup. Step-up auth *before opening a session*. |
| 6 | Liability | **Contract states it explicitly** — the agent runs a shell with the customer's own privileges (their accepted risk). Your liability = relay-as-injection-vector, which E2E bounds to "we can disrupt connectivity but cannot read or inject." (Only truthful *because* of decision 2.) Pair with hard revocation + audited operator access; SOC 2 + published security model at business scale. |
**Accepted consequence of E2E (decision 2):** server-side preview thumbnails / manage-page grid **move to CLIENT-SIDE rendering** (the key-holding browser decrypts + renders); relay can't render ciphertext. Ring-buffer replay and multi-device mirror survive (relay stores/forwards ciphertext). *(Assumption pending explicit user confirm; correct here if wrong.)*
---
## 1. Reframe: DDNS vs rendezvous-relay
Your instinct ("like DDNS, my middle server is the bridge") is directionally right about the *stable-name* part and wrong about the *reachability* part — and the gap between them is the whole engineering problem. Plain DDNS only maps a name to an IP; it assumes something can then connect **inbound** to that IP. Your customers can't offer that: they're behind NAT, and increasingly CGNAT (carrier-grade NAT, where the ISP shares one public IP across many subscribers), so there is **no inbound port to forward** — the customer doesn't even own a routable address. The pattern that actually works is a **rendezvous / reverse-tunnel-as-a-service** (the self-hosted ngrok / frp / Cloudflare-Tunnel model): the customer's machine runs a small **host-agent that dials OUT** to your relay over TLS/443 and holds that connection open; external devices connect to *your* relay, which splices them onto the right host's already-open outbound tunnel. Outbound-443 traverses NAT/CGNAT/corporate firewalls exactly the way a browser does, which is why it works where DDNS fundamentally can't. The "DDNS-like" element survives only as a cosmetic convenience: a **stable per-tenant name** (`alice.term.yourdomain.com`) that stays constant while the underlying tunnel reconnects and moves between relay nodes. Name = stable identity; tunnel = reachability. Don't conflate them.
---
## 2. Reference architecture & build-vs-reuse
**The single most important build decision: do NOT hand-roll the tunnel transport.** NAT-traversing, reconnecting, multiplexed, authenticated tunnels are a *commodity* solved by multiple mature open-source projects. Hand-rolling one is 612 months of undifferentiated infra, and because you're bridging **full interactive shells for many tenants**, every transport bug is catastrophic (RCE-shaped). Your moat is **not** the byte pipe — it's the persistent, agent-aware, phone-first session product (ring-buffer replay, walk-away/approve-from-phone, zero customer networking setup) layered on top. Invest ~90% of engineering there and treat the tunnel as a dependency you *configure*, not code you *write*.
**Substrate options, ranked for your exact topology (host dials out → your relay → route external clients by tenant → you provision/deprovision):**
| Candidate | What it is | Fit for you | Verdict |
|---|---|---|---|
| **frp** (fatedier/frp, Apache-2.0) | Go, ~90100k★, `frps` server + `frpc` client, yamux multiplexing, subdomain/vhost routing, server-plugin HTTP hooks (`Login`/`NewProxy`/`NewUserConn`) | Canonical "host dials out + relay routes by subdomain." Hooks let your control plane authorize/provision. You build the tenancy model; frp gives the transport + hooks. | **Default recommendation for the owned substrate.** Free, vendor-independent, preserves the byte-shuttle. |
| **rathole** (Rust, Apache-2.0) | Leaner frp-alike, lower mem/CPU, Noise encryption, high connection density | Great raw pipe, **thinner control surface** (fewer provisioning hooks) | Use *only* if host hardware footprint (Raspberry-Pi-class agents) forces it. |
| **Pangolin** (2026) | Self-hostable tunneled reverse-proxy *management server*: WireGuard "Newt" agents, dashboard, auto-SSL, identity/access | Closest existing *product* to what you're building — already solves enrollment + per-host routing + control-plane UI | **Study or fork it.** Specialize its data path for the shell-mirror/`sessionId` model. |
| **Teleport** (Community OSS) | Identity-aware access proxy; agents **dial out** to a central proxy, browser terminal, RBAC, session recording, tenant isolation, host↔account pairing — *your pattern, generalized* | Solves exactly the first-order concerns your base app lacks — but it's enterprise IAM (short-lived certs, RBAC ceremony) that fights your "zero-setup consumer" goal | **Read its reverse-tunnel + web-terminal source before designing.** Adopt as substrate only if you'll tolerate its weight. |
| **inlets-uplink** (commercial) | Explicitly "multi-tenant tunnels for service providers" — your business, productized | Topological bullseye, but **per-tunnel licensing** (~$250/mo/cluster + per-tunnel) taxes your unit economics and couples margins to a vendor | Great to *feel* the provisioning API in a prototype; wrong to build the company on. |
| **Cloudflare Tunnel / ngrok** | Production tunnels, subdomain routing, provisioning APIs | **You don't own the relay** — they *are* the middle server. Contradicts your entire "I am the bridge" thesis; customers' shells transit them. | **v0 shortcut to prove the UX only.** Never the permanent substrate. |
| chisel / bore | Minimal single-binary tunnel primitives | Point-to-point, no multi-tenant routing model — you'd build all tenancy yourself | Fine for a *minimal* demo; more DIY than frp for the routing you need. |
| Tailscale/Headscale | WireGuard mesh + self-hosted control server | **You explicitly ruled this out** — requires every customer (and every viewing phone) to install/enroll a client into your mesh. Wrong for productized zero-setup. | Right for *your own* infra access; wrong as the *customer-facing* transport. |
**Is this already a product?** Close cousins exist — **sshx** (E2E collaborative browser terminals, ephemeral), **tmate** (ephemeral SSH sharing), **Coder/Gitpod** (they *provision* the machine; you bridge to the customer's *own* machine). But **none is "hosted, multi-tenant, persistent, mobile-first tunnel for Claude Code walk-away sessions."** That specific bundle — survive-the-disconnect sessions + phone-first cockpit + remote approvals + zero customer networking setup — is **unoccupied**. That's your opening.
**Recommendation:** Prototype on Cloudflare Tunnel *or* frp to validate the UX (weeks), then **own the substrate on frp** and build your multi-tenant control plane against its plugin hooks. Borrow Teleport's security model; don't reinvent it.
---
## 3. System design
### The core split: control plane vs data plane
Physically separate the two so (a) the byte-forwarding hot loop has zero DB/auth logic, and (b) a slow or compromised control plane can't leak bytes between tenants.
- **Control plane** (stateful, low-QPS, strongly-consistent — Postgres + Redis): account registry, host registry (`host_id`, `account_id`, `subdomain`, `agent_pubkey`, `status`, `last_seen`), enrollment/pairing, the **live routing table** `host_id → relay_node_id` (ephemeral → Redis with heartbeat TTL; Postgres is source-of-truth for *ownership*, Redis for *where the live tunnel is right now*), and auth issuance (browser cookies/JWT, agent credentials).
- **Data plane** (stateless, dumb, fast — the relay nodes): terminate inbound browser TLS, hold outbound agent tunnels, look up routing in Redis, verify a short-lived signed token, and shovel frames. **No tenant business logic in the pipe.** If a node crashes, agents reconnect and re-register; nothing durable is lost because there was none.
### Host-agent lifecycle
- **Enrollment (one-time):** customer clicks "Add machine" → control plane mints a **single-use, short-TTL pairing code** → on the host, `web-terminal-agent pair ABCD-1234` generates an **Ed25519 keypair locally**, sends the *public* key + code redemption over TLS → control plane binds `host_id → account_id`, stores the public key, assigns the stable subdomain. **Private key never leaves the machine.** (This is the `cloudflared`/Pangolin-Newt model, proven in 2026 production.)
- **Steady state:** agent dials OUT `wss://relay/agent` (443, CGNAT-friendly), authenticates by proving key possession (mTLS client cert / signed challenge), relay writes `host_id → this_node` into Redis with heartbeat TTL.
- **Liveness/reconnect:** ping/pong every ~15s; exponential backoff with jitter (1s/2s/4s… cap 30s — the *same policy the frontend already ships*). On reconnect the agent may land on a different node and re-registers; the subdomain is stable throughout — that's the DDNS-like guarantee the customer sees.
- **Forwards to `127.0.0.1:3000`** — the *unmodified* web-terminal, speaking plain HTTP/WS. The base app thinks it's being hit by a local browser.
### Routing external clients
**Use per-customer subdomain as the primary routing key** — not path-prefix. `alice.term.yourdomain.com` → wildcard DNS `*.term...` → ingress → node holding Alice's tunnel. Subdomain wins because it gives each tenant its **own browser origin**, which naturally partitions cookies, `localStorage` (where web-terminal keeps `sessionId`!), and CORS/Origin. A shared-origin path scheme (`app.com/alice/...`) would let one tenant's page script reach another's WebSocket — catastrophic for a shell bridge. **Two layers of identity, never conflated:** *subdomain = which host*; *`sessionId` in the first `attach` frame = which PTY on that host* (base-app concept, stays entirely inside the tunnel — the relay never parses it; `?join=<id>` and share-QR keep working as passthrough query params).
### Multiplexing
One physical outbound connection per host carries N sessions × M mirrored clients + heartbeat. Run **yamux (or HTTP/2 streams) over a single `wss://`** — each inbound browser connection ⇒ one new logical stream to the agent ⇒ one fresh `localhost:3000` connection. Per-stream flow control means one heavy `vim`/`top` redraw can't starve another. **frp v0.69 ships exactly this** (TCP stream mux + work-conn pool), so you adopt it rather than hand-rolling the mux.
### Scaling / HA
- **Sticky routing:** a host's tunnel terminates on *one* node; its browsers must land there. Solve with **Redis routing table (source of truth) + L7 ingress (Envoy/Traefik/Nginx) that reads it** and forwards to the correct node's internal address. Consistent hashing is an optional latency optimization, not a replacement for Redis.
- **Stateless nodes ⇒ trivially horizontal.** Graceful drain: mark node "draining" → send agents a `reconnect` control frame → they backoff-reconnect elsewhere → drain routing keys → take it down. **The PTY survives the whole time on the customer's machine** — a relay-node bounce is invisible to the running Claude Code task. This falls out *for free* from the existing PTY≠WS decoupling.
- **Control-plane down blocks new connects/enrollment but does NOT drop existing byte-forwarding** (data plane already holds its tokens) — another payoff of the split. Postgres primary+replica; Redis Sentinel/cluster. Regional relay clusters + anycast when geography demands.
### End-to-end data path
```
┌─────────────┐ ┌──────────────── YOUR CLOUD ─────────────────┐ ┌── CUSTOMER MACHINE (behind NAT/CGNAT) ──┐
│ Phone / │ │ │ │ │
│ Browser │ wss │ ┌──────────┐ subdomain→ ┌─────────┐ │ │ ┌────────────┐ ┌──────────────┐ │
│ alice.term │═════▶│ │ Ingress │──authz────── │ Control │ │ │ │ host-agent │ http/ │ web-terminal │ │
│ /ws │ 443 │ │ L7 route │ Redis:host→ │ Plane │ │ │ │ dials OUT │ ws │ (UNCHANGED) │ │
└─────────────┘ │ └────┬─────┘ node lookup └─────────┘ │ │ │ holds mux │──────▶ │ 127.0.0.1: │ │
▲ │ │ forward to node holding Alice tunnel│ │ │ tunnel │ local │ 3000 │ │
│ │ ▼ PG: accounts/hosts │ │ └─────▲──────┘ │ node-pty+ring│ │
│ output │ ┌──────────────┐ │ │ │ └──────────────┘ │
└──────────────│──│ Relay node 7 │ 1 mux stream per browser │ │ │ (outbound only; NO inbound port)│
(verbatim │ │ (DATA PLANE) │═══ single wss + yamux ════│══════│════════┘ │
bytes) │ │ stateless │ many streams / 1 link │ │ │
│ └──────────────┘ │ └──────────────────────────────────────────┘
└─────────────────────────────────────────────┘
Byte hot-loop: Phone ─ Ingress ─ Relay7 ═yamux═ Agent ─ 127.0.0.1:3000 ─ pty.write()
keypress → xterm onData → wss → ingress → relay → yamux → agent → local WS → pty.write() → shell stdin
Nobody between the phone and pty.write() parses the terminal stream. attach/input/resize/output/exit stay end-to-end.
```
### What stays UNCHANGED (the load-bearing claim)
The **server stays a byte-shuttle** — no ANSI/terminal parsing in relay or agent. **PTY≠WS decoupling, ring-buffer replay, multi-client mirror, latest-writer-wins sizing, orphan reclaim/`IDLE_TTL`, `pty.kill()` on exit** — all local to the customer's server, untouched. **`sessionId` first-frame, `?join`, share-QR, manage-page previews, hooks** — pure passthrough. **`protocol.ts` and the session model — byte-for-byte identical.** All 212 tests keep passing. The relay adds a transport layer *underneath* the existing WebSocket; it is not a rewrite of it.
**The one base-app touch-point (config, not code):** the Origin check. Traffic arriving via the loopback agent will carry Origin `https://alice.term.yourdomain.com` instead of a LAN IP. **Do not weaken the check.** Set the existing `ALLOWED_ORIGINS` env var (already a knob!) to include the assigned subdomain during agent install. Zero code change; CSWSH protection stays meaningful because the real browser Origin is validated end-to-end.
---
## 4. Security & tenant isolation (the crux — no hand-waving)
You are proposing to operate a central relay that bridges **full interactive shells for many customers**. That is one of the highest-blast-radius services you can build: a full relay compromise is, worst-case, **RCE as the logged-in user on every connected customer machine**. The base app was *deliberately* built with none of the required controls (no auth, no multi-user isolation, Origin-check only). Treat every control below as net-new, and **treat the shell bytes as radioactive**.
### 4a. Account & pairing — three distinct trust relationships, never conflated
1. **Host-agent → account.** **Never a shared static token in a config file** (leaks via dotfiles/backups/screen-share → attacker registers as the victim's host). Use the pairing-code → per-host Ed25519 keypair flow (§3). Then authenticate the tunnel with **mTLS using that per-host key**, not a bearer token. Issue **short-lived, auto-rotated certs (SPIFFE/SPIRE model)** — revocation becomes "just stop renewing" + kill the live tunnel, no CRL/OCSP distribution nightmare. *Why asymmetric matters:* a bearer token is a symmetric secret your DB also holds — a relay DB breach leaks every host's credential. A per-host **public** key in your DB is useless to a thief.
2. **External device → account.** Boringly standard, strong, not invented by you: **Passkeys (WebAuthn) as primary** (phishing-resistant, GA across major IdPs in early 2026 — not optional when the payload is a root-capable shell); fallback email+password **with mandatory TOTP or a second passkey**; **never SMS OTP** (SIM-swap → shell = catastrophic); OIDC SSO for business customers. Short-lived access tokens + refresh, device-bound (DPoP) where possible.
3. **Relay authorizes device → host (the actual gate).** On **every** connection: `session.account_id == host.account_id`? Only then splice the WebSocket. **Capability-scoped**, not just ownership: a token names the exact host(s) and rights (attach vs manage vs kill), so a QR share link can grant *one session, read-or-write, expiring* without granting the whole account.
### 4b. The hard isolation guarantee
**Invariant: customer A's browser can NEVER reach customer B's host.** Everything else is negotiable; this is not. Enforcement:
- Ownership join is the *only* path — **no code path resolves a host by raw address/port/user-supplied hostname**; the client names only a `host_id` it's authorized for.
- **Deny-by-default, server-side.** `account_id` is derived from the *authenticated session*, never from a client-supplied field (client-supplied `tenant_id`/`host_id` as source-of-truth is the classic multi-tenant IDOR).
- Push toward **silo for the data plane**: tunnels for different tenants must not share mutable in-process state.
**Cross-tenant failure modes to design against explicitly:** (1) **IDOR on `host_id`** → ownership join + capability tokens + a **CI test asserting cross-tenant attach → 403 as a permanent tripwire**; (2) **`host_id` guessing/reuse** → unguessable UUIDv4, never recycled; (3) **cross-tenant buffer bleed** — the base app's ~2MB ring buffer + `RingBuffer.tail()` previews become cross-tenant memory at a relay; a shared/global buffer pool or off-by-one can splice A's stdout into B's stream → **no global mutable buffers, per-connection allocation** (E2E makes this class *impossible*, not just unlikely); (4) **reconnect races** — reattach must re-validate `session_id` against `account_id`, never resolve purely by client-provided id; (5) **Origin/CSWSH is now insufficient** — keep it *and* require an authenticated capability token on the WS upgrade; (6) **wildcard-subdomain / Host-header confusion** → route by authenticated session, not the Host header.
### 4c. The trust decision: relay-sees-plaintext vs E2E
This is *the* decision — it determines your liability, your feature set, and whether a breach is "embarrassing" or "company-ending."
- **Option A — relay sees plaintext.** Cheapest; keeps *every* server-side feature (preview thumbnails, relay-side replay, server search, session recording). But you'd be **operating a live keylogger-shaped honeypot** for hundreds of developer machines — `sudo` passwords, `.env` secrets, API keys, and pointedly **Claude/Anthropic tokens** scrolling through in plaintext. You inherit SOC 2 / DPA obligations, encryption-at-rest, insider-access controls, breach-notification, and the standing reality that **one relay compromise = every customer's live credentials exfiltrated in real time.** For a small operator this is a near-uninsurable posture. **Not recommended as default.**
- **Option B — End-to-end encryption (RECOMMENDED).** Browser and host-agent negotiate a **per-session symmetric key the relay never learns**; the relay moves only ciphertext. This is exactly what **sshx** does (Argon2 + AES, key material in the URL fragment `#` which browsers never send to the server) — **steal its crypto model wholesale.** Concretely: authenticated **X25519 ECDH through the relay** (relay forwards the handshake, can't derive the secret), **bound to the host's enrollment key and the device's auth** so it's not a bare DH the relay could MITM (browser verifies the host's pubkey fingerprint, TOFU or pinned at enrollment); all `input`/`output`/`resize` frames **AEAD-encrypted** (AES-GCM / XChaCha20-Poly1305, per-message nonces) via Web Crypto. **This is a marketing asset too:** "we cannot see your terminal" is a *verifiable* claim.
**What E2E costs you (decide consciously):**
- **Preview thumbnails / manage-page grid die server-side** — the relay can't render a screen it can't read. Move preview rendering **client-side** (the authorized, key-holding browser decrypts and renders), or drop live previews for unattended sessions. **This is the biggest product hit.**
- **Server-side ring-buffer replay SURVIVES** — the relay stores/replays *ciphertext* (doesn't need to read it); the reconnecting browser presents the session key (re-derivable from account/passkey, not lost on refresh) to decrypt. "Refresh and the session is still there" still works.
- **Server-side search & recording → client-side only** (i.e., not server-side at all).
- **Multi-device mirror still works** — every authorized device derives the same key, but the key must reach each joining device through an **authenticated channel** (derived from account auth), *not* a plaintext QR a shoulder-surfer or the relay could reuse.
Even with E2E you still own **routing and availability** — so tenant isolation, auth, DoS, and MAC+sequence-numbering frames (to prevent replay/injection) remain your problem.
### 4d. Blast radius & liability — stated honestly
There is **no way to make this risk zero** while offering the product — the agent runs a shell with the user's own privileges (that *is* the product; it's the customer's risk to accept). The engineering job is to **bound the relay's blast radius** so a breach degrades instead of detonating. Controls, ranked by impact: (1) **E2E** — the biggest lever; a relay compromise yields ciphertext + routing metadata, not a live credential feed, and the attacker can't inject valid encrypted frames — *this is the difference between "we had an incident" and "we ended every customer";* (2) **per-host asymmetric keys, no shared secrets** — a DB dump can't impersonate a host; (3) **no shell bytes at rest** — ciphertext-only buffers, short TTL; (4) **least-privilege signed auto-updating agent** — runs as user not root, so you can push emergency patches (a compromised update = same blast radius, so sign + pin the channel); (5) **rapid global revocation** — one control to kill all tunnels + stop cert issuance + rotate relay keys; (6) **MFA-gated, time-boxed, audited operator access** — under E2E your own staff *cannot* silently read a session by construction.
The **honest customer-facing statement** — defensible *only if you ship E2E*: *"We route your session; with E2E we cannot read it. The agent runs on your machine with your privileges — that access is inherent to the product. A breach of our relay cannot read your terminal, but can disrupt connectivity; we cannot inject commands into an E2E session."*
### 4e. Minimum bar to operate responsibly (checklist)
**CRITICAL — block launch / do not onboard a second paying customer until all true:**
- [ ] Auth on **every** device connection (passkey primary; TOTP fallback; no SMS). No anonymous shell access, ever.
- [ ] Per-host asymmetric identity via single-use short-TTL pairing code; private key never leaves host; relay stores only the public key.
- [ ] Server-side, deny-by-default tenant authz: `account_id` from the authenticated principal (never client-supplied), joined against `host.account_id` on every connect **and every reattach**.
- [ ] Automated cross-tenant isolation test in CI (device A → host B = 403) as a permanent tripwire.
- [ ] E2E encryption of terminal payload (per-session key via authenticated ECDH through the relay; relay routes ciphertext only). *If you launch Option A instead, you've accepted operating a secret-exfiltration honeypot and need the compliance program to match — advised against.*
- [ ] No plaintext shell bytes at rest on the relay (ciphertext buffers, short TTL).
- [ ] Unguessable, non-recycled `host_id`/`session_id`; reattach re-validated against account.
- [ ] Global + per-host revocation that kills live tunnels within seconds.
- [ ] Authenticated WS handshake (retain Origin/CSWSH **and** require a capability token).
- [ ] Secrets in a manager/HSM, validated at startup, never logged. No public-internet exposure of raw host ports.
**HIGH — before scaling past a handful:** per-tenant rate limits/quotas (sessions, bytes, enrollments); SPIFFE-style short-lived auto-rotated host certs; immutable control-plane audit log with cross-tenant-crossing alerts and zero payload logging; step-up auth *before opening a session* (not just at login); signed/pinned agent auto-update; least-privilege agent; MFA-gated time-boxed operator access; "assume relay compromised" IR runbook.
**MEDIUM — maturity:** SOC 2 Type II; third-party pentest targeting tenant crossing + E2E MITM-by-malicious-relay; client-side preview rendering to recover the manage-page UX; login/session anomaly detection; published verifiable security-model doc.
**The one-line upgrade to the base app's philosophy:** "the server is a byte-shuttle" is the *right* instinct to carry into the relay — but upgrade it from **byte-shuttle to ciphertext-shuttle.** The relay should move opaque, authenticated, per-tenant-routed blobs and know as little as physically possible about what's inside.
---
## 5. MVP & codebase impact
**The MVP demo IS the pitch:** *"I'm at a café, I open `alice.term.yourdomain.com` on my phone, type my token, and I'm in the shell running on my laptop at home — and I never configured a router, a VPN, or a static IP."* Everything else is hardening.
**Thinnest end-to-end system (v0.8, single-node relay):**
1. **One relay node** — one small VPS, wildcard DNS `*.term.yourdomain.com`, wildcard TLS (LetsEncrypt DNS-01 or terminate at Cloudflare in front). One process, no clustering.
2. **Flat account table** (SQLite): `{ accountId, subdomain, agentToken(hashed), clientToken(hashed) }`. Two tokens because agent and browser authenticate on different legs. Manual provisioning is fine.
3. **Host-agent** (~300600 LOC) — outbound WSS to relay, present `agentToken`, register `subdomain`, hold open with the backoff logic you already ship, open loopback streams to `:3000`.
4. **Data plane** — relay routes by subdomain to the agent's control connection, asks it to open a new logical stream; agent dials `ws://127.0.0.1:3000` (unmodified web-terminal) and splices. Relay sees opaque stream-tagged frames, never terminal semantics.
5. **Browser auth** — relay demands `clientToken` at the subdomain root, sets a signed cookie, then upgrades. **This is the auth the base app deliberately never had, and it must live at the relay edge** — once you're on the public internet, Origin-checking stops CSWSH but not "a stranger with the URL."
Total: **1 VPS + wildcard DNS/TLS + ~300-line agent + ~500-line relay + a 4-column table + a password gate.** Two legit shortcuts: **wrap `frpc` as the agent** (fastest to demo, zero transport code) or **hand-roll WS-over-WS multiplex** (keeps everything in your TS stack, gives you the auth/metering hooks frp won't). **Decision: prototype on frp to prove demand, port to native multiplex once you charge money.**
**Codebase impact — what changes in `src/`:** *Nothing structural.* `src/server.ts` byte-shuttle, `wss.handleUpgrade`, PTY/session decoupling, ring-buffer replay, `src/session/*`, `src/protocol.ts`, `src/config.ts`**100% unchanged.** The relay path terminates at `ws://127.0.0.1:3000` — the agent is *just another local WS client*. The **one subtlety** (`src/server.ts` Origin check, ~`:646`): the agent connects from loopback presenting a loopback-allowed Origin, or set `ALLOWED_ORIGINS` to include the subdomain at install — net change to `server.ts` is **a few lines, ideally zero.** Frontend (`public/main.ts`, same-origin scheme-following M6) needs **no change** — served from `alice.term...`, the browser connects same-host and the relay proxies. **New components live in new top-level packages/repos, NOT edits to any task's `Owns:`:** `agent/` (host-agent) and `term-relay/` (control+data plane + account store). Keep the immutability discipline from `src/session` — routing/account records are immutable snapshots swapped atomically, not mutated in place.
---
## 6. Cost, distribution & competitive framing
**Cost physics: interactive terminal is cheap; persistence and previews are the tax.** A human typing + reading a shell is kilobytes/minute — a power user's *interactive* month is well under 1 GB. The real cost is **always-on connections**: every agent holds a persistent socket 24/7, and v0.4 **live-preview thumbnails stream even when idle** if a manage-page is open. **Budget the relay by concurrent connections (memory/FD), not GB.** Host on **Hetzner/bare VPS, not Fly/AWS** — Hetzner ~€5.49/mo with 20 TB included / ~€1/TB overage; egress on Fly/AWS is the hidden bill-killer for a persistent-bandwidth product. One Hetzner CX node fronts hundreds of idle agents comfortably; egress is a rounding error dominated by preview streams. **Product mitigations baked in:** pause preview streams when no viewer is focused (extend the existing attachment-gating in the session model), and idle-disconnect viewer legs while keeping the agent control connection alive.
**Pricing — offer both:**
- **Free self-hosted relay (open-source relay + agent)** — competitive necessity. frp/Cloudflare-Tunnel/Tailscale all have free self-host; a closed relay with no free path is dead on arrival. Captures goodwill + funnel.
- **Hosted SaaS** — the revenue. Tier on the two real cost drivers (**paired hosts** + **concurrent viewers/preview**), not bandwidth: *Free* (1 host, 1 viewer, random subdomain, no preview); *Personal ~$68/mo* (12 hosts, custom subdomain, preview, 30-day persistence — anchored to ngrok Personal $8 / Tailscale $68); *Pro ~$20/mo* (up to ~5 hosts, multiple viewers, IP allow-list — ngrok Pro is $20); *Team/Enterprise quote* (SSO, audit log, dedicated relay — Teleport's $1240/resource + $50k self-host floor is your *ceiling*, not competitor). **Margin trap:** a customer leaving a 4-thumbnail manage page open on a big monitor 24/7 streams continuously — the preview-pause mitigation above is a *business* control, not just a UX one.
**Distribution (agent install), ranked by MVP-fit:** (1) **`npx web-terminal-agent pair <code>`** — customers already `npm install` the app; zero new toolchain (MVP default); (2) **single static binary** (`bun --compile`/`pkg`, or rewrite in Go like frpc) — one `curl | sh`, trivial `systemd`/`launchd` service (do at v0.9 once the protocol stabilizes); (3) Homebrew/Docker for power users. **Pairing UX is make-or-break** — the whole promise is "no networking setup," so onboarding must match: sign up → short pairing code → `npx web-terminal-agent pair ABCD-1234` (exchanges code for durable token, installs as a service, connects) → dashboard flips to "● online." **KPI: first-shell-in-under-2-minutes.**
**Differentiation (you compete with "what a dev reaches for to get their phone into their home shell," not with tunnel primitives):**
- **vs ngrok** — ngrok is a generic tunnel; you'd still run web-terminal behind it and hand-roll auth. You sell **the terminal + tunnel + phone UI as one paired appliance.** ngrok's tightening 2026 per-endpoint pricing is actively pushing devs to alternatives — tailwind.
- **vs Cloudflare Tunnel** — free and excellent, but raw infra the customer must configure (`cloudflared` + DNS + Access). Your wedge: **"you configure nothing."** (You can even run CF in front of your relay for TLS/DDoS.)
- **vs Teleport** — the enterprise PAM ceiling ($1240/resource, $50k+ self-host). **Don't fight it** — overkill/unaffordable for your walk-away vibe-coder persona; it's your future enterprise-tier *roadmap*, not your competitor.
- **vs "just use Tailscale"** — your **sharpest, most honest wedge.** Tailscale makes the *customer* the network admin (install on laptop *and every phone*, understand the tailnet). Your pitch: **"the viewing device needs nothing but a browser and a URL"** — zero install on the phone in your hand, no mesh to reason about. That café/borrowed-device/hand-it-to-a-teammate moment is exactly what Tailscale *can't* do. **Lead every marketing sentence with it.**
**One-line positioning:** *"Ngrok's zero-config reachability + a real terminal + a phone UI, paired in one command — so you (and only you) can reach your own machines' Claude Code sessions from any browser, with no VPN and nothing to install on the device in your hand."*
---
## 7. Phased roadmap
| Phase | Scope | Effort |
|---|---|---|
| **v0.8 — MVP single-node relay** | 1 VPS, wildcard DNS+TLS, host-agent (wrap `frpc` or native WS-over-WS), 4-col account table (manual provisioning), password gate + signed cookie at the relay edge, `npx …agent pair`. Prove the café demo. | **12 wks** (small on frp; ~2 wks native). Biggest risk: TLS/wildcard + agent reconnect edge cases. |
| **v0.9 — Multi-tenant SaaS** | Native WS multiplex (drop frp), self-serve signup + dashboard, pairing-code flow, per-account subdomain issuance, plan tiers + metering (hosts/viewers), preview-pause-when-unfocused, single static-binary agent + service install, basic rate-limit/abuse controls. | **35 wks.** The account/billing/dashboard plane is most of it; transport is small. |
| **v0.10 — Security hardening** | Real accounts (passkey/OIDC) replacing shared `clientToken`, per-session capability tokens, IP allow-list, audit log of every attach, agent cert rotation, **E2E encryption phone↔agent (relay routes ciphertext by subdomain only)**, third-party pentest. | **48 wks.** **E2E is the hard, non-negotiable item** — the difference between "one bad day" and "every customer's shell." Do it **before** scaling users, not after. |
| **v0.11+ — Scale** | Multiple relay nodes + geo/subdomain-sharded routing, horizontal control plane (Postgres/Redis), connection-count autoscaling, HA/failover, per-tenant observability, SLA + status page, optional enterprise tier. | **Ongoing.** Only when concurrent-agent FD/memory limits force it (hundreds→thousands). **YAGNI until the socket count forces it.** |
**Sequencing opinion:** ship v0.8 on frp *this month* — the demo, not the architecture, proves demand. But **gate paid signups (v0.9) behind the auth edge, and gate any growth push behind E2E (v0.10).** The base app's "no auth, no isolation" non-goals become CRITICAL the instant strangers can reach the port; a relay that can read every tenant's shell is a single catastrophic blast radius. Treat "keep the relay a dumb (cipher)pipe" as a *security* property — it can't leak what it can't parse — not just a simplicity one.
---
## 8. DECISIONS FOR USER
1. **Reuse a tunnel vs bespoke relay?****Reuse.** Prototype v0.8 on **frp** (or Cloudflare Tunnel purely for a throwaway demo), own the substrate on **frp** long-term (free, hooks, subdomain routing), port to native WS-multiplex by v0.9 when you charge money. Do **not** hand-roll NAT-traversal/mux, and do **not** build the *business* on Cloudflare/ngrok/inlets (being "the bridge" is your value prop — you must own the relay). *Open sub-question: adopt Teleport Community wholesale to inherit enterprise auth/isolation/audit, accepting its weight — or stay lightweight and build the tenancy glue yourself? Recommendation leans lightweight for the consumer persona, but read Teleport's source first.*
2. **Relay sees plaintext vs E2E?****The pivotal call.** Recommend **E2E (sshx model)** before scaling users. Plaintext is cheaper and keeps server-side previews/search/recording but makes you a live secret-exfiltration honeypot with an uninsurable breach posture. **Consciously accept the cost:** server-rendered previews die (move client-side), server search/recording die; replay + multi-device mirror survive. Decide this *before* v0.10, ideally design for it from v0.8.
3. **Subdomain vs token routing?****Per-tenant subdomain** as the routing key (own browser origin per tenant → cookie/localStorage/`sessionId`/CORS isolation for free). A capability *token* still authorizes the specific host+rights on top; the two layers coexist (subdomain = which host, token = may-you + which-PTY). Avoid shared-origin path-prefix routing.
4. **Self-host-relay product vs hosted SaaS?****Both.** Open-source the relay+agent (free self-host = competitive necessity + funnel) **and** run a hosted SaaS (the revenue), priced on **paired hosts + concurrent viewers**, not bandwidth, on **Hetzner-class infra**.
5. **Auth model?****Passkeys (WebAuthn) primary** for humans (TOTP fallback, never SMS; OIDC SSO for teams) + **per-host Ed25519 keys via single-use pairing codes, mTLS with SPIFFE-style short-lived certs** for agents. MVP may start with a shared `clientToken` gate but must reach real accounts before paid signups. **Step-up auth before opening a session**, not just at login.
6. **Who owns liability for bridged shells?** → Decide and put it in the contract. The agent runs a shell with the customer's own privileges — **that access is inherently the customer's accepted risk.** Your liability is the *relay as injection vector*, which **E2E bounds** to "we can disrupt connectivity but cannot read or inject." You cannot make this statement truthfully under the plaintext option — which is itself an argument for E2E. Pair it with a hard revocation control, audited operator access, and (at business scale) SOC 2 + a published, verifiable security-model doc.