diff --git a/docs/plans/history-rewrite.md b/docs/plans/history-rewrite.md index 2133c8a3..3cb33966 100644 --- a/docs/plans/history-rewrite.md +++ b/docs/plans/history-rewrite.md @@ -16,6 +16,7 @@ name" and "the login" stand for the values the script reads from the history its | The intake key | 6 tracked files, 8 commits (`78df757` to `4c9810f`) | `packaging/mac/packaged-config.sh`, `infra/gpu-bench/upload.sh`, `proving/windows-wsl2/prove-block.sh`, `prove-shard.sh`, `proto-cuda/windows-miner/upload-log.bat`, `proto-cuda/windows-app/upload-log.bat` | | The dl token | 1 tracked file, 1 commit (`c47ff03`) | `docs/plans/morning-2026-10-04.md` | | The `.next` rotations of both | 0 files, 0 commits | `~/.config/igneum/log-intake-key.next`, `dl-token.next` (4 October 19:25) are not in the tree | +| After the 5 October rotation (confirmed 5 October 2026, night, ledger G14) | the values IN THE HISTORY are the OLD ones | `~/.config/igneum/log-intake-key.old-2026-10-05` and `dl-token.old-2026-10-05` hold them; the live files hold the new values, which are in 0 tracked files. `replace.txt` must be written from the `.old-2026-10-05` files, not from the live ones, or the pass replaces nothing. The relay key, the relay token, the relay run key and the machine secrets (`relay-machines/`) are in 0 files and 0 commits | | The relay key and token (current and old) | 0 files, 0 commits | | | The review files | `docs/fud-ledger.md` (36 commits from `39c20b7`), `docs/fud-fixes.md` (6 from `e7545d5`), `docs/review/` (4 from `5ab296c`), `site/ledger.html` (5 from `0ec11be`) | tracked, not ignored | | Tracked files carrying the first name (case-insensitive) | 71 at HEAD; 93 commits touch such content; 10 commit messages carry it | `CLAUDE.md`, the agent file, plans, packaging, the app's WSL paths, the Chrome profile rule | diff --git a/infra/gpu-bench/upload.sh b/infra/gpu-bench/upload.sh index eaf3dd6c..32fceb96 100755 --- a/infra/gpu-bench/upload.sh +++ b/infra/gpu-bench/upload.sh @@ -24,6 +24,9 @@ data = open(path, 'rb').read()[-262144:] json.dump({"label": label, "machine": socket.gethostname(), "run_id": run_id, "lines": data.decode('utf-8', 'replace')}, open(out, 'w')) print(f"upload: run_id {run_id}, {len(data)} bytes") EOF -curl -sS --max-time 60 -X POST "$IGNEUM_LOG_URL" -H "Content-Type: application/json" -H "x-igneum-key: $IGNEUM_LOG_KEY" --data-binary "@$body" +# the key reaches curl through a config file (-K), never on its command line, where every local user can read it (X29) +cfg=$(mktemp); chmod 600 "$cfg" +printf 'header = "x-igneum-key: %s"\n' "$IGNEUM_LOG_KEY" > "$cfg" +curl -sS --max-time 60 -K "$cfg" -X POST "$IGNEUM_LOG_URL" -H "Content-Type: application/json" --data-binary "@$body" echo -rm -f "$body" +rm -f "$body" "$cfg" diff --git a/packaging/ota/publish-jobs.sh b/packaging/ota/publish-jobs.sh index 82f1f126..9f12b794 100755 --- a/packaging/ota/publish-jobs.sh +++ b/packaging/ota/publish-jobs.sh @@ -41,6 +41,7 @@ # folder and verifies the live file (up to --tries times, 5 s apart: the edge serves the previous file for a few # seconds after a deploy). Testing: --dest writes elsewhere; --base-url overrides the file URLs. set -euo pipefail +export TZ=UTC # every commit from here carries +0000, never the local offset (ledger G14) HERE="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$HERE/../.." && pwd)" export PATH="$HOME/.cargo/bin:$PATH" diff --git a/proto-cuda/windows-app/upload-log.bat b/proto-cuda/windows-app/upload-log.bat index 533fa8d7..f6ee3395 100644 --- a/proto-cuda/windows-app/upload-log.bat +++ b/proto-cuda/windows-app/upload-log.bat @@ -32,10 +32,13 @@ if errorlevel 1 ( exit /b 3 ) -curl.exe -sS --max-time 60 -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" -H "x-igneum-key: %IGNEUM_LOG_KEY%" --data-binary "@%OUT%" +rem the key reaches curl through a config file (-K), never on its command line, where every local process can read it (X29) +set "CFG=%TEMP%\igneum-upload-%RANDOM%.cfg" +>"%CFG%" echo header = "x-igneum-key: %IGNEUM_LOG_KEY%" +curl.exe -sS --max-time 60 -K "%CFG%" -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" --data-binary "@%OUT%" set "RC=%ERRORLEVEL%" echo. -del "%OUT%" >nul 2>&1 +del "%OUT%" "%CFG%" >nul 2>&1 if not "%RC%"=="0" ( echo upload-log: curl failed with code %RC% exit /b %RC% diff --git a/proto-cuda/windows-miner/upload-log.bat b/proto-cuda/windows-miner/upload-log.bat index 533fa8d7..f6ee3395 100644 --- a/proto-cuda/windows-miner/upload-log.bat +++ b/proto-cuda/windows-miner/upload-log.bat @@ -32,10 +32,13 @@ if errorlevel 1 ( exit /b 3 ) -curl.exe -sS --max-time 60 -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" -H "x-igneum-key: %IGNEUM_LOG_KEY%" --data-binary "@%OUT%" +rem the key reaches curl through a config file (-K), never on its command line, where every local process can read it (X29) +set "CFG=%TEMP%\igneum-upload-%RANDOM%.cfg" +>"%CFG%" echo header = "x-igneum-key: %IGNEUM_LOG_KEY%" +curl.exe -sS --max-time 60 -K "%CFG%" -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" --data-binary "@%OUT%" set "RC=%ERRORLEVEL%" echo. -del "%OUT%" >nul 2>&1 +del "%OUT%" "%CFG%" >nul 2>&1 if not "%RC%"=="0" ( echo upload-log: curl failed with code %RC% exit /b %RC% diff --git a/relay/README.md b/relay/README.md index d9c6673c..e4dc957d 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,35 +53,40 @@ 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. @@ -70,8 +95,8 @@ An unknown hostname that registers appears in the feed with a "name this machine ``` 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`, `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. +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. 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. +`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). diff --git a/relay/api/console.mjs b/relay/api/console.mjs index 80200c7f..819abe4c 100644 --- a/relay/api/console.mjs +++ b/relay/api/console.mjs @@ -1,5 +1,6 @@ // Igneum console: one function, dispatched on ?fn=, reached through the rewrite /r//c/. -// Auth: the token in the path (or x-relay-token), or the intake key in x-igneum-key. Nothing else. No secret +// Auth: the token in the path (or x-relay-token), or the relay key in x-igneum-key; the intake key opens nothing here +// (5 October 2026, night, X23). No secret // reaches the client: DL_TOKEN (the downloads folder) is read here from the project env. // Every GET answer is cached 10 s in the function instance. // GET machines one card per machine from the log intake (miner_logs): node, cards, telemetry, faults @@ -13,7 +14,7 @@ // TUNE records in miner_logs (relay/lib/ember.mjs), with the sample counts and MH/W // POST post {kind,title,body,who,key?,meta?} one item; with key it upserts // POST sync {items:[...]} bulk upsert by key -import { neon, authed, readJson, str, iso } from '../lib/relay.mjs'; +import { neon, authedNoIntake as authed, readJson, str, iso } from '../lib/relay.mjs'; import { kv, kvNum, lastMatch, FAULT, parseLabel, parseMinerTail, parseHeader, parseAppTail, STALE_S, markStale } from '../lib/parse.mjs'; import { parseRecords, aggregate } from '../lib/ember.mjs'; diff --git a/relay/api/relay.mjs b/relay/api/relay.mjs index 69c3fae3..dbaa1256 100644 --- a/relay/api/relay.mjs +++ b/relay/api/relay.mjs @@ -1,201 +1,18 @@ -// Igneum relay: one function, dispatched on ?fn=. Reached through the rewrite /r//api/. -// Auth: the token in the path (or x-relay-token), or the intake key in x-igneum-key. Nothing else. -// GET feed ?since= | ?before= | ?machine=X | ?limit=N items newest first + machines + unread counts -// GET item ?id= one item with its full body -// GET file ?id= 302 to the Blob URL (or the inline bytes) -// GET inbox ?machine=PC1&kind=run|task|all&ack=1 unread tasks for a machine; ack marks them read -// GET machines every machine with role, hostname, last_seen -// POST drop JSON {from,to,kind,title,body,file_name,file_url,size,task_id,flags} or raw octet-stream (x-file-name, x-from) -// POST task JSON {to,title,body,file_name,file_url,size,kind:'task'|'run',flags:{elevated,reboot_continue},from} -// POST upload JSON {name,size} -> {token, put_url, api_version} client token for a direct PUT to Vercel Blob (50 MB) -// POST ack JSON {ids:[...]} mark read -// POST done JSON {id, exit_code} mark done (runner finished) -// POST register JSON {hostname, info, role?} machine checks in; returns its name and role -// POST name JSON {hostname, name} name an unknown machine -// POST role JSON {name, role} set a machine's role -// POST delete JSON {id} -import { neon, authed, readJson, readRaw, str, safeName, storeBuffer, clientUploadToken, ITEM_COLS, rowOut, iso, touch, KINDS, ROLES, MAX_INLINE, MAX_BODY } from '../lib/relay.mjs'; - -const machineOut = m => ({ ...m, last_seen: iso(m.last_seen) }); +// Igneum relay: one function, dispatched on ?fn=. Reached as /api/relay?fn= with x-relay-token (the Mac tools +// and the PC clients) or x-igneum-key (the relay key; the intake key for uploads only), and through the rewrite +// /r//api/ from the phone's page. The contract and the whole handler live in ../lib/handler.mjs so +// relay/test/handler.test.mjs drives it with a fake database and fake blobs (5 October 2026, night: X23 to X28). +import { neon, authed } from '../lib/relay.mjs'; +import { storeBuffer, clientUploadToken, deleteBlobs } from '../lib/blob.mjs'; +import { makeHandler } from '../lib/handler.mjs'; const json = (res, status, obj) => { res.status(status).setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(obj)); }; - -async function insertItem(sql, o) { - let kind = KINDS.has(o.kind) ? o.kind : (o.file_url || o.file_b64 ? 'file' : 'text'); - if (kind === 'text' && o.file_url) kind = 'file'; - const flags = o.flags && typeof o.flags === 'object' ? o.flags : {}; - const rows = await sql( - `INSERT INTO relay_items (from_machine, to_machine, kind, title, body, file_name, file_url, file_b64, size, flags, task_id) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11) RETURNING id, ts`, - [str(o.from, 80) || 'unknown', str(o.to, 80) || 'all', kind, str(o.title, 300), str(o.body, MAX_BODY), - o.file_name ? safeName(o.file_name) : null, o.file_url ? str(o.file_url, 1000) : null, o.file_b64 || null, - Number(o.size) || 0, JSON.stringify(flags), o.task_id ? Number(o.task_id) : null]); - await touch(sql, o.from); - return { id: Number(rows[0].id), ts: rows[0].ts, kind }; -} +const state = {}; +let inner = null; export default async function handler(req, res) { - res.setHeader('Cache-Control', 'no-store'); - const via = authed(req); - if (!via) return json(res, 401, { ok: false, error: 'no token' }); - const fn = String(req.query.fn || ''); - const q = req.query; let sql; - try { sql = neon(); } catch (e) { return json(res, 500, { ok: false, error: e.message }); } - try { - if (req.method === 'GET') { - if (fn === 'feed') { - const limit = Math.min(500, Math.max(1, Number(q.limit) || 200)); - const where = []; const params = []; - if (q.since) { params.push(Number(q.since)); where.push(`id > $${params.length}`); } - if (q.before) { params.push(Number(q.before)); where.push(`id < $${params.length}`); } - if (q.machine) { params.push(str(q.machine, 80)); where.push(`(from_machine = $${params.length} OR to_machine = $${params.length})`); } - const items = await sql(`SELECT ${ITEM_COLS} FROM relay_items ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY id DESC LIMIT ${limit}`, params); - const machines = await sql(`SELECT m.name, m.hostname, m.role, m.named, m.info, m.last_seen, - (SELECT count(*) FROM relay_items i WHERE NOT i.read AND i.kind IN ('task','run') AND (i.to_machine = m.name OR (i.to_machine = 'all' AND i.kind = 'task')))::int AS unread - FROM relay_machines m ORDER BY m.last_seen DESC NULLS LAST, m.name`); - return json(res, 200, { ok: true, items: items.map(rowOut), machines: machines.map(machineOut), now: new Date().toISOString() }); - } - if (fn === 'machines') { - const machines = await sql(`SELECT name, hostname, role, named, info, last_seen FROM relay_machines ORDER BY name`); - return json(res, 200, { ok: true, machines: machines.map(machineOut) }); - } - if (fn === 'item') { - const rows = await sql(`SELECT ${ITEM_COLS} FROM relay_items WHERE id = $1`, [Number(q.id)]); - if (!rows.length) return json(res, 404, { ok: false, error: 'no such item' }); - return json(res, 200, { ok: true, item: rowOut(rows[0]) }); - } - if (fn === 'file') { - const rows = await sql(`SELECT file_name, file_url, file_b64 FROM relay_items WHERE id = $1`, [Number(q.id)]); - if (!rows.length || (!rows[0].file_url && !rows[0].file_b64)) return json(res, 404, { ok: false, error: 'no file' }); - if (rows[0].file_url) { res.statusCode = 302; res.setHeader('Location', rows[0].file_url + (q.download ? '?download=1' : '')); return res.end(); } - const buf = Buffer.from(rows[0].file_b64, 'base64'); - res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader('Content-Disposition', `attachment; filename="${safeName(rows[0].file_name)}"`); - return res.status(200).end(buf); - } - if (fn === 'inbox') { - const machine = str(q.machine, 80); - if (!machine) return json(res, 400, { ok: false, error: 'machine required' }); - const kind = q.kind === 'run' ? ['run'] : q.kind === 'task' ? ['task'] : ['task', 'run']; - // run items only ever go to one named machine; task items may be addressed to all - const rows = await sql(`SELECT ${ITEM_COLS} FROM relay_items - WHERE NOT read AND NOT done AND kind = ANY($2) AND (to_machine = $1 OR (to_machine = 'all' AND kind = 'task')) - ORDER BY id ASC LIMIT 50`, [machine, kind]); - if (q.ack && rows.length) await sql(`UPDATE relay_items SET read = true, read_at = now() WHERE id = ANY($1)`, [rows.map(r => Number(r.id))]); - await touch(sql, machine); - return json(res, 200, { ok: true, machine, items: rows.map(rowOut) }); - } - return json(res, 404, { ok: false, error: `unknown fn ${fn}` }); - } - - if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method' }); - const ct = String(req.headers['content-type'] || ''); - - if (fn === 'drop' && !ct.includes('json')) { - // raw bytes through the function: curl --data-binary @file -H 'Content-Type: application/octet-stream' -H 'x-file-name: a.zip' - const buf = await readRaw(req); - if (!buf.length) return json(res, 400, { ok: false, error: 'empty body' }); - if (buf.length > MAX_INLINE) return json(res, 413, { ok: false, error: `raw upload over ${MAX_INLINE} bytes; use fn=upload for a Blob client token` }); - const name = safeName(req.headers['x-file-name'] || 'file.bin'); - const stored = await storeBuffer(name, buf, ct || 'application/octet-stream'); - const r = await insertItem(sql, { from: req.headers['x-from'] || q.from, to: req.headers['x-to'] || q.to, kind: 'file', - title: str(req.headers['x-title'] || q.title || name, 300), body: '', file_name: name, file_url: stored.url, size: buf.length, - task_id: req.headers['x-task-id'] || q.task_id }); - return json(res, 200, { ok: true, ...r, file_url: stored.url }); - } - - let body; - try { body = await readJson(req); } catch { return json(res, 400, { ok: false, error: 'bad json' }); } - - // Review round 4, X23: the intake key is in every miner package, so it may only report (drop text and files, ack, - // done, register, upload). Anything a machine would EXECUTE, and anything that renames or re-roles a machine, - // needs the console token. - if ((fn === 'task' || (fn === 'drop' && (body.kind === 'run' || body.kind === 'task')) || fn === 'name' || fn === 'role' || fn === 'delete') && via !== 'token') - return json(res, 403, { ok: false, error: 'this call needs the console token' }); - if (fn === 'drop' || fn === 'task') { - const o = { ...body }; - if (fn === 'task') { o.kind = o.kind === 'run' ? 'run' : 'task'; if (!o.to) return json(res, 400, { ok: false, error: 'to required' }); } - if (o.kind === 'run' && (!o.to || o.to === 'all')) return json(res, 400, { ok: false, error: 'a run task needs one named machine' }); - if (o.file_b64 && !o.file_url) { - const buf = Buffer.from(String(o.file_b64), 'base64'); - if (buf.length > MAX_INLINE) return json(res, 413, { ok: false, error: 'inline file over 4 MB; use fn=upload' }); - const stored = await storeBuffer(o.file_name || 'file.bin', buf, o.content_type); - o.file_url = stored.url; o.size = buf.length; delete o.file_b64; - } - if (!o.body && !o.file_url && !o.title) return json(res, 400, { ok: false, error: 'nothing to send' }); - if (o.file_url && !/^https:\/\/[a-z0-9.-]+\.public\.blob\.vercel-storage\.com\//i.test(o.file_url)) return json(res, 400, { ok: false, error: 'file_url must be a Vercel Blob URL from fn=upload' }); - const r = await insertItem(sql, o); - return json(res, 200, { ok: true, ...r }); - } - if (fn === 'upload') { - const name = safeName(body.name || 'file.bin'); - const t = await clientUploadToken(name, Number(body.size) || 0); - return json(res, 200, { ok: true, name, ...t }); - } - if (fn === 'ack') { - const ids = (Array.isArray(body.ids) ? body.ids : [body.id]).map(Number).filter(Boolean); - if (!ids.length) return json(res, 400, { ok: false, error: 'ids required' }); - await sql(`UPDATE relay_items SET read = true, read_at = now() WHERE id = ANY($1)`, [ids]); - return json(res, 200, { ok: true, ids }); - } - if (fn === 'done') { - const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' }); - const extra = body.exit_code === undefined ? {} : { exit_code: Number(body.exit_code) }; - await sql(`UPDATE relay_items SET done = true, done_at = now(), read = true, read_at = COALESCE(read_at, now()), flags = flags || $2::jsonb WHERE id = $1`, [id, JSON.stringify(extra)]); - return json(res, 200, { ok: true, id }); - } - if (fn === 'delete') { - const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' }); - await sql(`DELETE FROM relay_items WHERE id = $1`, [id]); - return json(res, 200, { ok: true, id }); - } - if (fn === 'register') { - const hostname = str(body.hostname, 120).trim(); - if (!hostname) return json(res, 400, { ok: false, error: 'hostname required' }); - const info = body.info && typeof body.info === 'object' ? body.info : {}; - let rows = await sql(`SELECT name, role, named FROM relay_machines WHERE hostname = $1`, [hostname]); - if (!rows.length) { - // first contact from this hostname: it shows up under its own hostname until the Mac names it - rows = await sql(`INSERT INTO relay_machines (name, hostname, role, named, info, last_seen) VALUES ($1, $1, $2, false, $3::jsonb, now()) - ON CONFLICT (name) DO UPDATE SET hostname = EXCLUDED.hostname, last_seen = now(), info = EXCLUDED.info RETURNING name, role, named`, - [hostname, ROLES.has(body.role) ? body.role : '', JSON.stringify(info)]); - } else { - await sql(`UPDATE relay_machines SET last_seen = now(), info = $2::jsonb WHERE hostname = $1`, [hostname, JSON.stringify(info)]); - } - return json(res, 200, { ok: true, name: rows[0].name, role: rows[0].role || '', named: !!rows[0].named, hostname }); - } - if (fn === 'name') { - const hostname = str(body.hostname, 120).trim(); const name = str(body.name, 80).trim(); - if (!hostname || !name) return json(res, 400, { ok: false, error: 'hostname and name required' }); - const target = await sql(`SELECT name, hostname FROM relay_machines WHERE name = $1`, [name]); - const old = await sql(`SELECT name FROM relay_machines WHERE hostname = $1`, [hostname]); - if (target.length && target[0].hostname && target[0].hostname !== hostname) return json(res, 409, { ok: false, error: `${name} is already ${target[0].hostname}` }); - if (target.length) { - // a pre-seeded name (PC2 with no hostname yet): attach the hostname, drop the placeholder row, move its items - if (old.length && old[0].name !== name) { - await sql(`DELETE FROM relay_machines WHERE hostname = $1 AND name <> $2`, [hostname, name]); - await sql(`UPDATE relay_items SET from_machine = $2 WHERE from_machine = $1`, [old[0].name, name]); - await sql(`UPDATE relay_items SET to_machine = $2 WHERE to_machine = $1`, [old[0].name, name]); - } - await sql(`UPDATE relay_machines SET hostname = $1, named = true, last_seen = COALESCE(last_seen, now()) WHERE name = $2`, [hostname, name]); - } else if (old.length) { - await sql(`UPDATE relay_machines SET name = $2, named = true WHERE hostname = $1`, [hostname, name]); - await sql(`UPDATE relay_items SET from_machine = $2 WHERE from_machine = $1`, [old[0].name, name]); - await sql(`UPDATE relay_items SET to_machine = $2 WHERE to_machine = $1`, [old[0].name, name]); - } else { - await sql(`INSERT INTO relay_machines (name, hostname, role, named) VALUES ($2, $1, '', true)`, [hostname, name]); - } - return json(res, 200, { ok: true, hostname, name }); - } - if (fn === 'role') { - const name = str(body.name, 80).trim(); const role = str(body.role, 20).trim(); - if (!name || !ROLES.has(role)) return json(res, 400, { ok: false, error: `role must be one of ${[...ROLES].filter(Boolean).join(', ')}` }); - await sql(`INSERT INTO relay_machines (name, role, named) VALUES ($1, $2, true) ON CONFLICT (name) DO UPDATE SET role = EXCLUDED.role`, [name, role]); - return json(res, 200, { ok: true, name, role }); - } - return json(res, 404, { ok: false, error: `unknown fn ${fn}` }); - } catch (e) { - return json(res, 500, { ok: false, error: String(e.message || e) }); - } + try { sql = neon(); } catch (e) { res.setHeader('Cache-Control', 'no-store'); return json(res, 500, { ok: false, error: e.message }); } + if (!inner) inner = makeHandler({ sql, blob: { storeBuffer, clientUploadToken, deleteBlobs }, authed, json, state }); + return inner(req, res); } diff --git a/relay/api/wake.mjs b/relay/api/wake.mjs index 21736cb6..13adf669 100644 --- a/relay/api/wake.mjs +++ b/relay/api/wake.mjs @@ -2,9 +2,10 @@ // (public: the apps hold no token) and at /r//wake (the same function; the token matters only to POST). // The contract and the whole handler live in ../lib/wake.mjs so the test can drive it without a database. // GET wake?since=[&hold=45] up to 45 s; {stamp, at, added, changed, held_ms}; 30 a minute per IP -// POST wake {stamp, added?} the relay's auth (token in the path or x-relay-token, or x-igneum-key) +// POST wake {stamp, added?} the relay's auth (token in the path or x-relay-token, or the relay key in x-igneum-key; +// the intake key opens nothing here, X23) // maxDuration 60 s for this file is set in vercel.json. -import { neon, authed, readJson } from '../lib/relay.mjs'; +import { neon, authedNoIntake as authed, readJson } from '../lib/relay.mjs'; import { makeHandler, RateLimit } from '../lib/wake.mjs'; const json = (res, status, obj) => { res.status(status).setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(obj)); }; diff --git a/relay/clients/CLAUDE-PC.md b/relay/clients/CLAUDE-PC.md index 605ca1f6..cb27fba0 100644 --- a/relay/clients/CLAUDE-PC.md +++ b/relay/clients/CLAUDE-PC.md @@ -26,6 +26,6 @@ Also: `send.bat ""` posts a plain note, `send.bat peek` reads without mark ## Rules -- The URL, key and token baked into `send.ps1` and `igneum-agent.ps1` are the secret. Never paste them into chat, a commit, a screenshot or another machine. +- The URL, key and token baked into `send.ps1` and `igneum-agent.ps1`, and `machine-secret.txt` beside them, are the secret. Never paste them into chat, a commit, a screenshot or another machine. `machine-secret.txt` is what makes a `result` count as this PC's (the relay refuses a result from a bound machine without it) and what lets the agent run a signed task. - Never edit `send.ps1` or `igneum-agent.ps1`. If they break, report it with `send.bat result` and the project lead ships a new zip. - Copy law applies to results too: short sentences, numbers in tables, no em dashes. diff --git a/relay/clients/agent.sh b/relay/clients/agent.sh index ec80ddca..aa922306 100755 --- a/relay/clients/agent.sh +++ b/relay/clients/agent.sh @@ -2,21 +2,31 @@ # Igneum relay agent for the Mac (or Linux/WSL): the bash twin of igneum-agent.ps1 for `run` tasks whose body is a bash script. # agent.sh register this machine and poll every 20 s; run each `run` task, post a result, mark it done # agent.sh once one pass (used by the tests) -# Env: RELAY_MACHINE overrides the name (default: what the relay returns for this hostname, else `hostname -s`). -# State: ~/.local/state/igneum-relay (state.json, tasks/, logs/). Needs curl, python3 (jq optional). +# Before anything runs (X23) the task's HMAC tag must verify with this machine's secret (machine-secret.txt next to this +# file, or RELAY_MACHINE_SECRET) over the text the Mac signed, and the nonce must be new; else exit 77 and a result. +# The token, key and secret go to curl through a header file (-K), never on the command line or in the URL (X24, X29). +# Env: RELAY_MACHINE overrides the name (default: what the relay returns for this hostname, else `hostname -s`); +# RELAY_DL_BASE reaches every task (the downloads base the agent holds, never written into a body: X26). +# State: ~/.local/state/igneum-relay (state.json, nonces.txt, headers.cfg, tasks/, logs/). Needs curl, python3 (jq optional). set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" RELAY_URL='__RELAY_URL__'; RELAY_KEY='__RELAY_KEY__'; RELAY_TOKEN='__RELAY_TOKEN__' -BASE="$RELAY_URL/r/$RELAY_TOKEN/api" -STATE="${XDG_STATE_HOME:-$HOME/.local/state}/igneum-relay"; mkdir -p "$STATE/tasks" "$STATE/logs" +DL_BASE_BAKED='__DL_BASE__' +export RELAY_DL_BASE="${RELAY_DL_BASE:-$DL_BASE_BAKED}" +API="$RELAY_URL/api/relay?fn=" +STATE="${XDG_STATE_HOME:-$HOME/.local/state}/igneum-relay"; mkdir -p "$STATE/tasks" "$STATE/logs"; chmod 700 "$STATE" POLL="${RELAY_POLL:-20}"; TAIL=65536 +SECRET="${RELAY_MACHINE_SECRET:-}"; [ -n "$SECRET" ] || { [ -f "$HERE/machine-secret.txt" ] && SECRET="$(tr -d '[:space:]' < "$HERE/machine-secret.txt")" || SECRET=""; } +HDR="$STATE/headers.cfg" +( umask 077; { printf 'header = "x-relay-token: %s"\nheader = "x-igneum-key: %s"\n' "$RELAY_TOKEN" "$RELAY_KEY"; [ -n "$SECRET" ] && printf 'header = "x-machine-secret: %s"\n' "$SECRET"; } > "$HDR" ) log() { echo "[$(date +%H:%M:%S)] $*"; } -get() { curl -sS --max-time 60 "$BASE/$1" -H "x-igneum-key: $RELAY_KEY"; } -post() { curl -sS --max-time 120 -X POST "$BASE/$1" -H 'Content-Type: application/json' -H "x-igneum-key: $RELAY_KEY" --data-binary "$2"; } +get() { curl -sS --max-time 60 -K "$HDR" "$API${1/\?/&}"; } +post() { curl -sS --max-time 120 -K "$HDR" -X POST "$API$1" -H 'Content-Type: application/json' --data-binary "$2"; } py() { python3 -c "$@"; } register() { local info - info="$(py 'import json,platform,shutil,subprocess,os + # no username and no folder in the registration (X28) + info="$(py 'import json,platform,shutil,subprocess gpus=[] try: out=subprocess.run(["system_profiler","SPDisplaysDataType"],capture_output=True,text=True,timeout=20).stdout @@ -25,12 +35,28 @@ except Exception: pass if not gpus and shutil.which("nvidia-smi"): try: gpus=[l.strip() for l in subprocess.run(["nvidia-smi","--query-gpu=name","--format=csv,noheader"],capture_output=True,text=True,timeout=10).stdout.splitlines() if l.strip()] except Exception: pass -print(json.dumps({"hostname":platform.node().split(".")[0],"info":{"os":platform.platform(),"user":os.environ.get("USER",""),"gpus":gpus,"nvcc":bool(shutil.which("nvcc")),"agent":"agent.sh v1","dir":os.getcwd()}}))')" +print(json.dumps({"hostname":platform.node().split(".")[0],"info":{"os":platform.platform(),"gpus":gpus,"nvcc":bool(shutil.which("nvcc")),"agent":"agent.sh v2"}}))')" local r; r="$(post register "$info")" || { log "register failed"; return 1; } - MACHINE="${RELAY_MACHINE:-$(printf '%s' "$r" | py 'import json,sys; print(json.load(sys.stdin)["name"])')}" + MACHINE="${RELAY_MACHINE:-$(printf '%s' "$r" | py 'import json,sys; print(json.load(sys.stdin)["name"])')}" || { log "register refused: $r"; return 1; } ROLE="$(printf '%s' "$r" | py 'import json,sys; print(json.load(sys.stdin).get("role",""))')" printf '%s\n' "$MACHINE" > "$STATE/machine.txt" - log "registered as $MACHINE (role ${ROLE:-unset})" + log "registered as $MACHINE (role ${ROLE:-unset}, bound $(printf '%s' "$r" | py 'import json,sys; print(json.load(sys.stdin).get("bound",False))'))" + [ -n "$SECRET" ] || log "no machine-secret.txt next to agent.sh: run tasks are refused until one is here" +} +check_task() { # json-of-one-item -> prints why it may not run, or nothing + RELAY_MACHINE_SECRET="$SECRET" NONCES="$STATE/nonces.txt" py 'import sys,json,hashlib,hmac,os,re +it=json.load(sys.stdin); f=it.get("flags") or {} +s=os.environ.get("RELAY_MACHINE_SECRET","") +if not s: print("this machine has no machine secret; nothing runs until machine-secret.txt is next to agent.sh"); sys.exit() +n=str(f.get("nonce","")); m=str(f.get("mac","")) +if not re.fullmatch(r"[0-9a-f]{32}", n): print("no nonce on the task"); sys.exit() +if not re.fullmatch(r"[0-9a-f]{64}", m): print("no machine tag (flags.mac) on the task"); sys.exit() +p=os.environ["NONCES"] +if os.path.exists(p) and n in open(p).read().split(): print("nonce already executed on this machine"); sys.exit() +fl=lambda v: "1" if v is True or str(v) in ("1","true") else "0" +canon="igneum-relay-run/1\nto=%s\nnonce=%s\nelevated=%s\nreboot_continue=%s\nreboot=%s\nbody_sha256=%s\n" % (it.get("to",""), n, fl(f.get("elevated")), fl(f.get("reboot_continue")), fl(f.get("reboot")), hashlib.sha256(str(it.get("body","")).encode()).hexdigest()) +want=hmac.new(s.encode(), canon.encode(), hashlib.sha256).hexdigest() +if not hmac.compare_digest(want, m): print("the machine tag does not verify: not signed for this machine, or changed after signing")' <<< "$1" } post_result() { # id title code log note local id="$1" title="$2" code="$3" logf="$4" note="${5:-}" body @@ -44,10 +70,17 @@ run_task() { # json-of-one-item pass id="$(printf '%s' "$it" | py 'import json,sys; print(json.load(sys.stdin)["id"])')" title="$(printf '%s' "$it" | py 'import json,sys; print(json.load(sys.stdin)["title"])')" elevated="$(printf '%s' "$it" | py 'import json,sys; print("1" if json.load(sys.stdin)["flags"].get("elevated") else "")')" - local rebootc; rebootc="$(printf '%s' "$it" | py 'import json,sys; print("1" if json.load(sys.stdin)["flags"].get("reboot_continue") else "")')" - printf '%s' "$it" | py 'import json,sys; sys.stdout.write(json.load(sys.stdin)["body"])' > "$STATE/tasks/task-$id.sh" + local rebootc rebootok; rebootc="$(printf '%s' "$it" | py 'import json,sys; print("1" if json.load(sys.stdin)["flags"].get("reboot_continue") else "")')" + rebootok="$(printf '%s' "$it" | py 'import json,sys; f=json.load(sys.stdin)["flags"]; print("1" if f.get("reboot") or f.get("reboot_continue") else "")')" local logf="$STATE/logs/task-$id-pass$pass-$(date +%Y%m%d-%H%M%S).log" log "task #$id '$title' pass $pass${elevated:+ elevated}${rebootc:+ reboot_continue}" + local why; why="$(check_task "$it")" + if [ -n "$why" ]; then + log "task #$id REFUSED: $why"; echo "REFUSED: $why" >> "$logf" + post_result "$id" "$title" 77 "$logf" "refused: $why"; post done "{\"id\":$id,\"exit_code\":77}" >/dev/null; return 0 + fi + printf '%s' "$it" | py 'import json,sys; print(json.load(sys.stdin)["flags"]["nonce"])' >> "$STATE/nonces.txt" + printf '%s' "$it" | py 'import json,sys; sys.stdout.write(json.load(sys.stdin)["body"])' > "$STATE/tasks/task-$id.sh" if [ -n "$elevated" ]; then RELAY_PASS="$pass" RELAY_TASK_ID="$id" RELAY_MACHINE="$MACHINE" RELAY_SEND="$HERE/send.sh" sudo -n -E bash "$STATE/tasks/task-$id.sh" > >(tee -a "$logf") 2>&1; rc=$? else @@ -55,7 +88,8 @@ run_task() { # json-of-one-item pass fi wait 2>/dev/null; sleep 0.2 echo "__RELAY_EXIT__=$rc" >> "$logf" - if grep -q 'RELAY-REBOOT' "$logf"; then + # the marker on a line of its own (X28), and only for a task queued with --reboot or --reboot-continue + if grep -qx 'RELAY-REBOOT' "$logf" && [ -n "$rebootok" ]; then if [ -n "$rebootc" ]; then post_result "$id" "$title" "$rc" "$logf" "rebooting, resumes as pass $((pass+1))" printf '{"pending":%s,"pass":%s}\n' "$id" "$((pass+1))" > "$STATE/state.json" @@ -70,8 +104,8 @@ one_pass() { local pend pass; pend="$(py 'import json; d=json.load(open("'"$STATE/state.json"'")); print(d["pending"])')"; pass="$(py 'import json; d=json.load(open("'"$STATE/state.json"'")); print(d["pass"])')" rm -f "$STATE/state.json"; run_task "$(get "item?id=$pend" | py 'import json,sys; print(json.dumps(json.load(sys.stdin)["item"]))')" "$pass" fi - local j; j="$(get "inbox?machine=$MACHINE&kind=run&ack=1")" || { log "poll failed"; return 1; } - local n; n="$(printf '%s' "$j" | py 'import json,sys; print(len(json.load(sys.stdin)["items"]))')" + local j; j="$(post inbox "{\"machine\":$(printf '%s' "$MACHINE" | py 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"kind\":\"run\",\"ack\":true}")" || { log "poll failed"; return 1; } + local n; n="$(printf '%s' "$j" | py 'import json,sys; print(len(json.load(sys.stdin)["items"]))')" || { log "poll refused: $j"; return 1; } local i=0; while [ "$i" -lt "$n" ]; do run_task "$(printf '%s' "$j" | py 'import json,sys; print(json.dumps(json.load(sys.stdin)["items"]['"$i"']))')" 1; i=$((i+1)); done [ "$n" = 0 ] && printf '\r[%s] idle as %s ' "$(date +%H:%M:%S)" "$MACHINE" return 0 diff --git a/relay/clients/igneum-agent.ps1 b/relay/clients/igneum-agent.ps1 index cc5a421f..3f946a7d 100644 --- a/relay/clients/igneum-agent.ps1 +++ b/relay/clients/igneum-agent.ps1 @@ -1,33 +1,49 @@ # Igneum relay agent for Windows (PowerShell 5.1 or later). Started by igneum-agent.bat, which keeps it alive. -# What it does: registers this PC on the relay (hostname, role from the relay, GPUs, WSL state, nvcc), then every 20 s -# fetches the `run` tasks queued for it on the Mac, runs each one in order (a PowerShell script per task), captures +# What it does: registers this PC on the relay (its machine secret names it; GPUs, WSL state, nvcc), then every 20 s +# fetches the `run` tasks queued for it on the Mac, checks each one, runs it (a PowerShell script per task), captures # the output and posts a `result` item (exit code, last 64 KB inline, the full log as a file) and marks the task done. +# Before anything runs (review round 4, X23): the task's HMAC tag must verify with this PC's machine secret over the +# same text the Mac signed (machine, nonce, body hash, the flags), and the nonce must be new. A task without a valid +# tag is refused with exit 77 and a result that says so; nothing of it is executed. # Flags per task: elevated (needs administrator; the agent itself runs elevated, so there is no prompt), -# reboot_continue (a task that prints RELAY-REBOOT is re-run after the restart with RELAY_PASS incremented). -# State lives in %LOCALAPPDATA%\igneum-relay (state.json, tasks\, logs\). Nothing else is written outside the task's own doing. -# The URL, key and token are written in by make-clients.sh. The repo copy holds placeholders. +# reboot (the script may ask for a restart by printing RELAY-REBOOT on a line of its own), reboot_continue (the task +# is re-run after the restart with RELAY_PASS incremented). The logon task that re-arms the agent is created only +# for that restart and removed again when the agent starts or exits (X25). +# Every call sends the token and the key as headers, never in the URL (X24). The downloads base reaches a task as +# $env:RELAY_DL_BASE and is never written into a task body (X26). +# State lives in %LOCALAPPDATA%\igneum-relay (state.json, nonces.txt, tasks\, logs\). Nothing else is written outside the task's own doing. +# The URL, key, token and downloads base are written in by make-clients.sh; machine-secret.txt next to this file (or +# RELAY_MACHINE_SECRET) is this PC's secret, from make-clients.sh --machine. The repo copy holds placeholders. $ErrorActionPreference = 'Continue' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $RelayUrl = '__RELAY_URL__' $RelayKey = '__RELAY_KEY__' $RelayToken = '__RELAY_TOKEN__' -$Base = "$RelayUrl/r/$RelayToken/api" -$Headers = @{ 'x-igneum-key' = $RelayKey } +$DlBase = '__DL_BASE__' +if ($env:RELAY_DL_BASE) { $DlBase = $env:RELAY_DL_BASE } $Here = Split-Path -Parent $MyInvocation.MyCommand.Path $StateDir = Join-Path $env:LOCALAPPDATA 'igneum-relay' $TaskDir = Join-Path $StateDir 'tasks' $LogDir = Join-Path $StateDir 'logs' $StateFile = Join-Path $StateDir 'state.json' +$NonceFile = Join-Path $StateDir 'nonces.txt' $PollSeconds = 20 $TailBytes = 65536 New-Item -ItemType Directory -Force -Path $StateDir, $TaskDir, $LogDir | Out-Null +$MachineSecret = '' +if ($env:RELAY_MACHINE_SECRET) { $MachineSecret = $env:RELAY_MACHINE_SECRET.Trim() } +elseif (Test-Path (Join-Path $Here 'machine-secret.txt')) { $MachineSecret = (Get-Content (Join-Path $Here 'machine-secret.txt') -Raw).Trim() } +$Headers = @{ 'x-relay-token' = $RelayToken; 'x-igneum-key' = $RelayKey } +if ($MachineSecret) { $Headers['x-machine-secret'] = $MachineSecret } + function Log([string] $m) { Write-Host ("[" + (Get-Date -Format 'HH:mm:ss') + "] " + $m) } function Is-Admin { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } -function Api-Get([string] $Fn) { Invoke-RestMethod -Uri "$Base/$Fn" -Headers $Headers -TimeoutSec 60 } +function Api-Url([string] $Fn) { return ($RelayUrl + '/api/relay?fn=' + ($Fn -replace '\?', '&')) } +function Api-Get([string] $Fn) { Invoke-RestMethod -Uri (Api-Url $Fn) -Headers $Headers -TimeoutSec 60 } function Api-Post([string] $Fn, $Body) { $bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $Body -Depth 8 -Compress)) - Invoke-RestMethod -Method Post -Uri "$Base/$Fn" -Headers $Headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 120 + Invoke-RestMethod -Method Post -Uri (Api-Url $Fn) -Headers $Headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 120 } function Read-State { if (Test-Path $StateFile) { try { return (Get-Content $StateFile -Raw | ConvertFrom-Json) } catch {} }; return $null } function Write-State($o) { if ($null -eq $o) { Remove-Item $StateFile -ErrorAction SilentlyContinue } else { ConvertTo-Json $o -Compress | Set-Content -Path $StateFile -Encoding ascii } } @@ -38,7 +54,8 @@ $mutex = New-Object System.Threading.Mutex($false, 'Global\IgneumRelayAgent') if (-not $mutex.WaitOne(0)) { Log 'another igneum-agent is already running on this PC; this one exits'; Start-Sleep 5; exit 0 } function Collect-Info { - $info = @{ os = ''; user = $env:USERNAME; admin = (Is-Admin); gpus = @(); wsl = ''; nvcc = $false; agent = 'igneum-agent.ps1 v1'; dir = $Here } + # no username and no folder (X28): the relay stores what it is told + $info = @{ os = ''; admin = (Is-Admin); gpus = @(); wsl = ''; nvcc = $false; agent = 'igneum-agent.ps1 v2' } try { $info.os = (Get-CimInstance Win32_OperatingSystem).Caption + ' build ' + (Get-CimInstance Win32_OperatingSystem).BuildNumber } catch {} try { $nv = Get-Command nvidia-smi -ErrorAction SilentlyContinue @@ -61,16 +78,18 @@ function Register-Machine { $r = Api-Post 'register' @{ hostname = $env:COMPUTERNAME; info = $info } Set-Content -Path (Join-Path $StateDir 'machine.txt') -Value $r.name -Encoding ascii $script:Machine = $r.name; $script:Role = $r.role - Log ("registered as " + $r.name + " (role " + ($(if ($r.role) { $r.role } else { 'unset' })) + ", named " + $r.named + "); gpus: " + ($info.gpus -join ', ') + "; wsl: " + $info.wsl + "; nvcc: " + $info.nvcc) + Log ("registered as " + $r.name + " (role " + ($(if ($r.role) { $r.role } else { 'unset' })) + ", named " + $r.named + ", bound " + $r.bound + "); gpus: " + ($info.gpus -join ', ') + "; wsl: " + $info.wsl + "; nvcc: " + $info.nvcc) if (-not $r.named) { Log "this PC is not named yet. On the Mac: node tools/relay.mjs name $env:COMPUTERNAME PC2" } + if (-not $MachineSecret) { Log 'no machine-secret.txt next to the agent: run tasks are refused until one is here (node tools/relay.mjs secret , then make-clients.sh --machine )' } } function Arm-Restart { - # Re-arm after a reboot: a logon scheduled task with highest privileges (no UAC prompt), plus RunOnce as a fallback. + # Re-arm for ONE restart that a task asked for: a logon scheduled task with highest privileges (no UAC prompt), plus + # RunOnce as a fallback. Disarm-Restart removes both when the agent is back (X25). $bat = Join-Path $Here 'igneum-agent.bat' try { & schtasks.exe /Create /F /TN 'IgneumRelayAgent' /SC ONLOGON /RL HIGHEST /TR ("cmd /c start `"igneum-agent`" `"$bat`"") 2>&1 | Out-Null - Log 'scheduled task IgneumRelayAgent set (runs at logon, highest privileges)' + Log 'scheduled task IgneumRelayAgent set for the restart (runs once at logon, highest privileges; removed when the agent is back)' } catch { Log ("schtasks failed: " + $_.Exception.Message) } try { New-Item -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' -Force | Out-Null @@ -78,17 +97,54 @@ function Arm-Restart { } catch { Log ("RunOnce failed: " + $_.Exception.Message) } } +function Disarm-Restart { + # Nothing of the agent survives its exit: no logon task, no RunOnce key. + $had = $false + try { & schtasks.exe /Query /TN 'IgneumRelayAgent' 2>&1 | Out-Null; if ($LASTEXITCODE -eq 0) { $had = $true; & schtasks.exe /Delete /F /TN 'IgneumRelayAgent' 2>&1 | Out-Null } } catch {} + try { + $k = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' + if ((Test-Path $k) -and ((Get-ItemProperty -Path $k -ErrorAction SilentlyContinue).PSObject.Properties.Name -contains 'IgneumRelayAgent')) { Remove-ItemProperty -Path $k -Name 'IgneumRelayAgent' -ErrorAction SilentlyContinue; $had = $true } + } catch {} + if ($had) { Log 'logon task and RunOnce key removed' } +} + +function Sha256-Hex([byte[]] $bytes) { + $h = [Security.Cryptography.SHA256]::Create() + try { return (($h.ComputeHash($bytes)) | ForEach-Object { $_.ToString('x2') }) -join '' } finally { $h.Dispose() } +} +function Hmac-Hex([string] $secret, [string] $text) { + $h = New-Object Security.Cryptography.HMACSHA256 (,[Text.Encoding]::UTF8.GetBytes($secret)) + try { return (($h.ComputeHash([Text.Encoding]::UTF8.GetBytes($text))) | ForEach-Object { $_.ToString('x2') }) -join '' } finally { $h.Dispose() } +} +function Flag-Text($v) { if ($v -eq $true -or "$v" -eq '1' -or "$v" -eq 'true') { return '1' }; return '0' } +function Run-Canon($task) { + # the same text relay/lib/guard.mjs runCanon() builds on the Mac and the relay checks + $f = $task.flags + return ("igneum-relay-run/1`nto=" + $task.to + "`nnonce=" + $f.nonce + "`nelevated=" + (Flag-Text $f.elevated) + "`nreboot_continue=" + (Flag-Text $f.reboot_continue) + "`nreboot=" + (Flag-Text $f.reboot) + "`nbody_sha256=" + (Sha256-Hex ([Text.Encoding]::UTF8.GetBytes("$($task.body)"))) + "`n") +} +function Check-Task($task) { + # '' when the task may run, else why not (X23: nothing runs on the token alone) + if (-not $MachineSecret) { return 'this PC has no machine secret; nothing runs until make-clients.sh --machine put machine-secret.txt here' } + $f = $task.flags + if (-not $f -or -not ("$($f.nonce)" -match '^[0-9a-f]{32}$')) { return 'no nonce on the task' } + if (-not ("$($f.mac)" -match '^[0-9a-f]{64}$')) { return 'no machine tag (flags.mac) on the task' } + if ((Test-Path $NonceFile) -and (Select-String -Path $NonceFile -Pattern ("^" + $f.nonce + "$") -Quiet)) { return 'nonce already executed on this PC' } + $want = Hmac-Hex $MachineSecret (Run-Canon $task) + if ($want -ne "$($f.mac)") { return 'the machine tag does not verify: not signed for this PC, or changed after signing' } + return '' +} + function Post-Result($task, [int] $code, [string] $logPath, [string] $note) { $tail = '' - if (Test-Path $logPath) { + if ($logPath -and (Test-Path $logPath)) { $bytes = [IO.File]::ReadAllBytes($logPath) $n = [Math]::Min($bytes.Length, $TailBytes) $tail = [Text.Encoding]::UTF8.GetString($bytes, $bytes.Length - $n, $n) } $o = @{ kind = 'result'; from = $script:Machine; to = 'all'; task_id = $task.id; body = $tail title = ($task.title + ": exit " + $code + $(if ($note) { " (" + $note + ")" } else { "" })) - flags = @{ exit_code = $code; pass = [int]$env:RELAY_PASS; machine = $env:COMPUTERNAME } } - if ((Test-Path $logPath) -and (Get-Item $logPath).Length -gt $TailBytes) { + flags = @{ exit_code = $code; pass = [int]$env:RELAY_PASS } } + if ($logPath -and (Test-Path $logPath) -and (Get-Item $logPath).Length -gt $TailBytes) { try { $f = & (Join-Path $Here 'send.ps1') -Machine $script:Machine -Kind 'file' -TaskId $task.id -Title ($task.title + ' full log') $logPath 2>&1 | Out-String; Log ("full log posted: " + $f.Trim()) } catch { Log ("log upload failed: " + $_.Exception.Message) } } try { $r = Api-Post 'drop' $o; Log ("result posted as #" + $r.id) } catch { Log ("result post failed: " + $_.Exception.Message) } @@ -101,15 +157,25 @@ function Run-Task($task, [int] $pass) { $log = Join-Path $LogDir ("task-" + $id + "-pass" + $pass + "-" + (Get-Date -Format 'yyyyMMdd-HHmmss') + ".log") $elevated = [bool]$task.flags.elevated $rebootContinue = [bool]$task.flags.reboot_continue - Log ("task #" + $id + " '" + $task.title + "' pass " + $pass + $(if ($elevated) { " elevated" } else { "" }) + $(if ($rebootContinue) { " reboot_continue" } else { "" })) + $rebootAllowed = $rebootContinue -or [bool]$task.flags.reboot + Log ("task #" + $id + " '" + $task.title + "' pass " + $pass + $(if ($elevated) { " elevated" } else { "" }) + $(if ($rebootContinue) { " reboot_continue" } elseif ($rebootAllowed) { " reboot" } else { "" })) + $why = Check-Task $task + if ($why) { + Log ("task #" + $id + " REFUSED: " + $why) + Add-Content -Path $log -Value ("REFUSED: " + $why) + Post-Result $task 77 $log ("refused: " + $why) + try { Api-Post 'done' @{ id = $id; exit_code = 77 } | Out-Null } catch { Log ("done failed: " + $_.Exception.Message) } + return + } + Add-Content -Path $NonceFile -Value $task.flags.nonce $body = "$($task.body)" -replace "`r?`n", "`r`n" [IO.File]::WriteAllText($script, $body, (New-Object Text.UTF8Encoding $true)) $env:RELAY_PASS = "$pass"; $env:RELAY_TASK_ID = "$id"; $env:RELAY_MACHINE = $script:Machine; $env:RELAY_ROLE = $script:Role - $env:RELAY_SEND = (Join-Path $Here 'send.ps1'); $env:RELAY_HOME = $StateDir + $env:RELAY_SEND = (Join-Path $Here 'send.ps1'); $env:RELAY_HOME = $StateDir; $env:RELAY_DL_BASE = $DlBase $wrapBody = @" `$ErrorActionPreference = 'Continue' `$env:RELAY_PASS = '$pass'; `$env:RELAY_TASK_ID = '$id'; `$env:RELAY_MACHINE = '$($script:Machine)'; `$env:RELAY_ROLE = '$($script:Role)' -`$env:RELAY_SEND = '$(Join-Path $Here 'send.ps1')'; `$env:RELAY_HOME = '$StateDir' +`$env:RELAY_SEND = '$(Join-Path $Here 'send.ps1')'; `$env:RELAY_HOME = '$StateDir'; `$env:RELAY_DL_BASE = '$DlBase' Start-Transcript -Path '$log' -Append | Out-Null `$code = 0 try { & '$script'; `$code = `$LASTEXITCODE; if (`$null -eq `$code) { `$code = 0 } } catch { Write-Host ("TASK ERROR: " + `$_.Exception.Message); `$code = 1 } @@ -130,44 +196,56 @@ exit `$code $code = $p.ExitCode } catch { Log ("could not start the task: " + $_.Exception.Message); Add-Content -Path $log -Value ("AGENT ERROR: " + $_.Exception.Message) } $text = ''; if (Test-Path $log) { $text = Get-Content $log -Raw } - $reboot = $text -match 'RELAY-REBOOT' + # the marker on a line of its own (X28), and only when the task was queued with --reboot or --reboot-continue + $asked = [bool]($text -match '(?m)^RELAY-REBOOT\r?$') + $reboot = $asked -and $rebootAllowed + if ($asked -and -not $rebootAllowed) { Log ("task #" + $id + " printed RELAY-REBOOT but was not queued with --reboot; not restarting") } if ($reboot -and $rebootContinue) { Log ("task #" + $id + " asked for a reboot and continues after it (pass " + ($pass + 1) + ")") Post-Result $task $code $log ("rebooting, resumes as pass " + ($pass + 1)) Write-State @{ pending = $id; pass = ($pass + 1); title = $task.title } Arm-Restart + $script:KeepArmed = $true & shutdown.exe /r /t 10 /c "Igneum relay: task #$id continues after the restart" Log 'restart in 10 s; the agent exits now' exit 0 } - Post-Result $task $code $log $(if ($reboot) { 'rebooting' } else { '' }) + Post-Result $task $code $log $(if ($reboot) { 'rebooting' } elseif ($asked) { 'reboot refused: not queued with --reboot' } else { '' }) try { Api-Post 'done' @{ id = $id; exit_code = $code } | Out-Null } catch { Log ("done failed: " + $_.Exception.Message) } if ($reboot) { Log ("task #" + $id + " asked for a reboot") Arm-Restart + $script:KeepArmed = $true & shutdown.exe /r /t 10 /c "Igneum relay: task #$id asked for a restart" exit 0 } } Log ("igneum relay agent on " + $env:COMPUTERNAME + " as " + $env:USERNAME + $(if (Is-Admin) { " (administrator)" } else { " (NOT administrator: elevated tasks will prompt)" })) -Arm-Restart +Disarm-Restart +$script:KeepArmed = $false $registered = $false -while ($true) { - try { - if (-not $registered) { Register-Machine; $registered = $true } - $st = Read-State - if ($st -and $st.pending) { - $pending = [long]$st.pending; $pass = [int]$st.pass - Write-State $null - try { $it = (Api-Get ("item?id=" + $pending)).item; Run-Task $it $pass } catch { Log ("could not resume task #" + $pending + ": " + $_.Exception.Message) } +try { + while ($true) { + try { + if (-not $registered) { Register-Machine; $registered = $true } + $st = Read-State + if ($st -and $st.pending) { + $pending = [long]$st.pending; $pass = [int]$st.pass + Write-State $null + try { $it = (Api-Get ("item?id=" + $pending)).item; Run-Task $it $pass } catch { Log ("could not resume task #" + $pending + ": " + $_.Exception.Message) } + } + $j = Api-Post 'inbox' @{ machine = $script:Machine; kind = 'run'; ack = $true } + foreach ($t in @($j.items)) { Run-Task $t 1 } + if (-not $j.items -or $j.items.Count -eq 0) { Write-Host -NoNewline ("`r[" + (Get-Date -Format 'HH:mm:ss') + "] idle as " + $script:Machine + ", next check in " + $PollSeconds + " s ") } + } catch { + Log ("loop error: " + $_.Exception.Message) + $registered = $false } - $j = Api-Get ("inbox?machine=" + [Uri]::EscapeDataString($script:Machine) + "&kind=run&ack=1") - foreach ($t in @($j.items)) { Run-Task $t 1 } - if (-not $j.items -or $j.items.Count -eq 0) { Write-Host -NoNewline ("`r[" + (Get-Date -Format 'HH:mm:ss') + "] idle as " + $script:Machine + ", next check in " + $PollSeconds + " s ") } - } catch { - Log ("loop error: " + $_.Exception.Message) - $registered = $false + Start-Sleep -Seconds $PollSeconds } - Start-Sleep -Seconds $PollSeconds +} finally { + # Ctrl+C and a normal exit land here (a closed window does not run this, so the next start disarms again); + # the one exit that keeps the logon task is the restart a task asked for + if (-not $script:KeepArmed) { Disarm-Restart } } diff --git a/relay/clients/make-clients.sh b/relay/clients/make-clients.sh index 1e58a8cf..c4d95080 100755 --- a/relay/clients/make-clients.sh +++ b/relay/clients/make-clients.sh @@ -1,25 +1,49 @@ #!/usr/bin/env bash -# Bakes the relay URL, key and token into copies of the clients and zips them to ~/Desktop/igneum-relay-clients.zip. -# The files in the repo keep their placeholders; the zip is the secret-bearing artefact. Usage: make-clients.sh [outdir-for-zip] +# Bakes the relay URL, key, token and downloads base into copies of the clients and zips them. The files in the repo keep +# their placeholders; the zip is the secret-bearing artefact and goes to the PC by hand (USB stick, AirDrop), NEVER +# through the downloads host (review round 4, X23: the hosted zip put both relay secrets one dl token away). +# +# make-clients.sh [--machine NAME] [outdir-for-zip] +# +# --machine NAME also puts that machine's secret (~/.config/igneum/relay-machines/NAME, from `node tools/relay.mjs +# secret NAME`) into the zip as machine-secret.txt, which is what lets the agent there run signed tasks and lets its +# results carry its name (X23, X27). Without --machine the zip can read, post notes and files, and nothing runs. +# Reads ~/.config/igneum/relay-url (optional), relay-key, relay-token, dl-token (for RELAY_DL_BASE; X26: the base is a +# value the agent holds, never text in a task body). set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" CFG="$HOME/.config/igneum" +MACHINE=""; OUT="$HOME/Desktop" +while [ $# -gt 0 ]; do + case "$1" in --machine) MACHINE="$2"; shift 2 ;; --*) echo "unknown flag $1" >&2; exit 2 ;; *) OUT="$1"; shift ;; esac +done URL="$(cat "$CFG/relay-url" 2>/dev/null | tr -d '\n' || true)"; URL="${URL:-https://relay.igneum.network}" KEY="$(tr -d '\n' < "$CFG/relay-key")"; TOKEN="$(tr -d '\n' < "$CFG/relay-token")" -OUT="${1:-$HOME/Desktop}"; mkdir -p "$OUT" -STAGE="$(mktemp -d)/igneum-relay-clients"; mkdir -p "$STAGE" +DL="$(tr -d '[:space:]' < "$CFG/dl-token" 2>/dev/null || true)" +DL_BASE="https://dl.igneum.network/dl/${DL:-MISSING-DL-TOKEN}" +SECRET="" +if [ -n "$MACHINE" ]; then + SF="$CFG/relay-machines/$MACHINE" + [ -f "$SF" ] || { echo "no $SF: node tools/relay.mjs secret $MACHINE first" >&2; exit 1; } + SECRET="$(tr -d '[:space:]' < "$SF")" + [ ${#SECRET} = 64 ] || { echo "$SF is not a 64-hex secret" >&2; exit 1; } +fi +mkdir -p "$OUT" +NAME="igneum-relay-clients${MACHINE:+-$MACHINE}" +STAGE="$(mktemp -d)/$NAME"; mkdir -p "$STAGE"; umask 077 for f in send.bat send.ps1 send.sh igneum-agent.bat igneum-agent.ps1 agent.sh CLAUDE-PC.md; do - sed -e "s#__RELAY_URL__#$URL#g" -e "s#__RELAY_KEY__#$KEY#g" -e "s#__RELAY_TOKEN__#$TOKEN#g" "$HERE/$f" > "$STAGE/$f" + sed -e "s#__RELAY_URL__#$URL#g" -e "s#__RELAY_KEY__#$KEY#g" -e "s#__RELAY_TOKEN__#$TOKEN#g" -e "s#__DL_BASE__#$DL_BASE#g" "$HERE/$f" > "$STAGE/$f" done +[ -n "$SECRET" ] && printf '%s\n' "$SECRET" > "$STAGE/machine-secret.txt" # Windows reads CRLF batch files most reliably; PowerShell is fine either way for f in send.bat igneum-agent.bat; do perl -pi -e 's/\r?\n/\r\n/' "$STAGE/$f"; done chmod +x "$STAGE/send.sh" "$STAGE/agent.sh" cat > "$STAGE/README.txt" <" | send.bat | send.bat inbox | send.bat result "" | send.bat get +Igneum relay clients${MACHINE:+ for $MACHINE}. Unzip anywhere. send.bat "" | send.bat | send.bat inbox | send.bat result "" | send.bat get igneum-agent.bat: double-click once, leave open. CLAUDE-PC.md: paste into the Claude Code session on this PC. -These files contain the relay secret. Keep them off shared drives. +These files contain the relay secret${MACHINE:+ and this machine's own secret (machine-secret.txt)}. Keep them off shared drives and off the downloads host. TXT -rm -f "$OUT/igneum-relay-clients.zip" -(cd "$(dirname "$STAGE")" && zip -qr "$OUT/igneum-relay-clients.zip" igneum-relay-clients) +rm -f "$OUT/$NAME.zip" +(cd "$(dirname "$STAGE")" && zip -qr "$OUT/$NAME.zip" "$NAME") echo "staged: $STAGE" -echo "zip: $OUT/igneum-relay-clients.zip ($(du -h "$OUT/igneum-relay-clients.zip" | cut -f1))" +echo "zip: $OUT/$NAME.zip ($(du -h "$OUT/$NAME.zip" | cut -f1)); carry it to the PC by hand, never through the downloads host" diff --git a/relay/clients/send.ps1 b/relay/clients/send.ps1 index e65ea958..60dc8382 100644 --- a/relay/clients/send.ps1 +++ b/relay/clients/send.ps1 @@ -1,13 +1,15 @@ # Igneum relay client (Windows PowerShell 5.1 or later). Driven by send.bat; the agent and the playbooks call it too. # send.ps1 "" post a note to everyone # send.ps1 post a file (up to 50 MB, straight to Vercel Blob) -# send.ps1 result "" [-TaskId N] [-File path] post a result (what a task produced) +# send.ps1 result "" [-TaskId N] [-File path] post a result (what a task produced; needs this PC's machine secret) # send.ps1 inbox print the unread tasks for this machine and mark them read # send.ps1 peek print them without marking read # send.ps1 get print an item; download its file into the current folder # send.ps1 done mark a task done # -To PC1 / -Title "..." / -Machine NAME override the defaults (machine = this PC's name on the relay, else its hostname) -# The URL, key and token below are written in by make-clients.sh. They are the secret; keep this file off shared drives. +# The token and key travel as headers, never in the URL (X24). The URL, key and token below are written in by +# make-clients.sh; machine-secret.txt next to this file (or RELAY_MACHINE_SECRET) names this PC on every result (X27). +# They are the secret; keep this folder off shared drives. param( [Parameter(Position = 0)] [string] $A, [Parameter(Position = 1)] [string] $B, @@ -23,10 +25,15 @@ $ErrorActionPreference = 'Stop' $RelayUrl = '__RELAY_URL__' $RelayKey = '__RELAY_KEY__' $RelayToken = '__RELAY_TOKEN__' -$Base = "$RelayUrl/r/$RelayToken/api" -$Headers = @{ 'x-igneum-key' = $RelayKey } +$Here = Split-Path -Parent $MyInvocation.MyCommand.Path $StateDir = Join-Path $env:LOCALAPPDATA 'igneum-relay' +$MachineSecret = '' +if ($env:RELAY_MACHINE_SECRET) { $MachineSecret = $env:RELAY_MACHINE_SECRET.Trim() } +elseif (Test-Path (Join-Path $Here 'machine-secret.txt')) { $MachineSecret = (Get-Content (Join-Path $Here 'machine-secret.txt') -Raw).Trim() } +$Headers = @{ 'x-relay-token' = $RelayToken; 'x-igneum-key' = $RelayKey } +if ($MachineSecret) { $Headers['x-machine-secret'] = $MachineSecret } +function Api-Url([string] $Fn) { return ($RelayUrl + '/api/relay?fn=' + ($Fn -replace '\?', '&')) } function Get-MachineName { if ($Machine) { return $Machine } if ($env:RELAY_MACHINE) { return $env:RELAY_MACHINE } @@ -35,7 +42,7 @@ function Get-MachineName { return $env:COMPUTERNAME } function Invoke-Relay([string] $Method, [string] $Fn, $Body) { - $uri = "$Base/$Fn" + $uri = Api-Url $Fn if ($Method -eq 'GET') { return Invoke-RestMethod -Uri $uri -Headers $Headers -TimeoutSec 60 } $bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $Body -Depth 8 -Compress)) return Invoke-RestMethod -Method Post -Uri $uri -Headers $Headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 120 @@ -69,24 +76,25 @@ function Show-Item($it) { switch -Regex ($A) { '^(?i)inbox$' { - $j = Invoke-RestMethod -Uri "$Base/inbox?machine=$([Uri]::EscapeDataString((Get-MachineName)))&ack=1" -Headers $Headers -TimeoutSec 60 + # marking read is a POST (X28: a GET changes nothing) + $j = Invoke-Relay 'POST' 'inbox' @{ machine = (Get-MachineName); kind = 'task'; ack = $true } if (-not $j.items) { Write-Host ("nothing waiting for " + (Get-MachineName)); break } foreach ($it in $j.items) { Show-Item $it } break } '^(?i)peek$' { - $j = Invoke-RestMethod -Uri "$Base/inbox?machine=$([Uri]::EscapeDataString((Get-MachineName)))" -Headers $Headers -TimeoutSec 60 + $j = Invoke-Relay 'GET' ("inbox?machine=" + [Uri]::EscapeDataString((Get-MachineName)) + "&kind=task") if (-not $j.items) { Write-Host ("nothing waiting for " + (Get-MachineName)); break } foreach ($it in $j.items) { Show-Item $it } break } '^(?i)get$' { if (-not $B) { throw 'get ' } - $it = (Invoke-RestMethod -Uri "$Base/item?id=$B" -Headers $Headers -TimeoutSec 60).item + $it = (Invoke-Relay 'GET' ("item?id=" + $B)).item Show-Item $it if ($it.has_file) { $out = Join-Path (Get-Location) ($it.id.ToString() + '-' + $it.file_name) - Invoke-WebRequest -Uri "$Base/file?id=$($it.id)" -Headers $Headers -OutFile $out -UseBasicParsing -TimeoutSec 600 + Invoke-WebRequest -Uri (Api-Url ("file?id=" + $it.id)) -Headers $Headers -OutFile $out -UseBasicParsing -TimeoutSec 600 Write-Host "downloaded: $out" } break @@ -94,6 +102,7 @@ switch -Regex ($A) { '^(?i)done$' { if (-not $B) { throw 'done ' }; Invoke-Relay 'POST' 'done' @{ id = [long]$B } | Out-Null; Write-Host "#$B done"; break } '^(?i)result$' { if (-not $B -and -not $File) { throw 'result "" [-TaskId N] [-File path]' } + if (-not $MachineSecret) { Write-Host 'note: no machine-secret.txt next to send.ps1; the relay refuses results from a bound machine without it' } Post-Item @{ kind = 'result'; body = "$B"; title = $Title } | Out-Null break } diff --git a/relay/clients/send.sh b/relay/clients/send.sh index 71f0c222..ff0c38f0 100755 --- a/relay/clients/send.sh +++ b/relay/clients/send.sh @@ -3,17 +3,23 @@ # send.sh "" note to everyone send.sh file (up to 50 MB) # send.sh inbox unread tasks for this machine, marked read send.sh peek same, not marked # send.sh get print an item, download its file here send.sh done -# send.sh result "" [--task-id N] [--file path] +# send.sh result "" [--task-id N] [--file path] (needs this machine's secret) # RELAY_TO=PC1 RELAY_TITLE="..." RELAY_MACHINE=Mac override the defaults. -# The URL, key and token are written in by make-clients.sh (the repo copy holds placeholders). +# The token, key and machine secret go to curl through a header file (-K), never on the command line or in the URL +# (X24, X29). The URL, key and token are written in by make-clients.sh (the repo copy holds placeholders); +# machine-secret.txt next to this file (or RELAY_MACHINE_SECRET) names this machine on every result (X27). set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" RELAY_URL='__RELAY_URL__'; RELAY_KEY='__RELAY_KEY__'; RELAY_TOKEN='__RELAY_TOKEN__' -BASE="$RELAY_URL/r/$RELAY_TOKEN/api" -STATE="${XDG_STATE_HOME:-$HOME/.local/state}/igneum-relay" +API="$RELAY_URL/api/relay?fn=" +STATE="${XDG_STATE_HOME:-$HOME/.local/state}/igneum-relay"; mkdir -p "$STATE"; chmod 700 "$STATE" +SECRET="${RELAY_MACHINE_SECRET:-}"; [ -n "$SECRET" ] || { [ -f "$HERE/machine-secret.txt" ] && SECRET="$(tr -d '[:space:]' < "$HERE/machine-secret.txt")" || SECRET=""; } +HDR="$STATE/headers.cfg" +( umask 077; { printf 'header = "x-relay-token: %s"\nheader = "x-igneum-key: %s"\n' "$RELAY_TOKEN" "$RELAY_KEY"; [ -n "$SECRET" ] && printf 'header = "x-machine-secret: %s"\n' "$SECRET"; } > "$HDR" ) machine() { if [ -n "${RELAY_MACHINE:-}" ]; then echo "$RELAY_MACHINE"; elif [ -f "$STATE/machine.txt" ]; then cat "$STATE/machine.txt"; else hostname -s; fi; } jqq() { if command -v jq >/dev/null; then jq "$@"; else python3 -c 'import json,sys; q=sys.argv[1]; d=json.load(sys.stdin); print(d[q.strip(".")])' "$@"; fi; } -post() { curl -sS --max-time 120 -X POST "$BASE/$1" -H 'Content-Type: application/json' -H "x-igneum-key: $RELAY_KEY" --data-binary "$2"; } -get() { curl -sS --max-time 60 "$BASE/$1" -H "x-igneum-key: $RELAY_KEY"; } +post() { curl -sS --max-time 120 -K "$HDR" -X POST "$API$1" -H 'Content-Type: application/json' --data-binary "$2"; } +get() { curl -sS --max-time 60 -K "$HDR" "$API${1/\?/&}"; } jstr() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' ; } upload() { local path="$1" name size tok @@ -33,13 +39,14 @@ for x in (it if isinstance(it,list) else [it]): if x.get("has_file"): print("file: %s (%s bytes); send.sh get %s downloads it" % (x["file_name"],x["size"],x["id"]))'; } cmd="${1:-}"; M="$(machine)"; TO="${RELAY_TO:-all}"; TITLE="${RELAY_TITLE:-}" case "$cmd" in - inbox) get "inbox?machine=$M&ack=1" | jqq .items | show ;; - peek) get "inbox?machine=$M" | jqq .items | show ;; + inbox) post inbox "{\"machine\":$(printf '%s' "$M" | jstr),\"kind\":\"task\",\"ack\":true}" | jqq .items | show ;; # marking read is a POST (X28) + peek) get "inbox?machine=$M&kind=task" | jqq .items | show ;; get) [ -n "${2:-}" ] || { echo 'get ' >&2; exit 1; }; j="$(get "item?id=$2")"; printf '%s' "$j" | jqq .item | show - if [ "$(printf '%s' "$j" | jqq -r .item.has_file)" = "true" ]; then out="$2-$(printf '%s' "$j" | jqq -r .item.file_name)"; curl -sSL --max-time 600 "$BASE/file?id=$2" -H "x-igneum-key: $RELAY_KEY" -o "$out"; echo "downloaded: $out"; fi ;; + if [ "$(printf '%s' "$j" | jqq -r .item.has_file)" = "true" ]; then out="$2-$(printf '%s' "$j" | jqq -r .item.file_name)"; curl -sSL --max-time 600 -K "$HDR" "${API}file&id=$2" -o "$out"; echo "downloaded: $out"; fi ;; done) [ -n "${2:-}" ] || { echo "done " >&2; exit 1; }; post done "{\"id\":$2}" >/dev/null; echo "#$2 done" ;; result) shift; text="${1:-}"; shift || true; task=0; file="" while [ $# -gt 0 ]; do case "$1" in --task-id) task="$2"; shift 2;; --file) file="$2"; shift 2;; *) shift;; esac; done + [ -n "$SECRET" ] || echo "note: no machine-secret.txt next to send.sh; the relay refuses results from a bound machine without it" >&2 extra=""; [ -n "$file" ] && extra=",$(upload "$file")" post drop "{\"from\":$(printf '%s' "$M" | jstr),\"to\":\"$TO\",\"kind\":\"result\",\"title\":$(printf '%s' "$TITLE" | jstr),\"body\":$(printf '%s' "$text" | jstr),\"task_id\":$task$extra}"; echo ;; "") echo 'send.sh "" | send.sh | send.sh inbox | send.sh result "" | send.sh get | send.sh done ' >&2; exit 1 ;; diff --git a/relay/lib/blob.mjs b/relay/lib/blob.mjs new file mode 100644 index 00000000..cb16190a --- /dev/null +++ b/relay/lib/blob.mjs @@ -0,0 +1,31 @@ +// The Vercel Blob side of the relay (store igneum-relay): the only module that imports @vercel/blob, so everything +// else loads without node_modules. api/relay.mjs hands these three to lib/handler.mjs; the tests hand it fakes. +import { put, del } from '@vercel/blob'; +import { generateClientTokenFromReadWriteToken } from '@vercel/blob/client'; +import { blobPath, MAX_BLOB } from './relay.mjs'; + +export async function storeBuffer(name, buf, contentType) { + const r = await put(blobPath(name), buf, { + access: 'public', addRandomSuffix: true, contentType: contentType || 'application/octet-stream', + }); + return { url: r.url, size: buf.length }; +} + +export async function clientUploadToken(name, size) { + const pathname = blobPath(name); + const token = await generateClientTokenFromReadWriteToken({ + pathname, + addRandomSuffix: true, + allowOverwrite: false, + maximumSizeInBytes: MAX_BLOB, + validUntil: Date.now() + 60 * 60 * 1000, + }); + return { token, pathname, put_url: `https://vercel.com/api/blob/?pathname=${encodeURIComponent(pathname)}`, api_version: '11', max: MAX_BLOB, size }; +} + +/** Deletes the blobs behind the given URLs (X26: a deleted or expired row takes its file with it). Never throws. */ +export async function deleteBlobs(urls) { + const list = [...new Set(urls.filter(Boolean))]; + if (!list.length) return 0; + try { await del(list); return list.length; } catch { return 0; } +} diff --git a/relay/lib/guard.mjs b/relay/lib/guard.mjs new file mode 100644 index 00000000..38d11732 --- /dev/null +++ b/relay/lib/guard.mjs @@ -0,0 +1,124 @@ +// The relay's guards (review round 4, ledger X23 to X28; fixed 5 October 2026, night). No dependencies, so +// `node --test relay/test/guard.test.mjs` covers every rule here. +// +// Three secrets, three tiers: +// token the console token: the URL path (the phone's page only) or the x-relay-token header. Everything. +// key the relay's own key (RELAY_KEY, ~/.config/igneum/relay-key) in x-igneum-key. Reports and reads; +// never task, run, name, role, delete, secret. +// intake the log-intake key (LOG_INTAKE_KEY, LOG_INTAKE_KEY_NEXT) in x-igneum-key: the key inside every shipped +// package. Only `upload` and a `drop` of kind file or text (the PC apps' build-job outputs). Nothing else, +// no reads. Closed by RELAY_INTAKE_COMPAT=0 once the apps carry a relay key of their own. +// +// A `run` task (a script the agent executes, often as administrator) needs more than the token: +// sig an Ed25519 signature by the Mac's run key (~/.config/igneum/relay-run-key) over runCanon(); the API +// verifies it with RELAY_RUN_PUB and refuses the task with 401 when it is missing or wrong. +// mac an HMAC-SHA256 tag with the target machine's own secret over the same canonical text; the agent verifies +// it before it executes anything (Windows PowerShell 5.1 has no Ed25519, so the agent's check is the HMAC). +// nonce 32 hex, unique per run task; the agent remembers executed nonces. +// The canonical text names the machine, the nonce, the body's sha256 and the three flags that change what the agent +// does, so none of them can be altered by a holder of the token or the database alone. +// +// Machines: a per-machine secret (32 hex, made on the Mac, `node tools/relay.mjs secret PC1`) whose sha256 the relay +// stores; `register` and every `result` present it in x-machine-secret and `from` must be that machine. +import { createHash, createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign as edSign, verify as edVerify, timingSafeEqual } from 'node:crypto'; +import { sameSecret } from './auth.mjs'; + +export const FEED_LIMIT_DEFAULT = 50; +export const FEED_LIMIT_MAX = 100; // was 500: one secret no longer pages the whole history in five calls +export const RETENTION_DAYS = 30; +export const RATE_PER_MIN = 120; // authenticated calls per IP per minute (an agent polls 3 a minute) +export const AUTH_FAIL_PER_MIN = 10; // failed authentications per IP per minute +export const REBOOT_MARKER = 'RELAY-REBOOT'; + +const HEX = n => new RegExp(`^[0-9a-f]{${n}}$`); +export const isNonce = s => typeof s === 'string' && HEX(32).test(s); +export const isSig = s => typeof s === 'string' && HEX(128).test(s); +export const isMac = s => typeof s === 'string' && HEX(64).test(s); +export const isSecret = s => typeof s === 'string' && HEX(64).test(s); +export const isPub = s => typeof s === 'string' && HEX(64).test(s); + +/** Which tier a request authenticates as, from its query (the rewrite's ?token=) and headers; null when none. */ +export function authVia({ query = {}, headers = {} }, env = process.env) { + const given = query.token || headers['x-relay-token']; + if (sameSecret(given, env.RELAY_TOKEN)) return 'token'; + const k = headers['x-igneum-key']; + if (sameSecret(k, env.RELAY_KEY)) return 'key'; + if (env.RELAY_INTAKE_COMPAT !== '0') { + for (const name of ['LOG_INTAKE_KEY', 'LOG_INTAKE_KEY_NEXT']) if (env[name] && sameSecret(k, env[name])) return 'intake'; + } + return null; +} + +/** What each tier may call. POST fn names; GET reads are allowed for token and key only. */ +export const POST_ALLOWED = { + token: new Set(['drop', 'task', 'upload', 'ack', 'done', 'delete', 'register', 'name', 'role', 'secret', 'inbox']), + key: new Set(['drop', 'upload', 'ack', 'done', 'register', 'inbox']), + intake: new Set(['drop', 'upload']), +}; +export const DROP_KINDS = { token: null, key: new Set(['text', 'file', 'result']), intake: new Set(['text', 'file']) }; // null: any kind +export const mayRead = via => via === 'token' || via === 'key'; + +export const sha256hex = s => createHash('sha256').update(Buffer.isBuffer(s) ? s : Buffer.from(String(s), 'utf8')).digest('hex'); +export const secretHash = secret => sha256hex(secret); +const flag = v => (v === true || v === 1 || v === '1' || v === 'true' ? '1' : '0'); + +/** The canonical text a run task is signed over. Deterministic; the agent builds the same string. */ +export function runCanon({ to, nonce, body, flags = {} }) { + return ['igneum-relay-run/1', `to=${String(to || '')}`, `nonce=${String(nonce || '')}`, `elevated=${flag(flags.elevated)}`, + `reboot_continue=${flag(flags.reboot_continue)}`, `reboot=${flag(flags.reboot)}`, `body_sha256=${sha256hex(String(body || ''))}`, ''].join('\n'); +} + +// Ed25519 raw keys as hex (32-byte seed, 32-byte public), the OTA key's shape, through PKCS8 and SPKI DER prefixes. +const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex'); +const SPKI = Buffer.from('302a300506032b6570032100', 'hex'); +export const privFromSeed = seedHex => createPrivateKey({ key: Buffer.concat([PKCS8, Buffer.from(seedHex, 'hex')]), format: 'der', type: 'pkcs8' }); +export const pubFromHex = pubHex => createPublicKey({ key: Buffer.concat([SPKI, Buffer.from(pubHex, 'hex')]), format: 'der', type: 'spki' }); +export function keygen() { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const seed = privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(PKCS8.length).toString('hex'); + const pub = publicKey.export({ format: 'der', type: 'spki' }).subarray(SPKI.length).toString('hex'); + return { seed, pub }; +} +export const signRun = (canon, seedHex) => edSign(null, Buffer.from(canon, 'utf8'), privFromSeed(seedHex)).toString('hex'); +export function verifyRun(canon, sigHex, pubHex) { + if (!isSig(sigHex) || !isPub(pubHex)) return false; + try { return edVerify(null, Buffer.from(canon, 'utf8'), pubFromHex(pubHex), Buffer.from(sigHex, 'hex')); } catch { return false; } +} +export const machineTag = (secret, canon) => createHmac('sha256', Buffer.from(String(secret), 'utf8')).update(Buffer.from(canon, 'utf8')).digest('hex'); +export function sameTag(a, b) { + if (!isMac(a) || !isMac(b)) return false; + return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); +} +export const newNonce = () => randomBytes(16).toString('hex'); +export const newSecret = () => randomBytes(32).toString('hex'); + +/** + * Checks everything a run task must carry before the API stores it. Returns null when good, else the refusal text. + * pubHex: RELAY_RUN_PUB; without it every run is refused (the safe failure at a deploy that forgot the key). + */ +export function checkRun(o, pubHex) { + const f = o.flags && typeof o.flags === 'object' ? o.flags : {}; + if (!o.to || o.to === 'all') return 'a run task needs one named machine'; + if (!isPub(pubHex)) return 'run tasks are refused: RELAY_RUN_PUB is not set on the relay'; + if (!isNonce(f.nonce)) return 'a run task needs flags.nonce (32 hex)'; + if (!isMac(f.mac)) return 'a run task needs flags.mac, the HMAC tag with the machine secret'; + if (!isSig(f.sig)) return 'a run task needs flags.sig, the Ed25519 signature by the relay run key'; + if (!verifyRun(runCanon({ to: o.to, nonce: f.nonce, body: o.body, flags: f }), f.sig, pubHex)) return 'the run signature does not verify against RELAY_RUN_PUB'; + return null; +} + +/** The reboot request: the marker on a line of its own, never inside other output (X28). */ +export const wantsReboot = text => /(^|\r?\n)RELAY-REBOOT\r?(\n|$)/.test(String(text || '')); + +export const feedLimit = q => Math.min(FEED_LIMIT_MAX, Math.max(1, Number(q && q.limit) || FEED_LIMIT_DEFAULT)); + +/** The oldest timestamp the relay keeps, as an ISO string, for `ts < $1`. */ +export const retentionCutoff = (now = Date.now()) => new Date(now - RETENTION_DAYS * 86400_000).toISOString(); + +/** The machine a presented secret names: {name} from the rows, or an error text. rows: [{name, secret_hash}]. */ +export function machineForSecret(secret, rows) { + if (!isSecret(secret)) return { error: 'x-machine-secret must be 64 hex' }; + const h = secretHash(secret); + const hit = rows.find(r => r.secret_hash && sameSecret(h, r.secret_hash)); + return hit ? { name: hit.name } : { error: 'unknown machine secret' }; +} diff --git a/relay/lib/handler.mjs b/relay/lib/handler.mjs new file mode 100644 index 00000000..b392b931 --- /dev/null +++ b/relay/lib/handler.mjs @@ -0,0 +1,295 @@ +// The relay handler with its dependencies injected (api/relay.mjs wires Neon and Vercel Blob; relay/test/handler.test.mjs +// wires fakes). Dispatched on ?fn=; reached as /api/relay?fn= with x-relay-token or x-igneum-key, or through the +// rewrite /r//api/ from the phone's page (the only caller that keeps the token in the path, X24). +// +// GET feed ?since= | ?before= | ?machine=X | ?limit=N (cap 100) items newest first + machines + unread counts +// GET item ?id= one item with its full body +// GET file ?id= 302 to the Blob URL (or the inline bytes) +// GET inbox ?machine=PC1&kind=run|task|all unread tasks for a machine; a GET never marks anything (X28) +// POST inbox JSON {machine, kind, ack:true} the same list, marked read +// GET machines every machine with role, hostname, last_seen, bound +// POST drop JSON {from,to,kind,title,body,file_name,file_url,size,task_id,flags} or raw octet-stream (x-file-name, x-from) +// POST task JSON {to,title,body,kind:'task'|'run',flags:{elevated,reboot_continue,reboot,nonce,sig,mac},from} +// run: token AND flags.sig (Ed25519 by the relay run key, verified here with RELAY_RUN_PUB) AND flags.mac +// (the machine's HMAC tag, verified by the agent) AND a fresh flags.nonce; anything less is 401 (X23) +// POST upload JSON {name,size} -> {token, put_url, api_version} client token for a direct PUT to Vercel Blob (50 MB) +// POST ack JSON {ids:[...]} mark read +// POST done JSON {id, exit_code} mark done (runner finished) +// POST register JSON {hostname, info, role?} + x-machine-secret machine checks in; the secret names it (X27) +// POST name JSON {hostname, name} name an unknown machine (token) +// POST role JSON {name, role} set a machine's role (token) +// POST secret JSON {name, secret_hash} bind a machine to sha256(its secret) (token) +// POST delete JSON {id} the row and its blob (token) +// Retention: rows older than 30 days go, blobs with them, checked on a feed read at most every 10 minutes per instance. +// Rate limit: 120 calls a minute per IP, 10 failed authentications a minute per IP (X28). +import { readJson, readRaw, str, safeName, ITEM_COLS, BLOB_URL_RE, rowOut, iso, touch, KINDS, ROLES, MAX_INLINE, MAX_BODY } from './relay.mjs'; +import { POST_ALLOWED, DROP_KINDS, mayRead, checkRun, feedLimit, retentionCutoff, machineForSecret, secretHash, isSecret, RATE_PER_MIN, AUTH_FAIL_PER_MIN } from './guard.mjs'; +import { RateLimit, ipOf } from './wake.mjs'; + +export const EXPIRE_EVERY_MS = 10 * 60_000; + +const machineOut = m => ({ name: m.name, hostname: m.hostname, role: m.role, named: m.named, info: m.info, last_seen: iso(m.last_seen), bound: !!m.secret_hash, ...(m.unread !== undefined ? { unread: m.unread } : {}) }); + +async function insertItem(sql, o) { + let kind = KINDS.has(o.kind) ? o.kind : (o.file_url || o.file_b64 ? 'file' : 'text'); + if (kind === 'text' && o.file_url) kind = 'file'; + const flags = o.flags && typeof o.flags === 'object' ? o.flags : {}; + const rows = await sql( + `INSERT INTO relay_items (from_machine, to_machine, kind, title, body, file_name, file_url, file_b64, size, flags, task_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11) RETURNING id, ts`, + [str(o.from, 80) || 'unknown', str(o.to, 80) || 'all', kind, str(o.title, 300), str(o.body, MAX_BODY), + o.file_name ? safeName(o.file_name) : null, o.file_url ? str(o.file_url, 1000) : null, o.file_b64 || null, + Number(o.size) || 0, JSON.stringify(flags), o.task_id ? Number(o.task_id) : null]); + await touch(sql, o.from); + return { id: Number(rows[0].id), ts: rows[0].ts, kind }; +} + +/** The secret_hash column, added once per instance (ADD COLUMN IF NOT EXISTS is idempotent and takes no long lock). */ +async function ensureSecretColumn(sql, state) { + if (state.secretColumn) return; + await sql(`ALTER TABLE relay_machines ADD COLUMN IF NOT EXISTS secret_hash text`); + state.secretColumn = true; +} + +/** Rows older than the retention and their blobs (X26). */ +export async function expire(sql, blob, now = Date.now()) { + const rows = await sql(`DELETE FROM relay_items WHERE ts < $1 RETURNING id, file_url`, [retentionCutoff(now)]); + const blobs = await blob.deleteBlobs(rows.map(r => r.file_url)); + return { rows: rows.length, blobs }; +} + +/** + * makeHandler({ sql, blob: {storeBuffer, clientUploadToken, deleteBlobs}, authed, json, env, now, limiter, authLimiter, state }) + * json(res, status, obj) writes a reply; state is per instance (the expire clock and the column flag). + */ +export function makeHandler({ sql, blob, authed, json, env = process.env, now = Date.now, limiter, authLimiter, state = {} }) { + const limit = limiter || new RateLimit({ perMinute: RATE_PER_MIN, now }); + const authLimit = authLimiter || new RateLimit({ perMinute: AUTH_FAIL_PER_MIN, now }); + const bindMachine = async (secret) => { + // the machine a presented x-machine-secret names, or an error text + await ensureSecretColumn(sql, state); + const rows = await sql(`SELECT name, secret_hash FROM relay_machines WHERE secret_hash IS NOT NULL`); + return machineForSecret(secret, rows); + }; + const hasSecret = async (name) => { + await ensureSecretColumn(sql, state); + const rows = await sql(`SELECT secret_hash FROM relay_machines WHERE name = $1`, [name]); + return !!(rows.length && rows[0].secret_hash); + }; + + return async function handler(req, res) { + res.setHeader('Cache-Control', 'no-store'); + const ip = ipOf(req); + const via = authed(req); + if (!via) { + const wait = authLimit.take(ip); + if (wait !== null) { res.setHeader('Retry-After', String(wait)); return json(res, 429, { ok: false, error: `too many failed authentications; retry in ${wait} s` }); } + return json(res, 401, { ok: false, error: 'no token' }); + } + const wait = limit.take(ip); + if (wait !== null) { res.setHeader('Retry-After', String(wait)); return json(res, 429, { ok: false, error: `rate limit; retry in ${wait} s` }); } + const fn = String(req.query.fn || ''); + const q = req.query; + try { + if (req.method === 'GET') { + if (!mayRead(via)) return json(res, 403, { ok: false, error: 'the intake key may only upload and drop files' }); + if (fn === 'feed') { + if (now() - (state.lastExpire || 0) > EXPIRE_EVERY_MS) { state.lastExpire = now(); try { state.expired = await expire(sql, blob, now()); } catch (e) { state.expireError = String(e.message || e); } } + const lim = feedLimit(q); + const where = []; const params = []; + if (q.since) { params.push(Number(q.since)); where.push(`id > $${params.length}`); } + if (q.before) { params.push(Number(q.before)); where.push(`id < $${params.length}`); } + if (q.machine) { params.push(str(q.machine, 80)); where.push(`(from_machine = $${params.length} OR to_machine = $${params.length})`); } + const items = await sql(`SELECT ${ITEM_COLS} FROM relay_items ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY id DESC LIMIT ${lim}`, params); + await ensureSecretColumn(sql, state); + const machines = await sql(`SELECT m.name, m.hostname, m.role, m.named, m.info, m.last_seen, m.secret_hash, + (SELECT count(*) FROM relay_items i WHERE NOT i.read AND i.kind IN ('task','run') AND (i.to_machine = m.name OR (i.to_machine = 'all' AND i.kind = 'task')))::int AS unread + FROM relay_machines m ORDER BY m.last_seen DESC NULLS LAST, m.name`); + return json(res, 200, { ok: true, items: items.map(rowOut), machines: machines.map(machineOut), limit: lim, now: new Date(now()).toISOString() }); + } + if (fn === 'machines') { + await ensureSecretColumn(sql, state); + const machines = await sql(`SELECT name, hostname, role, named, info, last_seen, secret_hash FROM relay_machines ORDER BY name`); + return json(res, 200, { ok: true, machines: machines.map(machineOut) }); + } + if (fn === 'item') { + const rows = await sql(`SELECT ${ITEM_COLS} FROM relay_items WHERE id = $1`, [Number(q.id)]); + if (!rows.length) return json(res, 404, { ok: false, error: 'no such item' }); + return json(res, 200, { ok: true, item: rowOut(rows[0]) }); + } + if (fn === 'file') { + const rows = await sql(`SELECT file_name, file_url, file_b64 FROM relay_items WHERE id = $1`, [Number(q.id)]); + if (!rows.length || (!rows[0].file_url && !rows[0].file_b64)) return json(res, 404, { ok: false, error: 'no file' }); + if (rows[0].file_url) { res.statusCode = 302; res.setHeader('Location', rows[0].file_url + (q.download ? '?download=1' : '')); return res.end(); } + const buf = Buffer.from(rows[0].file_b64, 'base64'); + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${safeName(rows[0].file_name)}"`); + return res.status(200).end(buf); + } + if (fn === 'inbox') return inbox(q, false); + return json(res, 404, { ok: false, error: `unknown fn ${fn}` }); + } + + if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method' }); + if (!POST_ALLOWED[via].has(fn)) return json(res, via === 'intake' ? 403 : (POST_ALLOWED.token.has(fn) ? 403 : 404), { ok: false, error: POST_ALLOWED.token.has(fn) ? `${fn} needs the console token` : `unknown fn ${fn}` }); + const ct = String(req.headers['content-type'] || ''); + + if (fn === 'drop' && !ct.includes('json')) { + // raw bytes through the function: curl --data-binary @file -H 'Content-Type: application/octet-stream' -H 'x-file-name: a.zip' + const buf = await readRaw(req); + if (!buf.length) return json(res, 400, { ok: false, error: 'empty body' }); + if (buf.length > MAX_INLINE) return json(res, 413, { ok: false, error: `raw upload over ${MAX_INLINE} bytes; use fn=upload for a Blob client token` }); + const name = safeName(req.headers['x-file-name'] || 'file.bin'); + const stored = await blob.storeBuffer(name, buf, ct || 'application/octet-stream'); + const r = await insertItem(sql, { from: req.headers['x-from'] || q.from, to: req.headers['x-to'] || q.to, kind: 'file', + title: str(req.headers['x-title'] || q.title || name, 300), body: '', file_name: name, file_url: stored.url, size: buf.length, + task_id: req.headers['x-task-id'] || q.task_id }); + return json(res, 200, { ok: true, ...r, file_url: stored.url }); + } + + let body; + try { body = await readJson(req); } catch { return json(res, 400, { ok: false, error: 'bad json' }); } + const presented = req.headers['x-machine-secret']; + + if (fn === 'inbox') { if (!POST_ALLOWED[via].has('inbox')) return json(res, 403, { ok: false, error: 'inbox needs the token or the relay key' }); return inbox(body, !!body.ack); } + + if (fn === 'drop' || fn === 'task') { + const o = { ...body }; + if (fn === 'task') { o.kind = o.kind === 'run' ? 'run' : 'task'; if (!o.to) return json(res, 400, { ok: false, error: 'to required' }); } + const kind = KINDS.has(o.kind) ? o.kind : (o.file_url || o.file_b64 ? 'file' : 'text'); + const allowed = DROP_KINDS[via]; + if (allowed && !allowed.has(kind)) return json(res, 403, { ok: false, error: `a ${kind} item needs the console token` }); + if (kind === 'run') { + const why = checkRun(o, env.RELAY_RUN_PUB); + if (why) return json(res, why.startsWith('a run task needs one') ? 400 : 401, { ok: false, error: why }); + const dup = await sql(`SELECT id FROM relay_items WHERE kind = 'run' AND flags->>'nonce' = $1`, [o.flags.nonce]); + if (dup.length) return json(res, 409, { ok: false, error: `nonce already used by #${dup[0].id}` }); + } + if (kind === 'result') { + // X27: a result names the machine its secret proves; a forged `from` is refused + if (presented !== undefined) { + const m = await bindMachine(presented); + if (m.error) return json(res, 403, { ok: false, error: m.error }); + if (o.from && o.from !== m.name) return json(res, 403, { ok: false, error: `from ${o.from} does not match the machine secret (${m.name})` }); + o.from = m.name; + } else if (o.from && await hasSecret(o.from)) { + return json(res, 403, { ok: false, error: `results from ${o.from} need its machine secret (x-machine-secret)` }); + } else { + o.flags = { ...(o.flags && typeof o.flags === 'object' ? o.flags : {}), unbound: true }; + } + } + if (o.file_b64 && !o.file_url) { + const buf = Buffer.from(String(o.file_b64), 'base64'); + if (buf.length > MAX_INLINE) return json(res, 413, { ok: false, error: 'inline file over 4 MB; use fn=upload' }); + const stored = await blob.storeBuffer(o.file_name || 'file.bin', buf, o.content_type); + o.file_url = stored.url; o.size = buf.length; delete o.file_b64; + } + if (!o.body && !o.file_url && !o.title) return json(res, 400, { ok: false, error: 'nothing to send' }); + if (o.file_url && !BLOB_URL_RE.test(o.file_url)) return json(res, 400, { ok: false, error: 'file_url must be a Vercel Blob URL from fn=upload' }); + const r = await insertItem(sql, o); + return json(res, 200, { ok: true, ...r }); + } + if (fn === 'upload') { + const name = safeName(body.name || 'file.bin'); + const t = await blob.clientUploadToken(name, Number(body.size) || 0); + return json(res, 200, { ok: true, name, ...t }); + } + if (fn === 'ack') { + const ids = (Array.isArray(body.ids) ? body.ids : [body.id]).map(Number).filter(Boolean); + if (!ids.length) return json(res, 400, { ok: false, error: 'ids required' }); + await sql(`UPDATE relay_items SET read = true, read_at = now() WHERE id = ANY($1)`, [ids]); + return json(res, 200, { ok: true, ids }); + } + if (fn === 'done') { + const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' }); + const extra = body.exit_code === undefined ? {} : { exit_code: Number(body.exit_code) }; + if (presented !== undefined) { const m = await bindMachine(presented); if (m.error) return json(res, 403, { ok: false, error: m.error }); extra.done_by = m.name; } + await sql(`UPDATE relay_items SET done = true, done_at = now(), read = true, read_at = COALESCE(read_at, now()), flags = flags || $2::jsonb WHERE id = $1`, [id, JSON.stringify(extra)]); + return json(res, 200, { ok: true, id }); + } + if (fn === 'delete') { + const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' }); + const rows = await sql(`DELETE FROM relay_items WHERE id = $1 RETURNING file_url`, [id]); + const blobs = await blob.deleteBlobs(rows.map(r => r.file_url)); + return json(res, 200, { ok: true, id, blobs }); + } + if (fn === 'register') { + const hostname = str(body.hostname, 120).trim(); + if (!hostname) return json(res, 400, { ok: false, error: 'hostname required' }); + const info = body.info && typeof body.info === 'object' ? { ...body.info } : {}; + delete info.user; delete info.dir; // X28: no username and no secret folder in the registration + if (presented !== undefined) { + // the secret names the machine; the hostname is recorded against that row (both PCs report the same hostname) + const m = await bindMachine(presented); + if (m.error) return json(res, 403, { ok: false, error: m.error }); + const rows = await sql(`UPDATE relay_machines SET hostname = $2, last_seen = now(), info = $3::jsonb WHERE name = $1 RETURNING name, role, named`, [m.name, hostname, JSON.stringify(info)]); + return json(res, 200, { ok: true, name: rows[0].name, role: rows[0].role || '', named: !!rows[0].named, hostname, bound: true }); + } + await ensureSecretColumn(sql, state); + let rows = await sql(`SELECT name, role, named, secret_hash FROM relay_machines WHERE hostname = $1`, [hostname]); + if (rows.some(r => r.secret_hash)) return json(res, 403, { ok: false, error: `${hostname} is bound to a machine secret; present x-machine-secret` }); + if (!rows.length) { + // first contact from this hostname: it shows up under its own hostname until the Mac names it + rows = await sql(`INSERT INTO relay_machines (name, hostname, role, named, info, last_seen) VALUES ($1, $1, $2, false, $3::jsonb, now()) + ON CONFLICT (name) DO UPDATE SET hostname = EXCLUDED.hostname, last_seen = now(), info = EXCLUDED.info RETURNING name, role, named`, + [hostname, ROLES.has(body.role) ? body.role : '', JSON.stringify(info)]); + } else { + await sql(`UPDATE relay_machines SET last_seen = now(), info = $2::jsonb WHERE hostname = $1`, [hostname, JSON.stringify(info)]); + } + return json(res, 200, { ok: true, name: rows[0].name, role: rows[0].role || '', named: !!rows[0].named, hostname, bound: false }); + } + if (fn === 'secret') { + const name = str(body.name, 80).trim(); const hash = str(body.secret_hash, 64).trim(); + if (!name || !isSecret(hash)) return json(res, 400, { ok: false, error: 'name and secret_hash (64 hex, sha256 of the secret) required' }); + await ensureSecretColumn(sql, state); + await sql(`INSERT INTO relay_machines (name, role, named, secret_hash) VALUES ($1, '', true, $2) ON CONFLICT (name) DO UPDATE SET secret_hash = EXCLUDED.secret_hash, named = true`, [name, hash]); + return json(res, 200, { ok: true, name, bound: true }); + } + if (fn === 'name') { + const hostname = str(body.hostname, 120).trim(); const name = str(body.name, 80).trim(); + if (!hostname || !name) return json(res, 400, { ok: false, error: 'hostname and name required' }); + const target = await sql(`SELECT name, hostname FROM relay_machines WHERE name = $1`, [name]); + const old = await sql(`SELECT name FROM relay_machines WHERE hostname = $1`, [hostname]); + if (target.length && target[0].hostname && target[0].hostname !== hostname) return json(res, 409, { ok: false, error: `${name} is already ${target[0].hostname}` }); + if (target.length) { + // a pre-seeded name (PC2 with no hostname yet): attach the hostname, drop the placeholder row, move its items + if (old.length && old[0].name !== name) { + await sql(`DELETE FROM relay_machines WHERE hostname = $1 AND name <> $2`, [hostname, name]); + await sql(`UPDATE relay_items SET from_machine = $2 WHERE from_machine = $1`, [old[0].name, name]); + await sql(`UPDATE relay_items SET to_machine = $2 WHERE to_machine = $1`, [old[0].name, name]); + } + await sql(`UPDATE relay_machines SET hostname = $1, named = true, last_seen = COALESCE(last_seen, now()) WHERE name = $2`, [hostname, name]); + } else if (old.length) { + await sql(`UPDATE relay_machines SET name = $2, named = true WHERE hostname = $1`, [hostname, name]); + await sql(`UPDATE relay_items SET from_machine = $2 WHERE from_machine = $1`, [old[0].name, name]); + await sql(`UPDATE relay_items SET to_machine = $2 WHERE to_machine = $1`, [old[0].name, name]); + } else { + await sql(`INSERT INTO relay_machines (name, hostname, role, named) VALUES ($2, $1, '', true)`, [hostname, name]); + } + return json(res, 200, { ok: true, hostname, name }); + } + if (fn === 'role') { + const name = str(body.name, 80).trim(); const role = str(body.role, 20).trim(); + if (!name || !ROLES.has(role)) return json(res, 400, { ok: false, error: `role must be one of ${[...ROLES].filter(Boolean).join(', ')}` }); + await sql(`INSERT INTO relay_machines (name, role, named) VALUES ($1, $2, true) ON CONFLICT (name) DO UPDATE SET role = EXCLUDED.role`, [name, role]); + return json(res, 200, { ok: true, name, role }); + } + return json(res, 404, { ok: false, error: `unknown fn ${fn}` }); + } catch (e) { + return json(res, 500, { ok: false, error: String(e.message || e) }); + } + + async function inbox(src, ack) { + const machine = str(src.machine, 80); + if (!machine) return json(res, 400, { ok: false, error: 'machine required' }); + const kind = src.kind === 'run' ? ['run'] : src.kind === 'task' ? ['task'] : ['task', 'run']; + // run items only ever go to one named machine; task items may be addressed to all + const rows = await sql(`SELECT ${ITEM_COLS} FROM relay_items + WHERE NOT read AND NOT done AND kind = ANY($2) AND (to_machine = $1 OR (to_machine = 'all' AND kind = 'task')) + ORDER BY id ASC LIMIT 50`, [machine, kind]); + if (ack && rows.length) await sql(`UPDATE relay_items SET read = true, read_at = now() WHERE id = ANY($1)`, [rows.map(r => Number(r.id))]); + await touch(sql, machine); + return json(res, 200, { ok: true, machine, items: rows.map(rowOut), acked: ack ? rows.length : 0 }); + } + }; +} diff --git a/relay/lib/relay.mjs b/relay/lib/relay.mjs index ca5f777e..8e6ef29e 100644 --- a/relay/lib/relay.mjs +++ b/relay/lib/relay.mjs @@ -1,11 +1,10 @@ -// Shared pieces for the Igneum relay function. Zero dependencies apart from @vercel/blob. -// Storage: Neon (HTTP SQL driver) for every item, Vercel Blob (store igneum-relay, public URLs with a -// random suffix) for files. The token in the URL path is the only secret the web page holds; scripts -// may also present the intake key in x-igneum-key. -import { put } from '@vercel/blob'; -import { generateClientTokenFromReadWriteToken } from '@vercel/blob/client'; +// Shared pieces for the Igneum relay function. Zero dependencies (the Vercel Blob calls live in lib/blob.mjs, so +// lib/handler.mjs and its tests load without node_modules). Storage: Neon (HTTP SQL driver) for every item, +// Vercel Blob (store igneum-relay, public URLs with a random suffix) for files. The token in the URL path is the +// only secret the web page holds; scripts send it in x-relay-token; the relay key and the intake key go in +// x-igneum-key with the powers lib/guard.mjs gives them (5 October 2026, night: X23, X24, X27). import { randomBytes } from 'node:crypto'; -import { sameSecret } from './auth.mjs'; +import { authVia } from './guard.mjs'; export const MAX_INLINE = 4 * 1024 * 1024; // raw upload through the function (Vercel body cap is 4.5 MB) export const MAX_BLOB = 50 * 1024 * 1024; // direct-to-Blob upload with a client token @@ -31,19 +30,10 @@ export function neon() { export const str = (v, max) => (v === undefined || v === null ? '' : String(v)).slice(0, max); -export function authed(req) { - const q = req.query || {}; - const token = process.env.RELAY_TOKEN; - const key = process.env.RELAY_KEY; - const given = q.token || req.headers['x-relay-token']; - if (sameSecret(given, token)) return 'token'; - const k = req.headers['x-igneum-key']; - if (sameSecret(k, key)) return 'key'; - // the key packaged into every miner app (LOG_INTAKE_KEY, and its successor during a rotation) reports too: the - // build job uploads its binaries with it (5 October 2026: 0.3.5 apps got "no token" after the relay key split) - for (const name of ['LOG_INTAKE_KEY', 'LOG_INTAKE_KEY_NEXT']) if (process.env[name] && sameSecret(k, process.env[name])) return 'key'; - return null; -} +/** 'token', 'key', 'intake' or null (lib/guard.mjs). The intake tier may only upload and drop files. */ +export const authed = (req, env = process.env) => authVia({ query: req.query || {}, headers: req.headers || {} }, env); +/** The same, with the intake tier excluded: the console and the wake POST take reports from nobody's package. */ +export const authedNoIntake = (req, env = process.env) => { const v = authed(req, env); return v === 'intake' ? null : v; }; export async function readJson(req) { if (req.body !== undefined && req.body !== null) { @@ -74,26 +64,8 @@ export function blobPath(name) { return `relay/${randomBytes(6).toString('hex')}/${safeName(name)}`; } -export async function storeBuffer(name, buf, contentType) { - const r = await put(blobPath(name), buf, { - access: 'public', addRandomSuffix: true, contentType: contentType || 'application/octet-stream', - }); - return { url: r.url, size: buf.length }; -} - -export async function clientUploadToken(name, size) { - const pathname = blobPath(name); - const token = await generateClientTokenFromReadWriteToken({ - pathname, - addRandomSuffix: true, - allowOverwrite: false, - maximumSizeInBytes: MAX_BLOB, - validUntil: Date.now() + 60 * 60 * 1000, - }); - return { token, pathname, put_url: `https://vercel.com/api/blob/?pathname=${encodeURIComponent(pathname)}`, api_version: '11', max: MAX_BLOB, size }; -} - export const ITEM_COLS = 'id, ts, from_machine, to_machine, kind, title, body, file_name, file_url, size, read, read_at, done, done_at, flags, task_id, (file_b64 IS NOT NULL) AS inline'; +export const BLOB_URL_RE = /^https:\/\/[a-z0-9.-]+\.public\.blob\.vercel-storage\.com\//i; export const iso = v => { if (!v) return null; const d = new Date(String(v).replace(' ', 'T').replace(/([+-]\d\d)$/, '$1:00')); return isNaN(d) ? String(v) : d.toISOString(); }; diff --git a/relay/playbooks/miner-v4.ps1 b/relay/playbooks/miner-v4.ps1 index 685aa234..9b6feb8d 100644 --- a/relay/playbooks/miner-v4.ps1 +++ b/relay/playbooks/miner-v4.ps1 @@ -2,14 +2,14 @@ # Queue: node tools/relay.mjs run PC1 "miner v4" relay/playbooks/miner-v4.ps1 # UNTESTED on a PC as of 4 Oct 2026. $ErrorActionPreference = 'Continue' -$zipUrl = '__DL_BASE__/igneum-windows-v4.zip' +$zipUrl = "$env:RELAY_DL_BASE/igneum-windows-v4.zip" # the agent holds the downloads base (X26); never a token in this file $root = 'C:\igneum-v4' $zip = Join-Path $env:TEMP 'igneum-windows-v4.zip' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $old = Join-Path $root 'igneum-windows\STOP-IGNEUM.bat' if (Test-Path $old) { Write-Host 'stopping the running miner'; & cmd.exe /c "`"$old`"" 2>&1 | Out-Null; Start-Sleep 3 } Get-Process igneum-miner, igneumd -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue -Write-Host "downloading $zipUrl" +Write-Host "downloading igneum-windows-v4.zip from the downloads host" Invoke-WebRequest -Uri $zipUrl -OutFile $zip -UseBasicParsing -TimeoutSec 900 if (Test-Path $root) { Remove-Item $root -Recurse -Force } New-Item -ItemType Directory -Force -Path $root | Out-Null diff --git a/relay/playbooks/oneclick-test.ps1 b/relay/playbooks/oneclick-test.ps1 index 95f2fa32..603613a3 100644 --- a/relay/playbooks/oneclick-test.ps1 +++ b/relay/playbooks/oneclick-test.ps1 @@ -2,6 +2,6 @@ # Queue: node tools/relay.mjs run PC1 "one-click test" relay/playbooks/oneclick-test.ps1 $ErrorActionPreference = 'Continue' Write-Host "oneclick-test placeholder on $env:COMPUTERNAME as $env:RELAY_MACHINE (pass $env:RELAY_PASS)" -Write-Host 'planned: download the one-click zip from __DL_BASE__, extract to C:\igneum-oneclick, run its START script, wait 60 s, post the log' +Write-Host 'planned: download the one-click zip from the downloads host ($env:RELAY_DL_BASE), extract to C:\igneum-oneclick, run its START script, wait 60 s, post the log' Get-ChildItem C:\ -Directory -Filter 'igneum*' -ErrorAction SilentlyContinue | ForEach-Object { Write-Host ("present: " + $_.FullName) } exit 0 diff --git a/relay/playbooks/prover-setup.ps1 b/relay/playbooks/prover-setup.ps1 index 26126e4f..16a7ebe5 100644 --- a/relay/playbooks/prover-setup.ps1 +++ b/relay/playbooks/prover-setup.ps1 @@ -1,15 +1,15 @@ # Igneum playbook: install the SP1 prover package inside WSL (CUDA toolkit, Rust, sp1up, pre-build). Needs wsl-setup first. # Queue: node tools/relay.mjs run PC2 "prover setup" relay/playbooks/prover-setup.ps1 -# Downloads igneum-prove-wsl2.zip from the downloads host (relay.mjs fills __DL_BASE__ in), extracts to C:\igneum-prove, -# runs setup-wsl.sh as the igneum user with sudo unlocked (NOPASSWD from wsl-setup; sudo -S with the password as a fallback). +# Downloads igneum-prove-wsl2.zip from the downloads host ($env:RELAY_DL_BASE, held by the agent), extracts to C:\igneum-prove, +# runs setup-wsl.sh as the igneum user; sudo is unlocked for apt-get and dpkg only (wsl-setup, X28), no password on any command line. # 15 to 40 minutes, 4 to 5 GB of downloads (approximate, from the package README). UNTESTED on a PC as of 4 Oct 2026. $ErrorActionPreference = 'Continue' $distro = 'Ubuntu-24.04' -$zipUrl = '__DL_BASE__/igneum-prove-wsl2.zip' +$zipUrl = "$env:RELAY_DL_BASE/igneum-prove-wsl2.zip" # the agent holds the downloads base (X26); never a token in this file $root = 'C:\igneum-prove' $zip = Join-Path $env:TEMP 'igneum-prove-wsl2.zip' function Strip([string] $s) { if ($null -eq $s) { return '' }; return ($s -replace "`0", '') } -Write-Host "downloading $zipUrl" +Write-Host "downloading igneum-prove-wsl2.zip from the downloads host" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Invoke-WebRequest -Uri $zipUrl -OutFile $zip -UseBasicParsing -TimeoutSec 600 New-Item -ItemType Directory -Force -Path $root | Out-Null @@ -17,8 +17,8 @@ Expand-Archive -Path $zip -DestinationPath $root -Force $pkg = Join-Path $root 'igneum-prove-wsl2' if (-not (Test-Path (Join-Path $pkg 'setup-wsl.sh'))) { Write-Host "ERROR: setup-wsl.sh missing under $pkg"; exit 2 } $linuxDir = '/mnt/c/igneum-prove/igneum-prove-wsl2' -Write-Host "running setup-wsl.sh inside $distro as igneum (sudo unlocked)" -$cmd = "echo igneum | sudo -S -v 2>/dev/null; sudo -n true || echo 'sudo still asks for a password: wsl-setup did not run'; cd $linuxDir && bash ./setup-wsl.sh" +Write-Host "running setup-wsl.sh inside $distro as igneum (sudo unlocked for apt-get and dpkg)" +$cmd = "sudo -n apt-get --version >/dev/null 2>&1 || echo 'sudo apt-get still asks for a password: wsl-setup did not run'; cd $linuxDir && bash ./setup-wsl.sh" & wsl.exe -d $distro -u igneum -- bash -lc $cmd 2>&1 | ForEach-Object { Strip "$_" } $rc = $LASTEXITCODE Write-Host "setup-wsl.sh exit $rc" diff --git a/relay/playbooks/shard-test.ps1 b/relay/playbooks/shard-test.ps1 index 2535aa11..1d7e5e36 100644 --- a/relay/playbooks/shard-test.ps1 +++ b/relay/playbooks/shard-test.ps1 @@ -14,7 +14,7 @@ $started = Get-Date $deadline = $started.AddMinutes($budgetMinutes) $distro = 'Ubuntu-24.04' $wslUser = '[user]' -$zipUrl = '__DL_BASE__/igneum-prove-wsl2.zip' +$zipUrl = "$env:RELAY_DL_BASE/igneum-prove-wsl2.zip" # the agent holds the downloads base (X26); never a token in this file $root = 'C:\igneum-prove' $pkg = Join-Path $root 'igneum-prove-wsl2' $zip = Join-Path $env:TEMP 'igneum-prove-wsl2.zip' diff --git a/relay/playbooks/wsl-setup.ps1 b/relay/playbooks/wsl-setup.ps1 index 505a5ef0..df92dca9 100644 --- a/relay/playbooks/wsl-setup.ps1 +++ b/relay/playbooks/wsl-setup.ps1 @@ -1,7 +1,9 @@ # Igneum playbook: WSL2 + Ubuntu 24.04 with a ready `igneum` user, no questions asked. Two passes around one reboot. # Queue from the Mac: node tools/relay.mjs run PC2 "wsl setup" relay/playbooks/wsl-setup.ps1 --elevated --reboot-continue -# Pass 1: hypervisor on, VirtualMachinePlatform + WSL features, prints RELAY-REBOOT (the agent reboots and re-arms). -# Pass 2: wsl --install Ubuntu-24.04 --no-launch, creates the user igneum (password igneum, sudo without a password), +# Pass 1: hypervisor on, VirtualMachinePlatform + WSL features, prints RELAY-REBOOT on its own line (the agent reboots +# only because the task was queued with --reboot-continue, and re-arms for that one restart). +# Pass 2: wsl --install Ubuntu-24.04 --no-launch, creates the user igneum (password igneum; sudo without a password for +# apt-get and dpkg only, with SETENV for DEBIAN_FRONTEND, which is all setup-wsl.sh needs: X28, no blanket sudo), # makes it the default, checks the GPU is visible inside WSL. # UNTESTED on a PC as of 4 Oct 2026: written from the wsl.exe and dism.exe documentation; expect a first-run fix. $ErrorActionPreference = 'Continue' @@ -37,7 +39,7 @@ $list = Strip (((& wsl.exe --list --verbose 2>&1) | Out-String)); Write-Host $li if ($list -notmatch 'Ubuntu-24\.04') { Write-Host "ERROR: $distro is not registered after the install"; exit 2 } $mk = 'id igneum >/dev/null 2>&1 || (useradd -m -s /bin/bash igneum && echo igneum:igneum | chpasswd && usermod -aG sudo igneum); ' + - 'echo "igneum ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/igneum && chmod 440 /etc/sudoers.d/igneum; ' + + 'echo "igneum ALL=(root) NOPASSWD:SETENV: /usr/bin/apt-get, /usr/bin/dpkg" > /etc/sudoers.d/igneum && chmod 440 /etc/sudoers.d/igneum && visudo -cf /etc/sudoers.d/igneum; ' + 'printf "[user]\ndefault=igneum\n[boot]\nsystemd=true\n" > /etc/wsl.conf; id igneum' & wsl.exe -d $distro -u root -- bash -c $mk 2>&1 | ForEach-Object { Strip "$_" } & wsl.exe --terminate $distro 2>&1 | Out-Null diff --git a/relay/test/clients.test.mjs b/relay/test/clients.test.mjs new file mode 100644 index 00000000..94ad70af --- /dev/null +++ b/relay/test/clients.test.mjs @@ -0,0 +1,119 @@ +// node --test relay/test/clients.test.mjs Structural checks of the clients, the playbooks and the Mac tools (no pwsh +// on the Mac: the PowerShell 5.1 parse is windows.yml's job; these catch the shapes the ledger names: X24, X25, X26, +// X27, X28, X29). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const read = p => readFileSync(join(ROOT, p), 'utf8'); +const CLIENTS = ['relay/clients/igneum-agent.ps1', 'relay/clients/send.ps1', 'relay/clients/agent.sh', 'relay/clients/send.sh']; +const TOOLS = ['tools/relay.mjs', 'tools/console.mjs', 'tools/build-job.mjs']; + +test('X24: every client and Mac tool sends x-relay-token as a header and never builds a tokened API path', () => { + for (const f of [...CLIENTS, ...TOOLS]) { + const s = read(f); + assert.match(s, /x-relay-token/, `${f} sends no x-relay-token header`); + assert.doesNotMatch(s, /\/r\/\$RelayToken\/api|\/r\/\$RELAY_TOKEN\/api|\/r\/\$\{token\b|\/r\/\$\{TOKEN\}\/(api|c)\//, `${f} still builds an API path with the token in it`); + } + // the one tokened path left is the phone's page, printed by `url` + assert.match(read('tools/relay.mjs'), /const WEB = `\$\{BASE\}\/r\/\$\{TOKEN\}`/); + assert.match(read('tools/relay.mjs'), /const API = `\$\{BASE\}\/api\/relay\?fn=`/); +}); + +test('X29: the shell clients hand curl its secret headers through a config file, never on the command line', () => { + for (const f of ['relay/clients/agent.sh', 'relay/clients/send.sh']) { + const s = read(f); + assert.match(s, /-K "\$HDR"/, `${f} does not use curl -K`); + assert.doesNotMatch(s, /curl[^\n]*-H "x-(igneum-key|relay-token|machine-secret):/, `${f} puts a secret header on the curl command line`); + } +}); + +test('X25: the agent arms the logon task only on the reboot paths and disarms on start and in finally', () => { + const s = read('relay/clients/igneum-agent.ps1'); + const lines = s.split('\n'); + const arms = lines.map((l, i) => [l, i]).filter(([l]) => /^\s*Arm-Restart\s*$/.test(l)); + assert.equal(arms.length, 2, 'Arm-Restart is called exactly twice (the two reboot branches)'); + for (const [, i] of arms) { + assert.ok(lines.slice(i, i + 4).some(l => /shutdown\.exe \/r/.test(l)), `Arm-Restart at line ${i + 1} is not followed by the restart`); + assert.ok(/^\s+/.test(lines[i]), 'Arm-Restart is never a top-level statement'); + } + assert.match(s, /^Disarm-Restart$/m, 'the agent disarms at start'); + assert.match(s, /finally \{[\s\S]*Disarm-Restart[\s\S]*\}/, 'the agent disarms in finally'); + assert.match(s, /schtasks\.exe \/Delete \/F \/TN 'IgneumRelayAgent'/); + assert.match(s, /Remove-ItemProperty -Path \$k -Name 'IgneumRelayAgent'/); +}); + +test('X23: the agent checks the machine tag and the nonce before Start-Process, and refuses with exit 77', () => { + const s = read('relay/clients/igneum-agent.ps1'); + const run = s.slice(s.indexOf('function Run-Task'), s.indexOf('Log ("igneum relay agent on')); + const check = run.indexOf('$why = Check-Task $task'); + const start = run.indexOf('Start-Process powershell.exe'); + assert.ok(check > 0 && start > check, 'Check-Task runs before Start-Process'); + assert.match(run, /Post-Result \$task 77 \$log \("refused: " \+ \$why\)/); + assert.match(s, /HMACSHA256/); + assert.match(s, /igneum-relay-run\/1`nto=/); + const sh = read('relay/clients/agent.sh'); + assert.ok(sh.indexOf('check_task "$it"') < sh.indexOf('bash "$STATE/tasks/task-$id.sh"'), 'agent.sh checks before it runs'); + assert.match(sh, /hmac\.compare_digest/); +}); + +test('X28: the reboot marker must stand on its own line and the task must be queued with a reboot flag; GET inbox is never acked', () => { + const ps = read('relay/clients/igneum-agent.ps1'); + assert.match(ps, /\(\?m\)\^RELAY-REBOOT\\r\?\$/); + assert.match(ps, /\$reboot = \$asked -and \$rebootAllowed/); + assert.match(ps, /Api-Post 'inbox' @\{ machine = \$script:Machine; kind = 'run'; ack = \$true \}/); + assert.doesNotMatch(ps, /inbox\?machine=[^\n]*ack=1/); + const sh = read('relay/clients/agent.sh'); + assert.match(sh, /grep -qx 'RELAY-REBOOT' "\$logf" && \[ -n "\$rebootok" \]/); + assert.doesNotMatch(sh, /inbox\?machine=[^\n]*ack=1/); + for (const f of ['relay/clients/send.ps1', 'relay/clients/send.sh']) assert.doesNotMatch(read(f), /inbox\?machine=[^\n]*ack=1/, `${f} acks through a GET`); +}); + +test('X28: the registration carries no username and no folder', () => { + const ps = read('relay/clients/igneum-agent.ps1'); + const info = ps.slice(ps.indexOf('function Collect-Info'), ps.indexOf('function Register-Machine')); + assert.doesNotMatch(info, /user = |dir = /); + const sh = read('relay/clients/agent.sh'); + assert.doesNotMatch(sh, /"user":|"dir":/); +}); + +test('X26: no playbook carries __DL_BASE__ or prints the download URL; the agents hand the base over as RELAY_DL_BASE', () => { + for (const f of readdirSync(join(ROOT, 'relay/playbooks'))) { + const s = read(`relay/playbooks/${f}`); + assert.doesNotMatch(s, /__DL_BASE__/, `${f} still uses __DL_BASE__`); + assert.doesNotMatch(s, /Write-Host "downloading \$zipUrl"/, `${f} prints the tokened URL`); + } + assert.match(read('relay/clients/igneum-agent.ps1'), /\$env:RELAY_DL_BASE = '\$DlBase'/); + assert.match(read('relay/clients/agent.sh'), /export RELAY_DL_BASE=/); + const mjs = read('tools/relay.mjs'); + assert.doesNotMatch(mjs, /replace\(\/__DL_BASE__\/g/, 'tools/relay.mjs still substitutes the dl base into bodies'); + assert.match(mjs, /carries the dl token; it must never be in a task body/); +}); + +test('X27: results and registration carry the machine secret header; make-clients.sh bakes it per machine', () => { + for (const f of CLIENTS) assert.match(read(f), /x-machine-secret/, `${f} never presents the machine secret`); + const mk = read('relay/clients/make-clients.sh'); + assert.match(mk, /--machine\) MACHINE=/); + assert.match(mk, /machine-secret\.txt/); + assert.match(mk, /__DL_BASE__#\$DL_BASE#g/); +}); + +test('X28: the WSL playbook grants sudo for apt-get and dpkg only, and no password travels on a command line', () => { + const w = read('relay/playbooks/wsl-setup.ps1'); + assert.doesNotMatch(w, /NOPASSWD:ALL/); + assert.match(w, /NOPASSWD:SETENV: \/usr\/bin\/apt-get, \/usr\/bin\/dpkg/); + assert.doesNotMatch(read('relay/playbooks/prover-setup.ps1'), /echo igneum \| sudo -S/); +}); + +test('PowerShell shape: balanced braces and here-strings in the two .ps1 clients (the 5.1 parse runs in windows.yml)', () => { + for (const f of ['relay/clients/igneum-agent.ps1', 'relay/clients/send.ps1']) { + const s = read(f); + const open = (s.match(/\{/g) || []).length; const close = (s.match(/\}/g) || []).length; + assert.equal(open, close, `${f}: ${open} { against ${close} }`); + assert.equal((s.match(/@"\s*$/gm) || []).length, (s.match(/^"@\s*$/gm) || []).length, `${f}: here-string markers`); + assert.doesNotMatch(s, /\$[A-Za-z_]+:\s[a-z]/, `${f}: a "$name: text" drive-qualified reference (the 5.1 class of 4 October)`); + } +}); diff --git a/relay/test/guard.test.mjs b/relay/test/guard.test.mjs new file mode 100644 index 00000000..37cc66ed --- /dev/null +++ b/relay/test/guard.test.mjs @@ -0,0 +1,113 @@ +// node --test relay/test/guard.test.mjs (no dependencies, no network) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { authVia, runCanon, keygen, signRun, verifyRun, machineTag, sameTag, checkRun, wantsReboot, feedLimit, retentionCutoff, machineForSecret, secretHash, newNonce, newSecret, FEED_LIMIT_MAX, RETENTION_DAYS, POST_ALLOWED, DROP_KINDS, mayRead } from '../lib/guard.mjs'; + +const T = 'ABCDEFGHIJKLMNOPQRST'; // the token shape: 20 characters +const K = 'relaykeyrelaykeyrelaykeyrelaykeyrelaykeyrelayke'; // 48 +const I = 'intakekeyintakekeyintakekeyinta'; // 32 +const env = { RELAY_TOKEN: T, RELAY_KEY: K, LOG_INTAKE_KEY: I }; + +test('authVia: the header alone is the token tier (X24); the path token still works for the phone page', () => { + assert.equal(authVia({ headers: { 'x-relay-token': T } }, env), 'token'); + assert.equal(authVia({ query: { token: T }, headers: {} }, env), 'token'); + assert.equal(authVia({ headers: { 'x-relay-token': T.slice(0, 19) + 'x' } }, env), null); + assert.equal(authVia({ headers: {} }, env), null); +}); + +test('authVia: three tiers; the intake key is its own tier and RELAY_INTAKE_COMPAT=0 closes it (X23)', () => { + assert.equal(authVia({ headers: { 'x-igneum-key': K } }, env), 'key'); + assert.equal(authVia({ headers: { 'x-igneum-key': I } }, env), 'intake'); + assert.equal(authVia({ headers: { 'x-igneum-key': I } }, { ...env, LOG_INTAKE_KEY: '', LOG_INTAKE_KEY_NEXT: I }), 'intake'); + assert.equal(authVia({ headers: { 'x-igneum-key': I } }, { ...env, RELAY_INTAKE_COMPAT: '0' }), null); + assert.equal(authVia({ headers: { 'x-igneum-key': 'wrongwrongwrongwrongwrongwrongwr' } }, env), null); +}); + +test('tiers: what each may post and read', () => { + assert.equal(POST_ALLOWED.token.has('task'), true); + assert.equal(POST_ALLOWED.key.has('task'), false); + assert.equal(POST_ALLOWED.key.has('secret'), false); + assert.deepEqual([...POST_ALLOWED.intake].sort(), ['drop', 'upload']); + assert.equal(DROP_KINDS.intake.has('result'), false); + assert.equal(DROP_KINDS.key.has('run'), false); + assert.equal(DROP_KINDS.token, null); + assert.deepEqual(['token', 'key', 'intake', null].map(mayRead), [true, true, false, false]); +}); + +test('runCanon: deterministic, names the machine, the nonce, the flags and the body hash', () => { + const a = runCanon({ to: 'PC1', nonce: 'a'.repeat(32), body: 'Write-Host hi', flags: { elevated: true } }); + const b = runCanon({ to: 'PC1', nonce: 'a'.repeat(32), body: 'Write-Host hi', flags: { elevated: 1 } }); + assert.equal(a, b); + assert.match(a, /^igneum-relay-run\/1\nto=PC1\nnonce=a{32}\nelevated=1\nreboot_continue=0\nreboot=0\nbody_sha256=[0-9a-f]{64}\n$/); + assert.notEqual(a, runCanon({ to: 'PC2', nonce: 'a'.repeat(32), body: 'Write-Host hi', flags: { elevated: true } })); + assert.notEqual(a, runCanon({ to: 'PC1', nonce: 'a'.repeat(32), body: 'Write-Host hi ', flags: { elevated: true } })); + assert.notEqual(a, runCanon({ to: 'PC1', nonce: 'a'.repeat(32), body: 'Write-Host hi', flags: {} })); +}); + +test('Ed25519: a good signature verifies; a changed byte, another key or a malformed signature does not', () => { + const { seed, pub } = keygen(); + const other = keygen(); + const canon = runCanon({ to: 'PC1', nonce: newNonce(), body: 'x' }); + const sig = signRun(canon, seed); + assert.equal(sig.length, 128); + assert.equal(verifyRun(canon, sig, pub), true); + assert.equal(verifyRun(canon + ' ', sig, pub), false); + assert.equal(verifyRun(canon, sig, other.pub), false); + assert.equal(verifyRun(canon, sig.slice(0, 127) + (sig.endsWith('0') ? '1' : '0'), pub), false); + assert.equal(verifyRun(canon, 'nothex', pub), false); + assert.equal(verifyRun(canon, sig, 'nothex'), false); +}); + +test('machineTag: HMAC with the machine secret; sameTag compares in constant time and refuses malformed tags', () => { + const s = newSecret(); + const canon = runCanon({ to: 'PC1', nonce: newNonce(), body: 'x' }); + const t = machineTag(s, canon); + assert.equal(t.length, 64); + assert.equal(sameTag(t, machineTag(s, canon)), true); + assert.equal(sameTag(t, machineTag(newSecret(), canon)), false); + assert.equal(sameTag(t, machineTag(s, canon + 'x')), false); + assert.equal(sameTag(t, 'short'), false); +}); + +test('checkRun: a run without the signature, the tag or the nonce is refused; a complete one passes (X23)', () => { + const { seed, pub } = keygen(); + const nonce = newNonce(); + const body = 'Write-Host hi'; + const flags = { elevated: true, nonce, mac: machineTag(newSecret(), runCanon({ to: 'PC1', nonce, body, flags: { elevated: true } })) }; + flags.sig = signRun(runCanon({ to: 'PC1', nonce, body, flags }), seed); + assert.equal(checkRun({ to: 'PC1', body, flags }, pub), null); + assert.match(checkRun({ to: 'PC1', body, flags: {} }, pub), /nonce/); + assert.match(checkRun({ to: 'PC1', body, flags: { nonce } }, pub), /mac/); + assert.match(checkRun({ to: 'PC1', body, flags: { nonce, mac: flags.mac } }, pub), /sig/); + assert.match(checkRun({ to: 'PC1', body, flags }, ''), /RELAY_RUN_PUB/); + assert.match(checkRun({ to: 'PC1', body: body + ' ', flags }, pub), /does not verify/); + assert.match(checkRun({ to: 'PC2', body, flags }, pub), /does not verify/); + assert.match(checkRun({ to: 'PC1', body, flags: { ...flags, elevated: false } }, pub), /does not verify/); + assert.match(checkRun({ to: 'all', body, flags }, pub), /one named machine/); +}); + +test('wantsReboot: the marker on its own line only (X28)', () => { + assert.equal(wantsReboot('features enabled\nRELAY-REBOOT\n'), true); + assert.equal(wantsReboot('RELAY-REBOOT'), true); + assert.equal(wantsReboot('a\r\nRELAY-REBOOT\r\nb'), true); + assert.equal(wantsReboot('the script prints RELAY-REBOOT when it wants a restart\n'), false); + assert.equal(wantsReboot('RELAY-REBOOT-NOT\n'), false); + assert.equal(wantsReboot(''), false); +}); + +test('feedLimit and retention (X26)', () => { + assert.equal(feedLimit({}), 50); + assert.equal(feedLimit({ limit: '500' }), FEED_LIMIT_MAX); + assert.equal(feedLimit({ limit: '0' }), 50); + assert.equal(feedLimit({ limit: '7' }), 7); + assert.equal(RETENTION_DAYS, 30); + assert.equal(retentionCutoff(Date.UTC(2026, 9, 5, 22, 0, 0)), '2026-09-05T22:00:00.000Z'); +}); + +test('machineForSecret: the stored sha256 names the machine; a wrong or malformed secret names nothing (X27)', () => { + const s = newSecret(); + const rows = [{ name: 'PC1', secret_hash: secretHash(s) }, { name: 'PC2', secret_hash: secretHash(newSecret()) }, { name: 'Mac', secret_hash: null }]; + assert.deepEqual(machineForSecret(s, rows), { name: 'PC1' }); + assert.deepEqual(machineForSecret(newSecret(), rows), { error: 'unknown machine secret' }); + assert.deepEqual(machineForSecret('short', rows), { error: 'x-machine-secret must be 64 hex' }); +}); diff --git a/relay/test/handler.test.mjs b/relay/test/handler.test.mjs new file mode 100644 index 00000000..f62f21c3 --- /dev/null +++ b/relay/test/handler.test.mjs @@ -0,0 +1,251 @@ +// node --test relay/test/handler.test.mjs (no dependencies, no database, no network: a fake Neon, fake blobs, a fake clock) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeHandler, expire, EXPIRE_EVERY_MS } from '../lib/handler.mjs'; +import { authed } from '../lib/relay.mjs'; +import { RateLimit } from '../lib/wake.mjs'; +import { keygen, signRun, runCanon, machineTag, newNonce, newSecret, secretHash, RATE_PER_MIN, AUTH_FAIL_PER_MIN } from '../lib/guard.mjs'; + +const T = 'ABCDEFGHIJKLMNOPQRST'; +const K = 'relaykeyrelaykeyrelaykeyrelaykeyrelaykeyrelayke'; +const I = 'intakekeyintakekeyintakekeyinta'; +const RUN = keygen(); +const ENV = { RELAY_TOKEN: T, RELAY_KEY: K, LOG_INTAKE_KEY: I, RELAY_RUN_PUB: RUN.pub }; +const BLOB = 'https://abc123.public.blob.vercel-storage.com/relay/aa/x.zip'; + +// The smallest Neon stand-in that answers every query lib/handler.mjs sends, matched on the query text. +function fakeDb({ nowIso = () => '2026-10-05T22:00:00.000Z' } = {}) { + const items = []; const machines = []; let seq = 0; + const flagsOf = r => (typeof r.flags === 'string' ? JSON.parse(r.flags) : r.flags || {}); + const sql = async (query, params = []) => { + const Q = query.replace(/\s+/g, ' ').trim(); + if (/^ALTER TABLE relay_machines/.test(Q)) return []; + if (/^SELECT name, secret_hash FROM relay_machines WHERE secret_hash IS NOT NULL/.test(Q)) return machines.filter(m => m.secret_hash).map(m => ({ name: m.name, secret_hash: m.secret_hash })); + if (/^SELECT secret_hash FROM relay_machines WHERE name = \$1/.test(Q)) return machines.filter(m => m.name === params[0]).map(m => ({ secret_hash: m.secret_hash || null })); + if (/^DELETE FROM relay_items WHERE ts < \$1 RETURNING/.test(Q)) { const gone = items.filter(i => i.ts < params[0]); for (const g of gone) items.splice(items.indexOf(g), 1); return gone.map(g => ({ id: g.id, file_url: g.file_url })); } + if (/^SELECT .* FROM relay_items WHERE NOT read AND NOT done AND kind = ANY\(\$2\)/.test(Q)) return items.filter(i => !i.read && !i.done && params[1].includes(i.kind) && (i.to_machine === params[0] || (i.to_machine === 'all' && i.kind === 'task'))).sort((a, b) => a.id - b.id).slice(0, 50); + if (/^SELECT .* FROM relay_items WHERE id = \$1/.test(Q)) return items.filter(i => i.id === params[0]); + if (/^SELECT file_name, file_url, file_b64 FROM relay_items WHERE id = \$1/.test(Q)) return items.filter(i => i.id === params[0]); + if (/^SELECT id FROM relay_items WHERE kind = 'run' AND flags->>'nonce' = \$1/.test(Q)) return items.filter(i => i.kind === 'run' && flagsOf(i).nonce === params[0]).map(i => ({ id: i.id })); + if (/^SELECT .* FROM relay_items .*ORDER BY id DESC LIMIT (\d+)/.test(Q)) { const lim = Number(Q.match(/LIMIT (\d+)/)[1]); return [...items].sort((a, b) => b.id - a.id).slice(0, lim); } + if (/^SELECT m\.name, m\.hostname/.test(Q)) return machines.map(m => ({ ...m, unread: items.filter(i => !i.read && ['task', 'run'].includes(i.kind) && (i.to_machine === m.name || (i.to_machine === 'all' && i.kind === 'task'))).length })); + if (/^SELECT name, hostname, role, named, info, last_seen, secret_hash FROM relay_machines ORDER BY name/.test(Q)) return machines.map(m => ({ ...m })); + if (/^SELECT name, role, named, secret_hash FROM relay_machines WHERE hostname = \$1/.test(Q)) return machines.filter(m => m.hostname === params[0]).map(m => ({ ...m })); + if (/^INSERT INTO relay_items/.test(Q)) { + const [from_machine, to_machine, kind, title, body, file_name, file_url, file_b64, size, flags, task_id] = params; + const row = { id: ++seq, ts: nowIso(), from_machine, to_machine, kind, title, body, file_name, file_url, file_b64, size, read: false, read_at: null, done: false, done_at: null, flags: JSON.parse(flags), task_id }; + items.push(row); return [{ id: row.id, ts: row.ts }]; + } + if (/^INSERT INTO relay_machines \(name, role, named, last_seen\)/.test(Q)) { const m = machines.find(x => x.name === params[0]); if (m) m.last_seen = nowIso(); else machines.push({ name: params[0], hostname: null, role: '', named: false, info: {}, last_seen: nowIso(), secret_hash: null }); return []; } + if (/^INSERT INTO relay_machines \(name, role, named, secret_hash\)/.test(Q)) { const m = machines.find(x => x.name === params[0]); if (m) { m.secret_hash = params[1]; m.named = true; } else machines.push({ name: params[0], hostname: null, role: '', named: true, info: {}, last_seen: null, secret_hash: params[1] }); return []; } + if (/^INSERT INTO relay_machines \(name, hostname, role, named, info, last_seen\)/.test(Q)) { let m = machines.find(x => x.name === params[0]); if (!m) { m = { name: params[0], hostname: params[0], role: params[1], named: false, info: JSON.parse(params[2]), last_seen: nowIso(), secret_hash: null }; machines.push(m); } else { m.hostname = params[0]; m.info = JSON.parse(params[2]); } return [{ name: m.name, role: m.role, named: m.named }]; } + if (/^UPDATE relay_machines SET hostname = \$2, last_seen = now\(\), info = \$3::jsonb WHERE name = \$1 RETURNING/.test(Q)) { const m = machines.find(x => x.name === params[0]); m.hostname = params[1]; m.info = JSON.parse(params[2]); m.last_seen = nowIso(); return [{ name: m.name, role: m.role, named: m.named }]; } + if (/^UPDATE relay_machines SET last_seen = now\(\), info = \$2::jsonb WHERE hostname = \$1/.test(Q)) { for (const m of machines) if (m.hostname === params[0]) { m.info = JSON.parse(params[1]); m.last_seen = nowIso(); } return []; } + if (/^UPDATE relay_items SET read = true, read_at = now\(\) WHERE id = ANY\(\$1\)/.test(Q)) { for (const i of items) if (params[0].includes(i.id)) { i.read = true; i.read_at = nowIso(); } return []; } + if (/^UPDATE relay_items SET done = true/.test(Q)) { const i = items.find(x => x.id === params[0]); if (i) { i.done = true; i.read = true; i.flags = { ...i.flags, ...JSON.parse(params[1]) }; } return []; } + if (/^DELETE FROM relay_items WHERE id = \$1 RETURNING file_url/.test(Q)) { const i = items.find(x => x.id === params[0]); if (!i) return []; items.splice(items.indexOf(i), 1); return [{ file_url: i.file_url }]; } + throw new Error('fake db: unexpected query ' + Q.slice(0, 120)); + }; + return { sql, items, machines, seed: (row) => { const r = { id: ++seq, ts: nowIso(), read: false, done: false, flags: {}, file_url: null, file_b64: null, ...row }; items.push(r); return r; } }; +} + +function fakeBlob() { + const deleted = []; + return { deleted, storeBuffer: async (name, buf) => ({ url: BLOB, size: buf.length }), clientUploadToken: async (name, size) => ({ token: 't', put_url: 'https://vercel.com/api/blob/?pathname=x', api_version: '11', max: 50 * 1024 * 1024, size }), deleteBlobs: async urls => { const l = urls.filter(Boolean); deleted.push(...l); return l.length; } }; +} + +function res() { + const r = { headers: {}, body: null, code: null }; + r.setHeader = (k, v) => { r.headers[k] = v; return r; }; + r.status = s => { r.code = s; return r; }; + r.end = s => { r.body = s; }; + return r; +} +const json = (r, status, obj) => { r.status(status); r.end(JSON.stringify(obj)); }; +const reply = r => ({ code: r.code, ...(r.body ? JSON.parse(r.body) : {}) }); +const req = (method, query = {}, { headers = {}, body, ip = '203.0.113.9' } = {}) => ({ method, query, headers: { 'x-forwarded-for': ip, 'content-type': 'application/json', ...headers }, body: body === undefined ? undefined : JSON.stringify(body) }); +const TOKEN = { 'x-relay-token': T }; +const KEY = { 'x-igneum-key': K }; +const INTAKE = { 'x-igneum-key': I }; + +function rig(opts = {}) { + const db = fakeDb(); const blob = fakeBlob(); let t = Date.UTC(2026, 9, 5, 22, 0, 0); + const now = () => t; + const env = { ...ENV, ...(opts.env || {}) }; + const state = {}; + const h = makeHandler({ sql: db.sql, blob, authed: r => authed(r, env), json, env, now, state, limiter: opts.limiter, authLimiter: opts.authLimiter }); + const call = async (...a) => { const r = res(); await h(req(...a), r); return reply(r); }; + return { db, blob, call, state, tick: ms => { t += ms; } }; +} + +function signedRun(to, body, secret, flags = {}) { + const nonce = newNonce(); + const f = { ...flags, nonce }; + f.mac = machineTag(secret, runCanon({ to, nonce, body, flags: f })); + f.sig = signRun(runCanon({ to, nonce, body, flags: f }), RUN.seed); + return { to, title: 'job', body, kind: 'run', flags: f }; +} + +test('X24: the x-relay-token header with no token in the path is the token tier; no auth is 401', async () => { + const { call } = rig(); + assert.equal((await call('GET', { fn: 'feed' }, { headers: TOKEN })).code, 200); + assert.equal((await call('GET', { fn: 'feed' }, { headers: { 'x-relay-token': 'wrong' } })).code, 401); + assert.equal((await call('GET', { fn: 'feed' })).code, 401); +}); + +test('X23: a token-only run task is refused with 401; a signed, tagged, fresh one is stored; a replayed nonce is 409', async () => { + const { call, db } = rig(); + const secret = newSecret(); + const bare = await call('POST', { fn: 'task' }, { headers: TOKEN, body: { to: 'PC1', title: 'x', body: 'Write-Host hi', kind: 'run', flags: { elevated: true } } }); + assert.equal(bare.code, 401); + assert.match(bare.error, /nonce/); + const good = signedRun('PC1', 'Write-Host hi', secret, { elevated: true }); + const r = await call('POST', { fn: 'task' }, { headers: TOKEN, body: good }); + assert.equal(r.code, 200, r.error); + assert.equal(db.items[0].kind, 'run'); + assert.equal(db.items[0].flags.sig, good.flags.sig); + const again = await call('POST', { fn: 'task' }, { headers: TOKEN, body: good }); + assert.equal(again.code, 409); + // the path token (the phone page) is still the token tier, and still needs the signature + const viaPath = await call('POST', { fn: 'task', token: T }, { body: { to: 'PC1', title: 'x', body: 'y', kind: 'run' } }); + assert.equal(viaPath.code, 401); +}); + +test('X23: a run signed by another key, or with a changed body, flag or target after signing, is refused', async () => { + const { call } = rig(); + const secret = newSecret(); + const good = signedRun('PC1', 'Write-Host hi', secret, { elevated: false }); + assert.equal((await call('POST', { fn: 'task' }, { headers: TOKEN, body: { ...good, body: 'Write-Host bye' } })).code, 401); + assert.equal((await call('POST', { fn: 'task' }, { headers: TOKEN, body: { ...good, to: 'PC2' } })).code, 401); + assert.equal((await call('POST', { fn: 'task' }, { headers: TOKEN, body: { ...good, flags: { ...good.flags, elevated: true } } })).code, 401); + const other = keygen(); + const forged = { ...good, flags: { ...good.flags, sig: signRun(runCanon({ to: 'PC1', nonce: good.flags.nonce, body: good.body, flags: good.flags }), other.seed) } }; + assert.equal((await call('POST', { fn: 'task' }, { headers: TOKEN, body: forged })).code, 401); +}); + +test('X23: without RELAY_RUN_PUB on the project every run is refused', async () => { + const { call } = rig({ env: { RELAY_RUN_PUB: '' } }); + const r = await call('POST', { fn: 'task' }, { headers: TOKEN, body: signedRun('PC1', 'x', newSecret()) }); + assert.equal(r.code, 401); + assert.match(r.error, /RELAY_RUN_PUB/); +}); + +test('X23: the relay key may report and read but never queue, rename, re-role, delete or bind', async () => { + const { call } = rig(); + assert.equal((await call('POST', { fn: 'task' }, { headers: KEY, body: { to: 'PC1', title: 'x', body: 'y', kind: 'task' } })).code, 403); + assert.equal((await call('POST', { fn: 'drop' }, { headers: KEY, body: { to: 'PC1', title: 'x', body: 'y', kind: 'run' } })).code, 403); + assert.equal((await call('POST', { fn: 'name' }, { headers: KEY, body: { hostname: 'h', name: 'PC9' } })).code, 403); + assert.equal((await call('POST', { fn: 'role' }, { headers: KEY, body: { name: 'PC1', role: 'miner' } })).code, 403); + assert.equal((await call('POST', { fn: 'delete' }, { headers: KEY, body: { id: 1 } })).code, 403); + assert.equal((await call('POST', { fn: 'secret' }, { headers: KEY, body: { name: 'PC1', secret_hash: 'a'.repeat(64) } })).code, 403); + assert.equal((await call('POST', { fn: 'drop' }, { headers: KEY, body: { from: 'PC1', title: 'note', body: 'hi', kind: 'text' } })).code, 200); + assert.equal((await call('GET', { fn: 'feed' }, { headers: KEY })).code, 200); +}); + +test('X23: the intake key (inside every package) may only upload and drop files or text; nothing else, no reads', async () => { + const { call } = rig(); + assert.equal((await call('POST', { fn: 'upload' }, { headers: INTAKE, body: { name: 'a.zst', size: 10 } })).code, 200); + assert.equal((await call('POST', { fn: 'drop' }, { headers: INTAKE, body: { from: 'DESKTOP-1234', to: 'mac', kind: 'file', title: 'build-job 7 a.zst', file_name: 'a.zst', file_url: BLOB, size: 10 } })).code, 200); + assert.equal((await call('POST', { fn: 'drop' }, { headers: INTAKE, body: { from: 'PC1', kind: 'result', title: 'r', body: 'x' } })).code, 403); + assert.equal((await call('POST', { fn: 'drop' }, { headers: INTAKE, body: { to: 'PC1', kind: 'run', title: 'r', body: 'x' } })).code, 403); + assert.equal((await call('POST', { fn: 'task' }, { headers: INTAKE, body: { to: 'PC1', title: 'x', body: 'y' } })).code, 403); + assert.equal((await call('POST', { fn: 'register' }, { headers: INTAKE, body: { hostname: 'h' } })).code, 403); + assert.equal((await call('POST', { fn: 'ack' }, { headers: INTAKE, body: { ids: [1] } })).code, 403); + assert.equal((await call('GET', { fn: 'feed' }, { headers: INTAKE })).code, 403); + assert.equal((await call('GET', { fn: 'inbox', machine: 'PC1' }, { headers: INTAKE })).code, 403); + const closed = rig({ env: { RELAY_INTAKE_COMPAT: '0' } }); + assert.equal((await closed.call('POST', { fn: 'upload' }, { headers: INTAKE, body: { name: 'a', size: 1 } })).code, 401); +}); + +test('X27: a result whose from does not match the machine secret is refused; a matching one is stored under that machine', async () => { + const { call, db } = rig(); + const s1 = newSecret(); const s2 = newSecret(); + assert.equal((await call('POST', { fn: 'secret' }, { headers: TOKEN, body: { name: 'PC1', secret_hash: secretHash(s1) } })).code, 200); + assert.equal((await call('POST', { fn: 'secret' }, { headers: TOKEN, body: { name: 'PC2', secret_hash: secretHash(s2) } })).code, 200); + const forged = await call('POST', { fn: 'drop' }, { headers: { ...KEY, 'x-machine-secret': s2 }, body: { from: 'PC1', kind: 'result', title: 'r', body: 'x', task_id: 3 } }); + assert.equal(forged.code, 403); + assert.match(forged.error, /does not match/); + const unknown = await call('POST', { fn: 'drop' }, { headers: { ...KEY, 'x-machine-secret': newSecret() }, body: { from: 'PC1', kind: 'result', title: 'r', body: 'x' } }); + assert.equal(unknown.code, 403); + const bare = await call('POST', { fn: 'drop' }, { headers: KEY, body: { from: 'PC1', kind: 'result', title: 'r', body: 'x' } }); + assert.equal(bare.code, 403); + assert.match(bare.error, /need its machine secret/); + const good = await call('POST', { fn: 'drop' }, { headers: { ...KEY, 'x-machine-secret': s1 }, body: { from: 'PC1', kind: 'result', title: 'r', body: 'x' } }); + assert.equal(good.code, 200); + assert.equal(db.items.at(-1).from_machine, 'PC1'); + assert.equal(db.items.at(-1).flags.unbound, undefined); + // a machine with no secret yet (the compatibility window) is accepted and marked unbound + const unbound = await call('POST', { fn: 'drop' }, { headers: KEY, body: { from: 'Laptop', kind: 'result', title: 'r', body: 'x' } }); + assert.equal(unbound.code, 200); + assert.equal(db.items.at(-1).flags.unbound, true); +}); + +test('X27: register with the secret names the machine whatever the hostname says; a bound hostname without it is refused', async () => { + const { call, db } = rig(); + const s1 = newSecret(); + await call('POST', { fn: 'secret' }, { headers: TOKEN, body: { name: 'PC1', secret_hash: secretHash(s1) } }); + const r = await call('POST', { fn: 'register' }, { headers: { ...KEY, 'x-machine-secret': s1 }, body: { hostname: 'DESKTOP-KMCV30N', info: { user: 'someone', dir: 'C:\\secret', gpus: ['5090'] } } }); + assert.deepEqual([r.code, r.name, r.bound], [200, 'PC1', true]); + assert.equal(db.machines.find(m => m.name === 'PC1').hostname, 'DESKTOP-KMCV30N'); + assert.deepEqual(db.machines.find(m => m.name === 'PC1').info, { gpus: ['5090'] }); // X28: no username, no folder + const bare = await call('POST', { fn: 'register' }, { headers: KEY, body: { hostname: 'DESKTOP-KMCV30N', info: {} } }); + assert.equal(bare.code, 403); + const fresh = await call('POST', { fn: 'register' }, { headers: KEY, body: { hostname: 'NEWBOX', info: { user: 'u', dir: 'd', os: 'w' } } }); + assert.deepEqual([fresh.code, fresh.name, fresh.bound], [200, 'NEWBOX', false]); + assert.deepEqual(db.machines.find(m => m.name === 'NEWBOX').info, { os: 'w' }); + const wrong = await call('POST', { fn: 'register' }, { headers: { ...KEY, 'x-machine-secret': newSecret() }, body: { hostname: 'NEWBOX', info: {} } }); + assert.equal(wrong.code, 403); +}); + +test('X28: a GET inbox never acks, even with ack=1; POST inbox {ack:true} does', async () => { + const { call, db } = rig(); + db.seed({ from_machine: 'Mac', to_machine: 'PC1', kind: 'task', title: 't', body: 'b' }); + const peek = await call('GET', { fn: 'inbox', machine: 'PC1', ack: '1' }, { headers: KEY }); + assert.deepEqual([peek.code, peek.items.length, peek.acked, db.items[0].read], [200, 1, 0, false]); + const got = await call('POST', { fn: 'inbox' }, { headers: KEY, body: { machine: 'PC1', kind: 'all', ack: true } }); + assert.deepEqual([got.code, got.items.length, got.acked, db.items[0].read], [200, 1, 1, true]); + assert.equal((await call('POST', { fn: 'inbox' }, { headers: KEY, body: { machine: 'PC1', ack: true } })).items.length, 0); +}); + +test('X28: the rate limit answers 429 per IP, and failed authentications have their own, lower limit', async () => { + const { call } = rig({ limiter: new RateLimit({ perMinute: 3 }), authLimiter: new RateLimit({ perMinute: 2 }) }); + assert.equal(RATE_PER_MIN, 120); assert.equal(AUTH_FAIL_PER_MIN, 10); + for (let i = 0; i < 3; i++) assert.equal((await call('GET', { fn: 'machines' }, { headers: TOKEN })).code, 200); + assert.equal((await call('GET', { fn: 'machines' }, { headers: TOKEN })).code, 429); + assert.equal((await call('GET', { fn: 'machines' }, { headers: TOKEN, ip: '198.51.100.2' })).code, 200); + assert.equal((await call('GET', { fn: 'machines' }, { ip: '198.51.100.3' })).code, 401); + assert.equal((await call('GET', { fn: 'machines' }, { ip: '198.51.100.3' })).code, 401); + assert.equal((await call('GET', { fn: 'machines' }, { ip: '198.51.100.3' })).code, 429); +}); + +test('X26: feed is capped at 100 a call; rows older than 30 days go with their blobs; delete takes the blob', async () => { + const { call, db, blob, tick, state } = rig(); + for (let i = 0; i < 120; i++) db.seed({ from_machine: 'Mac', to_machine: 'all', kind: 'text', title: 't' + i, body: 'b' }); + const f = await call('GET', { fn: 'feed', limit: '500' }, { headers: TOKEN }); + assert.deepEqual([f.code, f.items.length, f.limit], [200, 100, 100]); + const old = db.seed({ ts: '2026-08-01T00:00:00.000Z', from_machine: 'PC1', to_machine: 'all', kind: 'file', title: 'old', body: '', file_url: BLOB + '?old' }); + const recent = db.seed({ ts: '2026-10-01T00:00:00.000Z', from_machine: 'PC1', to_machine: 'all', kind: 'file', title: 'recent', body: '', file_url: BLOB + '?recent' }); + await call('GET', { fn: 'feed' }, { headers: TOKEN }); // inside the 10-minute window: no second sweep yet + assert.equal(db.items.includes(old), true); + tick(EXPIRE_EVERY_MS + 1); + await call('GET', { fn: 'feed' }, { headers: TOKEN }); + assert.equal(db.items.includes(old), false); + assert.equal(db.items.includes(recent), true); + assert.deepEqual(blob.deleted, [BLOB + '?old']); + assert.deepEqual(state.expired, { rows: 1, blobs: 1 }); + const d = await call('POST', { fn: 'delete' }, { headers: TOKEN, body: { id: recent.id } }); + assert.deepEqual([d.code, d.blobs], [200, 1]); + assert.deepEqual(blob.deleted, [BLOB + '?old', BLOB + '?recent']); + const direct = await expire(db.sql, blob, Date.UTC(2027, 0, 1)); + assert.equal(direct.rows, 120); +}); + +test('done carries the machine the secret proves; a wrong secret is refused', async () => { + const { call, db } = rig(); + const s1 = newSecret(); + await call('POST', { fn: 'secret' }, { headers: TOKEN, body: { name: 'PC1', secret_hash: secretHash(s1) } }); + const it = db.seed({ from_machine: 'Mac', to_machine: 'PC1', kind: 'run', title: 't', body: 'b' }); + assert.equal((await call('POST', { fn: 'done' }, { headers: { ...KEY, 'x-machine-secret': newSecret() }, body: { id: it.id, exit_code: 0 } })).code, 403); + assert.equal((await call('POST', { fn: 'done' }, { headers: { ...KEY, 'x-machine-secret': s1 }, body: { id: it.id, exit_code: 0 } })).code, 200); + assert.deepEqual([it.done, it.flags.exit_code, it.flags.done_by], [true, 0, 'PC1']); +}); diff --git a/tools/build-job.mjs b/tools/build-job.mjs index 176ff048..7be6bb8c 100755 --- a/tools/build-job.mjs +++ b/tools/build-job.mjs @@ -16,7 +16,7 @@ // (packaging/windows/push-inputs.sh and make-payload.sh read them there) // igneum-app.exe -> app/igneum-app/target/x86_64-pc-windows-gnu/release/ (make-payload.sh's IGNEUM_APP_EXE default) // igneumd, igneum-miner, igneum-app (Linux) -> infra/cross/out/ with version.txt (where infra/cross/build-linux.sh leaves them) -// Reads ~/.config/igneum/env (DATABASE_URL, the intake, as tools/jobs.mjs), relay-token, log-intake-key, dl-token. +// Reads ~/.config/igneum/env (DATABASE_URL, the intake, as tools/jobs.mjs), relay-token (or relay-key), dl-token. // Needs zstd and python3 on the PATH. No dependencies. import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, chmodSync, statSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; @@ -100,13 +100,14 @@ async function watch(id, quiet = false) { // ---- the relay (file download by item id, feed search by title) ------------------------------------------------------ function relayApi() { - const token = cfg('relay-token'); const key = cfg('log-intake-key'); - if (!token && !key) { console.error('no ~/.config/igneum/relay-token and no log-intake-key'); process.exit(1); } - const api = `${RELAY_BASE}/r/${token || '-'}/api/`; - const headers = key ? { 'x-igneum-key': key } : {}; + // the token or the relay's own key, as headers (X24); the intake key reads nothing on the relay any more (X23) + const token = cfg('relay-token'); const key = cfg('relay-key'); + if (!token && !key) { console.error('no ~/.config/igneum/relay-token and no relay-key'); process.exit(1); } + const api = `${RELAY_BASE}/api/relay?fn=`; + const headers = token ? { 'x-relay-token': token } : { 'x-igneum-key': key }; return { - async get(fn, q) { const r = await fetch(api + fn + (q ? '?' + new URLSearchParams(q) : ''), { headers }); const j = await r.json(); if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`); return j; }, - async download(id, to) { const r = await fetch(`${api}file?id=${id}`, { headers, redirect: 'follow' }); if (!r.ok) throw new Error(`download of relay item ${id}: http ${r.status}`); writeFileSync(to, Buffer.from(await r.arrayBuffer())); return statSync(to).size; }, + async get(fn, q) { const r = await fetch(api + fn + (q ? '&' + new URLSearchParams(q) : ''), { headers }); const j = await r.json(); if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`); return j; }, + async download(id, to) { const r = await fetch(`${api}file&id=${id}`, { headers, redirect: 'follow' }); if (!r.ok) throw new Error(`download of relay item ${id}: http ${r.status}`); writeFileSync(to, Buffer.from(await r.arrayBuffer())); return statSync(to).size; }, }; } const sha256 = p => createHash('sha256').update(readFileSync(p)).digest('hex'); diff --git a/tools/ci/commit-tz-check.sh b/tools/ci/commit-tz-check.sh new file mode 100755 index 00000000..55c21009 --- /dev/null +++ b/tools/ci/commit-tz-check.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Every commit path the tooling owns runs git with TZ=UTC, so no commit carries the local offset (review round 4, +# ledger G14; docs/plans/history-rewrite.md step 7). The class check (standing rule, 5 October 2026): any script under +# tools/, packaging/, infra/ or .github/ that invokes `git commit` (shell) or `['commit'` (node) must set TZ=UTC in +# the same file, or this fails. It also prints how many commits on the current branch still carry a non-UTC offset +# (the history rewrite is the owner's date; the count is information, not a failure). +# +# bash tools/ci/commit-tz-check.sh [--self-test] +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +check_tree() { # -> 0 when clean; prints offenders + local root="$1" bad=0 f + while IFS= read -r f; do + if grep -Eq "git commit|git\\b.*\\['commit'|\\['commit',|\"commit\"," "$f" 2>/dev/null; then + if ! grep -Eq "TZ=UTC|TZ: 'UTC'|TZ: \"UTC\"" "$f"; then echo "commit without TZ=UTC: ${f#$root/}"; bad=1; fi + fi + done < <(find "$root/tools" "$root/packaging" "$root/infra" "$root/.github" -type f \( -name '*.sh' -o -name '*.mjs' -o -name '*.js' -o -name '*.yml' -o -name '*.yaml' -o -name '*.py' \) 2>/dev/null | grep -v '/node_modules/' | grep -v '/fixtures/') + return $bad +} +if [ "${1:-}" = "--self-test" ]; then + T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT + mkdir -p "$T/tools" "$T/packaging" "$T/infra" "$T/.github" + printf '#!/bin/sh\nexport TZ=UTC\ngit commit -m x\n' > "$T/tools/good.sh" + printf "const git = a => run('git', a, { env: { TZ: 'UTC' } }); git(['commit', '-m', 'x']);\n" > "$T/tools/good.mjs" + check_tree "$T" >/dev/null || { echo "self-test: the clean tree failed"; exit 1; } + printf '#!/bin/sh\ngit commit -m x\n' > "$T/packaging/bad.sh" + if check_tree "$T" >/dev/null; then echo "self-test: the bad tree passed"; exit 1; fi + echo "commit-tz-check self-test: fires on the bad case, passes the good one" + exit 0 +fi +if check_tree "$ROOT"; then echo "commit-tz-check: every tooling commit path sets TZ=UTC"; else exit 1; fi +if git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + n="$(git -C "$ROOT" log --format='%ad %cd' --date=raw 2>/dev/null | grep -cvE '^\S+ \+0000 \S+ \+0000$' || true)" + echo "commits on this branch with a non-UTC offset (author or committer): $n (0 after the history rewrite, docs/plans/history-rewrite.md)" +fi diff --git a/tools/ci/curl-header-check.sh b/tools/ci/curl-header-check.sh new file mode 100755 index 00000000..f08e9d87 --- /dev/null +++ b/tools/ci/curl-header-check.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# No secret header on a curl command line (review round 4, ledger X29 and X24): a key passed as `-H "x-igneum-key: ..."` +# is readable by every local user in the process list for the length of the upload. Scripts pass secret headers through +# a config file (`curl -K ` with `header = "..."` lines) or a header file (`-H @file`). The class check (standing +# rule, 5 October 2026): any .sh, .bat, .cmd or .ps1 line that invokes curl with x-igneum-key, x-relay-token or +# x-machine-secret as a -H argument fails this. +# +# bash tools/ci/curl-header-check.sh [--self-test] +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PAT='curl[^|]*-H[[:space:]]+"?[^"]*x-(igneum-key|relay-token|machine-secret):' +check_tree() { # -> 0 when clean; prints offenders + local root="$1" bad=0 + while IFS= read -r hit; do echo "secret header on a curl command line: ${hit#$root/}"; bad=1; done \ + < <(grep -rnE --include='*.sh' --include='*.bat' --include='*.cmd' --include='*.ps1' "$PAT" "$root" 2>/dev/null | grep -v '/node_modules/' | grep -v '/vendor/' | grep -v '/fixtures/' | grep -v 'tools/ci/curl-header-check.sh' | grep -vE '^[^:]+:[0-9]+:[[:space:]]*(printf|echo) ' || true) # fixture writers in the other checks + return $bad +} +if [ "${1:-}" = "--self-test" ]; then + T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT + mkdir -p "$T/a" + printf 'curl -sS -K "$cfg" -X POST "$url" -H "Content-Type: application/json" --data-binary @body\n' > "$T/a/good.sh" + check_tree "$T" >/dev/null || { echo "self-test: the clean tree failed"; exit 1; } + printf 'curl -sS -X POST "$url" -H "x-igneum-key: $KEY" --data-binary @body\n' > "$T/a/bad.sh" + if check_tree "$T" >/dev/null; then echo "self-test: the bad tree passed"; exit 1; fi + rm "$T/a/bad.sh" + printf 'curl.exe -sS -X POST "%%URL%%" -H "x-igneum-key: %%KEY%%" --data-binary "@%%OUT%%"\n' > "$T/a/bad.bat" + if check_tree "$T" >/dev/null; then echo "self-test: the bad .bat passed"; exit 1; fi + echo "curl-header-check self-test: fires on the bad cases, passes the good one" + exit 0 +fi +if check_tree "$ROOT"; then echo "curl-header-check: no secret header on any curl command line"; else exit 1; fi diff --git a/tools/console.mjs b/tools/console.mjs index c5ef9099..55968a61 100644 --- a/tools/console.mjs +++ b/tools/console.mjs @@ -11,7 +11,7 @@ // node tools/console.mjs sync-hetzner push the newest infra/cloud-devnet/results/ summary // node tools/console.mjs sync all three syncs // node tools/console.mjs url the console URL -// Reads ~/.config/igneum/relay-token (the URL secret), relay-key (x-igneum-key), relay-url (optional), +// Reads ~/.config/igneum/relay-token (sent as the x-relay-token header, never in a URL: X24), relay-url (optional), // dl-token and dlsite-dir (sync-dl). Zero dependencies. import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; @@ -21,11 +21,12 @@ import { fileURLToPath } from 'node:url'; process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; }); const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const cfg = n => { try { return readFileSync(join(homedir(), '.config', 'igneum', n), 'utf8').trim(); } catch { return ''; } }; -const TOKEN = cfg('relay-token'); const KEY = cfg('relay-key'); // the relay's own key since 4 Oct 2026 (round 4, X23) +const TOKEN = cfg('relay-token'); const BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(/\/$/, ''); if (!TOKEN) { console.error('no ~/.config/igneum/relay-token'); process.exit(1); } -const API = `${BASE}/r/${TOKEN}/c/`; -const WEB = `${BASE}/r/${TOKEN}/`; +const API = `${BASE}/api/console?fn=`; // the function itself; the token travels in the header +const WEB = `${BASE}/r/${TOKEN}/`; // the phone's page: the one place the token stays in the path +const HEADERS = { 'x-relay-token': TOKEN }; const argv = process.argv.slice(2); const flags = {}; const pos = []; @@ -37,8 +38,8 @@ for (let i = 0; i < argv.length; i++) { const cmd = pos[0] || 'log'; async function api(fn, { q, body } = {}) { - const r = await fetch(API + fn + (q ? '?' + new URLSearchParams(q) : ''), body === undefined ? { headers: { 'x-igneum-key': KEY } } - : { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-igneum-key': KEY }, body: JSON.stringify(body) }); + const r = await fetch(API + fn + (q ? '&' + new URLSearchParams(q) : ''), body === undefined ? { headers: HEADERS } + : { method: 'POST', headers: { 'Content-Type': 'application/json', ...HEADERS }, body: JSON.stringify(body) }); const j = await r.json().catch(() => ({ ok: false, error: `http ${r.status}` })); if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`); return j; diff --git a/tools/relay.mjs b/tools/relay.mjs index 2262b3ce..0b16412b 100644 --- a/tools/relay.mjs +++ b/tools/relay.mjs @@ -1,43 +1,52 @@ #!/usr/bin/env node // Mac side of the Igneum relay (relay/ in this repo, https://relay.igneum.network). // node tools/relay.mjs the feed, newest first (last 50) -// node tools/relay.mjs list [N] [--machine X] more of the feed +// node tools/relay.mjs list [N] [--machine X] more of the feed (100 a call at most) // node tools/relay.mjs read print an item; its file is downloaded to --out (default $TMPDIR/igneum-relay) // node tools/relay.mjs drop "" | post a note or a file from the Mac [--to PC1] [--title "..."] [--body "..." with a file] // node tools/relay.mjs task "title" [file] [--body "..."] a task for a person or a Claude session on that PC -// node tools/relay.mjs run "title" [--elevated] [--reboot-continue] a script the igneum-agent runs +// node tools/relay.mjs run "title" [--elevated] [--reboot-continue] [--reboot] +// a script the igneum-agent runs: signed with ~/.config/igneum/relay-run-key +// (Ed25519, checked by the relay) and tagged with the machine's secret +// (~/.config/igneum/relay-machines/, checked by the agent); X23 +// node tools/relay.mjs keygen make the run key pair once; prints the public key for RELAY_RUN_PUB +// node tools/relay.mjs secret make that machine's secret, bind it on the relay, then make-clients.sh --machine // node tools/relay.mjs watch [--since ] poll every 10 s and print new items (results included) -// node tools/relay.mjs inbox [--ack] what that machine has not read yet +// node tools/relay.mjs inbox [--ack] what that machine has not read yet (--ack marks it read, a POST) // node tools/relay.mjs machines | role | name // node tools/relay.mjs ack | done | rm | url -// Reads ~/.config/igneum/relay-token (the URL secret), relay-key (x-igneum-key), dl-token (for __DL_BASE__ in -// playbooks) and relay-url (optional, default https://relay.igneum.network). Zero dependencies. -import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs'; +// Reads ~/.config/igneum/relay-token (sent as the x-relay-token header, never in a URL: X24), relay-run-key, +// relay-machines/, dl-token (only to refuse a body that carries it: X26) and relay-url (optional, default +// https://relay.igneum.network). Zero dependencies. +import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, chmodSync } from 'node:fs'; import { homedir, tmpdir, hostname } from 'node:os'; -import { basename, join, resolve } from 'node:path'; +import { basename, join, resolve, dirname } from 'node:path'; +import { keygen as edKeygen, signRun, runCanon, machineTag, newNonce, newSecret, secretHash } from '../relay/lib/guard.mjs'; process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; }); -const cfg = n => { try { return readFileSync(join(homedir(), '.config', 'igneum', n), 'utf8').trim(); } catch { return ''; } }; -const TOKEN = cfg('relay-token'); const KEY = cfg('relay-key'); const DL = cfg('dl-token'); // relay-key is the relay's own key since 4 Oct 2026 (round 4, X23); the intake key no longer opens the relay +const CFG = join(homedir(), '.config', 'igneum'); +const cfg = n => { try { return readFileSync(join(CFG, n), 'utf8').trim(); } catch { return ''; } }; +const TOKEN = cfg('relay-token'); const DL = cfg('dl-token'); const BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(/\/$/, ''); if (!TOKEN) { console.error('no ~/.config/igneum/relay-token'); process.exit(1); } -const API = `${BASE}/r/${TOKEN}/api/`; -const WEB = `${BASE}/r/${TOKEN}`; +const API = `${BASE}/api/relay?fn=`; // the function itself; the token travels in the header +const WEB = `${BASE}/r/${TOKEN}`; // the phone's page: the one place the token stays in the path const SHOWN = `${BASE}/r/`; // printed in place of WEB everywhere but `url` (round 4, X24: the token in every terminal) +const HEADERS = { 'x-relay-token': TOKEN }; const argv = process.argv.slice(2); const flags = {}; const pos = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--') && !['elevated', 'reboot-continue', 'ack', 'all'].includes(k)) { flags[k] = next; i++; } else flags[k] = true; } + if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--') && !['elevated', 'reboot-continue', 'reboot', 'ack', 'all', 'rotate'].includes(k)) { flags[k] = next; i++; } else flags[k] = true; } else pos.push(a); } const cmd = pos[0] && !/^\d+$/.test(pos[0]) ? pos[0] : (pos[0] ? 'read' : 'list'); if (cmd === 'read' && /^\d+$/.test(pos[0])) pos.unshift('read'); async function api(fn, { q, body } = {}) { - const r = await fetch(API + fn + (q ? '?' + new URLSearchParams(q) : ''), body === undefined ? { headers: { 'x-igneum-key': KEY } } - : { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-igneum-key': KEY }, body: JSON.stringify(body) }); + const r = await fetch(API + fn + (q ? '&' + new URLSearchParams(q) : ''), body === undefined ? { headers: HEADERS } + : { method: 'POST', headers: { 'Content-Type': 'application/json', ...HEADERS }, body: JSON.stringify(body) }); const j = await r.json().catch(() => ({ ok: false, error: `http ${r.status}` })); if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`); return j; @@ -68,17 +77,34 @@ function printItem(it) { } const outDir = () => { const d = resolve(flags.out || process.env.RELAY_DOWNLOAD_DIR || join(tmpdir(), 'igneum-relay')); mkdirSync(d, { recursive: true }); return d; }; async function download(it) { - const r = await fetch(`${API}file?id=${it.id}`, { headers: { 'x-igneum-key': KEY }, redirect: 'follow' }); + const r = await fetch(`${API}file&id=${it.id}`, { headers: HEADERS, redirect: 'follow' }); if (!r.ok) throw new Error(`download http ${r.status}`); const buf = Buffer.from(await r.arrayBuffer()); const p = join(outDir(), `${it.id}-${it.file_name || 'file'}`); writeFileSync(p, buf); return p; } +// X26: the body is posted as written. The downloads base reaches the script as $env:RELAY_DL_BASE (RELAY_DL_BASE in +// bash), a value the agent holds; a body that still says __DL_BASE__ or carries the dl token is refused here. function playbook(path) { - let s = readFileSync(path, 'utf8'); - s = s.replace(/__DL_BASE__/g, DL ? `https://dl.igneum.network/dl/${DL}` : 'https://dl.igneum.network/dl/MISSING-DL-TOKEN'); + const s = readFileSync(path, 'utf8'); + if (/__DL_BASE__/.test(s)) throw new Error(`${path} uses __DL_BASE__; write $env:RELAY_DL_BASE (PowerShell) or $RELAY_DL_BASE (bash) instead, the agent fills it in`); + if (DL && s.includes(DL)) throw new Error(`${path} carries the dl token; it must never be in a task body`); return s; } +const RUN_KEY = join(CFG, 'relay-run-key'); +const machineSecretFile = m => join(CFG, 'relay-machines', m); +// a run task: nonce, the machine's HMAC tag, the Ed25519 signature (relay/lib/guard.mjs, the same code the relay runs) +function signRunTask(o) { + const seed = cfg('relay-run-key'); + if (!/^[0-9a-f]{64}$/.test(seed)) throw new Error(`no run key at ${RUN_KEY}: node tools/relay.mjs keygen (then set RELAY_RUN_PUB on the relay project)`); + let secret = ''; + try { secret = readFileSync(machineSecretFile(o.to), 'utf8').trim(); } catch {} + if (!/^[0-9a-f]{64}$/.test(secret)) throw new Error(`no machine secret for ${o.to}: node tools/relay.mjs secret ${o.to} first, then relay/clients/make-clients.sh --machine ${o.to}`); + o.flags.nonce = newNonce(); + o.flags.mac = machineTag(secret, runCanon({ to: o.to, nonce: o.flags.nonce, body: o.body, flags: o.flags })); + o.flags.sig = signRun(runCanon({ to: o.to, nonce: o.flags.nonce, body: o.body, flags: o.flags }), seed); + return o; +} try { if (cmd === 'url') { console.log(WEB); } @@ -106,13 +132,32 @@ try { const o = { from: flags.from || 'Mac', to, title, kind: cmd, body: flags.body || '', flags: {} }; if (cmd === 'run') { if (!file || !existsSync(file)) throw new Error('run needs a script file (.ps1 for Windows, .sh for the Mac)'); - o.body = playbook(file); o.flags = { elevated: !!flags.elevated, reboot_continue: !!flags['reboot-continue'], shell: file.endsWith('.sh') ? 'bash' : 'powershell', script: basename(file) }; + o.body = playbook(file); o.flags = { elevated: !!flags.elevated, reboot_continue: !!flags['reboot-continue'], reboot: !!flags.reboot || !!flags['reboot-continue'], shell: file.endsWith('.sh') ? 'bash' : 'powershell', script: basename(file) }; + signRunTask(o); } else if (file) { if (!existsSync(file)) throw new Error(`no such file ${file}`); Object.assign(o, await uploadFile(file)); } const r = await api('task', { body: o }); console.log(`queued #${r.id} ${cmd} for ${to}: ${title}`); } + else if (cmd === 'keygen') { + if (existsSync(RUN_KEY)) throw new Error(`${RUN_KEY} exists; not overwriting a signing key`); + const { seed, pub } = edKeygen(); + mkdirSync(CFG, { recursive: true }); + writeFileSync(RUN_KEY, seed + '\n', { mode: 0o600 }); chmodSync(RUN_KEY, 0o600); + writeFileSync(RUN_KEY + '.pub', pub + '\n', { mode: 0o644 }); + console.log(`run key written: ${RUN_KEY} (0600) and ${RUN_KEY}.pub\npublic key ${pub}\nnext: set RELAY_RUN_PUB to that value on the Vercel project igneum-relay (relay/README.md, "Rotation"); until then every run task is refused`); + } + else if (cmd === 'secret') { + const m = pos[1]; if (!m || !/^[\w.-]{1,80}$/.test(m)) throw new Error('secret '); + const f = machineSecretFile(m); + if (existsSync(f) && !flags.rotate) throw new Error(`${f} exists; pass --rotate to replace it (the old zip on that PC stops being accepted)`); + const secret = newSecret(); + mkdirSync(dirname(f), { recursive: true, mode: 0o700 }); + writeFileSync(f, secret + '\n', { mode: 0o600 }); chmodSync(f, 0o600); + await api('secret', { body: { name: m, secret_hash: secretHash(secret) } }); + console.log(`${m}: secret written to ${f} (0600) and its sha256 bound on the relay\nnext: relay/clients/make-clients.sh --machine ${m}, carry the zip to ${m} by hand (never through the downloads host), start igneum-agent.bat there`); + } else if (cmd === 'inbox') { const machine = pos[1]; if (!machine) throw new Error('inbox '); - const j = await api('inbox', { q: { machine, ...(flags.ack ? { ack: 1 } : {}) } }); + const j = flags.ack ? await api('inbox', { body: { machine, kind: 'all', ack: true } }) : await api('inbox', { q: { machine } }); if (!j.items.length) console.log(`nothing waiting for ${machine}`); for (const it of j.items) printItem(it); } else if (cmd === 'machines') { for (const m of (await api('machines')).machines) console.log(`${m.name.padEnd(10)} ${(m.role || '-').padEnd(8)} ${(m.hostname || '-').padEnd(18)} ${m.named === false ? 'UNNAMED ' : ''}${m.last_seen ? 'seen ' + when(m.last_seen) : 'never seen'}${m.info && m.info.gpus ? ' gpu ' + [].concat(m.info.gpus).join(', ') : ''}${m.info && m.info.wsl ? ' wsl ' + m.info.wsl : ''}`); } diff --git a/tools/ship-app.mjs b/tools/ship-app.mjs index 3a061d8d..56e81e41 100644 --- a/tools/ship-app.mjs +++ b/tools/ship-app.mjs @@ -145,7 +145,8 @@ function runSync(cmd, args, { cwd = ROOT, env = {} } = {}) { const r = spawnSync(cmd, args, { cwd, env: { ...process.env, PATH: `${homedir()}/.cargo/bin:/opt/homebrew/bin:${process.env.PATH}`, ...env }, encoding: 'utf8' }); return { code: r.status ?? 1, out: ((r.stdout || '') + (r.stderr || '')).trim() }; } -const git = (args, cwd = ROOT) => runSync('git', args, { cwd }); +// TZ=UTC: every commit this tool makes carries +0000, never the local offset (review round 4, ledger G14) +const git = (args, cwd = ROOT) => runSync('git', args, { cwd, env: { TZ: 'UTC' } }); // git status --porcelain: the paths only ("XY path"; the first line's leading space does not survive a trim) const gitStatusPaths = (args, cwd = ROOT) => git(['status', '--porcelain', ...args], cwd).out.split('\n').filter(Boolean).map(l => l.replace(/^\s*\S+\s+/, '')); const has = cmd => runSync('sh', ['-c', `command -v ${cmd}`]).code === 0;