Merge mf11-master 0e3b36fe into master (gate: green on 0e3b36fe, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge)
This commit is contained in:
commit
f15943f795
32 changed files with 2000 additions and 384 deletions
16
docs/plans/relay-deploy-2026-10-07.md
Normal file
16
docs/plans/relay-deploy-2026-10-07.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Relay deploy, 7 October 2026 (the X23 tree, MF-11)
|
||||
|
||||
| What | Value |
|
||||
|---|---|
|
||||
| Deployed | 2026-10-07 15:38 BST, from branch update-return bd9b4f4e, relay/ |
|
||||
| Production deployment | https://igneum-relay-iabqarnby-igneum.vercel.app (inspect KHyscUSVKueXueqxvZBRKkaUHwJR), aliased https://relay.igneum.network |
|
||||
| Previous production | https://igneum-relay-bgy767z40-igneum.vercel.app |
|
||||
| Env added | RELAY_RUN_PUB (the public half of ~/.config/igneum/relay-run-key, made by `node tools/relay.mjs keygen` the same hour); RELAY_INTAKE_COMPAT unset |
|
||||
| Read-backs | /wake answers; fn=machines carries the bound field; the console page answers 200 and c/machines carries poll fields; a v1-shaped register by hostname (key tier) answers named:false bound:false; a signed start-app from the Mac answers 200 (item 394); an unsigned run from master's old tool is refused |
|
||||
|
||||
## Rollback
|
||||
|
||||
`cd relay && npx --yes vercel@latest --global-config ~/.config/igneum/vercel --scope igneum rollback https://igneum-relay-bgy767z40-igneum.vercel.app`
|
||||
(or `vercel promote https://igneum-relay-bgy767z40-igneum.vercel.app`). Seconds; nothing to undo in the database: the X23 code adds relay_machines.secret_hash and the
|
||||
table relay_wake_seen, both ignored by the old code. What a rollback loses: signed run verification (the old relay never
|
||||
had it), the start-app kind, the ping; PC 2's new agent registers by hostname on either.
|
||||
|
|
@ -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 Josh. 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 Josh's: the hosted `igneum-relay-clients.zip` off the downloads host (it holds the old token and key; dead values now, but the file is the shape X23 names), `RELAY_RUN_PUB` on the project after `keygen`, one `secret` per PC and the zips carried by hand.
|
||||
|
||||
## What is stored where
|
||||
|
||||
|
|
@ -33,49 +53,62 @@ The web page lives at `/r/<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.
|
||||
|
||||
## The job-channel ping (MF-11, 7 October 2026)
|
||||
## The agent as a logon task, start-app, and the job-channel ping (MF-11, 7 October 2026)
|
||||
|
||||
PC 2 lost power at 10:46Z on 7 October 2026 and nothing said so until a person read the intake. From 0.3.21 every app wake request carries `machine=<id8>&v=<version>&job=<last job id>`; `/wake` records one row per machine in `relay_wake_seen` before it holds (`relay/lib/wake.mjs`: `recordSeen`, `seenList`, `pingState`, `PING_SILENT_S` = 900 s); the console's Machines tab reads it as "job channel polled N ago, last job X" and, after 15 minutes without a poll, "job channel silent since <time>, last job X" in red, whatever the uploads say (`api/console.mjs` `poll`, `ui.html`). No secret travels: id8 is what the console already shows. Test: `node --test relay/test/wake.test.mjs`.
|
||||
PC 2 lost power at 10:46Z on 7 October 2026 (Kernel-Power 41, no bugcheck) and sat dark until a hand pressed the button at 13:37Z; its relay agent had been dead since 6 October (the one-shot logon task of X25 exited at every boot behind a UAC prompt), so nothing could reach it. Three things close the class:
|
||||
|
||||
| Piece | What | Where |
|
||||
|---|---|---|
|
||||
| the agent as a logon task | `install-agent.ps1` (elevated, once per PC) registers `IgneumRelayService` from `IgneumRelayService.xml`: at this user's logon, highest run level, interactive, restarted on failure every minute up to 999 times, no time limit, one instance; its action is the hidden loop `igneum-agent-service.ps1`, which restarts `igneum-agent.ps1` 15 s after any exit and logs to `%LOCALAPPDATA%\igneum-relay\logs\agent-service.log`. The agent's own X25 disarm removes only the one-shot `IgneumRelayAgent`; the service task is never touched. `install-agent.ps1 -Remove` takes it off | `relay/clients/`, in the zip from `make-clients.sh --machine <name>` |
|
||||
| `start-app` | `node tools/relay.mjs start-app PC2`: a task kind signed and tagged exactly like `run` (the relay checks `sig`, `mac` and the nonce; the token tier only), delivered in the agent's `run` inbox. An agent from v3 executes nothing from its body: its own `Start-App` starts the installed Igneum Miner as the user (an elevated agent goes through a one-off `/RL LIMITED` task, so the app never runs elevated), waits up to 90 s for an engine to answer `api/state`, and posts a result with the version and the seconds. The body is `relay/playbooks/start-app.ps1`, the same thing for an agent from before the kind. It never sends quit, pause or resume | `relay/lib/relay.mjs` (`KINDS`, `AGENT_KINDS`), `lib/handler.mjs`, `clients/igneum-agent.ps1`, `tools/relay.mjs` |
|
||||
| the ping | every app wake request (0.3.21) carries `machine=<id8>&v=<version>&job=<last job id>`; `/wake` records one row per machine in `relay_wake_seen` before it holds; the console's Machines tab reads it as "job channel polled N ago, last job X" and, after 15 minutes without a poll, "job channel silent since <time>, last job X" in red, whatever the uploads say | `relay/lib/wake.mjs` (`recordSeen`, `seenList`, `pingState`, `PING_SILENT_S`), `api/console.mjs`, `ui.html` |
|
||||
|
||||
Tests: `node --test relay/test/service.test.mjs relay/test/handler.test.mjs relay/test/wake.test.mjs`.
|
||||
|
||||
## Deploy
|
||||
|
||||
```
|
||||
cd relay && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes --scope igneum
|
||||
```
|
||||
Env on the project: `DATABASE_URL`, `RELAY_KEY`, `RELAY_TOKEN`, `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,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 Josh ships a new zip.
|
||||
- Copy law applies to results too: short sentences, numbers in tables, no em dashes.
|
||||
|
|
|
|||
59
relay/clients/IgneumRelayService.xml
Normal file
59
relay/clients/IgneumRelayService.xml
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?xml version="1.0" encoding="UTF-16"?>
|
||||
<!-- The relay agent's logon task (MF-11, 7 October 2026). install-agent.ps1 fills __USER__ (DOMAIN\user) and
|
||||
__AGENT_DIR__ (the unzipped client folder) and registers it with schtasks /Create /XML. Runs at that user's logon
|
||||
in the interactive session (so start-app can open the app window), at the run level the installer chooses (LeastPrivilege
|
||||
by default: no administrator and no UAC prompt to register or run; -Highest for a PC where elevated tasks must not prompt),
|
||||
restarted on failure every minute up to 999 times, no time limit, one instance at a time, never stopped for a
|
||||
battery, started late if the logon was missed. -->
|
||||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>Igneum relay agent: polls the relay for signed tasks and can start Igneum Miner; survives the app and a reboot.</Description>
|
||||
<URI>\IgneumRelayService</URI>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<LogonTrigger>
|
||||
<Enabled>true</Enabled>
|
||||
<UserId>__USER__</UserId>
|
||||
<Delay>PT20S</Delay>
|
||||
</LogonTrigger>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<UserId>__USER__</UserId>
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>__RUNLEVEL__</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>true</AllowHardTerminate>
|
||||
<StartWhenAvailable>true</StartWhenAvailable>
|
||||
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
||||
<IdleSettings>
|
||||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
||||
<RestartOnIdle>false</RestartOnIdle>
|
||||
</IdleSettings>
|
||||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
||||
<Enabled>true</Enabled>
|
||||
<Hidden>false</Hidden>
|
||||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||||
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
|
||||
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>7</Priority>
|
||||
<RestartOnFailure>
|
||||
<Interval>PT1M</Interval>
|
||||
<Count>999</Count>
|
||||
</RestartOnFailure>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>powershell.exe</Command>
|
||||
<Arguments>-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "__AGENT_DIR__\igneum-agent-service.ps1"</Arguments>
|
||||
<WorkingDirectory>__AGENT_DIR__</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
25
relay/clients/igneum-agent-service.ps1
Normal file
25
relay/clients/igneum-agent-service.ps1
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# The relay agent as a logon task (IgneumRelayService, registered by install-agent.ps1 from IgneumRelayService.xml).
|
||||
# This loop keeps igneum-agent.ps1 running with no window: the Task Scheduler restarts this loop on failure (PT1M, 999
|
||||
# times), and the loop restarts the agent 15 s after any exit, so the agent outlives the app, a crash of its own, and a
|
||||
# reboot (the task fires at logon; the PC signs in by itself). MF-11, 7 October 2026: PC 2's agent had been dead since
|
||||
# 6 October and its one-shot logon task exited at every boot, so neither a signed job nor a relay task could start the app.
|
||||
# Output goes to %LOCALAPPDATA%\igneum-relay\logs\agent-service.log (5 MB, one rotation); the idle line is left out.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Agent = Join-Path $Here 'igneum-agent.ps1'
|
||||
$LogDir = Join-Path $env:LOCALAPPDATA 'igneum-relay\logs'
|
||||
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||||
$log = Join-Path $LogDir 'agent-service.log'
|
||||
function Note([string]$t) { Add-Content -Path $log -Value ((Get-Date -Format s) + ' service: ' + $t) }
|
||||
while ($true) {
|
||||
if ((Test-Path $log) -and ((Get-Item $log).Length -gt 5MB)) { Move-Item -Force -Path $log -Destination ($log + '.1') }
|
||||
if (-not (Test-Path $Agent)) { Note ('no agent at ' + $Agent + '; waiting'); Start-Sleep -Seconds 60; continue }
|
||||
Note 'starting igneum-agent.ps1'
|
||||
try {
|
||||
# one Add-Content per line, so the file is never held open: a reader (a job's Get-Content -Tail) is refused while a
|
||||
# pipeline holds it (PC 2, 7 October 2026, 15:38 local: "being used by another process")
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Agent 2>&1 | ForEach-Object { "$_" } | Where-Object { $_ -notmatch 'idle as ' } | ForEach-Object { Add-Content -Path $log -Value $_ }
|
||||
Note ('agent exited with code ' + $LASTEXITCODE + '; again in 15 s')
|
||||
} catch { Note ('agent failed to start: ' + $_.Exception.Message + '; again in 15 s') }
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
|
|
@ -1,33 +1,53 @@
|
|||
# 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.
|
||||
# start-app (MF-11, 7 October 2026): a task of that kind, signed and tagged like a run, starts the installed Igneum Miner
|
||||
# as the signed-in user (never elevated: an elevated agent goes through a one-off limited-level task) and reports whether
|
||||
# an engine answered api/state; nothing from its body is executed. The agent itself runs as the logon task
|
||||
# IgneumRelayService (install-agent.ps1, restart on failure), which outlives the app and survives a reboot.
|
||||
# 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 +58,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 v3' }
|
||||
try { $info.os = (Get-CimInstance Win32_OperatingSystem).Caption + ' build ' + (Get-CimInstance Win32_OperatingSystem).BuildNumber } catch {}
|
||||
try {
|
||||
$nv = Get-Command nvidia-smi -ErrorAction SilentlyContinue
|
||||
|
|
@ -56,21 +77,33 @@ function Collect-Info {
|
|||
return $info
|
||||
}
|
||||
|
||||
function Machine-Hostname {
|
||||
# the hostname this PC registers under: COMPUTERNAME plus the app's per-install id (the first 8 hex of
|
||||
# %LOCALAPPDATA%\igneum\app\machine-id, the id the console and the jobs channel use), so two PCs with one Windows
|
||||
# hostname (both report DESKTOP-KMCV30N) are two machines on the relay; the bare hostname when the app is not installed
|
||||
$f = Join-Path $env:LOCALAPPDATA 'igneum\app\machine-id'
|
||||
if (Test-Path $f) {
|
||||
try { $id = (Get-Content $f -Raw).Trim().ToLower(); if ($id -match '^[0-9a-f]{16}$') { return ($env:COMPUTERNAME + '-' + $id.Substring(0, 8)) } } catch { }
|
||||
}
|
||||
return $env:COMPUTERNAME
|
||||
}
|
||||
function Register-Machine {
|
||||
$info = Collect-Info
|
||||
$r = Api-Post 'register' @{ hostname = $env:COMPUTERNAME; info = $info }
|
||||
$r = Api-Post 'register' @{ hostname = (Machine-Hostname); 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)
|
||||
if (-not $r.named) { Log "this PC is not named yet. On the Mac: node tools/relay.mjs name $env:COMPUTERNAME PC2" }
|
||||
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 " + (Machine-Hostname) + " 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 +111,99 @@ 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 Find-App {
|
||||
foreach ($d in @((Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner'), (Join-Path $env:ProgramFiles 'Igneum Miner'))) {
|
||||
if (Test-Path (Join-Path $d 'igneum-app.exe')) { return $d }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
function App-Version {
|
||||
# the installed app's own answer (api/state .version through its URL file), '' when nothing answers
|
||||
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
|
||||
if (-not (Test-Path $urlFile)) { return '' }
|
||||
try { $u = (Get-Content $urlFile -Raw).Trim(); $st = Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing; return [string]$st.version } catch { return '' }
|
||||
}
|
||||
function Start-App {
|
||||
# MF-11: start the installed Igneum Miner and read back that an engine answers. Never elevated: an elevated agent starts it
|
||||
# through a one-off scheduled task at the limited run level (the app's own rule: it runs as the user). Never a quit,
|
||||
# pause or resume of the installed app (the standing rule of 5 October 2026).
|
||||
$dir = Find-App
|
||||
if (-not $dir) { return @{ code = 2; note = 'no installed Igneum Miner on this PC'; text = 'start-app: igneum-app.exe not found under Programs or Program Files' } }
|
||||
$already = App-Version
|
||||
if ($already) { return @{ code = 0; note = ('app ' + $already + ' was already up'); text = ('start-app: an engine already answers api/state (app ' + $already + '); nothing started') } }
|
||||
$exe = Join-Path $dir 'igneum-app.exe'
|
||||
$t0 = Get-Date
|
||||
if (Is-Admin) {
|
||||
& schtasks.exe /Create /F /TN 'IgneumStartApp' /SC ONCE /ST 00:00 /RL LIMITED /TR ('"' + $exe + '" --launch') 2>&1 | Out-Null
|
||||
& schtasks.exe /Run /TN 'IgneumStartApp' 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds 3
|
||||
& schtasks.exe /Delete /F /TN 'IgneumStartApp' 2>&1 | Out-Null
|
||||
$how = 'through a one-off limited-level task (this agent is elevated)'
|
||||
} else {
|
||||
# console: igneum-app.exe is a windows-subsystem program (no console); the window host it opens is its own
|
||||
Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $dir | Out-Null
|
||||
$how = 'directly as this user'
|
||||
}
|
||||
$deadline = (Get-Date).AddSeconds(90)
|
||||
$answered = ''
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$answered = App-Version
|
||||
if ($answered) { break }
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
$s = [int]((Get-Date) - $t0).TotalSeconds
|
||||
if ($answered) { return @{ code = 0; note = ('app ' + $answered + ' answered in ' + $s + ' s'); text = ('start-app: started ' + $how + '; app ' + $answered + ' answered api/state after ' + $s + ' s') } }
|
||||
return @{ code = 3; note = 'no engine answered in 90 s'; text = ('start-app: started ' + $how + '; no engine answered api/state inside 90 s') }
|
||||
}
|
||||
|
||||
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 +216,34 @@ 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
|
||||
if ("$($task.kind)" -eq 'start-app') {
|
||||
# nothing of the body runs: the agent's own Start-App is the whole task
|
||||
$r = Start-App
|
||||
Log ("task #" + $id + " " + $r.text)
|
||||
Add-Content -Path $log -Value $r.text
|
||||
Post-Result $task ([int]$r.code) $log $r.note
|
||||
try { Api-Post 'done' @{ id = $id; exit_code = [int]$r.code } | Out-Null } catch { Log ("done failed: " + $_.Exception.Message) }
|
||||
return
|
||||
}
|
||||
$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 +264,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 }
|
||||
}
|
||||
|
|
|
|||
60
relay/clients/install-agent.ps1
Normal file
60
relay/clients/install-agent.ps1
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Registers the relay agent as the logon task IgneumRelayService (the manifest IgneumRelayService.xml beside this file:
|
||||
# runs at this user's logon in the interactive session, hidden, restarted on failure every minute up to 999 times, no
|
||||
# time limit, one instance), by the agent's full installed path, then starts it. Per user and with no administrator by
|
||||
# default (LeastPrivilege: a standard user may register a task for themselves, so a signed job can run this on a PC with
|
||||
# nobody at the keyboard); -Highest registers it elevated, which needs an administrator shell once. Re-running replaces
|
||||
# the task. Every IgneumRelayAgent* task (the one-shot reboot arms of X25) and the RunOnce key go first: on PC 2 a stale
|
||||
# one pointed at a path that was not there and popped "Windows cannot find 'igneum-agent'" at every boot (7 October 2026).
|
||||
# A failure here is a line on stdout and an exit code, never a dialog.
|
||||
# powershell -ExecutionPolicy Bypass -File install-agent.ps1 [-Highest] [-Remove]
|
||||
param([switch]$Highest, [switch]$Remove)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$TaskName = 'IgneumRelayService'
|
||||
function Stale-Tasks {
|
||||
# every task whose name starts with IgneumRelayAgent, from the CSV listing (the one-shot arms, and any hand-made copy)
|
||||
$out = @()
|
||||
try {
|
||||
$rows = & schtasks.exe /Query /FO CSV /NH 2>$null | ForEach-Object { "$_" }
|
||||
foreach ($r in $rows) { $n = ($r -split '","')[0].Trim('"'); if ($n -like '\IgneumRelayAgent*') { $out += $n } }
|
||||
} catch { }
|
||||
return $out
|
||||
}
|
||||
foreach ($t in (Stale-Tasks)) {
|
||||
& schtasks.exe /End /TN $t 2>&1 | Out-Null
|
||||
& schtasks.exe /Delete /F /TN $t 2>&1 | Out-Null
|
||||
Write-Host ('removed the stale task ' + $t)
|
||||
}
|
||||
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; Write-Host 'removed the RunOnce key IgneumRelayAgent' }
|
||||
} catch { }
|
||||
if ($Remove) {
|
||||
& schtasks.exe /End /TN $TaskName 2>&1 | Out-Null
|
||||
& schtasks.exe /Delete /F /TN $TaskName 2>&1 | Out-Null
|
||||
Write-Host ('removed the ' + $TaskName + ' task (an agent window started by hand is not touched)')
|
||||
exit 0
|
||||
}
|
||||
foreach ($f in @('IgneumRelayService.xml', 'igneum-agent-service.ps1', 'igneum-agent.ps1', 'machine-secret.txt')) {
|
||||
if (-not (Test-Path (Join-Path $Here $f))) { Write-Host ('missing ' + $f + ' beside this script (unzip the whole client zip from make-clients.sh --machine <name>)'); exit 2 }
|
||||
}
|
||||
$level = 'LeastPrivilege'
|
||||
if ($Highest) { $level = 'HighestAvailable' }
|
||||
$user = $env:USERDOMAIN + '\' + $env:USERNAME
|
||||
$xml = Get-Content (Join-Path $Here 'IgneumRelayService.xml') -Raw
|
||||
$xml = $xml.Replace('__USER__', $user).Replace('__AGENT_DIR__', $Here).Replace('__RUNLEVEL__', $level)
|
||||
$tmp = Join-Path $env:TEMP ('IgneumRelayService-' + [guid]::NewGuid().ToString('n') + '.xml')
|
||||
[IO.File]::WriteAllText($tmp, $xml, (New-Object Text.UnicodeEncoding $false, $true))
|
||||
$code = 1
|
||||
try {
|
||||
$out = & schtasks.exe /Create /F /TN $TaskName /XML $tmp 2>&1 | ForEach-Object { "$_" }
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -ne 0) { Write-Host ('schtasks /Create failed (' + $code + '): ' + ($out -join ' ')); exit 3 }
|
||||
Write-Host ('registered ' + $TaskName + ' for ' + $user + ' at ' + $level + ' from ' + $Here)
|
||||
} finally { Remove-Item -Path $tmp -Force -ErrorAction SilentlyContinue }
|
||||
& schtasks.exe /Run /TN $TaskName 2>&1 | ForEach-Object { "$_" } | Write-Host
|
||||
Start-Sleep -Seconds 3
|
||||
& schtasks.exe /Query /TN $TaskName /V /FO LIST 2>&1 | ForEach-Object { "$_" } | Where-Object { $_ -match '^(TaskName|Status|Logon Mode|Last Run Time|Last Result|Task To Run|Run As User|Schedule Type):' } | Write-Host
|
||||
Write-Host 'the agent now runs under the task; close any igneum-agent.bat window (the task copy exits while that one holds the mutex, and comes back 15 s after it closes)'
|
||||
Write-Host ('its log: ' + (Join-Path $env:LOCALAPPDATA 'igneum-relay\logs\agent-service.log'))
|
||||
exit 0
|
||||
|
|
@ -1,25 +1,52 @@
|
|||
#!/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"
|
||||
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"
|
||||
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 igneum-agent-service.ps1 install-agent.ps1 IgneumRelayService.xml 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" -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"
|
||||
# the two optional phrases are built first: an apostrophe inside ${MACHINE:+...} in a heredoc is "no closing }" to bash 3.2
|
||||
FOR="${MACHINE:+ for $MACHINE}"
|
||||
OWN=""; [ -n "$MACHINE" ] && OWN=" and this machine's own secret (machine-secret.txt)"
|
||||
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-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.
|
||||
Igneum relay clients$FOR. Unzip anywhere. send.bat "<text>" | send.bat <file> | send.bat inbox | send.bat result "<text>" | send.bat get <id>
|
||||
install-agent.ps1 (right-click, Run with PowerShell; no administrator needed): registers the agent as the per-user logon task IgneumRelayService, restarted on failure, so it outlives the app and comes back after a reboot. igneum-agent.bat: the hand-started form, double-click once, leave open. CLAUDE-PC.md: paste into the Claude Code session on this PC.
|
||||
These files contain the relay secret$OWN. 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 ;;
|
||||
|
|
|
|||
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' };
|
||||
}
|
||||
297
relay/lib/handler.mjs
Normal file
297
relay/lib/handler.mjs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// 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'|'start-app',flags:{elevated,reboot_continue,reboot,nonce,sig,mac},from}
|
||||
// start-app (MF-11): the same checks as run; the agent starts the installed app instead of running the body
|
||||
// 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, AGENT_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','start-app') 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 = AGENT_KINDS.includes(o.kind) ? o.kind : '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 (AGENT_KINDS.includes(kind)) {
|
||||
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 IN ('run', 'start-app') 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' });
|
||||
// an agent asking for `run` gets start-app too: both are its kinds, and an agent before the kind runs its body as a script
|
||||
const kind = src.kind === 'run' ? AGENT_KINDS : src.kind === 'task' ? ['task'] : ['task', ...AGENT_KINDS];
|
||||
// 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,16 +1,20 @@
|
|||
// 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
|
||||
export const MAX_BODY = 1024 * 1024; // text body per item
|
||||
export const KINDS = new Set(['text', 'file', 'task', 'result', 'run']);
|
||||
// start-app (MF-11, 7 October 2026): a signed, tagged task like `run`, but the agent executes nothing from its body: it
|
||||
// starts the installed Igneum Miner (hidden console, as the user) and reports whether an engine answered. The body
|
||||
// carries relay/playbooks/start-app.ps1 so an agent from before this kind runs the same thing as a `run`.
|
||||
export const KINDS = new Set(['text', 'file', 'task', 'result', 'run', 'start-app']);
|
||||
/** The two kinds the agent executes: signed by the run key, tagged with the machine secret, one nonce each. */
|
||||
export const AGENT_KINDS = ['run', 'start-app'];
|
||||
export const ROLES = new Set(['miner', 'prover', 'bench', 'mac', 'phone', '']);
|
||||
|
||||
export function neon() {
|
||||
|
|
@ -31,19 +35,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 +69,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(); };
|
||||
|
||||
|
|
|
|||
123
relay/playbooks/agent-install.ps1
Normal file
123
relay/playbooks/agent-install.ps1
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Installs (or replaces) the relay agent on this PC as the per-user logon task IgneumRelayService, through the signed
|
||||
# jobs channel with nobody at the keyboard (MF-11, PC 2, 7 October 2026). The client zip (the relay URL, key and token,
|
||||
# this machine's secret) never travels through the downloads host: the Mac encrypts it (AES-256-CBC, PKCS7) and posts it
|
||||
# as a relay file item; this job fetches that item with the relay values already baked into the client on this PC, checks
|
||||
# its sha256, decrypts it with the key this job carries, and unzips it under the user's profile. The jobs file and the
|
||||
# relay feed are two different secrets away from each other, so neither alone holds the zip. Then: every IgneumRelayAgent*
|
||||
# task and the stale client folders go, install-agent.ps1 registers the task (LeastPrivilege, no UAC), starts it, and this
|
||||
# job reads back the relay's machine list and the app's state. It never quits, pauses or resumes the installed app.
|
||||
# Published with the four placeholders filled: __URL__ (the ciphertext's unlisted blob URL on the relay store), __SHA__
|
||||
# (its sha256), __NONCE__ and __IV__ (hex). The key is sha256(<machine-id>:<nonce>); nothing of it is printed.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
function Say([string]$m) { Write-Host ((Get-Date -Format 'HH:mm:ss') + ' ' + $m) }
|
||||
function Hex-Bytes([string]$h) { $b = New-Object byte[] ($h.Length / 2); for ($i = 0; $i -lt $b.Length; $i++) { $b[$i] = [Convert]::ToByte($h.Substring($i * 2, 2), 16) }; return ,$b }
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$dest = Join-Path $env:LOCALAPPDATA 'igneum-relay\agent'
|
||||
$stateDir = Join-Path $env:LOCALAPPDATA 'igneum-relay'
|
||||
|
||||
# 1. the key: this PC's own per-install id (%LOCALAPPDATA%\igneum\app\machine-id, 16 hex, random at the first run of the
|
||||
# app, never in a package and never on the downloads host) hashed with the nonce this job carries. The jobs file alone
|
||||
# (one dl token away) holds the nonce, the IV and the unlisted blob URL, not the key.
|
||||
$idFile = Join-Path $env:LOCALAPPDATA 'igneum\app\machine-id'
|
||||
if (-not (Test-Path $idFile)) { Say 'no machine-id on this PC; nothing installed'; exit 2 }
|
||||
$mid = (Get-Content $idFile -Raw).Trim().ToLower()
|
||||
if ($mid -notmatch '^[0-9a-f]{16}$') { Say 'machine-id is not 16 hex; nothing installed'; exit 2 }
|
||||
$sha256 = [Security.Cryptography.SHA256]::Create()
|
||||
$keyBytes = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($mid + ':' + '__NONCE__'))
|
||||
$sha256.Dispose()
|
||||
|
||||
# 2. the encrypted zip from its unlisted blob URL, sha256 checked, decrypted, unzipped under the profile
|
||||
$enc = Join-Path $env:TEMP 'igneum-relay-client.zip.enc'
|
||||
$zip = Join-Path $env:TEMP 'igneum-relay-client.zip'
|
||||
$unz = Join-Path $env:TEMP 'igneum-relay-client-unzip'
|
||||
try {
|
||||
Invoke-WebRequest -Uri '__URL__' -OutFile $enc -UseBasicParsing -MaximumRedirection 5 -TimeoutSec 120
|
||||
} catch { Say ('could not fetch the client zip: ' + $_.Exception.Message); exit 3 }
|
||||
$have = (Get-FileHash -Path $enc -Algorithm SHA256).Hash.ToLower()
|
||||
if ($have -ne '__SHA__') { Say ('the fetched zip has sha256 ' + $have + ', not the one this job carries; nothing installed'); Remove-Item $enc -Force -ErrorAction SilentlyContinue; exit 4 }
|
||||
Say ('encrypted zip fetched, ' + (Get-Item $enc).Length + ' bytes, sha256 verified')
|
||||
try {
|
||||
$aes = [Security.Cryptography.Aes]::Create()
|
||||
$aes.Mode = [Security.Cryptography.CipherMode]::CBC
|
||||
$aes.Padding = [Security.Cryptography.PaddingMode]::PKCS7
|
||||
$aes.Key = $keyBytes
|
||||
$aes.IV = Hex-Bytes '__IV__'
|
||||
$bytes = [IO.File]::ReadAllBytes($enc)
|
||||
$plain = $aes.CreateDecryptor().TransformFinalBlock($bytes, 0, $bytes.Length)
|
||||
[IO.File]::WriteAllBytes($zip, $plain)
|
||||
$aes.Dispose()
|
||||
} catch { Say ('decrypt failed (the key is this machine-id with the nonce; another PC cannot open it): ' + $_.Exception.Message); Remove-Item $enc -Force -ErrorAction SilentlyContinue; exit 5 }
|
||||
Remove-Item $enc -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path $unz) { Remove-Item $unz -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
try { Expand-Archive -Path $zip -DestinationPath $unz -Force } catch { Say ('unzip failed: ' + $_.Exception.Message); Remove-Item $zip -Force -ErrorAction SilentlyContinue; exit 6 }
|
||||
Remove-Item $zip -Force -ErrorAction SilentlyContinue
|
||||
$src = Get-ChildItem -Path $unz -Recurse -Filter 'install-agent.ps1' -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $src) { Say 'the zip carries no install-agent.ps1; nothing installed'; exit 7 }
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
& robocopy.exe $src.DirectoryName $dest /MIR /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -ge 8) { Say ('robocopy into ' + $dest + ' failed (' + $LASTEXITCODE + ')'); exit 8 }
|
||||
Remove-Item $unz -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Say ('client installed under ' + $dest + ': ' + ((Get-ChildItem $dest | ForEach-Object { $_.Name }) -join ', '))
|
||||
# the relay values for the read-back below come from the installed client; never printed
|
||||
$t = Get-Content (Join-Path $dest 'igneum-agent.ps1') -Raw
|
||||
$RelayUrl = [regex]::Match($t, '\$RelayUrl = ''([^'']+)''').Groups[1].Value
|
||||
$RelayKey = [regex]::Match($t, '\$RelayKey = ''([^'']+)''').Groups[1].Value
|
||||
$RelayToken = [regex]::Match($t, '\$RelayToken = ''([^'']+)''').Groups[1].Value
|
||||
$Headers = @{ 'x-relay-token' = $RelayToken; 'x-igneum-key' = $RelayKey }
|
||||
# earlier client folders on this PC (another machine's secret, or an old copy): found for the clean-up below
|
||||
$clients = @()
|
||||
$roots = @((Join-Path $env:USERPROFILE 'Downloads'), (Join-Path $env:USERPROFILE 'Desktop'))
|
||||
try {
|
||||
$tr = & schtasks.exe /Query /TN IgneumRelayAgent /V /FO LIST 2>$null | ForEach-Object { "$_" } | Where-Object { $_ -match '^Task To Run:' }
|
||||
if ($tr) { $m = [regex]::Match("$tr", '([A-Za-z]:\\[^"]*?igneum-agent\.bat)'); if ($m.Success) { $roots = @((Split-Path -Parent $m.Groups[1].Value)) + $roots; Say ('the stale task points at ' + $m.Groups[1].Value) } }
|
||||
} catch { }
|
||||
foreach ($root in $roots) {
|
||||
if ($root -and (Test-Path $root)) { $clients += @(Get-ChildItem -Path $root -Recurse -Depth 4 -Filter 'igneum-agent.ps1' -ErrorAction SilentlyContinue) }
|
||||
}
|
||||
|
||||
# 3. the stale one-shot tasks (install-agent.ps1 removes IgneumRelayAgent* itself) and the stale client folders that
|
||||
# carried another machine's secret; the state folder's machine.txt goes so the new registration names this PC
|
||||
foreach ($c in $clients) {
|
||||
$dir = $c.DirectoryName
|
||||
if ($dir -like ($dest + '*')) { continue }
|
||||
try { Remove-Item -Path $dir -Recurse -Force -ErrorAction Stop; Say ('removed the stale client folder ' + $dir) } catch { Say ('could not remove ' + $dir + ': ' + $_.Exception.Message) }
|
||||
}
|
||||
Get-ChildItem -Path (Join-Path $env:USERPROFILE 'Downloads') -Filter 'igneum-relay-clients*.zip' -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue; Say ('removed ' + $_.Name) }
|
||||
Remove-Item (Join-Path $stateDir 'machine.txt') -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# 4. register and start the task (per user, LeastPrivilege, no prompt; a failure is a line here, never a dialog)
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $dest 'install-agent.ps1') 2>&1 | ForEach-Object { "$_" } | ForEach-Object { Say ('install-agent: ' + $_) }
|
||||
Say ('install-agent exit ' + $LASTEXITCODE)
|
||||
& schtasks.exe /Query /FO CSV /NH 2>$null | ForEach-Object { "$_" } | Where-Object { $_ -match 'IgneumRelay' } | ForEach-Object { Say ('task: ' + $_) }
|
||||
|
||||
# 5. the relay's machine list: this PC must be seen inside a minute
|
||||
$seen = ''
|
||||
$deadline = (Get-Date).AddSeconds(75)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$m = Invoke-RestMethod -Uri ($RelayUrl + '/api/relay?fn=machines') -Headers $Headers -TimeoutSec 30
|
||||
$mine = @($m.machines | Where-Object { $_.hostname -and ($_.hostname -like ($env:COMPUTERNAME + '*')) })
|
||||
$fresh = @($mine | Where-Object { $_.last_seen -and (([DateTime]$_.last_seen).ToUniversalTime() -gt (Get-Date).ToUniversalTime().AddSeconds(-90)) })
|
||||
if ($fresh.Count -gt 0) { $seen = ($fresh | ForEach-Object { 'RESULT machine ' + $_.name + ' hostname ' + $_.hostname + ' role ' + $_.role + ' last_seen ' + $_.last_seen }) -join "`n"; break }
|
||||
} catch { Say ('machines: ' + $_.Exception.Message) }
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
if ($seen) { Write-Host $seen } else { Say 'RESULT machine: this PC was not seen on the relay inside 75 s (read the agent-service.log lines below)' }
|
||||
$slog = Join-Path $stateDir 'logs\agent-service.log'
|
||||
if (Test-Path $slog) { Say '--- agent-service.log (last 25)'; Get-Content $slog -Tail 25 }
|
||||
|
||||
# 6. the app's state (api/state read only) and what its window is
|
||||
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
|
||||
if (Test-Path $urlFile) {
|
||||
try {
|
||||
$st = Invoke-RestMethod -Uri ((Get-Content $urlFile -Raw).Trim() + 'api/state') -TimeoutSec 5 -UseBasicParsing
|
||||
Say ('RESULT app version ' + $st.version + ' phase ' + $st.phase + ' mining ' + $st.mining.state + ' node ' + $st.node.state + ' blocks ' + $st.node.blocks + ' peers ' + $st.node.peers + ' up ' + $st.uptime_s + ' s')
|
||||
} catch { Say ('app state: ' + $_.Exception.Message) }
|
||||
}
|
||||
foreach ($p in @(Get-Process -Name 'Igneum Miner', 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue)) { Say ('RESULT process ' + $p.ProcessName + ' pid ' + $p.Id + ' started ' + $p.StartTime.ToString('o') + ' window "' + $p.MainWindowTitle + '"') }
|
||||
try {
|
||||
$wv = Get-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' -ErrorAction SilentlyContinue
|
||||
if ($wv) { Say ('RESULT webview2 runtime ' + $wv.pv) } else { Say 'RESULT webview2 runtime: not found in the registry (the dashboard would open in the browser and the window would stay dark)' }
|
||||
} catch { }
|
||||
Say 'agent install done'
|
||||
exit 0
|
||||
30
relay/playbooks/boot-check.ps1
Normal file
30
relay/playbooks/boot-check.ps1
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Was this boot a power loss or a hard reset? (MF-11, 7 October 2026.) Reads the current boot's time, the Kernel-Power 41
|
||||
# and EventLog 6008 entries since it, the last clean-shutdown marks (1074, 6006) before it, and the video controllers
|
||||
# (an eGPU just fitted shows here). Read-only; nothing is sent to the installed app. Every finding is a RESULT line.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
function Say([string]$m) { Write-Host ((Get-Date -Format 'HH:mm:ss') + ' ' + $m) }
|
||||
$os = Get-CimInstance Win32_OperatingSystem
|
||||
$boot = $os.LastBootUpTime
|
||||
$bootUtc = $boot.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.000Z')
|
||||
$beforeUtc = $boot.AddHours(-6).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.000Z')
|
||||
Say ('RESULT boot ' + $boot.ToUniversalTime().ToString('o') + ' UTC (local ' + $boot.ToString('o') + ')')
|
||||
function Q([string]$xpath, [int]$count) {
|
||||
try { & wevtutil.exe qe System ('/q:' + $xpath) /f:text ('/c:' + $count) /rd:true 2>&1 | ForEach-Object { "$_" } } catch { @('wevtutil failed: ' + $_.Exception.Message) }
|
||||
}
|
||||
$lines = Q ("*[System[(EventID=41 or EventID=6008) and TimeCreated[@SystemTime>='" + $bootUtc + "']]]") 4
|
||||
$ids = @($lines | Where-Object { $_ -match '^\s*Event ID: (\d+)' } | ForEach-Object { $Matches[1] })
|
||||
$dates = @($lines | Where-Object { $_ -match '^\s*Date: (\S+)' } | ForEach-Object { $Matches[1] })
|
||||
if ($ids.Count -gt 0) { Say ('RESULT unexpected-shutdown YES: event ' + ($ids -join ', ') + ' at ' + ($dates -join ', ') + ' (this boot followed a power loss, a hard reset or a hang; no clean shutdown was recorded)') }
|
||||
else { Say 'RESULT unexpected-shutdown NO: neither 41 nor 6008 since this boot (a clean shutdown or restart preceded it)' }
|
||||
$clean = Q ("*[System[(EventID=1074 or EventID=6006 or EventID=13) and TimeCreated[@SystemTime>='" + $beforeUtc + "'] and TimeCreated[@SystemTime<='" + $bootUtc + "']]]") 6
|
||||
$cids = @($clean | Where-Object { $_ -match '^\s*Event ID: (\d+)' } | ForEach-Object { $Matches[1] })
|
||||
$cdates = @($clean | Where-Object { $_ -match '^\s*Date: (\S+)' } | ForEach-Object { $Matches[1] })
|
||||
if ($cids.Count -gt 0) { Say ('RESULT clean-shutdown-before-boot: event ' + ($cids -join ', ') + ' at ' + ($cdates -join ', ')) } else { Say 'RESULT clean-shutdown-before-boot: none in the six hours before this boot' }
|
||||
$who = Q ("*[System[Provider[@Name='User32'] and (EventID=1074) and TimeCreated[@SystemTime>='" + $beforeUtc + "']]]") 2
|
||||
$who | Where-Object { $_ -match 'Description|process|reason|user' } | Select-Object -First 6 | ForEach-Object { Say (' 1074: ' + $_.Trim()) }
|
||||
$bug = Q ("*[System[(EventID=1001) and Provider[@Name='Microsoft-Windows-WER-SystemErrorReporting'] and TimeCreated[@SystemTime>='" + $beforeUtc + "']]]") 2
|
||||
if (@($bug | Where-Object { $_ -match 'Event ID: 1001' }).Count -gt 0) { Say 'RESULT bugcheck 1001 recorded since six hours before this boot' } else { Say 'RESULT bugcheck: none' }
|
||||
try { Get-CimInstance Win32_VideoController | ForEach-Object { Say ('RESULT video ' + $_.Name + ' driver ' + $_.DriverVersion + ' status ' + $_.Status + ' pnp ' + $_.PNPDeviceID) } } catch { }
|
||||
try { & nvidia-smi --query-gpu=index,name,pci.bus_id,driver_version --format=csv,noheader 2>&1 | ForEach-Object { Say ('RESULT nvidia ' + "$_") } } catch { }
|
||||
Say ('RESULT uptime ' + [int]((Get-Date) - $boot).TotalSeconds + ' s')
|
||||
exit 0
|
||||
36
relay/playbooks/freeze-check.ps1
Normal file
36
relay/playbooks/freeze-check.ps1
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# A freeze, not a power drop: what shape? (PC 2, 7 October 2026, 15:10 UK, the first boot with an RTX 5060 Ti in a
|
||||
# Thunderbolt enclosure.) Reads, since 14:00Z: the display driver's TDR events (Display 4101, nvlddmkm), WHEA 17/18/19/20,
|
||||
# Thunderbolt and PCI Express link events (the Thunderbolt providers, Kernel-PnP, pci: "link down"), the clean/unclean
|
||||
# boot marks (41, 6008, 1074, 6006), the installed app's last lines before the freeze (the second-newest app log), and the
|
||||
# GPUs with their bus ids. Read-only; nothing is sent to the installed app. Every finding is a RESULT line.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
function Say([string]$m) { Write-Host ((Get-Date -Format 'HH:mm:ss') + ' ' + $m) }
|
||||
$since = '2026-10-07T14:00:00.000Z'
|
||||
$time = "TimeCreated[@SystemTime>='" + $since + "']"
|
||||
function Q([string]$log, [string]$xpath, [int]$count) {
|
||||
try { & wevtutil.exe qe $log ('/q:' + $xpath) /f:text ('/c:' + $count) 2>&1 | ForEach-Object { "$_" } } catch { @('wevtutil failed: ' + $_.Exception.Message) }
|
||||
}
|
||||
function Count([string[]]$lines) { return @($lines | Where-Object { $_ -match '^\s*Event ID: ' }).Count }
|
||||
function Show([string]$label, [string[]]$lines, [int]$max) {
|
||||
$n = Count $lines
|
||||
Say ('RESULT ' + $label + ': ' + $n + ' event(s)')
|
||||
$shown = 0
|
||||
foreach ($l in $lines) { if ($l -match '^\s*(Date|Event ID|Source|Level|Description):' -or ($l -match '^\s{4}\S' -and $l.Length -lt 220)) { Say (' ' + $l.Trim()); $shown++ }; if ($shown -ge $max) { break } }
|
||||
}
|
||||
$os = Get-CimInstance Win32_OperatingSystem
|
||||
Say ('RESULT boot ' + $os.LastBootUpTime.ToUniversalTime().ToString('o') + ' UTC, uptime ' + [int]((Get-Date) - $os.LastBootUpTime).TotalSeconds + ' s')
|
||||
Show 'boot marks 41/6008/1074/6006 since 14:00Z' (Q 'System' ("*[System[" + $time + " and (EventID=41 or EventID=6008 or EventID=1074 or EventID=6006)]]") 8) 40
|
||||
Show 'display TDR (Display 4101, nvlddmkm, Kernel-Power 505/506)' (Q 'System' ("*[System[" + $time + " and (Provider[@Name='Display'] or Provider[@Name='nvlddmkm'] or Provider[@Name='amdkmdag'] or EventID=4101)]]") 20) 60
|
||||
Show 'WHEA 17/18/19/20/47' (Q 'System' ("*[System[" + $time + " and Provider[@Name='Microsoft-Windows-WHEA-Logger']]]") 20) 60
|
||||
Show 'Thunderbolt and PCIe link (Thunderbolt providers, Kernel-PnP, pci, PCIe)' (Q 'System' ("*[System[" + $time + " and (Provider[@Name='Microsoft-Windows-Thunderbolt'] or Provider[@Name='ThunderboltService'] or Provider[@Name='Microsoft-Windows-Kernel-PnP'] or Provider[@Name='pci'] or Provider[@Name='PCI'] or Provider[@Name='Microsoft-Windows-Kernel-Pnp'] or Provider[@Name='Microsoft-Windows-Kernel-Power'])]]") 60) 120
|
||||
Show 'every critical and error in System since 14:00Z' (Q 'System' ("*[System[" + $time + " and (Level=1 or Level=2)]]") 40) 120
|
||||
Show 'Application errors since 14:00Z (Application Error, WER, Igneum)' (Q 'Application' ("*[System[" + $time + " and (Level=1 or Level=2)]]") 20) 60
|
||||
Say 'RESULT GPUs:'
|
||||
try { Get-CimInstance Win32_VideoController | ForEach-Object { Say (' video ' + $_.Name + ' driver ' + $_.DriverVersion + ' status ' + $_.Status + ' pnp ' + $_.PNPDeviceID) } } catch { }
|
||||
try { & nvidia-smi --query-gpu=index,name,pci.bus_id,driver_version,pstate,temperature.gpu --format=csv,noheader 2>&1 | ForEach-Object { Say (' nvidia ' + "$_") } } catch { }
|
||||
try { Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | Where-Object { $_.FriendlyName -match 'Thunderbolt|Razer|Core X|PCI Express Root' } | ForEach-Object { Say (' pnp ' + $_.Status + ' ' + $_.Class + ' ' + $_.FriendlyName + ' ' + $_.InstanceId) } } catch { }
|
||||
$logs = Join-Path $env:LOCALAPPDATA 'igneum\logs'
|
||||
$files = @(Get-ChildItem $logs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 2)
|
||||
if ($files.Count -ge 2) { $f = $files[1]; Say ('RESULT app log before the freeze: ' + $f.Name + ' (last write ' + $f.LastWriteTime.ToUniversalTime().ToString('o') + ' UTC), last 40 lines:'); Get-Content $f.FullName -Tail 40 -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch 'TEMPLATE|\[block\]|ACCEPTED' } | ForEach-Object { Say (' ' + $_) } }
|
||||
Say 'freeze check done'
|
||||
exit 0
|
||||
|
|
@ -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
|
||||
|
|
|
|||
82
relay/playbooks/pc2-crash-collect.ps1
Normal file
82
relay/playbooks/pc2-crash-collect.ps1
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# What stopped PC 2 at 10:46Z on 7 October 2026 (MF-11): every stream (app, node, miner) went quiet mid-line twelve
|
||||
# minutes after the 0.3.19 update returned. Read-only collection, printed to the job report: the System and
|
||||
# Application event logs from 10:00Z (boot, power, bugcheck, WHEA, display drivers first), the minidump folder and
|
||||
# the latest dump's bugcheck code, the app's last two logs and OTA files, the relay agent's logs and task, the GPU
|
||||
# drivers and the reliability records. Queued as a signed `run` job (packaging/ota/publish-jobs.sh); it touches
|
||||
# nothing and sends nothing to the installed app.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$sinceUtc = '2026-10-07T10:00:00.000Z'
|
||||
function Section([string]$t) { Write-Host ''; Write-Host ('== ' + $t) }
|
||||
function Q([string]$log, [string]$xpath, [int]$count) {
|
||||
try { & wevtutil.exe qe $log ('/q:' + $xpath) /f:text ('/c:' + $count) 2>&1 | ForEach-Object { "$_" } } catch { Write-Host ('wevtutil failed: ' + $_.Exception.Message) }
|
||||
}
|
||||
$time = "TimeCreated[@SystemTime>='" + $sinceUtc + "']"
|
||||
|
||||
Section ('now ' + (Get-Date -Format o) + ' (local), ' + [DateTime]::UtcNow.ToString('o') + ' UTC')
|
||||
try { Get-CimInstance Win32_OperatingSystem | Select-Object LastBootUpTime, Caption, BuildNumber | Format-List | Out-String | Write-Host } catch {}
|
||||
|
||||
Section 'boot and shutdown events since 10:00Z (41 Kernel-Power, 1001 BugCheck, 6005 6006 6008 Event Log, 1074 User32, 12 13 Kernel-General)'
|
||||
Q 'System' ("*[System[" + $time + " and (EventID=41 or EventID=1001 or EventID=6005 or EventID=6006 or EventID=6008 or EventID=1074 or EventID=12 or EventID=13)]]") 60
|
||||
|
||||
Section 'System by source: Kernel-Power, BugCheck, WHEA, nvlddmkm, amdkmdag, Display, Kernel-General'
|
||||
Q 'System' ("*[System[" + $time + " and Provider[@Name='Microsoft-Windows-Kernel-Power' or @Name='Microsoft-Windows-WER-SystemErrorReporting' or @Name='Microsoft-Windows-WHEA-Logger' or @Name='nvlddmkm' or @Name='amdkmdag' or @Name='Display' or @Name='Microsoft-Windows-Kernel-General' or @Name='volmgr' or @Name='disk']]]") 200
|
||||
|
||||
Section 'System: every critical and error since 10:00Z'
|
||||
Q 'System' ("*[System[" + $time + " and (Level=1 or Level=2)]]") 200
|
||||
|
||||
Section 'Application: every critical and error since 10:00Z (Application Error, WER, .NET, Igneum)'
|
||||
Q 'Application' ("*[System[" + $time + " and (Level=1 or Level=2)]]") 200
|
||||
|
||||
Section 'Application: Igneum and PowerShell sources since 10:00Z'
|
||||
Q 'Application' ("*[System[" + $time + " and Provider[@Name='Igneum Miner' or @Name='igneum-app' or @Name='PowerShell' or @Name='Windows Error Reporting']]]") 100
|
||||
|
||||
Section 'minidumps'
|
||||
$mdDir = Join-Path $env:SystemRoot 'Minidump'
|
||||
try { Get-ChildItem $mdDir -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | Format-Table Name, Length, LastWriteTime -AutoSize | Out-String | Write-Host } catch {}
|
||||
try { Get-Item (Join-Path $env:SystemRoot 'MEMORY.DMP') -ErrorAction SilentlyContinue | Format-Table Name, Length, LastWriteTime -AutoSize | Out-String | Write-Host } catch {}
|
||||
$dump = Get-ChildItem $mdDir -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($dump) {
|
||||
# the 64-bit dump header: 'PAGEDU64', the bugcheck code at 0x38 and its four parameters at 0x40, 0x48, 0x50, 0x58
|
||||
try {
|
||||
$fs = [IO.File]::OpenRead($dump.FullName)
|
||||
$hdr = New-Object byte[] 4096
|
||||
$n = $fs.Read($hdr, 0, 4096)
|
||||
$fs.Close()
|
||||
$sig = [Text.Encoding]::ASCII.GetString($hdr, 0, 8)
|
||||
$code = [BitConverter]::ToUInt32($hdr, 0x38)
|
||||
$p = @(0x40, 0x48, 0x50, 0x58) | ForEach-Object { ('0x{0:x}' -f [BitConverter]::ToUInt64($hdr, $_)) }
|
||||
Write-Host ('latest dump ' + $dump.Name + ' (' + $dump.LastWriteTime.ToString('o') + '): header ' + $sig + ', bugcheck 0x' + ('{0:x}' -f $code) + ' params ' + ($p -join ' '))
|
||||
} catch { Write-Host ('dump header not read: ' + $_.Exception.Message) }
|
||||
} else { Write-Host 'no minidump on this PC' }
|
||||
|
||||
Section 'reliability records (last 40, newest first)'
|
||||
try { Get-CimInstance Win32_ReliabilityRecords -ErrorAction SilentlyContinue | Sort-Object TimeGenerated -Descending | Select-Object -First 40 TimeGenerated, SourceName, EventIdentifier, ProductName, Message | Format-List | Out-String -Width 220 | Write-Host } catch { Write-Host ('no reliability records: ' + $_.Exception.Message) }
|
||||
|
||||
Section 'GPU drivers'
|
||||
try { Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion, DriverDate, Status, VideoProcessor | Format-List | Out-String | Write-Host } catch {}
|
||||
try { & nvidia-smi --query-gpu=name,driver_version,pstate,temperature.gpu --format=csv 2>&1 | ForEach-Object { "$_" } } catch {}
|
||||
try { & powercfg.exe /lastwake 2>&1 | ForEach-Object { "$_" } } catch {}
|
||||
try { & powercfg.exe /requests 2>&1 | ForEach-Object { "$_" } } catch {}
|
||||
|
||||
Section 'app logs: the last three runs (tail 80 each)'
|
||||
$logs = Join-Path $env:LOCALAPPDATA 'igneum\logs'
|
||||
$files = @(Get-ChildItem $logs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 3)
|
||||
$files | Format-Table Name, Length, LastWriteTime -AutoSize | Out-String | Write-Host
|
||||
foreach ($f in $files) { Write-Host ('--- ' + $f.Name + ' (last write ' + $f.LastWriteTime.ToString('o') + ')'); Get-Content $f.FullName -Tail 80 -ErrorAction SilentlyContinue }
|
||||
|
||||
Section 'OTA files in the app folder'
|
||||
$app = Join-Path $env:LOCALAPPDATA 'igneum\app'
|
||||
foreach ($n in @('ota-apply.log', 'ota-setup.log', 'update-pending.json', 'update-result.json', 'failed-versions.json', 'jobs-state.json')) {
|
||||
$p = Join-Path $app $n
|
||||
if (Test-Path $p) { Write-Host ('--- ' + $n + ' (' + (Get-Item $p).LastWriteTime.ToString('o') + ')'); Get-Content $p -Tail 40 -ErrorAction SilentlyContinue }
|
||||
}
|
||||
Write-Host ('start at login: ' + (Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty 'Igneum Miner' -ErrorAction SilentlyContinue))
|
||||
|
||||
Section 'relay agent: logs, scheduled task, running shells'
|
||||
$rl = Join-Path $env:LOCALAPPDATA 'igneum-relay'
|
||||
try { Get-ChildItem (Join-Path $rl 'logs') -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 6 Name, Length, LastWriteTime | Format-Table -AutoSize | Out-String | Write-Host } catch {}
|
||||
foreach ($n in @('state.json', 'machine.txt')) { $p = Join-Path $rl $n; if (Test-Path $p) { Write-Host ('--- ' + $n); Get-Content $p -ErrorAction SilentlyContinue } }
|
||||
try { & schtasks.exe /Query /TN IgneumRelayAgent /V /FO LIST 2>&1 | Select-Object -First 14 | ForEach-Object { "$_" } } catch {}
|
||||
try { & schtasks.exe /Query /TN IgneumRelayService /V /FO LIST 2>&1 | Select-Object -First 14 | ForEach-Object { "$_" } } catch {}
|
||||
try { Get-Process -Name powershell, cmd, 'Igneum Miner', igneum-app, igneumd -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, StartTime | Format-Table -AutoSize | Out-String | Write-Host } catch {}
|
||||
Write-Host 'collection done'
|
||||
|
|
@ -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 = 'josh'
|
||||
$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'
|
||||
|
|
|
|||
35
relay/playbooks/start-app.ps1
Normal file
35
relay/playbooks/start-app.ps1
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# The body of a start-app task (tools/relay.mjs start-app <machine>; MF-11, 7 October 2026). An agent from v3 on never
|
||||
# runs this text: its own Start-App does the same thing. An agent from before the kind runs it as a `run` task, which is
|
||||
# why it is here. Starts the installed Igneum Miner as this user and reads back that an engine answers api/state.
|
||||
# It never sends quit, pause or resume to the installed app (the standing rule of 5 October 2026).
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$dir = ''
|
||||
foreach ($d in @((Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner'), (Join-Path $env:ProgramFiles 'Igneum Miner'))) {
|
||||
if (Test-Path (Join-Path $d 'igneum-app.exe')) { $dir = $d; break }
|
||||
}
|
||||
if (-not $dir) { Write-Host 'start-app: igneum-app.exe not found'; exit 2 }
|
||||
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
|
||||
function App-Version {
|
||||
if (-not (Test-Path $urlFile)) { return '' }
|
||||
try { $u = (Get-Content $urlFile -Raw).Trim(); $st = Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing; return [string]$st.version } catch { return '' }
|
||||
}
|
||||
$v = App-Version
|
||||
if ($v) { Write-Host ('start-app: an engine already answers (app ' + $v + '); nothing started'); exit 0 }
|
||||
$exe = Join-Path $dir 'igneum-app.exe'
|
||||
$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if ($admin) {
|
||||
& schtasks.exe /Create /F /TN 'IgneumStartApp' /SC ONCE /ST 00:00 /RL LIMITED /TR ('"' + $exe + '" --launch') 2>&1 | Out-Null
|
||||
& schtasks.exe /Run /TN 'IgneumStartApp' 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds 3
|
||||
& schtasks.exe /Delete /F /TN 'IgneumStartApp' 2>&1 | Out-Null
|
||||
} else {
|
||||
# console: igneum-app.exe is a windows-subsystem program (no console); the window host it opens is its own
|
||||
Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $dir | Out-Null
|
||||
}
|
||||
$t0 = Get-Date
|
||||
$deadline = (Get-Date).AddSeconds(90)
|
||||
while ((Get-Date) -lt $deadline) { $v = App-Version; if ($v) { break }; Start-Sleep -Seconds 3 }
|
||||
$s = [int]((Get-Date) - $t0).TotalSeconds
|
||||
if ($v) { Write-Host ('start-app: app ' + $v + ' answered api/state after ' + $s + ' s'); exit 0 }
|
||||
Write-Host 'start-app: no engine answered api/state inside 90 s'
|
||||
exit 3
|
||||
|
|
@ -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
|
||||
|
|
|
|||
120
relay/test/clients.test.mjs
Normal file
120
relay/test/clients.test.mjs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// 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}`);
|
||||
if (/substituted at publish time/.test(s)) continue; // a jobs-channel body (packaging/ota/publish-jobs.sh fills it), never a relay run
|
||||
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' });
|
||||
});
|
||||
275
relay/test/handler.test.mjs
Normal file
275
relay/test/handler.test.mjs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
// 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 IN \('run', 'start-app'\) AND flags->>'nonce' = \$1/.test(Q)) return items.filter(i => (i.kind === 'run' || i.kind === 'start-app') && 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', 'start-app'].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('MF-11: a start-app task needs the same signature, tag and fresh nonce as a run; it lands in the agent inbox beside run items', async () => {
|
||||
const { call, db } = rig();
|
||||
const secret = newSecret();
|
||||
const bare = await call('POST', { fn: 'task' }, { headers: TOKEN, body: { to: 'PC2', title: 'start', body: '', kind: 'start-app', flags: {} } });
|
||||
assert.equal(bare.code, 401);
|
||||
const good = { ...signedRun('PC2', '# start-app body', secret), kind: 'start-app' };
|
||||
const r = await call('POST', { fn: 'task' }, { headers: TOKEN, body: good });
|
||||
assert.equal(r.code, 200, r.error);
|
||||
assert.equal(db.items[0].kind, 'start-app');
|
||||
assert.equal((await call('POST', { fn: 'task' }, { headers: TOKEN, body: good })).code, 409, 'a replayed nonce');
|
||||
// the relay key tier may not queue it (the console token only), like run
|
||||
assert.equal((await call('POST', { fn: 'task' }, { headers: KEY, body: { ...signedRun('PC2', 'x', secret), kind: 'start-app' } })).code, 403);
|
||||
// the agent asks for run and gets both kinds, in order; a task item is not in that inbox
|
||||
db.seed({ from_machine: 'Mac', to_machine: 'PC2', kind: 'task', title: 't', body: 'for a person' });
|
||||
const inbox = await call('POST', { fn: 'inbox' }, { headers: KEY, body: { machine: 'PC2', kind: 'run', ack: true } });
|
||||
assert.equal(inbox.code, 200);
|
||||
assert.deepEqual(inbox.items.map(i => i.kind), ['start-app']);
|
||||
assert.equal(inbox.acked, 1);
|
||||
// a kind the relay does not know becomes a plain task, never an agent kind
|
||||
const odd = await call('POST', { fn: 'task' }, { headers: TOKEN, body: { to: 'PC2', title: 'x', body: 'y', kind: 'reboot-now' } });
|
||||
assert.equal(odd.code, 200);
|
||||
assert.equal(db.items.find(i => i.title === 'x' && i.body === 'y').kind, 'task');
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
112
relay/test/service.test.mjs
Normal file
112
relay/test/service.test.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// node --test relay/test/service.test.mjs The relay agent as a logon task and the start-app kind (MF-11, 7 October
|
||||
// 2026). Structural checks of the manifest, the installer, the hidden loop, the agent's branch and the Mac tool; the
|
||||
// PowerShell 5.1 parse of the .ps1 files is windows.yml's job.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { KINDS, AGENT_KINDS } from '../lib/relay.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const read = p => readFileSync(join(ROOT, p), 'utf8');
|
||||
|
||||
test('the service manifest: at logon, the run level the installer chooses, interactive, restarted on failure, no time limit, one instance, hidden window', () => {
|
||||
const x = read('relay/clients/IgneumRelayService.xml');
|
||||
assert.match(x, /<LogonTrigger>[\s\S]*<UserId>__USER__<\/UserId>[\s\S]*<\/LogonTrigger>/);
|
||||
assert.match(x, /<Principal id="Author">[\s\S]*<UserId>__USER__<\/UserId>[\s\S]*<LogonType>InteractiveToken<\/LogonType>[\s\S]*<RunLevel>__RUNLEVEL__<\/RunLevel>/);
|
||||
const restart = /<RestartOnFailure>\s*<Interval>PT(\d+)M<\/Interval>\s*<Count>(\d+)<\/Count>\s*<\/RestartOnFailure>/.exec(x);
|
||||
assert.ok(restart, 'RestartOnFailure with Interval and Count');
|
||||
assert.ok(Number(restart[1]) <= 5 && Number(restart[2]) >= 100, `restart every ${restart[1]} min, ${restart[2]} times`);
|
||||
assert.match(x, /<ExecutionTimeLimit>PT0S<\/ExecutionTimeLimit>/, 'no time limit');
|
||||
assert.match(x, /<MultipleInstancesPolicy>IgnoreNew<\/MultipleInstancesPolicy>/);
|
||||
assert.match(x, /<DisallowStartIfOnBatteries>false<\/DisallowStartIfOnBatteries>/);
|
||||
assert.match(x, /<StopIfGoingOnBatteries>false<\/StopIfGoingOnBatteries>/);
|
||||
assert.match(x, /<StartWhenAvailable>true<\/StartWhenAvailable>/);
|
||||
assert.match(x, /<Command>powershell\.exe<\/Command>\s*<Arguments>-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "__AGENT_DIR__\\igneum-agent-service\.ps1"<\/Arguments>/);
|
||||
assert.match(x, /<URI>\\IgneumRelayService<\/URI>/);
|
||||
});
|
||||
|
||||
test('the installer fills the manifest and registers, runs and reads back the task; it carries no secret', () => {
|
||||
const s = read('relay/clients/install-agent.ps1');
|
||||
assert.doesNotMatch(s, /#Requires -RunAsAdministrator/, 'a per-user task needs no administrator (PC 2 has nobody to click a prompt)');
|
||||
assert.match(s, /\.Replace\('__RUNLEVEL__', \$level\)/);
|
||||
assert.match(s, /\$level = 'LeastPrivilege'[\s\S]*if \(\$Highest\) \{ \$level = 'HighestAvailable' \}/);
|
||||
// the stale one-shot tasks (PC 2, 7 October 2026: a logon task at a path that was not there popped a dialog at every boot) go first
|
||||
assert.match(s, /IgneumRelayAgent\*/);
|
||||
assert.match(s, /schtasks\.exe \/Delete \/F \/TN \$t/);
|
||||
assert.match(s, /RunOnce/);
|
||||
assert.match(s, /\.Replace\('__USER__', \$user\)\.Replace\('__AGENT_DIR__', \$Here\)/);
|
||||
assert.match(s, /schtasks\.exe \/Create \/F \/TN \$TaskName \/XML \$tmp/);
|
||||
assert.match(s, /\$TaskName = 'IgneumRelayService'/);
|
||||
assert.match(s, /schtasks\.exe \/Run \/TN \$TaskName/);
|
||||
assert.match(s, /schtasks\.exe \/Query \/TN \$TaskName/);
|
||||
assert.match(s, /machine-secret\.txt/, 'the zip must carry the machine secret for the agent to run anything');
|
||||
assert.doesNotMatch(s, /__RELAY_(URL|KEY|TOKEN)__|x-relay-token|x-igneum-key/, 'the installer touches no secret');
|
||||
assert.match(s, /-Remove/);
|
||||
});
|
||||
|
||||
test('the hidden loop keeps the agent running for ever, 15 s after any exit, logs without the idle line, and a missing agent path is a log line, never a dialog', () => {
|
||||
const s = read('relay/clients/igneum-agent-service.ps1');
|
||||
// the task runs the loop by its full path under -WindowStyle Hidden (the manifest), and the loop itself opens nothing:
|
||||
// a missing igneum-agent.ps1 is one line in the log and a 60 s wait, no `cmd /c start`, no message box, no exit
|
||||
const missing = s.slice(s.indexOf('if (-not (Test-Path $Agent))'), s.indexOf('Note \'starting igneum-agent.ps1\''));
|
||||
assert.match(missing, /Note \('no agent at ' \+ \$Agent \+ '; waiting'\); Start-Sleep -Seconds 60; continue/);
|
||||
assert.doesNotMatch(s, /cmd(\.exe)? \/c start|MessageBox|Read-Host|\[System\.Windows\.Forms/);
|
||||
assert.match(s, /-File \$Agent/, 'the agent runs by its full path');
|
||||
assert.match(s, /ForEach-Object \{ Add-Content -Path \$log -Value \$_ \}/, 'one write per line: the log is never held open');
|
||||
assert.match(s, /\$Agent = Join-Path \$Here 'igneum-agent\.ps1'/);
|
||||
assert.match(s, /while \(\$true\) \{/);
|
||||
assert.match(s, /powershell\.exe -NoProfile -ExecutionPolicy Bypass -File \$Agent/);
|
||||
assert.match(s, /Start-Sleep -Seconds 15/);
|
||||
assert.match(s, /-notmatch 'idle as '/);
|
||||
assert.match(s, /agent-service\.log/);
|
||||
assert.doesNotMatch(s, /api\/(quit|pause|resume)/);
|
||||
});
|
||||
|
||||
test('the agent runs a start-app task through its own Start-App after the tag check, never the body, never elevated, never a quit', () => {
|
||||
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 nonce = run.indexOf('Add-Content -Path $NonceFile -Value $task.flags.nonce');
|
||||
const branch = run.indexOf("if (\"$($task.kind)\" -eq 'start-app') {");
|
||||
const script = run.indexOf('[IO.File]::WriteAllText($script');
|
||||
assert.ok(check > 0 && nonce > check && branch > nonce && script > branch, 'tag check, nonce recorded, the start-app branch, and only then a script is written');
|
||||
assert.match(run, /\$r = Start-App[\s\S]*Post-Result \$task \(\[int\]\$r\.code\) \$log \$r\.note[\s\S]*return\s*\}/);
|
||||
const start = s.slice(s.indexOf('function Start-App'), s.indexOf('function Sha256-Hex'));
|
||||
assert.match(start, /\/RL LIMITED/, 'an elevated agent starts the app at the limited run level');
|
||||
assert.match(start, /Start-Process -FilePath \$exe -ArgumentList '--launch'/);
|
||||
assert.match(start, /api\/state/);
|
||||
assert.doesNotMatch(start, /api\/(quit|pause|resume|cards)/);
|
||||
assert.doesNotMatch(start, /\$task\.body|Invoke-Expression/);
|
||||
assert.match(s, /agent = 'igneum-agent\.ps1 v3'/);
|
||||
// the registration carries the app's per-install id, so two PCs with one Windows hostname are two machines
|
||||
assert.match(s, /Api-Post 'register' @\{ hostname = \(Machine-Hostname\); info = \$info \}/);
|
||||
assert.match(s, /igneum\\app\\machine-id/);
|
||||
assert.match(s, /\$id\.Substring\(0, 8\)/);
|
||||
// the one-shot reboot arm is still the only task the agent removes: the service task is never touched
|
||||
const disarm = s.slice(s.indexOf('function Disarm-Restart'), s.indexOf('function Sha256-Hex'));
|
||||
assert.doesNotMatch(disarm, /IgneumRelayService/);
|
||||
assert.match(disarm, /schtasks\.exe \/Delete \/F \/TN 'IgneumRelayAgent'/);
|
||||
});
|
||||
|
||||
test('the start-app body for older agents does the same thing and never quits the installed app', () => {
|
||||
const s = read('relay/playbooks/start-app.ps1');
|
||||
assert.match(s, /api\/state/);
|
||||
assert.match(s, /\/RL LIMITED/);
|
||||
assert.doesNotMatch(s, /api\/(quit|pause|resume|cards)/);
|
||||
assert.doesNotMatch(s, /__DL_BASE__/);
|
||||
});
|
||||
|
||||
test('the kind is known to the relay, the Mac tool signs it, and the zip carries the service files', () => {
|
||||
assert.ok(KINDS.has('start-app'));
|
||||
assert.deepEqual(AGENT_KINDS, ['run', 'start-app']);
|
||||
const m = read('tools/relay.mjs');
|
||||
assert.match(m, /cmd === 'start-app'/);
|
||||
assert.match(m, /kind: 'start-app', body, flags: \{ elevated: false/);
|
||||
const sa = m.slice(m.indexOf("cmd === 'start-app'"), m.indexOf("cmd === 'keygen'"));
|
||||
assert.match(sa, /signRunTask\(o\)/, 'start-app is signed and tagged like run');
|
||||
assert.match(sa, /'start-app\.ps1'\)/);
|
||||
const mk = read('relay/clients/make-clients.sh');
|
||||
for (const f of ['igneum-agent-service.ps1', 'install-agent.ps1', 'IgneumRelayService.xml']) assert.match(mk, new RegExp(f.replace('.', '\\.')), `${f} is not in the zip`);
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
// node tools/console.mjs sync-hetzner push the newest infra/cloud-devnet/results/ summary
|
||||
// node tools/console.mjs sync all three syncs
|
||||
// node tools/console.mjs url the console URL
|
||||
// Reads ~/.config/igneum/relay-token (the URL secret), relay-key (x-igneum-key), relay-url (optional),
|
||||
// Reads ~/.config/igneum/relay-token (sent as the x-relay-token header, never in a URL: X24), relay-url (optional),
|
||||
// dl-token and dlsite-dir (sync-dl). Zero dependencies.
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
|
@ -21,11 +21,12 @@ import { fileURLToPath } from 'node:url';
|
|||
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const cfg = n => { try { return readFileSync(join(homedir(), '.config', 'igneum', n), 'utf8').trim(); } catch { return ''; } };
|
||||
const TOKEN = cfg('relay-token'); const KEY = cfg('relay-key'); // the relay's own key since 4 Oct 2026 (round 4, X23)
|
||||
const TOKEN = cfg('relay-token');
|
||||
const BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(/\/$/, '');
|
||||
if (!TOKEN) { console.error('no ~/.config/igneum/relay-token'); process.exit(1); }
|
||||
const API = `${BASE}/r/${TOKEN}/c/`;
|
||||
const WEB = `${BASE}/r/${TOKEN}/`;
|
||||
const API = `${BASE}/api/console?fn=`; // the function itself; the token travels in the header
|
||||
const WEB = `${BASE}/r/${TOKEN}/`; // the phone's page: the one place the token stays in the path
|
||||
const HEADERS = { 'x-relay-token': TOKEN };
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const flags = {}; const pos = [];
|
||||
|
|
@ -37,8 +38,8 @@ for (let i = 0; i < argv.length; i++) {
|
|||
const cmd = pos[0] || 'log';
|
||||
|
||||
async function api(fn, { q, body } = {}) {
|
||||
const r = await fetch(API + fn + (q ? '?' + new URLSearchParams(q) : ''), body === undefined ? { headers: { 'x-igneum-key': KEY } }
|
||||
: { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-igneum-key': KEY }, body: JSON.stringify(body) });
|
||||
const r = await fetch(API + fn + (q ? '&' + new URLSearchParams(q) : ''), body === undefined ? { headers: HEADERS }
|
||||
: { method: 'POST', headers: { 'Content-Type': 'application/json', ...HEADERS }, body: JSON.stringify(body) });
|
||||
const j = await r.json().catch(() => ({ ok: false, error: `http ${r.status}` }));
|
||||
if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`);
|
||||
return j;
|
||||
|
|
|
|||
|
|
@ -1,43 +1,55 @@
|
|||
#!/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 start-app <machine> the agent there starts the installed Igneum Miner and reports whether an
|
||||
// engine answered (MF-11); signed and tagged like run, body = relay/playbooks/start-app.ps1
|
||||
// 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 { fileURLToPath } from 'node:url';
|
||||
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 +80,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 +135,39 @@ 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 === 'start-app') {
|
||||
const to = pos[1]; if (!to) throw new Error('start-app <machine>');
|
||||
const body = playbook(join(dirname(fileURLToPath(import.meta.url)), '..', 'relay', 'playbooks', 'start-app.ps1'));
|
||||
const o = { from: flags.from || 'Mac', to, title: flags.title || 'start the installed Igneum Miner', kind: 'start-app', body, flags: { elevated: false, reboot_continue: false, reboot: false, shell: 'powershell', script: 'start-app.ps1' } };
|
||||
signRunTask(o);
|
||||
const r = await api('task', { body: o }); console.log(`queued #${r.id} start-app for ${to}: the agent starts the installed app and reports in about a minute (node tools/relay.mjs watch)`);
|
||||
}
|
||||
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 : ''}`); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue