feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)

Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

103
control-plane/src/main.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* T11 — control-plane bootstrap. Wires env → stores → services → Fastify routes. The runnable
* v0.9-MVP default uses the in-memory stores; swapping in the Postgres/Redis-backed adapters
* (db/pool.ts + db/migrations + an ioredis client) is the Testcontainers integration step (PLAN §10).
*
* CROSS-PACKAGE INTEGRATION POINTS (injected, not hard-imported):
* - `CapabilityVerifier` — P5 owns `verifyCapabilityToken` + the signing key. Injected here; the
* default stub REFUSES all tokens (fail-closed) until P5 is wired.
* - `RevocationBus` — publish side over Redis `relay:revocations` (P1 subscribes). Default is the
* in-memory bus for single-process dev.
* - `KmsResolver` — P5/§3.1 KMS custody of the CA intermediate key. Default is an in-process
* Ed25519 signer (DEV ONLY — NOT a real KMS).
*/
import Fastify, { type FastifyInstance } from 'fastify'
import type { ControlPlaneEnv } from './env.js'
import { createMemoryStores } from './store/memory.js'
import type { Stores } from './store/ports.js'
import { createAuditLog } from './audit/log.js'
import { createAccountRegistry } from './registry/accounts.js'
import { createHostRegistry } from './registry/hosts.js'
import { createSessionRegistry } from './registry/sessions.js'
import { createSubdomainAssigner } from './subdomain/assign.js'
import { createPairingIssuer } from './pairing/issue.js'
import { createPairingRedeemer } from './pairing/redeem.js'
import { createLeafSigner } from './ca/sign.js'
import { createRoutingTable } from './routing/table.js'
import { createInMemoryRevocationBus, type TestableRevocationBus } from './routing/bus.js'
import { createMeteringCollector } from './metering/collect.js'
import { createDeprovisioner } from './deprovision/deprovision.js'
import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
import { buildRouter } from './api/provision.js'
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from './boot/ca-wiring.js'
import type { RevocationBus } from 'relay-contracts'
export interface ControlPlaneOverrides {
readonly stores?: Stores
readonly verifier?: CapabilityVerifier
readonly bus?: RevocationBus & Partial<TestableRevocationBus>
readonly kmsResolver?: KmsResolver
readonly caChainDer?: readonly Uint8Array[]
}
/** Default fail-closed verifier — refuses everything until P5 is wired (INV6). */
const refuseAllVerifier: CapabilityVerifier = {
verify() {
throw new Error('capability verification not configured (P5 integration point)')
},
}
/** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */
function devKmsResolver(): KmsResolver {
const signer = inProcessCaSigner()
return {
async resolve() {
return { signer, policyRestrictedToServicePrincipal: true }
},
}
}
export async function buildControlPlane(
env: ControlPlaneEnv,
overrides: ControlPlaneOverrides = {},
): Promise<{ app: FastifyInstance; stores: Stores }> {
const stores = overrides.stores ?? createMemoryStores()
const bus: RevocationBus = overrides.bus ?? createInMemoryRevocationBus()
const caChainDer = overrides.caChainDer ?? []
const audit = createAuditLog(stores.audit)
const accounts = createAccountRegistry({ accounts: stores.accounts, audit })
const hosts = createHostRegistry({ hosts: stores.hosts, audit })
createSessionRegistry({ sessions: stores.sessions, hosts: stores.hosts })
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit })
const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver())
const leafSigner = createLeafSigner({ hosts: stores.hosts, signer: caSigner, caChainDer })
const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
const redeemer = createPairingRedeemer({
pairing: stores.pairing,
hosts,
subdomains,
leafSigner,
pairingMaxRedeemAttempts: env.pairingMaxRedeemAttempts,
audit,
})
const routing = createRoutingTable({
routes: stores.routes,
nodeStatus: async (nodeId) => (await stores.nodes.get(nodeId))?.status ?? null,
})
createMeteringCollector({ metering: stores.metering, hosts })
const deprovisioner = createDeprovisioner({ hosts, routing })
void bus // reserved for the node-coordinator / revoker wiring (drain + revoke publishers)
const authorizer = createAuthorizer({
verifier: overrides.verifier ?? refuseAllVerifier,
expectedAud: env.baseDomain,
})
const app = Fastify({ logger: false })
await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner }))
return { app, stores }
}