feat(agent): auto-renew the host cert in the native run-loop (A5)

Wire createCertRotator/renewCert into superviseNative so the frp-client cert
renews silently at ~2/3 TTL over mTLS /renew and frpc restarts onto the fresh
leaf; failures retry with backoff, never crash the supervisor. mTLS transport
has a 15s timeout + 64KB response cap. 281 tests pass.
This commit is contained in:
Yaojia Wang
2026-07-18 13:32:05 +02:00
parent fff011bb7f
commit 9a5909f672
8 changed files with 815 additions and 6 deletions

View File

@@ -13,6 +13,7 @@ import type { AgentConfig } from '../config/agentConfig.js'
import type { AgentIdentity } from '../keys/identity.js'
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'
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
@@ -22,6 +23,8 @@ export interface CertRotator {
stop(): void
onRotated(cb: () => void): void
onRevoked(cb: () => void): void
/** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */
onError(cb: (err: unknown) => void): void
}
export type RenewOutcome = 'rotated' | 'revoked'
@@ -79,6 +82,8 @@ export function createCertRotator(
fetchImpl?: typeof fetch
now?: () => Date
parseCert?: (pem: string) => Date
/** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */
retryBackoff?: BackoffPolicy
} = {},
): CertRotator {
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
@@ -91,9 +96,11 @@ export function createCertRotator(
}
const doFetch = opts.fetchImpl ?? fetch
const now = opts.now ?? (() => new Date())
const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true })
let handle: unknown = null
let rotatedCb: (() => void) | null = null
let revokedCb: (() => void) | null = null
let errorCb: ((err: unknown) => void) | null = null
function schedule(): void {
const certs = ks.loadCert()
@@ -109,12 +116,16 @@ export function createCertRotator(
revokedCb?.()
return
}
retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle
rotatedCb?.()
schedule()
})
.catch(() => {
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile.
handle = timer.setTimeout(runRenewal, renewBeforeMs)
.catch((err: unknown) => {
// Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry
// with backoff. The cert is still valid until expiry, so the tunnel stays up meanwhile — a
// failed renewal must NEVER tear the supervisor down.
errorCb?.(err)
handle = timer.setTimeout(runRenewal, retryBackoff.nextDelayMs())
})
}
@@ -132,5 +143,8 @@ export function createCertRotator(
onRevoked(cb): void {
revokedCb = cb
},
onError(cb): void {
errorCb = cb
},
}
}