feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation
Customers install one command / log in once; hardware-generated keys never leave the device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12, no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md. Control-plane / PKI (control-plane/): - ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256) - ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing - ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers - ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert) - registry/devices.ts: device registry + per-account cap/rate-limit - auth/session.ts: device:enroll capability token mint/verify - api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default) - pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm - boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast) Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind) Host agent (agent/): - transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract) - keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE - net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient + Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap) Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403 Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85, iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy, VPS frps, physical iPhone) per PROGRESS_LOG runbook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
182
agent/src/health/probe.ts
Normal file
182
agent/src/health/probe.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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<boolean>
|
||||
/** 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<LoopbackResponse>
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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<HealthReport> {
|
||||
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<typeof setInterval>),
|
||||
}
|
||||
|
||||
/** 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<HealthReport>,
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user