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.
258 lines
10 KiB
TypeScript
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`)
|
|
},
|
|
}
|
|
}
|