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