/** * 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 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 (` 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' /** 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 const DEL_CODE = 0x7f export interface SystemdUnitOptions { /** Inline env map → one `Environment="KEY=value"` line each (keys sorted, values escaped). */ readonly env?: ServiceEnv /** Path referenced by a single `EnvironmentFile=` line (preferred over inline for secrets). */ 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 AGENT_UNIT } 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 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) { const code = char.codePointAt(0) if (code === undefined) continue if (code < FIRST_PRINTABLE_ASCII || code === DEL_CODE) return true } return false } /** * 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 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}"` } /** Build the `EnvironmentFile=` / `Environment=` lines (empty array when neither is supplied). */ function environmentLines(options: SystemdUnitOptions): readonly string[] { const lines: string[] = [] 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)}`) } return lines } /** * Build a unit whose `ExecStart` is `execStart` (a full command line, e.g. ` run` or * `node dist/server.js`); Restart=on-failure; User= (never root). When `options.env`/ * `options.envFile` are supplied, the per-host tunnel env is injected. Pure/immutable. */ export function buildSystemdUnit( 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=${description}`, 'After=network-online.target', '', '[Service]', 'Type=simple', `ExecStart=${execStart}`, 'Restart=on-failure', 'RestartSec=1', `User=${user}`, ...environmentLines(options), '', '[Install]', 'WantedBy=default.target', '', ].join('\n') } export function systemdEnableCommand( unitName: string = AGENT_UNIT, ): { cmd: string; args: readonly string[] } { return { cmd: 'systemctl', args: ['--user', 'enable', '--now', unitName] } } export function systemdDisableCommand( unitName: string = AGENT_UNIT, ): { cmd: string; args: readonly string[] } { return { cmd: 'systemctl', args: ['--user', 'disable', '--now', unitName] } }