Files
web-terminal/docs/PLAN_RELAY_PHASE1.md
Yaojia Wang 95b9cccf07 feat(relay): Phase1 foundation — P3 Postgres store + async capability verifier + compose
RELAY-PHASE1 Wave A (2/3) + E1 infra:
- A1: createPgStores() Postgres adapter for all 9 P3 store ports + runMigrations();
  writable-CTE atomic INV8 status swaps; 0002_routes.sql. 23/23 pg tests, 96.72% cov.
- A3: real capability verifier delegating to relay-auth verifyCapabilityToken; sync->async
  seam across authz/provision/main. Full CP suite 15 files/101 pass, tsc clean.
- E1: deploy/docker-compose.yml (Postgres16+Redis7, loopback-only) + .env.example.
- docs: PLAN_RELAY_PHASE1.md file-level execution spec; PROGRESS_LOG RELAY-PHASE1 section.
2026-07-06 14:51:37 +02:00

173 lines
13 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.

# PLAN_RELAY_PHASE1 — deploy the native rendezvous-relay to a single VPS (staging, durable)
> **Decision (2026-07-06):** Build **Phase 1 "1b — full durable staging"** on cloud VPS `8.138.1.192`
> (Alibaba Cloud, mainland). Real **Postgres + Redis via Docker Compose on the VPS**; real **TLS on
> `:443` via Let's Encrypt** against an **already ICP-filed domain**; agent runs on the operator's own
> machine and dials OUT. This plan is the file-level execution spec derived from a full code audit of
> the 7 relay packages. It supersedes the high-level phasing in [DEPLOY_RELAY.md](./DEPLOY_RELAY.md) §4
> for the concrete build; that doc still holds for the *why* and the security runbook (§7).
>
> **Scope OUT (→ Phase 2):** real KMS custody of the CA (dev in-process signer is accepted for
> staging), F6 recoverable-replay transport (`loadReplay` stays fail-closed — not on the terminal path),
> WebAuthn/passkey step-up (staging uses `NO_STEPUP_POLICY` — single operator), wildcard multi-tenant
> subdomains, metering/alerting. Single tenant, single host, single relay node.
---
## 0. Target (what "done" means)
From the operator's laptop (agent dialing out) and any browser:
1. `https://<sub>.<BASE_DOMAIN>` serves the relay-web bundle (same origin as the WSS endpoint).
2. Operator logs in, picks the host, and gets a live shell **spliced by the real relay-node** — the
relay only sees ciphertext (INV2); E2E is browser↔agent.
3. Agent enrolled once via a pairing code → holds a SPIFFE mTLS cert → dials `wss://…:AGENT_PORT`.
4. **Restart-safe:** bouncing the relay/control-plane process does NOT lose the host registration or
kill the operator's PTY (state is in Postgres/Redis; INV7).
5. Revoking the host tears the tunnel down within the INV12 budget (Redis `relay:revocations`).
---
## 1. Code audit summary — what exists vs. what must be built
Security-critical *logic* is written and injectable across all 7 packages. Phase 1 = build the
integration + infra layer they were designed to receive. Grounded gaps:
| Area | State (file evidence) | Task |
|---|---|---|
| P3 Postgres adapter | **absent**`store/pg.ts` does not exist; only `db/pool.ts` (`createPgPool`/`createQuery`) + full `db/migrations/0001_init.sql`. `memory.ts` is the semantics reference (INV8 versioning, CAS). | **A1** |
| P3 migration runner | none | **A1** |
| P3 server entrypoint | **absent** — no `.listen()`, no `start`; only `buildControlPlane(env, overrides)` factory (`main.ts:60`). | **A2** |
| P3 Redis revocation bus | `createRedisRevocationBus(RedisPublisher)` exists (`routing/bus.ts:42`) but no `ioredis` client instantiated; `main.ts:93` leaves `bus` inert. | **A2** |
| P3 F2 capability verifier | throwing stub `refuseAllVerifier` (`main.ts:44`). Must inject relay-auth `verifyCapabilityToken`. **Impedance:** CP's `CapabilityVerifier.verify` is **sync** (`api/authz.ts:24`), relay-auth's is **async** → seam must go async. | **A3** |
| relay EnforceDeps over shared store | relay-run uses its own in-RAM fakes (`relay-run/src/wiring/memory-stores.ts`), a **separate world** from the CP. Must implement `HostRegistryPort`/`SessionRegistryPort`/`RevocationStore`/`TokenBucketStore`/`AuditSink` over the **same** Postgres/Redis. | **B1** |
| relay `MtlsVerifier` | stub returns seeded host for any cert (`relay-world.ts:149`). Must use relay-auth `verifyAgentCert(leaf, caChain, now, hosts)` against the shared host registry + pinned agent CA. | **B2** |
| relay `RouteResolver` | one-entry in-RAM map (`data-plane.ts:110`). Must resolve subdomain→hostId from `hosts.getBySubdomain`. | **B3** |
| relay revocation subscriber | not wired. Subscribe `relay:revocations``killsScope``closeStream`. | **B4** |
| relay-run Phase-1 entry | `main.ts` hardcodes `127.0.0.1:8443`, `baseDomain='term.localhost'`, self-signed certs, dials no agent, `NO_STEP_UP`. | **B5** |
| agent build | `noEmit:true`, no `build` script, `dist/cli.js` never produced. | **C1** |
| agent runnable entry | `cli.ts` exports `parseArgs`/`runCli` but has **no `main()`/argv bootstrap, no `CliDeps` factory, no concrete `runTunnel`** (only assembled in `test/acceptance/cafeDemo.test.ts`). | **C2** |
| relay-web static serve | `build.mjs` emits `public/build/`; **no HTTP server** ships. Must serve `public/` same-origin as WSS. | **D1** |
| Infra | no Dockerfiles/compose/systemd anywhere. | **E** |
---
## 2. Build order (waves by dependency)
```
A (P3 durable + serving) ──▶ B (relay data-plane on shared store) ──▶ E (infra + wire-up)
└──▶ C (agent buildable + runnable) ──────────▶
└──▶ D (relay-web served) ────────────────────▶
```
A is the foundation (both planes read the same store). B/C/D can proceed once A's store contract is
green. E stands up infra and does the end-to-end enroll→dial→click-through.
### Wave A — control-plane durable + serving
- **A1 · PG store adapter + migration runner.** `Owns: control-plane/src/store/pg.ts`,
`control-plane/src/db/migrate.ts`, `control-plane/test/store/pg.test.ts`.
`createPgStores(query: QueryFn): Stores` implementing all 9 ports (`store/ports.ts`) with **byte-for-byte
semantics parity with `memory.ts`**: INV8 status-version+pointer swaps in one transaction, `casRedeem`
single-winner, `registerFailure` increment, `reserve` single-winner on the `subdomain UNIQUE`
constraint, TTL on routes (store `expires_at`, filter on read — Postgres has no key TTL). Map
snake_case columns ↔ camelCase records. Migration runner applies `db/migrations/*.sql` idempotently.
**Verify:** TDD against a real Postgres (Testcontainers or a Docker `postgres:16` on
`127.0.0.1:5432`); run the SAME behavioral suite the memory store passes.
- **A2 · P3 server entrypoint + Redis wiring.** `Owns: control-plane/src/server.ts`,
`control-plane/src/boot/redis.ts`, `package.json` (`start` script).
`loadEnv(process.env)``createPgPool(PG_URL)`+`createQuery``createPgStores` → migrate →
`ioredis` client → `createRedisRevocationBus(redis)``buildControlPlane(env, {stores, bus,
verifier, caChainDer})``app.listen({host:'0.0.0.0', port})`. Graceful SIGTERM. **Verify:** boots
against Docker PG+Redis; `POST /accounts` then `POST /accounts/:id/pairing-codes` round-trips.
- **A3 · F2 capability verifier (async).** `Owns: control-plane/src/api/authz.ts` (make
`CapabilityVerifier.verify` return `Promise`, await at call sites), `control-plane/src/boot/verifier.ts`,
`control-plane/src/main.ts` (default + `overrides.verifier` plumb + call `loadVerifyKeyFromEnv`).
Real verifier delegates to relay-auth `verifyCapabilityToken(raw, expectedAud, now)`; configure the
verify key from `CAPABILITY_SIGN_PUBKEY_B64` at boot. **Verify:** a token signed by the matching key
verifies; a foreign/expired token 401s; unblocks programmatic account/pairing seeding.
### Wave B — relay data-plane on the shared store (P1 via relay-run)
- **B1 · shared-store EnforceDeps.** `Owns: relay-run/src/wiring/stores-pg.ts`,
`relay-run/test/stores-pg.test.ts`. Implement relay-auth's `HostRegistryPort`, `SessionRegistryPort`,
`RevocationStore`, `TokenBucketStore` (Redis token bucket), `AuditSink` (append to `audit_log`) over
the SAME PG/Redis as P3. Replace `memory-stores.ts` in the Phase-1 path.
- **B2 · registry-backed `MtlsVerifier`.** `Owns: relay-run/src/wiring/mtls-verifier.ts`. Wrap
relay-auth `verifyAgentCert(leafPem, caChainPem, now, hosts)`; pin the agent CA bundle; return
`{hostId, accountId}` only for enrolled+unrevoked hosts (INV4/INV14).
- **B3 · store-backed `RouteResolver`.** `Owns: relay-run/src/wiring/route-resolver.ts`.
`resolve(subdomain)``hosts.getBySubdomain` → hostId (or null).
- **B4 · revocation subscriber.** `Owns: relay-run/src/wiring/revocation-subscriber.ts` (or reuse
`term-relay/data-plane/revocation-subscriber.ts`). ioredis SUBSCRIBE `relay:revocations` → parse
`KillSignal``killsScope(signal, hostAccountId, hostId)``node.closeStream(...)`.
- **B5 · relay-run Phase-1 entry.** `Owns: relay-run/src/main-phase1.ts`, `relay-run/package.json`
(`start:phase1`). Env-driven (no hardcoding, CLAUDE §Config): bind `0.0.0.0`; `BASE_DOMAIN` +
computed `allowedOrigins` (port-less on :443, exact-match at `onUpgrade.ts:102`); real LE cert/key
paths for browser WSS; **separate** private agent-CA bundle for mTLS; `loadVerifyKeyFromEnv` at
startup; compose B1B4; keep `NO_STEPUP_POLICY` (staging). Leave the Phase-0 `main.ts` untouched for dev.
### Wave C — agent buildable + runnable (P2)
- **C1 · agent build.** `Owns: agent/tsconfig.build.json`, `agent/package.json` (`build`). Emit
`dist/cli.js` (esbuild bundle or `tsc` emit; keep `src` tsconfig `noEmit` for typecheck).
- **C2 · CLI bootstrap + `runTunnel`.** `Owns: agent/src/main.ts` (shebang + argv → `runCli`),
`agent/src/transport/runTunnel.ts`, `agent/src/cli/deps.ts` (`CliDeps` factory). Assemble
`dialRelay``holdTunnel``createStreamRouter``dialLoopback` + heartbeat/backoff, porting the proven
wiring from `test/acceptance/cafeDemo.test.ts`. **Verify:** `web-terminal-agent pair <CODE>` then
`web-terminal-agent run` enrolls + dials against a local Phase-1 relay.
### Wave D — serve relay-web (P6)
- **D1 · same-origin static server.** `Owns: relay-run/src/servers/static-web.ts` (fold into the
browser HTTPS server so the bundle is served from the SAME origin/cert as the WSS, keeping
Origin/CSP aligned). Serve `relay-web/public/` (built). Add `npm --prefix relay-web run build` to the
deploy step. `loadReplay` stays fail-closed (Phase 2).
### Wave E — infra + integration on 8.138.1.192
- **E1 · Docker Compose** `Owns: deploy/docker-compose.yml`, `deploy/.env.example`. `postgres:16` +
`redis:7`, volumes, bound to `127.0.0.1` (not public). Health checks.
- **E2 · DNS + TLS.** A-record `<sub>.<BASE_DOMAIN>``8.138.1.192`; Let's Encrypt cert
(HTTP-01 on :80 or DNS-01) for that subdomain → `TLS_CERT_PATH`/`TLS_KEY_PATH`.
- **E3 · Enrollment CA.** Generate the private agent CA (staging may reuse the CP dev signer's CA) →
`CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `AGENT_CA_CERT_PATH`. **Never** LE for mTLS.
- **E4 · Env + systemd + security group.** `deploy/control-plane.env`, `deploy/relay.env`,
`deploy/*.service`; open inbound `:443` (browser) + `AGENT_PORT` (agent mTLS) in the Aliyun security
group; keep CP admin + PG + Redis loopback-only.
- **E5 · End-to-end.** `POST /accounts``POST /accounts/:id/pairing-codes` → agent `pair`+`run` on
the laptop → browser opens `https://<sub>.<BASE_DOMAIN>` → click through to the shell. Confirm
restart-safety + revocation teardown.
---
## 3. Environment / config reference (Phase 1, this VPS)
**control-plane** (`control-plane/src/env.ts`, all required unless defaulted):
`PG_URL`, `REDIS_URL`, `CAPABILITY_SIGN_PUBKEY_B64` (32-byte Ed25519, base64), `CA_INTERMEDIATE_KMS_KEY_REF`
(dev signer accepts any ref), `CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `BASE_DOMAIN`;
defaulted `HEARTBEAT_TTL_SEC`=15, `PAIRING_TTL_SEC`=600, `PAIRING_MAX_REDEEM_ATTEMPTS`=5.
**relay-run Phase 1** (`term-relay/data-plane/config.ts` + new): `BASE_DOMAIN`, `BIND_HOST`=0.0.0.0,
`BIND_PORT`=443, `TLS_CERT_PATH`, `TLS_KEY_PATH`, `AGENT_BIND_PORT`, `AGENT_CA_CERT_PATH`,
`AGENT_CA_CHAIN_PATH`, `RELAY_NODE_ID`, `RELAY_AUTH_VERIFY_PUBKEY` (= `CAPABILITY_SIGN_PUBKEY_B64`,
base64url), `RELAY_TRUST_DOMAIN`, `PG_URL`, `REDIS_URL`.
**agent** (`agent/src/config/agentConfig.ts`): `RELAY_URL` (`wss://…:AGENT_PORT`), `ENROLL_URL`
(`https://<cp-host>/enroll`), `HOST_ID`, `SUBDOMAIN`, `LOCAL_TARGET_URL` (`ws://127.0.0.1:3000` — the
base app), `STATE_DIR` (`~/.web-terminal-agent`).
> **Key agreement (linchpin):** the P5 capability-signing keypair — CP signs, relay verifies. CP env
> `CAPABILITY_SIGN_PUBKEY_B64` and relay env `RELAY_AUTH_VERIFY_PUBKEY` MUST be the SAME public key.
> The **agent enrollment CA** (mTLS) and the **browser LE cert** are two independent trust chains.
---
## 4. Invariants to preserve (do not regress)
INV2 opaque splice (relay sees only ciphertext) · INV3 accountId only from authenticated material ·
INV7 PTY≠WS, relay nodes stateless/restart-safe · INV8 immutable versioned status · INV10 zero-payload
audit · INV12 revocation teardown budget · INV14 registry-gated mTLS. Origin/CSWSH exact-match on every
browser upgrade. Config via env only — no hardcoded hosts/ports/secrets.
---
## 5. Progress
Tracked in [PROGRESS_LOG.md](./PROGRESS_LOG.md) under a `RELAY-PHASE1` heading. Task IDs: `A1A3`,
`B1B5`, `C1C2`, `D1`, `E1E5`. Orchestrator appends one entry per task on completion (status, files,
verification command+result, deviations, next).