/** * 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 { X509Certificate } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir, userInfo } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import type { CliDeps, NativeEnrollResult } from '../cli.js' import type { AgentConfig } from '../config/agentConfig.js' import { resolveHostIdentity, saveHostRecord } from '../config/hostRecord.js' import { loadAgentConfig } from '../config/agentConfig.js' import { openKeystore } from '../keys/keystore.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 { startNativeAutoRenew } from '../certs/nativeRenew.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) } function runCommand(cmd: string, args: readonly string[]): Promise { return new Promise((resolve, reject) => { execFile(cmd, [...args], (err) => (err ? reject(err) : resolve())) }) } function realInstallDeps(): InstallDeps { return { writeFile: (path, content) => { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, content) }, runCommand, getuid: () => (typeof process.getuid === 'function' ? process.getuid() : 0), homedir, username: () => userInfo().username, binPath: selfBinPath, } } /** Map the current OS to its service manager, or fail fast with a clear message. */ function requirePlatform(): ServicePlatform { const platform = detectPlatform(process.platform) if (platform === null) { throw new Error(`service install/uninstall is unsupported on platform '${process.platform}'`) } 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 { const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true }) // Write the identifiers down: the long-running `run` process has no other way to learn them, and // without them every log line from the tunnel reads `{"subdomain":null,"hostId":null}`. saveHostRecord(cfg.stateDir, { hostId: enroll.hostId, subdomain: enroll.subdomain }) 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 + A5): 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), AND auto-renew the frp-client leaf at ~2/3 TTL so the tunnel * never drops on cert expiry (A5): a successful renewal restarts frpc onto the fresh leaf, a 403 * revoke tears the tunnel down, and a failed renewal retries with backoff without crashing the * supervisor. Resolves when the supervisor stops (SIGTERM/SIGINT). */ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise { 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 `/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 host = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null) const ids = { ...host, certNotAfter: certNotAfter(ks) } for (const line of renderHealthStatus(ids, report)) logger.log('info', line) }, ) // A5: silently renew the leaf before it expires. Restart frpc onto the fresh cert on rotation; // stop the whole supervisor on a 403 revoke (INV12). Null โ‡’ unenrolled (auto-renew disabled). const autoRenew = startNativeAutoRenew( cfg, ks, { restartChild: () => handle.restartChild(), stop: () => { void handle.stop() }, }, logger, ) const onSignal = (): void => { void handle.stop() } process.once('SIGTERM', onSignal) process.once('SIGINT', onSignal) return handle.done.finally(() => { monitor.stop() autoRenew?.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 => { void handle.stop() } process.once('SIGTERM', onSignal) process.once('SIGINT', onSignal) return handle.done }, resolveInstallOptions: () => buildInstallOptions(process.env), installService: (cfg, options) => installServiceUnit(cfg, requirePlatform(), realInstallDeps(), options), uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }), print: (line) => { process.stdout.write(`${line}\n`) }, } }