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:
103
control-plane/src/main.ts
Normal file
103
control-plane/src/main.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user