Rig installer for Ubuntu 24.04: install-rig.sh, the six units (node, miner@card, prover, telemetry, update timer), rig-status, the signed-manifest and package checks, the self-test; console accepts linux labels

packaging/linux: preflight (Ubuntu 24.04, NVIDIA driver 580 floor with libcuda and libnvrtc.so.12, AMD ROCm OpenCL ICD, Intel compute runtime, nvidia-smi and the OpenCL list printed, RAM, free disk, ports, ufw), the wallet and rig name asked once, the igneum system user, the Ed25519 check of dl/public/igneum-app-latest.json with the OTA public key (OpenSSL 3 pkeyutl -rawin, python3 cryptography fallback, never skipped), consensus.override written from the verified manifest and refreshed hourly (the HiveOS override rule without a package republish), the HiveOS package downloaded with size and sha256 checked (the signed linux entry when the manifest has one, else the .sha256 sidecar behind --allow-sidecar-sha256, said in capitals), releases under /opt/igneum with a current symlink and a 90-s rollback, one miner unit per card with CUDA_DEVICE_ORDER=PCI_BUS_ID and the OpenCL ordinal mapping, the integrated GPU and the BMC VGA excluded by the inventory, the prover unit as the proving-v1 loop in bash (12 GB gate, 20 GB mine-and-prove line with the card's miner paused per shard through a sudoers rule, idles in state setup while no Linux prover binary is published), telemetry in the app's line shapes with the relay upload under nodelog-linux/miner-<vendor>/linux labels, the identities rule with its 8 GiB threshold.

Tested on the Mac: shellcheck -x -S style clean, bash -n, check-units.sh (6 units, the systemd-analyze stand-in; no systemd or Docker here), the inventory on a fake sysfs tree, the rules, the live manifest verified by both verifiers and tampered copies refused, the installer dry run with the 0.3.9 package downloaded and verified. Untested until a rig exists: listed in README.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 20:37:51 +00:00
parent 19a5e391cf
commit 3799604a87
21 changed files with 1655 additions and 4 deletions

136
packaging/linux/README.md Normal file
View file

@ -0,0 +1,136 @@
# Igneum rig on Ubuntu 24.04
`install-rig.sh` turns an Ubuntu 24.04 machine with up to eight GPUs into an Igneum mining rig run by systemd:
one node, one miner per card (CUDA on NVIDIA, OpenCL on AMD and Intel), a prover unit, a telemetry unit, an hourly
signed-manifest update timer and a `rig-status` command. Built on 5 October 2026 from the HiveOS package
(`packaging/hive`: the hooks, the glibc ceiling, the consensus override rule, the stop path and the sync wait learnt
on PC 1 that night) for the rig the project lead is building (Threadripper PRO, 4 RTX 5090 or 4090, 2 RX 9070 XT, 2 Arc B580,
NVIDIA driver 580 or newer; HiveOS is out because its image's driver predates the RTX 50 series).
**Mining works from the package as published. Proving does not yet: the HiveOS package carries `igneumd`,
`igneum-miner` and the two workers, no `igneum-prove-host`, no `igneum-prove-export`, and its `igneum-miner` has no
`key-hash` or `sign-record` subcommand. The prover unit installs, decides by the per-card rule below, and then idles
in state `setup` naming what is missing. Until a Linux prover build is published, a rig mines only and the proving
share is earned from the app machines.** The same sentence belongs on the miners page (`site/miner.html` says the
card mines and proves) until the package carries the prover.
**Nothing here has run on a rig.** Everything below marked tested ran on this Mac (no systemd, no GPU, no Docker) on
5 October 2026; the "untested until a rig exists" list is the truth of the state.
## What the project lead types
```
git clone <the repo> && cd igneum
sudo packaging/linux/install-rig.sh --wallet 0x<40 hex> --name rig1 --allow-sidecar-sha256 \
[--relay-key-file ~/log-intake-key] [--network devnet|testnet] [--prover auto|on|off]
rig-status
```
Asked once when not given: the payout wallet (0x and 40 hex, an EVM address he holds the key for; lower-cased and
stored) and the rig name (letters, digits, `-` and `_`, up to 32; it labels the rig's vote keys as `<name>-<card>`,
its payouts, and the console card as `<name>-<id8>`). Everything else has a default in `/etc/igneum/rig.conf`
(the file is written once and kept on re-runs; edit it, then `systemctl restart igneum-node`, the miners follow).
`--allow-sidecar-sha256` is needed today: the signed public manifest names only the mac and windows builds, so the
package's sha256 comes from `igneum-downloads.json` and the `.sha256` sidecar on the same TLS host, unsigned. The
installer says so in capitals. The follow-up that removes the flag: `packaging/ota/publish-public.sh --hive` adds a
`platforms.linux` entry (url, sha256, size, kind) to the public app manifest and re-signs it; the apps ignore the
extra key (`manifest.rs parse` reads mac and windows only), and `install-rig.sh` and `igneum-update.sh` already
prefer that entry when it exists. `--package-url --package-sha256 --package-size` is the hand path.
`--relay-key-file` installs the log-intake key (the same upload-only key every app ships) so the rig's journals reach
the console every 60 s; without it nothing leaves the rig. `--preflight-only` runs the checks alone; `--dry-run`
prints every step and only downloads into a scratch folder.
## What the installer assumes
| Assumption | Why | Checked by the preflight |
|---|---|---|
| Ubuntu 24.04 on x86_64 with systemd, OpenSSL 3 (or python3-cryptography), curl, python3, tar, flock, pciutils | the units, the Ed25519 check, the package (built with `GLIBC=2.27`: igneumd 2.27, miner 2.25, workers 2.17, read from the ELFs of 0.3.9) | yes; `--force` goes on anyway |
| NVIDIA driver 580 or newer, `libcuda.so.1` and `libnvrtc.so.12` on the library path | the project lead's floor for the RTX 50 series; the CUDA worker dlopens NVRTC 12 and compiles the hourly program (`infra/cross/build-workers-linux.sh`); a CUDA 13 toolkit's `libnvrtc.so.13` does not satisfy it | yes, failure |
| AMD: amdgpu bound, `libOpenCL.so.1`, an AMD ICD in `/etc/OpenCL/vendors` (ROCm 6.4 or newer for RDNA 4, kernel 6.11 or newer; both approximate) | the OpenCL worker compiles through the ICD | ICD and library failure, versions warning |
| Intel: xe (or i915) bound, `intel-opencl-icd` from Intel's compute-runtime repository (a 2025 release for Battlemage, kernel 6.12 or newer; approximate) | same | ICD failure, kernel warning |
| 20 GB free on /var/lib and /opt; 8 GB RAM to mine, 16 GB to prove | chain data under /var/lib/igneum/node, releases under /opt/igneum; the SP1 host side measured 2.3 GB inside WSL2 on 5 October 2026 (approximate for bare Linux) | yes |
| Ports 26611 (devnet P2P) or 26811 (testnet) free; RPC 26610/26810 and EVM RPC 26790/26890 on loopback | the apps' port layout (`config.rs evm_port` = rpc + 180) | yes; ufw rule added when ufw is active |
| The integrated GPU is not a card: AMD APUs report under 2 GiB of VRAM carve-out, an Intel iGPU sits at `0000:00:02.0`, the BMC's ASPEED VGA is vendor 1a03 | the rule the app's telemetry uses (kind integrated) rebuilt from sysfs; approximate | printed as notes |
| CUDA device index = PCI bus order (`CUDA_DEVICE_ORDER=PCI_BUS_ID` in the units); OpenCL index = the card's position among its vendor's devices in `igneum-worker-opencl --list` | OpenCL lists no bus id; two identical AMD cards are told apart by list order only, as the app does | the list is printed at install |
| Vote keys are derived from labels (`VoteSecretKey::from_label`; `--identities N` gives `<label>-1..N`) | no key store to back up; a rig's keys are its name plus its card ids; a renamed rig has new keys | n/a |
## The units
| Unit | Runs | Notes |
|---|---|---|
| `igneum-node.service` | `igneumd --devnet` (or `--testnet`) with the apps' flags, `--override-params-file /etc/igneum/override-params.json` from the verified manifest's `consensus.override`, the package's own file as the offline fallback | the HiveOS rule: without the override the devnet seed refuses the node (digest mismatch) |
| `igneum-miner@<card>.service` | one `igneum-miner` with `igneum-worker-cuda` or `igneum-worker-opencl`; waits for `synced=true` (SYNC_WAIT 3600 s), exports the pack once for all cards under a lock, re-exports on exit 42 | `PartOf=igneum-node`: a node restart restarts every miner; user igneum in video and render |
| `igneum-prover.service` | the shard loop of `app/igneum-app/src/prover.rs` (proving-v1) in bash: keys from `igneum-miner key-hash`, `igneum_getAssignedShards`, export, cut, `igneum-prove-host --mode compressed` with `SP1_PROVER=cuda` on the chosen card, `sign-record`, `igneum_submitProofRecord`; state in `/run/igneum/prover.state` | not ported: the v1 aggregator step and the paid-shard poll; pauses the card's miner through a sudoers rule when the card is under the mine-and-prove line |
| `igneum-telemetry.service` | every 5 s one line per card: NVIDIA from `nvidia-smi` (the app's query), AMD from the amdgpu sysfs in the exact `gpu-telemetry.c` line, Intel from the xe hwmon in the same shape; every 30 s the app's `status:` line, every 300 s its `stability:` line per card; every 60 s the journal tails to the intake as `nodelog-linux-<id8>`, `miner-<vendor>-<id8>-<n>`, `linux-<id8>` | the console (`relay/lib/parse.mjs`) now accepts the `linux` os; the relay must be redeployed for that |
| `igneum-update.timer` + `.service` | hourly (15 min after boot, 10 min jitter): verify the manifest; a changed `consensus.override` is written and the node restarted; a newer package (signed entry, or sidecar when `PACKAGE_SOURCE=sidecar`) is downloaded, checked, unpacked under `/opt/igneum/releases/<v>`, switched and restarted; rollback when the new node does not answer in 90 s | no restart while `prover.state` says proving; a deferral older than 6 h applies anyway (the app's `SAFE_MOMENT_PATIENCE_S`); the miners' hourly program boundary is not consulted (the app does; a follow-up) |
| `rig-status` | per card: MH/s (the last STATUS line's `now=`), W and GPU C (the last telemetry line), MH/W, accepted, rejected, unit state, STATUS age; node blocks, headers, daa, peers, sync; prover state; update line | read-only |
Layout: scripts in `/opt/igneum/bin`, releases in `/opt/igneum/releases/<version>` with `current` a symlink,
config in `/etc/igneum` (`rig.conf` 0640 root:igneum, `machine-id` 16 hex, `override-params.json`,
`log-intake-key`), data in `/var/lib/igneum` (`node`, `packs`, `proving`, `updates`), runtime state in `/run/igneum`.
Logs are journald only: `journalctl -fu igneum-miner@nvidia0`.
## The per-card rules and what they mean
| Rule | Value | Source | Consequence per tier |
|---|---|---|---|
| Identities per card | 8 for 8 GiB or more (or unknown), else 2; `IDENTITIES=N` overrides | `app/igneum-app/src/detect.rs` (proving-v1), the app's rule; the HiveOS README's "8 for a big card, 2 for a small one" now has its threshold | an 8 GB card runs 8 vote keys; a 6 GB card 2 (fewer blue blocks per key, the same hashrate) |
| Prover on by default | NVIDIA card with 11,776 MB or more (the 12 GB gate), the biggest card proves; `PROVER=on` or `off` wins | `app/igneum-app/src/provedefault.rs` (proving-v1); the brief said 16 GB, the branch's constant is 12 GB and is what the app ships, so the rig follows the branch and the line below handles the difference | 8 GB: off. 12 and 16 GB: on, with the miner paused per shard. 24 and 32 GB: on, mining and proving at once. AMD and Intel: off (no CUDA prover) |
| Mine and prove on one card | 20,480 MB or more; under it the card's miner stops for each shard (`PROVER_PAUSE_MINER=auto`; `always` and `never` force it) | `docs/bench-log.md` on proving-v1, "Step 1, the prover default and its cost": 15.6 GB measured for the miner and the prover together on one card | a 16 GB card loses its hashrate for the shard's duration (10.9 s of proving on a 5090 for one shard, bench-log; the pause is the whole export-cut-prove-sign round, longer); a 24 GB card loses nothing |
| RAM | 8 GB to mine, 16 GB to prove (warning, not failure) | 2.3 GB inside WSL2 on PC 1 (5 October 2026), the rest approximate | a 4 to 8 GB rig board mines; proving on it is a warning until measured on bare Linux |
| SP1 GPU server | downloaded by the SDK on the first `SP1_PROVER=cuda` run into the igneum user's `~/.sp1/bin` (home is `/var/lib/igneum`) | `proving/windows-wsl2/setup-wsl.sh` (134 MB for v6.8.1; 251 MB reported for CUDA 12.8; both approximate) | the first proof waits for the download; the 20 GB free-disk check covers it |
| Sync wait | 3600 s cap, miners start at `synced=true` | PC 1 under WSL2, 5 October 2026: miners started during IBD rebuilt their pack on every epoch seed move | a fresh rig mines 5 to 8 min after the node starts on the devnet (runs 2 to 4 of the HiveOS test); longer on a bigger chain |
| Package glibc | ELFs want GLIBC_2.27 (igneumd), 2.25 (miner), 2.17 (workers) | read from the 0.3.9 package tonight | runs on any Ubuntu from 18.04 up; 24.04 has 2.39 |
## Tested on 5 October 2026 (this Mac, no rig)
`packaging/linux/selftest.sh`, all passing:
| What | Result |
|---|---|
| `bash -n` and `shellcheck -x -S style` (0.11.0) on the installer, the library, the 7 runtime scripts, the checker and the self-test | clean |
| `check-units.sh`: the 6 unit files against the directive lists of systemd.unit/service/timer/exec(5), enumerated values, Exec paths against `bin/`, template `%i`, cross-references | 6 checked, 0 failures; `systemd-analyze verify` is NOT available here (no systemd, no Docker) and runs on the rig through the same script |
| `igneum-gpus.sh` on a fake sysfs tree: 2 NVIDIA, 2 AMD, 1 Intel Arc, plus an AMD APU, an Intel iGPU, an ASPEED BMC VGA and a NIC | 5 cards in PCI order per vendor, the four non-cards excluded with a note each |
| the identities rule, the vote-key labels, the prover rule (unknown VRAM off, `PROVER=on` picks nvidia0 and pauses), the OpenCL index mapping on a fake `--list` (amd1 to 1, intel0 to 2, intel1 to 3, amd2 to none) | as designed |
| the LIVE signed manifest: fetched, Ed25519 verified with the OTA public key through OpenSSL 3.6.4 (`pkeyutl -rawin`, the Ubuntu path) and through python3 cryptography (the fallback); a tampered copy and a flipped signature refused by both | version 0.3.9, override with the four fields |
| `install-rig.sh --dry-run`: the preflight (fails here, as expected), the manifest, the sidecar entry, the 24,179,978-byte `igneum-hive-0.3.9.tar.gz` downloaded and checked against the sidecar sha256 `7a58a30f...` and the size, the four binaries and `override-params.json` listed, the glibc floors read, then 31 printed steps (user, folders, machine id, release, symlink, override file, rig.conf, key, scripts, units, sudoers, enable, start) | nothing under / touched |
| `relay/test/parse.test.mjs` with the `linux` labels | 6 tests pass |
## Untested until a rig exists
- Every unit under a real systemd: start order, `PartOf` restarts, `ProtectSystem=full` and `ReadWritePaths` against
what the binaries open, the `RuntimeDirectory` shared by five units, `SuccessExitStatus=42`, the stop path
(TERM to `igneum-miner.sh` must end the miner and the worker; the HiveOS script needed a descendant walk).
- `systemd-analyze verify` itself (`check-units.sh` runs it when present).
- The preflight on real drivers: the driver and CUDA version parse of `nvidia-smi`, `ldconfig -p` for
`libnvrtc.so.12`, the ROCm and Intel ICD files, `ss` and `ufw`.
- The CUDA index assumption (`CUDA_DEVICE_ORDER=PCI_BUS_ID` against the sysfs PCI order) and the OpenCL ordinal
mapping on a real `--list` with AMD and Intel platforms both installed; whether the OpenCL worker runs at all on an
Arc B580 (never tried on Intel; the RDNA 4 measurements are on the telemetry branch).
- The AMD sysfs telemetry on RX 9070 XT (the file names come from `proto-opencl/gpu-telemetry.c`, measured on PC 1 under
Windows ADLX, not Linux sysfs), the Intel xe hwmon files (guessed: `power1_input` or `energy1_input`, `temp1_input`
or `temp2_input`), the `stability:` p95 over mawk.
- The relay upload from journald tails: the label parse is tested, the upload and the console card are not; the relay
needs a deploy for the `linux` os (`cd relay && npx vercel deploy`, `relay/README.md`).
- The whole prover unit: no Linux `igneum-prove-host` is published; the loop was ported from `prover.rs` by reading,
the JSON shapes (`igneum_getAssignedShards`, the results file, `sign-record`'s last line) are taken from that file;
the miner pause through sudo; `CUDA_VISIBLE_DEVICES` with SP1's GPU server.
- The update path: the hourly timer, a real override change (the node restart on the next switch), a package switch
and the 90-s rollback; `PACKAGE_SOURCE=sidecar` on a rig means the version in `igneum-downloads.json` drives it.
- The testnet: `--network testnet` uses `--testnet`, the three DNS seeds and ports 26810/26811/26890 from
`docs/testnet/README.md`; nothing mines there until the owner's go.
## Files
| File | What |
|---|---|
| `install-rig.sh` | the installer (`--help`) |
| `bin/igneum-rig-lib.sh` | paths, `rig.conf`, the manifest fetch and Ed25519 check, the package download and sha256 check, the inventory and prover rules, the relay upload |
| `bin/igneum-gpus.sh` | the card inventory from `/sys/bus/pci` (`IGNEUM_SYS_ROOT` for a fixture tree) |
| `bin/igneum-node.sh`, `igneum-miner.sh`, `igneum-prover.sh`, `igneum-telemetry.sh`, `igneum-update.sh` | the units' ExecStart scripts |
| `bin/rig-status` | installed to `/usr/local/bin/rig-status` |
| `units/*.service`, `units/igneum-update.timer` | installed to `/etc/systemd/system` |
| `check-units.sh` | the static unit check, plus `systemd-analyze verify` where it exists |
| `selftest.sh` | everything in the tested table above |

View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
# The rig's card inventory, one line per usable GPU, in PCI bus order within each vendor:
# <card> <vendor> <bus> <vram_mb> <ordinal> <worker> <name>
# nvidia0 nvidia 0000:01:00.0 32607 0 cuda NVIDIA GeForce RTX 5090
# amd0 amd 0000:41:00.0 16368 0 opencl AMD Radeon RX 9070 XT
# intel0 intel 0000:61:00.0 0 0 opencl Intel Arc B580
# <card> is the systemd instance name (igneum-miner@<card>). <ordinal> is the card's position among its vendor's cards:
# the CUDA device index for NVIDIA (the units set CUDA_DEVICE_ORDER=PCI_BUS_ID, so CUDA enumerates in PCI bus order,
# the same order as here; NVIDIA's CUDA environment-variable documentation, approximate), and for AMD and Intel the
# position among that vendor's devices in igneum-worker-opencl --list (igneum-miner.sh maps it to the OpenCL index).
#
# Excluded: anything that is not a display-class PCI device of NVIDIA (10de), AMD (1002) or Intel (8086); the BMC's
# VGA (ASPEED 1a03 on Threadripper PRO boards) by vendor; an integrated AMD GPU (an APU reports under 2 GiB of VRAM
# carve-out in mem_info_vram_total, approximate rule); an integrated Intel GPU (it sits on the CPU's root bus at
# 0000:00:02.0, approximate rule: a discrete Arc card is behind a PCIe bridge on a higher bus). IGNEUM_SYS_ROOT points
# the walk at a fixture tree for tests. Lines go to stdout; notes to stderr.
set -euo pipefail
SYS="${IGNEUM_SYS_ROOT:-/sys}"
PCI="$SYS/bus/pci/devices"
[[ -d "$PCI" ]] || { echo "no $PCI (not Linux?)" >&2; exit 0; }
rd() { local v; v="$(cat "$1" 2>/dev/null || true)"; printf '%s' "${v//$'\n'/}"; }
nv_names=""
if command -v nvidia-smi >/dev/null 2>&1 && [[ -z "${IGNEUM_SYS_ROOT:-}" ]]; then
nv_names="$(nvidia-smi --query-gpu=pci.bus_id,name,memory.total --format=csv,noheader,nounits 2>/dev/null || true)"
fi
nv=0; amd=0; intel=0
for d in "$PCI"/*; do
dev="${d##*/}"
class="$(rd "$d/class")"
[[ "$class" == 0x03* ]] || continue # display controller (VGA 0x030000, 3D 0x030200)
vendor="$(rd "$d/vendor")"
bus="$dev"
case "$vendor" in
0x10de)
name=""; mb=0
if [[ -n "$nv_names" ]]; then
# nvidia-smi prints the bus as 00000000:01:00.0; sysfs as 0000:01:00.0
line="$(printf '%s\n' "$nv_names" | awk -F', ' -v b="${bus#0000:}" 'tolower($1) ~ tolower(b)"$" { print; exit }')"
name="$(printf '%s' "$line" | awk -F', ' '{print $2}')"; mb="$(printf '%s' "$line" | awk -F', ' '{print $3}')"
fi
[[ -n "$name" ]] || name="NVIDIA $(rd "$d/device")"
printf '%s %s %s %s %s %s %s\n' "nvidia$nv" nvidia "$bus" "${mb:-0}" "$nv" cuda "$name"
nv=$((nv + 1)) ;;
0x1002)
vram="$(rd "$d/mem_info_vram_total")"
mb=0; [[ -n "$vram" ]] && mb=$((vram / 1048576))
if [[ -n "$vram" && "$mb" -lt 2048 ]]; then echo "skip $bus: AMD integrated GPU (${mb} MB VRAM carve-out)" >&2; continue; fi
name="$(rd "$d/product_name")"; [[ -n "$name" ]] || name="amdgpu $(rd "$d/device")"
[[ -d "$SYS/bus/pci/drivers/amdgpu/$bus" || -n "${IGNEUM_SYS_ROOT:-}" ]] || echo "note $bus: AMD card without the amdgpu driver bound" >&2
printf '%s %s %s %s %s %s %s\n' "amd$amd" amd "$bus" "$mb" "$amd" opencl "$name"
amd=$((amd + 1)) ;;
0x8086)
if [[ "$bus" == 0000:00:02.0 ]]; then echo "skip $bus: Intel integrated GPU (root bus 00:02.0)" >&2; continue; fi
name=""
if command -v lspci >/dev/null 2>&1 && [[ -z "${IGNEUM_SYS_ROOT:-}" ]]; then name="$(lspci -s "$bus" 2>/dev/null | sed 's/^[^:]*: //; s/ (rev .*//' | head -1)"; fi
[[ -n "$name" ]] || name="Intel $(rd "$d/device")"
vram="$(rd "$d/mem_info_vram_total")"; mb=0; [[ -n "$vram" ]] && mb=$((vram / 1048576))
[[ -d "$SYS/bus/pci/drivers/xe/$bus" || -d "$SYS/bus/pci/drivers/i915/$bus" || -n "${IGNEUM_SYS_ROOT:-}" ]] || echo "note $bus: Intel card without the xe or i915 driver bound" >&2
printf '%s %s %s %s %s %s %s\n' "intel$intel" intel "$bus" "$mb" "$intel" opencl "$name"
intel=$((intel + 1)) ;;
*) echo "skip $bus: display device of vendor $vendor (not NVIDIA, AMD or Intel)" >&2 ;;
esac
done

View file

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# igneum-miner@<card>.service: one igneum-miner with its GPU worker for one card (igneum-worker-cuda on NVIDIA,
# igneum-worker-opencl on AMD and Intel), the HiveOS package's h-run.sh per-GPU loop as a unit: wait for the node,
# wait for synced=true (SYNC_WAIT; a miner started during the chain walk mines IBD templates and rebuilds its pack on
# every epoch seed move, PC 1 under WSL2, 5 October 2026), export the hourly program pack once for every card (a lock),
# then mine. Exit 42 (the program changed and the worker cannot prepare) re-exports the pack and runs again; any other
# exit returns to systemd, which restarts the unit after RestartSec.
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=igneum-rig-lib.sh
. /opt/igneum/bin/igneum-rig-lib.sh
load_conf
card="${1:?card id, as igneum-gpus.sh prints it (nvidia0, amd0, intel0)}"
line="$(card_line "$card")"
[[ -n "$line" ]] || { log "no card $card in the inventory now (igneum-gpus.sh); nothing to mine with"; sleep 30; exit 1; }
vendor="$(card_field "$line" 2)"; bus="$(card_field "$line" 3)"; ordinal="$(card_field "$line" 5)"; worker="$(card_field "$line" 6)"; name="$(card_field "$line" 7)"
[[ -n "$WALLET" ]] || die "no WALLET in $RIG_CONF"
label="$(card_label "$card")"
PACKS="$IGNEUM_VAR/packs"
mkdir -p "$PACKS/$card" "$IGNEUM_RUN"
cd "$IGNEUM_VAR"
# 1. the node answers, then is synced
log "$card: $name ($vendor, $bus, $worker worker, device ordinal $ordinal); waiting for the node at $NODE_URL"
for _ in $(seq 1 90); do [[ -n "$(node_watch_line)" ]] && break; sleep 2; done
waited=0
while [[ "$SYNC_WAIT" -gt 0 ]]; do
l="$(node_watch_line)"
case "$l" in
*synced=true*) log "node synced ($l)"; break ;;
*synced=false*) [[ $((waited % 60)) == 0 ]] && log "node syncing, $waited s ($l)" ;;
*) log "node reports no sync state; starting"; break ;;
esac
[[ "$waited" -ge "$SYNC_WAIT" ]] && { log "node not synced after $waited s; starting anyway"; break; }
sleep 10; waited=$((waited + 10))
done
# 2. the device index for the worker
device="$ordinal"
if [[ "$worker" == opencl ]]; then
# igneum-worker-opencl --list prints "[<idx>] <name> | <platform> (...)" then "<type>, vendor <vendor>, ...";
# the card's OpenCL index is the ordinal-th device of its vendor in that list (no bus id in OpenCL's list, so two
# identical cards of one vendor are told apart by list order only; the app makes the same assumption).
device="$("$BIN/igneum-worker-opencl" --list 2>/dev/null | opencl_index_for "$vendor" "$ordinal")"
[[ -n "$device" ]] || { log "$card: no $vendor device number $ordinal in igneum-worker-opencl --list (is the OpenCL runtime installed?)"; "$BIN/igneum-worker-opencl" --list 2>&1 | sed 's/^/ /' | head -40; sleep 30; exit 1; }
fi
log "$card: worker device index $device"
# 3. the pack, exported once for every card under a lock; "force" (after exit 42) re-exports unless another card did
# so in the last 60 s, so eight miners leaving on one epoch change export once, not eight times
export_pack() {
(
flock 9
local age=999999
[[ -d "$PACKS/devnet" ]] && age=$(( $(date +%s) - $(stat -c %Y "$PACKS/devnet" 2>/dev/null || echo 0) ))
if [[ ! -d "$PACKS/devnet" || ( "${1:-}" == force && "$age" -gt 60 ) ]]; then
rm -rf "$PACKS/devnet.new"
if "$BIN/igneum-miner" export-pack "$NODE_URL" "$PACKS/devnet.new" 2>&1 | sed "s/^/$card export-pack: /"; then rm -rf "$PACKS/devnet"; mv "$PACKS/devnet.new" "$PACKS/devnet"; fi
fi
) 9> "$IGNEUM_RUN/pack.lock"
}
export_pack
[[ -d "$PACKS/devnet" ]] || log "$card: pack export failed; the miner retries"
# 4. mine
args=(mine "$NODE_URL" 1 100000000 "$label" --worker "$BIN/igneum-worker-$worker" --worker-args "--device $device --pack $PACKS/devnet"
--prepare-packs "$PACKS/$card/prepare" --exit-on-seed-change --evm-address "$WALLET" --payout-label "$label" --status-secs 30)
identities="$(card_identities "$card")"
[[ "$identities" -gt 1 ]] && args+=(--identities "$identities")
[[ "$VOTE" == 0 ]] && args+=(--no-vote)
[[ "$DEV_FEE" != 1 ]] && args+=(--dev-fee "$DEV_FEE")
# shellcheck disable=SC2206 # EXTRA is a flag string split on purpose, as in h-run.sh
[[ -n "$EXTRA" ]] && args+=($EXTRA)
export CUDA_DEVICE_ORDER=PCI_BUS_ID
while :; do
log "$card: igneum-miner ${args[*]}"
rc=0; "$BIN/igneum-miner" "${args[@]}" || rc=$?
if [[ $rc == 42 ]]; then log "$card: exit 42 (program changed); re-exporting the pack"; export_pack force; continue; fi
log "$card: igneum-miner exited with $rc"
exit "$rc"
done

View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# igneum-node.service: the bundled igneumd with the flags the apps use (app/igneum-app/src/engine.rs node args) and
# the HiveOS package's override rule (packaging/hive/h-run.sh): --override-params-file carries the signed manifest's
# consensus.override, else a peer refuses the node ("consensus params digest mismatch", 5 October 2026).
# /etc/igneum/override-params.json is written by install-rig.sh and refreshed hourly by igneum-update.sh from the
# verified manifest; the package's own override-params.json is the offline fallback.
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=igneum-rig-lib.sh
. /opt/igneum/bin/igneum-rig-lib.sh
load_conf
mkdir -p "$IGNEUM_VAR/node"
override=()
if [[ -s "$IGNEUM_ETC/override-params.json" ]]; then override+=("--override-params-file=$IGNEUM_ETC/override-params.json")
elif [[ -s "$IGNEUM_ROOT/current/override-params.json" ]]; then override+=("--override-params-file=$IGNEUM_ROOT/current/override-params.json"); log "using the package's override-params.json (no verified copy in $IGNEUM_ETC yet)"
else log "no consensus override file; the node runs the binary's defaults (fine for a fresh testnet, refused by the devnet)"; fi
mapfile -t peers < <(node_peer_args)
log "igneumd $NODE_FLAG, data $IGNEUM_VAR/node, RPC 127.0.0.1:$RPC_PORT, EVM RPC 127.0.0.1:$EVM_PORT, P2P 0.0.0.0:$P2P_PORT, peers ${peers[*]#--addpeer=}${override[0]:+, override $(tr -d ' \n' < "${override[0]#--override-params-file=}")}"
exec "$BIN/igneumd" "$NODE_FLAG" "--appdir=$IGNEUM_VAR/node" "--rpclisten=127.0.0.1:$RPC_PORT" "--evm-rpclisten=127.0.0.1:$EVM_PORT" \
"--listen=0.0.0.0:$P2P_PORT" "${peers[@]}" "${override[@]}" --nodnsseed --disable-upnp --nologfiles --yes

View file

@ -0,0 +1,141 @@
#!/usr/bin/env bash
# igneum-prover.service: the shard prover loop of app/igneum-app/src/prover.rs (branch proving-v1, 5 October 2026) as a
# unit: every 10 s, when proving is on and the node is synced, list the shards assigned to this rig's vote keys
# (igneum_getAssignedShards), take the newest unpaid one not yet attempted (an open one when nothing is assigned), export
# the chain (igneum_exportSegments), cut the fixture (igneum-prove-export), prove it (igneum-prove-host --mode compressed,
# SP1_PROVER=cuda on the prover card), sign the record with the identity's vote key (igneum-miner sign-record), submit
# (igneum_submitProofRecord). Not ported: the proving v1 aggregator step (aggregate_once) and the paid-shard poll.
#
# State for rig-status and igneum-update.sh: /run/igneum/prover.state, one line, "<state> <detail>" where state is
# off | setup | waiting | idle | proving | submitted. The updater never restarts units while it says proving.
#
# Needs next to the package binaries (/opt/igneum/current/bin or /opt/igneum/prover): igneum-prove-host built with
# --features igneum-prove-host/cuda for Linux x86_64 and igneum-prove-export (proving/README.md), and an igneum-miner
# with the key-hash and sign-record subcommands (the proving branch's miner; the 0.3.9 package's miner has neither).
# Until both are there the state is "setup" and the unit idles; nothing fails.
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=igneum-rig-lib.sh
. /opt/igneum/bin/igneum-rig-lib.sh
load_conf
mkdir -p "$IGNEUM_RUN" "$IGNEUM_VAR/proving"
STATE="$IGNEUM_RUN/prover.state"
state() { printf '%s %s\n' "$1" "${2:-}" > "$STATE.tmp" && mv "$STATE.tmp" "$STATE"; }
declare -A attempted=()
tools_ok=0; last_probe=0; HOST=""; EXPORT=""
find_tools() {
local d
for d in "$IGNEUM_ROOT/current/bin" "$IGNEUM_ROOT/prover" "$IGNEUM_ROOT/bin"; do
if [[ -x "$d/igneum-prove-host" && -x "$d/igneum-prove-export" ]]; then HOST="$d/igneum-prove-host"; EXPORT="$d/igneum-prove-export"; return 0; fi
done
return 1
}
decision="$(prover_decision)"
if [[ "$decision" != on* ]]; then
log "prover: ${decision#off }"
state off "${decision#off }"
exec sleep infinity
fi
read -r _ prover_card pause_mode reason <<< "$decision"
prover_line="$(card_line "$prover_card")"
prover_ordinal="$(card_field "$prover_line" 5)"
log "prover: $reason; proving on $prover_card (CUDA device $prover_ordinal)"
export CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES="$prover_ordinal"
# The miner on the prover card is paused for each shard when the card is under the mine-and-prove line (the
# installer's sudoers rule allows exactly these two commands); on a 24 GB or bigger card it keeps mining.
pause_miner() { [[ "$pause_mode" == pause ]] || return 0; log "prover: pausing igneum-miner@$prover_card for the shard"; sudo -n systemctl stop "igneum-miner@$prover_card" || warn "could not stop igneum-miner@$prover_card (sudoers rule missing?)"; }
resume_miner() { [[ "$pause_mode" == pause ]] || return 0; sudo -n systemctl start "igneum-miner@$prover_card" || warn "could not start igneum-miner@$prover_card"; log "prover: igneum-miner@$prover_card resumed"; }
trap 'resume_miner; exit 0' TERM INT
state waiting "starting"
while :; do
sleep 10
if [[ $tools_ok == 0 ]]; then
now=$(date +%s)
[[ $((now - last_probe)) -lt 60 ]] && continue
last_probe=$now
if ! find_tools; then
state setup "no igneum-prove-host and igneum-prove-export in $IGNEUM_ROOT/current/bin or $IGNEUM_ROOT/prover (proving/README.md: cargo build --release -p igneum-prove-export -p igneum-prove-host --features igneum-prove-host/cuda on Linux x86_64)"
log "prover: $(cat "$STATE")"; continue
fi
if ! "$BIN/igneum-miner" key-hash probe-label >/dev/null 2>&1; then
state setup "the installed igneum-miner has no key-hash/sign-record subcommand (a build from the proving branch's node is needed)"
log "prover: $(cat "$STATE")"; continue
fi
tools_ok=1
log "prover: host $HOST, exporter $EXPORT, CUDA"
fi
l="$(node_watch_line)"
[[ "$l" == *synced=true* ]] || { state waiting "waiting for the node to sync"; continue; }
# 1. the keys: one per vote key of every card (the miners' labels, src/prover.rs labels())
keys=(); labels=()
while read -r cl; do
[[ -z "$cl" ]] && continue
while read -r lab; do
h="$("$BIN/igneum-miner" key-hash "$lab" 2>/dev/null | tail -1 | tr -d ' ')"
[[ ${#h} == 64 ]] && h="0x$h"
[[ ${#h} == 66 ]] && { keys+=("$h"); labels+=("$lab"); }
done < <(card_labels_with_identities "$(card_field "$cl" 1)")
done < <(inventory)
[[ ${#keys[@]} -gt 0 ]] || { state waiting "no vote key (igneum-miner key-hash gave nothing)"; continue; }
keys_json="$(printf '%s\n' "${keys[@]}" | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))')"
work="$(evm_rpc igneum_getAssignedShards "[$keys_json, 60]" 10 2>/dev/null)" || { state waiting "igneum_getAssignedShards did not answer"; continue; }
# 2. choose (src/prover.rs choose): assigned, unpaid, not in the pool, not attempted; newest block first, smallest
# pgas among equals; else an open shard on the same terms
attempted_json="$(printf '%s\n' "${!attempted[@]}" | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))')"
pick="$(python3 - "$work" "$attempted_json" <<'PY'
import json, sys
work, attempted = json.loads(sys.argv[1]) or [], set(json.loads(sys.argv[2]))
hexu = lambda s: int(s, 16) if isinstance(s, str) and s.startswith("0x") else int(s or 0)
rows = []
for w in work:
rows.append({"number": hexu(w.get("number")), "hash": w.get("hash", ""), "shard": int(w.get("shard", 0)), "pgas": hexu(w.get("pgas")),
"tx": int(w.get("txCount", 0)), "assigned": bool(w.get("assigned")), "open": bool(w.get("open")), "paid": w.get("paid") is not None,
"pool": bool(w.get("pool")), "key": ((w.get("assignedKeys") or [""])[0]) or ""})
def pick(flag):
c = [r for r in rows if r[flag] and not r["paid"] and not r["pool"] and f'{r["hash"]}:{r["shard"]}' not in attempted]
c.sort(key=lambda r: (-r["number"], r["pgas"]))
return c[0] if c else None
r = pick("assigned") or pick("open")
assigned = sum(1 for x in rows if x["assigned"])
print(json.dumps({"assigned": assigned, "pick": r}))
PY
)"
assigned="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["assigned"])' "$pick")"
if [[ "$(python3 -c 'import json,sys; print("none" if json.loads(sys.argv[1])["pick"] is None else "some")' "$pick")" == none ]]; then
state idle "no shard to prove ($assigned assigned in the last 60 blocks)"; continue
fi
read -r number hash shard pgas txc is_assigned key_hash < <(python3 -c 'import json,sys; p=json.loads(sys.argv[1])["pick"]; print(p["number"], p["hash"], p["shard"], p["pgas"], p["tx"], p["assigned"], p["key"])' "$pick")
attempted["$hash:$shard"]=1
label="${labels[0]}"
for i in "${!keys[@]}"; do [[ "${keys[$i]}" == "$key_hash" ]] && label="${labels[$i]}"; done
state proving "block $number shard $shard ($txc txs, $pgas pgas$( [[ "$is_assigned" == True ]] || printf ', open')) since $(date +%s)"
log "prover: block $number shard $shard assigned to $label: export, cut, prove (CUDA), sign, submit"
dir="$IGNEUM_VAR/proving"; seq="$dir/seq.json"; fixture="$dir/block-$number.json"; results="$dir/results-$number-$shard.json"
started=$(date +%s)
ok=0
pause_miner
if evm_rpc igneum_exportSegments "[\"0x0\", \"$(printf '0x%x' "$number")\"]" 120 > "$seq" 2>/dev/null \
&& timeout 600 "$EXPORT" "$seq" "$number" "$fixture" > "$dir/export-$number.log" 2>&1 && [[ -f "$fixture" ]]; then
if SP1_PROVER=cuda RUST_LOG=off timeout $((3 * 3600)) "$HOST" "$fixture" --mode compressed --shard "$shard" --prover "$WALLET" --out "$results" > "$dir/prove-$number-$shard.log" 2>&1 && [[ -f "$results" ]]; then
read -r statement proof_sha proof_file secs < <(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(r["statement"], r["proof_sha256"], r["proof_file"], r.get("compressed_prove_seconds", 0))' "$results" 2>/dev/null || echo "")
if [[ -n "${statement:-}" && -f "${proof_file:-/nonexistent}" ]]; then
signed="$("$BIN/igneum-miner" sign-record "$label" "$CHAIN_NAME" "$hash" "$number" "$shard" "$WALLET" "$statement" "$proof_sha" 2>/dev/null | tail -1)"
record="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("record",""))' "$signed" 2>/dev/null || true)"
if [[ -n "$record" ]]; then
proof_hex="0x$(python3 -c 'import sys; print(open(sys.argv[1],"rb").read().hex())' "$proof_file")"
r="$(evm_rpc igneum_submitProofRecord "[{\"record\":\"$record\",\"proof\":\"$proof_hex\"}]" 60 2>/dev/null || echo '{}')"
if [[ "$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("accepted", False))' "$r")" == True ]]; then ok=1
else log "prover: block $number shard $shard: record refused: $(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("reason","?"))' "$r")"; fi
else log "prover: block $number shard $shard: sign-record gave no record: $signed"; fi
else log "prover: block $number shard $shard: $(grep -E 'RESULT|rror' "$dir/prove-$number-$shard.log" | tail -1 || echo 'no results file')"; fi
else log "prover: block $number shard $shard: prover failed: $(grep -E 'RESULT|rror' "$dir/prove-$number-$shard.log" 2>/dev/null | tail -1 || echo failed)"; fi
else log "prover: block $number shard $shard: exporter failed: $(tail -1 "$dir/export-$number.log" 2>/dev/null || echo 'igneum_exportSegments did not answer')"; fi
resume_miner
if [[ $ok == 1 ]]; then
log "[ok] proving: block $number shard $shard proven and submitted in $(( $(date +%s) - started )) s (prove ${secs:-?} s)"
state submitted "block $number shard $shard submitted; paid when a block carries it"
else
state idle "block $number shard $shard failed; see $dir"
fi
done

View file

@ -0,0 +1,301 @@
#!/usr/bin/env bash
# Igneum rig (Ubuntu 24.04, systemd): the library every rig script sources. Paths, the config file, logging, the
# signed-manifest fetch and Ed25519 check, the package download and sha256 check, the GPU inventory helpers.
# Sourced by install-rig.sh and by the scripts under /opt/igneum/bin; never run on its own.
# shellcheck shell=bash
# shellcheck disable=SC2034 # the variables are read by the scripts that source this file
IGNEUM_ROOT="${IGNEUM_ROOT:-/opt/igneum}" # bin/ (these scripts), releases/<version>/, current -> releases/<version>
IGNEUM_ETC="${IGNEUM_ETC:-/etc/igneum}" # rig.conf, machine-id, override-params.json, log-intake-key
IGNEUM_VAR="${IGNEUM_VAR:-/var/lib/igneum}" # node/ (chain data), packs/, proving/, updates/
IGNEUM_RUN="${IGNEUM_RUN:-/run/igneum}" # prover.state, update.deferred, pack.lock, telemetry samples
IGNEUM_USER="${IGNEUM_USER:-igneum}"
# The OTA public key, the constant OTA_PUBLIC_KEY_HEX in app/igneum-app/src/manifest.rs (4 October 2026). The apps
# verify the manifest bytes with it before parsing; the rig does the same. A rotated key is a new copy of this file.
OTA_PUBLIC_KEY_HEX="b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd"
DL_HOST="${IGNEUM_DL_HOST:-https://dl.igneum.network}"
MANIFEST_URL="$DL_HOST/dl/public/igneum-app-latest.json" # packaging/ota/publish-public.sh writes it, signed
DOWNLOADS_URL="$DL_HOST/dl/public/igneum-downloads.json" # the unsigned index the site reads (same script)
# The log intake the apps upload to (app/igneum-app/src/config.rs DEFAULT_INTAKE_URL, update.rs upload_log):
# POST JSON {label, machine, run_id, lines} with header x-igneum-key.
DEFAULT_INTAKE_URL="https://igneum-six.vercel.app/api/log"
# The public devnet seed (packaging/hive/h-run.sh, app/igneum-app/src/config.rs) and the testnet DNS seeders
# (docs/testnet/README.md: seed1/2/3.testnet.igneum.network, P2P 26811, gRPC 26810, EVM JSON-RPC 26890).
DEVNET_SEED="188.245.5.161:26611"
TESTNET_SEEDS="seed1.testnet.igneum.network:26811,seed2.testnet.igneum.network:26811,seed3.testnet.igneum.network:26811"
RIG_CONF="$IGNEUM_ETC/rig.conf"
MANIFEST_VERIFIER="" # set by verify_manifest_signature: which tool checked the signature
ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
log() { printf '%s %s\n' "$(ts)" "$*"; }
warn() { printf '%s WARNING: %s\n' "$(ts)" "$*" >&2; }
die() { printf '%s ERROR: %s\n' "$(ts)" "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
# ---- config -----------------------------------------------------------------------------------------------------
# rig.conf is KEY=VALUE, written once by install-rig.sh. Defaults first, then the file, then the derived values.
load_conf() {
WALLET=""; RIG_NAME="rig"; NETWORK="devnet"; PEERS=""; DEV_FEE=1; IDENTITIES=auto; VOTE=1; EXTRA=""
PROVER="auto"; PROVER_MIN_VRAM_MB=11776; PROVER_MINE_AND_PROVE_MB=20480; PROVER_PAUSE_MINER=auto; PROVER_CARD=""
SYNC_WAIT=3600; TELEMETRY_SECS=5
RELAY_INTAKE_URL=""; RELAY_KEY_FILE="$IGNEUM_ETC/log-intake-key"; PACKAGE_SOURCE="signed"
if [[ -f "$RIG_CONF" ]]; then
local k v
while IFS='=' read -r k v; do
[[ -z "$k" || "$k" == \#* ]] && continue
case "$k" in
WALLET|RIG_NAME|NETWORK|PEERS|DEV_FEE|IDENTITIES|VOTE|EXTRA|PROVER|PROVER_MIN_VRAM_MB|PROVER_MINE_AND_PROVE_MB|PROVER_PAUSE_MINER|PROVER_CARD|SYNC_WAIT|TELEMETRY_SECS|RELAY_INTAKE_URL|RELAY_KEY_FILE|PACKAGE_SOURCE)
printf -v "$k" '%s' "$v" ;;
esac
done < "$RIG_CONF"
fi
case "$NETWORK" in
devnet) RPC_PORT=26610; P2P_PORT=26611; NODE_FLAG="--devnet"; CHAIN_NAME="igneum-devnet" ;;
testnet) RPC_PORT=26810; P2P_PORT=26811; NODE_FLAG="--testnet"; CHAIN_NAME="igneum-testnet" ;;
*) die "rig.conf: NETWORK must be devnet or testnet, got '$NETWORK'" ;;
esac
EVM_PORT=$((RPC_PORT + 180)) # app/igneum-app/src/config.rs evm_port(): rpc_port + 180
NODE_URL="grpc://127.0.0.1:$RPC_PORT"
EVM_URL="http://127.0.0.1:$EVM_PORT"
MACHINE_ID="$(cat "$IGNEUM_ETC/machine-id" 2>/dev/null || echo 0000000000000000)"
ID8="${MACHINE_ID:0:8}"
BIN="$IGNEUM_ROOT/current/bin"
export WALLET RIG_NAME NETWORK PEERS DEV_FEE IDENTITIES VOTE EXTRA PROVER PROVER_MIN_VRAM_MB PROVER_MINE_AND_PROVE_MB PROVER_PAUSE_MINER PROVER_CARD SYNC_WAIT TELEMETRY_SECS
export RELAY_INTAKE_URL RELAY_KEY_FILE PACKAGE_SOURCE RPC_PORT P2P_PORT NODE_FLAG CHAIN_NAME EVM_PORT NODE_URL EVM_URL MACHINE_ID ID8 BIN
}
installed_version() { cat "$IGNEUM_ROOT/current/version" 2>/dev/null || echo none; }
# ---- the signed manifest ----------------------------------------------------------------------------------------
# Ed25519 over the manifest bytes with the OTA public key: OpenSSL 3 (Ubuntu 24.04 ships 3.0.13) through pkeyutl -rawin,
# else python3's cryptography module. Both must be absent for the check to fail closed; it never skips.
verify_manifest_signature() { # <manifest file> <sig file> -> 0 when the signature verifies
local m="$1" s="$2" sig_hex tmp rc=1
sig_hex="$(tr -d '[:space:]' < "$s" 2>/dev/null)"
[[ "$sig_hex" =~ ^[0-9a-fA-F]{128}$ ]] || { warn "manifest signature is not 64 bytes of hex"; return 1; }
tmp="$(mktemp -d)"
python3 - "$OTA_PUBLIC_KEY_HEX" "$sig_hex" "$tmp" <<'PY' || { rm -rf "$tmp"; return 1; }
import sys, base64, binascii
key, sig, tmp = sys.argv[1:4]
raw = binascii.unhexlify(key)
assert len(raw) == 32, "public key is not 32 bytes"
der = bytes.fromhex("302a300506032b6570032100") + raw # SubjectPublicKeyInfo for Ed25519 (RFC 8410)
open(tmp + "/pub.pem", "w").write("-----BEGIN PUBLIC KEY-----\n" + base64.b64encode(der).decode() + "\n-----END PUBLIC KEY-----\n")
open(tmp + "/sig.bin", "wb").write(binascii.unhexlify(sig))
PY
if have openssl && openssl pkeyutl -help 2>&1 | grep -q -- '-rawin'; then
if openssl pkeyutl -verify -pubin -inkey "$tmp/pub.pem" -rawin -in "$m" -sigfile "$tmp/sig.bin" >/dev/null 2>&1; then rc=0; else rc=1; fi
MANIFEST_VERIFIER="openssl $(openssl version 2>/dev/null | awk '{print $2}')"
elif python3 -c 'import cryptography' 2>/dev/null; then
if python3 - "$OTA_PUBLIC_KEY_HEX" "$m" "$tmp/sig.bin" <<'PY' 2>/dev/null; then rc=0; else rc=1; fi
import sys, binascii
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
k = Ed25519PublicKey.from_public_bytes(binascii.unhexlify(sys.argv[1]))
k.verify(open(sys.argv[3], "rb").read(), open(sys.argv[2], "rb").read())
PY
MANIFEST_VERIFIER="python3 cryptography"
else
warn "no Ed25519 verifier: openssl 3 (pkeyutl -rawin) or python3-cryptography is needed"
MANIFEST_VERIFIER="none"
fi
rm -rf "$tmp"
return $rc
}
# Fetches igneum-app-latest.json and its .sig into <dir> and verifies them; the manifest is then
# <dir>/igneum-app-latest.json and MANIFEST_VERIFIER names the tool that checked it. Fails closed. (Called in the
# current shell, never in $(...): the variable must reach the caller.)
fetch_manifest() { # <dir>
local dir="$1"
mkdir -p "$dir"
curl -fsSL --max-time 30 -H 'Cache-Control: no-cache' -o "$dir/igneum-app-latest.json" "$MANIFEST_URL" || { warn "cannot fetch $MANIFEST_URL"; return 1; }
curl -fsSL --max-time 30 -H 'Cache-Control: no-cache' -o "$dir/igneum-app-latest.json.sig" "$MANIFEST_URL.sig" || { warn "cannot fetch $MANIFEST_URL.sig"; return 1; }
verify_manifest_signature "$dir/igneum-app-latest.json" "$dir/igneum-app-latest.json.sig" || { warn "manifest signature does not verify with the OTA public key"; return 1; }
}
# One field of a verified manifest. manifest_field <file> <python expression over m>; prints "" when absent.
manifest_field() {
python3 - "$1" "$2" <<'PY' 2>/dev/null
import json, sys
m = json.load(open(sys.argv[1]))
try:
v = eval(sys.argv[2], {"m": m, "json": json})
except Exception:
v = None
if v is None: print("")
elif isinstance(v, (dict, list)): print(json.dumps(v, sort_keys=True, separators=(",", ":")))
else: print(v)
PY
}
# The consensus override the node must carry (the same object the apps write to override.json; the HiveOS package's
# override-params rule): canonical JSON of m["consensus"]["override"], or "" when the manifest carries none.
manifest_override() { manifest_field "$1" 'm.get("consensus", {}).get("override") or None'; }
# The Linux package named by the verified manifest: platforms.linux (preferred) or platforms.hive, as "url sha256 size
# version"; "" when the manifest has no such entry (5 October 2026: it names mac and windows only).
manifest_package() {
python3 - "$1" <<'PY' 2>/dev/null
import json, sys
m = json.load(open(sys.argv[1]))
p = m.get("platforms", {})
e = p.get("linux") or p.get("hive")
if e and e.get("url", "").startswith("https://") and len(e.get("sha256", "")) == 64 and int(e.get("size", 0)) > 0:
print(e["url"], e["sha256"].lower(), e["size"], m.get("version", ""))
else:
print("")
PY
}
# The unsigned fallback: igneum-downloads.json names the current HiveOS package (files.miner-hive) and dl/public/ holds
# its .sha256 sidecar. Both come over TLS from the same host as the signed manifest, but neither is signed, so the
# installer says so and needs --allow-sidecar-sha256. Prints "url sha256 size version".
sidecar_package() {
local tmp; tmp="$(mktemp)"
curl -fsSL --max-time 30 -H 'Cache-Control: no-cache' -o "$tmp" "$DOWNLOADS_URL" || { rm -f "$tmp"; return 1; }
python3 - "$tmp" "$DL_HOST" <<'PY'
import json, sys
d = json.load(open(sys.argv[1])); base = sys.argv[2]
f = d.get("files", {}).get("miner-hive")
if not f: sys.exit(1)
print(base + f["path"], f["sha256"].lower(), f["size"], f.get("version", ""))
PY
local rc=$?
rm -f "$tmp"
return $rc
}
sha256_of() { sha256sum "$1" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$1" | awk '{print $1}'; }
# Downloads <url> to <file> (resumable, 3 retries) and checks size and sha256; the file is removed on a mismatch.
download_verified() { # <url> <file> <sha256> <size>
local url="$1" out="$2" want="$3" size="$4" got
if [[ -f "$out" && "$(sha256_of "$out")" == "$want" ]]; then log "already downloaded: $out (sha256 matches)"; return 0; fi
curl -fsSL --retry 3 --retry-delay 5 -C - --max-time 1800 -o "$out" "$url" || { rm -f "$out"; warn "download failed: $url"; return 1; }
got="$(stat -c %s "$out" 2>/dev/null || stat -f %z "$out")"
[[ "$got" == "$size" ]] || { rm -f "$out"; warn "$out is $got bytes, the manifest says $size"; return 1; }
got="$(sha256_of "$out")"
[[ "$got" == "$want" ]] || { rm -f "$out"; warn "$out sha256 $got, expected $want"; return 1; }
log "downloaded and verified: $(basename "$out") ($size bytes, sha256 ${want:0:16}...)"
}
# The sidecar check, on top of download_verified when PACKAGE_SOURCE=sidecar: <file>.sha256 next to the package.
sidecar_matches() { # <url> <file>
local line; line="$(curl -fsSL --max-time 30 "$1.sha256" 2>/dev/null | awk '{print $1}')"
[[ -n "$line" && "$line" == "$(sha256_of "$2")" ]]
}
# ---- node and miner helpers --------------------------------------------------------------------------------------
# One line from igneum-miner watch (the node's gRPC): "<ts> node1 blocks=N headers=N daa=N tips=N peers=N difficulty=D
# sink=... blue=N synced=true|false", or "" when the node does not answer.
node_watch_line() { timeout 20 "$BIN/igneum-miner" watch 1 "$NODE_URL" 2>/dev/null | grep -o 'blocks=[0-9]*.*synced=[a-z]*' | tail -1; }
node_field() { printf '%s\n' "$1" | grep -o "$2=[^ ]*" | head -1 | cut -d= -f2; }
# Peers for the bundled node: PEERS from rig.conf, else the network's seeds.
node_peer_args() {
local p list=()
if [[ -n "$PEERS" ]]; then IFS=',' read -r -a list <<< "$PEERS"
elif [[ "$NETWORK" == devnet ]]; then list=("$DEVNET_SEED")
else IFS=',' read -r -a list <<< "$TESTNET_SEEDS"; fi
for p in "${list[@]}"; do printf -- '--addpeer=%s\n' "$p"; done
}
# JSON-RPC to the node's EVM endpoint (what src/prover.rs evm_rpc does with curl): prints the "result" as JSON or fails.
evm_rpc() { # <method> <params json> [timeout s]
local body out
body="$(printf '{"jsonrpc":"2.0","id":1,"method":"%s","params":%s}' "$1" "$2")"
out="$(curl -s --max-time "${3:-10}" -X POST "$EVM_URL" -H 'Content-Type: application/json' --data-binary "$body")" || return 1
python3 -c 'import json,sys; r=json.loads(sys.stdin.read()); e=r.get("error"); (print(json.dumps(r.get("result"))) if not e else (sys.stderr.write(str(e)+"\n"), sys.exit(1)))' <<< "$out"
}
# ---- the card inventory ------------------------------------------------------------------------------------------
# igneum-gpus.sh prints one line per usable card: "<card> <vendor> <bus> <vram_mb> <ordinal> <worker> <name>"; this
# reads it into arrays. CARD_LINES holds the lines; card_field <line> <n> picks a column (the name is the tail).
inventory() { "$IGNEUM_ROOT/bin/igneum-gpus.sh" 2>/dev/null; }
card_field() { printf '%s\n' "$1" | awk -v n="$2" '{ if (n <= 6) print $n; else { s=""; for (i=7;i<=NF;i++) s=s (i>7?" ":"") $i; print s } }'; }
card_line() { inventory | awk -v c="$1" '$1 == c { print; exit }'; }
# The OpenCL device index of a vendor's ordinal-th card, from igneum-worker-opencl --list on stdin. The list prints
# "[<idx>] <name> | <platform> (...)" then "<type>, vendor <vendor>, driver ..." per device (proto-opencl/host.c
# printDevice); OpenCL gives no bus id, so two identical cards of one vendor are told apart by list order only (the
# app makes the same assumption). AMD's vendor string is "Advanced Micro Devices, Inc.", Intel's "Intel(R) Corporation".
opencl_index_for() { # <amd|intel> <ordinal>
local want; want="$( [[ "$1" == amd ]] && echo 'Advanced Micro Devices' || echo 'Intel' )"
awk -v want="$want" -v ord="$2" '
/^[ *]\[[0-9]+\]/ { idx = $0; sub(/^[ *]\[/, "", idx); sub(/\].*/, "", idx); next }
/vendor / && index($0, want) { if (n == ord) { print idx; exit } n++ }'
}
# The miner's identity label for a card (the vote keys are derived from it: VoteSecretKey::from_label in the node,
# "vote keys <label>-1..N" with --identities N, igneum/miner/src/main.rs). The prover derives the same labels.
card_label() { printf '%s-%s\n' "$RIG_NAME" "$1"; }
# Identities per card: IDENTITIES=auto is the app's rule (app/igneum-app/src/detect.rs on proving-v1: 8 for a card
# with 8 GiB or more of VRAM, else 2; a card whose VRAM is unknown counts as big); a number applies to every card.
card_identities() { # <card>
if [[ "$IDENTITIES" =~ ^[0-9]+$ ]]; then printf '%s\n' "$IDENTITIES"; return; fi
local mb; mb="$(card_field "$(card_line "$1")" 4)"
if [[ -z "$mb" || "$mb" == 0 || "$mb" -ge 8192 ]]; then echo 8; else echo 2; fi
}
card_labels_with_identities() { # <card> -> one label per vote key
local base n; base="$(card_label "$1")"; n="$(card_identities "$1")"
if [[ "$n" -gt 1 ]]; then local i; for ((i = 1; i <= n; i++)); do printf '%s-%d\n' "$base" "$i"; done
else printf '%s\n' "$base"; fi
}
# The prover default (app/igneum-app/src/provedefault.rs on branch proving-v1, 5 October 2026): on for an NVIDIA
# card at or above MIN_VRAM_MB = 11,776 (the 12 GB gate, spec 5.1), off otherwise; the biggest qualifying card
# proves. Whether that card keeps mining while it proves: docs/bench-log.md on that branch ("Step 1, the prover
# default and its cost") measured 15.6 GB for the miner and the prover together on one card, so a card with
# PROVER_MINE_AND_PROVE_MB (20,480; a 24 GB card) or more mines and proves at once and a smaller one has its miner
# paused for each shard (PROVER_PAUSE_MINER=auto; always|never force it). PROVER=on|off in rig.conf wins over auto.
prover_decision() { # prints "on <card> <pause|keep> <reason>" or "off <reason>"
local best="" best_mb=0 best_name="" line mb vendor card name mode
while read -r line; do
[[ -z "$line" ]] && continue
vendor="$(card_field "$line" 2)"; card="$(card_field "$line" 1)"; mb="$(card_field "$line" 4)"; name="$(card_field "$line" 7)"
[[ "$vendor" == nvidia ]] || continue
if [[ -n "$PROVER_CARD" && "$card" == "$PROVER_CARD" ]]; then best="$card"; best_mb="$mb"; best_name="$name"; break; fi
if [[ -z "$best" || "$mb" -gt "$best_mb" ]]; then best="$card"; best_mb="$mb"; best_name="$name"; fi
done < <(inventory)
case "$PROVER_PAUSE_MINER" in
always) mode=pause ;; never) mode=keep ;;
*) if [[ -n "$best" && "$best_mb" -ge "$PROVER_MINE_AND_PROVE_MB" ]]; then mode=keep; else mode=pause; fi ;;
esac
case "$PROVER" in
off) printf 'off PROVER=off in rig.conf\n'; return ;;
on) [[ -n "$best" ]] && { printf 'on %s %s PROVER=on in rig.conf: %s (%s MB), miner %s while proving\n' "$best" "$mode" "$best_name" "$best_mb" "$( [[ $mode == pause ]] && echo paused || echo kept )"; return; }
printf 'off PROVER=on but no NVIDIA card\n'; return ;;
esac
if [[ -n "$best" && "$best_mb" -ge "$PROVER_MIN_VRAM_MB" ]]; then
printf 'on %s %s proving on by default: %s (%s MB, gate %s MB) on Linux; its miner is %s while a shard proves (%s MB line)\n' "$best" "$mode" "$best_name" "$best_mb" "$PROVER_MIN_VRAM_MB" "$( [[ $mode == pause ]] && echo paused || echo kept )" "$PROVER_MINE_AND_PROVE_MB"
elif [[ -n "$best" ]]; then
printf 'off proving off by default: the biggest NVIDIA card is %s (%s MB), under the %s MB gate; PROVER=on in rig.conf switches it on\n' "$best_name" "$best_mb" "$PROVER_MIN_VRAM_MB"
else
printf 'off proving off by default: no NVIDIA card (no CUDA prover for AMD or Intel yet)\n'
fi
}
# ---- the relay upload (the apps' path: app/igneum-app/src/update.rs upload_log) ----------------------------------
# POST {label, machine, run_id, lines} to the intake with x-igneum-key; the first line is the IGNEUM-APP header the
# console parses (relay/lib/parse.mjs parseHeader). Lines carrying a dl token never leave the machine.
relay_configured() { [[ -n "$RELAY_INTAKE_URL" && -s "$RELAY_KEY_FILE" ]]; }
relay_header() { printf 'IGNEUM-APP version=rig-%s machine=%s platform=linux node=%s\n' "$(installed_version)" "$ID8" "igneumd_$("$BIN/igneumd" --version 2>/dev/null | awk '{print $2}' | tr -d ' ' || echo unknown)"; }
relay_upload() { # <label> <run_id> <file with the lines>; the header goes first; 256 KiB tail like the app
relay_configured || return 0
local label="$1" run_id="$2" file="$3" key tmp code
key="$(tr -d '[:space:]' < "$RELAY_KEY_FILE")"
tmp="$(mktemp)"
python3 - "$label" "$RIG_NAME-$ID8" "$run_id" "$file" "$(relay_header)" > "$tmp" <<'PY'
import json, sys
label, machine, run_id, path, header = sys.argv[1:6]
data = open(path, "rb").read()
if len(data) > 262144: data = data[-262144:]
lines = [l for l in data.decode("utf-8", "replace").split("\n") if "/dl/" not in l or "/dl/public/" in l]
print(json.dumps({"label": label, "machine": machine, "run_id": run_id, "lines": header + "\n" + "\n".join(lines)}))
PY
code="$(curl -sS --max-time 60 -X POST "$RELAY_INTAKE_URL" -H 'Content-Type: application/json' -H "x-igneum-key: $key" --data-binary "@$tmp" -o /dev/null -w '%{http_code}' 2>/dev/null)"
rm -f "$tmp"
[[ "$code" == 200 ]]
}

View file

@ -0,0 +1,129 @@
#!/usr/bin/env bash
# igneum-telemetry.service: one line per card every TELEMETRY_SECS (5) to the journal, in the shapes the app's telemetry
# branch reads (app/igneum-app/src/engine.rs on opencl-rdna4-telemetry, 5 October 2026):
# nvidia-smi --query-gpu=index,power.draw,temperature.gpu,temperature.memory,power.limit for NVIDIA (the app's
# query, one shot per tick here), written as
# nvidia <i> bus <bus> kind discrete name "<name>" watts W temp_c T mem_temp_c M power_limit_w L fan_pct F util_pct U mclk_mhz X gclk_mhz Y source nvidia-smi
# the amdgpu sysfs for AMD (proto-opencl/gpu-telemetry.c sysfsSample: hwmon power1_average|power1_input, temp1_input,
# fan1_input, pwm1/pwm1_max, pp_dpm_mclk, pp_dpm_sclk, gpu_busy_percent), the helper's exact line:
# amd <i> bus <bus> kind discrete name "<name>" watts W temp_c T fan_rpm R fan_pct F mclk_mhz X gclk_mhz Y util_pct U source sysfs
# the xe/i915 hwmon for Intel Arc in the same shape (power1_input when present, else watts from the energy1_input
# delta; temp from temp1_input or temp2_input; untested, the exact files vary by kernel):
# intel <i> bus <bus> kind discrete name "<name>" watts W temp_c T source sysfs
# Every 30 s the app's status line and every 300 s its stability line per card (relay/lib/parse.mjs parseAppTail):
# status: accepted N blocks (N this run), X MH/s, mining | node N blocks, N peers, synced | up <duration>
# stability: <name>: draw p95 N W, max N W (cap N W), max GPU N C, max memory N C, N samples
# and, when the relay upload is configured, every 60 s the journal tails go to the log intake under the labels the
# console parses (nodelog-linux-<id8>, miner-<vendor>-<id8>-<n>, linux-<id8>). The last telemetry line per card is
# kept in /run/igneum/telemetry/<card> for rig-status.
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=igneum-rig-lib.sh
. /opt/igneum/bin/igneum-rig-lib.sh
load_conf
mkdir -p "$IGNEUM_RUN/telemetry"
SYS="${IGNEUM_SYS_ROOT:-/sys}"
rd() { local v; v="$(cat "$1" 2>/dev/null || true)"; printf '%s' "${v//$'\n'/}"; }
num() { [[ "$1" =~ ^-?[0-9.]+$ ]] && printf '%s' "$1" || printf -- '-'; }
started=$(date +%s)
run_id="linux-$ID8-$started"
declare -A energy_prev=() energy_t=()
# /run/igneum/telemetry/<card>.samples: one "watts gpu_c mem_c" per tick, the last 300 s, for the stability line
hwmon_dir() { local h; for h in "$SYS/bus/pci/devices/$1/hwmon/hwmon"*; do [[ -d "$h" ]] && { printf '%s\n' "$h"; return; }; done; return 0; }
dpm_current() { awk '/\*/ { sub(/.*: */, ""); sub(/Mhz.*/, ""); print; exit }' "$1" 2>/dev/null; }
tick=0
relay_header | sed 's/^/telemetry start: /'
log "telemetry every $TELEMETRY_SECS s; relay upload $(relay_configured && echo "on ($RELAY_INTAKE_URL)" || echo off)"
while :; do
nv_smi=""
if command -v nvidia-smi >/dev/null 2>&1; then nv_smi="$(nvidia-smi --query-gpu=index,pci.bus_id,power.draw,temperature.gpu,temperature.memory,power.limit,fan.speed,utilization.gpu,clocks.mem,clocks.gr --format=csv,noheader,nounits 2>/dev/null || true)"; fi
while read -r line; do
[[ -z "$line" ]] && continue
card="$(card_field "$line" 1)"; vendor="$(card_field "$line" 2)"; bus="$(card_field "$line" 3)"; ord="$(card_field "$line" 5)"; name="$(card_field "$line" 7)"
out=""; w="-"; t="-"; m="-"
case "$vendor" in
nvidia)
row="$(printf '%s\n' "$nv_smi" | awk -F', ' -v b="${bus#0000:}" 'tolower($2) ~ tolower(b)"$" { print; exit }')"
if [[ -n "$row" ]]; then
IFS=', ' read -r _ _ w t m lim fan util mclk gclk <<< "$row"
out="nvidia $ord bus $bus kind discrete name \"$name\" watts $(num "$w") temp_c $(num "$t") mem_temp_c $(num "$m") power_limit_w $(num "$lim") fan_pct $(num "$fan") util_pct $(num "$util") mclk_mhz $(num "$mclk") gclk_mhz $(num "$gclk") source nvidia-smi"
fi ;;
amd)
d="$SYS/bus/pci/devices/$bus"; h="$(hwmon_dir "$bus")"
if [[ -n "$h" ]]; then
uw="$(rd "$h/power1_average")"; [[ -n "$uw" ]] || uw="$(rd "$h/power1_input")"
[[ -n "$uw" ]] && w="$(awk -v v="$uw" 'BEGIN{printf "%.1f", v/1e6}')"
mt="$(rd "$h/temp1_input")"; [[ -n "$mt" ]] && t="$(awk -v v="$mt" 'BEGIN{printf "%.1f", v/1000}')"
rpm="$(rd "$h/fan1_input")"; pwm="$(rd "$h/pwm1")"; pwmmax="$(rd "$h/pwm1_max")"
fp="-"; if [[ -n "$pwm" ]]; then fp="$(awk -v p="$pwm" -v mx="${pwmmax:-255}" 'BEGIN{printf "%.0f", 100*p/(mx>0?mx:255)}')"; fi
else rpm=""; fp="-"; fi
mclk="$(dpm_current "$d/pp_dpm_mclk")"; gclk="$(dpm_current "$d/pp_dpm_sclk")"; util="$(rd "$d/gpu_busy_percent")"
out="amd $ord bus $bus kind discrete name \"$name\" watts $w temp_c $t fan_rpm $(num "${rpm:--}") fan_pct $fp mclk_mhz $(num "${mclk:--}") gclk_mhz $(num "${gclk:--}") util_pct $(num "${util:--}") source sysfs" ;;
intel)
h="$(hwmon_dir "$bus")"
if [[ -n "$h" ]]; then
uw="$(rd "$h/power1_input")"
if [[ -n "$uw" ]]; then w="$(awk -v v="$uw" 'BEGIN{printf "%.1f", v/1e6}')"
else
e="$(rd "$h/energy1_input")"; now=$(date +%s)
if [[ -n "$e" && -n "${energy_prev[$card]:-}" && $((now - energy_t[$card])) -gt 0 ]]; then w="$(awk -v a="${energy_prev[$card]}" -v b="$e" -v dt="$((now - energy_t[$card]))" 'BEGIN{printf "%.1f", (b-a)/1e6/dt}')"; fi
[[ -n "$e" ]] && { energy_prev[$card]="$e"; energy_t[$card]="$now"; }
fi
mt="$(rd "$h/temp1_input")"; [[ -n "$mt" ]] || mt="$(rd "$h/temp2_input")"; [[ -n "$mt" ]] && t="$(awk -v v="$mt" 'BEGIN{printf "%.1f", v/1000}')"
fi
out="intel $ord bus $bus kind discrete name \"$name\" watts $w temp_c $t source sysfs" ;;
esac
[[ -n "$out" ]] || out="$vendor $ord bus $bus kind discrete name \"$name\" watts - temp_c - source none"
printf '%s\n' "$out"
printf '%s\n' "$out" > "$IGNEUM_RUN/telemetry/$card"
printf '%s %s %s\n' "$w" "$t" "$m" >> "$IGNEUM_RUN/telemetry/$card.samples"
done < <(inventory)
tick=$((tick + TELEMETRY_SECS))
# the status line (every 30 s): the miners' last STATUS lines plus the node's watch line
if (( tick % 30 == 0 )); then
acc=0; mhs=0; mining=stopped
while read -r line; do
[[ -z "$line" ]] && continue
c="$(card_field "$line" 1)"
st="$(journalctl -u "igneum-miner@$c" -o cat -n 300 --no-pager 2>/dev/null | grep ' STATUS ' | tail -1 || true)"
[[ -n "$st" ]] || continue
a="$(printf '%s' "$st" | sed -n 's/.* accepted=\([0-9]*\).*/\1/p')"; acc=$((acc + ${a:-0}))
n="$(printf '%s' "$st" | sed -n 's/.* now=\([0-9.]*\) MH\/s.*/\1/p')"; mhs="$(awk -v a="$mhs" -v b="${n:-0}" 'BEGIN{printf "%.2f", a+b}')"; mining=mining
done < <(inventory)
nl="$(node_watch_line)"
blocks="$(node_field "$nl" blocks)"; peers="$(node_field "$nl" peers)"; synced="$(node_field "$nl" synced)"
up=$(( $(date +%s) - started ))
printf '%s status: accepted %s blocks (%s this run), %s MH/s, %s | node %s blocks, %s peers, %s | up %dh %dm\n' "$(date +%s)" "$acc" "$acc" "$mhs" "$mining" "${blocks:-0}" "${peers:-0}" "$( [[ "$synced" == true ]] && echo synced || echo syncing )" $((up / 3600)) $((up % 3600 / 60))
fi
# the stability line (every 300 s) per card, then the samples start over
if (( tick % 300 == 0 )); then
while read -r line; do
[[ -z "$line" ]] && continue
c="$(card_field "$line" 1)"; name="$(card_field "$line" 7)"
cap="$(sed -n 's/.* power_limit_w \([0-9.]*\).*/\1/p' "$IGNEUM_RUN/telemetry/$c" 2>/dev/null)"
sf="$IGNEUM_RUN/telemetry/$c.samples"
[[ -s "$sf" ]] || continue
# p95 of the draw by sorting (mawk has no asort); max draw, max GPU and memory temperature, the sample count
n="$(awk '$1 ~ /^[0-9.]+$/' "$sf" | wc -l | tr -d ' ')"
[[ "$n" -gt 0 ]] || { : > "$sf"; continue; }
p95="$(awk '$1 ~ /^[0-9.]+$/ { print $1 }' "$sf" | sort -n | awk -v n="$n" 'NR == (int(0.95 * n) < 1 ? 1 : int(0.95 * n)) { printf "%d", $1; exit }')"
read -r mx g mm < <(awk '$1 ~ /^[0-9.]+$/ { if ($1+0 > mx) mx = $1+0; if ($2+0 > g) g = $2+0; if ($3+0 > mm) mm = $3+0 } END { printf "%d %d %d\n", mx, g, mm }' "$sf")
printf '%s stability: %s: draw p95 %d W, max %d W (cap %d W), max GPU %d C, max memory %d C, %d samples\n' "$(date +%s)" "$name" "${p95:-0}" "$mx" "${cap%.*}" "$g" "$mm" "$n"
: > "$sf"
done < <(inventory)
fi
# the relay upload (every 60 s): the journal tails, the app's labels (relay/lib/parse.mjs parseLabel)
if (( tick % 60 == 0 )) && relay_configured; then
tmp="$(mktemp)"
journalctl -u igneum-node -o cat -n 1500 --no-pager > "$tmp" 2>/dev/null && relay_upload "nodelog-linux-$ID8" "$run_id" "$tmp" || true
n=0
while read -r line; do
[[ -z "$line" ]] && continue
n=$((n + 1)); c="$(card_field "$line" 1)"; v="$(card_field "$line" 2)"
journalctl -u "igneum-miner@$c" -o cat -n 1500 --no-pager > "$tmp" 2>/dev/null && relay_upload "miner-$v-$ID8-$n" "$run_id" "$tmp" || true
done < <(inventory)
journalctl -u igneum-telemetry -u igneum-prover -u igneum-update -o cat -n 3000 --no-pager > "$tmp" 2>/dev/null && relay_upload "linux-$ID8" "$run_id" "$tmp" || true
rm -f "$tmp"
fi
sleep "$TELEMETRY_SECS"
done

View file

@ -0,0 +1,90 @@
#!/usr/bin/env bash
# igneum-update.service (hourly through igneum-update.timer): fetch the signed manifest, verify it with the OTA public
# key, then (1) the consensus override: when consensus.override differs from /etc/igneum/override-params.json, write
# the new one and restart the node (the HiveOS package's rule, packaging/hive/README.md "every override publish needs a
# package republish", done here without a republish); (2) the package: when the manifest's linux entry (or, in sidecar
# mode, igneum-downloads.json's miner-hive entry) names a newer version, download it, check size and sha256, unpack
# under /opt/igneum/releases/<version>, switch the current symlink and restart. Restarts happen only at a safe moment:
# the prover is not proving (prover.state); a deferral older than 6 h applies anyway (manifest::SAFE_MOMENT_PATIENCE_S
# in the app). After a package switch the node must answer within 90 s, else the symlink goes back and the old release
# restarts. Everything is logged to the journal; `rig-status` shows the last line.
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=igneum-rig-lib.sh
. /opt/igneum/bin/igneum-rig-lib.sh
load_conf
mkdir -p "$IGNEUM_RUN" "$IGNEUM_VAR/updates"
DEFER="$IGNEUM_RUN/update.deferred"
dir="$(mktemp -d)"; trap 'rm -rf "$dir"' EXIT
fetch_manifest "$dir" || { log "update check: manifest unavailable or unverified; nothing changes"; exit 0; }
m="$dir/igneum-app-latest.json"
log "update check: manifest $(manifest_field "$m" 'm.get("version")') ($MANIFEST_VERIFIER), published $(manifest_field "$m" 'm.get("published_at")')"
safe_moment() {
local st; st="$(cat "$IGNEUM_RUN/prover.state" 2>/dev/null || echo idle)"
if [[ "$st" == proving* ]]; then
[[ -f "$DEFER" ]] || date +%s > "$DEFER"
local since; since="$(cat "$DEFER")"
if [[ $(( $(date +%s) - since )) -ge $((6 * 3600)) ]]; then log "update: deferred for 6 h while a shard was proving; applying now"; return 0; fi
log "update: deferred, a shard is proving ($st); next hour"; return 1
fi
return 0
}
restart_all() { # <why>
log "update: restarting the node, the miners and the prover ($1)"
systemctl stop 'igneum-miner@*' igneum-prover 2>/dev/null || true
systemctl restart igneum-node
# a glob only matches loaded units, so the enabled instances are started by name
local c; for c in $(systemctl list-unit-files 'igneum-miner@*.service' --state=enabled --no-legend 2>/dev/null | awk '{print $1}'); do systemctl start "$c" || true; done
systemctl is-enabled igneum-prover >/dev/null 2>&1 && systemctl start igneum-prover || true
rm -f "$DEFER"
}
node_answers() { local _; for _ in $(seq 1 18); do [[ -n "$(node_watch_line)" ]] && return 0; sleep 5; done; return 1; }
# 1. the consensus override
new_override="$(manifest_override "$m")"
cur_override="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1])), sort_keys=True, separators=(",",":")))' "$IGNEUM_ETC/override-params.json" 2>/dev/null || true)"
need_restart=""
if [[ -n "$new_override" && "$new_override" != "$cur_override" ]]; then
log "update: consensus override changed: $cur_override -> $new_override"
if safe_moment; then printf '%s\n' "$new_override" > "$IGNEUM_ETC/override-params.json.new" && mv "$IGNEUM_ETC/override-params.json.new" "$IGNEUM_ETC/override-params.json"; need_restart="consensus override $new_override"; fi
fi
# 2. the package
entry="$(manifest_package "$m")"
source="signed manifest"
if [[ -z "$entry" ]]; then
if [[ "$PACKAGE_SOURCE" == sidecar ]]; then entry="$(sidecar_package || true)"; source="unsigned igneum-downloads.json + .sha256 sidecar"
else log "update: the signed manifest names no linux package and PACKAGE_SOURCE=$PACKAGE_SOURCE; package unchanged"; fi
fi
if [[ -n "$entry" ]]; then
read -r url sha size version <<< "$entry"
cur="$(installed_version)"
if [[ -z "$version" || "$version" == "$cur" ]]; then log "update check: $cur is current ($source)"
elif [[ -d "$IGNEUM_ROOT/releases/$version" && -x "$IGNEUM_ROOT/releases/$version/bin/igneumd" && "$(readlink "$IGNEUM_ROOT/current")" == *"/$version" ]]; then log "update check: $version already current"
else
log "update: $version is published ($source); installed $cur"
f="$IGNEUM_VAR/updates/$(basename "$url")"
if download_verified "$url" "$f" "$sha" "$size" && { [[ "$source" == "signed manifest" ]] || sidecar_matches "$url" "$f"; }; then
stage="$IGNEUM_ROOT/releases/$version.new"; rm -rf "$stage"; mkdir -p "$stage"
tar -C "$stage" --strip-components=1 -xzf "$f" && [[ -x "$stage/bin/igneumd" && -x "$stage/bin/igneum-miner" ]] || { warn "update: the package does not unpack to bin/igneumd and bin/igneum-miner"; rm -rf "$stage"; exit 0; }
printf '%s\n' "$version" > "$stage/version"; chmod -R a+rX "$stage"
rm -rf "$IGNEUM_ROOT/releases/$version"; mv "$stage" "$IGNEUM_ROOT/releases/$version"
log "update: $version downloaded, verified ($source) and unpacked; waiting for a safe moment"
if safe_moment; then
prev="$(readlink "$IGNEUM_ROOT/current" || true)"
ln -sfn "$IGNEUM_ROOT/releases/$version" "$IGNEUM_ROOT/current.new" && mv -T "$IGNEUM_ROOT/current.new" "$IGNEUM_ROOT/current"
restart_all "package $cur -> $version"
if node_answers; then log "[ok] update to $version complete (from $cur)"; need_restart=""
else
warn "update: the $version node did not answer in 90 s; rolling back to $cur"
[[ -n "$prev" ]] && ln -sfn "$prev" "$IGNEUM_ROOT/current.new" && mv -T "$IGNEUM_ROOT/current.new" "$IGNEUM_ROOT/current"
restart_all "rollback to $cur"; need_restart=""
log "update: $version is marked failed (rolled back to $cur)"
fi
fi
else warn "update: $version not applied (download or verification failed)"; fi
fi
fi
[[ -n "$need_restart" ]] && restart_all "$need_restart"
exit 0

34
packaging/linux/bin/rig-status Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# rig-status: per card the hash rate (the miner's last STATUS line, now= MH/s), watts and temperature (the last
# telemetry line), MH/W, accepted and rejected blocks and the unit state; the node's height, peers and sync state
# (igneum-miner watch); the prover's state (/run/igneum/prover.state); the last update line; versions. Read-only.
set -uo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=igneum-rig-lib.sh
. /opt/igneum/bin/igneum-rig-lib.sh
load_conf
unit_state() { systemctl is-active "$1" 2>/dev/null || echo unknown; }
printf 'Igneum rig %s (%s, %s, machine %s); package %s; prover default %s\n' "$RIG_NAME" "$NETWORK" "$(hostname)" "$ID8" "$(installed_version)" "$(prover_decision | cut -d' ' -f1)"
printf '%-8s %-30s %-9s %9s %7s %7s %8s %9s %9s %s\n' CARD NAME UNIT 'MH/s' W 'GPU C' 'MH/W' ACCEPTED REJECTED 'STATUS AGE'
total_mhs=0; total_w=0
while read -r line; do
[[ -z "$line" ]] && continue
c="$(card_field "$line" 1)"; name="$(card_field "$line" 7)"
st="$(journalctl -u "igneum-miner@$c" -o cat -n 400 --no-pager 2>/dev/null | grep ' STATUS ' | tail -1)"
mhs="$(printf '%s' "$st" | sed -n 's/.* now=\([0-9.]*\) MH\/s.*/\1/p')"; [[ -n "$mhs" ]] || mhs="$(printf '%s' "$st" | sed -n 's/.* hash=\([0-9.]*\) MH\/s.*/\1/p')"
acc="$(printf '%s' "$st" | sed -n 's/.* accepted=\([0-9]*\).*/\1/p')"; rej="$(printf '%s' "$st" | sed -n 's/.* rejected=\([0-9]*\).*/\1/p')"
age="-"; t="$(printf '%s' "$st" | awk '{print int($1)}')"; [[ -n "$t" && "$t" -gt 0 ]] && age="$(( $(date +%s) - t ))s"
tel="$(cat "$IGNEUM_RUN/telemetry/$c" 2>/dev/null)"
w="$(printf '%s' "$tel" | sed -n 's/.* watts \([0-9.]*\).*/\1/p')"; tc="$(printf '%s' "$tel" | sed -n 's/.* temp_c \([0-9.]*\).*/\1/p')"
mhw="-"; [[ -n "$w" && -n "$mhs" ]] && mhw="$(awk -v m="$mhs" -v p="$w" 'BEGIN{ if (p > 0) printf "%.3f", m/p; else print "-" }')"
printf '%-8s %-30.30s %-9s %9s %7s %7s %8s %9s %9s %s\n' "$c" "$name" "$(unit_state "igneum-miner@$c")" "${mhs:--}" "${w:--}" "${tc:--}" "$mhw" "${acc:--}" "${rej:--}" "$age"
total_mhs="$(awk -v a="$total_mhs" -v b="${mhs:-0}" 'BEGIN{printf "%.2f", a+b}')"; total_w="$(awk -v a="$total_w" -v b="${w:-0}" 'BEGIN{printf "%.1f", a+b}')"
done < <(inventory)
printf '%-8s %-30s %-9s %9s %7s %7s %8s\n' total '' '' "$total_mhs" "$total_w" '' "$(awk -v m="$total_mhs" -v p="$total_w" 'BEGIN{ if (p > 0) printf "%.3f", m/p; else print "-" }')"
nl="$(node_watch_line)"
if [[ -n "$nl" ]]; then
printf 'node %s: blocks %s, headers %s, daa %s, peers %s, %s\n' "$(unit_state igneum-node)" "$(node_field "$nl" blocks)" "$(node_field "$nl" headers)" "$(node_field "$nl" daa)" "$(node_field "$nl" peers)" "$( [[ "$(node_field "$nl" synced)" == true ]] && echo synced || echo 'not synced' )"
else printf 'node %s: no answer on %s\n' "$(unit_state igneum-node)" "$NODE_URL"; fi
printf 'prover %s: %s\n' "$(unit_state igneum-prover)" "$(cat "$IGNEUM_RUN/prover.state" 2>/dev/null || echo 'no state file yet')"
printf 'telemetry %s; relay upload %s\n' "$(unit_state igneum-telemetry)" "$(relay_configured && echo on || echo off)"
printf 'update timer %s; last: %s\n' "$(unit_state igneum-update.timer)" "$(journalctl -u igneum-update -o cat -n 1 --no-pager 2>/dev/null || echo none)"

80
packaging/linux/check-units.sh Executable file
View file

@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Checks the unit files in units/ the way `systemd-analyze verify` would where it can be run (no systemd on this Mac,
# no Docker on 5 October 2026): sections, every key against the directive lists of systemd.unit(5), systemd.service(5),
# systemd.timer(5) and systemd.exec(5) (the subset used here, copied from the man pages; a key not in the list is a
# failure, so a typo never reaches a rig), the values of the enumerated keys, the ExecStart paths against the scripts
# that install-rig.sh installs, the template specifier, and each unit's references to the others. On a machine with
# systemd it also runs `systemd-analyze verify` on copies of the units. Exit 1 on any failure.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
python3 - "$HERE" <<'PY'
import os, re, sys
here = sys.argv[1]
UNIT = {"Description", "Documentation", "After", "Before", "Wants", "Requires", "BindsTo", "PartOf", "Conflicts", "ConditionPathExists", "StartLimitIntervalSec", "StartLimitBurst"}
INSTALL = {"WantedBy", "RequiredBy", "Alias", "Also"}
EXEC = {"User", "Group", "SupplementaryGroups", "WorkingDirectory", "Environment", "EnvironmentFile", "LimitNOFILE", "Nice", "ProtectSystem", "ProtectHome", "PrivateTmp", "NoNewPrivileges", "ReadWritePaths", "ReadOnlyPaths", "RuntimeDirectory", "RuntimeDirectoryPreserve", "StateDirectory", "StandardOutput", "StandardError", "SyslogIdentifier", "UMask", "OOMScoreAdjust", "TimeoutStartSec", "TimeoutStopSec"}
SERVICE = {"Type", "ExecStart", "ExecStartPre", "ExecStop", "ExecReload", "Restart", "RestartSec", "SuccessExitStatus", "KillMode", "KillSignal", "RemainAfterExit", "TimeoutSec"} | EXEC
TIMER = {"OnBootSec", "OnUnitActiveSec", "OnCalendar", "RandomizedDelaySec", "Persistent", "Unit", "AccuracySec"}
ENUM = {"Type": {"simple", "exec", "forking", "oneshot", "notify", "idle"}, "Restart": {"no", "always", "on-success", "on-failure", "on-abnormal", "on-abort", "on-watchdog"},
"KillMode": {"control-group", "mixed", "process", "none"}, "ProtectSystem": {"true", "false", "full", "strict"}, "ProtectHome": {"true", "false", "read-only", "tmpfs"},
"StandardOutput": {"journal", "inherit", "null", "tty", "kmsg", "journal+console"}, "StandardError": {"journal", "inherit", "null", "tty", "kmsg", "journal+console"},
"RuntimeDirectoryPreserve": {"yes", "no", "restart"}, "Persistent": {"true", "false", "yes", "no"}, "PrivateTmp": {"true", "false", "yes", "no"}, "NoNewPrivileges": {"true", "false", "yes", "no"}}
units = sorted(f for f in os.listdir(os.path.join(here, "units")) if f.endswith((".service", ".timer")))
scripts = set(os.listdir(os.path.join(here, "bin")))
fails = 0
def fail(u, msg):
global fails; fails += 1; print(f" FAIL {u}: {msg}")
names = set(units)
for u in units:
text = open(os.path.join(here, "units", u)).read()
section = None; seen = {}
kind = u.rsplit(".", 1)[1]
template = "@" in u
for n, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line or line.startswith("#"): continue
m = re.fullmatch(r"\[(\w+)\]", line)
if m:
section = m.group(1)
if section not in {"Unit", "Service", "Timer", "Install"}: fail(u, f"line {n}: unknown section [{section}]")
if section == "Service" and kind != "service": fail(u, f"line {n}: [Service] in a {kind}")
if section == "Timer" and kind != "timer": fail(u, f"line {n}: [Timer] in a {kind}")
seen.setdefault(section, {}); continue
if section is None: fail(u, f"line {n}: a key before any section"); continue
if "=" not in line: fail(u, f"line {n}: not key=value: {line}"); continue
k, v = line.split("=", 1); k = k.strip(); v = v.strip()
allowed = {"Unit": UNIT, "Service": SERVICE, "Timer": TIMER, "Install": INSTALL}[section]
if k not in allowed: fail(u, f"line {n}: [{section}] does not take {k}")
if k in ENUM and v not in ENUM[k]: fail(u, f"line {n}: {k}={v} is not one of {sorted(ENUM[k])}")
if k.startswith("Exec"):
path = v.split()[0].lstrip("-@+!")
if not path.startswith("/"): fail(u, f"line {n}: {k} is not an absolute path: {v}")
if path.startswith("/opt/igneum/bin/") and os.path.basename(path) not in scripts: fail(u, f"line {n}: {k} names {path}, which bin/ does not hold")
if "%i" in v and not template: fail(u, f"line {n}: %i in a unit that is not a template")
if k in {"After", "Before", "Wants", "Requires", "BindsTo", "PartOf", "Unit"}:
for ref in v.split():
if ref.startswith("igneum-") and ref not in names and not (ref.endswith(".service") and ref.replace(".service", "") + ".service" in names):
fail(u, f"line {n}: {k} references {ref}, not in units/")
if k in {"RestartSec", "TimeoutStopSec", "TimeoutStartSec", "OnBootSec", "OnUnitActiveSec", "RandomizedDelaySec"} and not re.fullmatch(r"\d+(s|min|h|ms)?(\s+\d+(s|min|h|ms)?)*", v):
fail(u, f"line {n}: {k}={v} is not a time span")
seen[section][k] = v
if kind == "service":
if "Service" not in seen or "ExecStart" not in seen["Service"]: fail(u, "no ExecStart")
if "Install" not in seen and u != "igneum-update.service": fail(u, "no [Install] (the installer enables it)")
if template and "%i" not in seen.get("Service", {}).get("ExecStart", ""): fail(u, "a template whose ExecStart ignores %i")
if seen.get("Service", {}).get("Type") == "oneshot" and "Restart" in seen["Service"] and seen["Service"]["Restart"] != "no": fail(u, "Restart= on a oneshot service")
if kind == "timer":
if "Timer" not in seen: fail(u, "no [Timer]")
unit = seen["Timer"].get("Unit", u.replace(".timer", ".service"))
if unit not in names: fail(u, f"fires {unit}, not in units/")
print(f" ok {u}: sections {', '.join(seen)}; {sum(len(s) for s in seen.values())} keys")
print(f" units: {len(units)} checked, {fails} failure(s)")
sys.exit(1 if fails else 0)
PY
if command -v systemd-analyze >/dev/null 2>&1; then
tmp="$(mktemp -d)"; cp "$HERE"/units/* "$tmp/"
if systemd-analyze verify "$tmp"/*.service "$tmp"/*.timer; then echo " ok systemd-analyze verify"; else echo " FAIL systemd-analyze verify"; exit 1; fi
rm -rf "$tmp"
else
echo " note systemd-analyze is not on this machine; only the static check above ran (run this script on the rig for the real one)"
fi

307
packaging/linux/install-rig.sh Executable file
View file

@ -0,0 +1,307 @@
#!/usr/bin/env bash
# Igneum rig installer for Ubuntu 24.04: an 8-GPU mining and proving rig (NVIDIA through CUDA, AMD and Intel through
# OpenCL) as systemd units, built from the HiveOS package (packaging/hive) and its lessons of 5 October 2026.
#
# sudo packaging/linux/install-rig.sh --wallet 0x<40 hex> --name rig1 [--network devnet|testnet] [--prover auto|on|off]
# [--relay-key-file ~/log-intake-key] [--peers host:port,...] [--dev-fee 1] [--identities auto|N]
# [--allow-sidecar-sha256] [--package-url URL --package-sha256 HEX --package-size N] [--no-start] [--yes]
# packaging/linux/install-rig.sh --preflight-only the checks alone, nothing written (root not needed)
# packaging/linux/install-rig.sh --dry-run ... every step printed, the download and the manifest
# verification run for real into a scratch folder, nothing
# under / is touched (runs on a Mac too)
#
# What it does, in order: preflight (Ubuntu 24.04, drivers and compute runtimes per vendor, nvidia-smi and the OpenCL
# device list printed, RAM, free disk, ports); the wallet and the rig name, asked once; the igneum system user and the
# folders; the signed manifest (Ed25519, the OTA public key) for the consensus override and the package entry; the
# package download with size and sha256 checked; the units; enable and start. Re-running is safe: it keeps rig.conf
# and the machine id, replaces the scripts and units, and installs a newer package only when the manifest names one.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$HERE/bin/igneum-rig-lib.sh"
WALLET_ARG=""; NAME_ARG=""; NETWORK_ARG="devnet"; PROVER_ARG="auto"; RELAY_KEY_ARG=""; PEERS_ARG=""; DEV_FEE_ARG=1; IDENT_ARG=auto
ALLOW_SIDECAR=0; PKG_URL=""; PKG_SHA=""; PKG_SIZE=""; NO_START=0; YES=0; DRY=0; PREFLIGHT_ONLY=0; FORCE=0
MIN_NVIDIA_DRIVER="${MIN_NVIDIA_DRIVER:-580}" # the project lead, 5 October 2026: driver 580 or newer for the RTX 50 series
MIN_FREE_GB=20
while [[ $# -gt 0 ]]; do
case "$1" in
--wallet) WALLET_ARG="$2"; shift 2 ;;
--name) NAME_ARG="$2"; shift 2 ;;
--network) NETWORK_ARG="$2"; shift 2 ;;
--prover) PROVER_ARG="$2"; shift 2 ;;
--relay-key-file) RELAY_KEY_ARG="$2"; shift 2 ;;
--peers) PEERS_ARG="$2"; shift 2 ;;
--dev-fee) DEV_FEE_ARG="$2"; shift 2 ;;
--identities) IDENT_ARG="$2"; shift 2 ;;
--allow-sidecar-sha256) ALLOW_SIDECAR=1; shift ;;
--package-url) PKG_URL="$2"; shift 2 ;;
--package-sha256) PKG_SHA="$2"; shift 2 ;;
--package-size) PKG_SIZE="$2"; shift 2 ;;
--no-start) NO_START=1; shift ;;
--yes) YES=1; shift ;;
--dry-run) DRY=1; shift ;;
--preflight-only) PREFLIGHT_ONLY=1; shift ;;
--force) FORCE=1; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) die "unknown argument: $1 (see --help)" ;;
esac
done
run() { if [[ $DRY == 1 ]]; then printf '[dry-run] %s\n' "$*"; else "$@"; fi; }
step() { printf '\n== %s\n' "$*"; }
ok() { printf ' ok %s\n' "$*"; }
bad() { printf ' FAIL %s\n' "$*"; FAILS=$((FAILS + 1)); }
soft() { printf ' warn %s\n' "$*"; WARNS=$((WARNS + 1)); }
FAILS=0; WARNS=0
if [[ $DRY == 0 && $PREFLIGHT_ONLY == 0 && $(id -u) != 0 ]]; then die "run as root (sudo); --dry-run and --preflight-only need no root"; fi
# ---- 1. preflight -------------------------------------------------------------------------------------------------
step "preflight"
# shellcheck disable=SC1091
os_id="$(. /etc/os-release 2>/dev/null && echo "${ID:-?} ${VERSION_ID:-?}" || echo "unknown")"
if [[ "$os_id" == "ubuntu 24.04" ]]; then ok "Ubuntu 24.04"; else bad "not Ubuntu 24.04 ($os_id); the package was built for glibc 2.27 and newer, the units for systemd (--force to go on)"; fi
if [[ "$(uname -m)" == x86_64 ]]; then ok "x86_64"; else bad "architecture $(uname -m): the package is x86_64 only"; fi
if have systemctl; then ok "systemd $(systemctl --version 2>/dev/null | head -1 | awk '{print $2}')"; else bad "no systemctl"; fi
for t in curl python3 tar sha256sum flock awk sed; do if have "$t"; then ok "$t"; else bad "$t is missing (apt install curl python3 tar coreutils util-linux)"; fi; done
have lspci || soft "lspci is missing (apt install pciutils): Intel card names fall back to the PCI id"
if have openssl && openssl pkeyutl -help 2>&1 | grep -q -- '-rawin'; then ok "openssl $(openssl version | awk '{print $2}') verifies Ed25519 (pkeyutl -rawin)"
elif python3 -c 'import cryptography' 2>/dev/null; then ok "python3 cryptography verifies Ed25519 (openssl here lacks pkeyutl -rawin)"
else bad "no Ed25519 verifier: OpenSSL 3 or python3-cryptography is needed to check the signed manifest"; fi
kernel="$(uname -r)"; ok "kernel $kernel"
mem_kb="$(awk '/MemTotal/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)"; mem_gb=$((mem_kb / 1048576))
if [[ "$mem_gb" -ge 16 ]]; then ok "RAM $mem_gb GB"; elif [[ "$mem_gb" -ge 8 ]]; then soft "RAM $mem_gb GB: mining is fine; the SP1 prover's host side wants more (2.3 GB measured inside WSL2 on 5 October 2026, plus the node); 16 GB or more for proving"; else bad "RAM $mem_gb GB is under 8 GB"; fi
free_gb() { df -Pk "$1" 2>/dev/null | awk 'NR==2 {printf "%d", $4/1048576}'; }
for p in /var/lib /opt; do
g="$(free_gb "$p")"; [[ -n "$g" ]] || g=0
if [[ "$g" -ge "$MIN_FREE_GB" ]]; then ok "$g GB free on $p"; else bad "$g GB free on $p, under $MIN_FREE_GB GB (the chain data and the package)"; fi
done
# ports: the node's P2P port must be free (and open inbound for peers to dial in); RPC and EVM RPC are loopback only
NETWORK="$NETWORK_ARG"
case "$NETWORK" in devnet) P2P_PORT=26611; RPC_PORT=26610 ;; testnet) P2P_PORT=26811; RPC_PORT=26810 ;; *) die "--network must be devnet or testnet" ;; esac
EVM_PORT=$((RPC_PORT + 180))
if have ss; then
for port in "$P2P_PORT" "$RPC_PORT" "$EVM_PORT"; do
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -q ":$port\$"; then
if systemctl is-active igneum-node >/dev/null 2>&1; then ok "port $port is held by the running igneum-node (re-install)"; else bad "port $port is in use by something else"; fi
else ok "port $port free"; fi
done
else soft "ss is missing (iproute2); ports not checked"; fi
if have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then
if ufw status 2>/dev/null | grep -qE "^$P2P_PORT(/tcp)? +ALLOW"; then ok "ufw allows $P2P_PORT/tcp inbound"; else soft "ufw is active and $P2P_PORT/tcp is not allowed: the installer adds the rule (peers dial in on it; mining works without it)"; UFW_OPEN=1; fi
fi
UFW_OPEN="${UFW_OPEN:-0}"
# the cards
step "cards (igneum-gpus.sh)"
INV="$("$HERE/bin/igneum-gpus.sh" 2> >(sed 's/^/ note /' >&2) || true)"
if [[ -z "$INV" ]]; then bad "no NVIDIA, AMD or Intel discrete GPU found in /sys/bus/pci (on a Mac this is expected)"; else printf '%s\n' "$INV" | sed 's/^/ card /'; fi
NV_COUNT="$(printf '%s\n' "$INV" | grep -c '^nvidia' || true)"; AMD_COUNT="$(printf '%s\n' "$INV" | grep -c '^amd' || true)"; INTEL_COUNT="$(printf '%s\n' "$INV" | grep -c '^intel' || true)"
AMD_OK=1; INTEL_OK=1
if [[ "$NV_COUNT" -gt 0 ]]; then
step "NVIDIA ($NV_COUNT cards): driver, CUDA, NVRTC"
if have nvidia-smi; then
drv="$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -1)"
cuda="$(nvidia-smi 2>/dev/null | sed -n 's/.*CUDA Version: \([0-9.]*\).*/\1/p' | head -1)"
if [[ "${drv%%.*}" -ge "$MIN_NVIDIA_DRIVER" ]] 2>/dev/null; then ok "driver $drv (at least $MIN_NVIDIA_DRIVER)"; else bad "driver ${drv:-unknown}: $MIN_NVIDIA_DRIVER or newer is required (the RTX 50 series needs a Blackwell-capable driver; 580 is the floor the project lead set)"; fi
if [[ -n "$cuda" && "${cuda%%.*}" -ge 12 ]]; then ok "CUDA runtime $cuda reported by the driver (12 or newer)"; else soft "nvidia-smi reports CUDA '${cuda:-?}'; the worker needs a CUDA 12 driver"; fi
if ldconfig -p 2>/dev/null | grep -q 'libcuda\.so\.1'; then ok "libcuda.so.1 on the library path"; else bad "libcuda.so.1 is not on the library path (the driver's CUDA library)"; fi
# the worker compiles the hourly program with NVRTC 12 (dlopen libnvrtc.so.12; infra/cross/build-workers-linux.sh);
# a CUDA 13 toolkit ships libnvrtc.so.13, which does not satisfy it
if ldconfig -p 2>/dev/null | grep -q 'libnvrtc\.so\.12'; then ok "libnvrtc.so.12 on the library path"
else bad "libnvrtc.so.12 is missing: install a CUDA 12.x toolkit or the NVRTC 12 redistributable (apt install cuda-nvrtc-12-8 from NVIDIA's Ubuntu 24.04 repository, approximate package name) and run ldconfig"; fi
printf ' nvidia-smi:\n'; nvidia-smi 2>/dev/null | sed 's/^/ | /' || true
else bad "nvidia-smi is missing: no NVIDIA driver (install the $MIN_NVIDIA_DRIVER series or newer from NVIDIA's Ubuntu 24.04 repository)"; fi
fi
if [[ "$AMD_COUNT" -gt 0 ]]; then
step "AMD ($AMD_COUNT cards): amdgpu, ROCm OpenCL"
printf '%s\n' "$INV" | awk '$2 == "amd" {print $3}' | while read -r bus; do if [[ -d "/sys/bus/pci/drivers/amdgpu/$bus" ]]; then ok "amdgpu bound to $bus"; else soft "amdgpu is not bound to $bus"; fi; done
if [[ -f /opt/rocm/.info/version ]]; then ok "ROCm $(cat /opt/rocm/.info/version) (RDNA 4, gfx1201, needs ROCm 6.4 or newer: approximate, from AMD's ROCm release notes)"; else soft "no /opt/rocm/.info/version: ROCm is not installed the usual way (RDNA 4 needs ROCm 6.4 or newer, approximate)"; fi
if ldconfig -p 2>/dev/null | grep -q 'libOpenCL\.so\.1'; then ok "libOpenCL.so.1 on the library path"; else bad "libOpenCL.so.1 is missing (apt install rocm-opencl-runtime or ocl-icd-libopencl1 plus AMD's ICD)"; AMD_OK=0; fi
if ls /etc/OpenCL/vendors/*.icd >/dev/null 2>&1 && grep -qil 'amdocl\|rocm' /etc/OpenCL/vendors/*.icd 2>/dev/null; then ok "AMD OpenCL ICD in /etc/OpenCL/vendors"; else bad "no AMD ICD file in /etc/OpenCL/vendors (rocm-opencl-runtime writes one)"; AMD_OK=0; fi
if [[ "${kernel%%.*}" -ge 7 || ( "${kernel%%.*}" -eq 6 && "$(echo "$kernel" | cut -d. -f2)" -ge 11 ) ]] 2>/dev/null; then ok "kernel $kernel for RDNA 4 (6.11 or newer, approximate)"; else soft "kernel $kernel: RDNA 4 support landed in 6.11 (approximate; Ubuntu 24.04's HWE kernel is newer than its GA 6.8)"; fi
fi
if [[ "$INTEL_COUNT" -gt 0 ]]; then
step "Intel ($INTEL_COUNT cards): xe driver, compute runtime"
printf '%s\n' "$INV" | awk '$2 == "intel" {print $3}' | while read -r bus; do if [[ -d "/sys/bus/pci/drivers/xe/$bus" || -d "/sys/bus/pci/drivers/i915/$bus" ]]; then ok "xe or i915 bound to $bus"; else soft "neither xe nor i915 is bound to $bus"; fi; done
if ls /etc/OpenCL/vendors/*.icd >/dev/null 2>&1 && grep -qil 'intel\|igdrcl' /etc/OpenCL/vendors/*.icd 2>/dev/null; then ok "Intel OpenCL ICD in /etc/OpenCL/vendors ($(dpkg-query -W -f='${Version}' intel-opencl-icd 2>/dev/null || echo 'version unknown'))"; else bad "no Intel ICD (apt install intel-opencl-icd from Intel's compute-runtime repository; the Arc B580 needs a 2025 release, approximate)"; INTEL_OK=0; fi
if [[ "${kernel%%.*}" -ge 7 || ( "${kernel%%.*}" -eq 6 && "$(echo "$kernel" | cut -d. -f2)" -ge 12 ) ]] 2>/dev/null; then ok "kernel $kernel for Battlemage (6.12 or newer, approximate)"; else soft "kernel $kernel: the Arc B580 (Battlemage) needs the xe driver of 6.12 or newer (approximate)"; fi
fi
if have clinfo; then step "OpenCL devices (clinfo -l)"; clinfo -l 2>/dev/null | sed 's/^/ | /' || true; fi
printf '\n preflight: %d failure(s), %d warning(s)\n' "$FAILS" "$WARNS"
if [[ $PREFLIGHT_ONLY == 1 ]]; then exit $(( FAILS > 0 ? 1 : 0 )); fi
if [[ $FAILS -gt 0 && $FORCE == 0 && $DRY == 0 ]]; then die "preflight failed; fix the items above or pass --force"; fi
[[ $FAILS -gt 0 && $DRY == 1 ]] && printf ' (dry run: going on despite the failures)\n'
# ---- 2. what the project lead types: the wallet and the rig name ---------------------------------------------------------------
step "wallet and name"
if [[ -f "$RIG_CONF" ]]; then load_conf; [[ -n "$WALLET" && -z "$WALLET_ARG" ]] && WALLET_ARG="$WALLET"; [[ -n "$RIG_NAME" && -z "$NAME_ARG" ]] && NAME_ARG="$RIG_NAME"; printf ' existing %s read (wallet %s..., name %s)\n' "$RIG_CONF" "${WALLET_ARG:0:8}" "$NAME_ARG"; fi
ask() { local v; if [[ $YES == 1 ]]; then die "$1 is required with --yes"; fi; read -r -p " $2: " v; printf '%s' "$v"; }
[[ -n "$WALLET_ARG" ]] || WALLET_ARG="$(ask --wallet 'payout wallet (0x followed by 40 hex, an EVM address you hold the key for)')"
[[ "$WALLET_ARG" =~ ^0x[0-9a-fA-F]{40}$ ]] || die "the wallet must be 0x followed by 40 hex characters, got '$WALLET_ARG'"
WALLET_ARG="$(printf '%s' "$WALLET_ARG" | tr 'A-F' 'a-f')"
[[ -n "$NAME_ARG" ]] || NAME_ARG="$(ask --name 'rig name (letters, digits, - and _; it labels this rig in payouts and on the console)')"
NAME_ARG="$(printf '%s' "$NAME_ARG" | tr -c 'A-Za-z0-9_-' '-' | cut -c1-32)"
[[ -n "$NAME_ARG" ]] || die "the rig name is empty"
case "$PROVER_ARG" in auto|on|off) ;; *) die "--prover must be auto, on or off" ;; esac
[[ "$DEV_FEE_ARG" =~ ^[0-9]+$ ]] || die "--dev-fee must be a whole percent"
[[ "$IDENT_ARG" == auto || "$IDENT_ARG" =~ ^[0-9]+$ ]] || die "--identities must be auto or a number"
if [[ -n "$RELAY_KEY_ARG" ]]; then [[ -s "$RELAY_KEY_ARG" ]] || die "no relay key file at $RELAY_KEY_ARG"; fi
ok "wallet ${WALLET_ARG:0:10}...${WALLET_ARG: -4}, name $NAME_ARG, network $NETWORK, prover $PROVER_ARG, dev fee ${DEV_FEE_ARG}%, identities $IDENT_ARG, relay upload $( [[ -n "$RELAY_KEY_ARG" ]] && echo on || echo off )"
# ---- 3. user, folders, machine id -----------------------------------------------------------------------------------
step "system user and folders"
if id "$IGNEUM_USER" >/dev/null 2>&1; then ok "user $IGNEUM_USER exists"
else run useradd --system --home-dir "$IGNEUM_VAR" --shell /usr/sbin/nologin --user-group "$IGNEUM_USER"; fi
for g in video render systemd-journal; do if getent group "$g" >/dev/null 2>&1; then run usermod -a -G "$g" "$IGNEUM_USER"; else soft "group $g does not exist here (GPU device access on Ubuntu is through video and render)"; fi; done
run mkdir -p "$IGNEUM_ROOT/bin" "$IGNEUM_ROOT/releases" "$IGNEUM_ETC" "$IGNEUM_VAR/node" "$IGNEUM_VAR/packs" "$IGNEUM_VAR/proving" "$IGNEUM_VAR/updates" "$IGNEUM_RUN"
if [[ -s "$IGNEUM_ETC/machine-id" ]]; then ok "machine id $(cut -c1-8 "$IGNEUM_ETC/machine-id") kept"
else
mid="$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')"
if [[ $DRY == 1 ]]; then printf '[dry-run] write %s/machine-id = %s\n' "$IGNEUM_ETC" "$mid"; else printf '%s\n' "$mid" > "$IGNEUM_ETC/machine-id"; fi
ok "machine id ${mid:0:8} (the console's id8; labels are miner-<vendor>-${mid:0:8}-<n>)"
fi
# ---- 4. the signed manifest: the consensus override and the package ------------------------------------------------
step "signed manifest ($MANIFEST_URL)"
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
if fetch_manifest "$WORK"; then
m="$WORK/igneum-app-latest.json"
ok "signature verifies with the OTA public key ${OTA_PUBLIC_KEY_HEX:0:16}... ($MANIFEST_VERIFIER); version $(manifest_field "$m" 'm.get("version")'), published $(manifest_field "$m" 'm.get("published_at")'), channel $(manifest_field "$m" 'm.get("channel")')"
OVERRIDE="$(manifest_override "$m")"
if [[ -n "$OVERRIDE" ]]; then ok "consensus.override $OVERRIDE (the node's --override-params-file; refreshed hourly by igneum-update)"; else soft "the manifest carries no consensus.override (a fresh testnet needs none; the devnet refuses a node without it)"; fi
ENTRY="$(manifest_package "$m")"
else
bad "the manifest could not be fetched or verified; without it there is no consensus override and no package entry"
[[ $DRY == 1 ]] || die "stopped: the signed manifest is required"
OVERRIDE=""; ENTRY=""; m=""
fi
PACKAGE_SOURCE=signed
if [[ -n "$PKG_URL" ]]; then
[[ "$PKG_SHA" =~ ^[0-9a-fA-F]{64}$ && "$PKG_SIZE" =~ ^[0-9]+$ ]] || die "--package-url needs --package-sha256 (64 hex) and --package-size (bytes)"
ENTRY="$PKG_URL $(printf '%s' "$PKG_SHA" | tr 'A-F' 'a-f') $PKG_SIZE $(basename "$PKG_URL" | sed 's/^igneum-hive-//; s/\.tar\.gz$//')"; PACKAGE_SOURCE=given
ok "package given on the command line (its sha256 is yours to vouch for)"
elif [[ -n "$ENTRY" ]]; then ok "the signed manifest names the Linux package: $ENTRY"
else
soft "the signed manifest names no linux package (5 October 2026: it carries mac and windows only)"
if [[ $ALLOW_SIDECAR == 1 ]]; then
if ENTRY="$(sidecar_package)"; then PACKAGE_SOURCE=sidecar; soft "using the UNSIGNED igneum-downloads.json entry and the .sha256 sidecar over TLS (--allow-sidecar-sha256): $ENTRY"; else bad "igneum-downloads.json names no miner-hive package"; fi
else
bad "no signed package entry; pass --allow-sidecar-sha256 to accept the unsigned .sha256 sidecar from $DL_HOST, or --package-url/--package-sha256/--package-size"
[[ $DRY == 1 ]] || die "stopped: no package to install"
fi
fi
step "package download and verification"
VERSION=""; PKG=""
if [[ -n "$ENTRY" ]]; then
read -r url sha size VERSION <<< "$ENTRY"
[[ -n "$VERSION" ]] || VERSION="$(basename "$url" | sed 's/^igneum-hive-//; s/\.tar\.gz$//')"
dl_dir="$IGNEUM_VAR/updates"; [[ $DRY == 1 ]] && dl_dir="$WORK/updates"
mkdir -p "$dl_dir"
PKG="$dl_dir/$(basename "$url")"
if download_verified "$url" "$PKG" "$sha" "$size"; then
ok "$(basename "$PKG"): $size bytes, sha256 $sha ($PACKAGE_SOURCE)"
if [[ $PACKAGE_SOURCE == sidecar ]]; then if sidecar_matches "$url" "$PKG"; then ok ".sha256 sidecar agrees"; else bad ".sha256 sidecar disagrees with the download"; PKG=""; fi; fi
else bad "download or verification failed"; PKG=""; fi
fi
if [[ -n "$PKG" ]]; then
stage="$WORK/stage"; mkdir -p "$stage"
tar -C "$stage" --strip-components=1 -xzf "$PKG"
for b in igneumd igneum-miner igneum-worker-cuda igneum-worker-opencl; do if [[ -x "$stage/bin/$b" ]]; then ok "bin/$b $(stat -c %s "$stage/bin/$b" 2>/dev/null || stat -f %z "$stage/bin/$b") bytes"; else soft "bin/$b is not in the package"; fi; done
[[ -s "$stage/override-params.json" ]] && ok "override-params.json in the package (offline fallback): $(tr -d ' \n' < "$stage/override-params.json")"
# the glibc floor every ELF wants against this system's glibc (packaging/hive/make-hive-package.sh's gate, read here)
sys_glibc="$(ldd --version 2>/dev/null | head -1 | grep -o '[0-9]*\.[0-9]*$' || echo '?')"
for b in "$stage"/bin/*; do
want="$(python3 -c 'import re,sys; d=open(sys.argv[1],"rb").read(); vs=sorted(set(m.decode() for m in re.findall(rb"GLIBC_\d+\.\d+", d)), key=lambda v: tuple(int(x) for x in v[6:].split("."))); print(vs[-1][6:] if vs else "none")' "$b")"
printf ' glibc %s wants GLIBC_%s (system %s)\n' "$(basename "$b")" "$want" "$sys_glibc"
done
if [[ "$(uname -s)" == Linux && ( "$AMD_COUNT" -gt 0 || "$INTEL_COUNT" -gt 0 ) ]]; then
printf ' OpenCL device list (igneum-worker-opencl --list):\n'; "$stage/bin/igneum-worker-opencl" --list 2>&1 | sed 's/^/ | /' | head -60 || true
fi
REL="$IGNEUM_ROOT/releases/$VERSION"
if [[ -x "$REL/bin/igneumd" ]]; then ok "release $VERSION already unpacked at $REL"
else
run rm -rf "$REL.new"; run mkdir -p "$REL.new"
if [[ $DRY == 1 ]]; then printf '[dry-run] tar -C %s.new --strip-components=1 -xzf %s; write %s.new/version = %s; mv to %s\n' "$REL" "$PKG" "$REL" "$VERSION" "$REL"
else tar -C "$REL.new" --strip-components=1 -xzf "$PKG"; printf '%s\n' "$VERSION" > "$REL.new/version"; chmod -R a+rX "$REL.new"; mv "$REL.new" "$REL"; fi
ok "release $VERSION unpacked at $REL"
fi
run ln -sfn "$REL" "$IGNEUM_ROOT/current"
fi
if [[ -n "$OVERRIDE" ]]; then
if [[ $DRY == 1 ]]; then printf '[dry-run] write %s/override-params.json = %s\n' "$IGNEUM_ETC" "$OVERRIDE"; else printf '%s\n' "$OVERRIDE" > "$IGNEUM_ETC/override-params.json"; fi
fi
# ---- 5. configuration -----------------------------------------------------------------------------------------------
step "configuration ($RIG_CONF)"
conf="$(cat <<CONF
# Igneum rig, written by install-rig.sh on $(ts). Edit, then: systemctl restart igneum-node (the miners follow).
WALLET=$WALLET_ARG
RIG_NAME=$NAME_ARG
NETWORK=$NETWORK
PEERS=$PEERS_ARG
DEV_FEE=$DEV_FEE_ARG
IDENTITIES=$IDENT_ARG
VOTE=1
EXTRA=
PROVER=$PROVER_ARG
PROVER_MIN_VRAM_MB=11776
PROVER_MINE_AND_PROVE_MB=20480
PROVER_PAUSE_MINER=auto
PROVER_CARD=
SYNC_WAIT=3600
TELEMETRY_SECS=5
RELAY_INTAKE_URL=$( [[ -n "$RELAY_KEY_ARG" ]] && printf '%s' "$DEFAULT_INTAKE_URL" )
RELAY_KEY_FILE=$IGNEUM_ETC/log-intake-key
PACKAGE_SOURCE=$PACKAGE_SOURCE
CONF
)"
if [[ $DRY == 1 ]]; then printf '[dry-run] write %s:\n%s\n' "$RIG_CONF" "$(printf '%s\n' "$conf" | sed "s/^WALLET=.*/WALLET=${WALLET_ARG:0:10}.../; s/^/ | /")"
else printf '%s\n' "$conf" > "$RIG_CONF"; chown "root:$IGNEUM_USER" "$RIG_CONF"; chmod 0640 "$RIG_CONF"; fi
if [[ -n "$RELAY_KEY_ARG" ]]; then run install -o root -g "$IGNEUM_USER" -m 0640 "$RELAY_KEY_ARG" "$IGNEUM_ETC/log-intake-key"; ok "relay upload key installed; the console shows this rig as $NAME_ARG-<id8>"; fi
ok "config written"
# ---- 6. scripts, units, sudoers ---------------------------------------------------------------------------------------
step "scripts and units"
for f in "$HERE"/bin/*; do run install -o root -g root -m 0755 "$f" "$IGNEUM_ROOT/bin/$(basename "$f")"; done
run install -o root -g root -m 0755 "$HERE/bin/rig-status" /usr/local/bin/rig-status
run install -o root -g root -m 0644 "$HERE/README.md" "$IGNEUM_ROOT/README.md"
for u in "$HERE"/units/*; do run install -o root -g root -m 0644 "$u" "/etc/systemd/system/$(basename "$u")"; done
sudoers="$IGNEUM_USER ALL=(root) NOPASSWD: /usr/bin/systemctl stop igneum-miner@*, /usr/bin/systemctl start igneum-miner@*"
if [[ $DRY == 1 ]]; then printf '[dry-run] write /etc/sudoers.d/igneum-rig: %s\n' "$sudoers"; else printf '%s\n' "$sudoers" > /etc/sudoers.d/igneum-rig; chmod 0440 /etc/sudoers.d/igneum-rig; fi
run chown -R "$IGNEUM_USER:$IGNEUM_USER" "$IGNEUM_VAR" "$IGNEUM_RUN"
[[ "$UFW_OPEN" == 1 ]] && run ufw allow "$P2P_PORT/tcp" comment 'igneum p2p'
run systemctl daemon-reload
ok "units installed"
# the instances: one miner per usable card; the prover by the default rule
step "enable"
INSTANCES=()
while read -r line; do
[[ -z "$line" ]] && continue
c="$(awk '{print $1}' <<< "$line")"; v="$(awk '{print $2}' <<< "$line")"
case "$v" in amd) [[ $AMD_OK == 1 ]] || { soft "$c skipped: no AMD OpenCL runtime"; continue; } ;; intel) [[ $INTEL_OK == 1 ]] || { soft "$c skipped: no Intel OpenCL runtime"; continue; } ;; esac
INSTANCES+=("igneum-miner@$c.service")
done <<< "$INV"
run systemctl enable igneum-node.service igneum-telemetry.service igneum-update.timer
[[ ${#INSTANCES[@]} -gt 0 ]] && run systemctl enable "${INSTANCES[@]}"
if [[ $DRY == 0 ]]; then
PROVER="$PROVER_ARG"; load_conf; decision="$(prover_decision)"
else decision="(decided on the rig from its cards)"; fi
case "$decision" in on*) run systemctl enable igneum-prover.service; ok "prover: $decision" ;; *) run systemctl disable igneum-prover.service 2>/dev/null || true; ok "prover: $decision" ;; esac
ok "enabled: igneum-node, ${#INSTANCES[@]} miner instance(s) (${INSTANCES[*]:-none}), igneum-telemetry, igneum-update.timer"
if [[ $NO_START == 0 ]]; then
step "start"
run systemctl start igneum-node.service igneum-telemetry.service igneum-update.timer
[[ ${#INSTANCES[@]} -gt 0 ]] && run systemctl start "${INSTANCES[@]}"
case "$decision" in on*) run systemctl start igneum-prover.service ;; esac
ok "started; the miners wait for the node to sync (SYNC_WAIT 3600 s) before they mine"
fi
step "done"
cat <<TXT
rig-status per-card hash rate, watts, temperature, MH/W; node height and peers; prover
journalctl -fu igneum-node the node
journalctl -fu igneum-miner@nvidia0 one card (STATUS every 30 s: now=<MH/s>, accepted, rejected)
journalctl -fu igneum-telemetry one line per card every ${TELEMETRY_SECS:-5} s, status every 30 s, stability every 5 min
systemctl start igneum-update an update check now (hourly otherwise, at a safe moment)
$IGNEUM_ROOT/README.md what is assumed and what is untested until a rig exists
TXT

106
packaging/linux/selftest.sh Executable file
View file

@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Self-test of the rig package on a machine that is not the rig (the Mac, 5 October 2026): bash -n and shellcheck on
# every script, the unit files through check-units.sh, the card inventory on a fake sysfs tree (NVIDIA, AMD, Intel,
# an AMD APU, an Intel iGPU and an ASPEED BMC VGA, the last three excluded), the OpenCL index mapping on a fake
# --list, the prover default rule and the identities rule on fake inventories, the Ed25519 check against the LIVE
# signed manifest (and a tampered copy refused), then the installer's --dry-run end to end, which downloads and
# verifies the live package into a scratch folder. Nothing under / is touched. Needs network for the last two.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
pass() { printf ' ok %s\n' "$*"; }
fail() { printf ' FAIL %s\n' "$*"; exit 1; }
echo "== bash -n and shellcheck"
for f in "$HERE"/install-rig.sh "$HERE"/selftest.sh "$HERE"/check-units.sh "$HERE"/bin/*; do bash -n "$f" || fail "bash -n $f"; done
pass "bash -n on $(find "$HERE/bin" -type f | wc -l | tr -d ' ') scripts plus the installer, the checker and this test"
if command -v shellcheck >/dev/null 2>&1; then
shellcheck -x -S style "$HERE"/install-rig.sh "$HERE"/selftest.sh "$HERE"/check-units.sh "$HERE"/bin/* || fail "shellcheck"
pass "shellcheck $(shellcheck --version | awk '/version:/ {print $2}') clean at -S style"
else echo " note shellcheck is not installed (brew install shellcheck); skipped"; fi
echo "== unit files"
"$HERE/check-units.sh" || fail "check-units.sh"
echo "== card inventory on a fake sysfs tree"
S="$T/sys"; P="$S/bus/pci/devices"; mkdir -p "$P" "$S/bus/pci/drivers/amdgpu" "$S/bus/pci/drivers/xe"
mk() { # <bus> <class> <vendor> <device> [vram bytes] [product]
mkdir -p "$P/$1"; printf '%s\n' "$2" > "$P/$1/class"; printf '%s\n' "$3" > "$P/$1/vendor"; printf '%s\n' "$4" > "$P/$1/device"
[[ -n "${5:-}" ]] && printf '%s\n' "$5" > "$P/$1/mem_info_vram_total"; [[ -n "${6:-}" ]] && printf '%s\n' "$6" > "$P/$1/product_name"; true
}
mk 0000:01:00.0 0x030000 0x10de 0x2b85 # RTX 5090
mk 0000:21:00.0 0x030000 0x10de 0x2b85 # RTX 5090
mk 0000:41:00.0 0x030000 0x1002 0x7550 17163091968 "AMD Radeon RX 9070 XT"
mk 0000:42:00.0 0x030000 0x1002 0x7550 17163091968 "AMD Radeon RX 9070 XT"
mk 0000:61:00.0 0x030000 0x8086 0xe20b # Arc B580
mk 0000:00:02.0 0x030000 0x8086 0x7d55 # Intel iGPU, excluded
mk 0000:0c:00.0 0x030000 0x1002 0x164e 536870912 "AMD Radeon Graphics" # APU, excluded
mk 0000:03:00.0 0x030000 0x1a03 0x2000 # ASPEED BMC VGA, excluded
mk 0000:05:00.0 0x020000 0x8086 0x1521 # a NIC, not a display device
inv="$(IGNEUM_SYS_ROOT="$S" "$HERE/bin/igneum-gpus.sh" 2> "$T/notes")"
printf '%s\n' "$inv" | sed 's/^/ card /'; sed 's/^/ note /' "$T/notes"
[[ "$(printf '%s\n' "$inv" | wc -l | tr -d ' ')" == 5 ]] || fail "expected 5 cards, got: $inv"
printf '%s\n' "$inv" | grep -q '^nvidia1 nvidia 0000:21:00.0 0 1 cuda NVIDIA 0x2b85$' || fail "nvidia1 line"
printf '%s\n' "$inv" | grep -q '^amd1 amd 0000:42:00.0 16368 1 opencl AMD Radeon RX 9070 XT$' || fail "amd1 line"
printf '%s\n' "$inv" | grep -q '^intel0 intel 0000:61:00.0 0 0 opencl Intel 0xe20b$' || fail "intel0 line"
if ! { grep -q 'skip 0000:00:02.0: Intel integrated' "$T/notes" && grep -q 'skip 0000:0c:00.0: AMD integrated' "$T/notes" && grep -q 'skip 0000:03:00.0: display device of vendor 0x1a03' "$T/notes"; }; then fail "exclusions"; fi
pass "5 cards in PCI order per vendor; the iGPU, the APU and the BMC VGA excluded; the NIC ignored"
echo "== library rules on the fake inventory"
# shellcheck source=bin/igneum-rig-lib.sh
. "$HERE/bin/igneum-rig-lib.sh"
IGNEUM_ROOT="$T/root"; mkdir -p "$IGNEUM_ROOT/bin"; cp "$HERE/bin/igneum-gpus.sh" "$IGNEUM_ROOT/bin/"
export IGNEUM_SYS_ROOT="$S"
IGNEUM_ETC="$T/etc"; mkdir -p "$IGNEUM_ETC"; RIG_CONF="$IGNEUM_ETC/rig.conf"
printf 'WALLET=0xdfaea6000000000000000000000000000000002c2e\nRIG_NAME=rig1\nNETWORK=devnet\n' > "$RIG_CONF"
load_conf
[[ "$(card_identities nvidia0)" == 8 && "$(card_identities amd0)" == 8 ]] || fail "identities auto: big cards get 8"
mk 0000:22:00.0 0x030000 0x10de 0x2882 ; nv_small="$(IGNEUM_SYS_ROOT="$S" "$HERE/bin/igneum-gpus.sh" 2>/dev/null | grep -c '^nvidia')"; [[ "$nv_small" == 3 ]] || fail "third NVIDIA card"
pass "identities: auto gives 8 to a card of 8 GiB or more (and to one whose VRAM is unknown), as the app's rule"
printf 'IDENTITIES=2\n' >> "$RIG_CONF"; load_conf; [[ "$(card_identities nvidia0)" == 2 ]] || fail "IDENTITIES=2 applies"; pass "IDENTITIES=2 applies to every card"
[[ "$(card_labels_with_identities amd1 | tr '\n' ' ')" == "rig1-amd1-1 rig1-amd1-2 " ]] || fail "labels"; pass "vote-key labels rig1-amd1-1, rig1-amd1-2 (the miner's <label>-<i> rule)"
# the prover rule: no VRAM known for the fake NVIDIA cards (nvidia-smi absent) -> off by default; PROVER=on picks one
d="$(prover_decision)"; [[ "$d" == off* ]] || fail "prover default with unknown VRAM: $d"
printf 'PROVER=on\n' >> "$RIG_CONF"; load_conf; d="$(prover_decision)"; [[ "$d" == "on nvidia0 pause PROVER=on"* ]] || fail "PROVER=on: $d"
pass "prover: off by default when no NVIDIA VRAM is known; PROVER=on takes nvidia0 and pauses its miner under the 20,480 MB line"
rm -r "$P/0000:22:00.0"
printf 'PROVER=auto\nPROVER_PAUSE_MINER=never\n' >> "$RIG_CONF"; load_conf; [[ "$(prover_decision)" == off* ]] || fail "auto stays off"
pass "prover_decision output shape: $(prover_decision | cut -c1-60)..."
echo "== OpenCL index mapping on a fake --list"
fake_list="$(cat <<'L'
[0] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))
GPU, vendor Advanced Micro Devices, Inc., driver 3649.0, OpenCL C 2.0, 64 compute units, 2970 MHz
[1] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))
GPU, vendor Advanced Micro Devices, Inc., driver 3649.0, OpenCL C 2.0, 64 compute units, 2970 MHz
[2] Intel(R) Arc(TM) B580 Graphics | Intel(R) OpenCL Graphics (OpenCL 3.0)
GPU, vendor Intel(R) Corporation, driver 25.05.32567, OpenCL C 1.2, 160 compute units, 2850 MHz
[3] Intel(R) Arc(TM) B580 Graphics | Intel(R) OpenCL Graphics (OpenCL 3.0)
GPU, vendor Intel(R) Corporation, driver 25.05.32567, OpenCL C 1.2, 160 compute units, 2850 MHz
L
)"
[[ "$(printf '%s\n' "$fake_list" | opencl_index_for amd 1)" == 1 && "$(printf '%s\n' "$fake_list" | opencl_index_for intel 0)" == 2 && "$(printf '%s\n' "$fake_list" | opencl_index_for intel 1)" == 3 && -z "$(printf '%s\n' "$fake_list" | opencl_index_for amd 2)" ]] || fail "opencl_index_for"
pass "amd1 -> OpenCL 1, intel0 -> 2, intel1 -> 3, amd2 -> none"
echo "== the live signed manifest (Ed25519 with the OTA public key)"
unset IGNEUM_SYS_ROOT
fetch_manifest "$T/live" || fail "fetch_manifest"
manifest="$T/live/igneum-app-latest.json"
pass "signature verifies ($MANIFEST_VERIFIER): version $(manifest_field "$manifest" 'm.get("version")'), override $(manifest_override "$manifest")"
cp "$manifest" "$T/live/tampered.json"; printf ' ' >> "$T/live/tampered.json"
if verify_manifest_signature "$T/live/tampered.json" "$manifest.sig"; then fail "a tampered manifest verified"; else pass "a tampered manifest is refused"; fi
printf '%s' "$(tr -d '[:space:]' < "$manifest.sig" | sed 's/^./0/')" > "$T/live/bad.sig"
if verify_manifest_signature "$manifest" "$T/live/bad.sig"; then fail "a bad signature verified"; else pass "a bad signature is refused"; fi
if [[ -z "$(manifest_package "$manifest")" ]]; then pass "the manifest names no linux package today (sidecar mode needed, as documented)"; else pass "the manifest names a linux package: $(manifest_package "$manifest")"; fi
echo "== install-rig.sh --dry-run (downloads and verifies the live package into a scratch folder)"
IGNEUM_ROOT="$T/opt" IGNEUM_ETC="$T/etc2" IGNEUM_VAR="$T/var" IGNEUM_RUN="$T/run" "$HERE/install-rig.sh" --dry-run --yes --wallet 0xDFAEA60000000000000000000000000000002C2E --name "rig 1" --allow-sidecar-sha256 --prover auto > "$T/dry.out" 2>&1 || { cat "$T/dry.out"; fail "dry run exited non-zero"; }
grep -q 'downloaded and verified: igneum-hive-' "$T/dry.out" || { cat "$T/dry.out"; fail "no verified download in the dry run"; }
grep -q '\.sha256 sidecar agrees' "$T/dry.out" || fail "sidecar check"
if ! { grep -q 'bin/igneumd .* bytes' "$T/dry.out" && grep -q 'bin/igneum-worker-opencl .* bytes' "$T/dry.out"; }; then fail "package contents"; fi
grep -q '\[dry-run\] systemctl enable igneum-node.service' "$T/dry.out" || fail "enable step"
if ! { grep -q 'WALLET=0xdfaea600' "$T/dry.out" && grep -q 'RIG_NAME=rig-1' "$T/dry.out"; }; then fail "config step (wallet lower-cased, name sanitised)"; fi
grep -c '^\[dry-run\]' "$T/dry.out" | sed 's/^/ dry-run steps printed: /'
grep -E 'glibc igneumd|glibc igneum-miner' "$T/dry.out" | sed 's/^ */ /'
pass "dry run: preflight (fails as expected off the rig), manifest, download, verification, config, units, enable, start all printed"
echo "== self-test passed (scripts, units, inventory, rules, the live manifest and package; the rig itself is untested)"

View file

@ -0,0 +1,35 @@
[Unit]
Description=Igneum miner on card %i (igneum-miner with the CUDA or OpenCL worker)
Documentation=file:///opt/igneum/README.md
After=igneum-node.service
Requires=igneum-node.service
PartOf=igneum-node.service
[Service]
Type=simple
User=igneum
Group=igneum
SupplementaryGroups=video render
ExecStart=/opt/igneum/bin/igneum-miner.sh %i
WorkingDirectory=/var/lib/igneum
Environment=CUDA_DEVICE_ORDER=PCI_BUS_ID
Restart=always
RestartSec=5
SuccessExitStatus=42
TimeoutStopSec=20
KillMode=control-group
KillSignal=SIGTERM
LimitNOFILE=65536
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadWritePaths=/var/lib/igneum /run/igneum
RuntimeDirectory=igneum
RuntimeDirectoryPreserve=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=igneum-miner-%i
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,31 @@
[Unit]
Description=Igneum node (igneumd, bundled with the rig package)
Documentation=file:///opt/igneum/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=igneum
Group=igneum
ExecStart=/opt/igneum/bin/igneum-node.sh
WorkingDirectory=/var/lib/igneum
Restart=always
RestartSec=10
TimeoutStopSec=45
KillSignal=SIGTERM
LimitNOFILE=65536
Nice=0
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadWritePaths=/var/lib/igneum /run/igneum
RuntimeDirectory=igneum
RuntimeDirectoryPreserve=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=igneum-node
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,34 @@
[Unit]
Description=Igneum shard prover (SP1 CUDA on the biggest NVIDIA card)
Documentation=file:///opt/igneum/README.md
After=igneum-node.service
Requires=igneum-node.service
PartOf=igneum-node.service
[Service]
Type=simple
User=igneum
Group=igneum
SupplementaryGroups=video render
ExecStart=/opt/igneum/bin/igneum-prover.sh
WorkingDirectory=/var/lib/igneum
Environment=CUDA_DEVICE_ORDER=PCI_BUS_ID
Restart=always
RestartSec=30
TimeoutStopSec=30
KillMode=control-group
KillSignal=SIGTERM
LimitNOFILE=65536
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
# no NoNewPrivileges: the prover pauses and resumes the miner on its card through the sudoers rule the installer writes
ReadWritePaths=/var/lib/igneum /run/igneum
RuntimeDirectory=igneum
RuntimeDirectoryPreserve=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=igneum-prover
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,28 @@
[Unit]
Description=Igneum rig telemetry (one line per card to the journal, status and stability lines, relay upload)
Documentation=file:///opt/igneum/README.md
After=igneum-node.service
[Service]
Type=simple
User=igneum
Group=igneum
SupplementaryGroups=video render systemd-journal
ExecStart=/opt/igneum/bin/igneum-telemetry.sh
WorkingDirectory=/var/lib/igneum
Restart=always
RestartSec=10
TimeoutStopSec=10
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadWritePaths=/var/lib/igneum /run/igneum
RuntimeDirectory=igneum
RuntimeDirectoryPreserve=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=igneum-telemetry
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,16 @@
[Unit]
Description=Igneum rig update check (signed manifest: consensus override and package, applied at a safe moment)
Documentation=file:///opt/igneum/README.md
After=network-online.target igneum-node.service
Wants=network-online.target
[Service]
Type=oneshot
User=root
ExecStart=/opt/igneum/bin/igneum-update.sh
WorkingDirectory=/var/lib/igneum
TimeoutStartSec=2400
PrivateTmp=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=igneum-update

View file

@ -0,0 +1,12 @@
[Unit]
Description=Igneum rig update check, hourly
[Timer]
OnBootSec=15min
OnUnitActiveSec=1h
RandomizedDelaySec=10min
Persistent=true
Unit=igneum-update.service
[Install]
WantedBy=timers.target

View file

@ -85,13 +85,14 @@ async function machines(sql) {
// the app log's tail is wide (400 KB, about three hours on a PC that logs every block) so a stuck update's lines do
// not scroll out of the card: on 4 October 2026 PC 1's "installing 0.3.4" left the 60 KB window after 30 minutes
const rows = await sql(`SELECT DISTINCT ON (label) label, machine, run_id, received_at, left(lines, 300) AS head,
right(lines, CASE WHEN label LIKE 'nodelog-%' THEN 80000 WHEN label ~ '^(win|mac)-[0-9a-f]{8}$' THEN 400000 ELSE 20000 END) AS tail
right(lines, CASE WHEN label LIKE 'nodelog-%' THEN 80000 WHEN label ~ '^(win|mac|linux)-[0-9a-f]{8}$' THEN 400000 ELSE 20000 END) AS tail
FROM miner_logs WHERE received_at > now() - interval '36 hours' ORDER BY label, received_at DESC`);
const groups = new Map();
for (const r of rows) {
const p = parseLabel(r.label, r.machine);
if (!p) continue;
const g = groups.get(p.id) || { id: p.id, platform: p.platform, hosts: new Set(), runs: new Set(), last_seen: null, node: {}, cards: [], app: null, labels: [], header: null };
if (p.stream !== 'miner' && p.platform !== g.platform) g.platform = p.platform; // a miner label carries no os; the node or app log decides (a Linux rig)
const hd = parseHeader(r.head) || parseHeader(r.tail); if (hd) g.header = hd;
g.hosts.add(r.machine.replace(/-[0-9a-f]{8}$/, ''));
g.runs.add(r.run_id);
@ -121,7 +122,7 @@ async function machines(sql) {
const silent_s = g.last_seen ? Math.round((now - new Date(g.last_seen).getTime()) / 1000) : null;
return {
id: g.id, id8, platform: g.platform,
name: id8 ? (NAMES[id8] || (g.platform === 'mac' ? 'Mac' : 'PC ' + id8)) : g.id.slice(7),
name: id8 ? (NAMES[id8] || (g.platform === 'mac' ? 'Mac' : g.platform === 'linux' ? (([...g.hosts][0] || 'Rig') + ' ' + id8) : 'PC ' + id8)) : g.id.slice(7),
host: [...g.hosts][0] || null, legacy: !id8,
last_seen: g.last_seen, silent_s, silent: silent_s === null || silent_s > SILENT_S,
stopped: (silent_s === null || silent_s > SILENT_S) && g.app && g.app.stopped ? g.app.stopped : null,

View file

@ -23,11 +23,13 @@ export function markStale(cards, now = Date.now()) {
// Labels the app uploads: nodelog-<os>-<id8>, miner-<vendor>-<id8>-<n> (vendor as the app names the worker: nvidia, amd,
// mac/metal, opencl, and other/intel for the OpenCL fallback on an iGPU; 4 October 2026: PC 37ba0461's other-* worker
// was dropped and the card showed 0 MH/s), <os>-<id8> for the app log; the launchers before the app are "legacy".
// The os is win, mac or linux (linux since 5 October 2026: the Ubuntu rig units, packaging/linux, upload the same
// labels; a miner label carries no os, so its platform is win unless the group's node or app log says otherwise).
export function parseLabel(label, machine) {
let m;
if ((m = /^nodelog-(win|mac)-([0-9a-f]{8})$/.exec(label))) return { id: m[2], platform: m[1], stream: 'node' };
if ((m = /^nodelog-(win|mac|linux)-([0-9a-f]{8})$/.exec(label))) return { id: m[2], platform: m[1], stream: 'node' };
if ((m = /^miner-(nvidia|amd|mac|metal|opencl|other|intel)-([0-9a-f]{8})-(\d+)$/.exec(label))) return { id: m[2], platform: m[1] === 'mac' || m[1] === 'metal' ? 'mac' : 'win', stream: 'miner', vendor: m[1], card: Number(m[3]) };
if ((m = /^(win|mac)-([0-9a-f]{8})$/.exec(label))) return { id: m[2], platform: m[1], stream: 'app' };
if ((m = /^(win|mac|linux)-([0-9a-f]{8})$/.exec(label))) return { id: m[2], platform: m[1], stream: 'app' };
// the launchers before the app: nodelog-<host>, nvidia-<host>[-n], amd-<host>[-n], igneum-<host>, launcher-<host>
if ((m = /^nodelog-(.+)$/.exec(label))) return { id: 'legacy:' + m[1], platform: 'legacy', stream: 'node' };
if ((m = /^(nvidia|amd)-(.+?)(?:-(\d+))?$/.exec(label))) return { id: 'legacy:' + m[2], platform: 'legacy', stream: 'miner', vendor: m[1], card: Number(m[3] || 1) };

View file

@ -10,6 +10,10 @@ test('labels: every vendor the app names, the app log, the node log, the legacy
assert.deepEqual(parseLabel('miner-mac-d937c69d-1'), { id: 'd937c69d', platform: 'mac', stream: 'miner', vendor: 'mac', card: 1 });
assert.deepEqual(parseLabel('nodelog-win-1ccfe586'), { id: '1ccfe586', platform: 'win', stream: 'node' });
assert.deepEqual(parseLabel('win-1ccfe586'), { id: '1ccfe586', platform: 'win', stream: 'app' });
// the Ubuntu rig (packaging/linux, 5 October 2026): the same labels with os linux
assert.deepEqual(parseLabel('nodelog-linux-9f1c2a3b'), { id: '9f1c2a3b', platform: 'linux', stream: 'node' });
assert.deepEqual(parseLabel('linux-9f1c2a3b'), { id: '9f1c2a3b', platform: 'linux', stream: 'app' });
assert.deepEqual(parseLabel('miner-amd-9f1c2a3b-5'), { id: '9f1c2a3b', platform: 'win', stream: 'miner', vendor: 'amd', card: 5 });
assert.equal(parseLabel('nodelog-DESKTOP-KMCV30N').platform, 'legacy');
assert.equal(parseLabel('job-run'), null);
});