Files
web-terminal/agent/test/nativeRenew.test.ts
Yaojia Wang 5509c81eee fix(tunnel): recover an expired leaf over plain HTTPS, not mTLS
Correction to the previous commit. Its recovery vhost could not work: nginx
will not forward an expired client certificate in ANY `ssl_verify_client` mode.
`optional` answers a bare `400 The SSL certificate error` before any location
runs, and `optional_no_ca` only tolerates CHAIN failures — see nginx's
`ngx_ssl_verify_error_optional()`, which covers self-signed / unknown-issuer /
unverifiable-leaf and NOT `X509_V_ERR_CERT_HAS_EXPIRED`. Verified live against
the deployed :8472 server, which rejected the real expired leaf.

So recovery drops mTLS instead of trying to bend it:

- agent: `buildTlsOptions` and the mTLS renew transport go back to being
  strictly fail-closed on expiry — the relaxation is gone from the TLS layer
  entirely. The rotator now decides per attempt: valid → mTLS `/renew`,
  expired-inside-grace → plain `/recover`, expired-beyond-grace → terminal
  `onExhausted` with no request issued at all.
- new `recoverCert` POSTs `{cert, csr}` with no client certificate. Possession
  of the private key is still proven: the CSR is self-signed by it and the host
  signer already enforces CSR PoP plus `CSR key == registered key`, so a
  replayed (public) cert without the key yields at most a certificate the
  attacker cannot authenticate with.
- control-plane: `/renew` is strict again (grace 0, matching the terminator).
  The grace lives on the new `POST /recover`, which reads the cert from the BODY
  and ignores the `x-client-cert` header, then runs the identical trust
  pipeline: X.509 path validation to the frp-client-CA anchors, SPIFFE parse,
  `notBefore` (never graced), registry `active` + account match. Revocation
  still bites.
- deploy: no new vhost, no new SNI, no DNS. One `location = /recover` merged
  into the existing enroll vhost, documented in
  `deploy/nginx/enroll-recover-location.md` with the nginx source citation.

The load-bearing new test is "a self-signed cert with a FORGED SPIFFE SAN is
refused → 401": nginx no longer validates the chain on this path, so that
assertion is what keeps `/recover` from being a cert vending machine.

Verified: agent 289/289, control-plane 290/290, tsc clean on both.
2026-07-29 09:52:17 +02:00

351 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 } 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<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 })
})
})
/**
* 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()
})
})