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')
|
||||
}
|
||||
Reference in New Issue
Block a user