/** * Native-tunnel cert auto-renew wiring — TASK A5 (PLAN_ZERO_TOUCH_ROLLOUT). * * The native run-loop (`superviseNative`) used to only MONITOR the frp-client leaf's freshness; the * leaf therefore expired at ~24h and the tunnel dropped until a manual re-pair. This module closes * that gap by driving `createCertRotator`/`renewCert` (crypto is REUSED, never reimplemented): * * - `createMtlsFetch` — the injected `fetchImpl` the rotator hands to `renewCert`. It POSTs /renew * over mTLS presenting the CURRENT keystore leaf (re-read on every call, so the first renewal * after a rotation already authenticates with the freshly issued leaf). mTLS IS the auth — no * token, `rejectUnauthorized` always true (INV4/INV14). The private key stays in-process. * - `wireAutoRenew` — routes the rotator callbacks: rotated → restart frpc onto the new leaf and * log (non-secret); revoked (403) → tear the tunnel down (INV12); error → log + let the rotator * retry with backoff. A failed renewal NEVER crashes the supervisor. * - `startNativeAutoRenew` — the builder `superviseNative` calls: loads the identity, builds the * mTLS fetch + rotator at ~2/3-TTL, and starts it. Returns null (auto-renew disabled) if the host * is not enrolled (no identity), rather than throwing into the run-loop. */ import { request as httpsRequest } from 'node:https' import type { AgentConfig } from '../config/agentConfig.js' import { resolveHostIdentity } from '../config/hostRecord.js' import type { Keystore } from '../keys/keystore.js' import type { Logger } from '../log/logger.js' import type { TimerLike } from '../transport/seams.js' import { createBackoff } from '../transport/backoff.js' import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js' import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js' import { createCertRotator, type CertExpiredBeyondGraceError, type CertRotator, } from './rotation.js' /** Non-secret message from an unknown thrown value (never serializes cert/key material). */ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err) } /** * Socket-idle timeout for a /renew request. A stalled or overloaded control-plane (or a NAT that * silently drops the connection after the TLS handshake) must NOT leave the renewal Promise pending * forever — that would starve the rotator's backoff-retry loop and let the leaf silently expire. On * timeout the request is destroyed and the rejection surfaces through the rotator's onError→backoff. */ export const RENEW_REQUEST_TIMEOUT_MS = 15_000 /** * Hard cap on the buffered /renew response body. The reply is a small `{cert,caChain}` JSON; anything * beyond a few KB is malformed or hostile, so we destroy the stream and reject rather than buffer it. */ export const MAX_RENEW_RESPONSE_BYTES = 64 * 1024 // --- mTLS fetch -------------------------------------------------------------------------------- /** A single mTLS request the fetch shim delegates to (injectable so the shim is offline-testable). */ export interface MtlsRequestInit { readonly method: string readonly headers: Record readonly body?: string } export interface MtlsResponse { readonly status: number readonly body: string } export type MtlsRequest = ( url: string, tls: TlsClientOptions, init: MtlsRequestInit, ) => Promise /** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */ const defaultMtlsRequest: MtlsRequest = (url, tls, init) => new Promise((resolve, reject) => { const req = httpsRequest( url, { method: init.method, headers: init.headers, cert: tls.cert, key: tls.key, // ca omitted ⇒ verify the server against the system roots (LE-fronted CP). Present only when a // private CA is pinned (not for /renew). ...(tls.ca !== undefined ? { ca: tls.ca } : {}), rejectUnauthorized: tls.rejectUnauthorized, // always true (anti-MITM, INV14) }, (res) => { const chunks: Buffer[] = [] let total = 0 res.on('data', (c: Buffer) => { total += c.length if (total > MAX_RENEW_RESPONSE_BYTES) { res.destroy() // MEDIUM: refuse an unbounded body — a renew reply is a few-KB JSON reject(new Error(`renew response body exceeded ${MAX_RENEW_RESPONSE_BYTES} byte cap`)) return } chunks.push(c) }) res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }), ) res.on('error', reject) // a mid-stream socket error must reject, not hang }, ) // HIGH: bound the request so a peer that accepts the connection but never replies rejects (and the // rotator re-enters backoff) instead of pending forever — destroy(err) emits 'error' → reject below. req.setTimeout(RENEW_REQUEST_TIMEOUT_MS, () => { req.destroy(new Error(`renew request timed out after ${RENEW_REQUEST_TIMEOUT_MS}ms`)) }) req.on('error', reject) if (init.body !== undefined) req.write(init.body) req.end() }) function toHeaderRecord(headers: RequestInit['headers']): Record { if (!headers) return {} if (headers instanceof Headers) { const out: Record = {} headers.forEach((v, k) => { out[k] = v }) return out } if (Array.isArray(headers)) return Object.fromEntries(headers) return { ...(headers as Record) } } /** * Build the `fetch`-shaped shim `renewCert` uses. Each call re-reads the CURRENT keystore leaf via * `buildTlsOptions` (which fail-fast throws NotEnrolled/CertExpired — the rotator then logs + retries * with backoff, never crashing) and delegates to the mTLS transport, mapping the result to a real * `Response` (so `res.ok`/`res.status`/`res.json()` behave exactly as `renewCert` expects). */ export function createMtlsFetch( ks: Keystore, opts: { request?: MtlsRequest; certParser?: CertParser } = {}, ): typeof fetch { const request = opts.request ?? defaultMtlsRequest const shim = async (input: Parameters[0], init?: RequestInit): Promise => { const url = typeof input === 'string' ? input : input.toString() // Present the current frp-client leaf (client auth), but verify the /renew SERVER cert against the // SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private // enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent → // node uses the default roots); rejectUnauthorized stays true. // Deliberately still fail-closed on an EXPIRED leaf: nginx would refuse to forward it anyway, so // a lapsed leaf is routed to the plain `/recover` endpoint by the rotator instead of through here. const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) }) const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized } const reqInit: MtlsRequestInit = { method: init?.method ?? 'GET', headers: toHeaderRecord(init?.headers), ...(typeof init?.body === 'string' ? { body: init.body } : {}), } const { status, body } = await request(url, tls, reqInit) return new Response(body, { status }) } return shim as typeof fetch } // --- rotator wiring ---------------------------------------------------------------------------- /** Non-secret identifiers logged alongside renew events (INV9). */ export interface AutoRenewLogIds { readonly subdomain: string | null readonly hostId: string | null } /** The two run-loop effects the rotator drives. */ export interface AutoRenewHooks { /** Restart the supervised frpc so it re-reads the rotated cert (a leaf rotation only). */ restartChild(): void /** Tear the tunnel down (host revoked ⇒ never reconnect, INV12). */ stop(): void } /** Handle for the wired auto-renew loop. */ export interface AutoRenewController { stop(): void } /** * Wire a rotator's callbacks to the run-loop and start it. Rotated → restart frpc; revoked → stop; * error → log (non-secret) and let the rotator retry with backoff. Returns a controller that stops * the rotator's scheduled timer. */ export function wireAutoRenew( rotator: CertRotator, hooks: AutoRenewHooks, logger: Logger, ids: AutoRenewLogIds, ): AutoRenewController { const meta = { subdomain: ids.subdomain, hostId: ids.hostId } rotator.onRotated(() => { logger.log('info', 'frp-client cert rotated; restarting frpc onto the fresh leaf', meta) hooks.restartChild() }) rotator.onRevoked(() => { logger.log('warn', 'frp-client cert renewal refused (host revoked); tearing down tunnel', meta) hooks.stop() }) rotator.onError((err) => { logger.log('warn', 'frp-client cert renewal failed; will retry with backoff', { ...meta, error: errorMessage(err), }) }) // Terminal: the grace window is spent, so every further attempt is guaranteed to fail. Say so once, // at error level, naming the fix — and deliberately do NOT stop the supervisor: `pair` writes fresh // cert files that the restart-on-exit frpc child picks up without a manual service restart. rotator.onExhausted((err) => { logger.log('error', 'frp-client cert expired beyond recovery grace — run `web-terminal-agent pair ` to re-pair this host', { ...meta, expiredForMs: err.expiredForMs, graceMs: err.graceMs, }) }) rotator.start() return { stop: () => rotator.stop() } } // --- builder ----------------------------------------------------------------------------------- /** Injection seams for `startNativeAutoRenew` (all optional; unset ⇒ real transport/timers). */ export interface NativeAutoRenewOpts { readonly mtlsRequest?: MtlsRequest readonly certParser?: CertParser /** Window in which an already-expired leaf may still be recovered via `/recover`. */ readonly expiredGraceMs?: number /** Plain (NON-mTLS) fetch for the `/recover` call; unset ⇒ global fetch. */ readonly recoverFetchImpl?: typeof fetch readonly timer?: TimerLike readonly renewBeforeMs?: number readonly retryBaseMs?: number readonly now?: () => Date readonly parseCert?: (pem: string) => Date } /** * Build + start native cert auto-renew for `superviseNative`. Renews at ~2/3 of the leaf TTL * (default `DEFAULT_CERT_RENEW_WINDOW_MS`, the same window the health probe alarms on). Returns null * (auto-renew disabled, logged) when the host has no identity — an unenrolled run-loop must not throw. */ export function startNativeAutoRenew( cfg: AgentConfig, ks: Keystore, hooks: AutoRenewHooks, logger: Logger, opts: NativeAutoRenewOpts = {}, ): AutoRenewController | null { const id = ks.loadIdentity() if (id === null) { logger.log('warn', 'no identity in keystore — cert auto-renew disabled', {}) return null } const fetchImpl = createMtlsFetch(ks, { ...(opts.mtlsRequest ? { request: opts.mtlsRequest } : {}), ...(opts.certParser ? { certParser: opts.certParser } : {}), }) const rotator = createCertRotator(cfg, id, ks, { fetchImpl, ...(opts.expiredGraceMs !== undefined ? { expiredGraceMs: opts.expiredGraceMs } : {}), ...(opts.recoverFetchImpl ? { recoverFetchImpl: opts.recoverFetchImpl } : {}), renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS, ...(opts.timer ? { timer: opts.timer } : {}), ...(opts.now ? { now: opts.now } : {}), ...(opts.parseCert ? { parseCert: opts.parseCert } : {}), ...(opts.retryBaseMs !== undefined ? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) } : {}), }) // Prefer the resolved identity (config > enrolment record > leaf SPIFFE SAN) so renewal warnings // actually name the host — `cfg` alone is null on every install that predates the record. const ids = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null) return wireAutoRenew(rotator, hooks, logger, ids) }