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

13 KiB
Raw Blame History

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 §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 absentstore/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:revocationskillsScopecloseStream. 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)+createQuerycreatePgStores → 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 KillSignalkillsScope(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 dialRelayholdTunnelcreateStreamRouterdialLoopback + 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 /accountsPOST /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 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).