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:
Yaojia Wang
2026-07-10 16:11:13 +02:00
parent 31054450fc
commit e7f3bd05f0
79 changed files with 9920 additions and 385 deletions

View File

@@ -0,0 +1,200 @@
/**
* Native-tunnel frpc supervisor — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2).
*
* Spawns the pinned `frpc` child with the written `frpc.toml` and keeps it running: on every exit
* it restarts the child after an exponential backoff (REUSES `transport/backoff.ts` — the same
* 1s/2s/4s…cap-30s policy as the relay reconnect). A child that ran longer than a stability window
* resets the backoff, so a healthy long-lived tunnel that finally dies restarts fast, while a
* crash-loop stays capped at 30s. All IO — spawn, sleep, clock — is injected so the loop is
* fully offline-testable (no real frpc / no real timers). No `console.log` (INV9 redacting logger).
*
* SCOPE (deferral #11): the real `frpc` binary run is pending B3 tar.gz extraction; the default
* `spawn` shells out to the provisioned binary, but tests inject a fake child so nothing executes.
*
* LOG CAPTURE (B4/H4 wiring fix): when a `logFile` is supplied, the default spawn tees the frpc
* child's stdout+stderr into that file (truncated per (re)spawn) so the health probe's log scanner
* (`readFrpcLog` → `frpcProxyStarted`) has real content to match "start proxy success" against.
* Truncate-per-spawn keeps the file bounded to the CURRENT child and free of a stale success line
* from a dead one — a freshly connected frpc always re-logs the success line.
*/
import { spawn as nodeSpawn } from 'node:child_process'
import { createWriteStream, mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { createBackoff, type BackoffPolicy, type Sleep } from './backoff.js'
import { createLogger, type Logger } from '../log/logger.js'
/** A child process seam the supervisor drives (a fake child in tests; a real frpc in prod). */
export interface FrpcChild {
/** Register a one-shot exit handler (`null` code ⇒ killed by signal or spawn error). */
onExit(cb: (code: number | null) => void): void
/** Whether the child is still running (feeds the health probe's `isFrpcAlive`). */
isAlive(): boolean
/** Request termination. */
kill(): void
}
/** Spawn a frpc child running `frpc -c <tomlPath>`. */
export type SpawnFrpc = (binPath: string, tomlPath: string) => FrpcChild
/** Injectable seams for the supervisor; unset fields default to real spawn/sleep/clock/logger. */
export interface FrpSuperviseDeps {
spawn: SpawnFrpc
backoff: BackoffPolicy
sleep: Sleep
logger: Logger
now: () => number
}
/**
* Supervisor overrides: any `FrpSuperviseDeps` seam plus an optional `logFile`. When `logFile` is
* set and no explicit `spawn` is given, the default spawn tees frpc's stdout/stderr into that file
* so the health probe can scan it. An explicit `spawn` (tests) always wins over `logFile`.
*/
export interface FrpSuperviseOptions extends Partial<FrpSuperviseDeps> {
readonly logFile?: string
}
/** Handle returned by `superviseFrpc`: stop the loop, or await its terminal exit code. */
export interface FrpSuperviseHandle {
/** Request graceful shutdown (kills the child); resolves once the loop has fully stopped. */
stop(): Promise<void>
/** Resolves with an exit code (always 0 — a stopped supervisor is a clean shutdown). */
readonly done: Promise<number>
/** True while a frpc child is currently running (for the health probe). */
isChildAlive(): boolean
}
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
export const STABLE_RUN_MS = 60_000
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
/**
* Default spawn (no log capture): launch the provisioned frpc binary, discarding its stdout/stderr.
* `stdio: 'ignore'` (not `'pipe'`) is deliberate — an unconsumed pipe fills its OS buffer (~64KB) and
* then BLOCKS the child. Log capture is opt-in via `createFileLoggingSpawn` (see `logFile`).
*/
const realSpawn: SpawnFrpc = (binPath, tomlPath) => {
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'ignore', 'ignore'] })
let alive = true
return {
onExit(cb: (code: number | null) => void): void {
cp.once('exit', (code) => {
alive = false
cb(code)
})
cp.once('error', () => {
alive = false
cb(null)
})
},
isAlive: () => alive,
kill: () => {
cp.kill()
},
}
}
/**
* Spawn frpc and TEE its stdout+stderr into `logFile` (truncated per spawn) so the health probe's
* `readFrpcLog` scanner has real content. A log-write failure is swallowed — persisting the log for
* the probe must never crash the supervised tunnel.
*/
export function createFileLoggingSpawn(logFile: string): SpawnFrpc {
return (binPath, tomlPath) => {
mkdirSync(dirname(logFile), { recursive: true })
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'pipe', 'pipe'] })
// flags:'w' truncates so the file reflects only the current child (no stale success line).
const log = createWriteStream(logFile, { flags: 'w' })
log.on('error', () => {
/* a log-write failure is non-fatal to the tunnel; the probe just sees no success line */
})
cp.stdout?.pipe(log, { end: false })
cp.stderr?.pipe(log, { end: false })
let alive = true
const closeLog = (): void => {
log.end()
}
return {
onExit(cb: (code: number | null) => void): void {
cp.once('exit', (code) => {
alive = false
closeLog()
cb(code)
})
cp.once('error', () => {
alive = false
closeLog()
cb(null)
})
},
isAlive: () => alive,
kill: () => {
cp.kill()
},
}
}
}
function resolveDeps(o?: FrpSuperviseOptions): FrpSuperviseDeps {
const defaultSpawn = o?.logFile !== undefined ? createFileLoggingSpawn(o.logFile) : realSpawn
return {
spawn: o?.spawn ?? defaultSpawn,
backoff: o?.backoff ?? createBackoff({ jitter: true }),
sleep: o?.sleep ?? realSleep,
logger: o?.logger ?? createLogger('info'),
now: o?.now ?? Date.now,
}
}
/**
* Start supervising frpc. Returns immediately with a handle; the restart loop runs in the
* background. `stop()` sets the stop flag, kills the live child, and awaits loop termination.
*/
export function superviseFrpc(
binPath: string,
tomlPath: string,
overrides?: FrpSuperviseOptions,
): FrpSuperviseHandle {
const { spawn, backoff, sleep, logger, now } = resolveDeps(overrides)
let stopped = false
let child: FrpcChild | null = null
/** Spawn one frpc child and resolve when it exits. */
function runOnce(): Promise<number | null> {
return new Promise<number | null>((resolve) => {
const c = spawn(binPath, tomlPath)
child = c
c.onExit((code) => {
child = null
resolve(code)
})
})
}
async function loop(): Promise<number> {
while (!stopped) {
const startedAt = now()
const exitCode = await runOnce()
if (stopped) break
if (now() - startedAt >= STABLE_RUN_MS) backoff.reset()
const delayMs = backoff.nextDelayMs()
logger.log('warn', 'frpc exited — restarting after backoff', { exitCode, delayMs })
await sleep(delayMs)
}
return 0
}
const done = loop()
return {
async stop(): Promise<void> {
stopped = true
child?.kill()
await done
},
done,
isChildAlive: () => child?.isAlive() ?? false,
}
}

View File

@@ -0,0 +1,155 @@
/**
* Native-tunnel frpc.toml writer — TASK B2h (PLAN_TUNNEL_AUTOMATION §3.2 / PLAN_NATIVE_TUNNEL §4).
*
* Emits the frp v0.61 TOML that a host's `frpc` presents to the VPS:
* - dials `serverAddr:443`, SNI-routed by nginx `ssl_preread` to frps :7000;
* - control-channel mTLS (frp-client cert/key + trusted CA) + shared token;
* - one `[[proxies]]` of `type = "http"` exposing the loopback base app as `<sub>.terminal...`.
*
* SUPERSEDES the retired v0.8 `[common]/tls_enable` shape in `frpScaffold.ts` (do not reuse that
* grammar — this is the native-tunnel writer).
*
* ANTI-SSRF (hard invariant): `localIP` MUST be loopback. frpc forwards ONLY to the local base app,
* never an arbitrary target — a non-loopback `localIP` would turn the tunnel into an open proxy.
* All inputs are validated at this boundary (fail-fast, clear messages); no `console.log`.
*/
const DEFAULT_SERVER_ADDR = '8.138.1.192'
const SERVER_PORT = 443
const TLS_SERVER_NAME = 'frp.terminal.yaojia.wang'
const DEFAULT_LOCAL_IP = '127.0.0.1'
const MIN_PORT = 1
const MAX_PORT = 65535
const MAX_LABEL_LEN = 63
const DEL_CHAR_CODE = 0x7f
const FIRST_PRINTABLE_CODE = 0x20
/** Loopback forms accepted for `localIP` (anti-SSRF allowlist). */
const LOOPBACK_IPS: readonly string[] = ['127.0.0.1', '::1', 'localhost']
/** RFC 1035 DNS label: 1-63 chars, alnum, internal hyphens only (no leading/trailing hyphen). */
const LABEL_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
export interface NativeFrpcOptions {
/** Subdomain label -> reachable at `https://<subdomain>.terminal.yaojia.wang`. Label-safe. */
readonly subdomain: string
/** Loopback port of the local base app frpc forwards to (1-65535). */
readonly localPort: number
/** Shared frps auth token (kept out of logs; the file itself is chmod 600 by the caller). */
readonly authToken: string
/** Keystore path to this host's frp-client leaf cert (control-channel mTLS). */
readonly certFile: string
/** Keystore path to the matching private key. */
readonly keyFile: string
/** Keystore path to the CA that signed the frps control cert (server verification). */
readonly trustedCaFile: string
/** VPS address; defaults to the deployed relay `8.138.1.192`. */
readonly serverAddr?: string
/** Local forward target IP; MUST be loopback. Defaults to `127.0.0.1`. */
readonly localIP?: string
}
/** A validation failure at the frpc.toml boundary. */
export class FrpcTomlError extends Error {
constructor(message: string) {
super(message)
this.name = 'FrpcTomlError'
}
}
/** True iff `value` contains an ASCII control character (C0 range or DEL). */
function hasControlChar(value: string): boolean {
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i)
if (code < FIRST_PRINTABLE_CODE || code === DEL_CHAR_CODE) return true
}
return false
}
/** Non-empty, control-char-free path/secret at the boundary. */
function assertPresent(field: string, value: string): void {
if (typeof value !== 'string' || value.length === 0) {
throw new FrpcTomlError(`${field} is required`)
}
if (hasControlChar(value)) {
throw new FrpcTomlError(`${field} contains control characters`)
}
}
/** Escape a value for a TOML basic (double-quoted) string: backslash + quote (Windows paths). */
function tomlBasicString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function assertLoopback(localIP: string): void {
if (!LOOPBACK_IPS.includes(localIP)) {
throw new FrpcTomlError(
`localIP "${localIP}" is not loopback — frpc must forward only to the local base app (anti-SSRF)`,
)
}
}
function assertSubdomain(subdomain: string): void {
if (typeof subdomain !== 'string' || subdomain.length === 0) {
throw new FrpcTomlError('subdomain is required')
}
if (subdomain.length > MAX_LABEL_LEN || !LABEL_RE.test(subdomain)) {
throw new FrpcTomlError(
`subdomain "${subdomain}" is not a valid DNS label (alnum + internal hyphens, 1-63 chars)`,
)
}
}
function assertLocalPort(localPort: number): void {
if (!Number.isInteger(localPort) || localPort < MIN_PORT || localPort > MAX_PORT) {
throw new FrpcTomlError(
`localPort must be an integer in ${MIN_PORT}-${MAX_PORT}, got ${localPort}`,
)
}
}
/**
* Build a validated frp v0.61 `frpc.toml` for the native mTLS tunnel. Throws `FrpcTomlError` on any
* invalid input (fail-fast). The returned string is the full config file contents.
*/
export function buildNativeFrpcToml(opts: NativeFrpcOptions): string {
assertSubdomain(opts.subdomain)
assertLocalPort(opts.localPort)
assertPresent('authToken', opts.authToken)
assertPresent('certFile', opts.certFile)
assertPresent('keyFile', opts.keyFile)
assertPresent('trustedCaFile', opts.trustedCaFile)
const serverAddr = opts.serverAddr ?? DEFAULT_SERVER_ADDR
assertPresent('serverAddr', serverAddr)
const localIP = opts.localIP ?? DEFAULT_LOCAL_IP
assertLoopback(localIP)
const lines: readonly string[] = [
`serverAddr = "${tomlBasicString(serverAddr)}"`,
`serverPort = ${SERVER_PORT}`,
'',
'auth.method = "token"',
`auth.token = "${tomlBasicString(opts.authToken)}"`,
'',
'# control-channel mTLS: present this host frp-client cert; verify the frps control cert',
'transport.tls.enable = true',
`transport.tls.serverName = "${TLS_SERVER_NAME}"`,
'transport.tls.disableCustomTLSFirstByte = true',
`transport.tls.certFile = "${tomlBasicString(opts.certFile)}"`,
`transport.tls.keyFile = "${tomlBasicString(opts.keyFile)}"`,
`transport.tls.trustedCaFile = "${tomlBasicString(opts.trustedCaFile)}"`,
'',
'loginFailExit = false',
'',
'[[proxies]]',
`name = "${opts.subdomain}"`,
'type = "http"',
`localIP = "${localIP}"`,
`localPort = ${opts.localPort}`,
`subdomain = "${opts.subdomain}"`,
'',
]
return lines.join('\n')
}