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.
This commit is contained in:
Yaojia Wang
2026-07-06 14:51:37 +02:00
parent 242a4e0dc1
commit 95b9cccf07
17 changed files with 1488 additions and 16 deletions

172
docs/PLAN_RELAY_PHASE1.md Normal file
View File

@@ -0,0 +1,172 @@
# 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).