diff --git a/docs/plans/relay-deploy-2026-10-07.md b/docs/plans/relay-deploy-2026-10-07.md new file mode 100644 index 00000000..fdebbac5 --- /dev/null +++ b/docs/plans/relay-deploy-2026-10-07.md @@ -0,0 +1,16 @@ +# Relay deploy, 7 October 2026 (the X23 tree, MF-11) + +| What | Value | +|---|---| +| Deployed | 2026-10-07 15:38 BST, from branch update-return bd9b4f4e, relay/ | +| Production deployment | https://igneum-relay-iabqarnby-igneum.vercel.app (inspect KHyscUSVKueXueqxvZBRKkaUHwJR), aliased https://relay.igneum.network | +| Previous production | https://igneum-relay-bgy767z40-igneum.vercel.app | +| Env added | RELAY_RUN_PUB (the public half of ~/.config/igneum/relay-run-key, made by `node tools/relay.mjs keygen` the same hour); RELAY_INTAKE_COMPAT unset | +| Read-backs | /wake answers; fn=machines carries the bound field; the console page answers 200 and c/machines carries poll fields; a v1-shaped register by hostname (key tier) answers named:false bound:false; a signed start-app from the Mac answers 200 (item 394); an unsigned run from master's old tool is refused | + +## Rollback + +`cd relay && npx --yes vercel@latest --global-config ~/.config/igneum/vercel --scope igneum rollback https://igneum-relay-bgy767z40-igneum.vercel.app` +(or `vercel promote https://igneum-relay-bgy767z40-igneum.vercel.app`). Seconds; nothing to undo in the database: the X23 code adds relay_machines.secret_hash and the +table relay_wake_seen, both ignored by the old code. What a rollback loses: signed run verification (the old relay never +had it), the start-app kind, the ping; PC 2's new agent registers by hostname on either. diff --git a/relay/README.md b/relay/README.md index 4fdad445..4b319e7e 100644 --- a/relay/README.md +++ b/relay/README.md @@ -22,9 +22,29 @@ Mac: `node tools/console.mjs post --kind log --title "..." --body "..."` writes The app log (label `win-` or `mac-`) 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 secret is the path +## The secrets, since 5 October 2026 (night): three tiers, headers, no token in a URL -The web page lives at `/r//` and every API call sits under `/r//api/`. The token is 20 base32 characters generated once and stored at `~/.config/igneum/relay-token` on the Mac (and as `RELAY_TOKEN` in the project). Anyone with the link can read and post, so the link stays with the project lead. Scripts may present the log intake key in `x-igneum-key` instead (`RELAY_KEY`, the same value as `~/.config/igneum/log-intake-key`). There is no other login. Blob file URLs carry a random segment and a random suffix; they are not listed anywhere. +| 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//` only for the phone's page (`ui.html`) and the calls that page makes (`/r//api/`, `/r//c/`, `/r//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/` 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 @@ -33,49 +53,62 @@ The web page lives at `/r//` and every API call sits under `/r//ap | 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 key | `~/.config/igneum/relay-token`, `~/.config/igneum/relay-key` (the relay's own key since 4 October 2026, round 4 X23; the log-intake key no longer opens the relay); project env | never in the repo | +| The token and keys | `~/.config/igneum/relay-token`, `relay-key`, `relay-run-key`, `relay-machines/`; 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), `result` (what a task produced, linked by `task_id`). Roles: `miner`, `prover`, `bench`, `mac`, `phone`. +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 (all under `/r//api/`) +## API (`/api/relay?fn=` with the headers above; `/r//api/` from the phone's page only) | Call | Does | |---|---| -| `GET feed?since=&before=&machine=&limit=` | items newest first (200 by default) plus every machine with its unread count | +| `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&ack=1` | unread, not done tasks for that machine; `ack=1` marks them read | -| `GET machines` | names, roles, hostnames, last seen | -| `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) | -| `POST task` | same fields; `kind` `task` or `run`; `run` needs one named machine and flags `{elevated, reboot_continue}` | +| `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 | -| `POST register {hostname,info}` | a machine checks in; returns its name, role and whether it is named | -| `POST name {hostname,name}` `POST role {name,role}` | naming and roles, from the Mac | +| `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=` 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//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 `, `drop ""|`, `task PC2 "title" [file]`, `run PC2 "title" script.ps1 [--elevated] [--reboot-continue]`, `watch`, `inbox PC1`, `machines`, `role PC2 prover`, `name DESKTOP-XYZ PC2`, `ack|done|rm `, `url`. Playbooks live in `relay/playbooks/`; `run` fills `__DL_BASE__` in from `~/.config/igneum/dl-token`. +`node tools/relay.mjs` (feed), `read `, `drop ""|`, `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 `, `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` bakes the URL, key and token into copies of the clients and writes `~/Desktop/igneum-relay-clients.zip`. 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 (hostname, GPUs, WSL, nvcc), polls every 20 s, runs each `run` task 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` triggers `shutdown /r /t 10`; with `reboot_continue` the agent re-arms (scheduled task at logon with highest privileges, RunOnce as a fallback) and re-runs the task after the restart with `RELAY_PASS` incremented. The PC must sign in by itself for that to be unattended. +`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 PC2`. PC1 is DESKTOP-KMCV30N. -## The job-channel ping (MF-11, 7 October 2026) +## The agent as a logon task, start-app, and the job-channel ping (MF-11, 7 October 2026) -PC 2 lost power at 10:46Z on 7 October 2026 and nothing said so until a person read the intake. From 0.3.21 every app wake request carries `machine=&v=&job=`; `/wake` records one row per machine in `relay_wake_seen` before it holds (`relay/lib/wake.mjs`: `recordSeen`, `seenList`, `pingState`, `PING_SILENT_S` = 900 s); the console's Machines tab reads it as "job channel polled N ago, last job X" and, after 15 minutes without a poll, "job channel silent since