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:
@@ -1,30 +1,55 @@
|
||||
/**
|
||||
* CliDeps factory — PLAN_RELAY_PHASE1 C2. Wires the abstract `CliDeps` seams (consumed by
|
||||
* `runCli`) to their real implementations: env-driven config, the on-disk keystore, Ed25519
|
||||
* identity generation, §4.5 pairing redemption, the supervised tunnel, and OS service install.
|
||||
* All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable.
|
||||
* CliDeps factory — PLAN_RELAY_PHASE1 C2, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
|
||||
* Wires the abstract `CliDeps` seams (consumed by `runCli`) to their real implementations: env-driven
|
||||
* config, the on-disk keystore, identity generation (Ed25519 + P-256), §4.5 pairing redemption, the
|
||||
* native enroll + frpc provisioning + frpc.toml writer, the supervised tunnel, and the two-unit OS
|
||||
* service install. All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable.
|
||||
*/
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, userInfo } from 'node:os'
|
||||
import { dirname } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { CliDeps } from '../cli.js'
|
||||
import type { CliDeps, NativeEnrollResult } from '../cli.js'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import { loadAgentConfig } from '../config/agentConfig.js'
|
||||
import { openKeystore } from '../keys/keystore.js'
|
||||
import { generateIdentity } from '../keys/identity.js'
|
||||
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { redeemPairingCode } from '../enroll/pair.js'
|
||||
import { runTunnel } from '../transport/runTunnel.js'
|
||||
import { buildNativeFrpcToml } from '../transport/frpcToml.js'
|
||||
import { superviseFrpc } from '../transport/frpSupervise.js'
|
||||
import { provisionFrpc } from '../provision/frpcBinary.js'
|
||||
import {
|
||||
probeLoopbackBaseApp,
|
||||
renderHealthStatus,
|
||||
runHealthProbe,
|
||||
startHealthMonitor,
|
||||
} from '../health/probe.js'
|
||||
import { createLogger } from '../log/logger.js'
|
||||
import { ensureAllowedOrigin } from '../service/originConfig.js'
|
||||
import {
|
||||
buildInstallOptions,
|
||||
detectPlatform,
|
||||
NATIVE_ORIGIN_ZONE,
|
||||
installService as installServiceUnit,
|
||||
uninstallService as uninstallServiceUnit,
|
||||
type InstallDeps,
|
||||
type ServicePlatform,
|
||||
} from '../service/install.js'
|
||||
|
||||
/** Keystore file names in `stateDir` (kept in lockstep with `keys/keystore.ts`). */
|
||||
const KEYSTORE_CERT = 'agent.cert.pem'
|
||||
const KEYSTORE_KEY = 'agent.key.pem'
|
||||
const KEYSTORE_CA = 'agent.ca.pem'
|
||||
const FRPC_TOML = 'frpc.toml'
|
||||
const FRPC_LOG = 'frpc.log'
|
||||
const BASE_APP_ENV_FILE = 'base-app.env'
|
||||
const DEFAULT_LOCAL_PORT = 3000
|
||||
|
||||
/** Resolve this process's own executable path (the bundled `dist/cli.js`) for the service unit. */
|
||||
function selfBinPath(): string {
|
||||
return fileURLToPath(import.meta.url)
|
||||
@@ -59,13 +84,134 @@ function requirePlatform(): ServicePlatform {
|
||||
return platform
|
||||
}
|
||||
|
||||
/** Parse a positive-integer PORT from the env (falls back to the base-app default 3000). */
|
||||
function resolveLocalPort(): number {
|
||||
const raw = process.env.PORT
|
||||
const port = raw ? Number.parseInt(raw, 10) : NaN
|
||||
return Number.isInteger(port) && port > 0 ? port : DEFAULT_LOCAL_PORT
|
||||
}
|
||||
|
||||
/** Native enroll: build the P-256 CSR + POST /enroll (via the frozen redeem flow), store the cert. */
|
||||
async function enrollNative(
|
||||
cfg: AgentConfig,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
): Promise<NativeEnrollResult> {
|
||||
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks)
|
||||
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the native `frpc.toml` into `stateDir`, pointing frpc at this host's keystore cert/key/CA and
|
||||
* the loopback base app. Also materializes the base-app ALLOWED_ORIGINS env file. The frps shared
|
||||
* token comes from `FRP_AUTH_TOKEN` (deploy secret; never logged). NOTE: the frpc binary run/e2e is
|
||||
* pending the B3 tar.gz extraction; this seam only emits the config.
|
||||
*/
|
||||
function writeFrpcConfig(cfg: AgentConfig, subdomain: string): void {
|
||||
const domain = process.env.TUNNEL_DOMAIN
|
||||
const toml = buildNativeFrpcToml({
|
||||
subdomain,
|
||||
localPort: resolveLocalPort(),
|
||||
authToken: process.env.FRP_AUTH_TOKEN ?? '',
|
||||
certFile: join(cfg.stateDir, KEYSTORE_CERT),
|
||||
keyFile: join(cfg.stateDir, KEYSTORE_KEY),
|
||||
trustedCaFile: join(cfg.stateDir, KEYSTORE_CA),
|
||||
})
|
||||
mkdirSync(cfg.stateDir, { recursive: true })
|
||||
writeFileSync(join(cfg.stateDir, FRPC_TOML), toml, { mode: 0o600 })
|
||||
if (domain) {
|
||||
ensureAllowedOrigin(join(cfg.stateDir, BASE_APP_ENV_FILE), subdomain, domain, undefined, NATIVE_ORIGIN_ZONE)
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored frp-client leaf's `notAfter`, or null if no cert/parse failure (non-secret metadata). */
|
||||
function certNotAfter(ks: Keystore): Date | null {
|
||||
const cert = ks.loadCert()
|
||||
if (cert === null) return null
|
||||
try {
|
||||
return new X509Certificate(cert.certPem).validToDate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single frpc log path in `stateDir`. Used by BOTH the supervisor's file-logging spawn (writer)
|
||||
* and `readFrpcLog` (reader) so the health probe can never scan a different file than frpc writes.
|
||||
*/
|
||||
export function frpcLogPath(stateDir: string): string {
|
||||
return join(stateDir, FRPC_LOG)
|
||||
}
|
||||
|
||||
/** Read the accumulated frpc log (empty string if not yet written) for the proxy-started scan. */
|
||||
export function readFrpcLog(stateDir: string): string {
|
||||
const path = frpcLogPath(stateDir)
|
||||
if (!existsSync(path)) return ''
|
||||
try {
|
||||
return readFileSync(path, 'utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native run-loop (B4/H4): supervise the pinned frpc child with restart-on-exit backoff while a
|
||||
* periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring)
|
||||
* logs NON-SECRET status only (INV9). Resolves when the supervisor stops (SIGTERM/SIGINT).
|
||||
*/
|
||||
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
||||
const logger = createLogger('info')
|
||||
const binPath = join(cfg.stateDir, 'bin', 'frpc')
|
||||
const tomlPath = join(cfg.stateDir, FRPC_TOML)
|
||||
// Tee the frpc child's stdout/stderr into `<stateDir>/frpc.log` (the SAME path `readFrpcLog`
|
||||
// scans below) so the proxy-started health sub-check has real content — without this wiring the
|
||||
// log stays empty and `HealthReport.healthy` can never be true (B4/H4 goal).
|
||||
const handle = superviseFrpc(binPath, tomlPath, { logger, logFile: frpcLogPath(cfg.stateDir) })
|
||||
const port = resolveLocalPort()
|
||||
const monitor = startHealthMonitor(
|
||||
() =>
|
||||
runHealthProbe({
|
||||
isFrpcAlive: () => handle.isChildAlive(),
|
||||
probeBaseApp: () => probeLoopbackBaseApp(port, (url) => fetch(url)),
|
||||
readFrpcLog: () => readFrpcLog(cfg.stateDir),
|
||||
certNotAfter: () => certNotAfter(ks),
|
||||
now: () => new Date(),
|
||||
}),
|
||||
(report) => {
|
||||
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
|
||||
const ids = { subdomain: cfg.subdomain, hostId: cfg.hostId, certNotAfter: certNotAfter(ks) }
|
||||
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
|
||||
},
|
||||
)
|
||||
const onSignal = (): void => {
|
||||
void handle.stop()
|
||||
}
|
||||
process.once('SIGTERM', onSignal)
|
||||
process.once('SIGINT', onSignal)
|
||||
return handle.done.finally(() => monitor.stop())
|
||||
}
|
||||
|
||||
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
||||
export function createCliDeps(): CliDeps {
|
||||
return {
|
||||
loadConfig: () => loadAgentConfig(process.env),
|
||||
openKeystore: (stateDir) => openKeystore(stateDir),
|
||||
generateIdentity: () => generateIdentity(),
|
||||
generateP256Identity: () => generateP256Identity(),
|
||||
redeem: (cfg: AgentConfig, code, id, ks) => redeemPairingCode(cfg.enrollUrl, code, id, ks),
|
||||
enrollNative: (cfg, code, id, ks) => enrollNative(cfg, code, id, ks),
|
||||
provisionFrpc: async (cfg) => {
|
||||
const result = await provisionFrpc({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
binDir: join(cfg.stateDir, 'bin'),
|
||||
})
|
||||
return result.binPath
|
||||
},
|
||||
writeFrpcConfig: (cfg, subdomain) => writeFrpcConfig(cfg, subdomain),
|
||||
nativeConfigExists: (cfg) => existsSync(join(cfg.stateDir, FRPC_TOML)),
|
||||
superviseFrpc: (cfg, ks) => superviseNative(cfg, ks),
|
||||
runTunnel: async (cfg, ks) => {
|
||||
const handle = await runTunnel(cfg, ks)
|
||||
const onSignal = (): void => {
|
||||
|
||||
Reference in New Issue
Block a user