1. `.gitignore` swallowed a source file. `agent/src/dist/buildBinary.ts` is the
packaging config, not build output, but the blanket `dist/` rule meant it was
never committed — a fresh clone could neither typecheck `agent/src/index.ts`
nor import it from the committed `agent/test/buildBinary.test.ts`. Re-included
the DIRECTORY first (git does not descend into an excluded one, so un-ignoring
just the file would not have worked) and committed the file. Verified build
output under `agent/dist/`, `dist/`, `public/build/` is still ignored.
2. Tunnel logs named no host. `pair` learned hostId/subdomain from the enroll
response and threw them away, so the long-running `run` process logged
`{"subdomain":null,"hostId":null}` — including all 6380 warnings during the
8-day outage, at exactly the moment you want to know which host. New
`config/hostRecord.ts` persists them at enrol; `resolveHostIdentity` resolves
config > record > the subdomain embedded in the leaf's SPIFFE SAN, so hosts
enrolled before the record existed get an identifier back without re-pairing.
3. The phone track had no recovery path. Added `POST /device/:id/recover`,
mirroring the host route: cert in the body, device-CA path validation, SPIFFE
parse, `notBefore` never graced, and the full registry check (active + same
account + `:id` matches the cert + same key) — only `notAfter` is relaxed.
Verified: agent 300/300, control-plane 296/296, tsc clean on both.
NOTE: the iOS/Android clients are not wired to call /device/:id/recover yet —
the server capability exists, the client-side trigger does not.
274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
/**
|
|
* Native-tunnel cert auto-renew wiring — TASK A5 (PLAN_ZERO_TOUCH_ROLLOUT).
|
|
*
|
|
* The native run-loop (`superviseNative`) used to only MONITOR the frp-client leaf's freshness; the
|
|
* leaf therefore expired at ~24h and the tunnel dropped until a manual re-pair. This module closes
|
|
* that gap by driving `createCertRotator`/`renewCert` (crypto is REUSED, never reimplemented):
|
|
*
|
|
* - `createMtlsFetch` — the injected `fetchImpl` the rotator hands to `renewCert`. It POSTs /renew
|
|
* over mTLS presenting the CURRENT keystore leaf (re-read on every call, so the first renewal
|
|
* after a rotation already authenticates with the freshly issued leaf). mTLS IS the auth — no
|
|
* token, `rejectUnauthorized` always true (INV4/INV14). The private key stays in-process.
|
|
* - `wireAutoRenew` — routes the rotator callbacks: rotated → restart frpc onto the new leaf and
|
|
* log (non-secret); revoked (403) → tear the tunnel down (INV12); error → log + let the rotator
|
|
* retry with backoff. A failed renewal NEVER crashes the supervisor.
|
|
* - `startNativeAutoRenew` — the builder `superviseNative` calls: loads the identity, builds the
|
|
* mTLS fetch + rotator at ~2/3-TTL, and starts it. Returns null (auto-renew disabled) if the host
|
|
* is not enrolled (no identity), rather than throwing into the run-loop.
|
|
*/
|
|
import { request as httpsRequest } from 'node:https'
|
|
import type { AgentConfig } from '../config/agentConfig.js'
|
|
import { resolveHostIdentity } from '../config/hostRecord.js'
|
|
import type { Keystore } from '../keys/keystore.js'
|
|
import type { Logger } from '../log/logger.js'
|
|
import type { TimerLike } from '../transport/seams.js'
|
|
import { createBackoff } from '../transport/backoff.js'
|
|
import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js'
|
|
import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js'
|
|
import {
|
|
createCertRotator,
|
|
type CertExpiredBeyondGraceError,
|
|
type CertRotator,
|
|
} from './rotation.js'
|
|
|
|
/** Non-secret message from an unknown thrown value (never serializes cert/key material). */
|
|
function errorMessage(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err)
|
|
}
|
|
|
|
/**
|
|
* Socket-idle timeout for a /renew request. A stalled or overloaded control-plane (or a NAT that
|
|
* silently drops the connection after the TLS handshake) must NOT leave the renewal Promise pending
|
|
* forever — that would starve the rotator's backoff-retry loop and let the leaf silently expire. On
|
|
* timeout the request is destroyed and the rejection surfaces through the rotator's onError→backoff.
|
|
*/
|
|
export const RENEW_REQUEST_TIMEOUT_MS = 15_000
|
|
/**
|
|
* Hard cap on the buffered /renew response body. The reply is a small `{cert,caChain}` JSON; anything
|
|
* beyond a few KB is malformed or hostile, so we destroy the stream and reject rather than buffer it.
|
|
*/
|
|
export const MAX_RENEW_RESPONSE_BYTES = 64 * 1024
|
|
|
|
// --- mTLS fetch --------------------------------------------------------------------------------
|
|
|
|
/** A single mTLS request the fetch shim delegates to (injectable so the shim is offline-testable). */
|
|
export interface MtlsRequestInit {
|
|
readonly method: string
|
|
readonly headers: Record<string, string>
|
|
readonly body?: string
|
|
}
|
|
export interface MtlsResponse {
|
|
readonly status: number
|
|
readonly body: string
|
|
}
|
|
export type MtlsRequest = (
|
|
url: string,
|
|
tls: TlsClientOptions,
|
|
init: MtlsRequestInit,
|
|
) => Promise<MtlsResponse>
|
|
|
|
/** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */
|
|
const defaultMtlsRequest: MtlsRequest = (url, tls, init) =>
|
|
new Promise<MtlsResponse>((resolve, reject) => {
|
|
const req = httpsRequest(
|
|
url,
|
|
{
|
|
method: init.method,
|
|
headers: init.headers,
|
|
cert: tls.cert,
|
|
key: tls.key,
|
|
// 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) => {
|
|
const chunks: Buffer[] = []
|
|
let total = 0
|
|
res.on('data', (c: Buffer) => {
|
|
total += c.length
|
|
if (total > MAX_RENEW_RESPONSE_BYTES) {
|
|
res.destroy() // MEDIUM: refuse an unbounded body — a renew reply is a few-KB JSON
|
|
reject(new Error(`renew response body exceeded ${MAX_RENEW_RESPONSE_BYTES} byte cap`))
|
|
return
|
|
}
|
|
chunks.push(c)
|
|
})
|
|
res.on('end', () =>
|
|
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
|
|
)
|
|
res.on('error', reject) // a mid-stream socket error must reject, not hang
|
|
},
|
|
)
|
|
// HIGH: bound the request so a peer that accepts the connection but never replies rejects (and the
|
|
// rotator re-enters backoff) instead of pending forever — destroy(err) emits 'error' → reject below.
|
|
req.setTimeout(RENEW_REQUEST_TIMEOUT_MS, () => {
|
|
req.destroy(new Error(`renew request timed out after ${RENEW_REQUEST_TIMEOUT_MS}ms`))
|
|
})
|
|
req.on('error', reject)
|
|
if (init.body !== undefined) req.write(init.body)
|
|
req.end()
|
|
})
|
|
|
|
function toHeaderRecord(headers: RequestInit['headers']): Record<string, string> {
|
|
if (!headers) return {}
|
|
if (headers instanceof Headers) {
|
|
const out: Record<string, string> = {}
|
|
headers.forEach((v, k) => {
|
|
out[k] = v
|
|
})
|
|
return out
|
|
}
|
|
if (Array.isArray(headers)) return Object.fromEntries(headers)
|
|
return { ...(headers as Record<string, string>) }
|
|
}
|
|
|
|
/**
|
|
* Build the `fetch`-shaped shim `renewCert` uses. Each call re-reads the CURRENT keystore leaf via
|
|
* `buildTlsOptions` (which fail-fast throws NotEnrolled/CertExpired — the rotator then logs + retries
|
|
* with backoff, never crashing) and delegates to the mTLS transport, mapping the result to a real
|
|
* `Response` (so `res.ok`/`res.status`/`res.json()` behave exactly as `renewCert` expects).
|
|
*/
|
|
export function createMtlsFetch(
|
|
ks: Keystore,
|
|
opts: { request?: MtlsRequest; certParser?: CertParser } = {},
|
|
): typeof fetch {
|
|
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()
|
|
// 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.
|
|
// Deliberately still fail-closed on an EXPIRED leaf: nginx would refuse to forward it anyway, so
|
|
// a lapsed leaf is routed to the plain `/recover` endpoint by the rotator instead of through here.
|
|
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),
|
|
...(typeof init?.body === 'string' ? { body: init.body } : {}),
|
|
}
|
|
const { status, body } = await request(url, tls, reqInit)
|
|
return new Response(body, { status })
|
|
}
|
|
return shim as typeof fetch
|
|
}
|
|
|
|
// --- rotator wiring ----------------------------------------------------------------------------
|
|
|
|
/** Non-secret identifiers logged alongside renew events (INV9). */
|
|
export interface AutoRenewLogIds {
|
|
readonly subdomain: string | null
|
|
readonly hostId: string | null
|
|
}
|
|
|
|
/** The two run-loop effects the rotator drives. */
|
|
export interface AutoRenewHooks {
|
|
/** Restart the supervised frpc so it re-reads the rotated cert (a leaf rotation only). */
|
|
restartChild(): void
|
|
/** Tear the tunnel down (host revoked ⇒ never reconnect, INV12). */
|
|
stop(): void
|
|
}
|
|
|
|
/** Handle for the wired auto-renew loop. */
|
|
export interface AutoRenewController {
|
|
stop(): void
|
|
}
|
|
|
|
/**
|
|
* Wire a rotator's callbacks to the run-loop and start it. Rotated → restart frpc; revoked → stop;
|
|
* error → log (non-secret) and let the rotator retry with backoff. Returns a controller that stops
|
|
* the rotator's scheduled timer.
|
|
*/
|
|
export function wireAutoRenew(
|
|
rotator: CertRotator,
|
|
hooks: AutoRenewHooks,
|
|
logger: Logger,
|
|
ids: AutoRenewLogIds,
|
|
): AutoRenewController {
|
|
const meta = { subdomain: ids.subdomain, hostId: ids.hostId }
|
|
rotator.onRotated(() => {
|
|
logger.log('info', 'frp-client cert rotated; restarting frpc onto the fresh leaf', meta)
|
|
hooks.restartChild()
|
|
})
|
|
rotator.onRevoked(() => {
|
|
logger.log('warn', 'frp-client cert renewal refused (host revoked); tearing down tunnel', meta)
|
|
hooks.stop()
|
|
})
|
|
rotator.onError((err) => {
|
|
logger.log('warn', 'frp-client cert renewal failed; will retry with backoff', {
|
|
...meta,
|
|
error: errorMessage(err),
|
|
})
|
|
})
|
|
// Terminal: the grace window is spent, so every further attempt is guaranteed to fail. Say so once,
|
|
// at error level, naming the fix — and deliberately do NOT stop the supervisor: `pair` writes fresh
|
|
// cert files that the restart-on-exit frpc child picks up without a manual service restart.
|
|
rotator.onExhausted((err) => {
|
|
logger.log('error', 'frp-client cert expired beyond recovery grace — run `web-terminal-agent pair <CODE>` to re-pair this host', {
|
|
...meta,
|
|
expiredForMs: err.expiredForMs,
|
|
graceMs: err.graceMs,
|
|
})
|
|
})
|
|
rotator.start()
|
|
return { stop: () => rotator.stop() }
|
|
}
|
|
|
|
// --- builder -----------------------------------------------------------------------------------
|
|
|
|
/** Injection seams for `startNativeAutoRenew` (all optional; unset ⇒ real transport/timers). */
|
|
export interface NativeAutoRenewOpts {
|
|
readonly mtlsRequest?: MtlsRequest
|
|
readonly certParser?: CertParser
|
|
/** Window in which an already-expired leaf may still be recovered via `/recover`. */
|
|
readonly expiredGraceMs?: number
|
|
/** Plain (NON-mTLS) fetch for the `/recover` call; unset ⇒ global fetch. */
|
|
readonly recoverFetchImpl?: typeof fetch
|
|
readonly timer?: TimerLike
|
|
readonly renewBeforeMs?: number
|
|
readonly retryBaseMs?: number
|
|
readonly now?: () => Date
|
|
readonly parseCert?: (pem: string) => Date
|
|
}
|
|
|
|
/**
|
|
* Build + start native cert auto-renew for `superviseNative`. Renews at ~2/3 of the leaf TTL
|
|
* (default `DEFAULT_CERT_RENEW_WINDOW_MS`, the same window the health probe alarms on). Returns null
|
|
* (auto-renew disabled, logged) when the host has no identity — an unenrolled run-loop must not throw.
|
|
*/
|
|
export function startNativeAutoRenew(
|
|
cfg: AgentConfig,
|
|
ks: Keystore,
|
|
hooks: AutoRenewHooks,
|
|
logger: Logger,
|
|
opts: NativeAutoRenewOpts = {},
|
|
): AutoRenewController | null {
|
|
const id = ks.loadIdentity()
|
|
if (id === null) {
|
|
logger.log('warn', 'no identity in keystore — cert auto-renew disabled', {})
|
|
return null
|
|
}
|
|
const fetchImpl = createMtlsFetch(ks, {
|
|
...(opts.mtlsRequest ? { request: opts.mtlsRequest } : {}),
|
|
...(opts.certParser ? { certParser: opts.certParser } : {}),
|
|
})
|
|
const rotator = createCertRotator(cfg, id, ks, {
|
|
fetchImpl,
|
|
...(opts.expiredGraceMs !== undefined ? { expiredGraceMs: opts.expiredGraceMs } : {}),
|
|
...(opts.recoverFetchImpl ? { recoverFetchImpl: opts.recoverFetchImpl } : {}),
|
|
renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS,
|
|
...(opts.timer ? { timer: opts.timer } : {}),
|
|
...(opts.now ? { now: opts.now } : {}),
|
|
...(opts.parseCert ? { parseCert: opts.parseCert } : {}),
|
|
...(opts.retryBaseMs !== undefined
|
|
? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) }
|
|
: {}),
|
|
})
|
|
// Prefer the resolved identity (config > enrolment record > leaf SPIFFE SAN) so renewal warnings
|
|
// actually name the host — `cfg` alone is null on every install that predates the record.
|
|
const ids = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
|
|
return wireAutoRenew(rotator, hooks, logger, ids)
|
|
}
|