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:
172
docs/PLAN_RELAY_PHASE1.md
Normal file
172
docs/PLAN_RELAY_PHASE1.md
Normal 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 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).
|
||||
@@ -24,6 +24,25 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### 🚧 RELAY-PHASE1 — 把原生 rendezvous-relay 部署到单台 VPS(8.138.1.192,阿里云;2026-07-06)
|
||||
- **目标**: DEPLOY_RELAY §4 的 **Phase 1「1b 全量持久化 staging」**。VPS 上 Docker 自建 Postgres+Redis;已备案域名走 443 + Let's Encrypt;agent 跑在操作者本机向外拨号。完整文件级方案见 [PLAN_RELAY_PHASE1.md](./PLAN_RELAY_PHASE1.md)。
|
||||
- **进度**: 地基 Wave A 已 2/3。**A1 (PG store 适配器+迁移) DONE**、**A3 (F2 异步能力验证器) DONE**、E1 (docker-compose+env 模板) DONE。全套 `npx vitest run`(control-plane)= **15 files / 101 tests pass**(A1 的 pg 测试 + A3 的 verifier 测试并存无冲突)。
|
||||
- **下一步**: **A2** — P3 server 入口(`loadEnv→createPgStores→runMigrations→ioredis→createRedisRevocationBus→buildControlPlane→listen`)+ Redis 撤销总线接线(`main.ts:93` 现为 inert)。然后 Wave B(relay 数据面读同一持久 store)。
|
||||
- **未做/推迟到 Phase 2**: 真 KMS(dev signer 暂用)、F6 replay(`loadReplay` fail-closed)、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户。
|
||||
|
||||
#### [x] A1 — Postgres store 适配器(P3)+ 迁移运行器(2026-07-06)
|
||||
- **交付**: `createPgStores(query): Stores` 把 9 个 store 端口映射为参数化 SQL(零字符串拼接);`runMigrations(query)` 按文件名顺序跑 `db/migrations/*.sql`(幂等)。
|
||||
- **文件(全在 Owns 内)**: `control-plane/src/store/pg.ts`、`src/db/migrate.ts`、`db/migrations/0002_routes.sql`(新增 — 0001 无 routes 表)、`test/store/pg.test.ts`。
|
||||
- **语义对齐 memory.ts**: INV8 accounts/hosts `swapStatus` 用单条 **writable CTE**(UPDATE 指针 + INSERT 版本行)原子完成,无显式事务;重复插入捕获 pg `23505`→`throw duplicate`;`casRedeem` 单赢家 `UPDATE...WHERE redeemed_at IS NULL RETURNING`;RouteStore 存 `expires_at`,`expires_at<=now()` 视为不存在(fail-closed INV7)+ 惰性 DELETE。
|
||||
- **验证(实测)**: `npx vitest run test/store/pg.test.ts` → **23/23 通过**(自起 `postgres:16` 一次性容器 :5433,afterAll 自动 `docker rm -f`);`pg.ts` 覆盖 **96.72% stmts / 100% funcs**。
|
||||
- **偏差**: ① `0002_routes.sql` 新增;② SubdomainStore 无独立预留表,按 `hosts.subdomain UNIQUE` 归属裁决;③ migrate 按 `;` 切分逐条执行(参数化路径拒绝多语句)。**阻塞**: 无。
|
||||
|
||||
#### [x] A3 — 真能力验证器(relay-auth verifyCapabilityToken)+ 同步→异步 seam(2026-07-06)
|
||||
- **交付**: 抛异常的 `refuseAllVerifier` stub 换成委托 P5 异步 `verifyCapabilityToken` 的真验证器;`await` 贯通整个 authorizer 路径。默认仍 fail-closed(异步化后 reject→401)。
|
||||
- **文件**: `src/api/authz.ts`(`CapabilityVerifier.verify`→`Promise`)、`src/api/provision.ts`(5 个路由 call site 改 `await principal(req)`)、`src/boot/verifier.ts`(新;`configureCapabilityVerifyKey` 把 `env.capabilitySignPubkey` 32 字节导入 WebCrypto verify key 后调 relay-auth `configureVerifyKey`,桥接 `CAPABILITY_SIGN_PUBKEY_B64` vs `RELAY_AUTH_VERIFY_PUBKEY` 命名差)、`src/main.ts`、`package.json`(加 `relay-auth: file:../relay-auth`)、`test/verifier.test.ts`(新)+ 更新 api 测试 stub 为异步。
|
||||
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**。
|
||||
- **偏差**: 手建 `control-plane/node_modules/relay-auth` 符号链接以配合新 file: 依赖(`npm install` 会重建)。**阻塞**: 无。
|
||||
|
||||
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
|
||||
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
|
||||
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~`、`~/Documents`)原样列为项目。
|
||||
|
||||
Reference in New Issue
Block a user