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

View File

@@ -33,10 +33,20 @@ describe('AgentConfig validation', () => {
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://127.5.5.5:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://[::1]:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://10.0.0.5:3000')).toBe(false)
expect(isLoopbackWsUrl('wss://127.0.0.1:3000')).toBe(false)
})
it('REGRESSION: rejects a crafted suffixed-hostname target (anti-SSRF bypass)', () => {
// Hostname, not a loopback literal — the outbound dial would DNS-resolve and connect out.
expect(isLoopbackWsUrl('ws://127.0.0.1.attacker.example.com:3000/x')).toBe(false)
expect(isLoopbackWsUrl('ws://127.evil.net:3000')).toBe(false)
expect(() =>
AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://127.0.0.1.attacker.example.com:3000' }),
).toThrow()
})
it('loadAgentConfig fails fast on a missing relayUrl', () => {
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
})

View File

@@ -4,6 +4,7 @@ import type { Keystore } from '../src/keys/keystore.js'
import type { AgentIdentity } from '../src/keys/identity.js'
import type { EnrollResult } from 'relay-contracts'
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
import type { InstallOptions } from '../src/service/install.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
@@ -14,8 +15,9 @@ const CFG: AgentConfig = {
hostId: 'h-1',
}
function fakeIdentity(): AgentIdentity {
function fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
return {
alg,
publicKey: new Uint8Array(32),
enrollFpr: 'fpr',
sign: () => new Uint8Array(64),
@@ -24,10 +26,10 @@ function fakeIdentity(): AgentIdentity {
}
}
function fakeKeystore(enrolled: boolean): Keystore {
function fakeKeystore(enrolled: boolean, alg: AgentIdentity['alg'] = 'ed25519'): Keystore {
return {
saveIdentity: vi.fn(),
loadIdentity: () => (enrolled ? fakeIdentity() : null),
loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
saveCert: vi.fn(),
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
saveContentSecret: vi.fn(),
@@ -35,12 +37,19 @@ function fakeKeystore(enrolled: boolean): Keystore {
}
}
const NATIVE_OPTIONS: InstallOptions = {
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
domain: 'yaojia.wang',
zone: 'terminal',
}
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
const out: string[] = []
const d: CliDeps = {
loadConfig: () => CFG,
openKeystore: () => fakeKeystore(enrolled),
generateIdentity: fakeIdentity,
generateIdentity: () => fakeIdentity('ed25519'),
generateP256Identity: () => fakeIdentity('p256'),
redeem: async (): Promise<EnrollResult> => ({
hostId: 'h-1',
subdomain: 'host-42',
@@ -48,8 +57,13 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
caChain: 'CA',
hostContentSecret: new Uint8Array([1]),
}),
enrollNative: async () => ({ hostId: 'h-1', subdomain: 'host-42' }),
provisionFrpc: async () => '/opt/frpc',
writeFrpcConfig: vi.fn(),
nativeConfigExists: () => false,
runTunnel: async () => 0,
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }),
superviseFrpc: async () => 0,
resolveInstallOptions: () => NATIVE_OPTIONS,
installService: vi.fn(async () => {}),
uninstallService: vi.fn(async () => {}),
print: (l) => out.push(l),
@@ -80,7 +94,7 @@ describe('parseArgs (T5)', () => {
})
})
describe('runCli (T5)', () => {
describe('runCli — legacy relay pair (no --install)', () => {
it('pair happy path calls redeem and prints no secrets', async () => {
const { d, out } = deps()
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
@@ -88,23 +102,97 @@ describe('runCli (T5)', () => {
expect(out.join('\n')).toContain('host-42')
expect(out.join('\n')).not.toContain('PEM')
})
})
it('pair --install installs the service with the resolved options', async () => {
const options = { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } }
const install = vi.fn(async () => {})
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(install).toHaveBeenCalledOnce()
expect(install).toHaveBeenCalledWith(CFG, options)
describe('runCli — native pair --install onboard (B5)', () => {
it('wires keygen(P-256)→enroll→provisionFrpc→write toml+env→install both→print URL, in order', async () => {
const calls: string[] = []
const enrolledIds: AgentIdentity[] = []
const generateP256Identity = vi.fn(() => {
calls.push('keygen')
return fakeIdentity('p256')
})
const enrollNative = vi.fn(async (_cfg: AgentConfig, _code: string, id: AgentIdentity) => {
calls.push('enroll')
enrolledIds.push(id)
return { hostId: 'h-1', subdomain: 'host-42' }
})
const provisionFrpc = vi.fn(async () => {
calls.push('provision')
return '/opt/frpc'
})
const writeFrpcConfig = vi.fn(() => {
calls.push('writeConfig')
})
const installService = vi.fn(async () => {
calls.push('install')
})
const { d, out } = deps({
generateP256Identity,
enrollNative,
provisionFrpc,
writeFrpcConfig,
installService,
})
const code = await runCli(parseArgs(['pair', 'ABCD-1234', '--install']), d)
expect(code).toBe(0)
expect(calls).toEqual(['keygen', 'enroll', 'provision', 'writeConfig', 'install'])
// CSR is built from a P-256 identity (FIX H-host-2)
expect(enrolledIds[0]!.alg).toBe('p256')
// enroll seam received the pairing code
expect(enrollNative).toHaveBeenCalledWith(CFG, 'ABCD-1234', expect.anything(), expect.anything())
// both units installed with the resolved options (env routed to base-app by installService)
expect(installService).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
// frpc.toml written for the returned subdomain
expect(writeFrpcConfig).toHaveBeenCalledWith(CFG, 'host-42')
// prints the final tunnel URL
expect(out.join('\n')).toContain('https://host-42.terminal.yaojia.wang')
})
it('does NOT use the legacy relay redeem path on --install', async () => {
const redeem = vi.fn()
const { d } = deps({ redeem: redeem as unknown as CliDeps['redeem'] })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(redeem).not.toHaveBeenCalled()
})
it('saves the freshly generated P-256 identity before enrolling', async () => {
const ks = fakeKeystore(false)
const { d } = deps({ openKeystore: () => ks })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(ks.saveIdentity).toHaveBeenCalled()
})
it('rejects a native install whose zone is not `terminal` (FIX L-host-zone)', async () => {
const { d } = deps({
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'term' }),
})
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toThrow(/terminal/)
})
it('rejects a native install with no TUNNEL_DOMAIN (cannot form the origin)', async () => {
const { d } = deps({ resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }) })
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toBeInstanceOf(CliUsageError)
})
it('prints no key/cert material during --install (INV9)', async () => {
const { d, out } = deps()
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
const joined = out.join('\n')
expect(joined).not.toContain('PEM')
expect(joined).not.toContain('CA')
})
})
describe('runCli — install / run / status', () => {
it('install threads the resolved InstallOptions into installService (S2 env injection)', async () => {
const options = { env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'terminal' }
const install = vi.fn(async () => {})
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
const { d } = deps({ resolveInstallOptions: () => NATIVE_OPTIONS, installService: install })
const code = await runCli(parseArgs(['install']), d)
expect(code).toBe(0)
expect(install).toHaveBeenCalledWith(CFG, options)
expect(install).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
})
it('run before pairing fails fast', async () => {
@@ -112,6 +200,48 @@ describe('runCli (T5)', () => {
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
})
it('legacy run (Ed25519 identity, no frpc.toml) drives runTunnel — not frpc supervision', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps(
{ runTunnel, superviseFrpc, nativeConfigExists: () => false },
true, // enrolled with the default Ed25519 identity
)
const code = await runCli({ command: 'run', flags: {} }, d)
expect(code).toBe(0)
expect(runTunnel).toHaveBeenCalledWith(CFG, expect.anything())
expect(superviseFrpc).not.toHaveBeenCalled()
})
it('native run (P-256 identity + frpc.toml) supervises frpc — not the legacy relay', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps({
openKeystore: () => fakeKeystore(true, 'p256'),
nativeConfigExists: () => true,
runTunnel,
superviseFrpc,
})
const code = await runCli({ command: 'run', flags: {} }, d)
expect(code).toBe(0)
expect(superviseFrpc).toHaveBeenCalledWith(CFG, expect.anything())
expect(runTunnel).not.toHaveBeenCalled()
})
it('native identity but missing frpc.toml falls back to the legacy relay path', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps({
openKeystore: () => fakeKeystore(true, 'p256'),
nativeConfigExists: () => false,
runTunnel,
superviseFrpc,
})
await runCli({ command: 'run', flags: {} }, d)
expect(runTunnel).toHaveBeenCalled()
expect(superviseFrpc).not.toHaveBeenCalled()
})
it('status prints no key/cert material (INV9)', async () => {
const { d, out } = deps({}, true)
await runCli({ command: 'status', flags: {} }, d)

View File

@@ -1,8 +1,52 @@
import { describe, expect, it } from 'vitest'
import { X509Certificate } from 'node:crypto'
import { generateIdentity } from '../src/keys/identity.js'
import { X509Certificate, createPublicKey, verify } from 'node:crypto'
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
import { buildCsr } from '../src/enroll/csr.js'
// --- minimal DER reader (test-only) — walks the PKCS#10 outer SEQUENCE into its 3 children -------
interface Tlv {
readonly tag: number
/** the full tag+length+value bytes (what was signed, for the CertificationRequestInfo). */
readonly tlv: Uint8Array
readonly content: Uint8Array
}
function readTlv(buf: Uint8Array, off: number): { node: Tlv; next: number } {
const tag = buf[off]!
let i = off + 1
const first = buf[i]!
let len: number
if (first < 0x80) {
len = first
i += 1
} else {
const n = first & 0x7f
len = 0
for (let k = 0; k < n; k++) len = (len << 8) | buf[i + 1 + k]!
i += 1 + n
}
return { node: { tag, tlv: buf.subarray(off, i + len), content: buf.subarray(i, i + len) }, next: i + len }
}
function pemToDer(pem: string): Uint8Array {
const b64 = pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '')
return new Uint8Array(Buffer.from(b64, 'base64'))
}
/** The three children of the PKCS#10 outer SEQUENCE: [certificationRequestInfo, sigAlg, signature]. */
function csrChildren(pem: string): readonly Tlv[] {
const der = pemToDer(pem)
const outer = readTlv(der, 0).node
const children: Tlv[] = []
let p = 0
while (p < outer.content.length) {
const { node, next } = readTlv(outer.content, p)
children.push(node)
p = next
}
return children
}
describe('PKCS#10 CSR (T4)', () => {
it('emits a PEM CERTIFICATE REQUEST', () => {
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
@@ -30,3 +74,50 @@ describe('PKCS#10 CSR (T4)', () => {
expect(typeof X509Certificate).toBe('function')
})
})
describe('P-256 PKCS#10 CSR (FIX H-host-2)', () => {
it('emits a PEM CERTIFICATE REQUEST for a P-256 identity', () => {
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
expect(csr).not.toContain('PRIVATE KEY')
})
it('round-trips as a valid PKCS#10: signatureAlgorithm is ecdsa-with-SHA256', () => {
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
const [, sigAlg] = csrChildren(csr)
// sigAlg = SEQUENCE { OID 1.2.840.10045.4.3.2 } (no parameters, RFC 5758 §3.2)
const oidTlv = readTlv(sigAlg!.content, 0).node
expect(Array.from(oidTlv.content)).toEqual([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
// no parameters: the sigAlg SEQUENCE holds ONLY the OID.
expect(oidTlv.tlv.length).toBe(sigAlg!.content.length)
})
it('self-signature verifies over the CertificationRequestInfo (verifyCsrPoPEc semantics)', () => {
const id = generateP256Identity()
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
const [reqInfo, , sigVal] = csrChildren(csr)
// signatureValue BIT STRING content = 0x00 (unused bits) || DER ECDSA-Sig-Value.
expect(sigVal!.tag).toBe(0x03)
const signature = sigVal!.content.subarray(1)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
// Verify over the EXACT CertificationRequestInfo bytes that buildCsr signed.
expect(verify('sha256', reqInfo!.tlv, pub, signature)).toBe(true)
// Tampering with the signed body breaks PoP.
const tampered = Uint8Array.from(reqInfo!.tlv)
tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 0xff
expect(verify('sha256', tampered, pub, signature)).toBe(false)
})
it('embeds the identity EC SPKI verbatim as the CSR subjectPublicKeyInfo', () => {
const id = generateP256Identity()
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
const [reqInfo] = csrChildren(csr)
// requestInfo = SEQUENCE { version, name, spki, [0] attributes }; the spki is the 3rd child.
const inner = reqInfo!.content
const version = readTlv(inner, 0)
const name = readTlv(inner, version.next)
const spki = readTlv(inner, name.next).node
expect(Buffer.from(spki.tlv).equals(Buffer.from(id.publicKey))).toBe(true)
})
})

103
agent/test/deps.test.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* B4/H4 wiring test — closes the frpc-log capture loop that `HealthReport.healthy` depends on.
*
* Regression guarded: nothing used to WRITE `<stateDir>/frpc.log`, so `readFrpcLog` always returned
* '' and `frpcProxyStarted` was permanently false — health could never be true. This exercises the
* real production path end-to-end: `createFileLoggingSpawn` (the spawn `superviseNative` injects)
* tees a child's stdout into the exact file `readFrpcLog` scans, and `frpcProxyStarted` detects the
* "start proxy success" line. Writer path and reader path share `frpcLogPath`, so they can't diverge.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createFileLoggingSpawn, type FrpcChild } from '../src/transport/frpSupervise.js'
import { frpcLogPath, readFrpcLog } from '../src/cli/deps.js'
import { frpcProxyStarted } from '../src/health/probe.js'
const SUCCESS_LINE = '[web-terminal] start proxy success'
/** Poll `predicate` until true or `timeoutMs` elapses (real IO flush is async). */
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return true
await new Promise((r) => setTimeout(r, 25))
}
return predicate()
}
/** Write an executable fake `frpc` (node shebang) that repeatedly prints the success line to stdout. */
function writeFakeFrpc(dir: string): string {
const bin = join(dir, 'fake-frpc.mjs')
const script =
`#!${process.execPath}\n` +
`process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)})\n` +
`setInterval(() => process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)}), 100)\n`
writeFileSync(bin, script, { mode: 0o755 })
return bin
}
describe('B4/H4 frpc-log capture wiring (createFileLoggingSpawn ↔ readFrpcLog)', () => {
const dirs: string[] = []
let child: FrpcChild | null = null
afterEach(() => {
child?.kill()
child = null
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})
it('tees the frpc child stdout into the file readFrpcLog scans → proxyStarted becomes true', async () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
dirs.push(dir)
// Before any child runs, the log is empty and the proxy is not started.
expect(readFrpcLog(dir)).toBe('')
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
// Spawn via the SAME factory superviseNative injects, pointed at the SAME path it reads.
const bin = writeFakeFrpc(dir)
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
child = spawn(bin, join(dir, 'frpc.toml'))
const detected = await waitFor(() => frpcProxyStarted(readFrpcLog(dir)))
expect(detected).toBe(true)
expect(readFrpcLog(dir)).toContain('start proxy success')
})
it('createFileLoggingSpawn truncates a stale log so a dead childs success line is not reused', async () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
dirs.push(dir)
// Simulate a leftover log from a previous (now dead) frpc that had succeeded.
writeFileSync(frpcLogPath(dir), `${SUCCESS_LINE}\n`)
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(true)
// A fresh spawn whose child never prints the line must NOT keep reporting the stale success.
const bin = join(dir, 'silent-frpc.mjs')
writeFileSync(bin, `#!${process.execPath}\nsetInterval(() => {}, 100)\n`, { mode: 0o755 })
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
child = spawn(bin, join(dir, 'frpc.toml'))
// Truncate-on-spawn clears the stale line; the silent child adds none.
const cleared = await waitFor(() => readFrpcLog(dir) === '')
expect(cleared).toBe(true)
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
})
})
describe('frpcLogPath / readFrpcLog (deterministic)', () => {
it('frpcLogPath joins frpc.log under the state dir', () => {
expect(frpcLogPath('/state/x')).toBe(join('/state/x', 'frpc.log'))
})
it('readFrpcLog returns "" when the log file does not exist', () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
try {
expect(readFrpcLog(dir)).toBe('')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})

View File

@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest'
import { createLogger } from '../src/log/logger.js'
import { createBackoff } from '../src/transport/backoff.js'
import {
STABLE_RUN_MS,
superviseFrpc,
type FrpcChild,
type SpawnFrpc,
} from '../src/transport/frpSupervise.js'
const silentLogger = createLogger('error', () => {})
/**
* A fake frpc child whose exit is driven from the test. `kill()` models a real process: it dies,
* firing the exit handler (so the supervisor's `stop()` — which kills the live child — can unblock).
* exit/kill fire the handler at most once.
*/
function makeChild(): { child: FrpcChild; exit: (code: number | null) => void; killed: boolean } {
let onExit: ((code: number | null) => void) | null = null
let alive = true
const state = { killed: false }
const fire = (code: number | null): void => {
if (!alive) return
alive = false
onExit?.(code)
}
return {
child: {
onExit: (cb) => {
onExit = cb
},
isAlive: () => alive,
kill: () => {
state.killed = true
fire(null)
},
},
exit: (code) => fire(code),
get killed() {
return state.killed
},
}
}
describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
it('spawns the frpc binary with -c <toml> via the child seam', async () => {
const spawn: SpawnFrpc = vi.fn(() => makeChild().child)
superviseFrpc('/opt/agent/bin/frpc', '/state/frpc.toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
expect(spawn).toHaveBeenCalledWith('/opt/agent/bin/frpc', '/state/frpc.toml')
})
it('restarts the child on exit, backing off 1s → 2s → 4s', async () => {
const children = [makeChild(), makeChild(), makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const sleeps: number[] = []
// now() fixed so no run counts as "stable" ⇒ backoff monotonically increases.
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
backoff: createBackoff(),
sleep: async (ms) => {
sleeps.push(ms)
},
logger: silentLogger,
now: () => 1000,
})
// Crash three times; each crash schedules the next spawn after the growing backoff.
for (let i = 0; i < 3; i += 1) {
children[i]!.exit(1)
await Promise.resolve()
await Promise.resolve()
}
expect(sleeps).toEqual([1000, 2000, 4000])
await handle.stop()
})
it('resets the backoff after a run that stayed up past the stability window', async () => {
const children = [makeChild(), makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const sleeps: number[] = []
let clock = 0
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
backoff: createBackoff(),
sleep: async (ms) => {
sleeps.push(ms)
},
logger: silentLogger,
now: () => clock,
})
// First run crashes instantly ⇒ backoff 1s.
children[0]!.exit(1)
await Promise.resolve()
await Promise.resolve()
// Second run stays up past STABLE_RUN_MS before dying ⇒ backoff resets to 1s (not 2s).
clock += STABLE_RUN_MS + 1
children[1]!.exit(1)
await Promise.resolve()
await Promise.resolve()
expect(sleeps).toEqual([1000, 1000])
await handle.stop()
})
it('stop() halts the loop and kills the live child; done resolves 0', async () => {
const c = makeChild()
const spawn: SpawnFrpc = () => c.child
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
expect(handle.isChildAlive()).toBe(true)
// stop() kills the child; that fires the exit handler and the loop observes `stopped` → breaks.
const stopping = handle.stop()
c.exit(null)
await expect(stopping).resolves.toBeUndefined()
await expect(handle.done).resolves.toBe(0)
expect(c.killed).toBe(true)
})
it('does not restart after stop (no spawn past shutdown)', async () => {
const first = makeChild()
let n = 0
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
const stopping = handle.stop()
first.exit(0)
await stopping
expect(spawn).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,416 @@
import { describe, expect, it } from 'vitest'
import { createHash } from 'node:crypto'
import { gzipSync } from 'node:zlib'
import {
detectFrpcPlatform,
FRPC_PLATFORMS,
FRPC_RELEASES,
provisionFrpc,
type FrpcPlatform,
type FrpcReleaseRef,
type ProvisionFrpcDeps,
} from '../src/provision/frpcBinary.js'
import {
extractTarFileByBasename,
extractFrpcBinary,
TarExtractError,
} from '../src/provision/untar.js'
function sha256Hex(data: Uint8Array): string {
return createHash('sha256').update(data).digest('hex')
}
// ---------------------------------------------------------------------------
// In-test tar/gzip fixture builder — hand-builds real USTAR blocks so the
// extractor is exercised against the SAME on-disk layout frp ships (dir entry
// + regular files), with zero network access.
// ---------------------------------------------------------------------------
const TAR_BLOCK = 512
const TYPE_FILE = '0'
const TYPE_DIR = '5'
interface TarEntrySpec {
readonly name: string
readonly content: Uint8Array
readonly typeflag?: string
}
function writeOctalField(block: Uint8Array, offset: number, len: number, value: number): void {
const s = value.toString(8).padStart(len - 1, '0')
block.set(new TextEncoder().encode(s), offset)
block[offset + len - 1] = 0 // NUL terminator
}
function tarHeader(name: string, size: number, typeflag: string): Uint8Array {
const block = new Uint8Array(TAR_BLOCK)
const enc = new TextEncoder()
block.set(enc.encode(name).subarray(0, 100), 0)
writeOctalField(block, 100, 8, 0o755) // mode
writeOctalField(block, 108, 8, 0) // uid
writeOctalField(block, 116, 8, 0) // gid
writeOctalField(block, 124, 12, size) // size
writeOctalField(block, 136, 12, 0) // mtime
block[156] = typeflag.charCodeAt(0)
block.set(enc.encode('ustar'), 257) // magic "ustar\0"
block[263] = 0x30 // version "00"
block[264] = 0x30
// checksum: fields spaces during compute, then written as 6 octal + NUL + space
for (let i = 148; i < 156; i++) block[i] = 0x20
let sum = 0
for (let i = 0; i < TAR_BLOCK; i++) sum += block[i] ?? 0
block.set(enc.encode(sum.toString(8).padStart(6, '0')), 148)
block[154] = 0
block[155] = 0x20
return block
}
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
const total = parts.reduce((n, p) => n + p.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const p of parts) {
out.set(p, off)
off += p.length
}
return out
}
function makeTar(entries: readonly TarEntrySpec[]): Uint8Array {
const parts: Uint8Array[] = []
for (const e of entries) {
parts.push(tarHeader(e.name, e.content.length, e.typeflag ?? TYPE_FILE))
parts.push(e.content)
const pad = (TAR_BLOCK - (e.content.length % TAR_BLOCK)) % TAR_BLOCK
if (pad > 0) parts.push(new Uint8Array(pad))
}
parts.push(new Uint8Array(TAR_BLOCK * 2)) // end-of-archive: two zero blocks
return concatBytes(parts)
}
function makeTarGz(entries: readonly TarEntrySpec[]): Uint8Array {
return new Uint8Array(gzipSync(makeTar(entries)))
}
const FRPC_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0xde, 0xad, 0xbe, 0xef]) // fake "ELF" frpc
const FRPS_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x11, 0x22, 0x33]) // decoy frps
/** A realistic frp archive layout: dir entry + frpc + decoy frps + LICENSE. */
function realisticFrpTarGz(prefix = 'frp_0.61.1_darwin_arm64'): Uint8Array {
return makeTarGz([
{ name: `${prefix}/`, content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: `${prefix}/frps`, content: FRPS_BYTES },
{ name: `${prefix}/frpc`, content: FRPC_BYTES },
{ name: `${prefix}/LICENSE`, content: new TextEncoder().encode('MIT') },
])
}
interface FakeFsState {
writes: Map<string, Uint8Array>
renames: Array<{ from: string; to: string }>
removed: string[]
chmods: Array<{ path: string; mode: number }>
mkdirs: string[]
}
function makeFakeDeps(bytesByUrl: Record<string, Uint8Array>): {
deps: ProvisionFrpcDeps
state: FakeFsState
fetchedUrls: string[]
} {
const state: FakeFsState = {
writes: new Map(),
renames: [],
removed: [],
chmods: [],
mkdirs: [],
}
const fetchedUrls: string[] = []
const deps: ProvisionFrpcDeps = {
fetch: async (url) => {
fetchedUrls.push(url)
const bytes = bytesByUrl[url]
if (!bytes) throw new Error(`test: no fixture bytes for ${url}`)
return bytes
},
fs: {
mkdir: async (dir) => {
state.mkdirs.push(dir)
},
writeFile: async (path, data) => {
state.writes.set(path, data)
},
rename: async (from, to) => {
state.renames.push({ from, to })
const data = state.writes.get(from)
if (data) {
state.writes.delete(from)
state.writes.set(to, data)
}
},
chmod: async (path, mode) => {
state.chmods.push({ path, mode })
},
rm: async (path) => {
state.removed.push(path)
state.writes.delete(path)
},
},
}
return { deps, state, fetchedUrls }
}
function releaseOverride(
platform: FrpcPlatform,
ref: FrpcReleaseRef,
): Record<FrpcPlatform, FrpcReleaseRef> {
return { ...FRPC_RELEASES, [platform]: ref }
}
const BIN_DIR = '/opt/wt/bin'
const BIN_PATH = '/opt/wt/bin/frpc'
describe('detectFrpcPlatform (B3)', () => {
it('maps darwin/linux × arm64/amd64 (node x64 → amd64)', () => {
expect(detectFrpcPlatform('darwin', 'arm64')).toBe('darwin-arm64')
expect(detectFrpcPlatform('darwin', 'x64')).toBe('darwin-amd64')
expect(detectFrpcPlatform('linux', 'arm64')).toBe('linux-arm64')
expect(detectFrpcPlatform('linux', 'x64')).toBe('linux-amd64')
})
it('returns null for unsupported platform/arch', () => {
expect(detectFrpcPlatform('win32', 'x64')).toBeNull()
expect(detectFrpcPlatform('linux', 'ia32')).toBeNull()
expect(detectFrpcPlatform('freebsd', 'arm64')).toBeNull()
expect(detectFrpcPlatform('darwin', 'mips')).toBeNull()
})
})
describe('FRPC_RELEASES pinning', () => {
it('pins a release per supported platform (https url, semver, 64-hex sha256)', () => {
expect([...FRPC_PLATFORMS].sort()).toEqual([
'darwin-amd64',
'darwin-arm64',
'linux-amd64',
'linux-arm64',
])
for (const platform of FRPC_PLATFORMS) {
const ref = FRPC_RELEASES[platform]
expect(ref.url.startsWith('https://')).toBe(true)
expect(ref.url.endsWith('.tar.gz')).toBe(true)
expect(ref.version).toMatch(/^\d+\.\d+\.\d+$/)
expect(ref.sha256).toMatch(/^[0-9a-f]{64}$/)
}
})
it('pins REAL (non-placeholder) frp v0.61.1 checksums', () => {
for (const platform of FRPC_PLATFORMS) {
expect(FRPC_RELEASES[platform].sha256).not.toBe('0'.repeat(64))
}
})
})
describe('extractTarFileByBasename (tar path-traversal safe extractor)', () => {
it('returns the bytes of the first regular file whose basename matches', () => {
const tar = makeTar([
{ name: 'frp_x/', content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
expect(extractTarFileByBasename(tar, 'frps')).toEqual(FRPS_BYTES)
})
it('does NOT match a directory entry that shares the basename', () => {
const tar = makeTar([
{ name: 'frpc/', content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
})
it('throws when no matching file entry exists', () => {
const tar = makeTar([{ name: 'frp_x/frps', content: FRPS_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(TarExtractError)
})
it('REJECTS a matching entry whose name contains a ".." traversal segment', () => {
const tar = makeTar([{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|traversal|\.\./i)
})
it('REJECTS a matching entry with an absolute path name', () => {
const tar = makeTar([{ name: '/etc/frpc', content: FRPC_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|absolute/i)
})
it('throws on a corrupt (truncated) tar rather than reading out of bounds', () => {
const full = makeTar([{ name: 'frp_x/frpc', content: FRPC_BYTES }])
const truncated = full.subarray(0, TAR_BLOCK + 4) // header + partial content
expect(() => extractTarFileByBasename(truncated, 'frpc')).toThrow(TarExtractError)
})
})
describe('extractFrpcBinary (gunzip + extract)', () => {
it('gunzips then extracts the inner frpc bytes', () => {
expect(extractFrpcBinary(realisticFrpTarGz())).toEqual(FRPC_BYTES)
})
it('throws on non-gzip input', () => {
expect(() => extractFrpcBinary(new Uint8Array([1, 2, 3, 4]))).toThrow(TarExtractError)
})
})
describe('provisionFrpc (B3 verify-download + extract discipline)', () => {
it('selects the arch URL, VERIFIES the archive, and places the INNER frpc (not the archive)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/frp-darwin-arm64.tar.gz'
const releases = releaseOverride('darwin-arm64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state, fetchedUrls } = makeFakeDeps({ [url]: archive })
const result = await provisionFrpc(
{ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases },
deps,
)
expect(fetchedUrls).toEqual([url])
expect(result.binPath).toBe(BIN_PATH)
expect(result.version).toBe('0.61.1')
expect(result.platform).toBe('darwin-arm64')
// atomic place: renamed into the final path, made executable
expect(state.renames.some((r) => r.to === BIN_PATH)).toBe(true)
expect(state.chmods.some((c) => (c.mode & 0o111) !== 0)).toBe(true)
// the placed file is the EXTRACTED frpc binary, NOT the .tar.gz archive
const placed = state.writes.get(BIN_PATH)
expect(placed).toEqual(FRPC_BYTES)
expect(placed).not.toEqual(archive)
})
it('ignores the decoy frps entry and places frpc even when frps precedes it', async () => {
const archive = makeTarGz([
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
const url = 'https://example.test/frp.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps)
expect(state.writes.get(BIN_PATH)).toEqual(FRPC_BYTES)
})
it('REJECTS on sha256 mismatch and places NO binary (temp cleaned up, no extraction)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/frp-linux-amd64.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: 'f'.repeat(64), // deliberately wrong
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/sha-?256|hash|integrity|mismatch/i)
expect(state.renames).toEqual([])
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0) // unverified temp removed
})
it('never places or execs before verifying (mismatch leaves nothing executable)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/bad.tar.gz'
const releases = releaseOverride('linux-arm64', {
version: '0.61.1',
url,
sha256: '0'.repeat(64),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow()
expect(state.chmods.every((c) => c.path !== BIN_PATH)).toBe(true)
})
it('throws and places nothing when the verified archive has NO frpc entry', async () => {
const archive = makeTarGz([
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/LICENSE', content: new TextEncoder().encode('MIT') },
])
const url = 'https://example.test/no-frpc.tar.gz'
const releases = releaseOverride('darwin-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'darwin', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/extract|frpc|tar/i)
expect(state.renames).toEqual([])
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0) // temp removed on extraction failure
})
it('REJECTS a traversal frpc entry and never writes outside binDir', async () => {
const archive = makeTarGz([
{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES },
])
const url = 'https://example.test/evil.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow()
// nothing placed, and every write that ever happened stayed inside binDir
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.renames).toEqual([])
expect([...state.writes.keys()].every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
expect(state.removed.every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
})
it('REJECTS a hash-matching but corrupt (non-gzip) archive after the gate', async () => {
const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) // hashes fine, not a gzip
const url = 'https://example.test/corrupt.tar.gz'
const releases = releaseOverride('darwin-arm64', {
version: '0.61.1',
url,
sha256: sha256Hex(garbage),
})
const { deps, state } = makeFakeDeps({ [url]: garbage })
await expect(
provisionFrpc({ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/extract|gzip|tar/i)
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0)
})
it('throws a clear error on an unsupported platform (nothing fetched)', async () => {
const { deps, fetchedUrls } = makeFakeDeps({})
await expect(
provisionFrpc({ platform: 'win32', arch: 'x64', binDir: BIN_DIR }, deps),
).rejects.toThrow(/unsupported|platform/i)
expect(fetchedUrls).toEqual([])
})
})

100
agent/test/frpcToml.test.ts Normal file
View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { buildNativeFrpcToml, type NativeFrpcOptions } from '../src/transport/frpcToml.js'
const BASE: NativeFrpcOptions = {
subdomain: 'alice',
localPort: 3000,
authToken: 'super-secret-token',
certFile: '/home/alice/.web-terminal-agent/frpc.cert.pem',
keyFile: '/home/alice/.web-terminal-agent/frpc.key.pem',
trustedCaFile: '/home/alice/.web-terminal-agent/frps-ctrl-ca.pem',
}
describe('buildNativeFrpcToml (B2h)', () => {
it('emits the native-tunnel server/port/tls keys (PLAN_NATIVE_TUNNEL §4)', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).toContain('serverAddr = "8.138.1.192"')
expect(toml).toContain('serverPort = 443')
expect(toml).toContain('transport.tls.enable = true')
expect(toml).toContain('transport.tls.serverName = "frp.terminal.yaojia.wang"')
expect(toml).toContain('transport.tls.disableCustomTLSFirstByte = true')
expect(toml).toContain(`transport.tls.certFile = "${BASE.certFile}"`)
expect(toml).toContain(`transport.tls.keyFile = "${BASE.keyFile}"`)
expect(toml).toContain(`transport.tls.trustedCaFile = "${BASE.trustedCaFile}"`)
expect(toml).toContain('auth.method = "token"')
expect(toml).toContain('auth.token = "super-secret-token"')
})
it('emits a well-formed [[proxies]] http block bound to loopback', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).toContain('[[proxies]]')
expect(toml).toContain('type = "http"')
expect(toml).toContain('subdomain = "alice"')
expect(toml).toContain('localIP = "127.0.0.1"')
expect(toml).toContain('localPort = 3000')
// the proxy block comes after the server/tls preamble
expect(toml.indexOf('[[proxies]]')).toBeGreaterThan(toml.indexOf('serverAddr'))
})
it('does NOT emit the retired v0.8 [common]/tls_enable shape', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).not.toContain('[common]')
expect(toml).not.toContain('tls_enable')
})
it('honours a configurable serverAddr (default 8.138.1.192)', () => {
expect(buildNativeFrpcToml(BASE)).toContain('serverAddr = "8.138.1.192"')
expect(buildNativeFrpcToml({ ...BASE, serverAddr: '10.9.8.7' })).toContain(
'serverAddr = "10.9.8.7"',
)
})
it('THROWS when localIP is non-loopback (anti-SSRF hard invariant)', () => {
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '10.0.0.5' })).toThrow(/loopback/i)
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '0.0.0.0' })).toThrow(/loopback/i)
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '192.168.1.9' })).toThrow(/loopback/i)
})
it('accepts the three explicit loopback localIP forms', () => {
for (const ip of ['127.0.0.1', '::1', 'localhost']) {
expect(() => buildNativeFrpcToml({ ...BASE, localIP: ip })).not.toThrow()
}
})
it('rejects an empty or non-label-safe subdomain', () => {
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'has space' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '-bad' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'bad-' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a'.repeat(64) })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a/b' })).toThrow(/subdomain/i)
})
it('rejects an out-of-range or non-integer localPort', () => {
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 0 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 70000 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 3000.5 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: -1 })).toThrow(/port/i)
})
it('rejects missing keystore paths and an empty auth token', () => {
expect(() => buildNativeFrpcToml({ ...BASE, certFile: '' })).toThrow(/certFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: '' })).toThrow(/keyFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, trustedCaFile: '' })).toThrow(/trustedCaFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, authToken: '' })).toThrow(/token/i)
})
it('escapes backslashes and quotes in Windows-style paths (valid TOML basic string)', () => {
const winCert = 'C:\\Users\\alice\\.web-terminal-agent\\frpc.cert.pem'
const toml = buildNativeFrpcToml({ ...BASE, certFile: winCert })
expect(toml).toContain(
'transport.tls.certFile = "C:\\\\Users\\\\alice\\\\.web-terminal-agent\\\\frpc.cert.pem"',
)
})
it('rejects control characters in paths/token (TOML-injection guard)', () => {
expect(() => buildNativeFrpcToml({ ...BASE, authToken: 'a\nb' })).toThrow()
expect(() => buildNativeFrpcToml({ ...BASE, certFile: 'a\nb' })).toThrow()
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: 'a"b\nq' })).toThrow()
})
})

View File

@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { createPublicKey, verify } from 'node:crypto'
import {
computeEnrollFpr,
generateIdentity,
generateP256Identity,
identityFromPrivatePem,
p256IdentityFromPrivatePem,
verifySignature,
} from '../src/keys/identity.js'
@@ -50,4 +53,53 @@ describe('AgentIdentity (INV4)', () => {
// No function exports raw private key bytes onto the network surface.
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
})
it('keeps the Ed25519 alg tag (no P-256 regression)', () => {
expect(generateIdentity().alg).toBe('ed25519')
})
})
describe('P-256 AgentIdentity (FIX H-host-2 — native frp-client key)', () => {
it('generates distinct EC P-256 keypairs tagged alg=p256', () => {
const a = generateP256Identity()
const b = generateP256Identity()
expect(a.alg).toBe('p256')
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
// publicKey is the EC SubjectPublicKeyInfo DER (outer SEQUENCE), importable as a public key.
expect(a.publicKey[0]).toBe(0x30)
const pub = createPublicKey({ key: Buffer.from(a.publicKey), format: 'der', type: 'spki' })
expect(pub.asymmetricKeyType).toBe('ec')
expect(pub.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
})
it('sign produces a DER ECDSA signature that verifies under SHA-256', () => {
const id = generateP256Identity()
const msg = new TextEncoder().encode('certificationRequestInfo-bytes')
const sig = id.sign(msg)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
expect(verify('sha256', msg, pub, sig)).toBe(true)
expect(verify('sha256', new TextEncoder().encode('tampered'), pub, sig)).toBe(false)
})
it('enrollFpr is deterministic base64url(SHA-256(spki))', () => {
const id = generateP256Identity()
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
})
it('reloads the same P-256 identity from PEM (keystore load path)', () => {
const id = generateP256Identity()
const pem = id.exportPrivatePkcs8Pem()
const reloaded = p256IdentityFromPrivatePem(pem)
expect(reloaded.alg).toBe('p256')
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
})
it('security: P-256 identity exposes no raw private-key getter', () => {
const id = generateP256Identity()
expect('privateKey' in id).toBe(false)
// the PEM export is the only serialization surface; it is a PRIVATE key PEM (0600 keystore only).
expect(id.exportPrivatePkcs8Pem()).toContain('PRIVATE KEY')
})
})

View File

@@ -1,15 +1,18 @@
import { describe, expect, it } from 'vitest'
import type { AgentConfig } from '../src/config/agentConfig.js'
import {
BindHostError,
RootRefusedError,
assertNativeZone,
buildInstallOptions,
detectPlatform,
installService,
normalizeBindHost,
uninstallService,
type InstallDeps,
} from '../src/service/install.js'
import { buildLaunchdPlist } from '../src/service/launchd.js'
import { buildSystemdUnit } from '../src/service/systemd.js'
import { agentLabel, baseAppLabel, buildLaunchdPlist } from '../src/service/launchd.js'
import { agentUnitName, baseAppUnitName, buildSystemdUnit } from '../src/service/systemd.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
@@ -20,7 +23,12 @@ const CFG: AgentConfig = {
hostId: 'h-1',
}
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
type FakeDeps = InstallDeps & {
writes: Array<[string, string]>
runs: Array<[string, readonly string[]]>
}
function deps(uid = 501): FakeDeps {
const writes: Array<[string, string]> = []
const runs: Array<[string, readonly string[]]> = []
return {
@@ -37,6 +45,13 @@ function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs:
}
}
/** The unit content whose path contains `needle` (e.g. `'base-app'`, `'agent'`). */
function unitWith(d: FakeDeps, needle: string): string {
const hit = d.writes.find(([path]) => path.includes(needle))
if (!hit) throw new Error(`no unit written whose path contains '${needle}'`)
return hit[1]
}
describe('detectPlatform (T17)', () => {
it('maps darwin→launchd, linux→systemd, else null', () => {
expect(detectPlatform('darwin')).toBe('launchd')
@@ -45,143 +60,157 @@ describe('detectPlatform (T17)', () => {
})
})
describe('installService (T17)', () => {
describe('installService — least privilege (T17)', () => {
it('refuses to install as root (negative, least privilege)', async () => {
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
})
it('systemd: writes a run-as-user unit and enables it', async () => {
it('root refusal emits nothing', async () => {
const d = deps(0)
await expect(installService(CFG, 'systemd', d)).rejects.toBeInstanceOf(RootRefusedError)
expect(d.writes).toHaveLength(0)
expect(d.runs).toHaveLength(0)
})
})
describe('installService — two distinct units (FIX M-host-2service)', () => {
it('systemd: writes a base-app unit AND an agent unit, both run-as-user, both enabled', async () => {
const d = deps()
await installService(CFG, 'systemd', d)
const [, unit] = d.writes[0]!
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
expect(unit).toContain('User=alice')
expect(unit).not.toContain('User=root')
expect(unit).toContain('Restart=on-failure')
expect(d.runs[0]![0]).toBe('systemctl')
// exactly two units
expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName())
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback)
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
expect(baseApp).toContain('ExecStart=')
expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('web-terminal-agent run')
// both least-privilege + restart-on-failure
for (const unit of [baseApp, agent]) {
expect(unit).toContain('User=alice')
expect(unit).not.toContain('User=root')
expect(unit).toContain('Restart=on-failure')
}
// both enabled
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
it('launchd: writes a base-app plist AND an agent plist, both with KeepAlive, both loaded', async () => {
const d = deps()
await installService(CFG, 'launchd', d)
const [path, plist] = d.writes[0]!
expect(path).toContain('LaunchAgents')
expect(plist).toContain('<string>run</string>')
expect(plist).toContain('<key>KeepAlive</key>')
expect(d.runs[0]![0]).toBe('launchctl')
expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppLabel())
const agent = unitWith(d, agentLabel())
expect(agent).toContain('<string>run</string>')
expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('<string>run</string>')
for (const plist of [baseApp, agent]) {
expect(plist).toContain('<key>KeepAlive</key>')
}
expect(d.writes.every(([path]) => path.includes('LaunchAgents'))).toBe(true)
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('uninstall unloads cleanly', async () => {
it('routes base-app env to the base-app unit ONLY (never onto the agent unit)', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } })
const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).toContain('Environment="PORT=3000"')
// the agent unit must NOT carry the base-app env
expect(agent).not.toContain('BIND_HOST')
expect(agent).not.toContain('PORT=3000')
})
it('uninstall tears down BOTH units (launchd unload)', async () => {
const d = deps()
await uninstallService('launchd', d)
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
const targets = d.runs.map(([, args]) => args[args.length - 1])
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
expect(targets.some((t) => t?.includes(baseAppLabel()))).toBe(true)
expect(targets.some((t) => t?.includes(agentLabel()))).toBe(true)
})
it('uninstall tears down BOTH units (systemd disable)', async () => {
const d = deps()
await uninstallService('systemd', d)
const units = d.runs.map(([, args]) => args[args.length - 1])
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(units).toContain(baseAppUnitName())
expect(units).toContain(agentUnitName())
})
})
const TUNNEL_ENV = {
BIND_HOST: '127.0.0.1',
ALLOWED_ORIGINS: 'https://t1.terminal.yaojia.wang',
PORT: '3000',
} as const
describe('BIND_HOST loopback S-GATE (FIX C-host-1, CRITICAL)', () => {
it('normalizeBindHost defaults an absent value to loopback', () => {
expect(normalizeBindHost(undefined)).toBe('127.0.0.1')
expect(normalizeBindHost('')).toBe('127.0.0.1')
})
describe('env injection into the writers (PLAN_NATIVE_TUNNEL S2)', () => {
it('launchd: default (no options) omits the EnvironmentVariables block', async () => {
it('normalizeBindHost accepts loopback forms (127.0.0.0/8, ::1, localhost)', () => {
expect(normalizeBindHost('127.0.0.1')).toBe('127.0.0.1')
expect(normalizeBindHost('127.0.0.2')).toBe('127.0.0.2')
expect(normalizeBindHost('::1')).toBe('::1')
expect(normalizeBindHost('localhost')).toBe('localhost')
})
it('normalizeBindHost REJECTS 0.0.0.0 and other non-loopback values', () => {
expect(() => normalizeBindHost('0.0.0.0')).toThrow(BindHostError)
expect(() => normalizeBindHost('192.168.1.10')).toThrow(BindHostError)
expect(() => normalizeBindHost('::')).toThrow(BindHostError)
})
it('REGRESSION: rejects a suffixed hostname that merely starts with 127. (S-GATE bypass)', () => {
// These are hostnames, not loopback literals — Node would DNS-resolve them before bind().
expect(() => normalizeBindHost('127.0.0.1.attacker.example.com')).toThrow(BindHostError)
expect(() => normalizeBindHost('127.evil.net')).toThrow(BindHostError)
expect(() => normalizeBindHost('127.0.0.1x')).toThrow(BindHostError)
expect(() => buildInstallOptions({ BIND_HOST: '127.0.0.1.attacker.example.com' })).toThrow(
BindHostError,
)
})
it('buildInstallOptions throws on BIND_HOST=0.0.0.0 (fail-closed at env read)', () => {
expect(() => buildInstallOptions({ BIND_HOST: '0.0.0.0' })).toThrow(BindHostError)
})
it('NEGATIVE: installService with BIND_HOST=0.0.0.0 throws AND emits nothing', async () => {
const d = deps()
await installService(CFG, 'launchd', d)
const [, plist] = d.writes[0]!
expect(plist).not.toContain('EnvironmentVariables')
await expect(
installService(CFG, 'systemd', d, { env: { BIND_HOST: '0.0.0.0', PORT: '3000' } }),
).rejects.toBeInstanceOf(BindHostError)
expect(d.writes).toHaveLength(0)
expect(d.runs).toHaveLength(0)
})
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', async () => {
it('NEGATIVE (launchd): a 0.0.0.0 install emits no plist', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { env: TUNNEL_ENV })
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist).toContain('<key>BIND_HOST</key>')
expect(plist).toContain('<string>127.0.0.1</string>')
// keys are sorted (ALLOWED_ORIGINS before BIND_HOST before PORT)
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
await expect(
installService(CFG, 'launchd', d, { env: { BIND_HOST: '0.0.0.0' } }),
).rejects.toBeInstanceOf(BindHostError)
expect(d.writes).toHaveLength(0)
})
it('launchd: escapes XML-significant characters in env values', () => {
const plist = buildLaunchdPlist('/bin/agent', { X: `a&b<c>d"e'f` })
expect(plist).toContain('<string>a&amp;b&lt;c&gt;d&quot;e&apos;f</string>')
expect(plist).not.toContain('a&b<c>d')
})
it('systemd: default (no options) omits Environment lines', async () => {
it('the emitted base-app unit can NEVER contain BIND_HOST=0.0.0.0 (normalized when absent)', async () => {
const d = deps()
await installService(CFG, 'systemd', d)
const [, unit] = d.writes[0]!
expect(unit).not.toContain('Environment')
})
it('systemd: emits sorted, quoted Environment= lines from the env map', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV })
const [, unit] = d.writes[0]!
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(unit).toContain('Environment="PORT=3000"')
expect(unit.indexOf('ALLOWED_ORIGINS')).toBeLessThan(unit.indexOf('BIND_HOST'))
})
it('systemd: emits EnvironmentFile= (before inline Environment) when a path is given', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV, envFile: '/etc/web-terminal.env' })
const [, unit] = d.writes[0]!
expect(unit).toContain('EnvironmentFile=/etc/web-terminal.env')
expect(unit.indexOf('EnvironmentFile=')).toBeLessThan(unit.indexOf('Environment='))
})
it('systemd: escapes backslash and double-quote in Environment values', () => {
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
await installService(CFG, 'systemd', d, { env: { PORT: '3000' } })
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).not.toContain('0.0.0.0')
})
})
describe('tunnel-origin derivation (PLAN_NATIVE_TUNNEL S2)', () => {
it('merges https://<subdomain>.<zone>.<domain> into ALLOWED_ORIGINS when domain is given', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>ALLOWED_ORIGINS</key>')
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
})
it('defaults to the `term` zone when only a domain is supplied', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang' })
const [, plist] = d.writes[0]!
expect(plist).toContain('<string>https://host-42.term.yaojia.wang</string>')
})
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { ALLOWED_ORIGINS: 'https://keep.me' },
domain: 'yaojia.wang',
zone: 'terminal',
})
const [, unit] = d.writes[0]!
expect(unit).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
})
it('does not derive an origin when the config has no subdomain', async () => {
const d = deps()
await installService({ ...CFG, subdomain: null }, 'launchd', d, { domain: 'yaojia.wang' })
const [, plist] = d.writes[0]!
expect(plist).not.toContain('ALLOWED_ORIGINS')
})
})
describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/S2)', () => {
describe('buildInstallOptions — env → InstallOptions (S0/S2 + S-GATE)', () => {
it('defaults BIND_HOST to loopback so a tunnel install is never LAN-exposed (S0/R2)', () => {
const options = buildInstallOptions({})
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1' })
})
it('honours an explicit BIND_HOST and passes through the S0 base-app env vars', () => {
it('honours an explicit loopback BIND_HOST and passes through the S0 base-app env vars', () => {
const options = buildInstallOptions({
BIND_HOST: '127.0.0.2',
PORT: '3000',
@@ -189,6 +218,8 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
IDLE_TTL: '86400',
USE_TMUX: '1',
ALLOWED_ORIGINS: 'https://keep.me',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
expect(options.env).toEqual({
BIND_HOST: '127.0.0.2',
@@ -197,6 +228,17 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
IDLE_TTL: '86400',
USE_TMUX: '1',
ALLOWED_ORIGINS: 'https://keep.me',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
})
it('AG3: passes SCROLLBACK_BYTES and MAX_PAYLOAD_BYTES through as base-app config', () => {
const options = buildInstallOptions({ SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576' })
expect(options.env).toEqual({
BIND_HOST: '127.0.0.1',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
})
@@ -212,7 +254,11 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
})
it('lets TUNNEL_ZONE override the origin zone and carries AGENT_ENV_FILE through', () => {
const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang', TUNNEL_ZONE: 'term', AGENT_ENV_FILE: '/etc/wt.env' })
const options = buildInstallOptions({
TUNNEL_DOMAIN: 'yaojia.wang',
TUNNEL_ZONE: 'term',
AGENT_ENV_FILE: '/etc/wt.env',
})
expect(options.zone).toBe('term')
expect(options.envFile).toBe('/etc/wt.env')
})
@@ -224,46 +270,151 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
})
})
describe('install CLI seam end-to-end — resolved env reaches the units (PLAN_NATIVE_TUNNEL S2)', () => {
// Env the operator would export before `web-terminal-agent install` on a tunnel host.
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
it('launchd: the plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist).toContain('<key>BIND_HOST</key>')
expect(plist).toContain('<string>127.0.0.1</string>')
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
expect(plist).toContain('<key>PORT</key>')
expect(plist).not.toContain('0.0.0.0')
describe('tunnel-origin derivation into the base-app unit (FIX L-host-zone)', () => {
it('assertNativeZone accepts `terminal` and rejects `term`/undefined', () => {
expect(() => assertNativeZone('terminal')).not.toThrow()
expect(() => assertNativeZone('term')).toThrow(/terminal/)
expect(() => assertNativeZone(undefined)).toThrow(/terminal/)
})
it('systemd: the unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
it('merges https://<sub>.terminal.<domain> into the base-app ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
const [, unit] = d.writes[0]!
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(unit).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
expect(unit).toContain('Environment="PORT=3000"')
expect(unit).not.toContain('0.0.0.0')
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).toContain('<key>ALLOWED_ORIGINS</key>')
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
})
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { ALLOWED_ORIGINS: 'https://keep.me' },
domain: 'yaojia.wang',
zone: 'terminal',
})
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
})
it('does not derive an origin when the config has no subdomain', async () => {
const d = deps()
await installService({ ...CFG, subdomain: null }, 'launchd', d, {
domain: 'yaojia.wang',
zone: 'terminal',
})
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).not.toContain('ALLOWED_ORIGINS')
})
})
describe('systemd env value hardening (LOW: control-char injection)', () => {
it('rejects a newline in an env value so it cannot inject a [Service] directive', () => {
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\nExecStartPre=/x' } })).toThrow(
describe('install CLI seam end-to-end — resolved env reaches the base-app unit (S2)', () => {
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
it('launchd: the base-app plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).toContain('<key>BIND_HOST</key>')
expect(baseApp).toContain('<string>127.0.0.1</string>')
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
expect(baseApp).toContain('<key>PORT</key>')
expect(baseApp).not.toContain('0.0.0.0')
})
it('systemd: the base-app unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
expect(baseApp).toContain('Environment="PORT=3000"')
expect(baseApp).not.toContain('0.0.0.0')
})
it('systemd: emits EnvironmentFile= (before inline Environment) on the base-app unit', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
envFile: '/etc/web-terminal.env',
})
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('EnvironmentFile=/etc/web-terminal.env')
expect(baseApp.indexOf('EnvironmentFile=')).toBeLessThan(baseApp.indexOf('Environment='))
})
})
describe('unit writers — escaping & control-char hardening', () => {
it('launchd: escapes XML-significant characters in env values', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'], { X: `a&b<c>d"e'f` })
expect(plist).toContain('<string>a&amp;b&lt;c&gt;d&quot;e&apos;f</string>')
expect(plist).not.toContain('a&b<c>d')
})
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'], {
BIND_HOST: '127.0.0.1',
ALLOWED_ORIGINS: 'https://a',
PORT: '3000',
})
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
})
it('launchd: no env → no EnvironmentVariables block', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'])
expect(plist).not.toContain('EnvironmentVariables')
})
it('systemd: escapes backslash and double-quote in Environment values', () => {
const unit = buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
})
it('systemd: rejects a newline in an env value (no [Service] directive injection)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\nExecStartPre=/x' } }),
).toThrow(/control character/)
})
it('systemd: rejects a carriage return in an env value', () => {
expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\rb' } })).toThrow(
/control character/,
)
})
it('rejects a carriage return in an env value', () => {
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\rb' } })).toThrow(/control character/)
it('AG2: rejects a newline in the ExecStart command (no [Service] directive injection)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run\nExecStartPre=/x', 'alice'),
).toThrow(/control character/)
})
it('still accepts ordinary values with quotes and backslashes', () => {
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
it('AG2: rejects a newline in the User field', () => {
expect(() => buildSystemdUnit('/bin/agent run', 'alice\nExecStartPre=/x')).toThrow(
/control character/,
)
})
it('AG2: rejects a newline in the Description field', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', {}, 'desc\n[Service]\nExecStartPre=/x'),
).toThrow(/control character/)
})
it('AG2: rejects a newline in the EnvironmentFile path', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { envFile: '/etc/x.env\nExecStartPre=/y' }),
).toThrow(/control character/)
})
it('AG2: rejects a newline in an Environment KEY (not just the value)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { env: { 'X\nExecStartPre=/y': 'v' } }),
).toThrow(/control character/)
})
it('systemd: default (no env) omits Environment lines', () => {
const unit = buildSystemdUnit('/bin/agent run', 'alice')
expect(unit).not.toContain('Environment')
})
})

View File

@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { generateIdentity } from '../src/keys/identity.js'
import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
const dirs: string[] = []
@@ -67,4 +68,50 @@ describe('Keystore (INV4/INV5)', () => {
mkdirSync(dir, { mode: 0o755 })
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
})
it('keeps the Ed25519 identity byte-identical across save/load (no P-256 regression)', () => {
const dir = freshDir()
const ks = openKeystore(dir)
const id = generateIdentity()
ks.saveIdentity(id)
const reloaded = ks.loadIdentity()
expect(reloaded!.alg).toBe('ed25519')
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
})
it('FIX H-host-2: round-trips a P-256 frp-client identity (alg + usable signing key)', () => {
const dir = freshDir()
const ks = openKeystore(dir)
const id = generateP256Identity()
ks.saveIdentity(id)
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
const reloaded = ks.loadIdentity()
expect(reloaded).not.toBeNull()
// loadIdentity() branched on the stored key's alg discriminant → P-256, not Ed25519.
expect(reloaded!.alg).toBe('p256')
// EC SubjectPublicKeyInfo DER (outer SEQUENCE) preserved exactly.
expect(reloaded!.publicKey[0]).toBe(0x30)
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
// The reloaded key still signs: a DER ECDSA signature that verifies under the original pubkey.
const msg = new TextEncoder().encode('csr-bytes')
const sig = reloaded!.sign(msg)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
expect(verify('sha256', msg, pub, sig)).toBe(true)
})
it('AG1: rejects a stored EC key on a non-P256 curve (e.g. secp384r1) with a clear error', () => {
const dir = freshDir()
const ks = openKeystore(dir)
// Plant a valid PKCS#8 EC key on the WRONG curve directly at the key path — `ec` alone must not
// be mistaken for P-256; loadIdentity must assert the named curve and fail closed.
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'secp384r1' })
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
writeFileSync(join(dir, 'agent.key.pem'), pem, { mode: 0o600 })
expect(() => ks.loadIdentity()).toThrow(KeystoreError)
expect(() => ks.loadIdentity()).toThrow(/prime256v1|P-256|curve/)
})
})

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { isLoopbackHostLiteral } from '../src/net/loopbackLiteral.js'
describe('isLoopbackHostLiteral — strict loopback-literal check (FIX C-host-1 / anti-SSRF)', () => {
it('accepts exact loopback literals', () => {
expect(isLoopbackHostLiteral('localhost')).toBe(true)
expect(isLoopbackHostLiteral('::1')).toBe(true)
expect(isLoopbackHostLiteral('[::1]')).toBe(true)
})
it('accepts any well-formed IPv4 in 127.0.0.0/8', () => {
expect(isLoopbackHostLiteral('127.0.0.1')).toBe(true)
expect(isLoopbackHostLiteral('127.0.0.2')).toBe(true)
expect(isLoopbackHostLiteral('127.5.5.5')).toBe(true)
expect(isLoopbackHostLiteral('127.255.255.255')).toBe(true)
})
it('rejects non-loopback IPs and wildcards', () => {
expect(isLoopbackHostLiteral('0.0.0.0')).toBe(false)
expect(isLoopbackHostLiteral('192.168.1.10')).toBe(false)
expect(isLoopbackHostLiteral('10.0.0.5')).toBe(false)
expect(isLoopbackHostLiteral('::')).toBe(false)
})
it('REJECTS suffixed hostnames that merely start with 127. (the S-GATE bypass)', () => {
expect(isLoopbackHostLiteral('127.0.0.1.attacker.example.com')).toBe(false)
expect(isLoopbackHostLiteral('127.evil.net')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.1.evil.example.com')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.1x')).toBe(false)
})
it('rejects malformed / non-dotted-quad partials and out-of-range octets', () => {
expect(isLoopbackHostLiteral('127.1')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.256')).toBe(false)
expect(isLoopbackHostLiteral('0127.0.0.1')).toBe(false)
expect(isLoopbackHostLiteral('')).toBe(false)
expect(isLoopbackHostLiteral('127')).toBe(false)
})
})

216
agent/test/probe.test.ts Normal file
View File

@@ -0,0 +1,216 @@
import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_CERT_RENEW_WINDOW_MS,
certIsFresh,
frpcProxyStarted,
probeLoopbackBaseApp,
renderHealthStatus,
runHealthProbe,
startHealthMonitor,
type HealthProbeSeams,
type HealthReport,
type IntervalTimer,
} from '../src/health/probe.js'
/** All-passing seams; each test overrides exactly one to prove sub-check independence. */
function healthySeams(over: Partial<HealthProbeSeams> = {}): HealthProbeSeams {
return {
isFrpcAlive: () => true,
probeBaseApp: async () => true,
readFrpcLog: () => 'proxy [web-terminal] start proxy success',
certNotAfter: () => new Date('2026-08-01T00:00:00Z'),
now: () => new Date('2026-07-08T00:00:00Z'),
...over,
}
}
describe('frpcProxyStarted (log scan)', () => {
it('detects the frpc start-proxy-success line', () => {
expect(frpcProxyStarted('2026/07/08 [I] [proxy_manager] start proxy success')).toBe(true)
})
it('is false before frpc reports success', () => {
expect(frpcProxyStarted('login to server success\nstart proxy ...')).toBe(false)
expect(frpcProxyStarted('')).toBe(false)
})
})
describe('certIsFresh (near-expiry check)', () => {
const now = new Date('2026-07-08T00:00:00Z')
it('is fresh when notAfter is beyond the renewal window', () => {
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS + 60_000)
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(true)
})
it('is NOT fresh when notAfter is inside the renewal window', () => {
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS - 60_000)
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
})
it('treats a missing cert (null notAfter) as not fresh', () => {
expect(certIsFresh(null, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
})
})
describe('probeLoopbackBaseApp (loopback-only)', () => {
it('targets 127.0.0.1:PORT and returns true on an ok response', async () => {
const fetchImpl = vi.fn(async (url: string) => ({ ok: url.includes('127.0.0.1:3000') }))
await expect(probeLoopbackBaseApp(3000, fetchImpl)).resolves.toBe(true)
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:3000/')
})
it('returns false on a non-ok response', async () => {
await expect(probeLoopbackBaseApp(3000, async () => ({ ok: false }))).resolves.toBe(false)
})
it('swallows a rejected fetch (a probe never throws)', async () => {
await expect(
probeLoopbackBaseApp(3000, async () => {
throw new Error('ECONNREFUSED')
}),
).resolves.toBe(false)
})
it('rejects an out-of-range port without fetching', async () => {
const fetchImpl = vi.fn(async () => ({ ok: true }))
await expect(probeLoopbackBaseApp(0, fetchImpl)).resolves.toBe(false)
await expect(probeLoopbackBaseApp(70000, fetchImpl)).resolves.toBe(false)
expect(fetchImpl).not.toHaveBeenCalled()
})
})
describe('runHealthProbe (aggregate verdict)', () => {
it('is healthy when all four sub-checks pass', async () => {
const report = await runHealthProbe(healthySeams())
expect(report).toEqual<HealthReport>({
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
})
})
it('is unhealthy if frpc is dead', async () => {
const report = await runHealthProbe(healthySeams({ isFrpcAlive: () => false }))
expect(report.frpcAlive).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the base app is unreachable', async () => {
const report = await runHealthProbe(healthySeams({ probeBaseApp: async () => false }))
expect(report.baseAppReachable).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the proxy never started', async () => {
const report = await runHealthProbe(healthySeams({ readFrpcLog: () => 'connecting...' }))
expect(report.proxyStarted).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the cert is near expiry', async () => {
const report = await runHealthProbe(
healthySeams({
certNotAfter: () => new Date('2026-07-08T01:00:00Z'), // 1h out, inside 8h window
}),
)
expect(report.certFresh).toBe(false)
expect(report.healthy).toBe(false)
})
})
describe('renderHealthStatus (INV9 — non-secret only)', () => {
const report: HealthReport = {
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
}
it('prints subdomain, host id, expiry date, and flags', () => {
const lines = renderHealthStatus(
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
report,
)
const joined = lines.join('\n')
expect(joined).toContain('subdomain: alice')
expect(joined).toContain('host_id: h-1')
expect(joined).toContain('cert_expiry: 2026-08-01T00:00:00.000Z')
expect(joined).toContain('healthy: true')
})
it('leaks NO key/cert/token/CSR material', () => {
const lines = renderHealthStatus(
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
report,
)
const joined = lines.join('\n')
expect(joined).not.toMatch(/PRIVATE KEY|BEGIN CERTIFICATE|BEGIN CERTIFICATE REQUEST/)
expect(joined.toLowerCase()).not.toMatch(/token|secret|csr|pem/)
})
it('renders (none)/(unknown) placeholders when identifiers are absent', () => {
const lines = renderHealthStatus({ subdomain: null, hostId: null, certNotAfter: null }, report)
const joined = lines.join('\n')
expect(joined).toContain('subdomain: (none)')
expect(joined).toContain('cert_expiry: (unknown)')
})
})
describe('startHealthMonitor (periodic)', () => {
function fakeTimer(): { timer: IntervalTimer; fire: () => void; cleared: boolean } {
let cb: (() => void) | null = null
const state = { cleared: false }
return {
timer: {
setInterval: (fn) => {
cb = fn
return 1
},
clearInterval: () => {
state.cleared = true
},
},
fire: () => cb?.(),
get cleared() {
return state.cleared
},
}
}
it('runs the probe on each tick and reports it', async () => {
const report: HealthReport = {
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
}
const probe = vi.fn(async () => report)
const seen: HealthReport[] = []
const ft = fakeTimer()
const monitor = startHealthMonitor(probe, (r) => seen.push(r), { timer: ft.timer })
ft.fire()
await Promise.resolve()
await Promise.resolve()
expect(probe).toHaveBeenCalledTimes(1)
expect(seen).toEqual([report])
monitor.stop()
expect(ft.cleared).toBe(true)
})
it('swallows a rejected probe (monitor never crashes)', async () => {
const ft = fakeTimer()
const onReport = vi.fn()
startHealthMonitor(async () => Promise.reject(new Error('boom')), onReport, { timer: ft.timer })
ft.fire()
await Promise.resolve()
await Promise.resolve()
expect(onReport).not.toHaveBeenCalled()
})
})