Files
web-terminal/agent/src/cli/deps.ts
Yaojia Wang 2a602d5289 fix(tunnel): close the three leftovers from the renewal-deadlock fix
1. `.gitignore` swallowed a source file. `agent/src/dist/buildBinary.ts` is the
   packaging config, not build output, but the blanket `dist/` rule meant it was
   never committed — a fresh clone could neither typecheck `agent/src/index.ts`
   nor import it from the committed `agent/test/buildBinary.test.ts`. Re-included
   the DIRECTORY first (git does not descend into an excluded one, so un-ignoring
   just the file would not have worked) and committed the file. Verified build
   output under `agent/dist/`, `dist/`, `public/build/` is still ignored.

2. Tunnel logs named no host. `pair` learned hostId/subdomain from the enroll
   response and threw them away, so the long-running `run` process logged
   `{"subdomain":null,"hostId":null}` — including all 6380 warnings during the
   8-day outage, at exactly the moment you want to know which host. New
   `config/hostRecord.ts` persists them at enrol; `resolveHostIdentity` resolves
   config > record > the subdomain embedded in the leaf's SPIFFE SAN, so hosts
   enrolled before the record existed get an identifier back without re-pairing.

3. The phone track had no recovery path. Added `POST /device/:id/recover`,
   mirroring the host route: cert in the body, device-CA path validation, SPIFFE
   parse, `notBefore` never graced, and the full registry check (active + same
   account + `:id` matches the cert + same key) — only `notAfter` is relaxed.

Verified: agent 300/300, control-plane 296/296, tsc clean on both.
NOTE: the iOS/Android clients are not wired to call /device/:id/recover yet —
the server capability exists, the client-side trigger does not.
2026-07-29 10:40:15 +02:00

258 lines
10 KiB
TypeScript

/**
* 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<void> {
return new Promise<void>((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<NativeEnrollResult> {
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<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 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`)
},
}
}