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.
173 lines
13 KiB
Markdown
173 lines
13 KiB
Markdown
# 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 B1–B4; 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: `A1–A3`,
|
||
`B1–B5`, `C1–C2`, `D1`, `E1–E5`. Orchestrator appends one entry per task on completion (status, files,
|
||
verification command+result, deviations, next).
|