Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down Expand Up @@ -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 `<install-dir>/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 `<install-dir>/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/<version>/`, 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.

Expand Down
4 changes: 2 additions & 2 deletions CONNECT_NODE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
8 changes: 4 additions & 4 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
37 changes: 20 additions & 17 deletions ecosystem.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 0 additions & 40 deletions launchd/com.consensus.node.plist

This file was deleted.

93 changes: 93 additions & 0 deletions launchd/com.consensus.node.plist.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
LaunchDaemon template for the Consensus node.

Installed to /Library/LaunchDaemons by scripts/install-launchd.sh, which replaces
every at-sign-delimited placeholder below with an absolute path. This is a TEMPLATE
and not a usable plist on its own: launchd performs NO variable expansion
whatsoever, so a literal ${HOME} or ~ in any value below would be taken literally
rather than expanded. (The plist this replaces had exactly that bug.)

Do not write a placeholder-shaped token into these comments — install-launchd.sh
greps the rendered file for leftovers and aborts if it finds any.

A LaunchDaemon, not a LaunchAgent: agents load only at user login, which is the
whole problem this is here to solve. This runs at boot with no login. It launches
pm2-runtime — PM2 in the foreground, as a single supervised process — rather than
relying on `pm2 startup`, which itself emits a login-gated LaunchAgent.
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.consensus.node</string>

<key>ProgramArguments</key>
<array>
<string>@PM2_RUNTIME@</string>
<string>start</string>
<string>@INSTALL_DIR@/current/ecosystem.config.cjs</string>
<string>--only</string>
<string>@APP_NAME@</string>
</array>

<!-- Drop from root to the operator so everything under the state dir keeps its
existing ownership. Without this the daemon would run as root and write
root-owned files into a user-owned tree. -->
<key>UserName</key>
<string>@USER@</string>

<key>WorkingDirectory</key>
<string>@INSTALL_DIR@/current</string>

<key>EnvironmentVariables</key>
<dict>
<!-- A daemon inherits no HOME, and both PM2 and bun need one. -->
<key>HOME</key>
<string>@HOME@</string>
<!-- launchd's default PATH has neither node nor bun on it. -->
<key>PATH</key>
<string>@PATH@</string>
<key>PM2_HOME</key>
<string>@HOME@/.pm2</string>
<key>CONSENSUS_SERVER_URL</key>
<string>@SERVER_URL@</string>
<key>CONSENSUS_STATE_DIR</key>
<string>@STATE_DIR@</string>
<key>CONSENSUS_NODE_INSTALL_DIR</key>
<string>@INSTALL_DIR@</string>
<key>CONSENSUS_NODE_UPDATE_COMMAND</key>
<string>@INSTALL_DIR@/current/scripts/install-release.sh</string>
<key>CONSENSUS_PM2_NAME</key>
<string>@APP_NAME@</string>
<key>CONSENSUS_BUN_PATH</key>
<string>@BUN@</string>
</dict>

<key>RunAtLoad</key>
<true/>

<key>KeepAlive</key>
<true/>

<!-- launchd refuses to respawn a job more than once per ThrottleInterval, which
keeps a node that is crash-looping (bad release, no network) from spinning. -->
<key>ThrottleInterval</key>
<integer>10</integer>

<key>ProcessType</key>
<string>Background</string>

<!-- Must exceed PM2's kill_timeout (30s) so PM2 can drain the node's children
before launchd loses patience and SIGKILLs the whole job. -->
<key>ExitTimeOut</key>
<integer>40</integer>

<key>StandardOutPath</key>
<string>@STATE_DIR@/launchd.out.log</string>

<key>StandardErrorPath</key>
<string>@STATE_DIR@/launchd.err.log</string>
</dict>
</plist>
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading