/** * 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 { dirname, join } from 'node:path' import type { AgentConfig } from '../config/agentConfig.js' import { agentLabel, baseAppLabel, buildLaunchdPlist, launchdLoadCommand, launchdPlistPath, launchdUnloadCommand, type ServiceEnv, } from './launchd.js' import { agentUnitName, baseAppUnitName, buildSystemdUnit, systemdDisableCommand, systemdEnableCommand, systemdUnitPath, 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. `env` is the BASE-APP env (routed to the * base-app unit only); `baseAppExec` overrides the base-app `ExecStart` argv. */ export interface InstallOptions { /** 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 /** DNS zone label for the tunnel origin (`terminal` for native-tunnel hosts, default `term`). */ 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 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, 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://.terminal.`; 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://.terminal. [FIX L-host-zone]', ) } } /** * Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2). * 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( BASE_APP_ENV_KEYS.map((key) => [key, env[key]] as [string, string | undefined]).filter( (entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0, ), ) // 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] } : {}), } } export class RootRefusedError extends Error { constructor() { super('refusing to install the agent service as root — run as the logged-in user (least privilege)') this.name = 'RootRefusedError' } } export interface InstallDeps { writeFile(path: string, content: string): void runCommand(cmd: string, args: readonly string[]): Promise getuid(): number homedir(): string username(): string binPath(): string } /** Map a Node platform to its service manager, or null if unsupported. */ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null { if (os === 'darwin') return 'launchd' if (os === 'linux') return 'systemd' return null } /** * 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://..` into ALLOWED_ORIGINS — * never weakening an origin the caller already provided. Immutable. */ function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv { const base = options.env ?? {} 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 { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) } } /** * Write + load BOTH service units for `platform`: the base-app (`node dist/server.js` + base-app * env) and the agent (` 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, deps: InstallDeps, options: InstallOptions = {}, ): Promise { 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 rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC // launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node` // symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node // (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc // subprocesses — resolve their tools. const nodePath = process.execPath const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin` const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath } // The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front // (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the // agent's own runtime config (NOT the base-app env) alongside PATH. const agentEnv: Record = { PATH: unitPath, ENROLL_URL: cfg.enrollUrl, RELAY_URL: cfg.relayUrl, STATE_DIR: cfg.stateDir, LOCAL_TARGET_URL: cfg.localTargetUrl, } if (platform === 'launchd') { const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel()) deps.writeFile( baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')), ) const agentPath = launchdPlistPath(deps.homedir(), agentLabel()) deps.writeFile( agentPath, buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel(), join(cfg.stateDir, 'agent.log')), ) for (const path of [baseAppPath, agentPath]) { const { cmd, args } = launchdLoadCommand(path) await deps.runCommand(cmd, args) } return } const baseAppOptions: SystemdUnitOptions = options.envFile ? { env: baseAppEnvWithPath, envFile: options.envFile } : { env: baseAppEnvWithPath } 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(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, 'web-terminal host agent (frpc supervisor)'), ) for (const unit of [baseAppUnitName(), agentUnitName()]) { const { cmd, args } = systemdEnableCommand(unit) await deps.runCommand(cmd, args) } } /** Unload/disable BOTH service units for `platform` (agent first, then base-app). */ export async function uninstallService( platform: ServicePlatform, deps: Pick, ): Promise { if (platform === 'launchd') { for (const label of [agentLabel(), baseAppLabel()]) { const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label)) await deps.runCommand(cmd, args) } return } for (const unit of [agentUnitName(), baseAppUnitName()]) { const { cmd, args } = systemdDisableCommand(unit) await deps.runCommand(cmd, args) } }