/** * Native-tunnel health probe — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2 / §5, INV9). * * Reports four independent sub-checks that together say whether this host's native frp tunnel is * actually serving: * (a) frpcAlive — the supervised frpc child process is running; * (b) baseAppReachable — the loopback base app answers `GET http://127.0.0.1:PORT` (loopback ONLY); * (c) proxyStarted — frpc logged a "start proxy success" line (control channel + proxy up); * (d) certFresh — the frp-client leaf's `notAfter` is beyond the renewal window (not expiring). * * Every side effect is an INJECTABLE seam (process-liveness checker, loopback HTTP probe, log * scanner, clock) so the logic is pure and offline-testable. The status renderer emits ONLY * non-secret identifiers (subdomain, host id, cert expiry date, boolean flags) — NEVER keys, certs, * tokens, or CSRs (INV9). No `console.log`. */ /** Default renewal window: 8h — one third of the 24h host frp-client TTL (renew at ~2/3 TTL). */ export const DEFAULT_CERT_RENEW_WINDOW_MS = 8 * 60 * 60 * 1000 /** frpc emits this line on the control channel once a proxy is registered and forwarding. */ export const FRPC_START_SUCCESS_RE = /start proxy success/i /** Loopback host the base-app probe targets — hardcoded so the probe can never reach off-host. */ export const LOOPBACK_PROBE_HOST = '127.0.0.1' const MIN_PORT = 1 const MAX_PORT = 65535 /** The four independent sub-checks plus the derived overall verdict. */ export interface HealthReport { /** The supervised frpc child is running. */ readonly frpcAlive: boolean /** `GET http://127.0.0.1:PORT` returned a response (base app is up on loopback). */ readonly baseAppReachable: boolean /** frpc logged "start proxy success" (the tunnel proxy is registered). */ readonly proxyStarted: boolean /** The frp-client cert's `notAfter` is beyond the renewal window (not near expiry). */ readonly certFresh: boolean /** True iff all four sub-checks pass. */ readonly healthy: boolean } /** Injectable side effects the probe consumes; each is independently faked in tests. */ export interface HealthProbeSeams { /** Process-liveness checker (the supervisor exposes whether its frpc child is alive). */ readonly isFrpcAlive: () => boolean /** Loopback HTTP probe of the base app; resolves true iff it answered ok. */ readonly probeBaseApp: () => Promise /** Returns the accumulated frpc stdout/log text to scan for the success line. */ readonly readFrpcLog: () => string /** The stored frp-client leaf's `notAfter`, or null if no cert is available. */ readonly certNotAfter: () => Date | null /** Current time (injected for deterministic expiry tests). */ readonly now: () => Date } export interface HealthProbeConfig { /** Certs within this many ms of `notAfter` are "near expiry" (default 8h). */ readonly renewWindowMs?: number } /** Pure: true iff the frpc log contains a "start proxy success" line. */ export function frpcProxyStarted(logText: string): boolean { return FRPC_START_SUCCESS_RE.test(logText) } /** Pure: true iff `notAfter` is strictly beyond the renewal window from `now` (not near expiry). */ export function certIsFresh(notAfter: Date | null, now: Date, renewWindowMs: number): boolean { if (notAfter === null) return false return notAfter.getTime() - now.getTime() > renewWindowMs } /** Minimal shape of a fetch response the loopback probe cares about. */ export interface LoopbackResponse { readonly ok: boolean } /** Injectable loopback HTTP fetch (real wiring passes global `fetch`). */ export type LoopbackFetch = (url: string) => Promise /** * Probe the loopback base app with `GET http://127.0.0.1:PORT/`. The host is hardcoded loopback, so * the probe can never reach an off-host target (anti-SSRF). Any thrown/rejected fetch — or a * non-integer/out-of-range port — resolves to `false` rather than propagating (a probe never throws). */ export async function probeLoopbackBaseApp(port: number, fetchImpl: LoopbackFetch): Promise { if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) return false const url = `http://${LOOPBACK_PROBE_HOST}:${port}/` try { const res = await fetchImpl(url) return res.ok } catch { return false } } /** Run all four sub-checks and derive the overall verdict. Never throws (each seam is guarded). */ export async function runHealthProbe( seams: HealthProbeSeams, config: HealthProbeConfig = {}, ): Promise { const renewWindowMs = config.renewWindowMs ?? DEFAULT_CERT_RENEW_WINDOW_MS const frpcAlive = seams.isFrpcAlive() const baseAppReachable = await seams.probeBaseApp() const proxyStarted = frpcProxyStarted(seams.readFrpcLog()) const certFresh = certIsFresh(seams.certNotAfter(), seams.now(), renewWindowMs) const healthy = frpcAlive && baseAppReachable && proxyStarted && certFresh return { frpcAlive, baseAppReachable, proxyStarted, certFresh, healthy } } /** Non-secret identifiers safe to print in `status` (INV9): NO key/cert/token/CSR material. */ export interface StatusIdentifiers { readonly subdomain: string | null readonly hostId: string | null readonly certNotAfter: Date | null } /** * Render `status` lines from non-secret identifiers + a health report (INV9). Emits the subdomain, * host id, cert EXPIRY DATE (never the cert bytes), and the boolean sub-check flags — never any key, * cert, token, or CSR material. */ export function renderHealthStatus( ids: StatusIdentifiers, report: HealthReport, ): readonly string[] { return [ `subdomain: ${ids.subdomain ?? '(none)'}`, `host_id: ${ids.hostId ?? '(none)'}`, `cert_expiry: ${ids.certNotAfter ? ids.certNotAfter.toISOString() : '(unknown)'}`, `frpc_alive: ${report.frpcAlive}`, `base_app_reachable: ${report.baseAppReachable}`, `proxy_started: ${report.proxyStarted}`, `cert_fresh: ${report.certFresh}`, `healthy: ${report.healthy}`, ] } /** Default periodic health-monitor interval (30s). */ export const DEFAULT_HEALTH_INTERVAL_MS = 30_000 /** Minimal injectable interval timer (fake-timer-testable). */ export interface IntervalTimer { setInterval(cb: () => void, ms: number): unknown clearInterval(handle: unknown): void } const realIntervalTimer: IntervalTimer = { setInterval: (cb, ms) => setInterval(cb, ms), clearInterval: (h) => clearInterval(h as ReturnType), } /** Handle to a running health monitor. */ export interface HealthMonitor { stop(): void } /** * Start a periodic health monitor: every `intervalMs`, run `probe()` and hand the report to * `onReport` (the run-loop wires this to a redacting logger — non-secret lines only). A rejected * probe is swallowed (a monitor must never crash the supervisor). `stop()` clears the interval. */ export function startHealthMonitor( probe: () => Promise, onReport: (report: HealthReport) => void, opts: { intervalMs?: number; timer?: IntervalTimer } = {}, ): HealthMonitor { const intervalMs = opts.intervalMs ?? DEFAULT_HEALTH_INTERVAL_MS const timer = opts.timer ?? realIntervalTimer const handle = timer.setInterval(() => { void probe() .then(onReport) .catch(() => { /* a probe failure is itself an unhealthy signal; never let it crash the monitor */ }) }, intervalMs) return { stop(): void { timer.clearInterval(handle) }, } }