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:
@@ -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': {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
182
agent/src/health/probe.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
29
agent/src/net/loopbackLiteral.ts
Normal file
29
agent/src/net/loopbackLiteral.ts
Normal 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'
|
||||
}
|
||||
259
agent/src/provision/frpcBinary.ts
Normal file
259
agent/src/provision/frpcBinary.ts
Normal 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 }
|
||||
}
|
||||
159
agent/src/provision/untar.ts
Normal file
159
agent/src/provision/untar.ts
Normal 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})`)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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] }
|
||||
}
|
||||
|
||||
200
agent/src/transport/frpSupervise.ts
Normal file
200
agent/src/transport/frpSupervise.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
155
agent/src/transport/frpcToml.ts
Normal file
155
agent/src/transport/frpcToml.ts
Normal 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')
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
103
agent/test/deps.test.ts
Normal 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 child’s 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
148
agent/test/frpSupervise.test.ts
Normal file
148
agent/test/frpSupervise.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
416
agent/test/frpcBinary.test.ts
Normal file
416
agent/test/frpcBinary.test.ts
Normal 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
100
agent/test/frpcToml.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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&b<c>d"e'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&b<c>d"e'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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
39
agent/test/loopbackLiteral.test.ts
Normal file
39
agent/test/loopbackLiteral.test.ts
Normal 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
216
agent/test/probe.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user