Merge branch 'ledger-relay' into fud-close
This commit is contained in:
commit
325c0403f5
36 changed files with 1465 additions and 399 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -75,7 +75,11 @@ jobs:
|
|||
run: node --test site/api/faucet.test.mjs
|
||||
- name: ship tool self-test (version bump, the dl-both and public manifest helpers)
|
||||
run: node tools/ship-app.mjs --self-test
|
||||
- name: relay unit tests (parsers, secret compare, the wake endpoint)
|
||||
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs
|
||||
- name: relay unit tests (parsers, secret compare, the wake endpoint, the guards, the handler, the clients' shape)
|
||||
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/guard.test.mjs relay/test/handler.test.mjs relay/test/clients.test.mjs
|
||||
- name: every tooling commit path sets TZ=UTC (self-test first, then the tree; G14)
|
||||
run: bash tools/ci/commit-tz-check.sh --self-test && bash tools/ci/commit-tz-check.sh
|
||||
- name: no secret header on any curl command line (self-test first, then the tree; X29)
|
||||
run: bash tools/ci/curl-header-check.sh --self-test && bash tools/ci/curl-header-check.sh
|
||||
- name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows)
|
||||
run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs
|
||||
|
|
|
|||
|
|
@ -1524,3 +1524,19 @@ What is measured: one BLS12-381 aggregate signature over 16 summed G1 keys plus
|
|||
| on, split 90 s | v3 | 0 / 2 | none / 3 | 278 / 265 | apart | none | 3 on n0 | 2 (n0 reconnected 6 s after the heal, A's chain at about 58 DAA, inside the table) |
|
||||
|
||||
Reading (the NEW finding, ledger C4). With the module off GHOSTDAG alone converges on the heavier chain and the losing side's records re-determine (F24 works when the chain moves). With the module on the overlay holds during the split (A, with 30% of the frozen table, locks nothing; B locks 7 and 8) and then fails at the heal in the shipped node: B's certificates for blocks off n0's chain are "kept pending until the chain decides (no lock at this index)", n0's chain never decides because GHOSTDAG keeps its heavier tip and nothing turns the certificate into a fork-choice constraint, and once n0's last lock (index 7, DAA 209) is one window old (DAA 329) the frozen table stops applying on A's chain ("no frozen table (no lock on this chain inside the window)"), A's two keys are 100% of A's own window (B's post-cut blocks are red there) and n0 locks 10, 11, 12 alone; B's certificates for 10 and 11 then log CONFLICTING on n0 (n0 log, 17:27:04 to 17:29:54 BST). A finality fork from a 96-s honest partition, no attacker, table intact at the heal; the 150-s run and the v2 control end the same way. The spec's fork choice ("GHOSTDAG among tips through all certified checkpoints", 3.5) is therefore implemented only for certificates over blocks already on the node's chain. Fix named in the ledger entry: verify an off-chain certificate against the table at its own block and let it constrain fork choice (a certificate-driven reorg), then re-determine. Raw: `scratchpad fud-a/c4-results-*.md`, node logs `c4-on90-tmp/`, `c4-v2-control-tmp/`.
|
||||
|
||||
## 5 October 2026 (night), ledger close round 1: relay and CI fixes X23 to X28, G13, G14
|
||||
|
||||
Branch `ledger-relay`, worktree `igneum-wt-ledger-relay`, commit `28c028b` (the ledger and this entry follow in the next commit). Mac only, node 22.23.2, no network, no database, nothing deployed, nothing pushed, no PC touched.
|
||||
|
||||
| What ran | Command | Result |
|
||||
|---|---|---|
|
||||
| the relay suites (parsers, secret compare, wake, the new guards, the handler against a fake Neon and fake blobs, the clients' shape) | `node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/guard.test.mjs relay/test/handler.test.mjs relay/test/clients.test.mjs` | 47 tests, 47 pass, 0 fail (15 + 10 + 12 + 10) |
|
||||
| the signed-inputs chain (G13), with the signer from the main checkout | `IGNEUM_OTA_SIGN=/Users/joshm/Projects/igneum/app/igneum-app/target/release/igneum-ota-sign bash packaging/windows/test-inputs-signing.sh` | 16 passed, 0 failed; the one-byte-appended zip is refused on its sha256 |
|
||||
| TZ=UTC in every tooling commit path (G14), self-test then the tree | `bash tools/ci/commit-tz-check.sh --self-test && bash tools/ci/commit-tz-check.sh` | fires on the bad case; the tree is clean after `packaging/ota/publish-jobs.sh` gained `export TZ=UTC`; 417 commits on the branch still carry a non-UTC offset (the rewrite, the project lead's date) |
|
||||
| no secret header on a curl command line (X29), self-test then the tree | `bash tools/ci/curl-header-check.sh --self-test && bash tools/ci/curl-header-check.sh` | fires on the .sh and .bat bad cases; 0 hits in the tree |
|
||||
| the existing tree checks | `tools/ci/no-secrets-check.sh`, `tools/ci/copied-sources-check.sh`, `bash -n` on every touched shell script, `node --check` on every touched .mjs | 0 hits, clean, all parse |
|
||||
|
||||
What the handler tests prove at the API (relay/test/handler.test.mjs): a token-only `POST task` with `kind: run` is 401 and a signed, tagged, fresh one is stored (X23); the `x-relay-token` header with no token in the path is the token tier (X24); a `result` whose `from` does not match the caller's machine secret is 403 (X27); the intake key may only upload and drop files, and reads nothing (X23); `GET inbox` never acks (X28); 120 rows and `limit=500` return 100 (X26); a row older than 30 days goes with its blob on the next sweep (X26); 429 after the per-IP limit (X28).
|
||||
|
||||
Not measured tonight, by design: anything on PC 1 or PC 2 (the `schtasks /Query` check for X25, the 401 against the deployed relay for X23, the Vercel log view for X24, the GitHub workflow run for G13). The PowerShell 5.1 parse of the rewritten clients runs in `windows.yml` on the next push; on the Mac the clients are checked in structural terms only (`clients.test.mjs`).
|
||||
|
|
|
|||
|
|
@ -1719,57 +1719,69 @@ Review: `docs/review/round-4-2026-10-04.md`. Scope: devnet v4 through `a21ff239`
|
|||
### X23. One shipped key is an administrator channel to the founder's PCs
|
||||
"Your relay accepts either the URL token or the `x-igneum-key` header for everything, including queuing PowerShell that the PC agent runs as administrator. The key is the log intake key, a literal in six tracked files and inside every Windows and prover package you have handed out. And the zip with both relay secrets sits on the downloads host behind the dl token, which is in your git history and in every installed app. One token, four hops, no privilege boundary."
|
||||
|
||||
Status: Open, fatal as an operational fact (4 October 2026). The agent was live and elevated on PC 1 at 13:41 UTC (`GET machines`, read-only). Sweep (5 October 2026): the relay token was rotated on 4 October (`~/.config/igneum/relay-token.old-2026-10-04` sits beside the new one); `POST task` with `kind: run` still needs only the token (`relay/api/relay.mjs:114-119`, no per-machine secret or signature), so the operational half stands. The 401 test needs the deployed relay and PC 1 (relay owner; rotation is the project lead's).
|
||||
Status: Fixed on a branch, pending merge (5 October 2026, night): ledger-relay 28c028b, tests 47 of 47 (relay, 6 suites) + 16 of 16 (packaging/windows/test-inputs-signing.sh) + 2 CI class checks. Was: Open, fatal as an operational fact (4 October 2026). The agent was live and elevated on PC 1 at 13:41 UTC (`GET machines`, read-only). Sweep (5 October 2026): the relay token was rotated on 4 October (`~/.config/igneum/relay-token.old-2026-10-04` sits beside the new one); `POST task` with `kind: run` still needs only the token (`relay/api/relay.mjs:114-119`, no per-machine secret or signature), so the operational half stands. The 401 test needs the deployed relay and PC 1 (relay owner; rotation is the project lead's).
|
||||
|
||||
Answer: Correct. `relay/lib/relay.mjs:33-42` returns a truthy value for either secret and `relay/api/relay.mjs:111-124` accepts `kind: run` with `flags.elevated` from it; `relay/clients/igneum-agent.ps1:165-166` runs every item returned, as administrator, within 20 s. `README.md:7` and `make-clients.sh:8` make `RELAY_KEY` the intake key. The hosted `igneum-relay-clients.zip` carries the relay token and the key in four files; the dl token that guards it is 0644 on the Mac, in commit `c47ff03`, and in `igneum-app.json` of every install. Fix, in order: the zip off the host; rotate the relay token; a relay key of its own; a second per-machine secret or an Ed25519 signature over `{id, to, body}` for `run`; rotate the intake key and repackage. Review ids R4.4.1, R4.4.2, R4.5.1.
|
||||
|
||||
Evidence: the files above; `docs/review/round-4-2026-10-04.md` sections 4 and 5. Experiment: after the fix, `POST task` with the old key and with a key-only header must return 401, and `GET machines` must show the rotated agent on PC 1.
|
||||
|
||||
Fix (5 October 2026, night): three tiers in `relay/lib/guard.mjs` (`authVia`): the console token (header or the phone page's path), the relay's own key (`RELAY_KEY`: reads and reports, never `task`, `run`, `name`, `role`, `secret`, `delete`), and the intake key (`LOG_INTAKE_KEY`, `_NEXT`) as a tier of its own that may only `upload` and `drop` a file or a note, no reads; it exists because the 0.3.6+ apps upload build-job outputs with it (`app/igneum-app/src/jobrun.rs`, `relay_upload`), and `RELAY_INTAKE_COMPAT=0` on the project closes it the day the apps carry a relay key (that app change is owed, not in this branch). A `run` task now needs, on top of the token, `flags.sig`, an Ed25519 signature by the Mac's run key (`~/.config/igneum/relay-run-key`, `node tools/relay.mjs keygen`) over `runCanon` = {machine, nonce, body sha256, elevated, reboot_continue, reboot}, verified by the relay with `RELAY_RUN_PUB` (401 without it or with a wrong one; 409 on a reused nonce; every run refused while `RELAY_RUN_PUB` is unset), and `flags.mac`, an HMAC-SHA256 tag with the target PC's own secret that `igneum-agent.ps1` (`Check-Task`) and `agent.sh` (`check_task`) verify before anything runs (exit 77 and a result when it fails; Windows PowerShell 5.1 has no Ed25519, so the agent's check is the HMAC). The console and the wake POST refuse the intake tier (`authedNoIntake`). Tests: `relay/test/handler.test.mjs` (a token-only `POST task` kind `run` is 401; a signed one is stored; a changed body, flag or target, another key, or no `RELAY_RUN_PUB` is 401; the relay key gets 403 on `task`; the intake key gets 403 on everything but `upload` and a file drop), `relay/test/guard.test.mjs` (the signature, the tag, `checkRun`). Owed to the project lead: `keygen` and `RELAY_RUN_PUB` on the project, one `secret` per PC with the zip carried by hand, the hosted `igneum-relay-clients.zip` off the downloads host (its values are dead since the 4 and 5 October rotations, the file remains), and the deploy (`relay/README.md`, "Rotation"). The 401 against the deployed relay and `GET machines` on PC 1 run after that deploy.
|
||||
|
||||
### X24. The relay token rides in the URL on every request
|
||||
"Every poll of every agent and every page refresh puts the token in the path, so it is in Vercel's request logs, in browser history and in every terminal that ran `tools/relay.mjs`. You built an `x-relay-token` header and nobody uses it."
|
||||
|
||||
Status: Open (4 October 2026); the print lines fixed: `tools/relay.mjs` shows `/r/<token>` in `list` and `watch` (only `url` prints the real one). Sweep (5 October 2026): `x-relay-token` is accepted (`relay/lib/relay.mjs:38`) but no client sends it (no match in `relay/clients`, `tools/relay.mjs` or `app/igneum-app/src`), so every request still carries the token in the path. The Vercel log check needs the deployment (relay owner).
|
||||
Status: Fixed on a branch, pending merge (5 October 2026, night): ledger-relay 28c028b, tests 47 of 47 (relay, 6 suites) + 16 of 16 (packaging/windows/test-inputs-signing.sh) + 2 CI class checks. Was: Open (4 October 2026); the print lines fixed: `tools/relay.mjs` shows `/r/<token>` in `list` and `watch` (only `url` prints the real one). Sweep (5 October 2026): `x-relay-token` is accepted (`relay/lib/relay.mjs:38`) but no client sends it (no match in `relay/clients`, `tools/relay.mjs` or `app/igneum-app/src`), so every request still carries the token in the path. The Vercel log check needs the deployment (relay owner).
|
||||
|
||||
Answer: Correct. `relay/vercel.json:6` rewrites `/r/<token>/api/<fn>` to a query string; `igneum-agent.ps1:14`, `send.ps1:26`, `send.sh:11`, `agent.sh:10` and `tools/relay.mjs:24` all build the tokened URL, and `tools/relay.mjs:83,88,125` print it. `Referrer-Policy: no-referrer` and `X-Robots-Tag` are set (`vercel.json:12-13`); there is no HSTS. Fix: the header in every client, the URL token kept for the phone's page only, HSTS, and the print lines masked. Review id R4.4.3.
|
||||
|
||||
Evidence: the files above. Experiment: the Vercel log view for `igneum-relay` after the change shows no token in any path.
|
||||
|
||||
Fix (5 October 2026, night): every client and Mac tool calls `/api/relay?fn=<fn>` with `x-relay-token` (and `x-igneum-key`) as headers: `igneum-agent.ps1` and `send.ps1` (`Api-Url`), `agent.sh` and `send.sh` (through a 0600 curl config file, `-K`, so the headers are on no command line either), `tools/relay.mjs`, `tools/console.mjs` (`/api/console?fn=`), `tools/build-job.mjs` (the token, else the relay key; the intake key reads nothing now). `igneum-agent.bat` and `send.bat` build no URL. The path token stays for the phone's page only: `/r/<token>/` (`ui.html`) and the calls that page makes (`/r/<token>/api/<fn>`, `/r/<token>/c/<fn>`, `/r/<token>/wake`), documented in `relay/README.md`. Print lines: `tools/relay.mjs` shows `/r/<token>` everywhere but `url` (unchanged). Tests: `handler.test.mjs` (a request with the header and no token in the path is the token tier; a wrong header is 401), `clients.test.mjs` (every client and tool sends the header and none builds a tokened API path). Owed: the Vercel log view after the deploy (relay owner).
|
||||
|
||||
### X25. The PC agent installs itself at every logon, at highest privilege, on every start
|
||||
"Double-click once and the agent writes a scheduled task with `/RL HIGHEST` and a RunOnce key. Your README says it only re-arms for a reboot. Closing the window does nothing."
|
||||
|
||||
Status: Open (4 October 2026). Sweep (5 October 2026): unchanged (`relay/clients/igneum-agent.ps1:72`, `schtasks /SC ONLOGON /RL HIGHEST`). The `schtasks /Query` check needs PC 1.
|
||||
Status: Fixed on a branch, pending merge (5 October 2026, night): ledger-relay 28c028b, tests 47 of 47 (relay, 6 suites) + 16 of 16 (packaging/windows/test-inputs-signing.sh) + 2 CI class checks. Was: Open (4 October 2026). Sweep (5 October 2026): unchanged (`relay/clients/igneum-agent.ps1:72`, `schtasks /SC ONLOGON /RL HIGHEST`). The `schtasks /Query` check needs PC 1.
|
||||
|
||||
Answer: Correct. `Arm-Restart` (`igneum-agent.ps1:68-79`) runs at `:154` on every start; `README.md:42` describes it as the `reboot_continue` path. Fix: arm only when a task asks for a reboot, and remove the task and the key on a clean exit. Review id R4.4.4.
|
||||
|
||||
Evidence: `relay/clients/igneum-agent.ps1`. Experiment: `schtasks /Query /TN IgneumRelayAgent` on PC 1 before and after.
|
||||
|
||||
Fix (5 October 2026, night): `igneum-agent.ps1` calls `Arm-Restart` only on the two paths that end in `shutdown.exe /r` (a task that printed `RELAY-REBOOT` on its own line AND was queued with `--reboot` or `--reboot-continue`), sets `$script:KeepArmed` for that one exit, and `Disarm-Restart` (`schtasks /Delete /F /TN IgneumRelayAgent`, `Remove-ItemProperty` on the RunOnce key) runs on every start and in the main loop's `finally` (Ctrl+C included; a closed window skips `finally`, so the next start disarms again). The top-level `Arm-Restart` is gone. Tested on the Mac in parsing terms only (no `pwsh` here): `relay/test/clients.test.mjs` asserts `Arm-Restart` is called exactly twice, never at top level, each within 4 lines of the restart, and that `Disarm-Restart` runs at start and in `finally`; braces and here-strings balance; the 5.1 parse runs in `windows.yml` on the next push. Owed: `schtasks /Query /TN IgneumRelayAgent` on PC 1 before the new agent starts (expected: the task exists) and after (expected: nothing); PC 1 is not touched tonight.
|
||||
|
||||
### X26. The feed is a permanent transcript, and it holds the dl token by design
|
||||
"One secret pages the whole history: every task body and every result transcript, usernames, hostnames, the folder that holds the secrets. And `tools/relay.mjs` writes the tokened download URL into task bodies before posting, so a relay leak is a dl-token leak."
|
||||
|
||||
Status: Open (4 October 2026). Sweep (5 October 2026): unchanged in `relay/api/relay.mjs` (no retention or cap on `feed`). Relay owner.
|
||||
Status: Fixed on a branch, pending merge (5 October 2026, night): ledger-relay 28c028b, tests 47 of 47 (relay, 6 suites) + 16 of 16 (packaging/windows/test-inputs-signing.sh) + 2 CI class checks. Was: Open (4 October 2026). Sweep (5 October 2026): unchanged in `relay/api/relay.mjs` (no retention or cap on `feed`). Relay owner.
|
||||
|
||||
Answer: Correct. `ITEM_COLS` (`relay/lib/relay.mjs:92`) includes `body`; `feed` returns up to 500 per call with no retention and no cap; `delete` leaves blobs. `tools/relay.mjs:76-80` substitutes `__DL_BASE__` with the tokened base and `playbooks/miner-v4.ps1:12` and `prover-setup.ps1:12` print it into the transcript. Today's 12 items hold 0 copies (count-only). Fix: retention (30 days), a cap on `feed`, the dl base passed as an environment value the agent holds rather than text in the body, blob deletion with the row. Review id R4.4.5.
|
||||
|
||||
Evidence: the files above. Experiment: `feed?limit=500&before=<id>` after the change returns nothing older than the retention.
|
||||
|
||||
Fix (5 October 2026, night): retention 30 days (`relay/lib/handler.mjs` `expire`: `DELETE ... WHERE ts < now - 30 d RETURNING file_url`, blobs deleted with the rows through `@vercel/blob` `del`, run on a feed read at most every 10 minutes per instance); `feed` capped at 100 a call (50 by default; `FEED_LIMIT_MAX`); `delete` removes the blob with the row. The downloads base is no longer text in any body: `tools/relay.mjs run` posts the playbook as written and refuses a body that says `__DL_BASE__` or carries the dl token; `make-clients.sh` bakes the base into the agent, which hands it to every task as `RELAY_DL_BASE` (`$env:RELAY_DL_BASE` in the five playbooks); the two print lines name the zip, not the URL. Tests: `handler.test.mjs` (120 rows, a `limit=500` read returns 100 with `limit: 100` in the reply; a row dated August goes on the next sweep with its blob, a row dated 1 October stays; `delete` reports `blobs: 1`), `guard.test.mjs` (`feedLimit`, `retentionCutoff`), `clients.test.mjs` (no playbook carries `__DL_BASE__` or prints the URL; the agents export the base). Owed: `feed?limit=500&before=<id>` against the deployed relay after the first sweep.
|
||||
|
||||
### X27. The relay has no clean rotation and no sender binding
|
||||
"Rotate the token and the key path stays open; rotate the key and every shipped package stops uploading logs. Any holder posts a `result` from any machine name, or registers a machine, and the Mac's watch prints it as truth."
|
||||
|
||||
Status: Open (4 October 2026). Sweep (5 October 2026): unchanged (`from` is a free field in `relay/api/relay.mjs`; nothing binds a `result` to the caller's machine). Relay owner.
|
||||
Status: Fixed on a branch, pending merge (5 October 2026, night): ledger-relay 28c028b, tests 47 of 47 (relay, 6 suites) + 16 of 16 (packaging/windows/test-inputs-signing.sh) + 2 CI class checks. Was: Open (4 October 2026). Sweep (5 October 2026): unchanged (`from` is a free field in `relay/api/relay.mjs`; nothing binds a `result` to the caller's machine). Relay owner.
|
||||
|
||||
Answer: Correct. `authed()` has two independent secrets with equal power; `insertItem` takes `from` as free text (`relay/api/relay.mjs:30`); `register` creates rows for any hostname (`:148-162`). Fix: the relay's own key (X23), `from` bound to the registered machine for `result` and `register` by a per-machine secret, and a documented rotation (token, relay key, intake key) with what each breaks. Review ids R4.4.6, R4.4.7.
|
||||
|
||||
Evidence: the files above. Experiment: a `result` posted with a `from` that does not match the caller's machine secret is refused.
|
||||
|
||||
Fix (5 October 2026, night): a per-machine secret (64 hex, `node tools/relay.mjs secret PC1`: written to `~/.config/igneum/relay-machines/PC1` at 0600, its sha256 bound on the relay with `POST secret` (token only), carried to the PC as `machine-secret.txt` by `make-clients.sh --machine PC1`). `register` with `x-machine-secret` names the machine whatever the hostname says (both PCs report DESKTOP-KMCV30N), drops `info.user` and `info.dir`, and a bound hostname without the secret is 403; a `result` with the secret is stored under the machine it proves and a `from` that does not match is 403; a result without the secret from a bound machine is 403; from a machine with no secret yet it is accepted with `flags.unbound` (the compatibility window, visible in the feed). `done` records `done_by`. The rotation of every secret (token, relay key, intake key, run key, machine secret: how, what stops, in what order) is the table "Rotation" in `relay/README.md`. Tests: `handler.test.mjs` (the forged `from` is refused; the matching one stored; unknown secret 403; bound hostname 403; the compatibility case marked unbound; `done` with a wrong secret 403), `guard.test.mjs` (`machineForSecret`). Owed: one `secret` per PC, the zips by hand, and `ADD COLUMN IF NOT EXISTS secret_hash` runs on the first `secret` or `feed` call after the deploy.
|
||||
|
||||
### X28. Relay hygiene, minor
|
||||
"`===` on secrets, no HSTS, a GET that acks, a reboot on any output containing `RELAY-REBOOT`, orphaned blobs, no rate limit anywhere, a WSL user `igneum`/`igneum` with NOPASSWD sudo, the username and secret folder posted on register, and a file in `~/.config/igneum` whose name is a token."
|
||||
|
||||
Status: Open, minor (4 October 2026); two of the points fixed: secrets compared in constant time (`relay/lib/auth.mjs`, `sameSecret`, unit test in CI) and HSTS on the relay (`relay/vercel.json`). Sweep (5 October 2026): the remaining points unchanged; relay owner.
|
||||
Status: Fixed on a branch, pending merge (5 October 2026, night): ledger-relay 28c028b, tests 47 of 47 (relay, 6 suites) + 16 of 16 (packaging/windows/test-inputs-signing.sh) + 2 CI class checks. The stray file is gone. Was: Open, minor (4 October 2026); two of the points fixed: secrets compared in constant time (`relay/lib/auth.mjs`, `sameSecret`, unit test in CI) and HSTS on the relay (`relay/vercel.json`). Sweep (5 October 2026): the remaining points unchanged; relay owner.
|
||||
|
||||
Answer: Correct on each point: `relay/lib/relay.mjs:38,40`; `relay/vercel.json:8-16`; `relay/api/relay.mjs:85`; `igneum-agent.ps1:133`; `:145`; no limiter in either function; `relay/playbooks/wsl-setup.ps1:39-40` and `prover-setup.ps1:21`; `igneum-agent.ps1:41, :113`; the stray file next to `desec-token` (3 Oct 19:33). Fix when convenient; the stray file today. Review ids R4.4.9 to R4.4.13.
|
||||
|
||||
Evidence: the files above.
|
||||
|
||||
Fix (5 October 2026, night): the remaining points. The GET that acks: `GET inbox` marks nothing (`ack=1` is ignored); `POST inbox {machine, kind, ack:true}` returns the list and marks it, and every client uses the POST. The reboot trigger: the agent restarts only when `RELAY-REBOOT` stands on a line of its own (`(?m)^RELAY-REBOOT\r?$`; `wantsReboot` in `guard.mjs`) AND the task was queued with `--reboot` or `--reboot-continue` (`flags.reboot`, part of the signed text); a marker inside other output, or in a task without the flag, is logged and refused. The rate limit: 120 calls a minute per IP and 10 failed authentications a minute per IP, 429 with `Retry-After` (`RateLimit` from `lib/wake.mjs`). The register payload: no username and no folder from either agent, and the relay strips `info.user` and `info.dir` whatever a client sends. The NOPASSWD line: `wsl-setup.ps1` writes `igneum ALL=(root) NOPASSWD:SETENV: /usr/bin/apt-get, /usr/bin/dpkg` (what `setup-wsl.sh` runs under sudo: lines 20, 21, 27, 28, 31) and checks it with `visudo -cf`; `prover-setup.ps1` no longer echoes the password into `sudo -S` and tests `sudo -n apt-get --version` instead. The `igneum`/`igneum` password itself stays (the user exists to be unattended). The stray file: `ls -la ~/.config/igneum` tonight (read-only) lists no file whose name is a token; the 28-character file beside `desec-token` is gone, so nothing is left for the project lead to delete there. Tests: `handler.test.mjs` (GET inbox with `ack=1` leaves `read` false, POST acks; 429 after the limit, a second IP unaffected, failed auths on their own counter; registration stripped), `guard.test.mjs` (`wantsReboot`), `clients.test.mjs` (the agents' marker regex and flag gate, no GET ack in any client, the sudoers line, no `sudo -S`). Review ids R4.4.9 to R4.4.13 closed; the WSL user's password is the one point kept by design.
|
||||
|
||||
### G12. The PoW schedule comes from the environment on every network, including mainnet
|
||||
"Your mainnet gate refuses the override file. It does not refuse `IGNEUM_POW_EPOCH_BLOCKS`. A node without a file installs the schedule from the environment and `Params.pow_epoch_blocks` is never consulted."
|
||||
|
||||
|
|
@ -1786,21 +1798,25 @@ Evidence: the files above. Experiment: start a node with `IGNEUM_POW_EPOCH_BLOCK
|
|||
### G13. The update signature covers binaries that nobody signed
|
||||
"The runner fetches `payload-inputs.zip` and its sha256 from the same host, builds the installer, and the Mac signs the manifest over whatever the latest green run produced. A compromised dl project or runner ships as a genuine update."
|
||||
|
||||
Status: Open (4 October 2026).
|
||||
Status: Fixed on a branch and verified locally (5 October 2026, night): ledger-relay 28c028b, tests 47 of 47 (relay, 6 suites) + 16 of 16 (packaging/windows/test-inputs-signing.sh) + 2 CI class checks. The GitHub run is owed (no push tonight). Was: Open (4 October 2026).
|
||||
|
||||
Answer: Correct. `.github/workflows/windows.yml` (step "payload inputs") checks the zip against a sha256 served beside it; `packaging/windows/fetch-ci-artifacts.sh` takes the latest green run and calls `packaging/ota/publish-manifest.sh` by default; the node fork is not in the repository the runner builds, so spec 08 item 4 has nothing to reproduce from. Fix: a detached Ed25519 signature over `payload-inputs.zip` made on the Mac and verified in CI before the build; `OTA_SKIP=1` by default with the signing step naming the run id it signs. Review id R4.5.2.
|
||||
|
||||
Evidence: the files above. Experiment: alter one byte of a hosted `payload-inputs.zip` on a test folder and run the workflow; it must fail before the build.
|
||||
|
||||
Fix (5 October 2026, night): the chain already on master was read end to end and run, not asserted. `packaging/windows/push-inputs.sh` writes `payload-inputs.json` (the zip's sha256 and size, every file's, the node fork commit and branch, the repository commit) and signs it on the Mac with the OTA key (`igneum-ota-sign sign-inputs`); `.github/workflows/windows.yml` step "payload inputs" verifies the signature with the key compiled into the app, the zip's hash, every unpacked file and the pinned node commit (`packaging/windows/node-source.pin`) before anything is built from the inputs (the engine step runs first only because it builds the verifier, from git); `packaging/windows/fetch-ci-artifacts.sh` leaves the manifest alone by default (`OTA_SKIP=1` is the default; `--sign-manifest` needs an explicit run id, re-downloads that run's `igneum-windows-inputs` artifact, re-verifies the signature and the pin at the run's commit on the Mac, checks the runner's record names that run, that commit and that key, then signs). The local test the ledger asked for: `IGNEUM_OTA_SIGN=<main checkout>/app/igneum-app/target/release/igneum-ota-sign packaging/windows/test-inputs-signing.sh`, tonight 16 passed, 0 failed, including "one byte appended to the zip: refused (sha256 ...)", a changed manifest byte, a changed unpacked file, an unlisted file, a missing file, a wrong and a short pin, another key, the embedded key against a throwaway signature, and the real OTA key verifying against `embedded`. Owed: the workflow run itself on GitHub (nothing is pushed tonight); the experiment on a hosted test folder stays as written.
|
||||
|
||||
### G14. Secrets and identity in the history of a repository with a public date
|
||||
"The intake key is in six files across eight commits, the dl token in one, the review and ledger files are tracked, 51 tracked files carry the founder's first name, and every commit today is stamped with the local-time offset. The 3 October sweep said zero hits."
|
||||
|
||||
Status: Open (4 October 2026); extends `docs/fud-fixes.md` section 5.
|
||||
Status: Fixed on a branch, pending merge (5 October 2026, night), the tooling part: ledger-relay 28c028b, tests 47 of 47 (relay, 6 suites) + 16 of 16 (packaging/windows/test-inputs-signing.sh) + 2 CI class checks. Decision owner: the project lead for the rewrite date (`docs/plans/ledger-decisions.md`). Was: Open (4 October 2026); extends `docs/fud-fixes.md` section 5.
|
||||
|
||||
Answer: Correct, count-only. The key: `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`, commits `78df757` to `4c9810f`. The token: `docs/plans/morning-2026-10-04.md:49`, commit `c47ff03`. `git check-ignore` returns nothing for the ledger, fixes and review files. The CI identity grep covers the public export list, by design. Fix: both secrets join section 5 step 4's rewrite list (and are rotated regardless); `TZ=UTC` in the commit path now. Review ids R4.4.8, R4.5.3, R4.5.4.
|
||||
|
||||
Evidence: `git ls-files | xargs grep -lF <value>` counts, `git log -S`. Experiment: section 5 step 7 re-run after the rewrite returns nothing.
|
||||
|
||||
Fix (5 October 2026, night): `TZ=UTC` in every commit path the tooling owns: `tools/ship-app.mjs` (`git()` runs with `env: { TZ: 'UTC' }`), `packaging/ota/publish-jobs.sh` (`export TZ=UTC`, found by the class check), `tools/repo/fresh-repo.sh` (already). The class check `tools/ci/commit-tz-check.sh` (self-test first) fails CI on any script under `tools/`, `packaging/`, `infra/` or `.github/` that invokes `git commit` without `TZ=UTC`, and prints the count of commits on the branch with a non-UTC offset: 417 tonight, 0 after the rewrite. The two secrets on the rewrite list: `docs/plans/history-rewrite.md` section 2 already names the intake key and the dl token in `replace.txt`; added tonight: after the 5 October rotation the values IN THE HISTORY are the ones in `~/.config/igneum/log-intake-key.old-2026-10-05` and `dl-token.old-2026-10-05`, so `replace.txt` must be written from the `.old` files, not the live ones; the relay key, token, run key and machine secrets are in 0 files and 0 commits. The commit of this branch carries `+0000`. The rewrite itself (and its day) is the project lead's decision.
|
||||
|
||||
### X18. Two nodes with two override files connect, and only some mismatches fork
|
||||
"Your handshake compares the network name and nothing else. A PoW or difficulty mismatch forks and bans; a `finality` mismatch is a WARN; `rollout-v2.sh` throws the finality block away when it writes the file; the app rewrites the packaged file on every start."
|
||||
|
||||
|
|
@ -2004,12 +2020,14 @@ Evidence: `docs/design/miner-dev-fee.md`; the unit tests in `igneum/miner/src/ma
|
|||
### X29. Host and file hygiene, minor
|
||||
"The Mac's live node binds its gRPC to every interface. Four secrets or pointers in `~/.config/igneum` are world-readable, one token is a filename, and the intake key rides on `curl`'s command line. The manifest answers CORS `*` and the clock source is a cacheable page's Date header."
|
||||
|
||||
Status: Open, minor (4 October 2026). Sweep (5 October 2026): read-only checks on this Mac: every secret under `~/.config/igneum` is now mode 600 (only `ota-signing-key.pub` is world-readable, as it should be), so that half is fixed; the live node still listens on every interface (`lsof`: `igneumd` on `*:26610`). The `curl` command line and the clock source were not re-checked.
|
||||
Status: Fixed on a branch, pending merge (5 October 2026, night), the curl part: ledger-relay 28c028b, tests 47 of 47 (relay, 6 suites) + 16 of 16 (packaging/windows/test-inputs-signing.sh) + 2 CI class checks. Open: the live node's `--rpclisten` (decision, operator, group E) and the app's clock source (round 2). Was: Open, minor (4 October 2026). Sweep (5 October 2026): read-only checks on this Mac: every secret under `~/.config/igneum` is now mode 600 (only `ota-signing-key.pub` is world-readable, as it should be), so that half is fixed; the live node still listens on every interface (`lsof`: `igneumd` on `*:26610`). The `curl` command line and the clock source were not re-checked.
|
||||
|
||||
Answer: Correct. `--rpclisten=0.0.0.0:26610` on pid 33114 (no `--unsafe-rpc`, `--disable-upnp`); `ls -la ~/.config/igneum`; `app/igneum-app/src/update.rs` (`https_time`, `upload_log`); the dl host's headers. Vercel rewrote `Date` to now on a cache hit today, so the cached-Date failure did not show. Fix: RPC on loopback with PC 2 on a tunnel or its own node; `chmod 600`; the stray file removed; the key passed to `curl` through `-K` or a header file; an uncacheable path for the clock source. Review ids R4.5.5 to R4.5.7.
|
||||
|
||||
Evidence: `lsof`, `ls`, `curl -I`.
|
||||
|
||||
Fix (5 October 2026, night), the curl part: `infra/gpu-bench/upload.sh` writes `header = "x-igneum-key: ..."` to a 0600 temporary config and calls `curl -K`; `proto-cuda/windows-app/upload-log.bat` and `proto-cuda/windows-miner/upload-log.bat` do the same with a `%TEMP%` config deleted with the body; `relay/clients/agent.sh` and `send.sh` keep every secret header in a 0600 `headers.cfg` and use `-K`; `prove-block.sh` and `prove-shard.sh` already send the key from inside python's `urllib` (no command line). The class check: `tools/ci/curl-header-check.sh` (self-test first) fails CI on any `.sh`, `.bat`, `.cmd` or `.ps1` line that passes `x-igneum-key`, `x-relay-token` or `x-machine-secret` as a curl `-H` argument; tonight's tree: 0 hits. Not in this branch: the app's own `curl` calls still put the key on the command line (`app/igneum-app/src/update.rs:91`, `jobrun.rs` `relay_upload`); that is app code, owed to the app's next cut, named here so it is not lost. The mode half was fixed on 5 October (every secret 600); the `--rpclisten` half is the project lead's; the clock source is a round 2 candidate.
|
||||
|
||||
### X30. The live page and the bench page exposed operational detail
|
||||
"The engineering log page rendered the bench log with private strings; `/api/live` returned peer addresses, full key hashes and payout addresses; the mobile menu did not open."
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,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 <folder> 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"
|
||||
|
|
|
|||
|
|
@ -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%
|
||||
|
|
|
|||
|
|
@ -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%
|
||||
|
|
|
|||
|
|
@ -22,9 +22,29 @@ Mac: `node tools/console.mjs post --kind log --title "..." --body "..."` writes
|
|||
|
||||
The app log (label `win-<id8>` or `mac-<id8>`) reaches the intake since b8b349a (4 Oct 2026, 0.3.3); apps before that show `app ?`. The parsers (labels, miner tail, app tail, the stale mark) live in `relay/lib/parse.mjs` with no dependencies, and `relay/lib/auth.mjs` holds the constant-time secret compare; `node --test relay/test/parse.test.mjs relay/test/auth.test.mjs` runs their tests, and CI runs them in the site job.
|
||||
|
||||
## The 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/<token>/` and every API call sits under `/r/<token>/api/<fn>`. 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/<token>/` only for the phone's page (`ui.html`) and the calls that page makes (`/r/<token>/api/<fn>`, `/r/<token>/c/<fn>`, `/r/<token>/wake`) | everything, except that a `run` task also needs the run signature below |
|
||||
| the relay key (48 characters, the relay's own since 4 October 2026) | `~/.config/igneum/relay-key`, `RELAY_KEY` | `x-igneum-key` | read the feed, items, files, inbox, machines; post notes, files, results; register; ack; done. Never `task`, `run`, `name`, `role`, `secret`, `delete` |
|
||||
| the log-intake key (inside every shipped package) | `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT` | `x-igneum-key` | only `upload` and a `drop` of kind `file` or `text`: the PC apps' build-job outputs (`jobrun.rs`). No reads, no results, nothing else. `RELAY_INTAKE_COMPAT=0` on the project closes this tier once the apps carry a relay key of their own |
|
||||
| the run key (Ed25519, `node tools/relay.mjs keygen`) | `~/.config/igneum/relay-run-key` (seed, 0600) and `.pub`; the public half as `RELAY_RUN_PUB` on the project | `flags.sig` on every `run` task, over the canonical text in `relay/lib/guard.mjs` (`runCanon`: machine, nonce, body sha256, the elevated, reboot_continue and reboot flags) | without a verifying signature the relay answers 401 to a `run`; without `RELAY_RUN_PUB` every `run` is refused |
|
||||
| a machine secret per PC (64 hex, `node tools/relay.mjs secret PC1`) | `~/.config/igneum/relay-machines/<name>` on the Mac; `machine-secret.txt` beside the agent on that PC (from `make-clients.sh --machine`); its sha256 on the relay (`relay_machines.secret_hash`) | `x-machine-secret` on `register`, `result`, `done`; `flags.mac` on every `run` (an HMAC-SHA256 tag the agent verifies before it executes, because Windows PowerShell 5.1 has no Ed25519) | a result's `from` is the machine the secret proves (a mismatch is 403); a bound hostname cannot register without it; the agent runs nothing whose tag does not verify, and never the same nonce twice |
|
||||
|
||||
Review round 4 (X23) found one tier with every power: the intake key inside every package queued PowerShell on PC 1 as administrator. Now the token alone cannot queue a `run` either: the Mac signs it with the run key (checked by the relay) and tags it with the target's secret (checked by the agent). Compare is constant time (`lib/auth.mjs`). Blob file URLs carry a random segment and a random suffix; they are not listed anywhere. 120 calls a minute per IP, 10 failed authentications a minute per IP, then 429.
|
||||
|
||||
### Rotation (what each secret's change breaks, and the order)
|
||||
|
||||
| Rotate | How | What stops, until | Order |
|
||||
|---|---|---|---|
|
||||
| the console token | new value in `~/.config/igneum/relay-token` and `RELAY_TOKEN`; a new zip per PC (`make-clients.sh --machine`) carried by hand; the phone gets the new link | every PC client and the phone page, until the new zip and link are in place; the Mac tools at once (they read the file) | any time; the old zip on a PC is dead the moment the project env changes |
|
||||
| the relay key | new value in `relay-key` and `RELAY_KEY`; new zips | the PC clients' reads and reports until the new zip; `tools/build-job.mjs` falls back to the token | with the token, same zip |
|
||||
| the intake key | `LOG_INTAKE_KEY_NEXT` on the relay AND the site intake first, repackage every app with the next key (`packaged-config.sh`), publish; then move `_NEXT` to `LOG_INTAKE_KEY`; then `RELAY_INTAKE_COMPAT=0` once no shipped app uploads build outputs with it | build-job uploads from every app that still carries the old key; log uploads from every shipped package until it updates | the long one: apps update over days, so both values are accepted during the window (`docs/plans/rotation-phase-2.md`) |
|
||||
| the run key | `node tools/relay.mjs keygen` after moving the old `relay-run-key` aside; `RELAY_RUN_PUB` on the project | every `run` queued with the old key is refused at the relay; nothing on a PC changes (the PCs hold no run key) | any time, in one step |
|
||||
| a machine secret | `node tools/relay.mjs secret PC1 --rotate` (rebinds the sha256), `make-clients.sh --machine PC1`, carry the zip | that PC's results and registration until the new zip is there; queued `run` tasks tagged with the old secret are refused by the agent | per machine, any time |
|
||||
|
||||
What the 5 October rotation already did: the relay token (4 October), the intake key and the dl token (`.old-2026-10-05` beside the live files). What is still the project lead's: the hosted `igneum-relay-clients.zip` off the downloads host (it holds the old token and key; dead values now, but the file is the shape X23 names), `RELAY_RUN_PUB` on the project after `keygen`, one `secret` per PC and the zips carried by hand.
|
||||
|
||||
## What is stored where
|
||||
|
||||
|
|
@ -33,35 +53,40 @@ The web page lives at `/r/<token>/` and every API call sits under `/r/<token>/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/<name>`; project env (`RELAY_TOKEN`, `RELAY_KEY`, `RELAY_RUN_PUB`, `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT`) | never in the repo |
|
||||
| Retention | rows older than 30 days are deleted with their blobs, checked on a feed read at most every 10 minutes per instance; `delete` removes the blob with the row (X26) | 30 days |
|
||||
|
||||
Kinds: `text` (a note), `file`, `task` (for a person or a Claude session on a PC), `run` (a script the agent executes), `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/<token>/api/`)
|
||||
## API (`/api/relay?fn=<name>` with the headers above; `/r/<token>/api/<fn>` from the phone's page only)
|
||||
|
||||
| Call | Does |
|
||||
|---|---|
|
||||
| `GET feed?since=&before=&machine=&limit=` | items newest first (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=<stamp>` is public (the apps hold no token) and rate limited, 30 a minute per IP. It holds up to 45 s and answers `{stamp, at, added, changed, held_ms}` the moment the stored stamp differs from `since`, else the unchanged stamp at the deadline; without `since` it answers at once. `POST /r/<token>/wake {stamp, added}` (or `POST /wake` with `x-relay-token` or `x-igneum-key`) records the stamp; `packaging/ota/publish-jobs.sh` sends it after every verified deploy, with the ids it added. One row per stamp in Neon table `relay_wake` (created by the first POST); `tools/jobs.mjs status` reads the rows for the woken latency. The function's `maxDuration` is 60 s (`vercel.json`). Tests: `relay/test/wake.test.mjs` drives the handler with a fake database and clock.
|
||||
|
||||
## Mac
|
||||
|
||||
`node tools/relay.mjs` (feed), `read <id>`, `drop "<text>"|<file>`, `task PC2 "title" [file]`, `run PC2 "title" script.ps1 [--elevated] [--reboot-continue]`, `watch`, `inbox PC1`, `machines`, `role PC2 prover`, `name DESKTOP-XYZ PC2`, `ack|done|rm <id>`, `url`. Playbooks live in `relay/playbooks/`; `run` fills `__DL_BASE__` in from `~/.config/igneum/dl-token`.
|
||||
`node tools/relay.mjs` (feed), `read <id>`, `drop "<text>"|<file>`, `task PC2 "title" [file]`, `run PC2 "title" script.ps1 [--elevated] [--reboot-continue] [--reboot]`, `keygen`, `secret PC2`, `watch`, `inbox PC1 [--ack]`, `machines`, `role PC2 prover`, `name DESKTOP-XYZ PC2`, `ack|done|rm <id>`, `url`. Playbooks live in `relay/playbooks/`; a playbook reads the downloads base from `$env:RELAY_DL_BASE` (bash: `$RELAY_DL_BASE`), which the agent holds; `run` refuses a body that says `__DL_BASE__` or carries the dl token (X26). A script asks for a restart by printing `RELAY-REBOOT` on a line of its own, and only a task queued with `--reboot` or `--reboot-continue` restarts the PC.
|
||||
|
||||
## PCs
|
||||
|
||||
`relay/clients/make-clients.sh` 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 <hostname> 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).
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Igneum console: one function, dispatched on ?fn=, reached through the rewrite /r/<token>/c/<fn>.
|
||||
// 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
|
||||
|
|
@ -11,7 +12,7 @@
|
|||
// GET results bench entries (synced from docs/bench-log.md) + the FUD ledger counts
|
||||
// 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';
|
||||
|
||||
const json = (res, status, obj) => { res.status(status).setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(obj)); };
|
||||
|
|
|
|||
|
|
@ -1,201 +1,18 @@
|
|||
// Igneum relay: one function, dispatched on ?fn=. Reached through the rewrite /r/<token>/api/<fn>.
|
||||
// Auth: the token in the path (or x-relay-token), or the intake key in x-igneum-key. Nothing else.
|
||||
// GET feed ?since=<id> | ?before=<id> | ?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=<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/<token>/api/<fn> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
// (public: the apps hold no token) and at /r/<token>/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=<stamp>[&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)); };
|
||||
|
|
|
|||
|
|
@ -26,6 +26,6 @@ Also: `send.bat "<text>"` 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <name>, then make-clients.sh --machine <name>)' }
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" <<TXT
|
||||
Igneum relay clients. Unzip anywhere. send.bat "<text>" | send.bat <file> | send.bat inbox | send.bat result "<text>" | send.bat get <id>
|
||||
Igneum relay clients${MACHINE:+ for $MACHINE}. Unzip anywhere. send.bat "<text>" | send.bat <file> | send.bat inbox | send.bat result "<text>" | send.bat get <id>
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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 "<text>" post a note to everyone
|
||||
# send.ps1 <file> post a file (up to 50 MB, straight to Vercel Blob)
|
||||
# send.ps1 result "<text>" [-TaskId N] [-File path] post a result (what a task produced)
|
||||
# send.ps1 result "<text>" [-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 <id> print an item; download its file into the current folder
|
||||
# send.ps1 done <id> 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 <id>' }
|
||||
$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 <id>' }; Invoke-Relay 'POST' 'done' @{ id = [long]$B } | Out-Null; Write-Host "#$B done"; break }
|
||||
'^(?i)result$' {
|
||||
if (-not $B -and -not $File) { throw 'result "<text>" [-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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,23 @@
|
|||
# send.sh "<text>" note to everyone send.sh <file> file (up to 50 MB)
|
||||
# send.sh inbox unread tasks for this machine, marked read send.sh peek same, not marked
|
||||
# send.sh get <id> print an item, download its file here send.sh done <id>
|
||||
# send.sh result "<text>" [--task-id N] [--file path]
|
||||
# send.sh result "<text>" [--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 <id>' >&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 <id>" >&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 "<text>" | send.sh <file> | send.sh inbox | send.sh result "<text>" | send.sh get <id> | send.sh done <id>' >&2; exit 1 ;;
|
||||
|
|
|
|||
31
relay/lib/blob.mjs
Normal file
31
relay/lib/blob.mjs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
124
relay/lib/guard.mjs
Normal file
124
relay/lib/guard.mjs
Normal file
|
|
@ -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' };
|
||||
}
|
||||
295
relay/lib/handler.mjs
Normal file
295
relay/lib/handler.mjs
Normal file
|
|
@ -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=<fn> with x-relay-token or x-igneum-key, or through the
|
||||
// rewrite /r/<token>/api/<fn> from the phone's page (the only caller that keeps the token in the path, X24).
|
||||
//
|
||||
// GET feed ?since=<id> | ?before=<id> | ?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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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(); };
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
119
relay/test/clients.test.mjs
Normal file
119
relay/test/clients.test.mjs
Normal file
|
|
@ -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)`);
|
||||
}
|
||||
});
|
||||
113
relay/test/guard.test.mjs
Normal file
113
relay/test/guard.test.mjs
Normal file
|
|
@ -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' });
|
||||
});
|
||||
251
relay/test/handler.test.mjs
Normal file
251
relay/test/handler.test.mjs
Normal file
|
|
@ -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']);
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
35
tools/ci/commit-tz-check.sh
Executable file
35
tools/ci/commit-tz-check.sh
Executable file
|
|
@ -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() { # <root> -> 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
|
||||
31
tools/ci/curl-header-check.sh
Executable file
31
tools/ci/curl-header-check.sh
Executable file
|
|
@ -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 <file>` 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() { # <root> -> 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
|
||||
|
|
@ -10,7 +10,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';
|
||||
|
|
@ -20,11 +20,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 = [];
|
||||
|
|
@ -36,8 +37,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;
|
||||
|
|
|
|||
|
|
@ -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 <id> print an item; its file is downloaded to --out (default $TMPDIR/igneum-relay)
|
||||
// node tools/relay.mjs drop "<text>" | <file> post a note or a file from the Mac [--to PC1] [--title "..."] [--body "..." with a file]
|
||||
// node tools/relay.mjs task <machine> "title" [file] [--body "..."] a task for a person or a Claude session on that PC
|
||||
// node tools/relay.mjs run <machine> "title" <script.ps1|.sh> [--elevated] [--reboot-continue] a script the igneum-agent runs
|
||||
// node tools/relay.mjs run <machine> "title" <script.ps1|.sh> [--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/<machine>, 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 <machine> make that machine's secret, bind it on the relay, then make-clients.sh --machine
|
||||
// node tools/relay.mjs watch [--since <id>] poll every 10 s and print new items (results included)
|
||||
// node tools/relay.mjs inbox <machine> [--ack] what that machine has not read yet
|
||||
// node tools/relay.mjs inbox <machine> [--ack] what that machine has not read yet (--ack marks it read, a POST)
|
||||
// node tools/relay.mjs machines | role <name> <miner|prover|bench|mac|phone> | name <hostname> <name>
|
||||
// node tools/relay.mjs ack <id> | done <id> | rm <id> | 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/<name>, 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/<token>`; // 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 <machine>');
|
||||
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 <machine>');
|
||||
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 : ''}`); }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue