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

@@ -1,12 +1,20 @@
/**
* CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`.
* All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO.
* CLI entrypoint — PLAN_RELAY_AGENT T5, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
* `pair | run | status | install | uninstall`. All side effects (network/FS/tunnel/provision) are
* injected via `CliDeps` so `runCli` stays pure and offline-testable.
*
* `pair <CODE> --install` is the NATIVE zero-touch onboard: P-256 keygen (FIX H-host-2) → CSR →
* POST /enroll → provision the pinned frpc binary → write frpc.toml → install BOTH units (base-app
* + agent, base-app env routed to the base-app unit; the agent unit supervises frpc — NOT the old
* relay `runTunnel` rendezvous) → print `https://<sub>.terminal.<domain>`.
*
* `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
*/
import type { AgentConfig } from './config/agentConfig.js'
import type { AgentIdentity } from './keys/identity.js'
import type { Keystore } from './keys/keystore.js'
import type { InstallOptions } from './service/install.js'
import { assertNativeZone, type InstallOptions } from './service/install.js'
import { subdomainOrigin } from './service/originConfig.js'
import type { EnrollResult } from 'relay-contracts'
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
@@ -25,12 +33,38 @@ export class CliUsageError extends Error {
}
}
/** The non-secret enrollment result the native onboard needs (host id + assigned subdomain). */
export interface NativeEnrollResult {
readonly hostId: string
readonly subdomain: string
}
export interface CliDeps {
loadConfig(): AgentConfig
openKeystore(stateDir: string): Keystore
/** Ed25519 identity for the legacy relay path. */
generateIdentity(): AgentIdentity
/** P-256 identity for the native frp-client key (FIX H-host-2); private key never leaves the host. */
generateP256Identity(): AgentIdentity
/** Legacy relay redemption (Ed25519, E2E rendezvous). */
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
/** Native enroll: build the P-256 CSR, POST /enroll, store the returned cert; return ids only. */
enrollNative(
cfg: AgentConfig,
code: string,
id: AgentIdentity,
ks: Keystore,
): Promise<NativeEnrollResult>
/** Download + verify + place the pinned frpc binary (B3); returns its path. */
provisionFrpc(cfg: AgentConfig): Promise<string>
/** Write the native `frpc.toml` for `subdomain` (base-app env is routed via installService). */
writeFrpcConfig(cfg: AgentConfig, subdomain: string): void
/** True iff a written native `frpc.toml` exists in `cfg.stateDir` (native-onboard signal). */
nativeConfigExists(cfg: AgentConfig): boolean
/** Legacy relay run-loop (Ed25519 WS rendezvous, T10 backoff + T9 heartbeat). */
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
/** Native run-loop: supervise the pinned frpc child (restart-on-exit backoff + health probe). */
superviseFrpc(cfg: AgentConfig, ks: Keystore): Promise<number>
/** Resolve per-host install inputs (S0 env incl. loopback BIND_HOST, tunnel origin) from env/flags. */
resolveInstallOptions(): InstallOptions
installService(cfg: AgentConfig, options: InstallOptions): Promise<void>
@@ -63,23 +97,60 @@ export function parseArgs(argv: readonly string[]): CliArgs {
return args
}
/**
* Native zero-touch onboard for `pair <CODE> --install` (B5). Order is load-bearing: keygen(P-256)
* → enroll (CSR + POST /enroll) → provision frpc (so the binary exists before the agent unit
* starts) → write frpc.toml → install BOTH units (which start them) → print the tunnel URL.
*/
async function pairInstallNative(
code: string,
cfg: AgentConfig,
ks: Keystore,
deps: CliDeps,
): Promise<number> {
const options = deps.resolveInstallOptions()
if (!options.domain) {
throw new CliUsageError(
'native install requires TUNNEL_DOMAIN — the origin is https://<sub>.terminal.<domain>',
)
}
assertNativeZone(options.zone) // FIX L-host-zone: native ⇒ `terminal`
const id = deps.generateP256Identity() // keygen (P-256, FIX H-host-2)
ks.saveIdentity(id)
const enroll = await deps.enrollNative(cfg, code, id, ks) // CSR → POST /enroll → store cert
await deps.provisionFrpc(cfg) // pinned frpc binary on disk before the service starts (B3)
deps.writeFrpcConfig(cfg, enroll.subdomain) // frpc.toml
await deps.installService(cfg, options) // base-app + agent units; base-app env routed → started
deps.print(subdomainOrigin(enroll.subdomain, options.domain, options.zone))
return 0
}
/** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
const cfg = deps.loadConfig()
const ks = deps.openKeystore(cfg.stateDir)
switch (args.command) {
case 'pair': {
if (args.flags['install']) return pairInstallNative(args.code!, cfg, ks, deps)
// Legacy relay pair (Ed25519 rendezvous, no install).
const id = ks.loadIdentity() ?? deps.generateIdentity()
ks.saveIdentity(id)
const enroll = await deps.redeem(cfg, args.code!, id, ks)
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
if (args.flags['install']) await deps.installService(cfg, deps.resolveInstallOptions())
return 0
}
case 'run': {
if (ks.loadIdentity() === null || ks.loadCert() === null) {
const id = ks.loadIdentity()
if (id === null || ks.loadCert() === null) {
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
}
// Native onboard = a P-256 frp-client identity + a written frpc.toml. Supervise frpc as a
// child (restart-on-exit backoff + health probe) instead of the legacy Ed25519 relay tunnel.
if (id.alg === 'p256' && deps.nativeConfigExists(cfg)) {
return deps.superviseFrpc(cfg, ks)
}
return deps.runTunnel(cfg, ks)
}
case 'status': {

View File

@@ -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 => {

View File

@@ -9,6 +9,7 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { z } from 'zod'
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
export interface AgentConfig {
readonly relayUrl: string
@@ -19,9 +20,12 @@ export interface AgentConfig {
readonly hostId: string | null
}
const LOOPBACK_HOSTNAMES: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
/** True iff `url` is ws:// to a loopback host (127.0.0.0/8, localhost, or ::1). */
/**
* True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or
* ::1). Uses the shared strict check so a crafted suffixed hostname such as
* `ws://127.0.0.1.attacker.example.com:3000` — which the outbound dial would DNS-resolve and connect
* to wherever it points — is REJECTED, closing the anti-SSRF bypass (not merely `startsWith('127.')`).
*/
export function isLoopbackWsUrl(url: string): boolean {
let parsed: URL
try {
@@ -30,8 +34,7 @@ export function isLoopbackWsUrl(url: string): boolean {
return false
}
if (parsed.protocol !== 'ws:') return false
const host = parsed.hostname
return LOOPBACK_HOSTNAMES.includes(host) || host.startsWith('127.')
return isLoopbackHostLiteral(parsed.hostname)
}
function hasScheme(url: string, scheme: string): boolean {

View File

@@ -52,9 +52,11 @@ const OID = 0x06
const UTF8_STRING = 0x0c
const CONTEXT_0 = 0xa0
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
// OID 2.5.4.3 (commonName), 1.3.101.112 (Ed25519), 1.2.840.10045.4.3.2 (ecdsa-with-SHA256) as
// pre-encoded DER value bytes.
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
const OID_ECDSA_WITH_SHA256 = Uint8Array.from([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
@@ -63,6 +65,24 @@ function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
return tlv(SEQUENCE, concat([algId, pubBits]))
}
/**
* SubjectPublicKeyInfo bytes for the CSR. For P-256, `id.publicKey` IS already the full EC SPKI DER
* (built by `keys/identity.ts`), so it is embedded verbatim; for Ed25519 the raw 32-byte key is
* wrapped into the fixed SPKI structure.
*/
function spkiFor(id: AgentIdentity): Uint8Array {
return id.alg === 'p256' ? id.publicKey : spkiFromRawEd25519(id.publicKey)
}
/**
* The signatureAlgorithm AlgorithmIdentifier: `SEQUENCE { OID }` (no parameters — RFC 5758 §3.2 for
* ecdsa-with-SHA256, and Ed25519 likewise omits parameters).
*/
function sigAlgFor(id: AgentIdentity): Uint8Array {
const oid = id.alg === 'p256' ? OID_ECDSA_WITH_SHA256 : OID_ED25519
return tlv(SEQUENCE, tlv(OID, oid))
}
/** X.501 Name with a single CN=<subject> RDN. */
function nameFromCn(cn: string): Uint8Array {
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
@@ -77,18 +97,20 @@ function toPem(der: Uint8Array, label: string): string {
}
/**
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
* The private key is used in-process only; never serialized into the output (INV4).
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the identity's key. The
* Ed25519 and P-256 (ecdsa-with-SHA256, FIX H-host-2) paths share this one encoder — only the SPKI
* and the signatureAlgorithm differ. The private key is used in-process only; never serialized into
* the output (INV4).
*/
export function buildCsr(id: AgentIdentity, subject: string): string {
const version = tlv(INTEGER, Uint8Array.from([0x00]))
const name = nameFromCn(subject)
const spki = spkiFromRawEd25519(id.publicKey)
const spki = spkiFor(id)
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
const signature = id.sign(requestInfo) // over CertificationRequestInfo, per id.alg
const sigAlg = sigAlgFor(id)
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))

182
agent/src/health/probe.ts Normal file
View File

@@ -0,0 +1,182 @@
/**
* Native-tunnel health probe — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2 / §5, INV9).
*
* Reports four independent sub-checks that together say whether this host's native frp tunnel is
* actually serving:
* (a) frpcAlive — the supervised frpc child process is running;
* (b) baseAppReachable — the loopback base app answers `GET http://127.0.0.1:PORT` (loopback ONLY);
* (c) proxyStarted — frpc logged a "start proxy success" line (control channel + proxy up);
* (d) certFresh — the frp-client leaf's `notAfter` is beyond the renewal window (not expiring).
*
* Every side effect is an INJECTABLE seam (process-liveness checker, loopback HTTP probe, log
* scanner, clock) so the logic is pure and offline-testable. The status renderer emits ONLY
* non-secret identifiers (subdomain, host id, cert expiry date, boolean flags) — NEVER keys, certs,
* tokens, or CSRs (INV9). No `console.log`.
*/
/** Default renewal window: 8h — one third of the 24h host frp-client TTL (renew at ~2/3 TTL). */
export const DEFAULT_CERT_RENEW_WINDOW_MS = 8 * 60 * 60 * 1000
/** frpc emits this line on the control channel once a proxy is registered and forwarding. */
export const FRPC_START_SUCCESS_RE = /start proxy success/i
/** Loopback host the base-app probe targets — hardcoded so the probe can never reach off-host. */
export const LOOPBACK_PROBE_HOST = '127.0.0.1'
const MIN_PORT = 1
const MAX_PORT = 65535
/** The four independent sub-checks plus the derived overall verdict. */
export interface HealthReport {
/** The supervised frpc child is running. */
readonly frpcAlive: boolean
/** `GET http://127.0.0.1:PORT` returned a response (base app is up on loopback). */
readonly baseAppReachable: boolean
/** frpc logged "start proxy success" (the tunnel proxy is registered). */
readonly proxyStarted: boolean
/** The frp-client cert's `notAfter` is beyond the renewal window (not near expiry). */
readonly certFresh: boolean
/** True iff all four sub-checks pass. */
readonly healthy: boolean
}
/** Injectable side effects the probe consumes; each is independently faked in tests. */
export interface HealthProbeSeams {
/** Process-liveness checker (the supervisor exposes whether its frpc child is alive). */
readonly isFrpcAlive: () => boolean
/** Loopback HTTP probe of the base app; resolves true iff it answered ok. */
readonly probeBaseApp: () => Promise<boolean>
/** Returns the accumulated frpc stdout/log text to scan for the success line. */
readonly readFrpcLog: () => string
/** The stored frp-client leaf's `notAfter`, or null if no cert is available. */
readonly certNotAfter: () => Date | null
/** Current time (injected for deterministic expiry tests). */
readonly now: () => Date
}
export interface HealthProbeConfig {
/** Certs within this many ms of `notAfter` are "near expiry" (default 8h). */
readonly renewWindowMs?: number
}
/** Pure: true iff the frpc log contains a "start proxy success" line. */
export function frpcProxyStarted(logText: string): boolean {
return FRPC_START_SUCCESS_RE.test(logText)
}
/** Pure: true iff `notAfter` is strictly beyond the renewal window from `now` (not near expiry). */
export function certIsFresh(notAfter: Date | null, now: Date, renewWindowMs: number): boolean {
if (notAfter === null) return false
return notAfter.getTime() - now.getTime() > renewWindowMs
}
/** Minimal shape of a fetch response the loopback probe cares about. */
export interface LoopbackResponse {
readonly ok: boolean
}
/** Injectable loopback HTTP fetch (real wiring passes global `fetch`). */
export type LoopbackFetch = (url: string) => Promise<LoopbackResponse>
/**
* Probe the loopback base app with `GET http://127.0.0.1:PORT/`. The host is hardcoded loopback, so
* the probe can never reach an off-host target (anti-SSRF). Any thrown/rejected fetch — or a
* non-integer/out-of-range port — resolves to `false` rather than propagating (a probe never throws).
*/
export async function probeLoopbackBaseApp(port: number, fetchImpl: LoopbackFetch): Promise<boolean> {
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) return false
const url = `http://${LOOPBACK_PROBE_HOST}:${port}/`
try {
const res = await fetchImpl(url)
return res.ok
} catch {
return false
}
}
/** Run all four sub-checks and derive the overall verdict. Never throws (each seam is guarded). */
export async function runHealthProbe(
seams: HealthProbeSeams,
config: HealthProbeConfig = {},
): Promise<HealthReport> {
const renewWindowMs = config.renewWindowMs ?? DEFAULT_CERT_RENEW_WINDOW_MS
const frpcAlive = seams.isFrpcAlive()
const baseAppReachable = await seams.probeBaseApp()
const proxyStarted = frpcProxyStarted(seams.readFrpcLog())
const certFresh = certIsFresh(seams.certNotAfter(), seams.now(), renewWindowMs)
const healthy = frpcAlive && baseAppReachable && proxyStarted && certFresh
return { frpcAlive, baseAppReachable, proxyStarted, certFresh, healthy }
}
/** Non-secret identifiers safe to print in `status` (INV9): NO key/cert/token/CSR material. */
export interface StatusIdentifiers {
readonly subdomain: string | null
readonly hostId: string | null
readonly certNotAfter: Date | null
}
/**
* Render `status` lines from non-secret identifiers + a health report (INV9). Emits the subdomain,
* host id, cert EXPIRY DATE (never the cert bytes), and the boolean sub-check flags — never any key,
* cert, token, or CSR material.
*/
export function renderHealthStatus(
ids: StatusIdentifiers,
report: HealthReport,
): readonly string[] {
return [
`subdomain: ${ids.subdomain ?? '(none)'}`,
`host_id: ${ids.hostId ?? '(none)'}`,
`cert_expiry: ${ids.certNotAfter ? ids.certNotAfter.toISOString() : '(unknown)'}`,
`frpc_alive: ${report.frpcAlive}`,
`base_app_reachable: ${report.baseAppReachable}`,
`proxy_started: ${report.proxyStarted}`,
`cert_fresh: ${report.certFresh}`,
`healthy: ${report.healthy}`,
]
}
/** Default periodic health-monitor interval (30s). */
export const DEFAULT_HEALTH_INTERVAL_MS = 30_000
/** Minimal injectable interval timer (fake-timer-testable). */
export interface IntervalTimer {
setInterval(cb: () => void, ms: number): unknown
clearInterval(handle: unknown): void
}
const realIntervalTimer: IntervalTimer = {
setInterval: (cb, ms) => setInterval(cb, ms),
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
}
/** Handle to a running health monitor. */
export interface HealthMonitor {
stop(): void
}
/**
* Start a periodic health monitor: every `intervalMs`, run `probe()` and hand the report to
* `onReport` (the run-loop wires this to a redacting logger — non-secret lines only). A rejected
* probe is swallowed (a monitor must never crash the supervisor). `stop()` clears the interval.
*/
export function startHealthMonitor(
probe: () => Promise<HealthReport>,
onReport: (report: HealthReport) => void,
opts: { intervalMs?: number; timer?: IntervalTimer } = {},
): HealthMonitor {
const intervalMs = opts.intervalMs ?? DEFAULT_HEALTH_INTERVAL_MS
const timer = opts.timer ?? realIntervalTimer
const handle = timer.setInterval(() => {
void probe()
.then(onReport)
.catch(() => {
/* a probe failure is itself an unhealthy signal; never let it crash the monitor */
})
}, intervalMs)
return {
stop(): void {
timer.clearInterval(handle)
},
}
}

View File

@@ -1,20 +1,37 @@
/**
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
*
* Ed25519 keypair generated locally with node:crypto. `AgentIdentity` exposes ONLY the public
* key, the §4.2 enroll fingerprint, and an in-process `sign()`; there is NO API that returns or
* serializes the private key. The raw private key material is held in a module-private closure.
* Two key algorithms share one `AgentIdentity` shape (discriminated by `alg`):
* - `ed25519` — the relay E2E rendezvous path (unchanged).
* - `p256` — the native-tunnel HOST frp-client key (FIX H-host-2): the `frp-client-CA` is P-256,
* so the host key, its CSR (ECDSA-with-SHA256), and its leaf are all P-256.
* `AgentIdentity` exposes ONLY the public key, the §4.2 enroll fingerprint, and an in-process
* `sign()`; there is NO API that returns or serializes the private key. The raw private key material
* is held in a module-private closure and, for P-256, NEVER leaves the host (only the pubkey + CSR do).
*/
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
import type { KeyObject } from 'node:crypto'
import { encodeBase64UrlBytes } from 'relay-contracts'
/** Which key algorithm an identity carries. Drives CSR SPKI + signatureAlgorithm encoding. */
export type KeyAlg = 'ed25519' | 'p256'
export interface AgentIdentity {
/** Ed25519 raw 32-byte public key → stored in host registry (§4.2 agent_pubkey). */
/** Which key algorithm this identity uses (`ed25519` = relay path, `p256` = native frp-client). */
readonly alg: KeyAlg
/**
* The registry-stored public key bytes (§4.2 agent_pubkey):
* - `ed25519`: the raw 32-byte public key;
* - `p256`: the EC SubjectPublicKeyInfo DER (what the control-plane P-256 gate compares).
*/
readonly publicKey: Uint8Array
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
readonly enrollFpr: string
/** Ed25519 signature over `message`, using the in-process private key. Never returns the key. */
/**
* Signature over `message` using the in-process private key (never returns the key):
* - `ed25519`: a raw 64-byte Ed25519 signature;
* - `p256`: a DER `ECDSA-Sig-Value` (ecdsa-with-SHA256) — the PKCS#10 signatureValue shape.
*/
sign(message: Uint8Array): Uint8Array
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
exportPrivatePkcs8Pem(): string
@@ -39,6 +56,7 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
const rawPub = rawPublicKey(publicKey)
const enrollFpr = computeEnrollFpr(rawPub)
return {
alg: 'ed25519',
publicKey: rawPub,
enrollFpr,
sign(message: Uint8Array): Uint8Array {
@@ -53,19 +71,61 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
}
}
/**
* P-256 identity (FIX H-host-2). `publicKey` is the EC SubjectPublicKeyInfo DER (the exact bytes the
* control-plane frp-client gate compares against the registry); `sign` produces a DER `ECDSA-Sig-Value`
* over the SHA-256 digest — the PKCS#10 / X.509 signatureValue shape. The private key never leaves
* this closure (INV4); only the SPKI + CSR are emitted off-host.
*/
function buildP256Identity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
const spkiDer = new Uint8Array(publicKey.export({ type: 'spki', format: 'der' }))
const enrollFpr = computeEnrollFpr(spkiDer)
return {
alg: 'p256',
publicKey: spkiDer,
enrollFpr,
sign(message: Uint8Array): Uint8Array {
// ecdsa-with-SHA256 → DER ECDSA-Sig-Value (node's default dsaEncoding is 'der').
return new Uint8Array(sign('sha256', message, privateKey))
},
exportPrivatePkcs8Pem(): string {
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
},
privateKeyObject(): KeyObject {
return privateKey
},
}
}
/** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
export function generateIdentity(): AgentIdentity {
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
return buildIdentity(privateKey, publicKey)
}
/** Reconstruct an identity from a stored PKCS#8 PEM private key (keystore load path). */
/**
* Generate a fresh EC P-256 identity for the native-tunnel host frp-client key (FIX H-host-2). The
* private key is held in-memory only (INV4) and NEVER serialized off-host; only the pubkey + CSR leave.
*/
export function generateP256Identity(): AgentIdentity {
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
return buildP256Identity(privateKey, publicKey)
}
/** Reconstruct an Ed25519 identity from a stored PKCS#8 PEM private key (keystore load path). */
export function identityFromPrivatePem(pem: string): AgentIdentity {
const privateKey = createPrivateKey(pem)
const publicKey = createPublicKey(privateKey)
return buildIdentity(privateKey, publicKey)
}
/** Reconstruct a P-256 identity from a stored PKCS#8 PEM private key (keystore load path). */
export function p256IdentityFromPrivatePem(pem: string): AgentIdentity {
const privateKey = createPrivateKey(pem)
const publicKey = createPublicKey(privateKey)
return buildP256Identity(privateKey, publicKey)
}
/** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
export function verifySignature(
publicKey: Uint8Array,

View File

@@ -12,8 +12,9 @@ import {
writeFileSync,
} from 'node:fs'
import { join } from 'node:path'
import { createPrivateKey } from 'node:crypto'
import type { AgentIdentity } from './identity.js'
import { identityFromPrivatePem } from './identity.js'
import { identityFromPrivatePem, p256IdentityFromPrivatePem } from './identity.js'
const SECRET_MODE = 0o600
const DIR_MODE = 0o700
@@ -54,6 +55,32 @@ function ensureDir(stateDir: string): void {
}
}
/**
* Reconstruct an `AgentIdentity` from a stored PKCS#8 PEM, branching on the key's algorithm
* discriminant (the PKCS#8 AlgorithmIdentifier OID, surfaced as `asymmetricKeyType`): an Ed25519
* key → the relay-path builder; an EC P-256 key → the native frp-client builder (EC SPKI publicKey).
* A saved P-256 identity therefore round-trips as `alg: 'p256'` with a usable signing key, while
* Ed25519 stays byte-identical. Any other algorithm is a hard error (never silently mislabeled).
*/
function identityFromStoredPem(pem: string): AgentIdentity {
const key = createPrivateKey(pem)
const alg = key.asymmetricKeyType
if (alg === 'ed25519') return identityFromPrivatePem(pem)
if (alg === 'ec') {
// An `ec` key alone is not proof of P-256 — a P-384/secp256k1 key also reports `ec`. The native
// frp-client path is P-256 ONLY, so assert the named curve before treating it as such; any other
// curve is a hard error (never silently mislabeled as a usable P-256 identity).
const curve = key.asymmetricKeyDetails?.namedCurve
if (curve !== 'prime256v1') {
throw new Error(
`unsupported stored EC identity curve: ${curve ?? 'unknown'} (only prime256v1/P-256 is supported)`,
)
}
return p256IdentityFromPrivatePem(pem)
}
throw new Error(`unsupported stored identity key algorithm: ${alg ?? 'unknown'}`)
}
function writeSecret(path: string, data: string | Uint8Array): void {
writeFileSync(path, data, { mode: SECRET_MODE })
// Enforce 0600 even if a prior umask/file left it wider.
@@ -80,7 +107,7 @@ export function openKeystore(stateDir: string): Keystore {
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
}
try {
return identityFromPrivatePem(pem)
return identityFromStoredPem(pem)
} catch (err) {
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
}

View File

@@ -0,0 +1,29 @@
/**
* Strict loopback-literal check — the single source of truth for both S-GATE-style guards:
* - `service/install.ts` isLoopbackBindHost (BIND_HOST S-GATE, FIX C-host-1, CRITICAL)
* - `config/agentConfig.ts` isLoopbackWsUrl (localTargetUrl anti-SSRF)
*
* Both previously used `value.startsWith('127.')`, which treats an arbitrary suffixed HOSTNAME
* such as `127.0.0.1.attacker.example.com` or `127.evil.net` as loopback. Because Node resolves
* non-literal hosts via DNS before bind()/dial, that prefix match let a crafted value defeat the
* exact invariant the guard exists to enforce. This module fails closed: it accepts a value ONLY
* when it is an EXACT loopback literal — `localhost`, `::1`/`[::1]`, or a fully-parsed IPv4 address
* in 127.0.0.0/8 with NO trailing characters. Anything else (a hostname, a partial IP, `0.0.0.0`,
* `::`) is rejected.
*/
import { isIPv4 } from 'node:net'
/** Non-IPv4 loopback literals accepted verbatim (localhost + the IPv6 loopback, with/without brackets). */
const LOOPBACK_LITERALS: ReadonlySet<string> = new Set(['localhost', '::1', '[::1]'])
/**
* True iff `host` is an EXACT loopback literal: `localhost`, `::1`, `[::1]`, or a well-formed IPv4
* address in 127.0.0.0/8 (the whole string must parse as a dotted-quad — no trailing label). A
* suffixed hostname like `127.0.0.1.attacker.example.com` is NOT loopback and returns false.
*/
export function isLoopbackHostLiteral(host: string): boolean {
if (LOOPBACK_LITERALS.has(host)) return true
// isIPv4 requires the FULL string to be a dotted-quad, so no trailing hostname label can slip
// through; the first-octet check then confines it to the 127.0.0.0/8 loopback block.
return isIPv4(host) && host.split('.')[0] === '127'
}

View File

@@ -0,0 +1,259 @@
/**
* Pinned frpc binary provisioner — TASK B3 (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
*
* Mirrors the `dist/buildBinary.ts` verify-download discipline: detect OS/arch, select a PINNED
* release `{ version, url, sha256 }`, download the artifact TO DISK (a temp file), verify its
* SHA-256 against the pin BEFORE the binary is placed or executed, then place it atomically
* (temp -> rename) with the exec bit. On hash mismatch NOTHING is placed and the temp is removed.
*
* Non-negotiable (§5): never `curl | sh` a streamed secret, never disable TLS verification (no `-k`)
* — the default fetch enforces `https:` and there is no insecure escape hatch.
*
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
* a host cannot exec a `.tar.gz`. So AFTER the archive hash matches its pin (and only then) the bytes
* are handed to the path-traversal-hardened extractor in `untar.ts`, which pulls out ONLY the inner
* `frpc`; that verified binary is what gets placed. Extraction failure (no `frpc`, corrupt gzip/tar,
* unsafe entry name, oversize) places nothing and throws `FrpcProvisionError`.
*
* The network fetch and the filesystem are INJECTABLE seams (`ProvisionFrpcDeps`) so tests exercise
* URL/arch selection, hash-match extraction+placement, hash-mismatch rejection, extraction failures,
* and unsupported-platform errors with no network access.
*/
import { createHash, randomBytes } from 'node:crypto'
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { extractFrpcBinary } from './untar.js'
export type FrpcPlatform = 'darwin-arm64' | 'darwin-amd64' | 'linux-arm64' | 'linux-amd64'
export const FRPC_PLATFORMS: readonly FrpcPlatform[] = [
'darwin-arm64',
'darwin-amd64',
'linux-arm64',
'linux-amd64',
]
/** A pinned frpc release artifact for one platform. `sha256` is lowercase hex over the artifact. */
export interface FrpcReleaseRef {
readonly version: string
readonly url: string
readonly sha256: string
}
const FRPC_VERSION = '0.61.1'
const RELEASE_BASE = `https://github.com/fatedier/frp/releases/download/v${FRPC_VERSION}`
/**
* The pinned release map. URLs point at the real frp v0.61.1 assets and the `sha256` fields are the
* REAL published digests from `frp_sha256_checksums.txt` (verified against the downloaded
* darwin_arm64 archive on 2026-07-09). To bump frp: update `FRPC_VERSION` and paste the new digests
* from that release's checksum file. Tests inject their own release map.
*/
export const FRPC_RELEASES: Readonly<Record<FrpcPlatform, FrpcReleaseRef>> = {
'darwin-arm64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_arm64.tar.gz`,
sha256: '3e65f13a17a284bd6013e6bb6856bc2720074cea6094cc446c1f4c3932154c2d',
},
'darwin-amd64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_amd64.tar.gz`,
sha256: '403a0ee5e92f083a863d984b7af1e9d70ba2aaa28e87f42f1fe085adf76b8491',
},
'linux-arm64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_arm64.tar.gz`,
sha256: 'af6366f2b43920ebfe6235dba6060770399ed1fb18601e5818552bd46a7621f8',
},
'linux-amd64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_amd64.tar.gz`,
sha256: 'bff260b68ca7b1461182a46c4f34e9709ba32764eed30a15dd94ac97f50a2c40',
},
}
/** Node `os.arch()` values mapped to frp's arch tokens. */
const ARCH_MAP: Readonly<Record<string, 'arm64' | 'amd64'>> = {
arm64: 'arm64',
x64: 'amd64',
}
const SUPPORTED_OS: ReadonlySet<string> = new Set(['darwin', 'linux'])
const BIN_NAME = 'frpc'
const TMP_NAME = 'frpc.download.tmp'
const EXEC_MODE = 0o755
const DIR_MODE = 0o700
/** Map `os.platform()` + `os.arch()` to a supported `FrpcPlatform`, or `null` if unsupported. */
export function detectFrpcPlatform(platform: string, arch: string): FrpcPlatform | null {
const mappedArch = ARCH_MAP[arch]
if (!SUPPORTED_OS.has(platform) || mappedArch === undefined) return null
const key = `${platform}-${mappedArch}` as FrpcPlatform
return FRPC_PLATFORMS.includes(key) ? key : null
}
/** Injectable network fetch: returns the artifact bytes for `url`. */
export type FrpcFetch = (url: string) => Promise<Uint8Array>
/** Injectable filesystem seam (all async, mirrors `node:fs/promises`). */
export interface FrpcFsDeps {
mkdir(dir: string): Promise<void>
writeFile(path: string, data: Uint8Array): Promise<void>
rename(from: string, to: string): Promise<void>
chmod(path: string, mode: number): Promise<void>
rm(path: string): Promise<void>
}
export interface ProvisionFrpcDeps {
readonly fetch: FrpcFetch
readonly fs: FrpcFsDeps
/** Override the digest function (default: node:crypto SHA-256, lowercase hex). */
readonly computeSha256?: (data: Uint8Array) => string
}
export interface ProvisionFrpcOptions {
/** `os.platform()` (e.g. `'darwin'`, `'linux'`). */
readonly platform: string
/** `os.arch()` (e.g. `'arm64'`, `'x64'`). */
readonly arch: string
/** Directory the verified `frpc` binary is placed into. */
readonly binDir: string
/** Release map to select from; defaults to the pinned `FRPC_RELEASES`. */
readonly releases?: Readonly<Record<FrpcPlatform, FrpcReleaseRef>>
}
export interface ProvisionResult {
readonly binPath: string
readonly version: string
readonly platform: FrpcPlatform
}
/** A verify-download failure (unsupported platform, fetch error, or integrity mismatch). */
export class FrpcProvisionError extends Error {
constructor(message: string) {
super(message)
this.name = 'FrpcProvisionError'
}
}
function defaultSha256(data: Uint8Array): string {
return createHash('sha256').update(data).digest('hex')
}
/** Default HTTPS fetch — enforces `https:` (never `-k`, never plain http) and a 2xx status. */
async function defaultFetch(url: string): Promise<Uint8Array> {
let parsed: URL
try {
parsed = new URL(url)
} catch {
throw new FrpcProvisionError(`invalid frpc download URL: ${url}`)
}
if (parsed.protocol !== 'https:') {
throw new FrpcProvisionError(`frpc download refuses non-https URL: ${url}`)
}
let res: Response
try {
res = await fetch(url)
} catch (err: unknown) {
// Surface transport failures (DNS, refused, TLS) through the SAME typed error as every other
// failure path, so callers (e.g. the autoupdate rollback path) can pattern-match uniformly.
throw new FrpcProvisionError(
`frpc download failed: ${err instanceof Error ? err.message : 'network error'} for ${url}`,
)
}
if (!res.ok) {
throw new FrpcProvisionError(`frpc download failed: HTTP ${res.status} for ${url}`)
}
return new Uint8Array(await res.arrayBuffer())
}
const defaultFsDeps: FrpcFsDeps = {
mkdir: async (dir) => {
await mkdir(dir, { recursive: true, mode: DIR_MODE })
},
writeFile: async (path, data) => {
await writeFile(path, data, { mode: 0o600 })
},
rename: async (from, to) => {
await rename(from, to)
},
chmod: async (path, mode) => {
await chmod(path, mode)
},
rm: async (path) => {
await rm(path, { force: true })
},
}
function defaultDeps(): ProvisionFrpcDeps {
return { fetch: defaultFetch, fs: defaultFsDeps, computeSha256: defaultSha256 }
}
/**
* Download + verify + extract + place the pinned frpc binary for the current platform.
*
* Order (verify-before-extract-before-exec): detect platform -> select pin -> fetch archive ->
* write the unverified archive to a per-invocation temp -> SHA-256 verify against the pin. On
* mismatch: remove the temp and throw (no extraction, nothing placed). On match: gunzip+untar to
* pull ONLY the inner `frpc` (path-traversal-safe), overwrite the temp with that verified binary,
* chmod exec, and atomic-rename into place. Any extraction failure removes the temp and throws.
* Nothing is ever placed or made executable before the digest matches the pin.
*/
export async function provisionFrpc(
opts: ProvisionFrpcOptions,
deps: ProvisionFrpcDeps = defaultDeps(),
): Promise<ProvisionResult> {
const platform = detectFrpcPlatform(opts.platform, opts.arch)
if (platform === null) {
throw new FrpcProvisionError(
`unsupported platform for frpc: ${opts.platform}/${opts.arch} (supported: ${FRPC_PLATFORMS.join(', ')})`,
)
}
const releases = opts.releases ?? FRPC_RELEASES
const release = releases[platform]
if (release === undefined) {
throw new FrpcProvisionError(`no pinned frpc release for platform ${platform}`)
}
const computeSha256 = deps.computeSha256 ?? defaultSha256
// Per-invocation-unique temp path: two concurrent provisions (e.g. overlapping autoupdate polls)
// must never write/rename through the same temp file (TOCTOU). Same path is used for write+rm+rename.
const tmpPath = join(opts.binDir, `${TMP_NAME}.${process.pid}.${randomBytes(6).toString('hex')}`)
const binPath = join(opts.binDir, BIN_NAME)
const bytes = await deps.fetch(release.url)
await deps.fs.mkdir(opts.binDir)
await deps.fs.writeFile(tmpPath, bytes)
const digest = computeSha256(bytes).toLowerCase()
const expected = release.sha256.toLowerCase()
if (digest !== expected) {
await deps.fs.rm(tmpPath)
throw new FrpcProvisionError(
`frpc SHA-256 mismatch for ${platform}: expected ${expected}, got ${digest} — nothing placed`,
)
}
// Verified — extract ONLY the inner `frpc` from the archive. The extractor selects by basename and
// rejects any unsafe entry name, so nothing derived from the archive can escape binDir. On any
// extraction failure the (still-archive) temp is removed and nothing is placed.
let frpcBytes: Uint8Array
try {
frpcBytes = extractFrpcBinary(bytes)
} catch (err: unknown) {
await deps.fs.rm(tmpPath)
const detail = err instanceof Error ? err.message : 'unknown error'
throw new FrpcProvisionError(
`frpc extraction failed for ${platform}: ${detail} — nothing placed`,
)
}
// Overwrite the temp with the verified extracted binary, grant exec, then promote atomically.
await deps.fs.writeFile(tmpPath, frpcBytes)
await deps.fs.chmod(tmpPath, EXEC_MODE)
await deps.fs.rename(tmpPath, binPath)
return { binPath, version: release.version, platform }
}

View File

@@ -0,0 +1,159 @@
/**
* Minimal, path-traversal-hardened tar extractor for the frp release archive — TASK B3 extraction
* step (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
*
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
* the host cannot exec a `.tar.gz`, so after `frpcBinary.ts` has VERIFIED the archive's SHA-256
* against its pin it hands the bytes here to pull out ONLY the inner `frpc`.
*
* SECURITY (§5): this does NOT reconstruct the archive's directory tree on disk. The caller writes
* the returned bytes to a FIXED destination it owns; entries are selected purely by matching the
* (sanitized) tar name's BASENAME. Any candidate entry whose name is absolute, contains a NUL, or
* has a `..` path segment is REJECTED (never silently skipped, so a `../frpc` decoy cannot be used
* to derive a path). A malicious tarball can therefore never cause a write outside the caller's dir.
*
* Format scope: real frp archives are plain USTAR (magic `ustar `) with 100-byte names (no PAX/GNU
* long-name/sparse entries) — verified against frp v0.61.1. The parser reads only what USTAR needs:
* name (0..100), size octal (124..136), typeflag (156); regular files are typeflag `0` or legacy NUL.
*/
import { gunzipSync } from 'node:zlib'
const TAR_BLOCK = 512
const NAME_OFFSET = 0
const NAME_LEN = 100
const SIZE_OFFSET = 124
const SIZE_LEN = 12
const TYPEFLAG_OFFSET = 156
// USTAR regular-file type flags: '0' (0x30) and the legacy NUL (0x00). Everything else (dir '5',
// symlink '2', PAX 'x'/'g', GNU 'L', …) is not a plain file and is skipped when matching.
const TYPE_REGULAR = 0x30
const TYPE_LEGACY = 0x00
// Hard ceiling on a single extracted entry. frp's `frpc` is ~14 MB; 256 MB rejects a corrupt/hostile
// size field before it can drive a huge allocation or an out-of-bounds read.
const MAX_ENTRY_BYTES = 256 * 1024 * 1024
const FRPC_BASENAME = 'frpc'
/** A tar/gzip extraction failure (corrupt gzip, malformed/truncated tar, unsafe or missing entry). */
export class TarExtractError extends Error {
constructor(message: string) {
super(message)
this.name = 'TarExtractError'
}
}
/**
* True if `name` could escape a destination directory: contains a NUL, is an absolute path, or has
* any `..` path segment. Both `/` and `\` are treated as separators (defense in depth).
*/
function isUnsafeEntryName(name: string): boolean {
if (name.length === 0) return true
if (name.includes('\0')) return true
if (name.startsWith('/') || name.startsWith('\\')) return true
return name.split(/[/\\]/).some((segment) => segment === '..')
}
/** Last `/`- or `\`-separated segment of a tar entry name. */
function basename(name: string): string {
const parts = name.split(/[/\\]/)
return parts[parts.length - 1] ?? name
}
/** Decode the NUL-terminated USTAR name field of the header block at `off`. */
function readName(tar: Uint8Array, off: number): string {
const raw = tar.subarray(off + NAME_OFFSET, off + NAME_OFFSET + NAME_LEN)
const nul = raw.indexOf(0)
return new TextDecoder().decode(raw.subarray(0, nul < 0 ? NAME_LEN : nul))
}
/**
* Read a NUL/space-terminated octal numeric field. Leading spaces are tolerated (GNU pads them);
* any non-octal digit yields `NaN` so the caller can reject a corrupt header.
*/
function readOctal(tar: Uint8Array, start: number, len: number): number {
let value = 0
let seenDigit = false
for (let i = start; i < start + len; i++) {
const c = tar[i]
if (c === undefined || c === 0x00 || c === 0x20) {
if (seenDigit) break
continue
}
if (c < 0x30 || c > 0x37) return Number.NaN
value = value * 8 + (c - 0x30)
seenDigit = true
}
return seenDigit ? value : 0
}
/** True if the 512-byte header block at `off` is entirely zero (the end-of-archive marker). */
function isZeroBlock(tar: Uint8Array, off: number): boolean {
for (let i = off; i < off + TAR_BLOCK; i++) {
if (tar[i] !== 0) return false
}
return true
}
/**
* Return the bytes of the FIRST regular-file entry whose safe basename equals `targetBasename`.
*
* Selection is by basename only — the archive's directory structure is never mapped to a path. A
* candidate whose name is unsafe (absolute / NUL / `..`) throws rather than being used. Missing
* entry, a truncated/corrupt tar, or an over-size entry all throw `TarExtractError` (place nothing).
*/
export function extractTarFileByBasename(tar: Uint8Array, targetBasename: string): Uint8Array {
let off = 0
while (off + TAR_BLOCK <= tar.length) {
if (isZeroBlock(tar, off)) break // end-of-archive
const size = readOctal(tar, off + SIZE_OFFSET, SIZE_LEN)
if (!Number.isInteger(size) || size < 0) {
throw new TarExtractError('corrupt tar: invalid entry size field')
}
if (size > MAX_ENTRY_BYTES) {
throw new TarExtractError(`tar entry exceeds max size (${size} > ${MAX_ENTRY_BYTES})`)
}
const contentStart = off + TAR_BLOCK
const contentEnd = contentStart + size
if (contentEnd > tar.length) {
throw new TarExtractError('corrupt tar: entry content is truncated')
}
const typeflag = tar[off + TYPEFLAG_OFFSET]
const isRegular = typeflag === TYPE_REGULAR || typeflag === TYPE_LEGACY
if (isRegular) {
const name = readName(tar, off)
if (basename(name) === targetBasename) {
if (isUnsafeEntryName(name)) {
throw new TarExtractError(`refusing unsafe tar entry name: ${JSON.stringify(name)}`)
}
return tar.slice(contentStart, contentEnd) // copy — never a view into the archive buffer
}
}
// Advance past this entry's content, padded up to the next 512-byte boundary.
off = contentEnd + ((TAR_BLOCK - (size % TAR_BLOCK)) % TAR_BLOCK)
}
throw new TarExtractError(`no "${targetBasename}" file entry found in tar archive`)
}
/**
* Gunzip a verified frp `.tar.gz` and return the inner `frpc` binary's bytes. `gunzipSync` is used
* on already-hash-verified input (the pin gate runs first), so there is no attacker-controlled
* decompression-bomb surface here; a corrupt/non-gzip payload throws `TarExtractError`.
*/
export function extractFrpcBinary(archiveGz: Uint8Array): Uint8Array {
return extractTarFileByBasename(gunzip(archiveGz), FRPC_BASENAME)
}
function gunzip(archiveGz: Uint8Array): Uint8Array {
try {
return new Uint8Array(gunzipSync(archiveGz))
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : 'not a gzip stream'
throw new TarExtractError(`corrupt frpc archive: gunzip failed (${detail})`)
}
}

View File

@@ -1,10 +1,20 @@
/**
* Service install dispatcher — PLAN_RELAY_AGENT T17. Detects the platform, writes the unit, and
* loads it. REFUSES to install as root (EXPLORE §4d least privilege). All IO is injected so the
* logic is unit-testable without touching the real system.
* Service install dispatcher — PLAN_RELAY_AGENT T17, re-targeted for the native tunnel
* (PLAN_TUNNEL_AUTOMATION B5). Detects the platform and emits TWO durable units — the base-app and
* the agent — then loads/enables both. REFUSES to install as root (EXPLORE §4d least privilege).
* All IO is injected so the logic is unit-testable without touching the real system.
*
* Two safety controls are load-bearing here:
* - FIX C-host-1 (S-GATE, CRITICAL): the base-app unit MUST bind loopback. A non-loopback
* `BIND_HOST` (e.g. `0.0.0.0`) is REJECTED — the throw happens before any file is written, so a
* rejected install emits nothing. An absent value is normalized to `127.0.0.1`.
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
*/
import type { AgentConfig } from '../config/agentConfig.js'
import {
agentLabel,
baseAppLabel,
buildLaunchdPlist,
launchdLoadCommand,
launchdPlistPath,
@@ -12,6 +22,8 @@ import {
type ServiceEnv,
} from './launchd.js'
import {
agentUnitName,
baseAppUnitName,
buildSystemdUnit,
systemdDisableCommand,
systemdEnableCommand,
@@ -19,15 +31,17 @@ import {
type SystemdUnitOptions,
} from './systemd.js'
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
export type ServicePlatform = 'launchd' | 'systemd'
/**
* Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
* existing callers (and relay callers) are unaffected — an empty object writes the historical unit.
* existing callers (and relay callers) are unaffected. `env` is the BASE-APP env (routed to the
* base-app unit only); `baseAppExec` overrides the base-app `ExecStart` argv.
*/
export interface InstallOptions {
/** S0 env injected into the supervised process (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
/** Base-app env injected into the base-app unit (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
readonly env?: ServiceEnv
/** systemd `EnvironmentFile=` path (preferred over inline for values best kept off the unit). */
readonly envFile?: string
@@ -35,26 +49,84 @@ export interface InstallOptions {
readonly zone?: string
/** Parent domain (e.g. `yaojia.wang`); with `cfg.subdomain` derives the tunnel ALLOWED_ORIGINS. */
readonly domain?: string
/** Base-app process argv (default `['node','dist/server.js']`). */
readonly baseAppExec?: readonly string[]
}
/** S0 base-app env vars the install CLI bakes into the per-host unit, passed through verbatim. */
const BASE_APP_ENV_KEYS = ['ALLOWED_ORIGINS', 'PORT', 'SHELL_PATH', 'IDLE_TTL', 'USE_TMUX'] as const
/** S0 base-app env vars the install CLI bakes into the base-app unit, passed through verbatim. */
const BASE_APP_ENV_KEYS = [
'ALLOWED_ORIGINS',
'PORT',
'SHELL_PATH',
'IDLE_TTL',
'USE_TMUX',
'SCROLLBACK_BYTES',
'MAX_PAYLOAD_BYTES',
] as const
/**
* Tunnel hosts MUST bind loopback. At the relay the device-cert mTLS is the ONLY auth gate, so a
* default `0.0.0.0` bind (`src/config.ts`) would serve an unauth'd shell on the LAN, bypassing mTLS
* entirely (PLAN_NATIVE_TUNNEL S0/R2). `buildInstallOptions` therefore defaults `BIND_HOST` here.
* entirely (PLAN_NATIVE_TUNNEL S0/R2, FIX C-host-1). This is the normalized loopback default.
*/
export const TUNNEL_DEFAULT_BIND_HOST = '127.0.0.1'
/** Native-tunnel origin zone → `https://<subdomain>.terminal.<domain>`; overridable via TUNNEL_ZONE. */
export const TUNNEL_ORIGIN_ZONE = 'terminal'
/** Native-tunnel origin zone label; native installs MUST use this (FIX L-host-zone). */
export const NATIVE_ORIGIN_ZONE = 'terminal'
/** Base-app process argv when the caller does not override it. */
const DEFAULT_BASE_APP_EXEC: readonly string[] = ['node', 'dist/server.js']
/** A base-app BIND_HOST that is not loopback — the S-GATE fail-closed error (FIX C-host-1). */
export class BindHostError extends Error {
constructor(value: string) {
super(
`refusing to install: BIND_HOST="${value}" is not loopback. A tunnel host MUST bind ` +
'127.0.0.1/::1/localhost — the device-cert mTLS at the relay is the only auth gate, so a ' +
'0.0.0.0 (or LAN-IP) bind would serve an unauth\'d shell on the LAN. [FIX C-host-1 S-GATE]',
)
this.name = 'BindHostError'
}
}
/**
* True iff `value` is a loopback bind address (a well-formed 127.0.0.0/8 IPv4 literal, ::1, or
* localhost). Delegates to the shared strict check so a suffixed hostname such as
* `127.0.0.1.attacker.example.com` — which Node would DNS-resolve before bind() — is REJECTED,
* not treated as loopback (FIX C-host-1 S-GATE).
*/
function isLoopbackBindHost(value: string): boolean {
return isLoopbackHostLiteral(value)
}
/**
* S-GATE (FIX C-host-1): normalize an absent/empty BIND_HOST to loopback; REJECT any non-loopback
* value (throws `BindHostError`). The emitted base-app unit can therefore never bind `0.0.0.0`.
*/
export function normalizeBindHost(value: string | undefined): string {
if (value === undefined || value.length === 0) return TUNNEL_DEFAULT_BIND_HOST
if (!isLoopbackBindHost(value)) throw new BindHostError(value)
return value
}
/** Assert a native-tunnel install uses the `terminal` zone (FIX L-host-zone). Throws otherwise. */
export function assertNativeZone(zone: string | undefined): void {
if (zone !== NATIVE_ORIGIN_ZONE) {
throw new Error(
`native tunnel install requires zone="${NATIVE_ORIGIN_ZONE}" (got "${zone ?? '(default term)'}")` +
' — the base-app origin must be https://<sub>.terminal.<domain> [FIX L-host-zone]',
)
}
}
/**
* Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2).
* Sources the S0 base-app env — defaulting `BIND_HOST` to loopback so a tunnel install can never
* emit a LAN-exposed unit — plus the tunnel-origin `domain`/`zone` used to derive ALLOWED_ORIGINS.
* Pure/immutable: `env` is a parameter, so this is unit-testable without touching real process state.
* Sources the S0 base-app env — normalizing/gating `BIND_HOST` to loopback (S-GATE: throws on a
* non-loopback value so no install can ever emit a LAN-exposed unit) — plus the tunnel-origin
* `domain`/`zone` used to derive ALLOWED_ORIGINS. Pure/immutable (env is a parameter).
*/
export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
const passthrough = Object.fromEntries(
@@ -62,13 +134,16 @@ export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0,
),
)
const serviceEnv: ServiceEnv = { BIND_HOST: env.BIND_HOST || TUNNEL_DEFAULT_BIND_HOST, ...passthrough }
// S-GATE at env-read time: a non-loopback BIND_HOST fails closed here (before any install).
const serviceEnv: ServiceEnv = { BIND_HOST: normalizeBindHost(env.BIND_HOST), ...passthrough }
const domain = env.TUNNEL_DOMAIN
const envFile = env.AGENT_ENV_FILE
const baseAppEntry = env.BASE_APP_ENTRY
return {
env: serviceEnv,
...(domain ? { domain, zone: env.TUNNEL_ZONE || TUNNEL_ORIGIN_ZONE } : {}),
...(envFile ? { envFile } : {}),
...(baseAppEntry ? { baseAppExec: ['node', baseAppEntry] } : {}),
}
}
@@ -96,18 +171,26 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
}
/**
* Resolve the env map injected into the unit. When a `domain` (+ `cfg.subdomain`) is supplied, the
* tunnel origin `https://<subdomain>.<zone>.<domain>` is merged into ALLOWED_ORIGINS so the base app
* trusts its own tunnel host — never weakening any origin the caller already provided. Immutable.
* Resolve the BASE-APP env injected into the base-app unit. Runs the S-GATE on `BIND_HOST` (throws
* `BindHostError` on a non-loopback value, before any write) and, when a `domain` (+ `cfg.subdomain`)
* is supplied, merges the tunnel origin `https://<subdomain>.<zone>.<domain>` into ALLOWED_ORIGINS —
* never weakening an origin the caller already provided. Immutable.
*/
function resolveEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
const base = options.env ?? {}
if (!options.domain || !cfg.subdomain) return base
const bindHost = normalizeBindHost(base.BIND_HOST) // S-GATE — throws on non-loopback
const withBind: ServiceEnv = { ...base, BIND_HOST: bindHost }
if (!options.domain || !cfg.subdomain) return withBind
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
return { ...base, ALLOWED_ORIGINS: mergeOrigins(base.ALLOWED_ORIGINS, origin) }
return { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) }
}
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
/**
* Write + load BOTH service units for `platform`: the base-app (`node dist/server.js` + base-app
* env) and the agent (`<bin> run`, supervises frpc — no base-app env). Throws RootRefusedError if
* running as root, or BindHostError (S-GATE) BEFORE any write if the base-app BIND_HOST is not
* loopback (so a rejected install emits nothing).
*/
export async function installService(
cfg: AgentConfig,
platform: ServicePlatform,
@@ -115,34 +198,57 @@ export async function installService(
options: InstallOptions = {},
): Promise<void> {
if (deps.getuid() === 0) throw new RootRefusedError()
// Resolve (and S-GATE) the base-app env BEFORE any IO — a non-loopback BIND_HOST throws here,
// so nothing is ever written for a rejected install.
const baseAppEnv = resolveBaseAppEnv(cfg, options)
const bin = deps.binPath()
const env = resolveEnv(cfg, options)
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
if (platform === 'launchd') {
const path = launchdPlistPath(deps.homedir())
deps.writeFile(path, buildLaunchdPlist(bin, env))
const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args)
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnv, baseAppLabel()))
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel()))
for (const path of [baseAppPath, agentPath]) {
const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args)
}
return
}
const path = systemdUnitPath(deps.homedir())
const systemdOptions: SystemdUnitOptions = options.envFile
? { env, envFile: options.envFile }
: { env }
deps.writeFile(path, buildSystemdUnit(bin, deps.username(), systemdOptions))
const { cmd, args } = systemdEnableCommand()
await deps.runCommand(cmd, args)
const baseAppOptions: SystemdUnitOptions = options.envFile
? { env: baseAppEnv, envFile: options.envFile }
: { env: baseAppEnv }
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
deps.writeFile(
baseAppPath,
buildSystemdUnit(baseAppExec.join(' '), deps.username(), baseAppOptions, 'web-terminal base app (loopback)'),
)
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
deps.writeFile(
agentPath,
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'),
)
for (const unit of [baseAppUnitName(), agentUnitName()]) {
const { cmd, args } = systemdEnableCommand(unit)
await deps.runCommand(cmd, args)
}
}
/** Unload the service unit for `platform`. */
/** Unload/disable BOTH service units for `platform` (agent first, then base-app). */
export async function uninstallService(
platform: ServicePlatform,
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
): Promise<void> {
if (platform === 'launchd') {
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir()))
await deps.runCommand(cmd, args)
for (const label of [agentLabel(), baseAppLabel()]) {
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
await deps.runCommand(cmd, args)
}
return
}
const { cmd, args } = systemdDisableCommand()
await deps.runCommand(cmd, args)
for (const unit of [agentUnitName(), baseAppUnitName()]) {
const { cmd, args } = systemdDisableCommand(unit)
await deps.runCommand(cmd, args)
}
}

View File

@@ -2,23 +2,38 @@
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
*
* PLAN_NATIVE_TUNNEL S2: the plist can now inject a caller-supplied per-host env map
* PLAN_NATIVE_TUNNEL S2: the plist can inject a caller-supplied per-host env map
* (BIND_HOST, ALLOWED_ORIGINS, PORT, SHELL_PATH, IDLE_TTL, USE_TMUX, …) as a launchd
* `<key>EnvironmentVariables</key><dict>…</dict>` block. Values are XML-escaped and keys are
* sorted for deterministic, immutable output.
*
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on `label` +
* `programArguments`, so a native-tunnel install emits TWO distinct plists — the base-app
* (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which supervises
* frpc). Base-app env is routed to the base-app plist ONLY by the caller (`install.ts`).
*/
/** Shared env-map shape for the durable-service writers (launchd + systemd). Immutable. */
export type ServiceEnv = Readonly<Record<string, string>>
const LABEL = 'com.web-terminal.agent'
/** The native-tunnel agent unit (supervises frpc). */
const AGENT_LABEL = 'com.web-terminal.agent'
/** The base-app unit (`node dist/server.js`, loopback-bound). */
const BASE_APP_LABEL = 'com.web-terminal.base-app'
export function agentLabel(): string {
return AGENT_LABEL
}
export function baseAppLabel(): string {
return BASE_APP_LABEL
}
/** Back-compat alias for the agent label. */
export function launchdLabel(): string {
return LABEL
return AGENT_LABEL
}
export function launchdPlistPath(homedir: string): string {
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
return `${homedir}/Library/LaunchAgents/${label}.plist`
}
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
@@ -44,24 +59,34 @@ function environmentVariablesBlock(env: ServiceEnv): readonly string[] {
return lines
}
/** Build the `<key>ProgramArguments</key><array>…</array>` lines. */
function programArgumentsBlock(programArguments: readonly string[]): readonly string[] {
const lines = [' <key>ProgramArguments</key>', ' <array>']
for (const arg of programArguments) {
lines.push(` <string>${escapeXml(arg)}</string>`)
}
lines.push(' </array>')
return lines
}
/**
* Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). When `env`
* is non-empty, a launchd `EnvironmentVariables` dict is injected so the supervised process
* receives the per-host tunnel config. Pure/immutable — returns a fresh string.
* Build a plist for `label` running `programArguments` (e.g. `[bin, 'run']` or `[node, serverJs]`);
* RunAtLoad + KeepAlive (restart on failure). When `env` is non-empty, a launchd
* `EnvironmentVariables` dict is injected. Pure/immutable — returns a fresh string.
*/
export function buildLaunchdPlist(binPath: string, env: ServiceEnv = {}): string {
export function buildLaunchdPlist(
programArguments: readonly string[],
env: ServiceEnv = {},
label: string = AGENT_LABEL,
): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<dict>',
' <key>Label</key>',
` <string>${escapeXml(LABEL)}</string>`,
' <key>ProgramArguments</key>',
' <array>',
` <string>${escapeXml(binPath)}</string>`,
' <string>run</string>',
' </array>',
` <string>${escapeXml(label)}</string>`,
...programArgumentsBlock(programArguments),
' <key>RunAtLoad</key>',
' <true/>',
' <key>KeepAlive</key>',

View File

@@ -2,14 +2,22 @@
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
*
* PLAN_NATIVE_TUNNEL S2: the unit can now inject the per-host tunnel env via an
* `EnvironmentFile=` line (preferred — keeps values out of the world-readable unit) and/or
* inline `Environment=` lines from a caller-supplied env map. Inline `Environment=` is emitted
* after `EnvironmentFile=` so an explicit value overrides the file on conflict.
* PLAN_NATIVE_TUNNEL S2: the unit can inject the per-host tunnel env via an `EnvironmentFile=`
* line (preferred — keeps values out of the world-readable unit) and/or inline `Environment=`
* lines from a caller-supplied env map. Inline `Environment=` is emitted after `EnvironmentFile=`
* so an explicit value overrides the file on conflict.
*
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on the full
* `ExecStart` command + `Description`, so a native-tunnel install emits TWO distinct units — the
* base-app (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which
* supervises frpc). Base-app env is routed to the base-app unit ONLY by the caller (`install.ts`).
*/
import type { ServiceEnv } from './launchd.js'
const UNIT_NAME = 'web-terminal-agent.service'
/** The native-tunnel agent unit (supervises frpc). */
const AGENT_UNIT = 'web-terminal-agent.service'
/** The base-app unit (`node dist/server.js`, loopback-bound). */
const BASE_APP_UNIT = 'web-terminal-base-app.service'
/** DEL (0x7F) and everything below the printable ASCII range are rejected in env values. */
const FIRST_PRINTABLE_ASCII = 0x20
@@ -22,18 +30,25 @@ export interface SystemdUnitOptions {
readonly envFile?: string
}
export function agentUnitName(): string {
return AGENT_UNIT
}
export function baseAppUnitName(): string {
return BASE_APP_UNIT
}
/** Back-compat alias for the agent unit name. */
export function systemdUnitName(): string {
return UNIT_NAME
return AGENT_UNIT
}
export function systemdUnitPath(homedir: string): string {
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
export function systemdUnitPath(homedir: string, unitName: string = AGENT_UNIT): string {
return `${homedir}/.config/systemd/user/${unitName}`
}
/**
* True if `value` contains any control character (C0 range below 0x20, or DEL 0x7F). A raw
* newline/CR would terminate the single `Environment=` line and let the remainder inject arbitrary
* directives into the `[Service]` section — so such values are rejected rather than escaped.
* newline/CR would terminate the current line and let the remainder inject arbitrary directives into
* the `[Service]` section — so such values are rejected rather than escaped.
*/
function hasControlChar(value: string): boolean {
for (const char of value) {
@@ -44,14 +59,25 @@ function hasControlChar(value: string): boolean {
return false
}
/** Quote a systemd `Environment=` value: reject control chars, then double-quote escaping `\` and `"`. */
function quoteEnvAssignment(key: string, value: string): string {
/**
* Reject ANY field interpolated into the unit that carries a control character. EVERY value written
* into the unit (ExecStart, User, Description, EnvironmentFile path, and each Environment key+value)
* flows through this one guard, so no field can smuggle a newline that starts a new `[Service]`
* directive. Fail loud rather than escape — these fields are never legitimately multi-line.
*/
function assertNoControlChar(fieldName: string, value: string): void {
if (hasControlChar(value)) {
throw new Error(
`refusing to write systemd Environment for '${key}': value contains a control character ` +
`refusing to write systemd unit: ${fieldName} contains a control character ` +
'(newline/CR/etc.) that could corrupt the [Service] section',
)
}
}
/** Quote a systemd `Environment=` value: reject control chars (key AND value), then escape `\` and `"`. */
function quoteEnvAssignment(key: string, value: string): string {
assertNoControlChar(`Environment key '${key}'`, key)
assertNoControlChar(`Environment value for '${key}'`, value)
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return `"${key}=${escaped}"`
}
@@ -59,7 +85,10 @@ function quoteEnvAssignment(key: string, value: string): string {
/** Build the `EnvironmentFile=` / `Environment=` lines (empty array when neither is supplied). */
function environmentLines(options: SystemdUnitOptions): readonly string[] {
const lines: string[] = []
if (options.envFile) lines.push(`EnvironmentFile=${options.envFile}`)
if (options.envFile) {
assertNoControlChar('EnvironmentFile path', options.envFile)
lines.push(`EnvironmentFile=${options.envFile}`)
}
const entries = Object.entries(options.env ?? {}).sort(([a], [b]) => a.localeCompare(b))
for (const [key, value] of entries) {
lines.push(`Environment=${quoteEnvAssignment(key, value)}`)
@@ -68,23 +97,29 @@ function environmentLines(options: SystemdUnitOptions): readonly string[] {
}
/**
* Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). When
* `options.env`/`options.envFile` are supplied, the per-host tunnel env is injected into the
* `[Service]` section. Pure/immutable — returns a fresh string.
* Build a unit whose `ExecStart` is `execStart` (a full command line, e.g. `<bin> run` or
* `node dist/server.js`); Restart=on-failure; User=<user> (never root). When `options.env`/
* `options.envFile` are supplied, the per-host tunnel env is injected. Pure/immutable.
*/
export function buildSystemdUnit(
binPath: string,
execStart: string,
user: string,
options: SystemdUnitOptions = {},
description: string = 'web-terminal host agent',
): string {
// Guard every non-env field the same way env values are guarded — a newline in ExecStart/User/
// Description would otherwise inject a `[Service]` directive on the next line.
assertNoControlChar('ExecStart', execStart)
assertNoControlChar('User', user)
assertNoControlChar('Description', description)
return [
'[Unit]',
'Description=web-terminal host agent (rendezvous relay)',
`Description=${description}`,
'After=network-online.target',
'',
'[Service]',
'Type=simple',
`ExecStart=${binPath} run`,
`ExecStart=${execStart}`,
'Restart=on-failure',
'RestartSec=1',
`User=${user}`,
@@ -96,9 +131,13 @@ export function buildSystemdUnit(
].join('\n')
}
export function systemdEnableCommand(): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', UNIT_NAME] }
export function systemdEnableCommand(
unitName: string = AGENT_UNIT,
): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', unitName] }
}
export function systemdDisableCommand(): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', UNIT_NAME] }
export function systemdDisableCommand(
unitName: string = AGENT_UNIT,
): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', unitName] }
}

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')
}