Fixes found deploying `pair --install` against the live control-plane: - native enroll tolerates hostContentSecret:null and converts the base64-DER cert + caChain[] the CP returns into PEM for frpc/keystore - launchd/systemd units use an absolute node (process.execPath) + a PATH with /usr/local/bin (bare `node` died with EX_CONFIG 78) - the agent unit now carries ENROLL_URL/RELAY_URL/STATE_DIR/LOCAL_TARGET_URL (`run` loadConfig()-validates them; missing → supervisor exited 1) 281 tests pass. Follow-up: add unit tests for the native-enroll tolerance + agent-unit env (only install node-path is covered so far).
274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
/**
|
|
* 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 } 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://<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 — 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<void>
|
|
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://<subdomain>.<zone>.<domain>` 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 (`<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,
|
|
deps: InstallDeps,
|
|
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 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<string, string> = {
|
|
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()))
|
|
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
|
|
deps.writeFile(agentPath, buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel()))
|
|
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<InstallDeps, 'runCommand' | 'homedir'>,
|
|
): Promise<void> {
|
|
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)
|
|
}
|
|
}
|