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:
136
agent/src/certs/rotation.ts
Normal file
136
agent/src/certs/rotation.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Short-lived cert rotation — PLAN_RELAY_AGENT T13 (INV14). Renews the mTLS cert BEFORE expiry via
|
||||
* P3's renewal endpoint using a fresh CSR over the SAME Ed25519 key (pubkey unchanged, only the
|
||||
* cert rotates). Installs the new cert atomically (keystore writeFile is whole-file). A 403 from
|
||||
* the renewal endpoint ⇒ the host was revoked ⇒ onRevoked (⇒ T14 teardown, INV12).
|
||||
*
|
||||
* NOTE (cross-plan / open Q#5): the renewal URL + its auth (mTLS with the current cert vs a short
|
||||
* renewal token) is P3-owned. This derives `${enrollUrl}` → `.../renew` and injects fetch; when P3
|
||||
* freezes the route, adjust `renewalUrlFor`. Single integration point.
|
||||
*/
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import type { TimerLike } from '../transport/seams.js'
|
||||
import { buildCsr } from '../enroll/csr.js'
|
||||
|
||||
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
|
||||
|
||||
export interface CertRotator {
|
||||
start(): void
|
||||
stop(): void
|
||||
onRotated(cb: () => void): void
|
||||
onRevoked(cb: () => void): void
|
||||
}
|
||||
|
||||
export type RenewOutcome = 'rotated' | 'revoked'
|
||||
|
||||
/** Derive P3's renewal route from the enroll URL (integration point, open Q#5). */
|
||||
export function renewalUrlFor(cfg: AgentConfig): string {
|
||||
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
|
||||
}
|
||||
|
||||
/** Ms until (validTo − renewBeforeMs), clamped to ≥ 0. */
|
||||
export function computeRenewDelayMs(
|
||||
certPem: string,
|
||||
renewBeforeMs: number,
|
||||
now: Date,
|
||||
parse: (pem: string) => Date = (p) => new Date(new X509Certificate(p).validTo),
|
||||
): number {
|
||||
const validTo = parse(certPem).getTime()
|
||||
return Math.max(0, validTo - renewBeforeMs - now.getTime())
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform one renewal round-trip. Returns 'rotated' (new cert stored) or 'revoked' (403). Any
|
||||
* other HTTP/network failure throws (caller retries with backoff; the tunnel stays up until the
|
||||
* cert actually expires).
|
||||
*/
|
||||
export async function renewCert(
|
||||
cfg: AgentConfig,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<RenewOutcome> {
|
||||
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
||||
const res = await fetchImpl(renewalUrlFor(cfg), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ csr }),
|
||||
})
|
||||
if (res.status === 403) return 'revoked'
|
||||
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
||||
const json = (await res.json()) as { cert?: string; caChain?: string }
|
||||
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') {
|
||||
throw new Error('cert renewal response missing cert/caChain')
|
||||
}
|
||||
ks.saveCert(json.cert, json.caChain) // atomic whole-file install
|
||||
return 'rotated'
|
||||
}
|
||||
|
||||
export function createCertRotator(
|
||||
cfg: AgentConfig,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
opts: {
|
||||
renewBeforeMs?: number
|
||||
timer?: TimerLike
|
||||
fetchImpl?: typeof fetch
|
||||
now?: () => Date
|
||||
parseCert?: (pem: string) => Date
|
||||
} = {},
|
||||
): CertRotator {
|
||||
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
||||
const parseCert = opts.parseCert ?? ((p) => new Date(new X509Certificate(p).validTo))
|
||||
const timer = opts.timer ?? {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const now = opts.now ?? (() => new Date())
|
||||
let handle: unknown = null
|
||||
let rotatedCb: (() => void) | null = null
|
||||
let revokedCb: (() => void) | null = null
|
||||
|
||||
function schedule(): void {
|
||||
const certs = ks.loadCert()
|
||||
if (certs === null) return
|
||||
const delay = computeRenewDelayMs(certs.certPem, renewBeforeMs, now(), parseCert)
|
||||
handle = timer.setTimeout(runRenewal, delay)
|
||||
}
|
||||
|
||||
function runRenewal(): void {
|
||||
void renewCert(cfg, id, ks, doFetch)
|
||||
.then((outcome) => {
|
||||
if (outcome === 'revoked') {
|
||||
revokedCb?.()
|
||||
return
|
||||
}
|
||||
rotatedCb?.()
|
||||
schedule()
|
||||
})
|
||||
.catch(() => {
|
||||
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile.
|
||||
handle = timer.setTimeout(runRenewal, renewBeforeMs)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
start(): void {
|
||||
schedule()
|
||||
},
|
||||
stop(): void {
|
||||
if (handle !== null) timer.clearTimeout(handle)
|
||||
handle = null
|
||||
},
|
||||
onRotated(cb): void {
|
||||
rotatedCb = cb
|
||||
},
|
||||
onRevoked(cb): void {
|
||||
revokedCb = cb
|
||||
},
|
||||
}
|
||||
}
|
||||
97
agent/src/cli.ts
Normal file
97
agent/src/cli.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`.
|
||||
* All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO.
|
||||
* `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
|
||||
*/
|
||||
import type { AgentConfig } from './config/agentConfig.js'
|
||||
import type { AgentIdentity } from './keys/identity.js'
|
||||
import type { Keystore } from './keys/keystore.js'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
|
||||
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||||
const COMMANDS: readonly CliCommand[] = ['pair', 'run', 'status', 'install', 'uninstall']
|
||||
|
||||
export interface CliArgs {
|
||||
readonly command: CliCommand
|
||||
readonly code?: string
|
||||
readonly flags: Readonly<Record<string, string | boolean>>
|
||||
}
|
||||
|
||||
export class CliUsageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'CliUsageError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface CliDeps {
|
||||
loadConfig(): AgentConfig
|
||||
openKeystore(stateDir: string): Keystore
|
||||
generateIdentity(): AgentIdentity
|
||||
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
|
||||
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||
installService(cfg: AgentConfig): Promise<void>
|
||||
uninstallService(): Promise<void>
|
||||
print(line: string): void
|
||||
}
|
||||
|
||||
/** Parse argv (already sliced past node/script) into a typed CliArgs. */
|
||||
export function parseArgs(argv: readonly string[]): CliArgs {
|
||||
const [command, ...rest] = argv
|
||||
if (command === undefined || !COMMANDS.includes(command as CliCommand)) {
|
||||
throw new CliUsageError(`unknown command '${command ?? ''}' (expected ${COMMANDS.join(' | ')})`)
|
||||
}
|
||||
const flags: Record<string, string | boolean> = {}
|
||||
const positionals: string[] = []
|
||||
for (const token of rest) {
|
||||
if (token.startsWith('--')) {
|
||||
const [k, v] = token.slice(2).split('=')
|
||||
flags[k!] = v ?? true
|
||||
} else {
|
||||
positionals.push(token)
|
||||
}
|
||||
}
|
||||
const args: CliArgs = { command: command as CliCommand, flags }
|
||||
if (command === 'pair') {
|
||||
const code = positionals[0]
|
||||
if (code === undefined) throw new CliUsageError('usage: web-terminal-agent pair <CODE>')
|
||||
return { ...args, code }
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
/** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
|
||||
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
||||
const cfg = deps.loadConfig()
|
||||
const ks = deps.openKeystore(cfg.stateDir)
|
||||
switch (args.command) {
|
||||
case 'pair': {
|
||||
const id = ks.loadIdentity() ?? deps.generateIdentity()
|
||||
ks.saveIdentity(id)
|
||||
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
||||
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
||||
if (args.flags['install']) await deps.installService(cfg)
|
||||
return 0
|
||||
}
|
||||
case 'run': {
|
||||
if (ks.loadIdentity() === null || ks.loadCert() === null) {
|
||||
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
|
||||
}
|
||||
return deps.runTunnel(cfg, ks)
|
||||
}
|
||||
case 'status': {
|
||||
const enrolled = ks.loadIdentity() !== null && ks.loadCert() !== null
|
||||
// INV9: print only non-secret identifiers.
|
||||
deps.print(`enrolled: ${enrolled}`)
|
||||
deps.print(`host_id: ${cfg.hostId ?? '(none)'}`)
|
||||
deps.print(`subdomain: ${cfg.subdomain ?? '(none)'}`)
|
||||
return 0
|
||||
}
|
||||
case 'install':
|
||||
await deps.installService(cfg)
|
||||
return 0
|
||||
case 'uninstall':
|
||||
await deps.uninstallService()
|
||||
return 0
|
||||
}
|
||||
}
|
||||
87
agent/src/config/agentConfig.ts
Normal file
87
agent/src/config/agentConfig.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Agent configuration — PLAN_RELAY_AGENT T2. Zod-validated at the startup boundary (INV9):
|
||||
* - relayUrl MUST be wss:// (encrypted tunnel only)
|
||||
* - enrollUrl MUST be https:// (enrollment over TLS only)
|
||||
* - localTargetUrl MUST be ws:// to a LOOPBACK host (anti-SSRF: the agent forwards ONLY to
|
||||
* the local web-terminal, never an arbitrary target).
|
||||
* Fail-fast on missing/invalid values — never silently default a security-relevant field.
|
||||
*/
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
|
||||
export interface AgentConfig {
|
||||
readonly relayUrl: string
|
||||
readonly enrollUrl: string
|
||||
readonly stateDir: string
|
||||
readonly localTargetUrl: string
|
||||
readonly subdomain: string | null
|
||||
readonly hostId: string | null
|
||||
}
|
||||
|
||||
const LOOPBACK_HOSTNAMES: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
|
||||
|
||||
/** True iff `url` is ws:// to a loopback host (127.0.0.0/8, localhost, or ::1). */
|
||||
export function isLoopbackWsUrl(url: string): boolean {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (parsed.protocol !== 'ws:') return false
|
||||
const host = parsed.hostname
|
||||
return LOOPBACK_HOSTNAMES.includes(host) || host.startsWith('127.')
|
||||
}
|
||||
|
||||
function hasScheme(url: string, scheme: string): boolean {
|
||||
try {
|
||||
return new URL(url).protocol === scheme
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const AgentConfigSchema = z
|
||||
.object({
|
||||
relayUrl: z
|
||||
.string()
|
||||
.refine((u) => hasScheme(u, 'wss:'), 'relayUrl must be a wss:// URL'),
|
||||
enrollUrl: z
|
||||
.string()
|
||||
.refine((u) => hasScheme(u, 'https:'), 'enrollUrl must be an https:// URL'),
|
||||
stateDir: z.string().min(1),
|
||||
localTargetUrl: z
|
||||
.string()
|
||||
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
||||
subdomain: z.string().min(1).nullable(),
|
||||
hostId: z.string().min(1).nullable(),
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
|
||||
const DEFAULT_LOCAL_TARGET = 'ws://127.0.0.1:3000'
|
||||
|
||||
/** Default state dir where key/cert/content-secret live (~/.web-terminal-agent). */
|
||||
export function defaultStateDir(): string {
|
||||
return join(homedir(), '.web-terminal-agent')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + validate an AgentConfig from env + explicit argv overrides (argv wins). Throws a
|
||||
* ZodError (fail-fast) if any required field is missing or fails a scheme/loopback refinement.
|
||||
*/
|
||||
export function loadAgentConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
argv: Partial<AgentConfig> = {},
|
||||
): AgentConfig {
|
||||
const merged = {
|
||||
relayUrl: argv.relayUrl ?? env.RELAY_URL,
|
||||
enrollUrl: argv.enrollUrl ?? env.ENROLL_URL,
|
||||
stateDir: argv.stateDir ?? env.STATE_DIR ?? defaultStateDir(),
|
||||
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
||||
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
||||
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
||||
}
|
||||
return AgentConfigSchema.parse(merged)
|
||||
}
|
||||
144
agent/src/e2e/hostEndpoint.ts
Normal file
144
agent/src/e2e/hostEndpoint.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Host E2E endpoint (§4.4) — PLAN_RELAY_AGENT T15. The HOST side of the authenticated ECDH: absorb
|
||||
* ClientHello → verify the device-auth-proof FIRST → reply HostHello → derive DirectionalKeys →
|
||||
* per-stream seal(h2c)/open(c2h). Distinct from live frames, every replay-bound output is ALSO
|
||||
* sealed under K_content via the T19 ReplaySealer (FIX 3).
|
||||
*
|
||||
* ANTI-MITM (INV2): the device-auth-proof `verifyDeviceProof` is P5-OWNED and reaches this module
|
||||
* INJECTED (FIX 6b) — this file MUST NOT `import { verifyDeviceAuthProof } from 'relay-e2e'`. The
|
||||
* proof is verified BEFORE any key derivation; a forged proof ⇒ MitmAbortError, NO HostHello, NO
|
||||
* DirectionalKeys. A no-stub guard test asserts this file imports no verifier and that swapping the
|
||||
* injected verifier for `async () => true` makes the MITM test fail.
|
||||
*
|
||||
* BLOCKING GATE (open Q#2 — DEFERRED): the real §4.4 crypto (`sealFrame`/`openFrame`/
|
||||
* `createE2ESession`) + host-handshake wiring (`createHostHandshake`) live in P4 `relay-e2e/`, and
|
||||
* the P5 verifier + forged-proof vector are a hard pre-W4 gate. P4 is NOT built yet, so those are
|
||||
* INJECTED here as seams typed to the frozen relay-contracts signatures — production passes the
|
||||
* relay-e2e impls verbatim, NEVER a stub. INV11: even after open(), bytes go to loopback OPAQUE.
|
||||
*/
|
||||
import type {
|
||||
ClientHello,
|
||||
E2ESession,
|
||||
HandshakeResult,
|
||||
HostHello,
|
||||
} from 'relay-contracts'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { FrameTransform } from '../transport/streamRouter.js'
|
||||
import type { ReplaySealer } from './replaySeal.js'
|
||||
|
||||
/** Host-side device-proof verifier — INJECTED (P5 issues+verifies), bound to the ClientHello. */
|
||||
export type VerifyDeviceProof = (
|
||||
proof: string,
|
||||
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
|
||||
) => Promise<boolean>
|
||||
|
||||
export class MitmAbortError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'MitmAbortError'
|
||||
}
|
||||
}
|
||||
|
||||
/** P4 host-handshake wiring seam (`createHostHandshake`), typed to §4.4 shapes. */
|
||||
export interface HostHandshake {
|
||||
respond(clientHello: ClientHello): Promise<{ hello: HostHello; result: HandshakeResult }>
|
||||
}
|
||||
export type CreateHostHandshake = (deps: {
|
||||
verifyDeviceProof: VerifyDeviceProof
|
||||
identity: AgentIdentity
|
||||
}) => HostHandshake
|
||||
|
||||
/** P4 §4.4 crypto core, injected (impls live in relay-e2e/). */
|
||||
export interface E2ECryptoDeps {
|
||||
createHostHandshake: CreateHostHandshake
|
||||
createE2ESession(role: 'host', result: HandshakeResult): E2ESession
|
||||
/** Wire codec for the HostHello reply (P4/P6-owned framing). */
|
||||
encodeHostHello(hello: HostHello): Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the HostHello + HandshakeResult for a ClientHello. The proof is verified FIRST; a forged
|
||||
* proof aborts with MitmAbortError and NO key derivation (anti-MITM). FIX 2: the result carries
|
||||
* DirectionalKeys{c2h,h2c}, never a single sessionKey.
|
||||
*/
|
||||
export async function makeHostHello(
|
||||
clientHello: ClientHello,
|
||||
id: AgentIdentity,
|
||||
verifyDeviceProof: VerifyDeviceProof,
|
||||
deps: Pick<E2ECryptoDeps, 'createHostHandshake'>,
|
||||
): Promise<{ hello: HostHello; result: HandshakeResult }> {
|
||||
const ok = await verifyDeviceProof(clientHello.deviceAuthProof, {
|
||||
clientEphPub: clientHello.clientEphPub,
|
||||
clientNonce: clientHello.clientNonce,
|
||||
})
|
||||
if (!ok) {
|
||||
throw new MitmAbortError('device-auth-proof verification failed — aborting, no keys derived')
|
||||
}
|
||||
const handshake = deps.createHostHandshake({ verifyDeviceProof, identity: id })
|
||||
return handshake.respond(clientHello)
|
||||
}
|
||||
|
||||
/**
|
||||
* FrameTransform + a `seedSession` seam. The wiring layer intercepts the first (ClientHello) frame,
|
||||
* runs `makeHostHello` (async, verify-first), then calls `seedSession` with the derived
|
||||
* E2ESession + the encoded HostHello (queued as a control frame the router flushes upstream).
|
||||
* After seeding: inbound = session.open(c2h) → loopback (OPAQUE, INV11); outbound = session.seal
|
||||
* (h2c) → tunnel AND replay.seal(K_content) — two DISTINCT ciphertexts (FIX 3).
|
||||
*/
|
||||
export interface E2ETransform extends FrameTransform {
|
||||
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void
|
||||
}
|
||||
|
||||
interface StreamE2EState {
|
||||
session: E2ESession | null
|
||||
readonly control: Uint8Array[]
|
||||
}
|
||||
|
||||
export function createE2ETransform(
|
||||
_id: AgentIdentity,
|
||||
_verifyDeviceProof: VerifyDeviceProof,
|
||||
replay: ReplaySealer,
|
||||
): E2ETransform {
|
||||
const streams = new Map<number, StreamE2EState>()
|
||||
|
||||
function stateFor(streamId: number): StreamE2EState {
|
||||
let s = streams.get(streamId)
|
||||
if (s === undefined) {
|
||||
s = { session: null, control: [] }
|
||||
streams.set(streamId, s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
return {
|
||||
openStream(streamId: number): void {
|
||||
stateFor(streamId)
|
||||
},
|
||||
closeStream(streamId: number): void {
|
||||
streams.delete(streamId)
|
||||
},
|
||||
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void {
|
||||
const s = stateFor(streamId)
|
||||
s.session = session
|
||||
s.control.push(hostHelloBytes)
|
||||
},
|
||||
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null {
|
||||
const s = stateFor(streamId)
|
||||
if (s.session === null) return null // handshake not yet seeded; wiring layer handles it
|
||||
return s.session.open(cipher) // opaque plaintext to loopback (INV11)
|
||||
},
|
||||
outbound(streamId: number, plain: Uint8Array): Uint8Array {
|
||||
const s = stateFor(streamId)
|
||||
if (s.session === null) {
|
||||
throw new MitmAbortError('cannot seal before the E2E session is established')
|
||||
}
|
||||
replay.seal(plain) // FIX 3: recoverable K_content seal, DISTINCT from the live h2c frame
|
||||
return s.session.seal(plain)
|
||||
},
|
||||
takeControlFrames(streamId: number): Uint8Array[] {
|
||||
const s = streams.get(streamId)
|
||||
if (s === undefined || s.control.length === 0) return []
|
||||
return s.control.splice(0, s.control.length)
|
||||
},
|
||||
}
|
||||
}
|
||||
49
agent/src/e2e/replaySeal.ts
Normal file
49
agent/src/e2e/replaySeal.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Replay-frame sealer (recoverable K_content) — PLAN_RELAY_AGENT T19 (FIX 3).
|
||||
*
|
||||
* Live host→client frames use the EPHEMERAL DirectionalKeys.h2c (forward-secret, gone after
|
||||
* reconnect). But "refresh the page and the Claude session is still there" needs the ring-buffer /
|
||||
* preview ciphertext to be RECOVERABLE — so every replay-bound output is ALSO sealed under the
|
||||
* host-scoped recoverable K_content = deriveContentKey({ hostContentSecret, sessionId, alg }),
|
||||
* DISTINCT from the live h2c frame. The browser re-derives the identical K_content (P5) and opens
|
||||
* it (P6). This is the single agent-side consumer of the FIX 3 recoverable key.
|
||||
*
|
||||
* INTEGRATION SEAM: the frozen §4.4 replay-crypto IMPLEMENTATIONS live in P4 `relay-e2e/`
|
||||
* (`deriveContentKey`, `sealReplayFrame`). P4 is not built yet, so they are INJECTED here typed to
|
||||
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
|
||||
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
|
||||
* NEVER logged, NEVER sent to the relay (INV2/INV9).
|
||||
*/
|
||||
import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||
|
||||
/** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */
|
||||
export interface ReplayCrypto {
|
||||
deriveContentKey(params: ReplayKeyParams): AeadKey
|
||||
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope
|
||||
}
|
||||
|
||||
export interface ReplaySealer {
|
||||
/** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */
|
||||
seal(plaintext: Uint8Array): E2EEnvelope
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-(host, session) replay sealer. K_content is derived ONCE from
|
||||
* { hostContentSecret, sessionId, alg }; seq is strictly monotonic from 0 (INV13).
|
||||
*/
|
||||
export function createReplaySealer(
|
||||
hostContentSecret: Uint8Array,
|
||||
sessionId: string,
|
||||
alg: AeadAlg,
|
||||
crypto: ReplayCrypto,
|
||||
): ReplaySealer {
|
||||
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg })
|
||||
let seq = 0n
|
||||
return {
|
||||
seal(plaintext: Uint8Array): E2EEnvelope {
|
||||
const env = crypto.sealReplayFrame(key, seq, plaintext)
|
||||
seq += 1n
|
||||
return env
|
||||
},
|
||||
}
|
||||
}
|
||||
96
agent/src/enroll/csr.ts
Normal file
96
agent/src/enroll/csr.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* PKCS#10 CSR over the Ed25519 identity — PLAN_RELAY_AGENT T4.
|
||||
*
|
||||
* Node has no built-in CSR generator, so this is a compact, self-contained DER encoder that
|
||||
* emits a standard PKCS#10 CertificationRequest signed with the in-process Ed25519 key (INV4 —
|
||||
* only the PUBLIC key + a signature leave; the private key is never serialized here).
|
||||
*
|
||||
* NOTE (cross-plan / open Q#1): the exact SPIFFE-style cert PROFILE (SAN = subdomain vs a SPIFFE
|
||||
* URI) is set by P3's CA. This builds the standard subject-CN PKCS#10 shape; when P3 freezes its
|
||||
* profile, extend `buildCsr`'s attributes here. That is the single integration point.
|
||||
*/
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
|
||||
// --- minimal DER encoding helpers -------------------------------------------------------------
|
||||
|
||||
function derLen(len: number): Uint8Array {
|
||||
if (len < 0x80) return Uint8Array.from([len])
|
||||
const bytes: number[] = []
|
||||
let n = len
|
||||
while (n > 0) {
|
||||
bytes.unshift(n & 0xff)
|
||||
n >>= 8
|
||||
}
|
||||
return Uint8Array.from([0x80 | bytes.length, ...bytes])
|
||||
}
|
||||
|
||||
function tlv(tag: number, value: Uint8Array): Uint8Array {
|
||||
const len = derLen(value.length)
|
||||
const out = new Uint8Array(1 + len.length + value.length)
|
||||
out[0] = tag
|
||||
out.set(len, 1)
|
||||
out.set(value, 1 + len.length)
|
||||
return out
|
||||
}
|
||||
|
||||
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
||||
const total = chunks.reduce((s, c) => s + c.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const c of chunks) {
|
||||
out.set(c, off)
|
||||
off += c.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const SEQUENCE = 0x30
|
||||
const SET = 0x31
|
||||
const INTEGER = 0x02
|
||||
const BIT_STRING = 0x03
|
||||
const OID = 0x06
|
||||
const UTF8_STRING = 0x0c
|
||||
const CONTEXT_0 = 0xa0
|
||||
|
||||
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
|
||||
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
|
||||
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
|
||||
|
||||
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
|
||||
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
|
||||
return tlv(SEQUENCE, concat([algId, pubBits]))
|
||||
}
|
||||
|
||||
/** X.501 Name with a single CN=<subject> RDN. */
|
||||
function nameFromCn(cn: string): Uint8Array {
|
||||
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||
const rdn = tlv(SET, atv)
|
||||
return tlv(SEQUENCE, rdn)
|
||||
}
|
||||
|
||||
function toPem(der: Uint8Array, label: string): string {
|
||||
const b64 = Buffer.from(der).toString('base64')
|
||||
const lines = b64.match(/.{1,64}/g) ?? []
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
|
||||
* The private key is used in-process only; never serialized into the output (INV4).
|
||||
*/
|
||||
export function buildCsr(id: AgentIdentity, subject: string): string {
|
||||
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||
const name = nameFromCn(subject)
|
||||
const spki = spkiFromRawEd25519(id.publicKey)
|
||||
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
||||
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
||||
|
||||
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
|
||||
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||
|
||||
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||
return toPem(csr, 'CERTIFICATE REQUEST')
|
||||
}
|
||||
137
agent/src/enroll/pair.ts
Normal file
137
agent/src/enroll/pair.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* §4.5 pairing-code redemption (agent side) — PLAN_RELAY_AGENT T4.
|
||||
*
|
||||
* REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5).
|
||||
* RETURN: the FROZEN EnrollResult (imported from relay-contracts, never redeclared), validated
|
||||
* with EnrollResultSchema at the boundary (untrusted external data). On success the FIX 3
|
||||
* `hostContentSecret` (wrapped to this agent's Ed25519 identity by P3) is UNWRAPPED in-process
|
||||
* and persisted 0600 via the keystore; the WRAPPED bytes are never stored, the unwrapped secret
|
||||
* is never logged/re-sent (INV5/INV9).
|
||||
*
|
||||
* Only the PUBLIC key + CSR leave the host (INV4).
|
||||
*/
|
||||
import { EnrollResultSchema, decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { buildCsr } from './csr.js'
|
||||
|
||||
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
||||
export type EnrollMode = 'token' | 'ed25519'
|
||||
export const DEFAULT_ENROLL_MODE: EnrollMode = 'ed25519'
|
||||
|
||||
export class EnrollError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'EnrollError'
|
||||
}
|
||||
}
|
||||
export class PairingCodeSpentError extends EnrollError {
|
||||
constructor() {
|
||||
super('pairing code already redeemed (single-use)')
|
||||
this.name = 'PairingCodeSpentError'
|
||||
}
|
||||
}
|
||||
export class PairingCodeExpiredError extends EnrollError {
|
||||
constructor() {
|
||||
super('pairing code expired')
|
||||
this.name = 'PairingCodeExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the P3-wrapped `hostContentSecret` using the agent's enrollment identity (in-process).
|
||||
* P3's exact wrap scheme is not yet frozen (cross-plan integration point); this seam is injected
|
||||
* so the real unwrap drops in without touching the redeem flow. Default = passthrough.
|
||||
*/
|
||||
export type UnwrapContentSecret = (wrapped: Uint8Array, id: AgentIdentity) => Uint8Array
|
||||
const passthroughUnwrap: UnwrapContentSecret = (wrapped) => wrapped
|
||||
|
||||
export interface RedeemOptions {
|
||||
readonly fetchImpl?: typeof fetch
|
||||
readonly mode?: EnrollMode
|
||||
readonly agentToken?: string
|
||||
readonly unwrapContentSecret?: UnwrapContentSecret
|
||||
readonly subject?: string
|
||||
}
|
||||
|
||||
interface EnrollResponseJson {
|
||||
hostId: string
|
||||
subdomain: string
|
||||
cert: string
|
||||
caChain: string
|
||||
hostContentSecret: string // base64url over the wire
|
||||
}
|
||||
|
||||
function parseEnrollResult(json: unknown): EnrollResult {
|
||||
const j = json as Partial<EnrollResponseJson>
|
||||
if (typeof j.hostContentSecret !== 'string') {
|
||||
throw new EnrollError('enroll response missing hostContentSecret')
|
||||
}
|
||||
const candidate = {
|
||||
hostId: j.hostId,
|
||||
subdomain: j.subdomain,
|
||||
cert: j.cert,
|
||||
caChain: j.caChain,
|
||||
hostContentSecret: decodeBase64UrlBytes(j.hostContentSecret),
|
||||
}
|
||||
const result = EnrollResultSchema.safeParse(candidate)
|
||||
if (!result.success) {
|
||||
throw new EnrollError(`enroll response failed schema validation: ${result.error.message}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem `code` at `enrollUrl`. Sends only pubkey + CSR (INV4); never persists the raw code
|
||||
* (INV5). Stores the returned cert + CA chain and the unwrapped hostContentSecret 0600.
|
||||
*/
|
||||
export async function redeemPairingCode(
|
||||
enrollUrl: string,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
opts: RedeemOptions = {},
|
||||
): Promise<EnrollResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const mode = opts.mode ?? DEFAULT_ENROLL_MODE
|
||||
const unwrap = opts.unwrapContentSecret ?? passthroughUnwrap
|
||||
|
||||
const body =
|
||||
mode === 'token'
|
||||
? { code, agentToken: opts.agentToken ?? '' }
|
||||
: {
|
||||
code,
|
||||
agentPubkey: encodeBase64UrlBytes(id.publicKey),
|
||||
csr: buildCsr(id, opts.subject ?? 'web-terminal-agent'),
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await doFetch(enrollUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new EnrollError(`enroll request failed: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
if (res.status === 409) throw new PairingCodeSpentError()
|
||||
if (res.status === 410) throw new PairingCodeExpiredError()
|
||||
if (!res.ok) throw new EnrollError(`enroll returned HTTP ${res.status}`)
|
||||
|
||||
let json: unknown
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch (err) {
|
||||
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
const enroll = parseEnrollResult(json)
|
||||
ks.saveCert(enroll.cert, enroll.caChain)
|
||||
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
||||
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||
ks.saveContentSecret(unwrapped)
|
||||
return enroll
|
||||
}
|
||||
32
agent/src/index.ts
Normal file
32
agent/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* web-terminal-agent (P2) — public barrel. The host agent: Ed25519 identity, §4.5 pairing, the
|
||||
* §4.1 mux tunnel + loopback splice, mTLS dial + cert rotation, fast revocation, and the §4.4 E2E
|
||||
* endpoint. Imports the FROZEN shared surface from relay-contracts read-only; wires P4 relay-e2e
|
||||
* crypto via injected seams (see docs/PLAN_RELAY_AGENT.md).
|
||||
*/
|
||||
export * from './config/agentConfig.js'
|
||||
export * from './log/logger.js'
|
||||
export * from './transport/seams.js'
|
||||
export * from './keys/identity.js'
|
||||
export * from './keys/keystore.js'
|
||||
export * from './enroll/csr.js'
|
||||
export * from './enroll/pair.js'
|
||||
export { parseArgs, runCli, CliUsageError } from './cli.js'
|
||||
export type { CliArgs, CliCommand, CliDeps } from './cli.js'
|
||||
export * from './transport/frpScaffold.js'
|
||||
export * from './transport/tunnel.js'
|
||||
export * from './transport/loopback.js'
|
||||
export * from './transport/streamRouter.js'
|
||||
export * from './transport/heartbeat.js'
|
||||
export * from './transport/backoff.js'
|
||||
export * from './transport/flowControl.js'
|
||||
export * from './transport/dial.js'
|
||||
export * from './certs/rotation.js'
|
||||
export * from './lifecycle/revocation.js'
|
||||
export * from './e2e/replaySeal.js'
|
||||
export * from './e2e/hostEndpoint.js'
|
||||
export * from './dist/buildBinary.js'
|
||||
export * from './service/install.js'
|
||||
export * from './service/launchd.js'
|
||||
export * from './service/systemd.js'
|
||||
export * from './service/originConfig.js'
|
||||
83
agent/src/keys/identity.ts
Normal file
83
agent/src/keys/identity.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
|
||||
*
|
||||
* Ed25519 keypair generated locally with node:crypto. `AgentIdentity` exposes ONLY the public
|
||||
* key, the §4.2 enroll fingerprint, and an in-process `sign()`; there is NO API that returns or
|
||||
* serializes the private key. The raw private key material is held in a module-private closure.
|
||||
*/
|
||||
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
|
||||
import type { KeyObject } from 'node:crypto'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
|
||||
export interface AgentIdentity {
|
||||
/** Ed25519 raw 32-byte public key → stored in host registry (§4.2 agent_pubkey). */
|
||||
readonly publicKey: Uint8Array
|
||||
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
|
||||
readonly enrollFpr: string
|
||||
/** Ed25519 signature over `message`, using the in-process private key. Never returns the key. */
|
||||
sign(message: Uint8Array): Uint8Array
|
||||
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
|
||||
exportPrivatePkcs8Pem(): string
|
||||
/** The underlying private KeyObject — for in-process crypto (CSR/mTLS) ONLY, never serialized to the wire. */
|
||||
privateKeyObject(): KeyObject
|
||||
}
|
||||
|
||||
/** SHA-256 fingerprint of a raw Ed25519 public key, base64url — §4.2 enroll_fpr. */
|
||||
export function computeEnrollFpr(publicKey: Uint8Array): string {
|
||||
const digest = createHash('sha256').update(publicKey).digest()
|
||||
return encodeBase64UrlBytes(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
/** Raw 32-byte Ed25519 public key from a KeyObject (strips the SPKI DER prefix). */
|
||||
function rawPublicKey(pub: KeyObject): Uint8Array {
|
||||
const der = pub.export({ type: 'spki', format: 'der' })
|
||||
// Ed25519 SPKI is a fixed 44-byte structure; the raw key is the trailing 32 bytes.
|
||||
return new Uint8Array(der.subarray(der.length - 32))
|
||||
}
|
||||
|
||||
function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
|
||||
const rawPub = rawPublicKey(publicKey)
|
||||
const enrollFpr = computeEnrollFpr(rawPub)
|
||||
return {
|
||||
publicKey: rawPub,
|
||||
enrollFpr,
|
||||
sign(message: Uint8Array): Uint8Array {
|
||||
return new Uint8Array(sign(null, message, privateKey))
|
||||
},
|
||||
exportPrivatePkcs8Pem(): string {
|
||||
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
},
|
||||
privateKeyObject(): KeyObject {
|
||||
return privateKey
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
|
||||
export function generateIdentity(): AgentIdentity {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
return buildIdentity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Reconstruct an identity from a stored PKCS#8 PEM private key (keystore load path). */
|
||||
export function identityFromPrivatePem(pem: string): AgentIdentity {
|
||||
const privateKey = createPrivateKey(pem)
|
||||
const publicKey = createPublicKey(privateKey)
|
||||
return buildIdentity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
|
||||
export function verifySignature(
|
||||
publicKey: Uint8Array,
|
||||
message: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
): boolean {
|
||||
const spkiPrefix = Uint8Array.from([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
])
|
||||
const der = new Uint8Array(spkiPrefix.length + publicKey.length)
|
||||
der.set(spkiPrefix, 0)
|
||||
der.set(publicKey, spkiPrefix.length)
|
||||
const pub = createPublicKey({ key: Buffer.from(der), format: 'der', type: 'spki' })
|
||||
return verify(null, message, pub, signature)
|
||||
}
|
||||
109
agent/src/keys/keystore.ts
Normal file
109
agent/src/keys/keystore.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* On-disk keystore — PLAN_RELAY_AGENT T3 (INV4/INV5). Persists ONLY to `stateDir`, every secret
|
||||
* file mode `0600`. Stores: the Ed25519 private key (PKCS#8 PEM), the mTLS cert + CA chain, and
|
||||
* the FIX 3 unwrapped `hostContentSecret`. The raw pairing code is NEVER written here (T4).
|
||||
*/
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentIdentity } from './identity.js'
|
||||
import { identityFromPrivatePem } from './identity.js'
|
||||
|
||||
const SECRET_MODE = 0o600
|
||||
const DIR_MODE = 0o700
|
||||
|
||||
export interface Keystore {
|
||||
saveIdentity(id: AgentIdentity): void
|
||||
loadIdentity(): AgentIdentity | null
|
||||
saveCert(certPem: string, caChainPem: string): void
|
||||
loadCert(): { certPem: string; caChainPem: string } | null
|
||||
saveContentSecret(secret: Uint8Array): void
|
||||
loadContentSecret(): Uint8Array | null
|
||||
}
|
||||
|
||||
const KEY_FILE = 'agent.key.pem'
|
||||
const CERT_FILE = 'agent.cert.pem'
|
||||
const CA_FILE = 'agent.ca.pem'
|
||||
const CONTENT_SECRET_FILE = 'content.secret'
|
||||
|
||||
/** Corrupt/unreadable key material surfaces as a typed error (never a silent empty read). */
|
||||
export class KeystoreError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'KeystoreError'
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(stateDir: string): void {
|
||||
if (!existsSync(stateDir)) {
|
||||
mkdirSync(stateDir, { recursive: true, mode: DIR_MODE })
|
||||
return
|
||||
}
|
||||
// Refuse to write secrets into a world-/group-accessible directory (INV5 hard error).
|
||||
const mode = statSync(stateDir).mode & 0o777
|
||||
if ((mode & 0o077) !== 0) {
|
||||
throw new KeystoreError(
|
||||
`stateDir ${stateDir} is group/world accessible (mode ${mode.toString(8)}); refusing to write secrets`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function writeSecret(path: string, data: string | Uint8Array): void {
|
||||
writeFileSync(path, data, { mode: SECRET_MODE })
|
||||
// Enforce 0600 even if a prior umask/file left it wider.
|
||||
chmodSync(path, SECRET_MODE)
|
||||
}
|
||||
|
||||
export function openKeystore(stateDir: string): Keystore {
|
||||
const keyPath = join(stateDir, KEY_FILE)
|
||||
const certPath = join(stateDir, CERT_FILE)
|
||||
const caPath = join(stateDir, CA_FILE)
|
||||
const secretPath = join(stateDir, CONTENT_SECRET_FILE)
|
||||
|
||||
return {
|
||||
saveIdentity(id: AgentIdentity): void {
|
||||
ensureDir(stateDir)
|
||||
writeSecret(keyPath, id.exportPrivatePkcs8Pem())
|
||||
},
|
||||
loadIdentity(): AgentIdentity | null {
|
||||
if (!existsSync(keyPath)) return null
|
||||
let pem: string
|
||||
try {
|
||||
pem = readFileSync(keyPath, 'utf8')
|
||||
} catch (err) {
|
||||
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
|
||||
}
|
||||
try {
|
||||
return identityFromPrivatePem(pem)
|
||||
} catch (err) {
|
||||
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
|
||||
}
|
||||
},
|
||||
saveCert(certPem: string, caChainPem: string): void {
|
||||
ensureDir(stateDir)
|
||||
writeSecret(certPath, certPem)
|
||||
writeSecret(caPath, caChainPem)
|
||||
},
|
||||
loadCert(): { certPem: string; caChainPem: string } | null {
|
||||
if (!existsSync(certPath) || !existsSync(caPath)) return null
|
||||
return {
|
||||
certPem: readFileSync(certPath, 'utf8'),
|
||||
caChainPem: readFileSync(caPath, 'utf8'),
|
||||
}
|
||||
},
|
||||
saveContentSecret(secret: Uint8Array): void {
|
||||
ensureDir(stateDir)
|
||||
writeSecret(secretPath, secret)
|
||||
},
|
||||
loadContentSecret(): Uint8Array | null {
|
||||
if (!existsSync(secretPath)) return null
|
||||
return new Uint8Array(readFileSync(secretPath))
|
||||
},
|
||||
}
|
||||
}
|
||||
68
agent/src/lifecycle/revocation.ts
Normal file
68
agent/src/lifecycle/revocation.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Revocation + GOAWAY teardown — PLAN_RELAY_AGENT T14 (INV12). Distinguishes a `revoked` GOAWAY
|
||||
* (immediate teardown, NO reconnect) from the graceful `operatorDrain`/`shutdown` reasons
|
||||
* (finish in-flight, reconnect elsewhere) using ONLY the FROZEN §4.1 3-value `GoAwayReason` +
|
||||
* `decodeGoAwayReason` — never a locally-invented numeric mapping (cross-plan drift = silent
|
||||
* INV12 defeat). An unknown numeric code FAILS CLOSED to `revoked`.
|
||||
*
|
||||
* The RevocationState seam is imported from transport/seams.ts (W0), not redeclared. Its
|
||||
* `isRevoked()` is what T10's reconnectLoop consumes (one-way edge, no task cycle).
|
||||
*/
|
||||
import { decodeGoAwayReason } from 'relay-contracts'
|
||||
import type { GoAwayReason } from 'relay-contracts'
|
||||
import type { RevocationState, RevokeReason } from '../transport/seams.js'
|
||||
|
||||
export type GoAwayAction = 'drain' | 'revoked'
|
||||
|
||||
/**
|
||||
* Pure exhaustive map over the FROZEN 3-value union. `operatorDrain`/`shutdown` → drain (reconnect
|
||||
* elsewhere); `revoked` → revoked (stop). No integer literals — the reason is already a label.
|
||||
*/
|
||||
export function classifyGoAway(reason: GoAwayReason): GoAwayAction {
|
||||
switch (reason) {
|
||||
case 'operatorDrain':
|
||||
case 'shutdown':
|
||||
return 'drain'
|
||||
case 'revoked':
|
||||
return 'revoked'
|
||||
}
|
||||
}
|
||||
|
||||
export function createRevocationState(onTeardown: () => void): RevocationState {
|
||||
let revoked = false
|
||||
return {
|
||||
isRevoked(): boolean {
|
||||
return revoked
|
||||
},
|
||||
markRevoked(_reason: RevokeReason): void {
|
||||
if (revoked) return
|
||||
revoked = true
|
||||
onTeardown() // close all streams + tunnel immediately
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a decoded GOAWAY reason to the revocation state. On `revoked`, marks the state revoked
|
||||
* (⇒ teardown + reconnect suppression). Returns the action taken.
|
||||
*/
|
||||
export function applyGoAway(reason: GoAwayReason, state: RevocationState): GoAwayAction {
|
||||
const action = classifyGoAway(reason)
|
||||
if (action === 'revoked') state.markRevoked('goaway-revoked')
|
||||
return action
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a raw GOAWAY wire code. Decodes via the FROZEN decoder; an UNKNOWN code (decoder throws)
|
||||
* FAILS CLOSED to `revoked` (a default that guessed "drain" would defeat INV12).
|
||||
*/
|
||||
export function applyGoAwayCode(code: number, state: RevocationState): GoAwayAction {
|
||||
let reason: GoAwayReason
|
||||
try {
|
||||
reason = decodeGoAwayReason(code)
|
||||
} catch {
|
||||
state.markRevoked('goaway-revoked') // fail closed
|
||||
return 'revoked'
|
||||
}
|
||||
return applyGoAway(reason, state)
|
||||
}
|
||||
75
agent/src/log/logger.ts
Normal file
75
agent/src/log/logger.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Structured redacting logger — PLAN_RELAY_AGENT T2 (INV9: secrets never logged, no console.log).
|
||||
*
|
||||
* Meta keys whose name matches a secret substring are replaced with `[REDACTED]` before the
|
||||
* line is emitted, so a caller that accidentally passes `{ privateKey, cert, pairingCode,
|
||||
* agentToken }` can never leak the value. Emission goes through an injectable sink (default
|
||||
* stderr) — `console.log` is never used.
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export interface Logger {
|
||||
log(level: LogLevel, msg: string, meta?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
/** Sink for a formatted line. Default writes to stderr (NOT console.log). */
|
||||
export type LogSink = (line: string) => void
|
||||
|
||||
const LEVEL_ORDER: Readonly<Record<LogLevel, number>> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
}
|
||||
|
||||
/**
|
||||
* Substrings that mark a meta key as secret (case-insensitive). Matches `privateKey` (key),
|
||||
* `cert`, `caChain` (chain? no — cert covers it via 'cert'), `pairingCode` (code), `agentToken`
|
||||
* (token), etc. `nonce`/`streamId`/`hostId` intentionally do NOT match (they are not secrets).
|
||||
*/
|
||||
const REDACT_SUBSTRINGS: readonly string[] = [
|
||||
'key',
|
||||
'cert',
|
||||
'code',
|
||||
'token',
|
||||
'secret',
|
||||
'proof',
|
||||
'password',
|
||||
'pem',
|
||||
]
|
||||
|
||||
const REDACTED = '[REDACTED]'
|
||||
|
||||
function isSecretKey(name: string): boolean {
|
||||
const lower = name.toLowerCase()
|
||||
return REDACT_SUBSTRINGS.some((s) => lower.includes(s))
|
||||
}
|
||||
|
||||
function redactMeta(meta: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
out[k] = isSecretKey(k) ? REDACTED : v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const defaultSink: LogSink = (line) => {
|
||||
process.stderr.write(`${line}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a redacting logger at a minimum level. Lines below `level` are dropped. Secret meta
|
||||
* values are redacted by key name before serialization (INV9).
|
||||
*/
|
||||
export function createLogger(level: LogLevel, sink: LogSink = defaultSink): Logger {
|
||||
const threshold = LEVEL_ORDER[level]
|
||||
return {
|
||||
log(msgLevel, msg, meta) {
|
||||
if (LEVEL_ORDER[msgLevel] < threshold) return
|
||||
const safeMeta = meta ? redactMeta(meta) : undefined
|
||||
const metaPart = safeMeta ? ` ${JSON.stringify(safeMeta)}` : ''
|
||||
sink(`${msgLevel.toUpperCase()} ${msg}${metaPart}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
78
agent/src/service/install.ts
Normal file
78
agent/src/service/install.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Service install dispatcher — PLAN_RELAY_AGENT T17. Detects the platform, writes the unit, and
|
||||
* loads it. REFUSES to install as root (EXPLORE §4d least privilege). All IO is injected so the
|
||||
* logic is unit-testable without touching the real system.
|
||||
*/
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import {
|
||||
buildLaunchdPlist,
|
||||
launchdLoadCommand,
|
||||
launchdPlistPath,
|
||||
launchdUnloadCommand,
|
||||
} from './launchd.js'
|
||||
import {
|
||||
buildSystemdUnit,
|
||||
systemdDisableCommand,
|
||||
systemdEnableCommand,
|
||||
systemdUnitPath,
|
||||
} from './systemd.js'
|
||||
|
||||
export type ServicePlatform = 'launchd' | 'systemd'
|
||||
|
||||
export class RootRefusedError extends Error {
|
||||
constructor() {
|
||||
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
||||
this.name = 'RootRefusedError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstallDeps {
|
||||
writeFile(path: string, content: string): void
|
||||
runCommand(cmd: string, args: readonly string[]): Promise<void>
|
||||
getuid(): number
|
||||
homedir(): string
|
||||
username(): string
|
||||
binPath(): string
|
||||
}
|
||||
|
||||
/** Map a Node platform to its service manager, or null if unsupported. */
|
||||
export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
|
||||
if (os === 'darwin') return 'launchd'
|
||||
if (os === 'linux') return 'systemd'
|
||||
return null
|
||||
}
|
||||
|
||||
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
|
||||
export async function installService(
|
||||
_cfg: AgentConfig,
|
||||
platform: ServicePlatform,
|
||||
deps: InstallDeps,
|
||||
): Promise<void> {
|
||||
if (deps.getuid() === 0) throw new RootRefusedError()
|
||||
const bin = deps.binPath()
|
||||
if (platform === 'launchd') {
|
||||
const path = launchdPlistPath(deps.homedir())
|
||||
deps.writeFile(path, buildLaunchdPlist(bin))
|
||||
const { cmd, args } = launchdLoadCommand(path)
|
||||
await deps.runCommand(cmd, args)
|
||||
return
|
||||
}
|
||||
const path = systemdUnitPath(deps.homedir())
|
||||
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
|
||||
const { cmd, args } = systemdEnableCommand()
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
|
||||
/** Unload the service unit for `platform`. */
|
||||
export async function uninstallService(
|
||||
platform: ServicePlatform,
|
||||
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
|
||||
): Promise<void> {
|
||||
if (platform === 'launchd') {
|
||||
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir()))
|
||||
await deps.runCommand(cmd, args)
|
||||
return
|
||||
}
|
||||
const { cmd, args } = systemdDisableCommand()
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
44
agent/src/service/launchd.ts
Normal file
44
agent/src/service/launchd.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
||||
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
|
||||
*/
|
||||
const LABEL = 'com.web-terminal.agent'
|
||||
|
||||
export function launchdLabel(): string {
|
||||
return LABEL
|
||||
}
|
||||
|
||||
export function launchdPlistPath(homedir: string): string {
|
||||
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
|
||||
}
|
||||
|
||||
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */
|
||||
export function buildLaunchdPlist(binPath: string): string {
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||
'<plist version="1.0">',
|
||||
'<dict>',
|
||||
' <key>Label</key>',
|
||||
` <string>${LABEL}</string>`,
|
||||
' <key>ProgramArguments</key>',
|
||||
' <array>',
|
||||
` <string>${binPath}</string>`,
|
||||
' <string>run</string>',
|
||||
' </array>',
|
||||
' <key>RunAtLoad</key>',
|
||||
' <true/>',
|
||||
' <key>KeepAlive</key>',
|
||||
' <true/>',
|
||||
'</dict>',
|
||||
'</plist>',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function launchdLoadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'launchctl', args: ['load', plistPath] }
|
||||
}
|
||||
export function launchdUnloadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'launchctl', args: ['unload', plistPath] }
|
||||
}
|
||||
57
agent/src/service/originConfig.ts
Normal file
57
agent/src/service/originConfig.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
||||
* APPENDS `https://<subdomain>.term.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
||||
* as CONFIG — NO `src/` code edit. AUGMENTS, never weakens, the Origin/CSWSH check: existing
|
||||
* origins are always preserved (EXPLORE §3 "do not weaken the check").
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
export interface OriginFsDeps {
|
||||
exists(path: string): boolean
|
||||
read(path: string): string
|
||||
write(path: string, content: string): void
|
||||
}
|
||||
|
||||
const defaultFs: OriginFsDeps = {
|
||||
exists: existsSync,
|
||||
read: (p) => readFileSync(p, 'utf8'),
|
||||
write: (p, c) => writeFileSync(p, c),
|
||||
}
|
||||
|
||||
/** Compose the subdomain origin the base app must trust. */
|
||||
export function subdomainOrigin(subdomain: string, domain: string): string {
|
||||
return `https://${subdomain}.term.${domain}`
|
||||
}
|
||||
|
||||
const KEY = 'ALLOWED_ORIGINS'
|
||||
|
||||
function upsertOriginLine(content: string, origin: string): string {
|
||||
const lines = content.length === 0 ? [] : content.split('\n')
|
||||
let found = false
|
||||
const next = lines.map((line) => {
|
||||
if (!line.startsWith(`${KEY}=`)) return line
|
||||
found = true
|
||||
const current = line.slice(KEY.length + 1)
|
||||
const origins = current.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
||||
if (origins.includes(origin)) return line // idempotent — already trusted
|
||||
return `${KEY}=${[...origins, origin].join(',')}`
|
||||
})
|
||||
if (!found) next.push(`${KEY}=${origin}`)
|
||||
return next.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
||||
* existing origin. Creates the file/line if absent.
|
||||
*/
|
||||
export function ensureAllowedOrigin(
|
||||
baseAppEnvPath: string,
|
||||
subdomain: string,
|
||||
domain: string,
|
||||
fs: OriginFsDeps = defaultFs,
|
||||
): void {
|
||||
const origin = subdomainOrigin(subdomain, domain)
|
||||
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
||||
const updated = upsertOriginLine(existing, origin)
|
||||
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
||||
}
|
||||
40
agent/src/service/systemd.ts
Normal file
40
agent/src/service/systemd.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
|
||||
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
|
||||
*/
|
||||
const UNIT_NAME = 'web-terminal-agent.service'
|
||||
|
||||
export function systemdUnitName(): string {
|
||||
return UNIT_NAME
|
||||
}
|
||||
|
||||
export function systemdUnitPath(homedir: string): string {
|
||||
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
|
||||
}
|
||||
|
||||
/** Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). */
|
||||
export function buildSystemdUnit(binPath: string, user: string): string {
|
||||
return [
|
||||
'[Unit]',
|
||||
'Description=web-terminal host agent (rendezvous relay)',
|
||||
'After=network-online.target',
|
||||
'',
|
||||
'[Service]',
|
||||
'Type=simple',
|
||||
`ExecStart=${binPath} run`,
|
||||
'Restart=on-failure',
|
||||
'RestartSec=1',
|
||||
`User=${user}`,
|
||||
'',
|
||||
'[Install]',
|
||||
'WantedBy=default.target',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function systemdEnableCommand(): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', UNIT_NAME] }
|
||||
}
|
||||
export function systemdDisableCommand(): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', UNIT_NAME] }
|
||||
}
|
||||
63
agent/src/transport/backoff.ts
Normal file
63
agent/src/transport/backoff.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Reconnection / backoff — PLAN_RELAY_AGENT T10. REUSES the base app's 1/2/4…cap-30s policy
|
||||
* (EXPLORE §3). `reconnectLoop` only CONSUMES an injected `isRevoked()` (the W0 seam) — a revoked
|
||||
* host short-circuits and never reconnects (INV12). No task edge to T14.
|
||||
*/
|
||||
import type { Tunnel } from './tunnel.js'
|
||||
|
||||
export const BACKOFF_BASE_MS = 1_000
|
||||
export const BACKOFF_CAP_MS = 30_000
|
||||
|
||||
export interface BackoffPolicy {
|
||||
nextDelayMs(): number
|
||||
reset(): void
|
||||
}
|
||||
|
||||
/** Exponential backoff 1s,2s,4s…capped at 30s, optional [0.5×,1×] jitter. */
|
||||
export function createBackoff(
|
||||
opts: { baseMs?: number; capMs?: number; jitter?: boolean; rng?: () => number } = {},
|
||||
): BackoffPolicy {
|
||||
const baseMs = opts.baseMs ?? BACKOFF_BASE_MS
|
||||
const capMs = opts.capMs ?? BACKOFF_CAP_MS
|
||||
const jitter = opts.jitter ?? false
|
||||
const rng = opts.rng ?? Math.random
|
||||
let attempt = 0
|
||||
return {
|
||||
nextDelayMs(): number {
|
||||
const raw = Math.min(baseMs * 2 ** attempt, capMs)
|
||||
attempt += 1
|
||||
if (!jitter) return raw
|
||||
return Math.round(raw * (0.5 + rng() * 0.5)) // [0.5×, 1×]
|
||||
},
|
||||
reset(): void {
|
||||
attempt = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type Sleep = (ms: number) => Promise<void>
|
||||
const realSleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/**
|
||||
* Dial with backoff until success (resolves the connected Tunnel) or the host is revoked
|
||||
* (resolves null — never reconnect). Each dial failure waits `backoff.nextDelayMs()`; a success
|
||||
* resets the backoff.
|
||||
*/
|
||||
export async function reconnectLoop(
|
||||
dial: () => Promise<Tunnel>,
|
||||
backoff: BackoffPolicy,
|
||||
isRevoked: () => boolean,
|
||||
sleep: Sleep = realSleep,
|
||||
): Promise<Tunnel | null> {
|
||||
while (!isRevoked()) {
|
||||
try {
|
||||
const tunnel = await dial()
|
||||
backoff.reset()
|
||||
return tunnel
|
||||
} catch {
|
||||
if (isRevoked()) return null
|
||||
await sleep(backoff.nextDelayMs())
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
103
agent/src/transport/dial.ts
Normal file
103
agent/src/transport/dial.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Outbound mTLS dial — PLAN_RELAY_AGENT T12 (INV14/INV4). Builds a wss:// client authenticated by
|
||||
* the client cert + IN-PROCESS Ed25519 key + pinned CA chain, `rejectUnauthorized: true`, and NO
|
||||
* bearer/agent token (mTLS IS the auth). Absent/expired cert ⇒ fail-fast, no dial.
|
||||
*/
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
export class NotEnrolledError extends Error {
|
||||
constructor() {
|
||||
super('agent is not enrolled (no identity/cert in keystore)')
|
||||
this.name = 'NotEnrolledError'
|
||||
}
|
||||
}
|
||||
export class CertExpiredError extends Error {
|
||||
constructor() {
|
||||
super('client certificate has expired; renew before dialling')
|
||||
this.name = 'CertExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
/** TLS material for the wss client. NOTE: `rejectUnauthorized` is ALWAYS true (anti-MITM). */
|
||||
export interface TlsClientOptions {
|
||||
readonly cert: string
|
||||
readonly key: string
|
||||
readonly ca: string
|
||||
readonly rejectUnauthorized: true
|
||||
}
|
||||
|
||||
export interface CertInfo {
|
||||
readonly validTo: Date
|
||||
}
|
||||
export type CertParser = (certPem: string) => CertInfo
|
||||
const defaultCertParser: CertParser = (pem) => ({ validTo: new Date(new X509Certificate(pem).validTo) })
|
||||
|
||||
/**
|
||||
* Assemble the mTLS options from the keystore. Throws NotEnrolledError if key/cert are missing,
|
||||
* CertExpiredError if the cert is past `validTo`. There is NO token field by construction (INV4).
|
||||
*/
|
||||
export function buildTlsOptions(
|
||||
ks: Keystore,
|
||||
opts: { now?: Date; certParser?: CertParser } = {},
|
||||
): TlsClientOptions {
|
||||
const id = ks.loadIdentity()
|
||||
const certs = ks.loadCert()
|
||||
if (id === null || certs === null) throw new NotEnrolledError()
|
||||
const parse = opts.certParser ?? defaultCertParser
|
||||
const now = opts.now ?? new Date()
|
||||
if (parse(certs.certPem).validTo.getTime() < now.getTime()) throw new CertExpiredError()
|
||||
return {
|
||||
cert: certs.certPem,
|
||||
key: id.exportPrivatePkcs8Pem(),
|
||||
ca: certs.caChainPem,
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawTlsWs {
|
||||
send(data: Uint8Array): void
|
||||
close(): void
|
||||
on(event: string, cb: (...args: unknown[]) => void): void
|
||||
once(event: string, cb: (...args: unknown[]) => void): void
|
||||
}
|
||||
export type TlsWsConstructor = new (url: string, opts: TlsClientOptions) => RawTlsWs
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
return null
|
||||
}
|
||||
|
||||
function adapt(raw: RawTlsWs): WsLike {
|
||||
return {
|
||||
send: (d) => raw.send(d),
|
||||
on(ev, cb) {
|
||||
if (ev === 'message') {
|
||||
raw.on('message', (data: unknown) => {
|
||||
const bytes = toU8(data)
|
||||
if (bytes !== null) cb(bytes)
|
||||
})
|
||||
} else {
|
||||
raw.on(ev, cb)
|
||||
}
|
||||
},
|
||||
close: () => raw.close(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Dial the relay's /agent endpoint over mTLS wss. Resolves the connected WsLike on open. */
|
||||
export function dialRelay(
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
opts: { Ctor: TlsWsConstructor; now?: Date; certParser?: CertParser },
|
||||
): Promise<WsLike> {
|
||||
const tls = buildTlsOptions(ks, { ...(opts.now ? { now: opts.now } : {}), ...(opts.certParser ? { certParser: opts.certParser } : {}) })
|
||||
return new Promise<WsLike>((resolve, reject) => {
|
||||
const raw = new opts.Ctor(cfg.relayUrl, tls)
|
||||
raw.once('open', () => resolve(adapt(raw)))
|
||||
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
|
||||
})
|
||||
}
|
||||
41
agent/src/transport/flowControl.ts
Normal file
41
agent/src/transport/flowControl.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Per-stream flow control — PLAN_RELAY_AGENT T11. CONSUMES the §4.1 WINDOW_UPDATE credit protocol
|
||||
* (P1 owns the protocol). Per-stream credit means one heavy vim/top redraw can't starve another
|
||||
* stream. streamId 0 is the connection-level window applied to the whole link.
|
||||
*/
|
||||
export interface FlowController {
|
||||
consume(streamId: number, bytes: number): boolean
|
||||
grant(streamId: number, credit: number): void
|
||||
initWindow(streamId: number, initialCredit: number): void
|
||||
}
|
||||
|
||||
const CONNECTION_STREAM_ID = 0
|
||||
|
||||
export function createFlowController(): FlowController {
|
||||
const windows = new Map<number, number>()
|
||||
// Connection-level window is unbounded until explicitly initialized.
|
||||
windows.set(CONNECTION_STREAM_ID, Number.POSITIVE_INFINITY)
|
||||
|
||||
function remaining(streamId: number): number {
|
||||
return windows.get(streamId) ?? 0
|
||||
}
|
||||
|
||||
return {
|
||||
initWindow(streamId: number, initialCredit: number): void {
|
||||
windows.set(streamId, initialCredit)
|
||||
},
|
||||
grant(streamId: number, credit: number): void {
|
||||
windows.set(streamId, remaining(streamId) + credit)
|
||||
},
|
||||
consume(streamId: number, bytes: number): boolean {
|
||||
const conn = remaining(CONNECTION_STREAM_ID)
|
||||
const stream = remaining(streamId)
|
||||
if (stream < bytes || conn < bytes) return false // credit exhausted → pause
|
||||
windows.set(streamId, stream - bytes)
|
||||
if (conn !== Number.POSITIVE_INFINITY) {
|
||||
windows.set(CONNECTION_STREAM_ID, conn - bytes)
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
72
agent/src/transport/frpScaffold.ts
Normal file
72
agent/src/transport/frpScaffold.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* v0.8 frpc-wrap stepping-stone — PLAN_RELAY_AGENT T6. Fastest path to the café demo: wraps a
|
||||
* child `frpc` presenting the shared v0.8 `agentToken`, registering the subdomain, forwarding to
|
||||
* 127.0.0.1:3000. EXPLICITLY a stepping-stone — the native mux (T7–T11) replaces it at v0.9.
|
||||
*
|
||||
* Forwards ONLY to loopback (anti-SSRF). Retired once EnrollMode==='ed25519' (guard below).
|
||||
*/
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import { isLoopbackWsUrl } from '../config/agentConfig.js'
|
||||
import type { EnrollMode } from '../enroll/pair.js'
|
||||
|
||||
export interface FrpScaffold {
|
||||
start(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
onExit(cb: (code: number) => void): void
|
||||
}
|
||||
|
||||
/** Minimal child-process seam so tests inject a fake spawn (no real frpc needed). */
|
||||
export interface ChildLike {
|
||||
on(ev: 'exit', cb: (code: number | null) => void): void
|
||||
kill(): void
|
||||
}
|
||||
export type SpawnImpl = (cmd: string, args: readonly string[]) => ChildLike
|
||||
|
||||
const LOOPBACK_IP = '127.0.0.1'
|
||||
const LOCAL_PORT = 3000
|
||||
|
||||
/** Build the frpc.toml. local_ip is ALWAYS loopback; tls is enabled. */
|
||||
export function buildFrpcToml(cfg: AgentConfig): string {
|
||||
if (!isLoopbackWsUrl(cfg.localTargetUrl)) {
|
||||
throw new Error('frpScaffold refuses a non-loopback localTargetUrl (anti-SSRF)')
|
||||
}
|
||||
const subdomain = cfg.subdomain ?? ''
|
||||
return [
|
||||
'[common]',
|
||||
'tls_enable = true',
|
||||
'',
|
||||
'[web-terminal]',
|
||||
'type = "tcp"',
|
||||
`local_ip = "${LOOPBACK_IP}"`,
|
||||
`local_port = ${LOCAL_PORT}`,
|
||||
`subdomain = "${subdomain}"`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** True once the native Ed25519 substrate is active — `run` must NOT wire frpc then. */
|
||||
export function isFrpRetired(mode: EnrollMode): boolean {
|
||||
return mode === 'ed25519'
|
||||
}
|
||||
|
||||
/** Spawn (a mockable) frpc child with a generated config. */
|
||||
export function spawnFrpc(cfg: AgentConfig, frpcPath: string, spawnImpl: SpawnImpl): FrpScaffold {
|
||||
const toml = buildFrpcToml(cfg) // validates loopback before spawning
|
||||
void toml
|
||||
let child: ChildLike | null = null
|
||||
const exitCbs: Array<(code: number) => void> = []
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
child = spawnImpl(frpcPath, ['-c', 'frpc.toml'])
|
||||
child.on('exit', (code) => {
|
||||
for (const cb of exitCbs) cb(code ?? 0)
|
||||
})
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
child?.kill()
|
||||
},
|
||||
onExit(cb: (code: number) => void): void {
|
||||
exitCbs.push(cb)
|
||||
},
|
||||
}
|
||||
}
|
||||
84
agent/src/transport/heartbeat.ts
Normal file
84
agent/src/transport/heartbeat.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* §4.1 heartbeat — PLAN_RELAY_AGENT T9. PING every 15s on streamId 0; a missed PONG within the
|
||||
* interval ⇒ the tunnel is dead (⇒ T10 reconnect). Also replies PONG (echoing the 8-byte token)
|
||||
* to an inbound PING. Timers are injectable (TimerLike) for deterministic fake-timer tests.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { TimerLike } from './seams.js'
|
||||
import { pingHeader, pongHeader, type Tunnel } from './tunnel.js'
|
||||
|
||||
export const HEARTBEAT_INTERVAL_MS = 15_000
|
||||
|
||||
export interface Heartbeat {
|
||||
onPing(token: Uint8Array): void
|
||||
onPong(token: Uint8Array): void
|
||||
start(): void
|
||||
stop(): void
|
||||
onDead(cb: () => void): void
|
||||
}
|
||||
|
||||
const realTimer: TimerLike = {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
|
||||
export function createHeartbeat(
|
||||
tunnel: Tunnel,
|
||||
opts: { intervalMs?: number; timer?: TimerLike; genToken?: () => Uint8Array } = {},
|
||||
): Heartbeat {
|
||||
const intervalMs = opts.intervalMs ?? HEARTBEAT_INTERVAL_MS
|
||||
const timer = opts.timer ?? realTimer
|
||||
const genToken = opts.genToken ?? (() => new Uint8Array(randomBytes(8)))
|
||||
|
||||
let interval: unknown = null
|
||||
let deadline: unknown = null
|
||||
let pending = false
|
||||
let deadCb: (() => void) | null = null
|
||||
let dead = false
|
||||
|
||||
function fireDead(): void {
|
||||
if (dead) return
|
||||
dead = true
|
||||
stop()
|
||||
deadCb?.()
|
||||
}
|
||||
|
||||
function sendPing(): void {
|
||||
pending = true
|
||||
tunnel.send(pingHeader(), genToken())
|
||||
deadline = timer.setTimeout(() => {
|
||||
if (pending) fireDead()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (interval !== null) timer.clearInterval(interval)
|
||||
if (deadline !== null) timer.clearTimeout(deadline)
|
||||
interval = null
|
||||
deadline = null
|
||||
}
|
||||
|
||||
return {
|
||||
onPing(token: Uint8Array): void {
|
||||
tunnel.send(pongHeader(), token) // echo the token byte-exact
|
||||
},
|
||||
onPong(): void {
|
||||
pending = false
|
||||
if (deadline !== null) {
|
||||
timer.clearTimeout(deadline)
|
||||
deadline = null
|
||||
}
|
||||
},
|
||||
start(): void {
|
||||
dead = false
|
||||
sendPing()
|
||||
interval = timer.setInterval(sendPing, intervalMs)
|
||||
},
|
||||
stop,
|
||||
onDead(cb: () => void): void {
|
||||
deadCb = cb
|
||||
},
|
||||
}
|
||||
}
|
||||
71
agent/src/transport/loopback.ts
Normal file
71
agent/src/transport/loopback.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Loopback forwarder — PLAN_RELAY_AGENT T8. One OPEN ⇒ one fresh ws://127.0.0.1:3000<path>
|
||||
* socket, REPLAYING the real browser `Origin` (§4.1 MuxOpen.originHeader) so the UNCHANGED base
|
||||
* app's Origin check still passes end-to-end (CSWSH protection preserved — EXPLORE §3).
|
||||
*
|
||||
* The raw `ws` constructor is injectable so the URL/Origin wiring is unit-testable without a real
|
||||
* socket. The target is always loopback (validated upstream in config).
|
||||
*/
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
export type DialLoopback = (path: string, origin: string) => Promise<WsLike>
|
||||
|
||||
/** Minimal surface of a raw `ws` client the adapter needs. */
|
||||
export interface RawWs {
|
||||
send(data: Uint8Array): void
|
||||
close(): void
|
||||
on(event: string, cb: (...args: unknown[]) => void): void
|
||||
once(event: string, cb: (...args: unknown[]) => void): void
|
||||
}
|
||||
export type WsConstructor = new (
|
||||
url: string,
|
||||
opts?: { headers?: Record<string, string> },
|
||||
) => RawWs
|
||||
|
||||
/** Join the loopback target with the request path (avoids a double slash). */
|
||||
export function buildLoopbackUrl(target: string, path: string): string {
|
||||
const base = target.endsWith('/') ? target.slice(0, -1) : target
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
return null
|
||||
}
|
||||
|
||||
function adapt(raw: RawWs): WsLike {
|
||||
return {
|
||||
send(d: Uint8Array): void {
|
||||
raw.send(d)
|
||||
},
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
|
||||
if (ev === 'message') {
|
||||
raw.on('message', (data: unknown) => {
|
||||
const bytes = toU8(data)
|
||||
if (bytes !== null) cb(bytes)
|
||||
})
|
||||
} else {
|
||||
raw.on(ev, cb)
|
||||
}
|
||||
},
|
||||
close(): void {
|
||||
raw.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DialLoopback bound to `target`. Resolves once the loopback socket is open; rejects on
|
||||
* a pre-open error (so a down base app surfaces as a typed dial failure, not a silent hang).
|
||||
*/
|
||||
export function dialLoopback(target: string, Ctor: WsConstructor): DialLoopback {
|
||||
return (path: string, origin: string): Promise<WsLike> =>
|
||||
new Promise<WsLike>((resolve, reject) => {
|
||||
const url = buildLoopbackUrl(target, path)
|
||||
const raw = new Ctor(url, { headers: { Origin: origin } })
|
||||
raw.once('open', () => resolve(adapt(raw)))
|
||||
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
|
||||
})
|
||||
}
|
||||
43
agent/src/transport/seams.ts
Normal file
43
agent/src/transport/seams.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* W0 shared injection seams (DEPENDENCY-CYCLE BREAKER) — PLAN_RELAY_AGENT §2 / T2.
|
||||
*
|
||||
* These are intra-`agent/` seam *types only*. NO cross-plan frozen contract lives here —
|
||||
* those stay in `relay-contracts/`. Declaring them once at W0 lets W2/W3 transport tasks
|
||||
* (T7/T10/T12/T14) consume a stable type WITHOUT a task-level cycle (see §3 T10↔T14 note).
|
||||
*
|
||||
* This module MUST remain side-effect-free and runtime-dependency-free (type-only): a test
|
||||
* asserts importing it pulls in no `ws`/crypto runtime, so W2/W3 can depend on it freely.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal WS surface the transport layer needs. dial/tunnel/backoff/loopback share it so no
|
||||
* per-task redeclare and no drift. Adapters wrap the real `ws` socket into this shape.
|
||||
*/
|
||||
export interface WsLike {
|
||||
send(d: Uint8Array): void
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** Why a host stopped tunnelling. `renewal-refused`/`goaway-revoked` ⇒ do NOT reconnect. */
|
||||
export type RevokeReason = 'renewal-refused' | 'goaway-revoked' | 'operator'
|
||||
|
||||
/**
|
||||
* Revocation seam — T14 IMPLEMENTS it; T10's reconnectLoop only CONSUMES `isRevoked()`.
|
||||
* One-way edge (T14 → wires T10's loop), so there is no task cycle.
|
||||
*/
|
||||
export interface RevocationState {
|
||||
isRevoked(): boolean
|
||||
markRevoked(reason: RevokeReason): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal timer seam so heartbeat (T9) / cert rotation (T13) are testable with fake timers
|
||||
* without depending on Node's global timer types leaking into the transport surface.
|
||||
*/
|
||||
export interface TimerLike {
|
||||
setTimeout(cb: () => void, ms: number): unknown
|
||||
clearTimeout(handle: unknown): void
|
||||
setInterval(cb: () => void, ms: number): unknown
|
||||
clearInterval(handle: unknown): void
|
||||
}
|
||||
148
agent/src/transport/streamRouter.ts
Normal file
148
agent/src/transport/streamRouter.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Stream router — PLAN_RELAY_AGENT T8. Maps §4.1 streamId ⇄ a loopback socket. Each OPEN gets a
|
||||
* FRESH per-stream allocation (socket + transform state); there are NO global mutable buffers, so
|
||||
* cross-tenant/cross-stream buffer bleed is structurally impossible (EXPLORE §4b failure #3).
|
||||
*
|
||||
* MANDATORY INV1 defense-in-depth: the FIRST thing handleOpen does — before any allocation or
|
||||
* dial — is compare MuxOpen.subdomain (§4.1) against this agent's enrolled subdomain. A mismatch
|
||||
* is RST and NEVER dialed (metadata-only audit log, INV10). Belt-and-suspenders to relay authz.
|
||||
*/
|
||||
import type { MuxOpen } from 'relay-contracts'
|
||||
import { MuxOpenSchema } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { Logger } from '../log/logger.js'
|
||||
import type { WsLike } from './seams.js'
|
||||
import type { DialLoopback } from './loopback.js'
|
||||
import { closeHeader, dataHeader, type Tunnel } from './tunnel.js'
|
||||
|
||||
/**
|
||||
* Per-stream cipher transform. Identity in v0.9 (plaintext passthrough); replaced by the E2E
|
||||
* codec in v0.10 (T15). `takeControlFrames` lets an E2E transform emit host→client control frames
|
||||
* (e.g. HostHello) that the router forwards upstream — no-op for the identity transform.
|
||||
*/
|
||||
export interface FrameTransform {
|
||||
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null
|
||||
outbound(streamId: number, plain: Uint8Array): Uint8Array
|
||||
openStream(streamId: number): void
|
||||
closeStream(streamId: number): void
|
||||
takeControlFrames?(streamId: number): Uint8Array[]
|
||||
}
|
||||
|
||||
export const identityTransform: FrameTransform = {
|
||||
inbound: (_s, cipher) => cipher,
|
||||
outbound: (_s, plain) => plain,
|
||||
openStream: () => {},
|
||||
closeStream: () => {},
|
||||
}
|
||||
|
||||
export interface StreamRouter {
|
||||
handleOpen(open: MuxOpen): void
|
||||
handleData(streamId: number, payload: Uint8Array): void
|
||||
handleClose(streamId: number, rst: boolean): void
|
||||
activeStreamCount(): number
|
||||
}
|
||||
|
||||
interface StreamState {
|
||||
socket: WsLike | null
|
||||
readonly pending: Uint8Array[] // inbound bytes buffered until the loopback socket is open
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
export function createStreamRouter(
|
||||
cfg: AgentConfig,
|
||||
tunnel: Tunnel,
|
||||
dial: DialLoopback,
|
||||
transform: FrameTransform,
|
||||
logger: Logger,
|
||||
): StreamRouter {
|
||||
const streams = new Map<number, StreamState>()
|
||||
|
||||
function flushControlFrames(streamId: number): void {
|
||||
const frames = transform.takeControlFrames?.(streamId) ?? []
|
||||
for (const frame of frames) {
|
||||
tunnel.send(dataHeader(streamId, frame.length), frame)
|
||||
}
|
||||
}
|
||||
|
||||
function teardown(streamId: number, rst: boolean): void {
|
||||
const state = streams.get(streamId)
|
||||
if (state === undefined) return
|
||||
// Delete FIRST so a synchronous socket 'close' event can't re-enter this teardown.
|
||||
streams.delete(streamId)
|
||||
state.closed = true
|
||||
transform.closeStream(streamId)
|
||||
tunnel.send(closeHeader(streamId, rst), new Uint8Array(0))
|
||||
state.socket?.close()
|
||||
}
|
||||
|
||||
return {
|
||||
handleOpen(open: MuxOpen): void {
|
||||
// INV1 defense-in-depth — FIRST statement, before any allocation or dial.
|
||||
if (open.subdomain !== cfg.subdomain) {
|
||||
tunnel.sendRst(open.streamId)
|
||||
logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId })
|
||||
return
|
||||
}
|
||||
if (!MuxOpenSchema.safeParse(open).success) {
|
||||
tunnel.sendRst(open.streamId)
|
||||
return
|
||||
}
|
||||
if (streams.has(open.streamId)) {
|
||||
tunnel.sendRst(open.streamId) // duplicate OPEN for a live stream
|
||||
return
|
||||
}
|
||||
|
||||
const state: StreamState = { socket: null, pending: [], closed: false }
|
||||
streams.set(open.streamId, state)
|
||||
transform.openStream(open.streamId)
|
||||
|
||||
dial(open.requestPath, open.originHeader)
|
||||
.then((socket) => {
|
||||
if (state.closed) {
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
state.socket = socket
|
||||
// loopback output → transform.outbound → tunnel DATA
|
||||
socket.on('message', (data: unknown) => {
|
||||
if (!(data instanceof Uint8Array)) return
|
||||
const cipher = transform.outbound(open.streamId, data)
|
||||
tunnel.send(dataHeader(open.streamId, cipher.length), cipher)
|
||||
})
|
||||
socket.on('close', () => teardown(open.streamId, false))
|
||||
// flush anything buffered before the socket opened
|
||||
for (const buffered of state.pending) socket.send(buffered)
|
||||
state.pending.length = 0
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.log('error', 'loopback dial failed', { streamId: open.streamId })
|
||||
void err
|
||||
teardown(open.streamId, true)
|
||||
})
|
||||
},
|
||||
|
||||
handleData(streamId: number, payload: Uint8Array): void {
|
||||
const state = streams.get(streamId)
|
||||
if (state === undefined) {
|
||||
tunnel.sendRst(streamId) // DATA before OPEN / after CLOSE / unknown stream → RST
|
||||
return
|
||||
}
|
||||
const plain = transform.inbound(streamId, payload)
|
||||
flushControlFrames(streamId) // E2E: emit HostHello etc. (no-op in v0.9)
|
||||
if (plain === null) return // consumed (handshake), nothing to forward
|
||||
if (state.socket === null) {
|
||||
state.pending.push(plain)
|
||||
return
|
||||
}
|
||||
state.socket.send(plain)
|
||||
},
|
||||
|
||||
handleClose(streamId: number, rst: boolean): void {
|
||||
teardown(streamId, rst)
|
||||
},
|
||||
|
||||
activeStreamCount(): number {
|
||||
return streams.size
|
||||
},
|
||||
}
|
||||
}
|
||||
161
agent/src/transport/tunnel.ts
Normal file
161
agent/src/transport/tunnel.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* §4.1 tunnel holder — PLAN_RELAY_AGENT T7. Holds ONE physical mux over a WsLike socket:
|
||||
* encodes outbound frames, decodes inbound frames (via the FROZEN relay-contracts codec — never
|
||||
* re-implemented), and dispatches by type to the router (OPEN/DATA/CLOSE) or heartbeat
|
||||
* (PING/PONG) or connection-level control (GOAWAY/WINDOW_UPDATE, streamId 0).
|
||||
*
|
||||
* INV11: payloads are OPAQUE — no ANSI/terminal parsing here. Malformed frames RST the affected
|
||||
* stream (never the whole tunnel). After an inbound GOAWAY the tunnel DRAINS: no new OPEN.
|
||||
*
|
||||
* Design note (vs plan `holdTunnel(socket, router, heartbeat)`): to keep the construction graph
|
||||
* ACYCLIC (router/heartbeat are built WITH the tunnel), wiring is a two-phase `dispatchTo()`
|
||||
* call rather than constructor args. Same behavior, no task cycle. Recorded as a deviation.
|
||||
*/
|
||||
import {
|
||||
decodeGoaway,
|
||||
decodeMuxFrame,
|
||||
decodeOpen,
|
||||
encodeGoaway,
|
||||
encodeMuxFrame,
|
||||
} from 'relay-contracts'
|
||||
import type { GoAwayReason, MuxFrameHeader, MuxOpen } from 'relay-contracts'
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
const EMPTY = new Uint8Array(0)
|
||||
|
||||
/** Handlers the tunnel dispatches decoded frames to (wired post-construction). */
|
||||
export interface StreamHandlers {
|
||||
handleOpen(open: MuxOpen): void
|
||||
handleData(streamId: number, payload: Uint8Array): void
|
||||
handleClose(streamId: number, rst: boolean): void
|
||||
}
|
||||
export interface HeartbeatSink {
|
||||
onPing(token: Uint8Array): void
|
||||
onPong(token: Uint8Array): void
|
||||
}
|
||||
|
||||
export interface Tunnel {
|
||||
send(header: MuxFrameHeader, payload: Uint8Array): void
|
||||
onFrame(cb: (h: MuxFrameHeader, payload: Uint8Array) => void): void
|
||||
onGoAway(cb: (reason: GoAwayReason) => void): void
|
||||
goAway(lastStreamId: number, reason: GoAwayReason): void
|
||||
sendRst(streamId: number): void
|
||||
dispatchTo(router: StreamHandlers, heartbeat: HeartbeatSink): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
// --- frame-header builders (shared across the transport layer) --------------------------------
|
||||
|
||||
export function dataHeader(streamId: number, payloadLen: number): MuxFrameHeader {
|
||||
return { version: 1, type: 'data', fin: false, rst: false, streamId, payloadLen }
|
||||
}
|
||||
export function closeHeader(streamId: number, rst: boolean): MuxFrameHeader {
|
||||
return { version: 1, type: 'close', fin: !rst, rst, streamId, payloadLen: 0 }
|
||||
}
|
||||
export function rstHeader(streamId: number): MuxFrameHeader {
|
||||
return closeHeader(streamId, true)
|
||||
}
|
||||
export function pingHeader(): MuxFrameHeader {
|
||||
return { version: 1, type: 'ping', fin: false, rst: false, streamId: 0, payloadLen: 8 }
|
||||
}
|
||||
export function pongHeader(): MuxFrameHeader {
|
||||
return { version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 8 }
|
||||
}
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (Array.isArray(data) && data[0] instanceof Uint8Array) return data[0] as Uint8Array
|
||||
return null
|
||||
}
|
||||
|
||||
export function holdTunnel(socket: WsLike): Tunnel {
|
||||
let frameCb: ((h: MuxFrameHeader, payload: Uint8Array) => void) | null = null
|
||||
let goAwayCb: ((reason: GoAwayReason) => void) | null = null
|
||||
let handlers: StreamHandlers | null = null
|
||||
let heartbeat: HeartbeatSink | null = null
|
||||
let draining = false
|
||||
|
||||
function send(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||
socket.send(encodeMuxFrame(header, payload))
|
||||
}
|
||||
function sendRst(streamId: number): void {
|
||||
send(rstHeader(streamId), EMPTY)
|
||||
}
|
||||
|
||||
function dispatch(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||
frameCb?.(header, payload)
|
||||
switch (header.type) {
|
||||
case 'ping':
|
||||
heartbeat?.onPing(payload)
|
||||
return
|
||||
case 'pong':
|
||||
heartbeat?.onPong(payload)
|
||||
return
|
||||
case 'goaway': {
|
||||
const { reason } = decodeGoaway(payload)
|
||||
draining = true
|
||||
goAwayCb?.(reason)
|
||||
return
|
||||
}
|
||||
case 'open': {
|
||||
if (draining) {
|
||||
sendRst(header.streamId) // drain: refuse new streams
|
||||
return
|
||||
}
|
||||
let open: MuxOpen
|
||||
try {
|
||||
open = decodeOpen(payload)
|
||||
} catch {
|
||||
sendRst(header.streamId) // malformed OPEN → RST that stream, tunnel stays up
|
||||
return
|
||||
}
|
||||
handlers?.handleOpen(open)
|
||||
return
|
||||
}
|
||||
case 'data':
|
||||
handlers?.handleData(header.streamId, payload)
|
||||
return
|
||||
case 'close':
|
||||
handlers?.handleClose(header.streamId, header.rst)
|
||||
return
|
||||
case 'windowUpdate':
|
||||
return // consumed by the flow controller at the wiring layer (T11)
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('message', (...args: unknown[]) => {
|
||||
const bytes = toU8(args[0])
|
||||
if (bytes === null) return
|
||||
let decoded: { header: MuxFrameHeader; payload: Uint8Array }
|
||||
try {
|
||||
decoded = decodeMuxFrame(bytes)
|
||||
} catch {
|
||||
return // malformed framing: drop the frame, keep the tunnel alive (robust framing)
|
||||
}
|
||||
dispatch(decoded.header, decoded.payload)
|
||||
})
|
||||
|
||||
return {
|
||||
send,
|
||||
sendRst,
|
||||
onFrame(cb): void {
|
||||
frameCb = cb
|
||||
},
|
||||
onGoAway(cb): void {
|
||||
goAwayCb = cb
|
||||
},
|
||||
goAway(lastStreamId: number, reason: GoAwayReason): void {
|
||||
draining = true
|
||||
const payload = encodeGoaway(lastStreamId, reason)
|
||||
send({ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length }, payload)
|
||||
},
|
||||
dispatchTo(router: StreamHandlers, hb: HeartbeatSink): void {
|
||||
handlers = router
|
||||
heartbeat = hb
|
||||
},
|
||||
close(): void {
|
||||
socket.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user