/** * A5 native cert auto-renew wiring — TDD. * * Covers the host-side glue that was the "one real host code gap": the native run-loop must actually * RENEW the frp-client leaf (not merely monitor its freshness). Three units: * - `createMtlsFetch` — the injected `fetchImpl` `renewCert` uses: it POSTs /renew over mTLS * presenting the CURRENT keystore leaf (read fresh on every call, so a post-rotation renewal * authenticates with the new leaf) and maps the transport response to a `Response`. * - `wireAutoRenew` — routes the rotator's rotated→restartChild(+log), revoked→stop(+log), * error→log(no secret) callbacks and starts it. * - `startNativeAutoRenew` — the end-to-end builder `superviseNative` calls (identity + mTLS fetch * + rotator), returning null (disabled) when unenrolled. */ import { describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { AgentConfig } from '../src/config/agentConfig.js' import { generateP256Identity } from '../src/keys/identity.js' import { openKeystore } from '../src/keys/keystore.js' import { createLogger } from '../src/log/logger.js' import type { CertRotator } from '../src/certs/rotation.js' import { createMtlsFetch, startNativeAutoRenew, wireAutoRenew, type MtlsRequest, } from '../src/certs/nativeRenew.js' import { FakeTimer } from './fixtures/fakes.js' import { CertExpiredBeyondGraceError } from '../src/certs/rotation.js' const CFG: AgentConfig = { relayUrl: 'wss://relay/agent', enrollUrl: 'https://cp.example.com/enroll', stateDir: '/tmp/x', localTargetUrl: 'ws://127.0.0.1:3000', subdomain: 'host-42', hostId: 'h-1', } function enrolledKs(): { dir: string; ks: ReturnType } { const dir = mkdtempSync(join(tmpdir(), 'wta-nr-')) const ks = openKeystore(dir) ks.saveIdentity(generateP256Identity()) ks.saveCert('LEAFCERT', 'CACHAIN') return { dir, ks } } const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) }) const flush = (): Promise => new Promise((r) => setImmediate(r)) describe('createMtlsFetch (A5)', () => { it('presents the current keystore cert/key/CA over mTLS and maps the transport response', async () => { const { dir, ks } = enrolledKs() const seen: Array<{ url: string; tls: Record; init: Record }> = [] const request: MtlsRequest = async (url, tls, init) => { seen.push({ url, tls: tls as unknown as Record, init: init as unknown as Record }) return { status: 200, body: JSON.stringify({ cert: 'NEW', caChain: 'NEWCA' }) } } const f = createMtlsFetch(ks, { request, certParser: farFuture }) const res = await f('https://cp.example.com/renew', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"csr":"x"}', }) expect(res.status).toBe(200) expect(res.ok).toBe(true) expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' }) expect(seen[0]!.url).toBe('https://cp.example.com/renew') expect(seen[0]!.tls.cert).toBe('LEAFCERT') // /renew verifies the LE-fronted control-plane against the SYSTEM roots, so no private CA is pinned // (pinning the enroll caChain here fails with "unable to get local issuer certificate"). expect(seen[0]!.tls.ca).toBeUndefined() expect(seen[0]!.tls.rejectUnauthorized).toBe(true) expect(String(seen[0]!.tls.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key, mTLS only expect(seen[0]!.init.method).toBe('POST') expect(seen[0]!.init.body).toBe('{"csr":"x"}') rmSync(dir, { recursive: true, force: true }) }) it('reads the current cert on every call so a post-rotation renewal uses the new leaf', async () => { const { dir, ks } = enrolledKs() const certs: string[] = [] const request: MtlsRequest = async (_url, tls) => { certs.push(tls.cert) return { status: 200, body: '{}' } } const f = createMtlsFetch(ks, { request, certParser: farFuture }) await f('https://x/renew', { method: 'POST' }) ks.saveCert('ROTATEDLEAF', 'CACHAIN') // simulate a completed rotation await f('https://x/renew', { method: 'POST' }) expect(certs).toEqual(['LEAFCERT', 'ROTATEDLEAF']) rmSync(dir, { recursive: true, force: true }) }) it('propagates a not-enrolled keystore as a throw (renewCert then retries with backoff)', async () => { const dir = mkdtempSync(join(tmpdir(), 'wta-nr-')) const ks = openKeystore(dir) const request: MtlsRequest = async () => ({ status: 200, body: '{}' }) const f = createMtlsFetch(ks, { request, certParser: farFuture }) await expect(f('https://x/renew', { method: 'POST' })).rejects.toThrow() rmSync(dir, { recursive: true, force: true }) }) }) describe('wireAutoRenew (A5)', () => { function fakeRotator(): { rotator: CertRotator fire: { rotated?: () => void revoked?: () => void error?: (e: unknown) => void exhausted?: (e: CertExpiredBeyondGraceError) => void } start: ReturnType stop: ReturnType } { const fire: { rotated?: () => void revoked?: () => void error?: (e: unknown) => void exhausted?: (e: CertExpiredBeyondGraceError) => void } = {} const start = vi.fn() const stop = vi.fn() const rotator: CertRotator = { start, stop, onRotated: (cb) => { fire.rotated = cb }, onRevoked: (cb) => { fire.revoked = cb }, onError: (cb) => { fire.error = cb }, onExhausted: (cb) => { fire.exhausted = cb }, } return { rotator, fire, start, stop } } it('routes rotated→restartChild, revoked→stop, error→log; starts and stops the rotator', () => { const { rotator, fire, start, stop } = fakeRotator() const restartChild = vi.fn() const stopSupervisor = vi.fn() const lines: string[] = [] const logger = createLogger('info', (l) => lines.push(l)) const controller = wireAutoRenew(rotator, { restartChild, stop: stopSupervisor }, logger, { subdomain: 'host-42', hostId: 'h-1', }) expect(start).toHaveBeenCalledTimes(1) fire.rotated!() expect(restartChild).toHaveBeenCalledTimes(1) fire.revoked!() expect(stopSupervisor).toHaveBeenCalledTimes(1) fire.error!(new Error('network down')) controller.stop() expect(stop).toHaveBeenCalledTimes(1) const joined = lines.join('\n') expect(joined).toContain('host-42') // non-secret identifier is logged expect(joined).not.toContain('LEAFCERT') // never a leaf/key/CSR }) }) describe('startNativeAutoRenew (A5 end-to-end)', () => { it('a scheduled 200 renewal rotates the leaf on disk and restarts frpc with it', async () => { const { dir, ks } = enrolledKs() const timer = new FakeTimer() const request: MtlsRequest = async () => ({ status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }), }) const restartChild = vi.fn() const stop = vi.fn() const controller = startNativeAutoRenew( CFG, ks, { restartChild, stop }, createLogger('error', () => {}), { mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) }, )! expect(controller).not.toBeNull() timer.advance(1000) // renewal fires at ~2/3 TTL await flush() expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') // atomic persist expect(restartChild).toHaveBeenCalledTimes(1) // frpc restarted onto the fresh leaf expect(stop).not.toHaveBeenCalled() controller.stop() rmSync(dir, { recursive: true, force: true }) }) it('a scheduled 403 renewal tears the tunnel down (revoked) and never rotates', async () => { const { dir, ks } = enrolledKs() const timer = new FakeTimer() const request: MtlsRequest = async () => ({ status: 403, body: '' }) const restartChild = vi.fn() const stop = vi.fn() const controller = startNativeAutoRenew( CFG, ks, { restartChild, stop }, createLogger('error', () => {}), { mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) }, )! timer.advance(1000) await flush() expect(stop).toHaveBeenCalledTimes(1) expect(restartChild).not.toHaveBeenCalled() expect(ks.loadCert()!.certPem).toBe('LEAFCERT') // untouched controller.stop() rmSync(dir, { recursive: true, force: true }) }) it('a failing renewal is logged (no secret) and retried without crashing, then rotates', async () => { const { dir, ks } = enrolledKs() const timer = new FakeTimer() let calls = 0 const request: MtlsRequest = async () => { calls += 1 if (calls === 1) throw new Error('ECONNREFUSED') return { status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }) } } const restartChild = vi.fn() const stop = vi.fn() const lines: string[] = [] const controller = startNativeAutoRenew( CFG, ks, { restartChild, stop }, createLogger('warn', (l) => lines.push(l)), { mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, retryBaseMs: 500, now: () => new Date(0), parseCert: () => new Date(2000), }, )! timer.advance(1000) // first attempt throws await flush() expect(restartChild).not.toHaveBeenCalled() expect(stop).not.toHaveBeenCalled() // a failure NEVER tears down expect(lines.join('\n')).toContain('retry') timer.advance(500) // backoff retry fires and succeeds await flush() expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') expect(restartChild).toHaveBeenCalledTimes(1) expect(lines.join('\n')).not.toContain('LEAFCERT') controller.stop() rmSync(dir, { recursive: true, force: true }) }) it('returns null (auto-renew disabled) when the keystore has no identity', () => { const dir = mkdtempSync(join(tmpdir(), 'wta-nr-')) const ks = openKeystore(dir) const lines: string[] = [] const controller = startNativeAutoRenew( CFG, ks, { restartChild: () => {}, stop: () => {} }, createLogger('warn', (l) => lines.push(l)), {}, ) expect(controller).toBeNull() expect(lines.join('\n')).toContain('auto-renew disabled') rmSync(dir, { recursive: true, force: true }) }) }) /** * The mTLS renew transport stays STRICT about expiry: nginx refuses to forward an expired client * cert at all, so an expired leaf must be routed to the plain `/recover` endpoint by the rotator * rather than smuggled through this transport. */ describe('createMtlsFetch stays fail-closed on an expired leaf', () => { it('refuses to present a lapsed leaf (recovery is the rotator\'s job, not this transport\'s)', async () => { const { dir, ks } = enrolledKs() let called = 0 const request: MtlsRequest = async () => { called += 1 return { status: 201, body: '{}' } } const f = createMtlsFetch(ks, { request, certParser: () => ({ validTo: new Date(Date.now() - 86_400_000) }), }) await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow( /expired/i, ) expect(called).toBe(0) rmSync(dir, { recursive: true, force: true }) }) }) describe('wireAutoRenew exhausted routing', () => { it('logs an actionable re-pair alarm and leaves the supervisor running', () => { const fire: { exhausted?: (e: CertExpiredBeyondGraceError) => void } = {} const rotator: CertRotator = { start: vi.fn(), stop: vi.fn(), onRotated: () => {}, onRevoked: () => {}, onError: () => {}, onExhausted: (cb) => { fire.exhausted = cb }, } const restartChild = vi.fn() const stop = vi.fn() const lines: string[] = [] wireAutoRenew(rotator, { restartChild, stop }, createLogger('info', (l) => lines.push(l)), { subdomain: 'h7fd8', hostId: 'h-1', }) fire.exhausted?.(new CertExpiredBeyondGraceError(40 * 86_400_000, 30 * 86_400_000)) const alarm = lines.find((l) => /re-pair/i.test(l)) expect(alarm).toBeDefined() expect(alarm).toContain('h7fd8') // The supervisor keeps running: a later `pair` writes fresh cert files that the restarting // frpc child picks up. Tearing down here would make recovery need a manual restart too. expect(stop).not.toHaveBeenCalled() expect(restartChild).not.toHaveBeenCalled() }) })