diff --git a/CLAUDE.md b/CLAUDE.md index a74b624..accbfde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,11 +12,11 @@ Verifiable Bun worker node runtime for the Consensus network. Written from scrat Each top-level lifecycle phase has its own entry file under `src/` and a matching `bun run` script: -- `bun run start` — runtime server (`src/instance.ts`), **loopback-only by default** (`NODE_HOST=127.0.0.1`, port `:9090`); serves local operator endpoints (`/health`, `/node/*`) plus a now-dormant `/connect` route. In production it runs alongside the control tunnel as one unit (`scripts/run-node.sh`). The client-facing data plane rides the **control tunnel** via the orchestrator node-gateway, so the node opens no inbound port and terminates no TLS — see `deploy/README.md`. +- `bun run start` — runtime server (`src/instance.ts`), **loopback-only by default** (`NODE_HOST=127.0.0.1`, port `:9090`); serves local operator endpoints (`/health`, `/node/*`) plus a now-dormant `/connect` route. In production it runs alongside the control tunnel as one unit (`src/supervise.ts`). The client-facing data plane rides the **control tunnel** via the orchestrator node-gateway, so the node opens no inbound port and terminates no TLS — see `deploy/README.md`. - `bun run setup` — interactive join wizard (recommended path; orchestrates eval → register → verify). - `bun run eval` — encrypted eval over the tunnel; passing eval writes `join-auth.json` into the state dir. - `bun run register` — submit join payload (requires `join-auth.json` from a prior eval). -- `bun run control` — long-lived encrypted control tunnel with exponential reconnect. This is the node's whole data path: heartbeats, proxy work, **and** the client-facing data plane, which the orchestrator node-gateway bridges onto its streams (`{kind:"data-plane"}` → `serveDataConnection`, via `src/clients/data-plane-stream.ts`). In production it runs **together with the runtime server** under one supervised unit (`scripts/run-node.sh`, which the PM2/systemd/launchd configs exec). `scripts/run-control.sh` (control-only) is kept for reference. +- `bun run control` — long-lived encrypted control tunnel with exponential reconnect. This is the node's whole data path: heartbeats, proxy work, **and** the client-facing data plane, which the orchestrator node-gateway bridges onto its streams (`{kind:"data-plane"}` → `serveDataConnection`, via `src/clients/data-plane-stream.ts`). In production it runs **together with the runtime server** under one supervised unit (`src/supervise.ts`, which the PM2/systemd configs exec). `scripts/run-control.sh` (control-only) is kept for reference. - `bun run verify` — server-side check that the registered node key signs the local manifest. - `bun run update` / `bun run update -- --download` — compare local manifest to server `/update/latest`; optional verified download. - `bun run release -- --version X --commit … --platform … --download-url …` — produce tarball + admin manifest in `dist/`. @@ -91,9 +91,9 @@ Hosted by `runtime/server.ts` (Fastify + `@fastify/websocket`) and the same eval `src/release.ts` builds a tarball, signs a `ReleaseManifest` (`src/types.ts`), and emits an `/admin/manifest` payload that the Consensus server consumes to gate updates. GitHub Actions' `Release` workflow is manual. In production: -- `ecosystem.config.cjs` configures PM2 to run `/current/scripts/run-node.sh`, which runs the control tunnel (`bun run control`, the data path) and a loopback-only runtime server (`bun run start`) as one unit and exits if either does, so an `update_apply` (or a crash) restarts both from the refreshed `current`. The client-facing data plane is bridged onto the control tunnel by the orchestrator node-gateway, so the node opens no inbound port and terminates no TLS. The `systemd/` and `launchd/` units exec the same script. +- `ecosystem.config.cjs` configures PM2 to run `/current/src/supervise.ts`, which runs the control tunnel (`bun run control`, the data path) and a loopback-only runtime server (`bun run start`) as one unit and exits if either does, so an `update_apply` (or a crash) restarts both from the refreshed `current`. The client-facing data plane is bridged onto the control tunnel by the orchestrator node-gateway, so the node opens no inbound port and terminates no TLS. The `systemd/` unit execs the same entry point via its `#!/usr/bin/env bun` shebang, and the macOS LaunchDaemon runs `pm2-runtime` against this same config. - `scripts/install-release.sh` is the default installer: unpacks the verified tarball into `releases//`, installs prod deps with the lockfile, atomically moves the `current` symlink, then prunes old releases per `CONSENSUS_NODE_RELEASE_RETENTION` (default 3) — while protecting the release that is mid-update. -- `scripts/ensure-pm2.sh` and `scripts/start-pm2.sh` bootstrap PM2 on macOS (Homebrew → Node → PM2). `launchd/` and `systemd/` templates exist for non-PM2 deployments. +- `scripts/ensure-pm2.sh` and `scripts/start-pm2.sh` bootstrap PM2 on macOS (Homebrew → Node → PM2). For boot persistence WITHOUT a login, `scripts/install-launchd.sh` (macOS, needs sudo) renders `launchd/com.consensus.node.plist.template` into `/Library/LaunchDaemons` and runs `pm2-runtime` under it; on Linux use `systemd/consensus-node.service`. Do NOT use `pm2 startup` on macOS — it emits a LaunchAgent, which loads only at user login. The installer runs `bun run secrets:check` **as the daemon's account** first and refuses to install if the encryption data key is not readable without a login. **FileVault must be off on a node**: it halts at a pre-boot unlock prompt, so nothing — daemon or agent — runs until a human types the password. The wrapper still tolerates the legacy exit code `75` from older releases. New code should exit with `0` (the supervisor handles the restart) and close with WS code `1012` so the server distinguishes update shutdowns from crashes. diff --git a/CONNECT_NODE_GUIDE.md b/CONNECT_NODE_GUIDE.md index f6aaad2..e7f9403 100644 --- a/CONNECT_NODE_GUIDE.md +++ b/CONNECT_NODE_GUIDE.md @@ -140,10 +140,10 @@ pm2 logs consensus-node-control The PM2 unit runs: ```txt -~/.consensus/node-runtime/current/scripts/run-node.sh +~/.consensus/node-runtime/current/src/supervise.ts ``` -That script starts both: +That unit starts both: 1. `bun run start` - local runtime server on `127.0.0.1:9090` by default. 2. `bun run control` - outbound encrypted control tunnel to the Consensus server. diff --git a/README.md b/README.md index ca02f1c..a26776d 100644 --- a/README.md +++ b/README.md @@ -212,4 +212,24 @@ scripts/install-release.sh `scripts/ensure-pm2.sh` installs missing macOS dependencies in order: Homebrew, Node.js/npm, then PM2. It also persists Homebrew shell setup in `~/.zprofile` when Homebrew is installed or discovered outside `PATH`. The older -`launchd/` and `systemd/` templates are still available if you do not want PM2. +A `systemd/` template is still available if you do not want PM2. + +### Running headless (starts at boot, no login) + +```bash +sudo scripts/install-launchd.sh +``` + +macOS only; on Linux install `systemd/consensus-node.service` instead. This installs a +**LaunchDaemon**, not a LaunchAgent — agents load only once a user logs in, which is why +`pm2 startup` is not used here. Before enabling the unit it runs `bun run secrets:check` +as the account the daemon will run as, and refuses to install if that account cannot +read the encryption data key without a login. + +Two prerequisites for a truly headless node: + +- **FileVault must be off.** It halts the boot at a pre-boot unlock prompt, so nothing + runs until someone types the password. For planned reboots on a FileVault machine, + `sudo fdesetup authrestart` boots once unattended, but power loss still needs a human. +- **Automatic restart after a power cut**, so the machine comes back at all: + `sudo pmset -a autorestart 1` (and `sudo pmset -a sleep 0` to stop it sleeping). diff --git a/deploy/README.md b/deploy/README.md index 3a42ded..4a882bf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,16 +18,16 @@ DNS pointed at the node's own IP). Those files (`deploy/Caddyfile`, ## What runs -- **`scripts/run-node.sh`** — one supervised unit running **both** the control +- **`src/supervise.ts`** — one supervised unit running **both** the control tunnel (`bun run control`, which now also serves the data plane over its streams) and the runtime server (`bun run start`). The runtime server binds **loopback-only** by default (`NODE_HOST=127.0.0.1`) and just exposes local operator endpoints (`/health`, `/node/*`); it is not reachable from outside and does not need to be. A single restart refreshes both children from the updated `current` symlink, and the unit cycles if either exits (so `update_apply` - restarts cleanly). `ecosystem.config.cjs`, `systemd/`, and `launchd/` all exec - it. Requires bash ≥ 4.3 (`wait -n`) — standard on Linux; on macOS run - `brew install bash`. + restarts cleanly). `ecosystem.config.cjs` and `systemd/` both exec it. Runs on + bun with no shell dependency — it replaced `scripts/run-node.sh`, which needed + bash ≥ 4.3 for `wait -n` and so failed on stock macOS (bash 3.2). ## Bring-up (per node) diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index ca21645..201cc0d 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -13,37 +13,40 @@ const currentDir = path.join(installDir, "current"); fs.mkdirSync(stateDir, { recursive: true }); -// run-node.sh needs bash >= 4.3 (`wait -n`). PM2's `interpreter` overrides the script -// shebang, so pin a sufficiently new bash explicitly — preferring Homebrew bash on -// macOS, where /bin/bash is 3.2 (so the documented Homebrew workaround actually takes -// effect under PM2). Falls back to /bin/bash, where run-node.sh prints a clear error, -// so evaluating this config never throws. -function resolveBash() { - const { execSync } = require("node:child_process"); - for (const bash of ["/opt/homebrew/bin/bash", "/usr/local/bin/bash", "/usr/bin/bash", "/bin/bash"]) { +// PM2's `interpreter` overrides the script shebang, so bun has to be named +// explicitly. Resolve it to an ABSOLUTE path: this config is also evaluated when PM2 +// itself is started by a boot-time daemon (launchd/systemd), whose PATH does not +// include ~/.bun/bin, so a bare "bun" would not resolve there. Falls back to the bare +// name — where PM2 reports a clear interpreter error — so evaluating this never throws. +function resolveBun() { + const candidates = [ + process.env.CONSENSUS_BUN_PATH, + path.join(os.homedir(), ".bun", "bin", "bun"), + "/opt/homebrew/bin/bun", + "/usr/local/bin/bun", + "/usr/bin/bun", + ]; + for (const candidate of candidates) { try { - if (!fs.existsSync(bash)) continue; - const out = execSync(`${bash} --version`, { stdio: ["ignore", "pipe", "ignore"] }).toString(); - const m = out.match(/version (\d+)\.(\d+)/); - if (m && (Number(m[1]) > 4 || (Number(m[1]) === 4 && Number(m[2]) >= 3))) return bash; + if (candidate && fs.existsSync(candidate)) return candidate; } catch { /* try next candidate */ } } - return "/bin/bash"; + return "bun"; } -const bashInterpreter = resolveBash(); +const bunInterpreter = resolveBun(); module.exports = { apps: [ { name: appName, - // run-node.sh runs the outbound control tunnel (which carries the data + // supervise.ts runs the outbound control tunnel (which carries the data // plane via the orchestrator gateway) AND a loopback-only runtime server as // one unit. (run-control.sh, control-only, is kept for reference.) - script: path.join(currentDir, "scripts", "run-node.sh"), - interpreter: bashInterpreter, + script: path.join(currentDir, "src", "supervise.ts"), + interpreter: bunInterpreter, cwd: currentDir, instances: 1, exec_mode: "fork", diff --git a/launchd/com.consensus.node.plist b/launchd/com.consensus.node.plist deleted file mode 100644 index 06795a5..0000000 --- a/launchd/com.consensus.node.plist +++ /dev/null @@ -1,40 +0,0 @@ - - - - - Label - com.consensus.node - - ProgramArguments - - /bin/zsh - -lc - ${HOME}/.consensus/node-runtime/current/scripts/run-node.sh - - - EnvironmentVariables - - CONSENSUS_SERVER_URL - https://consensus.canister.software - CONSENSUS_STATE_DIR - ${HOME}/.consensus/node - CONSENSUS_NODE_INSTALL_DIR - ${HOME}/.consensus/node-runtime - CONSENSUS_NODE_UPDATE_COMMAND - ${HOME}/.consensus/node-runtime/current/scripts/install-release.sh - - - KeepAlive - - - RunAtLoad - - - StandardOutPath - ${HOME}/.consensus/node/control.out.log - - StandardErrorPath - ${HOME}/.consensus/node/control.err.log - - diff --git a/launchd/com.consensus.node.plist.template b/launchd/com.consensus.node.plist.template new file mode 100644 index 0000000..19347e6 --- /dev/null +++ b/launchd/com.consensus.node.plist.template @@ -0,0 +1,93 @@ + + + + + + Label + com.consensus.node + + ProgramArguments + + @PM2_RUNTIME@ + start + @INSTALL_DIR@/current/ecosystem.config.cjs + --only + @APP_NAME@ + + + + UserName + @USER@ + + WorkingDirectory + @INSTALL_DIR@/current + + EnvironmentVariables + + + HOME + @HOME@ + + PATH + @PATH@ + PM2_HOME + @HOME@/.pm2 + CONSENSUS_SERVER_URL + @SERVER_URL@ + CONSENSUS_STATE_DIR + @STATE_DIR@ + CONSENSUS_NODE_INSTALL_DIR + @INSTALL_DIR@ + CONSENSUS_NODE_UPDATE_COMMAND + @INSTALL_DIR@/current/scripts/install-release.sh + CONSENSUS_PM2_NAME + @APP_NAME@ + CONSENSUS_BUN_PATH + @BUN@ + + + RunAtLoad + + + KeepAlive + + + + ThrottleInterval + 10 + + ProcessType + Background + + + ExitTimeOut + 40 + + StandardOutPath + @STATE_DIR@/launchd.out.log + + StandardErrorPath + @STATE_DIR@/launchd.err.log + + diff --git a/package.json b/package.json index 4279a32..83b69ab 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "bun src/instance.ts", "eval": "bun src/eval.ts", "control": "bun src/control.ts", + "supervise": "bun src/supervise.ts", "register": "bun src/register.ts", "verify": "bun src/verify.ts", "update": "bun src/update.ts", @@ -42,11 +43,15 @@ "test:ssrf": "bun src/tests/ssrf.test.ts", "test:tickets": "bun src/tests/tickets.test.ts", "test:dedupe": "bun src/tests/dedupe.test.ts", + "test:profile-v1": "bun src/tests/profile-v1.test.ts", "test:pin": "bun src/tests/pin.test.ts", "test:responder-auth": "bun src/tests/responder-auth.test.ts", "test:data-handshake": "bun src/tests/data-handshake.test.ts", "test:request-ticket": "bun src/tests/request-ticket.test.ts", "test:proxy-serve": "bun src/tests/proxy-serve.test.ts", + "test:supervise": "bun src/tests/supervise.test.ts", + "test:secret-store": "bun src/tests/secret-store.test.ts", + "secrets:check": "bun src/secrets-check.ts", "test:data-plane": "bun src/tests/data-plane.test.ts", "test:data-plane-stream": "bun src/tests/data-plane-stream.test.ts", "gen:responder-auth-vectors": "bun src/tunnel/gen-responder-auth-vectors.ts", diff --git a/scripts/install-launchd.sh b/scripts/install-launchd.sh new file mode 100755 index 0000000..f48211e --- /dev/null +++ b/scripts/install-launchd.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# +# Install the Consensus node as a macOS LaunchDaemon so it starts at boot WITHOUT a +# user login. +# +# Why a daemon and not `pm2 startup`: on macOS `pm2 startup` writes a LaunchAgent to +# ~/Library/LaunchAgents, and agents load only once a user logs in. A LaunchDaemon in +# /Library/LaunchDaemons loads at boot. Writing there requires root, which is why this +# script must be run with sudo. +# +# sudo scripts/install-launchd.sh +# +# Uninstall: +# +# sudo launchctl bootout system/com.consensus.node +# sudo rm /Library/LaunchDaemons/com.consensus.node.plist +# +set -euo pipefail + +LABEL="com.consensus.node" +PLIST_DEST="/Library/LaunchDaemons/${LABEL}.plist" +TEMPLATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMPLATE="${TEMPLATE_DIR}/launchd/${LABEL}.plist.template" + +if [[ "${EUID}" -ne 0 ]]; then + echo "This must run as root so it can write ${PLIST_DEST}:" >&2 + echo " sudo $0" >&2 + exit 77 +fi + +# Everything the daemon touches belongs to the human who invoked sudo, not to root. +target_user="${CONSENSUS_NODE_USER:-${SUDO_USER:-}}" +if [[ -z "${target_user}" || "${target_user}" == "root" ]]; then + echo "Could not determine the operator account. Re-run via sudo from your own login," >&2 + echo "or set CONSENSUS_NODE_USER=." >&2 + exit 78 +fi + +target_home="$(dscl . -read "/Users/${target_user}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" +if [[ -z "${target_home}" || ! -d "${target_home}" ]]; then + echo "No home directory found for user ${target_user}" >&2 + exit 78 +fi + +if [[ ! -f "${TEMPLATE}" ]]; then + echo "Template not found: ${TEMPLATE}" >&2 + exit 66 +fi + +install_dir="${CONSENSUS_NODE_INSTALL_DIR:-"${target_home}/.consensus/node-runtime"}" +state_dir="${CONSENSUS_STATE_DIR:-"${target_home}/.consensus/node"}" +server_url="${CONSENSUS_SERVER_URL:-"https://consensus.canister.software"}" +app_name="${CONSENSUS_PM2_NAME:-consensus-node-control}" + +if [[ ! -d "${install_dir}/current" ]]; then + echo "No installed release at ${install_dir}/current — run setup first." >&2 + exit 70 +fi + +# Resolve interpreters as the target user: pm2 and bun usually live under their home +# (nvm, ~/.bun), which root's PATH knows nothing about. +as_user() { sudo -u "${target_user}" -H /bin/bash -lc "$1"; } + +pm2_runtime="$(as_user 'command -v pm2-runtime || true')" +if [[ -z "${pm2_runtime}" ]]; then + echo "pm2-runtime not found for ${target_user}. Run scripts/ensure-pm2.sh first." >&2 + exit 69 +fi + +bun_bin="$(as_user 'command -v bun || true')" +if [[ -z "${bun_bin}" ]]; then + for candidate in "${target_home}/.bun/bin/bun" /opt/homebrew/bin/bun /usr/local/bin/bun; do + [[ -x "${candidate}" ]] && bun_bin="${candidate}" && break + done +fi +if [[ -z "${bun_bin}" ]]; then + echo "bun not found for ${target_user}. Run scripts/ensure-bun.sh first." >&2 + exit 69 +fi + +# The daemon's PATH: the dirs holding pm2-runtime and bun, plus the system defaults. +daemon_path="$(dirname "${pm2_runtime}"):$(dirname "${bun_bin}"):/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + +mkdir -p "${state_dir}" +chown "${target_user}" "${state_dir}" + +# Prove, as the account the daemon will run as, that the encryption data key is +# reachable with no login session. The daemon boots before anyone logs in, so a key +# this account cannot read would strand the node — surface that now, not at 3am. +echo "Checking encryption at rest as ${target_user}..." +if ! as_user "cd '${install_dir}/current' && CONSENSUS_STATE_DIR='${state_dir}' '${bun_bin}' src/secrets-check.ts"; then + echo >&2 + echo "The node's secrets are not readable by ${target_user} without a login." >&2 + echo "Refusing to install a boot unit that would fail on every reboot." >&2 + exit 76 +fi + +# `|` as the sed delimiter because every value here is a path. +tmp_plist="$(mktemp)" +trap 'rm -f "${tmp_plist}"' EXIT +sed \ + -e "s|@PM2_RUNTIME@|${pm2_runtime}|g" \ + -e "s|@INSTALL_DIR@|${install_dir}|g" \ + -e "s|@STATE_DIR@|${state_dir}|g" \ + -e "s|@SERVER_URL@|${server_url}|g" \ + -e "s|@APP_NAME@|${app_name}|g" \ + -e "s|@USER@|${target_user}|g" \ + -e "s|@HOME@|${target_home}|g" \ + -e "s|@PATH@|${daemon_path}|g" \ + -e "s|@BUN@|${bun_bin}|g" \ + "${TEMPLATE}" > "${tmp_plist}" + +if grep -q '@[A-Z_]*@' "${tmp_plist}"; then + echo "Template still contains unsubstituted tokens:" >&2 + grep -o '@[A-Z_]*@' "${tmp_plist}" | sort -u >&2 + exit 65 +fi + +# Fail before installing rather than leaving launchd with a plist it cannot parse. +plutil -lint "${tmp_plist}" >/dev/null + +# bootout first so a re-run replaces cleanly; it fails when nothing is loaded, which +# is fine on a first install. +launchctl bootout "system/${LABEL}" 2>/dev/null || true + +install -o root -g wheel -m 644 "${tmp_plist}" "${PLIST_DEST}" +launchctl bootstrap system "${PLIST_DEST}" +launchctl enable "system/${LABEL}" + +echo "Installed ${PLIST_DEST}" +echo " user: ${target_user}" +echo " pm2-runtime: ${pm2_runtime}" +echo " bun: ${bun_bin}" +echo " install dir: ${install_dir}" +echo " state dir: ${state_dir}" +echo +echo "Status: sudo launchctl print system/${LABEL}" +echo "Logs: tail -f ${state_dir}/launchd.err.log" +echo +echo "It now starts at boot with no login required. Verify with a reboot." diff --git a/scripts/run-node.sh b/scripts/run-node.sh deleted file mode 100755 index 3017d69..0000000 --- a/scripts/run-node.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# -# Supervised node unit — runs BOTH the control tunnel and the runtime server as -# one unit. -# -# The control tunnel (`bun run control`) is the whole data path: it carries -# heartbeats, proxy work, AND the client-facing data plane, which the orchestrator -# node-gateway bridges onto it (target {kind:"data-plane"} streams). The runtime -# server (`bun run start`) binds loopback-only and just serves local operator -# endpoints (/health, /node/*); it needs no inbound port or TLS. Running both under -# one PM2/systemd unit means a single restart refreshes both from the updated -# `current` symlink. -# -# The unit exits as soon as EITHER child exits: the control tunnel exits on -# `update_apply` (so the whole unit restarts onto the new release), and a crash of -# either child cycles the unit too. Requires bash >= 4.3 (wait -n); production nodes -# run Linux, so that's fine. -set -uo pipefail - -# `wait -n` (wake on the first child to exit) needs bash >= 4.3. That's universal on -# Linux (all production nodes); on macOS install a modern bash (brew install bash) and -# ensure it's first on PATH. Fail loudly rather than misbehave on the stock 3.2. -if [[ -z "${BASH_VERSINFO:-}" || ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 3 ) ]]; then - echo "run-node.sh requires bash >= 4.3 (have ${BASH_VERSION:-unknown}); on macOS: brew install bash" >&2 - exit 78 -fi - -install_dir="${CONSENSUS_NODE_INSTALL_DIR:-"$HOME/.consensus/node-runtime"}" -state_dir="${CONSENSUS_STATE_DIR:-"$HOME/.consensus/node"}" -server_url="${CONSENSUS_SERVER_URL:-"https://consensus.canister.software"}" - -export CONSENSUS_STATE_DIR="${state_dir}" -export CONSENSUS_SERVER_URL="${server_url}" -export CONSENSUS_NODE_INSTALL_DIR="${install_dir}" -export CONSENSUS_NODE_UPDATE_COMMAND="${CONSENSUS_NODE_UPDATE_COMMAND:-"${install_dir}/current/scripts/install-release.sh"}" - -current="${install_dir}/current" -if [[ ! -d "${current}" ]]; then - echo "No installed release at ${current}" >&2 - exit 70 -fi - -cd "${current}" - -# Loopback runtime server (operator endpoints) + outbound control tunnel (data path). -bun run start & runtime_pid=$! -bun run control & control_pid=$! - -shutdown() { - trap - EXIT INT TERM - kill "${runtime_pid}" "${control_pid}" 2>/dev/null || true - wait "${runtime_pid}" "${control_pid}" 2>/dev/null || true -} -trap shutdown EXIT INT TERM - -# Block until whichever child exits first; its code becomes the unit's exit code so -# the supervisor restarts everything from the refreshed `current`. -wait -n -code=$? - -shutdown -exit "${code}" diff --git a/src/crypto/identity.ts b/src/crypto/identity.ts index fe74c74..070cae4 100644 --- a/src/crypto/identity.ts +++ b/src/crypto/identity.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import { ensureState, exists } from "../node/state"; +import { readSecretFile, writeSecretFile, SLOT_NODE_KEY } from "../node/secret-store"; export interface NodeIdentity { privateKeyPem: string; @@ -10,10 +11,19 @@ export interface NodeIdentity { export async function loadOrCreateIdentity(): Promise { const p = await ensureState(); if ((await exists(p.privateKeyPem)) && (await exists(p.publicKeyPem))) { - return { - privateKeyPem: await fs.readFile(p.privateKeyPem, "utf8"), - publicKeyPem: await fs.readFile(p.publicKeyPem, "utf8") - }; + // readSecretFile returns null ONLY when the file is genuinely absent. A + // decryption failure, an unreadable file, or a damaged data key all throw + // rather than falling through to key generation. That is deliberate: minting a + // fresh identity here would overwrite the registered private key and silently + // orphan the node on the orchestrator, which is far worse than refusing to + // start. + const privateKeyPem = await readSecretFile(SLOT_NODE_KEY, p.privateKeyPem); + if (privateKeyPem) { + return { + privateKeyPem, + publicKeyPem: await fs.readFile(p.publicKeyPem, "utf8") + }; + } } const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", { @@ -21,7 +31,7 @@ export async function loadOrCreateIdentity(): Promise { privateKeyEncoding: { type: "pkcs8", format: "pem" } }); - await fs.writeFile(p.privateKeyPem, privateKey, { mode: 0o600 }); + await writeSecretFile(SLOT_NODE_KEY, p.privateKeyPem, privateKey); await fs.writeFile(p.publicKeyPem, publicKey); return { privateKeyPem: privateKey, publicKeyPem: publicKey }; diff --git a/src/node/secret-store.ts b/src/node/secret-store.ts new file mode 100644 index 0000000..25834b0 --- /dev/null +++ b/src/node/secret-store.ts @@ -0,0 +1,308 @@ +// Encryption at rest for the node's secrets — the Ed25519 identity key and the +// join authorization. +// +// Envelope scheme: a single 32-byte data key (DEK) lives in the platform keystore, +// never in the state directory. Secret files hold chacha20-poly1305 ciphertext keyed +// by that DEK, with the file's logical slot name as AAD so ciphertext cannot be moved +// between slots (a join-auth blob dropped into node.key fails to open). +// +// WHAT THIS PROTECTS AGAINST — do not over-claim it: +// +// * Covered: a copied state directory, and a backup scoped to it. That is the +// realistic leak for this data, since ~/.consensus is what gets rsync'd, +// archived, or handed to support. +// * NOT covered: theft of the whole disk, or an attacker who already has the +// node's uid on a running host. +// +// That ceiling is forced by the requirement to boot with no human present. If the +// node can decrypt unattended then the unlock secret is reachable on the same +// machine, so a full-disk attacker gets it too. This is NOT a shortcut we took: +// macOS's System keychain has the same property (it unlocks at boot from +// /var/db/SystemKey, on the same disk), so it would buy nothing here. Real +// at-rest protection means FileVault — which stops at a pre-boot unlock prompt and +// therefore cannot coexist with unattended boot. +// +// The one place that ceiling can be raised is Linux with a TPM, where the chip +// releases the key only on that hardware. The KeystoreAdapter interface below is +// where such an adapter drops in without touching any caller. +// +// WHY NOT THE macOS KEYCHAIN: the daemon runs as the operator with no login session +// (see launchd/com.consensus.node.plist.template), and the login keychain is only +// unlocked by a GUI login. The System keychain is readable pre-login but only by +// root. Using a keychain interactively and a file under the daemon would split the +// DEK across two stores and strand the node at boot, so there is exactly one store +// per platform. + +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { chacha20poly1305 } from "@noble/ciphers/chacha.js"; +import { log } from "../log"; + +const DEK_BYTES = 32; +const NONCE_BYTES = 12; +const ENVELOPE_VERSION = 1; +const ALGORITHM = "chacha20-poly1305"; + +/** Slot names double as AAD, so they are part of the on-disk format: changing one + * makes existing ciphertext for that slot unopenable. */ +export const SLOT_NODE_KEY = "node.key"; +export const SLOT_JOIN_AUTH = "join-auth.json"; + +export interface SecretEnvelope { + v: number; + alg: string; + nonce: string; + ct: string; +} + +/** A place the data key can live. Add a TPM-backed adapter here to close the + * Linux full-disk gap documented above. */ +export interface KeystoreAdapter { + readonly name: string; + available(): Promise; + get(): Promise; + set(key: Buffer): Promise; +} + +function isRoot(): boolean { + return typeof process.getuid === "function" && process.getuid() === 0; +} + +// --- Adapter: explicit key from the environment ----------------------------- +// For tests, and for operators who inject the DEK from their own KMS/secret manager. +// Takes precedence over every other adapter when set. + +const envAdapter: KeystoreAdapter = { + name: "env", + async available() { + return Boolean(process.env.CONSENSUS_NODE_SECRET_KEY); + }, + async get() { + const raw = process.env.CONSENSUS_NODE_SECRET_KEY; + if (!raw) return null; + const key = Buffer.from(raw, "base64"); + if (key.length !== DEK_BYTES) { + throw new Error( + `CONSENSUS_NODE_SECRET_KEY must be ${DEK_BYTES} base64-encoded bytes, got ${key.length}`, + ); + } + return key; + }, + async set() { + // The operator owns this key; we never overwrite what they injected. + }, +}; + +// --- Adapter: 0600 key file outside the state directory ---------------------- +// Deliberately NOT under CONSENSUS_STATE_DIR: keeping the DEK out of the directory +// it protects is the whole reason copying that directory yields nothing usable. +// +// Readable with no login session and no root, which is what makes pre-login boot +// work. CONSENSUS_NODE_SECRET_KEY_PATH overrides it for operators who want the key +// on removable media or a mounted secret. + +export function keyFilePath(): string { + const override = process.env.CONSENSUS_NODE_SECRET_KEY_PATH; + if (override) return override; + + if (process.platform === "darwin") { + return isRoot() + ? "/Library/Application Support/consensus-node/secret.key" + : path.join(os.homedir(), "Library", "Application Support", "consensus-node", "secret.key"); + } + if (isRoot()) return "/etc/consensus-node/secret.key"; + return path.join( + process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), + "consensus-node", + "secret.key", + ); +} + +const keyfileAdapter: KeystoreAdapter = { + name: "keyfile", + async available() { + return process.platform === "darwin" || process.platform === "linux"; + }, + async get() { + const file = keyFilePath(); + let raw: string; + try { + raw = await fs.readFile(file, "utf8"); + } catch (error) { + // Only a genuinely absent key means "not provisioned yet". Every other error + // (EACCES, EIO, a directory in the way) must NOT read as absent: the caller + // would mint a replacement and overwrite this file, and every secret already + // sealed under the old key would become unrecoverable. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new Error( + `data key at ${file} exists but could not be read (${(error as NodeJS.ErrnoException).code ?? "unknown"}). ` + + "Refusing to continue: replacing it would make every existing secret unrecoverable.", + ); + } + + const key = Buffer.from(raw.trim(), "base64"); + if (key.length !== DEK_BYTES) { + // Present but truncated or corrupt. Fail closed for the same reason. + throw new Error( + `data key at ${file} is malformed: decoded to ${key.length} bytes, expected ${DEK_BYTES}. ` + + "Restore the original file from backup. Delete it only if you accept that every " + + "secret encrypted under it — including this node's identity — is unrecoverable.", + ); + } + return key; + }, + async set(key) { + const file = keyFilePath(); + await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + await fs.writeFile(file, key.toString("base64"), { mode: 0o600 }); + // mkdir's mode is masked by umask, so set it explicitly rather than trusting it. + await fs.chmod(path.dirname(file), 0o700); + await fs.chmod(file, 0o600); + }, +}; + +const ADAPTERS: KeystoreAdapter[] = [envAdapter, keyfileAdapter]; + +async function selectAdapter(): Promise { + for (const adapter of ADAPTERS) { + if (await adapter.available()) return adapter; + } + throw new Error( + `No keystore adapter available for platform ${process.platform}. ` + + "Set CONSENSUS_NODE_SECRET_KEY to a base64 32-byte key to supply one yourself.", + ); +} + +let cachedKey: Buffer | null = null; + +/** The data key, minted into the platform keystore on first use. Cached per process + * so a node reading several secrets hits the keystore once. */ +export async function getOrCreateDataKey(): Promise { + if (cachedKey) return cachedKey; + + const adapter = await selectAdapter(); + const existing = await adapter.get(); + if (existing) { + cachedKey = existing; + return existing; + } + + const key = crypto.randomBytes(DEK_BYTES); + await adapter.set(key); + + // Read back rather than trusting the write: a keystore that silently refused + // would otherwise leave us encrypting against a key nothing can recover. + const stored = await adapter.get(); + if (!stored || !stored.equals(key)) { + throw new Error(`Keystore ${adapter.name} did not retain the node data key`); + } + + log.info("secret-store", "data-key-created", { adapter: adapter.name }); + cachedKey = key; + return key; +} + +/** Reset the cached key. Tests only. */ +export function resetDataKeyCache(): void { + cachedKey = null; +} + +export interface KeystoreStatus { + adapter: string; + location: string; + present: boolean; +} + +/** Where the data key lives and whether it is already there. Used by the boot-unit + * installers to prove, as the account the daemon will run as, that the key is + * reachable — so a misconfiguration surfaces at install time and not at 3am. */ +export async function describeKeystore(): Promise { + const adapter = await selectAdapter(); + return { + adapter: adapter.name, + location: adapter.name === "env" ? "CONSENSUS_NODE_SECRET_KEY" : keyFilePath(), + present: (await adapter.get()) !== null, + }; +} + +export function isSecretEnvelope(value: unknown): value is SecretEnvelope { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + candidate.v === ENVELOPE_VERSION && + candidate.alg === ALGORITHM && + typeof candidate.nonce === "string" && + typeof candidate.ct === "string" + ); +} + +export async function sealSecret(slot: string, plaintext: string): Promise { + const key = await getOrCreateDataKey(); + const nonce = crypto.randomBytes(NONCE_BYTES); + const aad = Buffer.from(slot, "utf8"); + const ct = chacha20poly1305(key, nonce, aad).encrypt(Buffer.from(plaintext, "utf8")); + return { + v: ENVELOPE_VERSION, + alg: ALGORITHM, + nonce: nonce.toString("base64"), + ct: Buffer.from(ct).toString("base64"), + }; +} + +export async function openSecret(slot: string, envelope: SecretEnvelope): Promise { + const key = await getOrCreateDataKey(); + const nonce = Buffer.from(envelope.nonce, "base64"); + const aad = Buffer.from(slot, "utf8"); + const pt = chacha20poly1305(key, nonce, aad).decrypt(Buffer.from(envelope.ct, "base64")); + return Buffer.from(pt).toString("utf8"); +} + +/** Write plaintext to disk encrypted, replacing any existing file atomically so a + * crash mid-write cannot leave a truncated secret behind. */ +export async function writeSecretFile(slot: string, file: string, plaintext: string): Promise { + const envelope = await sealSecret(slot, plaintext); + await fs.mkdir(path.dirname(file), { recursive: true }); + const tmp = `${file}.tmp-${crypto.randomBytes(6).toString("hex")}`; + await fs.writeFile(tmp, JSON.stringify(envelope), { mode: 0o600 }); + await fs.rename(tmp, file); +} + +/** + * Read a secret file, transparently upgrading a legacy plaintext one. + * + * Nodes provisioned before encryption landed hold a bare PEM (or bare JSON) here. + * Those are re-written sealed on first read, so an existing node encrypts itself on + * next start with no operator action and no re-registration. + */ +export async function readSecretFile(slot: string, file: string): Promise { + let raw: string; + try { + raw = await fs.readFile(file, "utf8"); + } catch (error) { + // Absent is a legitimate state — the caller provisions it. Anything else is not: + // loadOrCreateIdentity treats null as "no identity yet" and generates a new key, + // so swallowing EACCES here would rotate a registered node's identity and orphan + // it on the orchestrator. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new Error( + `secret ${slot} at ${file} exists but could not be read ` + + `(${(error as NodeJS.ErrnoException).code ?? "unknown"}). Refusing to treat it as absent.`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = null; + } + + if (isSecretEnvelope(parsed)) return openSecret(slot, parsed); + + // Legacy plaintext. Seal it in place, then hand back what the caller expected. + await writeSecretFile(slot, file, raw); + log.info("secret-store", "plaintext-migrated", { slot }); + return raw; +} diff --git a/src/node/state.ts b/src/node/state.ts index 324db67..a2b0617 100644 --- a/src/node/state.ts +++ b/src/node/state.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import type { NodeConfig } from "../types"; +import { readSecretFile, writeSecretFile, SLOT_JOIN_AUTH } from "./secret-store"; export interface StatePaths { base: string; @@ -83,12 +84,20 @@ export interface JoinAuthorization { export async function saveJoinAuthorization(auth: JoinAuthorization): Promise { const p = await ensureState(); - await fs.writeFile(p.joinAuth, JSON.stringify(auth, null, 2), { encoding: "utf8", mode: 0o600 }); + await writeSecretFile(SLOT_JOIN_AUTH, p.joinAuth, JSON.stringify(auth, null, 2)); } export async function loadJoinAuthorization(): Promise { const p = await ensureState(); - return readJson(p.joinAuth, null); + const raw = await readSecretFile(SLOT_JOIN_AUTH, p.joinAuth); + if (raw === null) return null; + try { + return JSON.parse(raw) as JoinAuthorization; + } catch { + // Matches the previous readJson() contract: a corrupt authorization is treated + // as absent, so the node re-runs eval rather than refusing to start. + return null; + } } export interface SetupProgress { diff --git a/src/release.ts b/src/release.ts index dcfce0e..2f00ddb 100644 --- a/src/release.ts +++ b/src/release.ts @@ -112,6 +112,9 @@ function parseBoolean(value: string): boolean { async function stagePackage(stageDir: string, release: { version: string; commit: string; platform: string }): Promise { await fs.mkdir(stageDir, { recursive: true }); + // fs.cp throws ENOENT on a missing source, so every entry here must exist in the + // repo. `src` carries supervise.ts and `launchd` the daemon template (mode 0755 is + // preserved, which the systemd unit's shebang ExecStart depends on). for (const entry of ["src", "bin", "scripts", "launchd", "systemd", "ecosystem.config.cjs", "package.json", "tsconfig.json", "bun.lock", "README.md"]) { await fs.cp(path.join(rootDir, entry), path.join(stageDir, entry), { recursive: true, diff --git a/src/runtime/benchmarks/suites/composite-request.ts b/src/runtime/benchmarks/suites/composite-request.ts index 5ecd013..958152f 100644 --- a/src/runtime/benchmarks/suites/composite-request.ts +++ b/src/runtime/benchmarks/suites/composite-request.ts @@ -124,13 +124,12 @@ const TARGET_URL = "https://upstream.example.com/api/v1/resource?b=2&a=1"; // eviction is O(1) amortized — but rotation still keeps the measured cost steady.) const REPLAY_ROTATE_ITERATIONS = 50_000; -// Realistic scoped GET: `accept`/`content-type` exercise the semantic-header -// canonicalization, `x-api-key` forces the scope hash, the rest is passthrough. +// Realistic GET: `accept`/`content-type` exercise semantic-header +// canonicalization while user-agent remains non-semantic passthrough metadata. const REQUEST_HEADERS: Record = { accept: "application/json", "content-type": "application/json", "user-agent": "consensus-bench/1.0", - "x-api-key": "bench-scope-key", }; const STAGE_NAMES: CompositeStageName[] = [ diff --git a/src/runtime/dedupe.ts b/src/runtime/dedupe.ts index 2f3586c..5933532 100644 --- a/src/runtime/dedupe.ts +++ b/src/runtime/dedupe.ts @@ -14,9 +14,11 @@ export interface DedupeParams { method: string; headers?: Headers; body?: RequestBody; + profile_hash?: string; } const ALLOW_HEADERS = new Set(['accept', 'content-type']); +const HASH_HEADERS = new Set(['authorization', 'cookie']); const MULTI_SPACE = /\s+/g; export function sha256Hex(input: string | Buffer): string { @@ -65,16 +67,19 @@ export function canonicalizeUrl(raw: string): string { } export function canonicalizeSemanticHeaders(headers: Headers): Headers { - // Two-phase: collect the two allowed keys, then emit in fixed alphabetical order - // so the result is deterministic without a sort step ('accept' < 'content-type'). + // Collect public semantic values plus hashes of sensitive upstream credentials, + // then emit in fixed alphabetical order. Secrets never enter the canonical form. const result: Headers = {}; for (const [k, v] of Object.entries(headers)) { const lower = k.toLowerCase(); // HTTP names have no surrounding whitespace if (ALLOW_HEADERS.has(lower)) result[lower] = v.trim().replace(MULTI_SPACE, ' '); + else if (HASH_HEADERS.has(lower)) result[lower] = sha256Hex(v); } const ordered: Headers = {}; if (result['accept']) ordered['accept'] = result['accept']; + if (result['authorization']) ordered['authorization'] = result['authorization']; if (result['content-type']) ordered['content-type'] = result['content-type']; + if (result['cookie']) ordered['cookie'] = result['cookie']; return ordered; } @@ -85,22 +90,16 @@ export function computeBodyHash(body: RequestBody): string { return sha256Hex(stableStringify(body)); } -export function getScope(headers: Headers): string { - for (const k in headers) { - if (k.toLowerCase() === 'x-api-key') return sha256Hex(headers[k]!); - } - return 'global'; -} - -export function generateDedupeKey({ target_url, method, headers = {}, body }: DedupeParams): string { +export function generateDedupeKey({ target_url, method, headers = {}, body, profile_hash }: DedupeParams): string { const semanticHeaders = canonicalizeSemanticHeaders(headers); const canonical = { v: 1, - scope: getScope(headers), + scope: 'global', method: method.toUpperCase(), url: canonicalizeUrl(target_url), headers: semanticHeaders, body_hash: computeBodyHash(body), + profile_hash: profile_hash || undefined, }; return sha256Hex(stableStringify(canonical)); diff --git a/src/runtime/dedupe.vectors.json b/src/runtime/dedupe.vectors.json index f2cf27a..06681c1 100644 --- a/src/runtime/dedupe.vectors.json +++ b/src/runtime/dedupe.vectors.json @@ -34,28 +34,29 @@ "key": "7f0eb385c62d00117d8e0685e279731e535c3b4199ba6fb97ce79ac720d165a2" }, { - "name": "api-key-scope", + "name": "semantic-headers-only", "input": { "target_url": "https://api.example.com/p", "method": "GET", "headers": { - "x-api-key": "secret" + "accept": "application/json", + "content-type": "application/json", + "x-other": "ignored" } }, - "key": "762942d3cea517745d2919b8fe98c45e8b0a5abcc9c1c94c3b2077014cc4cee9" + "key": "2f112dcafa15f0548a1661bb31187a8d810066860c4a28ce9f21a612333c8dbc" }, { - "name": "semantic-headers-only", + "name": "credential-headers-hashed", "input": { - "target_url": "https://api.example.com/p", + "target_url": "https://api.example.com/private", "method": "GET", "headers": { - "accept": "application/json", - "content-type": "application/json", - "x-other": "ignored" + "authorization": "Bearer secret", + "cookie": "session=secret" } }, - "key": "2f112dcafa15f0548a1661bb31187a8d810066860c4a28ce9f21a612333c8dbc" + "key": "4d3c084eb4e3f72fd7770f28433cd04d83c158f8f9099aaccdc894497e556fcd" }, { "name": "json-body-sorted", diff --git a/src/runtime/profile-v1.ts b/src/runtime/profile-v1.ts new file mode 100644 index 0000000..1238bdf --- /dev/null +++ b/src/runtime/profile-v1.ts @@ -0,0 +1,233 @@ +import crypto from 'node:crypto'; + +export const PROXY_PROFILE_PROTOCOL = 'consensus.proxy-profile' as const; +export const PROXY_PROFILE_VERSION = 1 as const; + +export interface ProxyExecutionProfileV1 { + protocol: typeof PROXY_PROFILE_PROTOCOL; + version: typeof PROXY_PROFILE_VERSION; + base_url: string; + allowed_methods: string[]; + allowed_paths: string[]; + cache_ttl: number; + verbose: boolean; + node_region?: string; + node_domain?: string; + node_exclude?: string; + direct: boolean; +} + +const PROFILE_KEYS = new Set([ + 'protocol', + 'version', + 'base_url', + 'allowed_methods', + 'allowed_paths', + 'cache_ttl', + 'verbose', + 'node_region', + 'node_domain', + 'node_exclude', + 'direct', +]); +const METHOD_ORDER = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']; +const METHODS = new Set(METHOD_ORDER); +const MAX_PATHS = 64; +const MAX_CACHE_TTL_SECONDS = 3_600; +const MAX_PREFERENCE_LENGTH = 256; +const PROFILE_CONTROL_HEADERS = new Set([ + 'x-cache-ttl', 'x-verbose', 'x-node-region', 'x-node-domain', 'x-node-exclude', 'x-direct', +]); + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .map(([key, item]): [string, unknown] => [key, stableValue(item)]) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ); + } + return value; +} + +function normalizePath(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) { + throw new TypeError(`${field} must be an origin-relative path beginning with /`); + } + const parsed = new URL(value, 'http://profile.local'); + if (parsed.origin !== 'http://profile.local' || parsed.search || parsed.hash) { + throw new TypeError(`${field} must not contain an origin, query, or fragment`); + } + return parsed.pathname; +} + +function optionalBoolean(value: unknown, field: string): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'boolean') throw new TypeError(`${field} must be a boolean`); + return value; +} + +function optionalPreference(value: unknown, field: string): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string') throw new TypeError(`${field} must be a string`); + const normalized = value.split(',').map((item) => item.trim()).filter(Boolean).join(','); + if (!normalized || normalized.length > MAX_PREFERENCE_LENGTH) { + throw new TypeError(`${field} must contain 1-${MAX_PREFERENCE_LENGTH} characters`); + } + return normalized; +} + +/** Validate and canonicalize the anonymous execution plan carried on the wire. */ +export function normalizeProxyProfileV1(input: unknown): ProxyExecutionProfileV1 { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('proxy profile must be an object'); + } + const value = input as Record; + for (const key of Object.keys(value)) { + if (!PROFILE_KEYS.has(key)) throw new TypeError(`unsupported proxy profile field: ${key}`); + } + if (value.protocol !== PROXY_PROFILE_PROTOCOL || value.version !== PROXY_PROFILE_VERSION) { + throw new TypeError(`unsupported proxy profile protocol/version; expected ${PROXY_PROFILE_PROTOCOL}@${PROXY_PROFILE_VERSION}`); + } + + let base: URL; + try { + base = new URL(String(value.base_url ?? '')); + } catch { + throw new TypeError('proxy profile base_url is invalid'); + } + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash) { + throw new TypeError('proxy profile base_url must be an http(s) URL without credentials, query, or fragment'); + } + base.pathname = base.pathname === '/' ? '/' : base.pathname.replace(/\/+$/, ''); + + if (!Array.isArray(value.allowed_methods) || value.allowed_methods.length === 0) { + throw new TypeError('proxy profile allowed_methods must be a non-empty array'); + } + const methodSet = new Set(value.allowed_methods.map((method) => String(method).toUpperCase())); + if ([...methodSet].some((method) => !METHODS.has(method))) { + throw new TypeError('proxy profile contains an unsupported HTTP method'); + } + const allowedMethods = METHOD_ORDER.filter((method) => methodSet.has(method)); + + if (!Array.isArray(value.allowed_paths) || value.allowed_paths.length === 0 || value.allowed_paths.length > MAX_PATHS) { + throw new TypeError(`proxy profile allowed_paths must contain 1-${MAX_PATHS} entries`); + } + const allowedPaths = [...new Set( + value.allowed_paths.map((path) => normalizePath(path, 'proxy profile allowed_paths entry')), + )].sort(); + + let cacheTtl = 300; + if (value.cache_ttl !== undefined) { + cacheTtl = Number(value.cache_ttl); + if (!Number.isInteger(cacheTtl) || cacheTtl < 1 || cacheTtl > MAX_CACHE_TTL_SECONDS) { + throw new TypeError(`proxy profile cache_ttl must be an integer from 1-${MAX_CACHE_TTL_SECONDS}`); + } + } + + return { + protocol: PROXY_PROFILE_PROTOCOL, + version: PROXY_PROFILE_VERSION, + base_url: base.toString(), + allowed_methods: allowedMethods, + allowed_paths: allowedPaths, + cache_ttl: cacheTtl, + verbose: optionalBoolean(value.verbose, 'proxy profile verbose') ?? false, + ...(value.node_region === undefined ? {} : { node_region: optionalPreference(value.node_region, 'proxy profile node_region')! }), + ...(value.node_domain === undefined ? {} : { node_domain: optionalPreference(value.node_domain, 'proxy profile node_domain')! }), + ...(value.node_exclude === undefined ? {} : { node_exclude: optionalPreference(value.node_exclude, 'proxy profile node_exclude')! }), + direct: optionalBoolean(value.direct, 'proxy profile direct') ?? true, + }; +} + +export function hashProxyProfileV1(input: unknown): string { + const profile = normalizeProxyProfileV1(input); + return hashNormalizedProfile(profile); +} + +function hashNormalizedProfile(profile: ProxyExecutionProfileV1): string { + return crypto.createHash('sha256').update(JSON.stringify(stableValue(profile))).digest('hex'); +} + +/** Enforce the profile independently at every execution boundary. */ +export function assertProxyProfileRequestV1( + input: unknown, + targetUrl: string, + method: string, +): ProxyExecutionProfileV1 { + const profile = normalizeProxyProfileV1(input); + let target: URL; + try { + target = new URL(targetUrl); + } catch { + throw new TypeError('proxy profile target_url is invalid'); + } + const base = new URL(profile.base_url); + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + throw new TypeError('proxy profile target must use http(s)'); + } + if (target.username || target.password || target.hash) { + throw new TypeError('proxy profile target cannot contain credentials or a fragment'); + } + if (target.origin !== base.origin) throw new TypeError('proxy profile target is outside its configured origin'); + + const basePath = base.pathname === '/' ? '' : base.pathname.replace(/\/$/, ''); + if (basePath && target.pathname !== basePath && !target.pathname.startsWith(`${basePath}/`)) { + throw new TypeError('proxy profile target is outside its configured base path'); + } + const relativePath = target.pathname.slice(basePath.length) || '/'; + const allowedPath = profile.allowed_paths.some((prefix) => + prefix === '/' || relativePath === prefix || relativePath.startsWith(`${prefix}/`), + ); + if (!allowedPath) throw new TypeError('proxy profile target path is not allowed'); + + const normalizedMethod = method.toUpperCase(); + if (!profile.allowed_methods.includes(normalizedMethod)) { + throw new TypeError(`method ${normalizedMethod} is not allowed by the proxy profile`); + } + return profile; +} + +/** Existing control headers remain the v1 execution adapter for cache and routing. */ +export function proxyProfileControlHeadersV1(input: unknown): Record { + const profile = normalizeProxyProfileV1(input); + return controlHeadersForNormalizedProfile(profile); +} + +function controlHeadersForNormalizedProfile(profile: ProxyExecutionProfileV1): Record { + return { + ...(profile.cache_ttl === undefined ? {} : { 'x-cache-ttl': String(profile.cache_ttl) }), + ...(profile.verbose === true ? { 'x-verbose': 'true' } : {}), + ...(profile.node_region === undefined ? {} : { 'x-node-region': profile.node_region }), + ...(profile.node_domain === undefined ? {} : { 'x-node-domain': profile.node_domain }), + ...(profile.node_exclude === undefined ? {} : { 'x-node-exclude': profile.node_exclude }), + ...(profile.direct === true ? { 'x-direct': 'true' } : {}), + }; +} + +export interface PreparedProxyProfileV1 { + profile: ProxyExecutionProfileV1; + profile_hash: string; + headers: Record; +} + +/** Canonicalize, enforce, hash, and apply a profile in one operation. */ +export function prepareProxyProfileRequestV1( + input: unknown, + targetUrl: string, + method: string, + headers: Record = {}, +): PreparedProxyProfileV1 { + const profile = assertProxyProfileRequestV1(input, targetUrl, method); + const requestHeaders = Object.fromEntries( + Object.entries(headers) + .filter(([key]) => !PROFILE_CONTROL_HEADERS.has(key.toLowerCase())) + .map(([key, value]) => [key, String(value)]), + ); + return { + profile, + profile_hash: hashNormalizedProfile(profile), + headers: { ...requestHeaders, ...controlHeadersForNormalizedProfile(profile) }, + }; +} diff --git a/src/runtime/profile-v1.vectors.json b/src/runtime/profile-v1.vectors.json new file mode 100644 index 0000000..3667810 --- /dev/null +++ b/src/runtime/profile-v1.vectors.json @@ -0,0 +1,54 @@ +{ + "_comment": "Shared profile-v1 canonicalization vectors. This file must remain byte-for-byte identical across consensus, consensus-client, and consensus-node.", + "version": 1, + "vectors": [ + { + "name": "catalog", + "input": { + "protocol": "consensus.proxy-profile", + "version": 1, + "base_url": "https://API.example.com:443/v1/", + "allowed_methods": ["get", "HEAD", "GET"], + "allowed_paths": ["/search", "/products", "/products"], + "cache_ttl": 120, + "node_region": "us-east", + "direct": false + }, + "normalized": { + "protocol": "consensus.proxy-profile", + "version": 1, + "base_url": "https://api.example.com/v1", + "allowed_methods": ["GET", "HEAD"], + "allowed_paths": ["/products", "/search"], + "cache_ttl": 120, + "verbose": false, + "node_region": "us-east", + "direct": false + }, + "hash": "3ad74b9c2800b3ef39132e89f440a3e8847988351e2daa316ad527770ed52c12" + }, + { + "name": "root-defaults-explicit", + "input": { + "protocol": "consensus.proxy-profile", + "version": 1, + "base_url": "http://example.com:80/", + "allowed_methods": ["HEAD", "GET"], + "allowed_paths": ["/"], + "verbose": true, + "direct": true + }, + "normalized": { + "protocol": "consensus.proxy-profile", + "version": 1, + "base_url": "http://example.com/", + "allowed_methods": ["GET", "HEAD"], + "allowed_paths": ["/"], + "cache_ttl": 300, + "verbose": true, + "direct": true + }, + "hash": "0576497336ea50afd848a53928cb232e6ec6b5ac0f1c1a299a7137361d70d46a" + } + ] +} diff --git a/src/runtime/proxy-command.ts b/src/runtime/proxy-command.ts index f5972da..3358939 100644 --- a/src/runtime/proxy-command.ts +++ b/src/runtime/proxy-command.ts @@ -1,32 +1,29 @@ import type { ProxyRequestMessage, ProxyResponseMessage } from "../tunnel/messages"; import { MESSAGE_TYPE, nowSeconds } from "../tunnel/messages"; +import { serveProxyRequest } from "./proxy-serve"; export async function executeProxyCommand(message: ProxyRequestMessage): Promise { const method = (message.method || "GET").toUpperCase(); - const start = performance.now(); const body = decodeBody(message.body, message.body_encoding); - - const response = await fetch(message.target_url, { + const response = await serveProxyRequest({ + target_url: message.target_url, method, - headers: { - ...(message.headers || {}), - "user-agent": "Consensus-Node/0.1", - }, - body: method === "GET" || method === "HEAD" ? undefined : body, - signal: AbortSignal.timeout(30_000), + headers: message.headers, + body, + profile: message.profile, }); - const responseBody = Buffer.from(await response.arrayBuffer()); - return { type: MESSAGE_TYPE.PROXY_RESPONSE, timestamp: nowSeconds(), reply_to: message.id ?? "", status: response.status, status_text: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - body: responseBody.toString("base64"), + headers: response.headers, + body: response.body.toString("base64"), body_encoding: "base64", + cached: response.cached, + profile_hash: response.profile_hash, }; } diff --git a/src/runtime/proxy-serve.ts b/src/runtime/proxy-serve.ts index ad85975..f5d0135 100644 --- a/src/runtime/proxy-serve.ts +++ b/src/runtime/proxy-serve.ts @@ -14,6 +14,11 @@ import { checkServerIdentity, type PeerCertificate } from "node:tls"; import { resolveAndCheckTarget, type SafeResolution } from "./ssrf"; +import { generateDedupeKey, sha256Hex, stableStringify } from "./dedupe"; +import { + prepareProxyProfileRequestV1, + type ProxyExecutionProfileV1, +} from "./profile-v1"; export type SsrfCheck = (url: string) => Promise; @@ -22,6 +27,7 @@ export interface ProxyServeRequest { method?: string; headers?: Record; body?: string | Buffer | null; + profile?: ProxyExecutionProfileV1; } export interface ProxyResult { @@ -29,6 +35,8 @@ export interface ProxyResult { statusText: string; headers: Record; body: Buffer; + cached?: boolean; + profile_hash?: string; } export interface ProxyServeOptions { @@ -49,6 +57,33 @@ interface BunFetchInit extends RequestInit { } const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_PROFILE_CACHE_ENTRIES = 1_000; +const profileCache = new Map(); + +export function clearProxyProfileCache(): void { + profileCache.clear(); +} + +function cachedProfileResult(key: string): ProxyResult | null { + const entry = profileCache.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + profileCache.delete(key); + return null; + } + return { ...entry.value, body: Buffer.from(entry.value.body), cached: true }; +} + +function storeProfileResult(key: string, value: ProxyResult, ttlSeconds: number): void { + if (profileCache.size >= MAX_PROFILE_CACHE_ENTRIES) { + const oldest = profileCache.keys().next().value as string | undefined; + if (oldest) profileCache.delete(oldest); + } + profileCache.set(key, { + value: { ...value, body: Buffer.from(value.body), cached: false }, + expiresAt: Date.now() + ttlSeconds * 1_000, + }); +} // Always-hop-by-hop headers (RFC 7230 §6.1); `host` we set ourselves. The // `Connection` header itself additionally NAMES further hop-by-hop headers that @@ -83,8 +118,8 @@ function buildHopByHopDenySet(headers: Record): Set { // target. On the direct data plane the client supplies the request headers and // the node serves them against the client's chosen upstream, so these are // stripped node-side as defense-in-depth — the orchestrator (relayed path) and -// the consensus-client both strip them too. Notably this keeps `x-api-key` (the -// caller's orchestrator scoping credential) from leaking upstream. +// the consensus-client both strip them too. The deprecated `x-api-key` remains +// only as a denylist entry and has no identity, routing, or cache semantics. // // Source of truth: STRIP_REQUEST_HEADERS in the consensus repo // (server/features/proxy/proxy.ts). This mirrors that list with ONE deliberate @@ -115,12 +150,69 @@ const CONSENSUS_CONTROL_HEADERS = new Set([ "forwarded", ]); +/** Only responses to safe, idempotent methods may be served from cache. Caching a + * POST/PUT/PATCH/DELETE would answer a repeated state-changing request locally and + * never contact the upstream, silently suppressing the second operation. */ +const CACHEABLE_METHODS = new Set(["GET", "HEAD"]); + +/** The headers this request will actually forward upstream, lowercased and sorted. + * Used both to build the outgoing Headers and to key the cache. */ +function forwardedHeaderEntries(effectiveHeaders: Record): Array<[string, string]> { + const deny = buildHopByHopDenySet(effectiveHeaders); + const entries: Array<[string, string]> = []; + for (const [key, value] of Object.entries(effectiveHeaders)) { + const lower = key.toLowerCase(); + if (deny.has(lower) || CONSENSUS_CONTROL_HEADERS.has(lower)) continue; + entries.push([lower, value]); + } + return entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); +} + +/** generateDedupeKey keys on a two-header allowlist (accept, content-type) because + * that is the locked cross-repo wire format. This node-local cache forwards far + * more than that — authorization, x-tenant-id, api-key — so keying on the dedupe + * key alone would serve one caller's response to another whose only difference is + * such a header. Fold every forwarded header into the local key. This value never + * leaves the node, so strengthening it does not touch the dedupe contract. + */ +function profileCacheKey( + dedupeKey: string, + forwarded: Array<[string, string]>, +): string { + return sha256Hex(`${dedupeKey}\n${stableStringify(forwarded)}`); +} + export async function serveProxyRequest( request: ProxyServeRequest, opts: ProxyServeOptions = {}, ): Promise { const ssrfCheck = opts.ssrfCheck ?? resolveAndCheckTarget; const method = (request.method ?? "GET").toUpperCase(); + const prepared = request.profile + ? prepareProxyProfileRequestV1(request.profile, request.target_url, method, request.headers) + : undefined; + const profile = prepared?.profile; + const profileHash = prepared?.profile_hash; + const effectiveHeaders = prepared?.headers ?? request.headers ?? {}; + const forwarded = forwardedHeaderEntries(effectiveHeaders); + + const cacheKey = + profile?.cache_ttl && CACHEABLE_METHODS.has(method) + ? profileCacheKey( + generateDedupeKey({ + target_url: request.target_url, + method, + headers: effectiveHeaders, + body: request.body, + profile_hash: profileHash, + }), + forwarded, + ) + : undefined; + if (cacheKey) { + const cached = cachedProfileResult(cacheKey); + if (cached) return cached; + } // SSRF gate: throws TypeError for private/loopback/invalid targets. const resolution = await ssrfCheck(request.target_url); @@ -133,13 +225,8 @@ export async function serveProxyRequest( // Pin the connection to the verified IP — no second DNS lookup. url.hostname = resolution.family === 6 ? `[${resolution.ip}]` : resolution.ip; - const deny = buildHopByHopDenySet(request.headers ?? {}); const headers = new Headers(); - for (const [key, value] of Object.entries(request.headers ?? {})) { - const lower = key.toLowerCase(); - if (deny.has(lower) || CONSENSUS_CONTROL_HEADERS.has(lower)) continue; - headers.set(key, value); - } + for (const [key, value] of forwarded) headers.set(key, value); headers.set("host", originalHost); if (!headers.has("user-agent")) headers.set("user-agent", "Consensus-Node/0.1"); @@ -163,10 +250,16 @@ export async function serveProxyRequest( const response = await fetch(url.toString(), init); - return { + const result: ProxyResult = { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), body: Buffer.from(await response.arrayBuffer()), + cached: false, + ...(profileHash ? { profile_hash: profileHash } : {}), }; + if (cacheKey && profile?.cache_ttl && result.status >= 200 && result.status < 300) { + storeProfileResult(cacheKey, result, profile.cache_ttl); + } + return result; } diff --git a/src/runtime/proxy-worker.ts b/src/runtime/proxy-worker.ts index e4fdb6b..d07952e 100644 --- a/src/runtime/proxy-worker.ts +++ b/src/runtime/proxy-worker.ts @@ -1,4 +1,6 @@ import type { FastifyInstance } from "fastify"; +import { serveProxyRequest } from "./proxy-serve"; +import type { ProxyExecutionProfileV1 } from "./profile-v1"; export async function registerProxyRoutes(app: FastifyInstance): Promise { app.post("/proxy", async (request, reply) => { @@ -7,36 +9,36 @@ export async function registerProxyRoutes(app: FastifyInstance): Promise { method?: string; headers?: Record; body?: unknown; + profile?: ProxyExecutionProfileV1; }; if (!body?.target_url) return reply.code(400).send({ error: "Missing target_url" }); const method = (body.method || "GET").toUpperCase(); const start = performance.now(); - const response = await fetch(body.target_url, { + const response = await serveProxyRequest({ + target_url: body.target_url, method, - headers: { - ...(body.headers || {}), - "user-agent": "Consensus-Node/0.1" - }, + headers: body.headers, body: method === "GET" || method === "HEAD" ? undefined : typeof body.body === "string" ? body.body : JSON.stringify(body.body ?? null), - signal: AbortSignal.timeout(30_000) + profile: body.profile, }); - - const responseText = await response.text(); + const responseText = response.body.toString("utf8"); return reply.code(response.status).send({ status: response.status, statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), + headers: response.headers, data: responseText, meta: { processing_ms: Math.round(performance.now() - start), - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), + cached: response.cached, + profile_hash: response.profile_hash, } }); }); diff --git a/src/secrets-check.ts b/src/secrets-check.ts new file mode 100644 index 0000000..604a64f --- /dev/null +++ b/src/secrets-check.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env bun +// +// Preflight for encryption at rest. Provisions the data key if it does not exist +// yet, then proves it can be read back and that the node's existing secrets open +// with it. +// +// The boot-unit installers run this AS THE ACCOUNT THE DAEMON WILL RUN AS, before +// enabling the unit. That is the point: the daemon starts with no login session, so +// "can this account reach the data key unattended?" has to be answered while an +// operator is still at the keyboard, not on the next reboot. +// +// Exit codes: 0 healthy, 1 unusable. + +import fs from "node:fs/promises"; +import { describeKeystore, getOrCreateDataKey, readSecretFile, SLOT_NODE_KEY, SLOT_JOIN_AUTH } from "./node/secret-store"; +import { paths } from "./node/state"; + +async function main(): Promise { + const before = await describeKeystore(); + console.log(`keystore: ${before.adapter}`); + console.log(`location: ${before.location}`); + console.log(`existing: ${before.present ? "yes" : "no (will be created)"}`); + + await getOrCreateDataKey(); + + const after = await describeKeystore(); + if (!after.present && after.adapter !== "env") { + throw new Error(`data key was not persisted to ${after.location}`); + } + + // Opening the real secrets is the only proof that matters: a key that exists but + // does not match what is already on disk would strand the node at boot. + const p = paths(); + for (const [slot, file] of [ + [SLOT_NODE_KEY, p.privateKeyPem], + [SLOT_JOIN_AUTH, p.joinAuth], + ] as const) { + try { + await fs.access(file); + } catch { + console.log(`${slot}: absent (nothing to verify yet)`); + continue; + } + try { + await readSecretFile(slot, file); + } catch (error) { + // An AEAD tag failure means the data key does not match this ciphertext — + // the key was rotated, lost, or minted under a different account. Say that, + // rather than surfacing "invalid tag" and leaving the operator to guess. + throw new Error( + `${slot} at ${file} could not be decrypted with the data key at ${after.location}. ` + + "The key does not match this node's secrets — it was most likely lost, rotated, or " + + "created under a different user account. Restore the original key file; a node " + + `cannot recover ${SLOT_NODE_KEY} without it and would have to re-register. ` + + `(underlying: ${error instanceof Error ? error.message : String(error)})`, + ); + } + console.log(`${slot}: opens correctly`); + } + + console.log("secrets ok — this account can decrypt unattended"); +} + +main().catch((error) => { + console.error(`secrets check FAILED: ${error instanceof Error ? error.message : String(error)}`); + console.error("The node would not be able to start without a login. Resolve this before enabling the boot unit."); + process.exit(1); +}); diff --git a/src/setup.ts b/src/setup.ts index 9eaf9a4..00d6b46 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -277,7 +277,15 @@ async function offerStartPm2(rl: readline.Interface, installDir: string, serverU console.log(`PM2 is managing ${appName}.`); console.log(`Logs: pm2 logs ${appName}`); - console.log("For reboot persistence, run `pm2 startup`, follow its printed command, then run `pm2 save`."); + // Deliberately NOT `pm2 startup`: on macOS that writes a LaunchAgent, which only + // loads once a user logs in, so the node stays down after an unattended reboot. + if (process.platform === "darwin") { + const installer = path.join(installDir, "current", "scripts", "install-launchd.sh"); + console.log(`For boot persistence without login, run: sudo ${installer}`); + } else { + const unit = path.join(installDir, "current", "systemd", "consensus-node.service"); + console.log(`For boot persistence, install the systemd unit: ${unit}`); + } } async function collectWalletAddresses(rl: readline.Interface, progress: SetupProgress): Promise { diff --git a/src/supervise.ts b/src/supervise.ts new file mode 100755 index 0000000..27aa58e --- /dev/null +++ b/src/supervise.ts @@ -0,0 +1,229 @@ +#!/usr/bin/env bun +// +// Supervised node unit — runs BOTH the control tunnel and the runtime server as +// one unit. Replaces scripts/run-node.sh. +// +// The control tunnel (`bun run control`) is the whole data path: it carries +// heartbeats, proxy work, AND the client-facing data plane, which the orchestrator +// node-gateway bridges onto it (target {kind:"data-plane"} streams). The runtime +// server (`bun run start`) binds loopback-only and just serves local operator +// endpoints (/health, /node/*); it needs no inbound port or TLS. Running both under +// one PM2/systemd/launchd unit means a single restart refreshes both from the +// updated `current` symlink. +// +// The unit exits as soon as EITHER child exits: the control tunnel exits on +// `update_apply` (so the whole unit restarts onto the new release), and a crash of +// either child cycles the unit too. +// +// This is a port of run-node.sh, which needed `wait -n` and therefore bash >= 4.3. +// Stock macOS ships bash 3.2, so that script exited 78 on any Mac whose operator +// had not run `brew install bash` — the reason this is TypeScript now. + +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { log } from "./log"; + +/** Same exit code run-node.sh used when `current` is missing. */ +const NO_RELEASE_EXIT = 70; + +/** How long a child gets to honour SIGTERM before we SIGKILL it. Stays under + * PM2's kill_timeout (30s) and systemd's TimeoutStopSec (30s) so the unit + * always tears itself down rather than being killed by its supervisor. + * Overridable only so the test suite does not have to wait out the real grace. */ +const SHUTDOWN_GRACE_MS = (() => { + const raw = Number(process.env.CONSENSUS_SUPERVISE_GRACE_MS); + return Number.isFinite(raw) && raw > 0 ? raw : 10_000; +})(); + +function envOr(name: string, fallback: string): string { + const value = process.env[name]; + return value && value.length > 0 ? value : fallback; +} + +const installDir = envOr( + "CONSENSUS_NODE_INSTALL_DIR", + path.join(os.homedir(), ".consensus", "node-runtime"), +); +const stateDir = envOr("CONSENSUS_STATE_DIR", path.join(os.homedir(), ".consensus", "node")); +const serverUrl = envOr("CONSENSUS_SERVER_URL", "https://consensus.canister.software"); +const currentDir = path.join(installDir, "current"); +const updateCommand = envOr( + "CONSENSUS_NODE_UPDATE_COMMAND", + path.join(currentDir, "scripts", "install-release.sh"), +); + +// Exported to the children exactly as run-node.sh did, so a release that reads +// these directly sees identical values whether it was started here or by hand. +const childEnv: NodeJS.ProcessEnv = { + ...process.env, + CONSENSUS_STATE_DIR: stateDir, + CONSENSUS_SERVER_URL: serverUrl, + CONSENSUS_NODE_INSTALL_DIR: installDir, + CONSENSUS_NODE_UPDATE_COMMAND: updateCommand, +}; + +// Prefer the interpreter we are already running under. A LaunchDaemon or systemd +// unit gets a minimal PATH that will not contain ~/.bun/bin, so resolving "bun" +// by name is exactly the failure mode this port is meant to remove. Falls back to +// PATH lookup only if something started us under a non-bun runtime. +const bunExecutable = process.versions.bun ? process.execPath : "bun"; + +interface Child { + name: string; + proc: ChildProcess; +} + +interface Exit { + name: string; + code: number; +} + +/** POSIX convention: a process killed by a signal reports 128 + signal number. */ +function exitCodeFor(code: number | null, signal: NodeJS.Signals | null): number { + if (typeof code === "number") return code; + if (signal) return 128 + (os.constants.signals[signal] ?? 0); + return 1; +} + +function startChild(name: string, script: string): Child { + // stdio is inherited so both children write straight to the unit's stdout/stderr, + // where PM2 / systemd / launchd already capture them. Same as the shell version. + // + // detached puts each child in its own process group. `bun run