`POST /renew` is authenticated by mTLS with the very leaf it renews, so once that leaf lapsed the host could never renew it and the tunnel stayed down until an operator re-paired by hand. Production hit exactly this: the Mac slept through its 8h renewal window, the 24h leaf expired, and the agent then logged `client certificate has expired; renew before dialling` 6380 times over 8 days without recovering. Three layers independently refused an expired leaf, so all three had to move: - agent `buildTlsOptions` gains an opt-in `expiredGraceMs`. Absent/0 keeps the historical fail-closed behaviour, and the TUNNEL dial never passes it — only the renew transport does. Past the window it throws the new `CertExpiredBeyondGraceError`. - agent rotator routes an already-expired leaf to a separate recovery endpoint and treats "beyond grace" as TERMINAL: report once via `onExhausted`, stop retrying, and name the fix (re-pair) instead of spamming warnings forever. - control-plane `assertPresentedCertTrusted` grants a bounded grace on `notAfter` only. Chain validation, SPIFFE identity, `notBefore`, and the registry `active`/account checks are all unchanged, so revocation still bites. - new `deploy/nginx/recover-mtls.conf` (:8472). The strict enroll vhost cannot host this: under `ssl_verify_client optional` nginx answers a bare 400 as soon as a presented cert fails verification, so an expired leaf never reaches the location — and the directive is server-level, not per-location. Grace defaults to 30 days on both ends and is configurable (`RECOVER_URL`, `expiredRenewGraceMs`). The honest trade is recorded in the code: a stale stolen leaf stays reusable for the window, which widens an existing exposure (an unexpired stolen leaf already renews indefinitely) rather than opening a new one. Verified: agent 296/296, control-plane 286/286, tsc clean on both.
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
/**
|
|
* 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,
|
|
DEFAULT_EXPIRED_RENEW_GRACE_MS,
|
|
} from '../src/transport/dial.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<typeof openKeystore> } {
|
|
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<void> => 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<string, unknown>; init: Record<string, unknown> }> = []
|
|
const request: MtlsRequest = async (url, tls, init) => {
|
|
seen.push({ url, tls: tls as unknown as Record<string, unknown>, init: init as unknown as Record<string, unknown> })
|
|
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<typeof vi.fn>
|
|
stop: ReturnType<typeof vi.fn>
|
|
} {
|
|
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 })
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Expired-leaf recovery. `createMtlsFetch` is the ONE place that decides whether a lapsed leaf may
|
|
* still be presented; if it keeps refusing (the pre-fix behaviour) the renewal can never leave the
|
|
* host and the tunnel stays dead until a manual re-pair.
|
|
*/
|
|
describe('createMtlsFetch expired-leaf recovery', () => {
|
|
const expiredBy = (ms: number) => () => ({ validTo: new Date(Date.now() - ms) })
|
|
const DAY = 86_400_000
|
|
|
|
it('still presents a leaf that expired INSIDE the grace window (renewal can go out)', async () => {
|
|
const { dir, ks } = enrolledKs()
|
|
let presented = ''
|
|
const request: MtlsRequest = async (_url, tls) => {
|
|
presented = tls.cert
|
|
return { status: 201, body: '{}' }
|
|
}
|
|
const f = createMtlsFetch(ks, { request, certParser: expiredBy(8 * DAY) })
|
|
const res = await f('https://recover.example.com/renew', { method: 'POST', body: '{}' })
|
|
expect(res.status).toBe(201)
|
|
expect(presented).toBe('LEAFCERT')
|
|
rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('refuses once the leaf is BEYOND the grace window (terminal — re-pair required)', async () => {
|
|
const { dir, ks } = enrolledKs()
|
|
const request: MtlsRequest = async () => ({ status: 201, body: '{}' })
|
|
const f = createMtlsFetch(ks, {
|
|
request,
|
|
certParser: expiredBy(DEFAULT_EXPIRED_RENEW_GRACE_MS + DAY),
|
|
})
|
|
await expect(f('https://recover.example.com/renew', { method: 'POST' })).rejects.toThrow(
|
|
CertExpiredBeyondGraceError,
|
|
)
|
|
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()
|
|
})
|
|
})
|