igneum/relay/README.md
igneum-labs 1065b81d05 relay: three auth tiers, signed run tasks, machine secrets, retention; clients on headers; TZ=UTC and curl -K checks (X23 X24 X25 X26 X27 X28 X29 G13 G14)
Relay (X23, X27): the intake key is its own tier (upload and file drops only, RELAY_INTAKE_COMPAT=0 closes it);
a run task needs an Ed25519 signature by the Mac run key over {to, nonce, body sha256, flags} (RELAY_RUN_PUB,
401 without) and an HMAC tag with the target's machine secret that the agent verifies before anything runs;
results and registration are bound to the machine the secret proves (403 on a forged from).
X24: every client and Mac tool sends x-relay-token as a header to /api/relay?fn=; the path token stays for the
phone page only. X25: the agent arms the logon task only for a restart a task asked for and disarms on start
and exit. X26: 30-day retention with blob deletion, feed capped at 100, the dl base as RELAY_DL_BASE held by the
agent, never in a body. X28: GET inbox never acks (POST inbox does), RELAY-REBOOT on its own line and only with a
reboot flag, 120/min and 10 failed auths/min per IP, no username or folder on register, WSL sudo scoped to
apt-get and dpkg with SETENV, no password on a command line. X29: the intake key reaches curl through -K in
upload.sh and both upload-log.bat; tools/ci/curl-header-check.sh fails the class. G14: TZ=UTC in ship-app.mjs
and publish-jobs.sh; tools/ci/commit-tz-check.sh fails the class; history-rewrite.md names the .old-2026-10-05
files as the values in the history. The handler moved to relay/lib/handler.mjs with injected sql and blobs
(relay/lib/blob.mjs holds @vercel/blob) so relay/test/handler.test.mjs drives it without a database:
47 tests across 6 suites, all green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 18:46:13 +00:00

102 lines
17 KiB
Markdown

# Igneum relay and console
Text, files and tasks between the project lead's devices without Gmail: the Mac, PC1, PC2 and the phone post to one feed and read from it. Vercel project `igneum-relay`, served at https://relay.igneum.network. Built 4 October 2026. Since the evening of 4 October 2026 the same private page is the Igneum console (`ui.html`): seven tabs, phone first, refreshed every 15 s. The relay feed and drop box are its last tab.
**Scope since 4 October 2026 (afternoon):** the relay stays for the Mac and for humans (notes, files, tasks for a person or a Claude session on a PC). Commands and files for the PCs themselves go over the line to the Igneum Miner app instead: signed jobs published next to the update manifest (`packaging/ota/publish-jobs.sh`, read back with `tools/jobs.mjs`, documented in `packaging/ota/README.md`, "Remote jobs"). The app jobs replace the PC agent (`igneum-agent.bat`): PC 2 has no Claude session and nobody at the keyboard, and both PCs report the same hostname (DESKTOP-KMCV30N), which the relay's registration cannot tell apart; the app's per-install machine id can. The playbooks under `relay/playbooks/` stay as the relay form of the same runs (`shard-test.ps1` is the model for a `run` job) and are parse-checked by `windows.yml`.
## The console
| Tab | Shows | Source |
|---|---|---|
| Machines | one card per machine: app and node version, height (daa), synced, hash rate, accepted blocks, peers, faults, power and temperatures, last seen; red after 3 min without an upload, grey "stopped (quit\|update)" when the app's last upload ends on its own quit lines; a worker card whose STATUS line is over 180 s old is marked stale and left out of the machine total; the OTA state is the newest update line the app logged (downloading, downloaded, staged, installing, updated, failed, current); worker labels nvidia, amd, mac, metal, opencl, other and intel | Neon `miner_logs` (the log intake in `site/api/log.mjs`): newest upload per label, the last 20 KB parsed server side (miner `STATUS` lines, node log, the app's `stability:` lines) |
| Jobs | the signed jobs file with per-machine status (queued, running, done + exit code) and the result line; tap a run for the full upload | `igneum-jobs.json` on the downloads host (fetched server side with `DL_TOKEN`), results from `miner_logs` rows whose `run_id` is `job-<id>-<machine>` |
| Builds | the OTA manifest (version, notes, platforms, sizes), the last CI fetch, build events, the downloads folder listing | `igneum-app-latest.json` and `igneum-windows-ci.json` on the downloads host; `console_items` kind `build` (posted by `packaging/windows/fetch-ci-artifacts.sh` and `packaging/ota/publish-manifest.sh`) and key `dl` (`tools/console.mjs sync-dl`) |
| Chain | blocks, identities, hash estimate, difficulty, last lock, finality state, peers, blocks per minute sparkline, events, Hetzner results | `https://igneum.network/api/live` fetched server side; `console_items` key `hetzner` (`sync-hetzner`) |
| Work log | what the agents and the main session post, merged with every relay item, newest first | `console_items` kinds `log`, `build`, `note`; the relay feed |
| Results | the bench log entries (heading + first paragraph), newest first; the FUD ledger counts by status | `console_items` kind `bench` and key `ledger`, written by `tools/console.mjs sync-bench` from `docs/bench-log.md` and `docs/fud-ledger.md` |
| Relay | the feed and the drop box, unchanged | `relay_items`, `relay_machines` |
The console function is `api/console.mjs`, reached through the rewrite `/r/<token>/c/<fn>`. Every GET answer is cached 10 s in the function instance. No secret reaches the client: the token in the path is the only auth, and `DL_TOKEN` (the downloads folder) lives in the project env and is used only server side. No GitHub token anywhere: build events come from the Mac-side scripts.
Mac: `node tools/console.mjs post --kind log --title "..." --body "..."` writes one work-log item (kinds `log`, `build`, `note`); `log`, `machines`, `chain`, `jobs`, `builds`, `results` print the tabs; `sync-bench`, `sync-dl`, `sync-hetzner` or `sync` push the file-derived data; `url` prints the link.
The app log (label `win-<id8>` or `mac-<id8>`) reaches the intake since b8b349a (4 Oct 2026, 0.3.3); apps before that show `app ?`. The parsers (labels, miner tail, app tail, the stale mark) live in `relay/lib/parse.mjs` with no dependencies, and `relay/lib/auth.mjs` holds the constant-time secret compare; `node --test relay/test/parse.test.mjs relay/test/auth.test.mjs` runs their tests, and CI runs them in the site job.
## The secrets, since 5 October 2026 (night): three tiers, headers, no token in a URL
| Secret | Where it lives | Sent as | May |
|---|---|---|---|
| the console token (20 base32 characters) | `~/.config/igneum/relay-token`, `RELAY_TOKEN` on the project | the `x-relay-token` header from every tool and client; the URL path `/r/<token>/` only for the phone's page (`ui.html`) and the calls that page makes (`/r/<token>/api/<fn>`, `/r/<token>/c/<fn>`, `/r/<token>/wake`) | everything, except that a `run` task also needs the run signature below |
| the relay key (48 characters, the relay's own since 4 October 2026) | `~/.config/igneum/relay-key`, `RELAY_KEY` | `x-igneum-key` | read the feed, items, files, inbox, machines; post notes, files, results; register; ack; done. Never `task`, `run`, `name`, `role`, `secret`, `delete` |
| the log-intake key (inside every shipped package) | `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT` | `x-igneum-key` | only `upload` and a `drop` of kind `file` or `text`: the PC apps' build-job outputs (`jobrun.rs`). No reads, no results, nothing else. `RELAY_INTAKE_COMPAT=0` on the project closes this tier once the apps carry a relay key of their own |
| the run key (Ed25519, `node tools/relay.mjs keygen`) | `~/.config/igneum/relay-run-key` (seed, 0600) and `.pub`; the public half as `RELAY_RUN_PUB` on the project | `flags.sig` on every `run` task, over the canonical text in `relay/lib/guard.mjs` (`runCanon`: machine, nonce, body sha256, the elevated, reboot_continue and reboot flags) | without a verifying signature the relay answers 401 to a `run`; without `RELAY_RUN_PUB` every `run` is refused |
| a machine secret per PC (64 hex, `node tools/relay.mjs secret PC1`) | `~/.config/igneum/relay-machines/<name>` on the Mac; `machine-secret.txt` beside the agent on that PC (from `make-clients.sh --machine`); its sha256 on the relay (`relay_machines.secret_hash`) | `x-machine-secret` on `register`, `result`, `done`; `flags.mac` on every `run` (an HMAC-SHA256 tag the agent verifies before it executes, because Windows PowerShell 5.1 has no Ed25519) | a result's `from` is the machine the secret proves (a mismatch is 403); a bound hostname cannot register without it; the agent runs nothing whose tag does not verify, and never the same nonce twice |
Review round 4 (X23) found one tier with every power: the intake key inside every package queued PowerShell on PC 1 as administrator. Now the token alone cannot queue a `run` either: the Mac signs it with the run key (checked by the relay) and tags it with the target's secret (checked by the agent). Compare is constant time (`lib/auth.mjs`). Blob file URLs carry a random segment and a random suffix; they are not listed anywhere. 120 calls a minute per IP, 10 failed authentications a minute per IP, then 429.
### Rotation (what each secret's change breaks, and the order)
| Rotate | How | What stops, until | Order |
|---|---|---|---|
| the console token | new value in `~/.config/igneum/relay-token` and `RELAY_TOKEN`; a new zip per PC (`make-clients.sh --machine`) carried by hand; the phone gets the new link | every PC client and the phone page, until the new zip and link are in place; the Mac tools at once (they read the file) | any time; the old zip on a PC is dead the moment the project env changes |
| the relay key | new value in `relay-key` and `RELAY_KEY`; new zips | the PC clients' reads and reports until the new zip; `tools/build-job.mjs` falls back to the token | with the token, same zip |
| the intake key | `LOG_INTAKE_KEY_NEXT` on the relay AND the site intake first, repackage every app with the next key (`packaged-config.sh`), publish; then move `_NEXT` to `LOG_INTAKE_KEY`; then `RELAY_INTAKE_COMPAT=0` once no shipped app uploads build outputs with it | build-job uploads from every app that still carries the old key; log uploads from every shipped package until it updates | the long one: apps update over days, so both values are accepted during the window (`docs/plans/rotation-phase-2.md`) |
| the run key | `node tools/relay.mjs keygen` after moving the old `relay-run-key` aside; `RELAY_RUN_PUB` on the project | every `run` queued with the old key is refused at the relay; nothing on a PC changes (the PCs hold no run key) | any time, in one step |
| a machine secret | `node tools/relay.mjs secret PC1 --rotate` (rebinds the sha256), `make-clients.sh --machine PC1`, carry the zip | that PC's results and registration until the new zip is there; queued `run` tasks tagged with the old secret are refused by the agent | per machine, any time |
What the 5 October rotation already did: the relay token (4 October), the intake key and the dl token (`.old-2026-10-05` beside the live files). What is still the project lead's: the hosted `igneum-relay-clients.zip` off the downloads host (it holds the old token and key; dead values now, but the file is the shape X23 names), `RELAY_RUN_PUB` on the project after `keygen`, one `secret` per PC and the zips carried by hand.
## What is stored where
| Thing | Where | Limit |
|---|---|---|
| Items (text, title, who, kind, flags, read and done marks) | Neon table `relay_items` (database `igneum`) | body 1 MB |
| Machines (name, hostname, role, GPU and WSL facts, last seen) | Neon table `relay_machines` | |
| Files | Vercel Blob store `igneum-relay` (public URLs with random path and suffix, London) | 50 MB per file through a client token; 4 MB when pushed through the function |
| The token and keys | `~/.config/igneum/relay-token`, `relay-key`, `relay-run-key`, `relay-machines/<name>`; project env (`RELAY_TOKEN`, `RELAY_KEY`, `RELAY_RUN_PUB`, `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT`) | never in the repo |
| Retention | rows older than 30 days are deleted with their blobs, checked on a feed read at most every 10 minutes per instance; `delete` removes the blob with the row (X26) | 30 days |
Kinds: `text` (a note), `file`, `task` (for a person or a Claude session on a PC), `run` (a script the agent executes; signed and tagged, see above), `result` (what a task produced, linked by `task_id`; `from` is the machine the secret proves, `flags.unbound` marks one from a machine with no secret yet). Roles: `miner`, `prover`, `bench`, `mac`, `phone`.
## API (`/api/relay?fn=<name>` with the headers above; `/r/<token>/api/<fn>` from the phone's page only)
| Call | Does |
|---|---|
| `GET feed?since=&before=&machine=&limit=` | items newest first (50 by default, 100 at most) plus every machine with its unread count and whether it is bound |
| `GET item?id=` | one item with its full body |
| `GET file?id=[&download=1]` | 302 to the file |
| `GET inbox?machine=PC1&kind=run|task` | unread, not done tasks for that machine; a GET never marks anything |
| `POST inbox {machine, kind, ack:true}` | the same list, marked read (the agents use this) |
| `GET machines` | names, roles, hostnames, last seen, bound |
| `POST drop` | JSON `{from,to,kind,title,body,file_name,file_url,size,task_id,flags}`; or raw bytes with `Content-Type: application/octet-stream` and `x-file-name` (4 MB cap). A `result` needs `x-machine-secret` when its machine is bound |
| `POST task` | same fields; `kind` `task` or `run`; `run` needs one named machine and `flags {elevated, reboot_continue, reboot, nonce, sig, mac}`; `tools/relay.mjs run` fills the last three in. 401 without a verifying `sig`, 409 on a reused nonce |
| `POST upload` | `{name,size}` returns a one-hour Blob client token and `put_url`; PUT the bytes there, then `drop` with the returned `url` |
| `POST ack {ids}` `POST done {id,exit_code}` `POST delete {id}` | marks; `delete` takes the blob with the row (token only) |
| `POST register {hostname,info}` | a machine checks in; with `x-machine-secret` the secret names it whatever the hostname says (both PCs report DESKTOP-KMCV30N); `info.user` and `info.dir` are dropped |
| `POST secret {name, secret_hash}` | binds a machine to the sha256 of its secret (token only; `tools/relay.mjs secret` does it) |
| `POST name {hostname,name}` `POST role {name,role}` | naming and roles, from the Mac (token only) |
Tests: `node --test relay/test/guard.test.mjs relay/test/handler.test.mjs relay/test/clients.test.mjs` (the rules, the handler against a fake database and fake blobs, the clients' shape), beside the parse, auth and wake suites; CI runs all six.
Wake (`api/wake.mjs`, 0.3.6, 5 October 2026): `GET /wake?since=<stamp>` is public (the apps hold no token) and rate limited, 30 a minute per IP. It holds up to 45 s and answers `{stamp, at, added, changed, held_ms}` the moment the stored stamp differs from `since`, else the unchanged stamp at the deadline; without `since` it answers at once. `POST /r/<token>/wake {stamp, added}` (or `POST /wake` with `x-relay-token` or `x-igneum-key`) records the stamp; `packaging/ota/publish-jobs.sh` sends it after every verified deploy, with the ids it added. One row per stamp in Neon table `relay_wake` (created by the first POST); `tools/jobs.mjs status` reads the rows for the woken latency. The function's `maxDuration` is 60 s (`vercel.json`). Tests: `relay/test/wake.test.mjs` drives the handler with a fake database and clock.
## Mac
`node tools/relay.mjs` (feed), `read <id>`, `drop "<text>"|<file>`, `task PC2 "title" [file]`, `run PC2 "title" script.ps1 [--elevated] [--reboot-continue] [--reboot]`, `keygen`, `secret PC2`, `watch`, `inbox PC1 [--ack]`, `machines`, `role PC2 prover`, `name DESKTOP-XYZ PC2`, `ack|done|rm <id>`, `url`. Playbooks live in `relay/playbooks/`; a playbook reads the downloads base from `$env:RELAY_DL_BASE` (bash: `$RELAY_DL_BASE`), which the agent holds; `run` refuses a body that says `__DL_BASE__` or carries the dl token (X26). A script asks for a restart by printing `RELAY-REBOOT` on a line of its own, and only a task queued with `--reboot` or `--reboot-continue` restarts the PC.
## PCs
`relay/clients/make-clients.sh --machine PC1` bakes the URL, key, token, downloads base and that PC's secret into copies of the clients and writes `~/Desktop/igneum-relay-clients-PC1.zip`. Carry it by hand; never through the downloads host. Unzip anywhere on the PC. `send.bat` for people and Claude sessions (see `CLAUDE-PC.md`), `igneum-agent.bat` for the automatic runner: double-click once, leave it open. It registers the PC (GPUs, WSL, nvcc; no username, no folder), polls every 20 s, checks each `run` task's tag and nonce against its secret (a task that fails is answered with exit 77 and nothing of it runs), runs the rest in order, posts a `result` (exit code, last 64 KB inline, full log as a file when longer) and marks it done. A script that prints `RELAY-REBOOT` on its own line, in a task queued with `--reboot` or `--reboot-continue`, triggers `shutdown /r /t 10`; with `reboot_continue` the agent arms a logon task (highest privileges, RunOnce as a fallback) for that one restart and re-runs the task after it with `RELAY_PASS` incremented. The agent removes the logon task and the RunOnce key every time it starts and when it exits (Ctrl+C included; a closed window is caught by the next start). Nothing is armed on an ordinary start (X25). The PC must sign in by itself for a restart to be unattended.
An unknown hostname that registers appears in the feed with a "name this machine" box, or `node tools/relay.mjs name <hostname> PC2`. PC1 is DESKTOP-KMCV30N.
## Deploy
```
cd relay && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes --scope igneum
```
Env on the project: `DATABASE_URL`, `RELAY_KEY`, `RELAY_TOKEN`, `RELAY_RUN_PUB` (the run key's public half; without it every `run` is refused), `LOG_INTAKE_KEY` and `LOG_INTAKE_KEY_NEXT` (the intake tier; `RELAY_INTAKE_COMPAT=0` closes it), `BLOB_READ_WRITE_TOKEN` (added by `vercel blob create-store`), `DL_TOKEN` (the downloads folder token, for the console; added 4 Oct 2026). DNS: `relay` CNAME `cname.vercel-dns.com` in the deSEC zone. The first `secret` or `feed` call after the deploy adds `relay_machines.secret_hash` (`ADD COLUMN IF NOT EXISTS`, no long lock).
## Untested until a PC runs it (4 Oct 2026)
`send.ps1`, `igneum-agent.ps1` and the five PowerShell playbooks were written and syntax-reviewed on the Mac (no `pwsh` here). The bash twin `agent.sh` and `send.sh` ran end to end against the live relay on 4 October. The 5 October (night) rewrite of all four clients (headers, the machine tag check, the disarm, POST inbox, `-K`) is covered by `relay/test/clients.test.mjs` on the Mac and by the PowerShell 5.1 parse in `windows.yml` on the next push; it has not run on a PC. Expect a first-run fix on Windows: `Start-Process -Wait` exit codes through the wrapper, `wsl --install --no-launch` on pass 2, the RunOnce path after a reboot, and `schtasks /Query /TN IgneumRelayAgent` on PC 1 (owed: it must return nothing after the new agent's first start).