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

@@ -72,7 +72,9 @@ const defaultMtlsRequest: MtlsRequest = (url, tls, init) =>
headers: init.headers,
cert: tls.cert,
key: tls.key,
ca: tls.ca,
// 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) => {
@@ -129,7 +131,12 @@ export function createMtlsFetch(
const request = opts.request ?? defaultMtlsRequest
const shim = async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
const url = typeof input === 'string' ? input : input.toString()
const tls = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) })
// 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.
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),

33
agent/src/certs/pem.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* PEM helpers shared by the native enroll (enroll/pair.ts) and renew (certs/rotation.ts) paths.
*
* The control-plane returns the frp-client leaf + CA chain as base64-encoded DER (cert as a string,
* caChain as a string[]); the keystore + frpc need PEM files. `derBase64ToPem` wraps a base64 DER body
* back into CERTIFICATE armor at 64 columns.
*/
/** base64(DER) → PEM (CERTIFICATE armor, 64-col wrapped). */
export function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string {
const body = derBase64.replace(/\s+/g, '')
const lines = body.match(/.{1,64}/g) ?? []
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
}
/**
* Normalize a control-plane cert response ({cert: base64 DER, caChain: base64 DER[]}) to PEM strings
* for the keystore. Throws if the shape is wrong. Shared by enroll + renew so both stay in lockstep.
*/
export function certResponseToPem(cert: unknown, caChain: unknown): { certPem: string; caChainPem: string } {
if (
typeof cert !== 'string' ||
!Array.isArray(caChain) ||
caChain.length === 0 ||
!caChain.every((c) => typeof c === 'string')
) {
throw new Error('cert response missing cert/caChain')
}
return {
certPem: derBase64ToPem(cert),
caChainPem: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
}
}

View File

@@ -15,6 +15,7 @@ import type { Keystore } from '../keys/keystore.js'
import type { TimerLike } from '../transport/seams.js'
import { createBackoff, type BackoffPolicy } from '../transport/backoff.js'
import { buildCsr } from '../enroll/csr.js'
import { certResponseToPem } from './pem.js'
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
@@ -64,11 +65,11 @@ export async function renewCert(
})
if (res.status === 403) return 'revoked'
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
const json = (await res.json()) as { cert?: string; caChain?: string }
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') {
throw new Error('cert renewal response missing cert/caChain')
}
ks.saveCert(json.cert, json.caChain) // atomic whole-file install
// The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the
// keystore + frpc (same shape as native enroll).
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
ks.saveCert(certPem, caChainPem) // atomic whole-file install
return 'rotated'
}