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>
183 lines
7.4 KiB
TypeScript
183 lines
7.4 KiB
TypeScript
/**
|
|
* 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)
|
|
},
|
|
}
|
|
}
|