fix(agent): make native cert auto-renew actually work end-to-end

Two real-deploy renew bugs (found running the live /renew):
- the /renew mTLS request pinned the enroll caChain as the server CA → TLS
  'unable to get local issuer certificate' (the LE-fronted CP is publicly
  trusted). Verify the server against SYSTEM roots (drop ca), keep the client
  cert + rejectUnauthorized:true. (TlsClientOptions.ca now optional.)
- renewCert parsed {cert, caChain:string}, but the CP returns cert=base64(DER)
  + caChain=base64(DER)[]; normalize to PEM (shared certs/pem.ts, reused by
  native enroll). Verified live: cert rotated 13:41→next-day, frpc restarted,
  tunnel stayed up. 281 tests pass.
This commit is contained in:
Yaojia Wang
2026-07-19 07:56:01 +02:00
parent 55d177e9ee
commit 1e398c7561
8 changed files with 71 additions and 30 deletions

View File

@@ -53,10 +53,10 @@ describe('renewCert (T13)', () => {
it('installs a fresh cert atomically on success (same key)', async () => {
const { dir, ks } = enrolledKs()
const before = ks.loadIdentity()!.publicKey
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' }))
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }))
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
expect(out).toBe('rotated')
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' })
expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA')
// pubkey unchanged — only the cert rotated
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
rmSync(dir, { recursive: true, force: true })
@@ -102,7 +102,7 @@ describe('createCertRotator (T13)', () => {
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch,
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch,
now: () => new Date(0),
parseCert: () => new Date(2000),
})
@@ -114,7 +114,7 @@ describe('createCertRotator (T13)', () => {
timer.advance(1000)
await flush()
expect(rotated).toBe(1)
expect(ks.loadCert()!.certPem).toBe('NEWCERT')
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
rotator.stop()
rmSync(dir, { recursive: true, force: true })
})
@@ -126,7 +126,7 @@ describe('createCertRotator (T13)', () => {
const fetchImpl = (async () => {
calls += 1
if (calls === 1) throw new Error('network down')
return jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
}) as unknown as typeof fetch
const errors: unknown[] = []
let rotated = 0
@@ -154,7 +154,7 @@ describe('createCertRotator (T13)', () => {
timer.advance(500)
await flush()
expect(rotated).toBe(1)
expect(ks.loadCert()!.certPem).toBe('NEWCERT')
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
rotator.stop()
rmSync(dir, { recursive: true, force: true })
})