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:
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user