From 9f80320cea44e13c6556d13a544ad10ba9625035 Mon Sep 17 00:00:00 2001 From: "pithead-developer[bot]" <328836012+pithead-developer[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:07:05 -0600 Subject: [PATCH 1/7] Preserve optional XMRig pool fields Part of #527. Implemented by an operator-run gpt-6-astra session at the owner's request. Co-Authored-By: Claude Opus 5.5 --- config.reference.json | 18 +++- docs/configuration.md | 39 +++++--- docs/operations.md | 4 +- rigforge.sh | 53 +++++----- tests/run.sh | 3 +- tests/test-pool-fields.sh | 202 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 276 insertions(+), 43 deletions(-) create mode 100644 tests/test-pool-fields.sh diff --git a/config.reference.json b/config.reference.json index f3f1c3e..b47b9e7 100644 --- a/config.reference.json +++ b/config.reference.json @@ -1,8 +1,22 @@ { - "_docs": "Reference for every config.json key, each shown with its default value. Copy ONLY the keys you actually want to change into config.json; any key you omit keeps the default shown here. The pool target is the native XMRig 'pools' array: each pool needs a 'url' (host:port); every other field falls back to a Pithead-friendly default. A pool's 'user' is the rig's label on the dashboard (defaults to the machine hostname); list multiple entries for failover. A pool's 'socks5' (default null) dials that pool through a SOCKS5 proxy given as host:port \u2014 e.g. \"127.0.0.1:9050\" for a local Tor client, which is how a rig reaches a stratum published as an onion service; XMRig sends the hostname to the proxy, so the proxy resolves it. RigForge points the miner at a proxy, it does not install or run one. An empty ACCESS_TOKEN (the default) leaves the rig's read-only HTTP API open (no token) — which matches Pithead's default no-auth stats probe; set a value only to require a Bearer token (then match it dashboard-side). 'ACCESS_TOKENS' (default {}) is an optional name -> token map, one token per Pithead stack that consumes this rig, so rotating the bench's token never touches production's: every entry is accepted on the sister API (:8081, raw or its own HMAC-derived read bearer) and the writable control path (:8082, raw), while XMRig's own :8080 and the control path's pools restore stay ACCESS_TOKEN's alone — a stack that probes :8080 directly must hold the master. 'autotune' is one of \"disabled\" (default — no scheduled tuning), \"performance\" (monthly tune for raw hashrate), or \"efficiency\" (monthly tune for hashrate-per-watt). 'watchdog' set to \"enabled\" installs a timer that health-checks the miner every 'watchdog_interval_min' minutes (default 5) and restarts it after two consecutive checks see 0 H/s or a dead API; 'max_temp_c' (empty = off) additionally stops the miner above that °C and starts it again 5°C below — check what your board's thermal_zone0 reports before setting it. 'miner_user' (empty = run as root, the default) runs the miner as that dedicated non-root system user — RigForge applies the MSR preset root-side; on CPU families without a known preset the ~10-15% MSR boost is skipped. 'api_allow_from' (empty = off) scopes the API port(s) — including the writable control port when 'control' is enabled — to one IPv4 or IPv6 address/CIDR source + loopback via an own nftables table (Linux, needs nft; for an IPv6 source the served port must bind a v6 address, so set 'api_bind'/'control_bind' to \"::\"). Set 'api' to enabled for the sister API: a second read-only port (default 8081) serving XMRig's summary enriched with RigForge tune/power/health/provenance data, gated by the same ACCESS_TOKEN; 'api_port'/'api_bind' place it. Set 'control' to \"enabled\" for the WRITABLE control path (#236): a SEPARATE authenticated port (default 8082, 'control_port'/'control_bind' place it) that lets a Pithead stack apply config changes through RigForge so config.json stays authoritative. Fail-closed: enabling it REQUIRES both ACCESS_TOKEN and api_allow_from (a writable API with no token or no pinned source is refused). Only pools, DONATION, autotune, watchdog(+interval), and max_temp_c are writable through it (and the REMOTE path refuses to disable watchdog or to unset/out-of-band max_temp_c — a rig's thermal protection can only be removed by a local rigforge.sh apply, #257); every change is validated, the old config is snapshotted to config-backups/, and a change that doesn't come back live is rolled back. Set 'control_upgrade' to \"enabled\" (a SECOND opt-in, only valid when 'control' is also enabled) to let the stack trigger this rig to upgrade its own RigForge to the latest release (#308, ADR 0002) — i.e. fetch and run new root code on a remote trigger. Default off, and never on just because 'control' is: the rig refuses any target that isn't a real release newer than the one running, verifies the release commit is reachable on main (the release branch), throttles repeat runs, and health-gates the swap with rollback. It rides the control path's own Bearer token + api_allow_from pin. Releases are checksummed, not signed — GitHub is the trust root (see SECURITY.md). For co-locating a miner on a busy box (e.g. a Pithead stack host), 'hugepages_reserve_extra_mb' (default 0) adds that many MB of HugePages to RigForge's computed mining reservation so the pool covers stack + miner — RigForge stays the sole writer of the reservation and, when the box's existing reservation already covers both, changes nothing and needs no reboot; 'hugepages_pool_ceiling_mb' (default 0 = no ceiling) is a HARD CAP on the runtime pool RigForge will grow to, in MB, distinct from hugepages_reserve_extra_mb (which adds declared headroom into the computed requirement rather than bounding the write) — set it on a RAM-constrained co-resident box so the grow-only write can never exceed the box's honest capacity no matter what the requirement/availability math computes (rigforge#398; an odd MB value floors to the 2MB page below, e.g. 5121 -> 5120MB effective, never rounds up past the declared ceiling). It bounds the PERSISTED reservation too: the 2MB `hugepages=` RigForge merges into the GRUB cmdline is capped the same way, so the pool does not come back up over the ceiling after a reboot, where the grow-only runtime write could never bring it down again (rigforge#410). The 1GB dataset reservation is a separate pool this key has never described and is not capped, so a ceiling below a box's 1GB reservation still leaves it holding more hugepage memory than the ceiling names; 'threads' (empty/null = auto) caps the RandomX thread count (a ceiling — min(auto, threads), e.g. nproc-2) so the miner leaves spare cores, and it sizes the reservation to match. A TLS pool can pin the server certificate with 'tls-fingerprint' (its SHA-256 as 64 hex chars; null = no pin) — XMRig does no CA verification for stratum, so the pin is the only server authentication TLS stratum has. Full descriptions: https://github.com/p2pool-starter-stack/rigforge/blob/main/docs/configuration.md#configuration-reference", + "_docs": "Reference for every config.json key, each shown with its default value. Copy ONLY the keys you actually want to change into config.json; any key you omit keeps the default shown here. The pool target is the native XMRig 'pools' array: each pool needs a 'url' (host:port); every other field falls back to a Pithead-friendly default. A pool's 'user' is the rig's label on the dashboard (defaults to the machine hostname); list multiple entries for failover. Optional pool fields: 'rig-id' is a separate worker label while 'user' remains the login; 'nicehash' enables the pool-assigned nonce protocol; 'algo' pins a supported rx/* algorithm; 'coin' selects a supported RandomX coin by case-insensitive name or code; and 'sni' sends the pool hostname during TLS setup. These five fields are omitted when absent or null; explicit false is preserved for the strict-boolean 'nicehash' and 'sni' fields. 'pass' is the Stratum password or worker name; 'keepalive' sends requests that prevent idle timeout; 'tls' enables encrypted Stratum; and 'enabled' controls whether XMRig may connect to the entry during failover. A pool's 'socks5' (default null) dials that pool through a SOCKS5 proxy given as host:port \u2014 e.g. \"127.0.0.1:9050\" for a local Tor client, which is how a rig reaches a stratum published as an onion service; XMRig sends the hostname to the proxy, so the proxy resolves it. RigForge points the miner at a proxy, it does not install or run one. An empty ACCESS_TOKEN (the default) leaves the rig's read-only HTTP API open (no token) — which matches Pithead's default no-auth stats probe; set a value only to require a Bearer token (then match it dashboard-side). 'ACCESS_TOKENS' (default {}) is an optional name -> token map, one token per Pithead stack that consumes this rig, so rotating the bench's token never touches production's: every entry is accepted on the sister API (:8081, raw or its own HMAC-derived read bearer) and the writable control path (:8082, raw), while XMRig's own :8080 and the control path's pools restore stay ACCESS_TOKEN's alone — a stack that probes :8080 directly must hold the master. 'autotune' is one of \"disabled\" (default — no scheduled tuning), \"performance\" (monthly tune for raw hashrate), or \"efficiency\" (monthly tune for hashrate-per-watt). 'watchdog' set to \"enabled\" installs a timer that health-checks the miner every 'watchdog_interval_min' minutes (default 5) and restarts it after two consecutive checks see 0 H/s or a dead API; 'max_temp_c' (empty = off) additionally stops the miner above that °C and starts it again 5°C below — check what your board's thermal_zone0 reports before setting it. 'miner_user' (empty = run as root, the default) runs the miner as that dedicated non-root system user — RigForge applies the MSR preset root-side; on CPU families without a known preset the ~10-15% MSR boost is skipped. 'api_allow_from' (empty = off) scopes the API port(s) — including the writable control port when 'control' is enabled — to one IPv4 or IPv6 address/CIDR source + loopback via an own nftables table (Linux, needs nft; for an IPv6 source the served port must bind a v6 address, so set 'api_bind'/'control_bind' to \"::\"). Set 'api' to enabled for the sister API: a second read-only port (default 8081) serving XMRig's summary enriched with RigForge tune/power/health/provenance data, gated by the same ACCESS_TOKEN; 'api_port'/'api_bind' place it. Set 'control' to \"enabled\" for the WRITABLE control path (#236): a SEPARATE authenticated port (default 8082, 'control_port'/'control_bind' place it) that lets a Pithead stack apply config changes through RigForge so config.json stays authoritative. Fail-closed: enabling it REQUIRES both ACCESS_TOKEN and api_allow_from (a writable API with no token or no pinned source is refused). Only pools, DONATION, autotune, watchdog(+interval), and max_temp_c are writable through it (and the REMOTE path refuses to disable watchdog or to unset/out-of-band max_temp_c — a rig's thermal protection can only be removed by a local rigforge.sh apply, #257); every change is validated, the old config is snapshotted to config-backups/, and a change that doesn't come back live is rolled back. Set 'control_upgrade' to \"enabled\" (a SECOND opt-in, only valid when 'control' is also enabled) to let the stack trigger this rig to upgrade its own RigForge to the latest release (#308, ADR 0002) — i.e. fetch and run new root code on a remote trigger. Default off, and never on just because 'control' is: the rig refuses any target that isn't a real release newer than the one running, verifies the release commit is reachable on main (the release branch), throttles repeat runs, and health-gates the swap with rollback. It rides the control path's own Bearer token + api_allow_from pin. Releases are checksummed, not signed — GitHub is the trust root (see SECURITY.md). For co-locating a miner on a busy box (e.g. a Pithead stack host), 'hugepages_reserve_extra_mb' (default 0) adds that many MB of HugePages to RigForge's computed mining reservation so the pool covers stack + miner — RigForge stays the sole writer of the reservation and, when the box's existing reservation already covers both, changes nothing and needs no reboot; 'hugepages_pool_ceiling_mb' (default 0 = no ceiling) is a HARD CAP on the runtime pool RigForge will grow to, in MB, distinct from hugepages_reserve_extra_mb (which adds declared headroom into the computed requirement rather than bounding the write) — set it on a RAM-constrained co-resident box so the grow-only write can never exceed the box's honest capacity no matter what the requirement/availability math computes (rigforge#398; an odd MB value floors to the 2MB page below, e.g. 5121 -> 5120MB effective, never rounds up past the declared ceiling). It bounds the PERSISTED reservation too: the 2MB `hugepages=` RigForge merges into the GRUB cmdline is capped the same way, so the pool does not come back up over the ceiling after a reboot, where the grow-only runtime write could never bring it down again (rigforge#410). The 1GB dataset reservation is a separate pool this key has never described and is not capped, so a ceiling below a box's 1GB reservation still leaves it holding more hugepage memory than the ceiling names; 'threads' (empty/null = auto) caps the RandomX thread count (a ceiling — min(auto, threads), e.g. nproc-2) so the miner leaves spare cores, and it sizes the reservation to match. A TLS pool can pin the server certificate with 'tls-fingerprint' (its SHA-256 as 64 hex chars; null = no pin) — XMRig does no CA verification for stratum, so the pin is the only server authentication TLS stratum has. The pool 'coin' lets XMRig choose the algorithm for that coin. Both 'algo' and 'coin' defer to pool algorithm negotiation; avoid 'coin' with NiceHash, where the pool controls the coin. Full descriptions: https://github.com/p2pool-starter-stack/rigforge/blob/main/docs/configuration.md#configuration-reference", "pools": [ - { "url": ":3333", "user": "", "pass": "x", "keepalive": true, "tls": false, "tls-fingerprint": null, "socks5": null, "enabled": true } + { + "url": ":3333", + "user": "", + "pass": "x", + "rig-id": null, + "nicehash": false, + "algo": null, + "coin": null, + "keepalive": true, + "tls": false, + "tls-fingerprint": null, + "sni": false, + "socks5": null, + "enabled": true + } ], "ACCESS_TOKEN": "", diff --git a/docs/configuration.md b/docs/configuration.md index 9d55018..06aa1b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -84,10 +84,10 @@ You don't write XMRig's config. RigForge generates it in-script and writes it in the live `config.json` the service runs from. There's no template file to keep in sync and no config key for it. Every run (re-runs included) rebuilds the config from four sources: -1. Your `config.json`: the `pools` array (with `user`/`pass`/`keepalive`/`tls` and failover - defaults filled in), the `donate-level`, and the `http` API block (bound to the LAN, read-only, - open by default; set `ACCESS_TOKEN` for a bearer token). These are the keys documented in the - [reference table](#configuration-reference). +1. Your `config.json`: the `pools` array (with connection defaults filled in and supported optional + fields preserved), the `donate-level`, and the `http` API block (bound to the LAN, read-only, open + by default; set `ACCESS_TOKEN` for a bearer token). These are the keys documented in the [reference + table](#configuration-reference). 2. Detected hardware: the per-CPU `cpu`/`randomx` tuning (thread count, `asm`, MSR, NUMA, HugePages). RigForge uses XMRig's own cache-aware auto-detection rather than a CPU-model table, so it stays correct for CPUs it's never seen. See [Hardware Requirements](hardware.md). @@ -108,20 +108,37 @@ hand has no effect. Change your repo-root `config.json` (or `tune`) and re-run i ## Pools (full control) -The pool target is XMRig's native `pools` array, passed straight through to XMRig, so you can use any -field XMRig supports. Only `url` is required; every other field has a default, so you specify only what -you care about: +The pool target uses XMRig's `pools` shape with the supported fields below. XMRig's daemon and solo +mining fields are deliberately excluded. Only `url` is required; specify the other fields only when +you need them: -| Field | Default if blank/omitted | +| Field | Default and meaning | |---|---| | `url` | *(required)* — `host:port` (e.g. `pool.supportxmr.com:443` or `your-stack:3333`). For an IPv6 literal, use the bracketed `[2001:db8::1]:3333` form. | -| `user` | the machine hostname. For Pithead this is the rig's dashboard label; for a public pool set it to your Monero wallet address (see below). | +| `user` | the machine hostname. This remains the pool login when `rig-id` is set. For Pithead this is the rig's dashboard label; for a public pool set it to your Monero wallet address (see below). | | `pass` | `"x"` — the stratum password / worker name. For an open Pithead stack the default works; if the operator enabled the stack's `p2pool.stratum_password`, set this to that secret or the proxy rejects the rig. See [Pithead Integration › Stratum authentication](pithead-integration.md#stratum-authentication-optional). | -| `keepalive` | `true` | +| `rig-id` | `null` (unset) — a separate worker label for pools that support XMRig's rig identifier. It accepts the same characters as `user` and may be empty. It coexists with `user`: `user` remains the pool login and keeps its hostname default. | +| `nicehash` | `false` — enable the NiceHash Stratum protocol, where the pool assigns the nonce. Accepts only JSON `true` or `false`. | +| `algo` | `null` (automatic) — pin a RandomX algorithm. Accepted values are `rx/0`, `rx/2`, `rx/wow`, `rx/arq`, `rx/graft`, `rx/sfx`, and `rx/yada`; any other value is rejected. | +| `coin` | `null` (automatic) — let XMRig choose the algorithm for a RandomX coin by case-insensitive name or code: `monero`/`XMR`, `arqma`/`ARQ`, `graft`/`GRFT`, `wownero`/`WOW`, `zephyr`/`ZEPH`, `townforge`, or `yadacoin`/`YDA`. Unknown values and non-RandomX coins such as Sumokoin and Ravencoin are rejected. | +| `keepalive` | `true` — send Stratum keepalive requests to prevent an idle connection timing out. | | `tls` | `false` — set `true` when you connect on the pool's TLS/SSL port. | | `tls-fingerprint` | `null` (no pin) — the pool cert's SHA-256 as 64 hex chars. XMRig does no CA validation on stratum TLS, so the pin is the only server authentication; without it, TLS encrypts but doesn't authenticate. Requires `"tls": true`. See [Pithead Integration › Stratum over TLS](pithead-integration.md#stratum-over-tls-optional). | +| `sni` | `false` — send the pool hostname through TLS SNI for a name-based TLS endpoint. Accepts only JSON `true` or `false`; use it with `tls`. | | `socks5` | `null` (direct connection) — dial this pool through a SOCKS5 proxy at `host:port`, e.g. `"127.0.0.1:9050"` for a local Tor client. Same address rules as `url`. XMRig sends the pool's **hostname** to the proxy rather than resolving it first, so a v3 `.onion` stratum works with no extra setting. RigForge points the miner at a proxy; running one is yours to arrange. | -| `enabled` | `true` | +| `enabled` | `true` — allow XMRig to connect to this entry in the failover list. | + +Absent or `null` `rig-id`, `nicehash`, `algo`, `coin`, and `sni` values are omitted from the generated config. +For `nicehash` and `sni`, an explicit `false` is preserved while `null` means unset. The algorithm and +coin allowlists are pinned to XMRig v6.26.0's `src/base/crypto/Algorithm.cpp` and +`src/base/crypto/Coin.cpp` at commit `b2ca72480c58d197e18c885d9fc1a0c8d517e60a`. + +XMRig defers to the pool when it supports algorithm negotiation, ignoring `algo` and `coin`. +Avoid `coin` with NiceHash, where the pool controls which coin is mined. + +`status` and `doctor` report the live algorithm when it is not `rx/0`. HugePages sizing and the +offline tune benchmark remain based on `rx/0`; RigForge makes no performance guarantee for another +RandomX variant. Two common setups follow; pick the one that matches where you're mining. diff --git a/docs/operations.md b/docs/operations.md index 142917d..7030a8a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -56,7 +56,7 @@ Not every verb needs root — the design, in four lines: | `upgrade` | Redeploy this RigForge release onto the rig: regenerate the live XMRig config, reinstall the systemd units, restart so both are in effect, re-tune if periodic autotune is enabled (the fastest knobs can shift between versions), and hand the files back to you. The **compile** is the conditional part — XMRig is rebuilt when the pinned version/commit changed, the compiled-minimum marker is missing or nonzero, or the binary checksum fails. A miner you had deliberately stopped is left stopped, and the regenerated config and unit take effect at your next `start` (#413). `--check` just reports whether a newer RigForge release exists (on-demand GitHub query, always exits 0). | | `apply` | Re-read `config.json`, regenerate the live XMRig config, and restart, without recompiling. A miner that is deliberately stopped — a watchdog thermal hold, or your own `stop` — is left stopped, and picks up the new config at its next start. The fast path after editing `config.json`. On Linux it also reconciles the periodic-autotune timer with config (so changing the `autotune` target takes effect) and reports it (efficiency / performance / disabled), then asks the running miner whether a live pool connection came up — and warns when none did (a bad `pools[0].url` otherwise mines nothing while looking applied, #343). Warn only, never a failure: the pool may be legitimately down at apply time. `apply` does exit non-zero if the watchdog units could not be re-rendered (#395) — that one is a real failure, because the cadence the rig is running would otherwise diverge from `config.json` silently. | | `uninstall` | Remove the service and revert all system changes (fstab, limits, modules, GRUB) and the worker build/logs. Leaves `config.json`. Prompts first; add `--yes` to skip. | -| `doctor` | Read-only health check (run with `sudo` for the deepest checks). Critical findings (counted as issues): the service is active, the miner has a live pool connection (read from the miner's own local API — a running miner with no connection is not mining, #343; with the service stopped, one TCP dial of `pools[0]` stands in for a direct pool; a proxied connection cannot be verified until mining starts), HugePages are reserved, kernel lockdown isn't blocking MSR writes (read from `/sys/kernel/security/lockdown` — active lockdown means the MSR mod cannot apply, and `msr.allow_writes=on` can't override it), the `msr` module is loaded, and the MSR mod actually applied, confirmed from XMRig's log and, as root, an `rdmsr` register read-back (see [MSR mod verification](#msr-mod-verification)). Advisory findings (hints, not failures): an unreachable configured SOCKS proxy for an enabled pool (a successful TCP dial stays quiet and does not prove SOCKS/Tor health), CPU governor, 1 GB HugePages, HugePages 100%-backed (from the XMRig log), hashrate-capping hardware RigForge can't fix but you can (single-channel or slow RAM via `dmidecode`, and a power/boost-capped CPU clock), and BIOS/firmware recommendations (board/BIOS context, plus enable XMP/EXPO/DOCP or SMT when they're off; manual BIOS changes RigForge can't make from the OS). Prints an actionable hint for anything off. Also binary tamper evidence (#141): the on-disk `xmrig` is compared against the SHA-256 recorded at compile time — a deliberate rebuild refreshes the record, anything else warns and counts as an issue. When the [writable control path](#writable-control-path-opt-in) is enabled, it's also a counted issue: `doctor` checks `rigforge-control` is active and its `/status` endpoint answers (#278), staying silent when control is disabled. Exits non-zero when critical issues are found (cron-friendly, matching Pithead's `status`). | +| `doctor` | Read-only health check (run with `sudo` for the deepest checks). Critical findings (counted as issues): the service is active, the miner has a live pool connection (read from the miner's own local API — a running miner with no connection is not mining, #343; with the service stopped, one TCP dial of `pools[0]` stands in for a direct pool; a proxied connection cannot be verified until mining starts), HugePages are reserved, kernel lockdown isn't blocking MSR writes (read from `/sys/kernel/security/lockdown` — active lockdown means the MSR mod cannot apply, and `msr.allow_writes=on` can't override it), the `msr` module is loaded, and the MSR mod actually applied, confirmed from XMRig's log and, as root, an `rdmsr` register read-back (see [MSR mod verification](#msr-mod-verification)). Advisory findings (hints, not failures): the live algorithm when it differs from `rx/0`, an unreachable configured SOCKS proxy for an enabled pool (a successful TCP dial stays quiet and does not prove SOCKS/Tor health), CPU governor, 1 GB HugePages, HugePages 100%-backed (from the XMRig log), hashrate-capping hardware RigForge can't fix but you can (single-channel or slow RAM via `dmidecode`, and a power/boost-capped CPU clock), and BIOS/firmware recommendations (board/BIOS context, plus enable XMP/EXPO/DOCP or SMT when they're off; manual BIOS changes RigForge can't make from the OS). Prints an actionable hint for anything off. Also binary tamper evidence (#141): the on-disk `xmrig` is compared against the SHA-256 recorded at compile time — a deliberate rebuild refreshes the record, anything else warns and counts as an issue. When the [writable control path](#writable-control-path-opt-in) is enabled, it's also a counted issue: `doctor` checks `rigforge-control` is active and its `/status` endpoint answers (#278), staying silent when control is disabled. Exits non-zero when critical issues are found (cron-friendly, matching Pithead's `status`). | | `bench` | Run a one-off `xmrig --bench` and report the hashrate (a quick perf/health check; set `BENCH=10M` for a longer run). | | `tune` | The single command for tuning. A bare `tune` measures the fastest CPU-specific knobs (prefetch, `cpu.yield`, thread count) offline and keeps them, an optional, one-time step. Live variants: `--now` / `--short` (a quick prefetch re-tune against the running miner, the *run a live tune now* path), `--now --long` (a full live search of every knob, = `--live`), `--confirm` (A/B-check the winner live). Plus `--efficiency` / `--perf`, `--history`, `--clear`. See [Tuning](#tuning). | | `bios` | Guided, resumable walk-through of the BIOS/UEFI changes for your hardware — the settings `tune` can't reach from the OS (Secure Boot when kernel lockdown is blocking the MSR mod, memory profile XMP/EXPO/DOCP, SMT, PBO/Eco-Mode, NUMA-per-socket NPS on EPYC; `--efficiency` picks the low-power set). Detects the current firmware state via the same probes `doctor` uses, hands you a board-specific checklist one item at a time, saves the pending items, and on the next run re-verifies which changes actually took. RigForge never writes BIOS itself; plan for console access (keyboard/KVM) for the reboot-into-BIOS step. Linux-only. See [Guided BIOS tuning](#guided-bios-tuning). | @@ -64,7 +64,7 @@ Not every verb needs root — the design, in four lines: | `watchdog` | One health check per run — the scheduled recovery verb, like `autotune`. You normally don't type it; set `"watchdog": "enabled"` in `config.json` and setup installs a timer that runs it every `watchdog_interval_min` minutes: two consecutive checks seeing 0 H/s or a dead API restart the wedged miner, and with `max_temp_c` set it stops the miner above that temperature (starting it again 5 °C below). Linux-only. See [Watchdog](#watchdog-opt-in). | | `backup` | Snapshot `config.json` + the tuning files into a timestamped `tar.gz` under `./backups`. See [Backup & restore](#backup--restore). | | `restore` | Restore `config.json` + tuning from a backup archive: `restore [-y] `. Prompts before overwriting. | -| `status` | One-glance live summary from the worker API (hashrate, pool, uptime, accepted/rejected shares — one fetch, no sudo), then the systemd service status. Miner stopped or API off: a single "worker API not reachable" line and the platform block as before. | +| `status` | One-glance live summary from the worker API (hashrate, pool, uptime, accepted/rejected shares, and the live algorithm when it differs from `rx/0` — one fetch, no sudo), then the systemd service status. Miner stopped or API off: a single "worker API not reachable" line and the platform block as before. | | `logs` | Follow the live service logs (`journalctl -f`). | | `start` / `stop` / `restart` | Start, stop, or restart the miner service. (`up` / `down` are aliases for `start` / `stop`.) | | `enable` / `disable` | Start the service on boot, or not. | diff --git a/rigforge.sh b/rigforge.sh index 9622e15..34f5735 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -441,27 +441,15 @@ parse_config() { }) ' "$CONFIG_JSON") || error "Could not parse 'pools' in $CONFIG_JSON." - # tls-fingerprint (#115): re-attach the pin from the raw config, emitted ONLY when set — adding - # it unconditionally (null) would change the generated config shape for every existing rig on - # its next apply. A second single-line pass rather than lines inside the map above: kcov can't - # attribute in-string program lines, and the patch-coverage gate needs every new line hittable. + # Omit unset optional fields to preserve existing generated configs. POOLS_JSON=$(jq -c --argjson base "$POOLS_JSON" '[$base, [.pools[] | ."tls-fingerprint"]] | transpose | map(.[0] + (if (.[1] // null) != null then {"tls-fingerprint": .[1]} else {} end))' "$CONFIG_JSON") || error "Could not parse 'pools' in $CONFIG_JSON." - - # Re-attach socks5 only when set, using the same shape/coverage rule as tls-fingerprint. POOLS_JSON=$(jq -c --argjson base "$POOLS_JSON" '[$base, [.pools[] | .socks5]] | transpose | map(.[0] + (if (.[1] // null) != null then {"socks5": .[1]} else {} end))' "$CONFIG_JSON") || error "Could not parse 'pools' in $CONFIG_JSON." + POOLS_JSON=$(jq -c --argjson base "$POOLS_JSON" '[$base, [.pools[] | {"rig-id": .["rig-id"], nicehash, algo, coin, sni} | with_entries(select(.value != null))]] | transpose | map(.[0] + .[1])' "$CONFIG_JSON") || error "Could not parse 'pools' in $CONFIG_JSON." - # #265: jq's `//` treats an explicit false like null/missing, so the map above rewrites an - # operator's "keepalive": false / "enabled": false to the true default. Restore explicit falses - # from the raw config in another single-line pass (same kcov rationale as #115 above). Only a - # boolean false matches `== false`; non-boolean junk flows through `//` untouched to validation. + # jq's // treats false as missing, so restore explicit false values from the raw config. POOLS_JSON=$(jq -c --argjson base "$POOLS_JSON" '[$base, [.pools[] | [(.keepalive == false), (.enabled == false)]]] | transpose | map(.[0] + (if .[1][0] then {"keepalive": false} else {} end) + (if .[1][1] then {"enabled": false} else {} end))' "$CONFIG_JSON") || error "Could not parse 'pools' in $CONFIG_JSON." - # Validate every pool field — fail fast with a clear message rather than writing a config XMRig - # would choke on. url must be host:port: a valid hostname / IPv4 / bracketed-IPv6 host and a port - # in 1-65535; user/pass reject whitespace and shell/control characters; keepalive/tls/enabled - # must be booleans. - # Iterate one compact JSON object per pool (robust even when a field like user is empty), and read - # each field back with jq. + # Validate each pool before it can reach the generated config. while IFS= read -r _pool; do _u=$(jq -r '.url' <<<"$_pool") _user=$(jq -r '.user' <<<"$_pool") @@ -489,14 +477,21 @@ parse_config() { if [ -n "$_user" ] && ! [[ "$_user" =~ ^[A-Za-z0-9._:@+-]+$ ]]; then error "Pool user '$_user' has invalid characters (allowed: letters, digits, . _ - : @ +)." fi - # #415: `pass` must be a STRING, checked before the charset rule below can be fooled. The - # feed masks a set password to the {"__secret__": true} sentinel and _control_commit - # resolves it back, but nothing stops a marker — or any other JSON object — reaching - # config.json by a hand edit or by a future writer that forgets to resolve one. `jq -r` - # renders an object as its JSON text, and the empty object `{}` renders as two graph - # characters on one line, so it passes the check below and becomes the rig's literal - # password. Type-check here so the guarantee does not depend on every writer remembering: - # the same posture as the non-string tls-fingerprint rejection below. + if ! jq -e 'has("rig-id") == false or (."rig-id" | type == "string" and (test("[^A-Za-z0-9._:@+-]") | not))' <<<"$_pool" >/dev/null 2>&1; then + error "Pool rig-id must be a string using only these characters: letters, digits, . _ - : @ + (or empty)." + fi + for _f in nicehash sni; do + if ! jq -e --arg f "$_f" 'has($f) == false or (.[$f] | type == "boolean")' <<<"$_pool" >/dev/null 2>&1; then + error "Pool $_f must be true or false." + fi + done + if ! jq -e '.algo == null or (.algo | IN("rx/0", "rx/2", "rx/wow", "rx/arq", "rx/graft", "rx/sfx", "rx/yada"))' <<<"$_pool" >/dev/null 2>&1; then + error "Pool algo must be a supported RandomX algorithm (rx/0, rx/2, rx/wow, rx/arq, rx/graft, rx/sfx, rx/yada): HugePages reservation and tune --bench assume a RandomX scratchpad." + fi + if ! jq -e '.coin == null or (.coin | ascii_downcase | IN("monero", "xmr", "arqma", "arq", "graft", "grft", "wownero", "wow", "zephyr", "zeph", "townforge", "yadacoin", "yda"))' <<<"$_pool" >/dev/null 2>&1; then + error "Pool coin must be a supported RandomX coin (monero/XMR, arqma/ARQ, graft/GRFT, wownero/WOW, zephyr/ZEPH, townforge, yadacoin/YDA): HugePages reservation and tune --bench assume a RandomX scratchpad." + fi + # Reject unresolved API secret markers before checking the password charset. if [ "$(jq -r '.pass | type' <<<"$_pool")" != "string" ]; then error "Pool pass must be a string (got a $(jq -r '.pass | type' <<<"$_pool")). A masked-secret marker from the API feed is not a password — send the real value, or omit 'pass' entirely." fi @@ -734,7 +729,7 @@ parse_config() { # #1 image seed. Warn NAMES only, never values — a fat-fingered token must not land in a log. _warn_unknown_config_keys() { local known="pools ACCESS_TOKEN ACCESS_TOKENS DONATION autotune add_to_path HOME_DIR api api_port api_bind api_allow_from miner_user RIG_NAME watchdog watchdog_interval_min max_temp_c control control_port control_bind control_upgrade hugepages_reserve_extra_mb hugepages_pool_ceiling_mb threads" - local known_pool="url user pass keepalive tls enabled tls-fingerprint socks5" + local known_pool="url user pass keepalive tls enabled tls-fingerprint socks5 rig-id nicehash algo coin sni" local k lk m lm hit hint unknown_seen=0 while IFS= read -r k; do case "$k" in _*) continue ;; esac @@ -3951,8 +3946,10 @@ mac_disable() { fi } -# One-glance live summary from a single /2/summary fetch (#143). Facts only, no ✓/! judgment -# markers (that's doctor's job); plain aligned lines stay grep-friendly. Never sudo, never prompts. +_print_pool_algorithm() { + jq -r '.algo | select(type == "string" and . != "" and . != "rx/0") | " Algorithm: \(.)"' 2>/dev/null || true +} +# A single fetch keeps the status fields consistent with each other. _status_api_summary() { local body hs pool up acc rej hp body=$(_read_api_summary) @@ -3968,6 +3965,7 @@ _status_api_summary() { [ -n "${hs:-}" ] || return 0 # half-up API / unparseable body: stay quiet, platform block follows printf ' %-10s %s H/s\n' "Hashrate:" "$hs" printf ' %-10s %s\n' "Pool:" "$pool" + printf '%s' "$body" | _print_pool_algorithm printf ' %-10s %s\n' "Uptime:" "$(_render_duration "$up")" printf ' %-10s %s accepted / %s rejected\n' "Shares:" "$acc" "$rej" [ -n "$hp" ] && printf ' %-10s %s\n' "HugePages:" "$hp" @@ -5463,6 +5461,7 @@ doctor() { _tcp_probe "$ph" "$pp" || _ck_warn "configured SOCKS proxy is unreachable (TCP connect failed) — check pools[].socks5 and install/start Tor if needed" done < <(jq -r '[.pools[]? | select(.enabled != false) | .socks5 | select(. != null and . != "")] | unique[] | if type == "string" and (test("[[:cntrl:]]") | not) then . else "invalid proxy" end' "$CONFIG_JSON" 2>/dev/null) if [ "$svc_up" = y ]; then + ACCESS_TOKEN="$(jq -r '.ACCESS_TOKEN // empty' "$CONFIG_JSON" 2>/dev/null || true)" _read_api_summary | _print_pool_algorithm if ! ACCESS_TOKEN="$(jq -r '.ACCESS_TOKEN // empty' "$CONFIG_JSON" 2>/dev/null || true)" _check_donation; then issues=$((issues + 1)) fi diff --git a/tests/run.sh b/tests/run.sh index d42e2fa..110c92d 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -376,6 +376,8 @@ assert_eq "second-pool socks5 stays on the second pool (#400)" "$(PJ "$c" | jq - # A pool may carry BOTH a pin and a proxy — the two re-attach passes must not clobber each other. c="$(mkconf p_s5fp "{ \"pools\": [{\"url\":\"h:443\",\"tls\":true,\"tls-fingerprint\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"socks5\":\"127.0.0.1:9050\"}] }")" assert_eq "socks5 and tls-fingerprint coexist (#400)" "$(PJ "$c" | jq -c '[(.[0]."tls-fingerprint" != null), (.[0].socks5 == "127.0.0.1:9050")]')" "[true,true]" +bash "$ROOT/tests/test-pool-fields.sh" "$SCRIPT" "$ROOT/tests/contract/v1/feed.json" +assert_rc "XMRig optional pool fields round-trip and validate (#527)" "$?" 0 # #400 validation: the socks5 address gets the SAME host:port rules as the pool url. Each of these # is rejected for the pool url already; assert the proxy is judged by the same standard. c="$(mkconf p_s5noport "{ \"pools\": [{\"url\":\"h:3333\",\"socks5\":\"127.0.0.1\"}] }")" @@ -494,7 +496,6 @@ assert_rc "no pools rejected" "$?" "1" c="$(mkconf p_emptypools "{ \"pools\": [] }")" parse_rc "$c" "$ROOT" assert_rc "empty pools array rejected" "$?" "1" - # Every config field is validated — bad input fails fast with a clear message rather than producing a # config XMRig would choke on. echo "== unit: config field sanitization ==" diff --git a/tests/test-pool-fields.sh b/tests/test-pool-fields.sh new file mode 100644 index 0000000..60ec69a --- /dev/null +++ b/tests/test-pool-fields.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1090,SC2034,SC2329 +set -uo pipefail +SCRIPT="$1" +FEED="$2" +D=$(mktemp -d) +trap 'rm -rf "$D"' EXIT +FAIL=0 +pass() { printf ' ok: %s\n' "$1"; } +fail() { + printf ' FAIL: %s%s\n' "$1" "${2:+ — $2}" >&2 + FAIL=$((FAIL + 1)) +} +eq() { + if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected [$3], got [$2]"; fi +} +BASE_POOL=$(jq -c '.rigforge.config.pools[0] | del(.pass)' "$FEED") +FIXTURE_ID=$(jq -r '.rigforge.control.change_id' "$FEED") +write_config() { jq -n --argjson pools "$2" '{pools: $pools}' >"$1"; } +parse() { + write_config "$D/input.json" "$1" + ( + source "$SCRIPT" + CONFIG_JSON="$D/input.json" SCRIPT_DIR=$(dirname "$SCRIPT") + parse_config + ) 2>&1 +} +has() { case "$2" in *"$3"*) pass "$1" ;; *) fail "$1" "missing [$3]" ;; esac } +absent() { case "$2" in *"$3"*) fail "$1" "unexpected [$3]" ;; *) pass "$1" ;; esac } +rejects() { + local label="$1" key="$2" value="$3" fragment="$4" pools out rc + pools=$(jq -cn --argjson base "$BASE_POOL" --argjson value "$value" --arg key "$key" '[($base + {($key): $value})]') + out=$(parse "$pools") + rc=$? + if [ "$rc" -eq 0 ]; then + fail "$label" "accepted invalid value" + return + fi + has "$label names the reason" "$(printf '%s' "$out" | tr '[:upper:]' '[:lower:]')" "$(printf '%s' "$fragment" | tr '[:upper:]' '[:lower:]')" +} +render() { + local rc + write_config "$D/input.json" "$1" + rm -rf "$D/worker" + mkdir -p "$D/logrotate" + ( + RIGFORGE_HOME=$(dirname "$SCRIPT") LOGROTATE_DIR="$D/logrotate" source "$SCRIPT" + CONFIG_JSON="$D/input.json" SCRIPT_DIR=$(dirname "$SCRIPT") OS_TYPE=Linux LOGROTATE_DIR="$D/logrotate" + parse_config >/dev/null + WORKER_ROOT="$D/worker" CPUINFO="$D/no-cpuinfo" + mkdir -p "$WORKER_ROOT/xmrig/build" + lscpu() { printf 'Model name: fixture cpu\n'; } + hostname() { printf '%s\n' "$FIXTURE_ID"; } + sudo() { "$@"; } + cd "$WORKER_ROOT/xmrig/build" || exit + generate_xmrig_config >/dev/null + ) 2>/dev/null + rc=$? + if [ "$rc" -ne 0 ]; then + fail "generated config render" "exited $rc" + return "$rc" + fi + cp "$D/worker/xmrig/build/config.json" "$2" +} +echo "== pool schema fields (#527) ==" +FP=$(printf '%064d' 0) +POOLS=$(jq -cn --argjson base "$BASE_POOL" --arg fp "$FP" '[ + ($base + {"rig-id":"Rig_One.:@+-","nicehash":false,"sni":true,"algo":"rx/wow","coin":"WOW","tls":true,"tls-fingerprint":$fp}), + ($base + {"rig-id":"","nicehash":true,"sni":false,"algo":"rx/arq","coin":"arqma","keepalive":false,"enabled":false}) +]') +render "$POOLS" "$D/all.json" +eq "new pool fields stay on their source index" "$(jq -c '[.pools[] | [."rig-id",.nicehash,.sni,.algo,.coin]]' "$D/all.json")" '[["Rig_One.:@+-",false,true,"rx/wow","WOW"],["",true,false,"rx/arq","arqma"]]' +eq "new fields coexist with older optional fields" "$(jq -r '.pools[0]."tls-fingerprint"' "$D/all.json")" "$FP" +eq "explicit false values survive generation" "$(jq -c '[.pools[0].nicehash,.pools[1].sni,.pools[1].keepalive,.pools[1].enabled]' "$D/all.json")" '[false,false,false,false]' +eq "rig-id coexists with the unchanged default user" "$(jq -r '.pools[0].user' "$D/all.json")" "$FIXTURE_ID" +out=$(parse "$POOLS") +absent "new fields do not trigger unknown-field warnings" "$out" "unknown pool field" +LEGACY=$(jq -cn --argjson base "$BASE_POOL" '[$base]') +NULLS=$(jq -cn --argjson base "$BASE_POOL" '[($base + {"rig-id":null,nicehash:null,sni:null,algo:null,coin:null})]') +render "$LEGACY" "$D/legacy.json" +render "$NULLS" "$D/nulls.json" +if cmp -s "$D/legacy.json" "$D/nulls.json"; then pass "null fields leave generated config byte-identical"; else fail "null fields leave generated config byte-identical"; fi +eq "absent fields remain absent" "$(jq -c '[.pools[0] | has("rig-id"),has("nicehash"),has("sni"),has("algo"),has("coin")]' "$D/legacy.json")" '[false,false,false,false,false]' + +for value in true 7 '{}' '[]'; do rejects "rig-id rejects non-string $value" rig-id "$value" string; done +rejects "rig-id rejects whitespace" rig-id '"bad value"' characters +rejects "rig-id rejects a slash" rig-id '"bad/value"' characters +for key in nicehash sni; do + rejects "$key rejects string false" "$key" '"false"' "$key" + rejects "$key rejects string true" "$key" '"true"' "$key" + rejects "$key rejects a number" "$key" 1 "$key" +done + +for algo in rx/0 rx/2 rx/wow rx/arq rx/graft rx/sfx rx/yada; do + pools=$(jq -cn --argjson base "$BASE_POOL" --arg algo "$algo" '[($base + {algo:$algo})]') + if parse "$pools" >/dev/null; then pass "algo $algo accepted"; else fail "algo $algo accepted"; fi +done +rejects "non-RandomX algo rejected" algo '"kawpow"' RandomX +pools=$(jq -cn --argjson base "$BASE_POOL" '[($base + {algo:"kawpow"})]') +out=$(parse "$pools") +has "algo diagnostic explains the HugePages constraint" "$out" HugePages +has "algo diagnostic explains the tune constraint" "$out" tune +has "algo diagnostic explains the scratchpad constraint" "$out" scratchpad +rejects "unknown RandomX algo rejected" algo '"rx/future"' supported + +for coin in monero XMR arqma ARQ graft GRFT wownero WOW zephyr ZEPH townforge yadacoin YDA; do + pools=$(jq -cn --argjson base "$BASE_POOL" --arg coin "$coin" '[($base + {coin:$coin})]') + if parse "$pools" >/dev/null; then pass "coin $coin accepted"; else fail "coin $coin accepted"; fi +done +for coin in ravencoin RVN sumokoin SUMO unknown; do rejects "coin $coin rejected" coin "\"$coin\"" coin; done + +summary_output() { + local algo="$1" entrypoint="$2" body="$D/summary.json" + jq --arg algo "$algo" '.algo = $algo' "$FEED" >"$body" + ( + source "$SCRIPT" + API_CMD="cat '$body'" ACCESS_TOKEN="" + if [ "$entrypoint" = status ]; then + _status_api_summary + else + OS_TYPE=Linux CONFIG_JSON="$D/doctor.json" SERVICE_NAME=fixture-service + MEMINFO="$D/none/meminfo" MSR_MODULE_DIR="$D/none/msr" GOVERNOR_FILE="$D/none/governor" + HUGEPAGES_1G_NR="$D/none/huge1g" CPUINFO="$D/none/cpuinfo" CPUFREQ_MAX="$D/none/freq" + LOCKDOWN_FILE="$D/none/lockdown" DMI_DIR="$D/none/dmi" NODE_SYSFS="$D/none/node" + THERMAL_ZONE="$D/none/thermal" HWMON_DIR="$D/none/hwmon" RDMSR_BIN="$D/none/rdmsr" + systemctl() { [ "${1:-}" = is-active ]; } + lscpu() { printf 'Model name: fixture cpu\n'; } + _check_donation() { return 0; } + doctor || true + fi + ) 2>&1 +} + +write_config "$D/doctor.json" "$LEGACY" +for entrypoint in status doctor; do + out=$(summary_output rx/wow "$entrypoint") + has "$entrypoint shows a live non-default algorithm" "$out" "Algorithm:" + has "$entrypoint names the live non-default algorithm" "$out" "rx/wow" + out=$(summary_output rx/0 "$entrypoint") + absent "$entrypoint omits the default algorithm" "$out" "Algorithm:" +done +jq 'del(.algo)' "$FEED" >"$D/summary.json" +out=$( ( + source "$SCRIPT" + API_CMD="cat '$D/summary.json'" ACCESS_TOKEN="" + _status_api_summary +) 2>&1) +absent "status is silent when the live algorithm is missing" "$out" "Algorithm:" +jq '.algo = 7' "$FEED" >"$D/summary.json" +out=$( ( + source "$SCRIPT" + API_CMD="cat '$D/summary.json'" ACCESS_TOKEN="" + _status_api_summary +) 2>&1) +absent "status is silent when the live algorithm is invalid" "$out" "Algorithm:" + +CHANGE=$(jq -cn --argjson pools "$POOLS" '{pools:$pools}') +mkdir -p "$D/state/spool" "$D/backups" +printf '%s' "$CHANGE" >"$D/change.json" +STAGED="$D/change.json" +if command -v python3 >/dev/null 2>&1; then + PYTHONDONTWRITEBYTECODE=1 python3 - "$SCRIPT" "$D/state/spool" "$D/change.json" <<'PY' +import importlib.util, json, pathlib, sys +path = pathlib.Path(sys.argv[1]).parent / "util/control-server.py" +spec = importlib.util.spec_from_file_location("control_server", path) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +payload = pathlib.Path(sys.argv[3]).read_bytes() +cid = module.stage_change(sys.argv[2], payload) +staged = pathlib.Path(sys.argv[2], "pending-" + cid + ".json").read_bytes() +assert json.loads(staged) == json.loads(payload) +PY + eq "control receiver stages every pool field" "$?" 0 + STAGED=$(find "$D/state/spool" -name 'pending-*.json' -print -quit) +else + printf ' SKIP: python3 unavailable; control receiver staging not run\n' +fi +write_config "$D/control.json" "$LEGACY" +( + source "$SCRIPT" + CONFIG_JSON="$D/control.json" SCRIPT_DIR=$(dirname "$SCRIPT") RIGFORGE_APPLIANCE=0 + _fsync_paths() { :; } + _control_commit "$STAGED" "$D/backups" >/dev/null +) +eq "control apply accepts the same valid pool fields" "$?" 0 +eq "control apply preserves all staged fields" "$(jq -c '.pools | map({"rig-id",nicehash,sni,algo,coin})' "$D/control.json")" '[{"rig-id":"Rig_One.:@+-","nicehash":false,"sni":true,"algo":"rx/wow","coin":"WOW"},{"rig-id":"","nicehash":true,"sni":false,"algo":"rx/arq","coin":"arqma"}]' + +BAD_CHANGE=$(jq -cn --argjson base "$BASE_POOL" '{pools:[($base + {algo:"kawpow"})]}') +printf '%s' "$BAD_CHANGE" >"$D/bad-change.json" +before=$(jq -cS . "$D/control.json") +out=$( ( + source "$SCRIPT" + CONFIG_JSON="$D/control.json" SCRIPT_DIR=$(dirname "$SCRIPT") RIGFORGE_APPLIANCE=0 + _fsync_paths() { :; } + _control_commit "$D/bad-change.json" "$D/backups" +) 2>&1) +rc=$? +if [ "$rc" -ne 0 ]; then pass "control apply uses shared pool validation"; else fail "control apply uses shared pool validation"; fi +has "control rejection reports invalid config" "$out" "rejected invalid-config" +eq "rejected control apply leaves config unchanged" "$(jq -cS . "$D/control.json")" "$before" +[ "$FAIL" -eq 0 ] || exit 1 +printf 'pool schema fields: PASS\n' From 40183f7b11a2fb250bc0f051691a279b65b3ce76 Mon Sep 17 00:00:00 2001 From: "pithead-developer[bot]" <4933481+pithead-developer[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:07:39 -0600 Subject: [PATCH 2/7] Remove explanatory comments from pool config changes Co-Authored-By: Claude Sonnet 5.5 --- rigforge.sh | 5 ----- 1 file changed, 5 deletions(-) diff --git a/rigforge.sh b/rigforge.sh index 34f5735..96e9bf1 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -441,15 +441,12 @@ parse_config() { }) ' "$CONFIG_JSON") || error "Could not parse 'pools' in $CONFIG_JSON." - # Omit unset optional fields to preserve existing generated configs. POOLS_JSON=$(jq -c --argjson base "$POOLS_JSON" '[$base, [.pools[] | ."tls-fingerprint"]] | transpose | map(.[0] + (if (.[1] // null) != null then {"tls-fingerprint": .[1]} else {} end))' "$CONFIG_JSON") || error "Could not parse 'pools' in $CONFIG_JSON." POOLS_JSON=$(jq -c --argjson base "$POOLS_JSON" '[$base, [.pools[] | .socks5]] | transpose | map(.[0] + (if (.[1] // null) != null then {"socks5": .[1]} else {} end))' "$CONFIG_JSON") || error "Could not parse 'pools' in $CONFIG_JSON." POOLS_JSON=$(jq -c --argjson base "$POOLS_JSON" '[$base, [.pools[] | {"rig-id": .["rig-id"], nicehash, algo, coin, sni} | with_entries(select(.value != null))]] | transpose | map(.[0] + .[1])' "$CONFIG_JSON") || error "Could not parse 'pools' in $CONFIG_JSON." - # jq's // treats false as missing, so restore explicit false values from the raw config. POOLS_JSON=$(jq -c --argjson base "$POOLS_JSON" '[$base, [.pools[] | [(.keepalive == false), (.enabled == false)]]] | transpose | map(.[0] + (if .[1][0] then {"keepalive": false} else {} end) + (if .[1][1] then {"enabled": false} else {} end))' "$CONFIG_JSON") || error "Could not parse 'pools' in $CONFIG_JSON." - # Validate each pool before it can reach the generated config. while IFS= read -r _pool; do _u=$(jq -r '.url' <<<"$_pool") _user=$(jq -r '.user' <<<"$_pool") @@ -491,7 +488,6 @@ parse_config() { if ! jq -e '.coin == null or (.coin | ascii_downcase | IN("monero", "xmr", "arqma", "arq", "graft", "grft", "wownero", "wow", "zephyr", "zeph", "townforge", "yadacoin", "yda"))' <<<"$_pool" >/dev/null 2>&1; then error "Pool coin must be a supported RandomX coin (monero/XMR, arqma/ARQ, graft/GRFT, wownero/WOW, zephyr/ZEPH, townforge, yadacoin/YDA): HugePages reservation and tune --bench assume a RandomX scratchpad." fi - # Reject unresolved API secret markers before checking the password charset. if [ "$(jq -r '.pass | type' <<<"$_pool")" != "string" ]; then error "Pool pass must be a string (got a $(jq -r '.pass | type' <<<"$_pool")). A masked-secret marker from the API feed is not a password — send the real value, or omit 'pass' entirely." fi @@ -3949,7 +3945,6 @@ mac_disable() { _print_pool_algorithm() { jq -r '.algo | select(type == "string" and . != "" and . != "rx/0") | " Algorithm: \(.)"' 2>/dev/null || true } -# A single fetch keeps the status fields consistent with each other. _status_api_summary() { local body hs pool up acc rej hp body=$(_read_api_summary) From 39526aed9b11d1351853481859808a9f870719e2 Mon Sep 17 00:00:00 2001 From: "pithead-developer[bot]" <328836012+pithead-developer[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:14:20 -0600 Subject: [PATCH 3/7] Expose appliance mode and baked ref in the sister feed (#637) * Expose appliance mode and baked ref in the sister feed Part of #452. Implemented by an operator-run GPT session at the owner's request. Co-Authored-By: Claude Opus 5.5 * Drop added test comment Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: pithead-developer[bot] <328836012+pithead-developer[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: pithead-developer[bot] <4933481+pithead-developer[bot]@users.noreply.github.com> --- docs/pithead-integration.md | 8 +++++++- rigforge.sh | 2 +- tests/contract/v1/README.md | 2 ++ tests/contract/v1/feed.json | 2 ++ tests/run.sh | 18 +++++++----------- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/pithead-integration.md b/docs/pithead-integration.md index 1b5e89b..96e4c94 100644 --- a/docs/pithead-integration.md +++ b/docs/pithead-integration.md @@ -76,7 +76,10 @@ pithead#235): - `GET /1/summary` and `GET /2/summary` — XMRig's own body passed through **verbatim** (a strict superset: everything the `:8080` probe returns is here unchanged), a top-level UTC `generated_at` timestamp for freshness checks, plus one namespaced - `rigforge` object: `version`/`xmrig_version`/`xmrig_commit` (provenance), `tune` (applied + `rigforge` object: `version`/`xmrig_version`/`xmrig_commit` (provenance), + `appliance` (boolean, true when the running setup uses appliance mode, re-read from the persisted + setup marker unless `RIGFORGE_APPLIANCE` overrides it), `stamp` (the `RIGFORGE_REF` contents beside + the running script, `ref= version=`; `null` when absent, unreadable or empty), `tune` (applied overrides, last run's target/best/candidates, the autotune schedule), `power` (RAPL watts and hashrate-per-watt over a 1s window; `null` when unmeasurable), `health` (the doctor probes as JSON: HugePages, MSR state, governor, RAM channels/speeds, XMP and SMT state, throttling), @@ -103,6 +106,9 @@ pithead#235): `"rigforge": {..., "xmrig_api": "unreachable"}` — a down miner is exactly when the health data matters. +Coordinators can use `rigforge.appliance` to hide git-based upgrade controls on appliance rigs and +show `rigforge.stamp` as the baked release provenance instead. + When a token is set, `:8081` accepts either the exact bearer for existing clients or the lowercase hex HMAC-SHA256 derived with that token as the key and `rigforge:api-read:v1` as the message. The derived bearer grants reads only: `:8082` rejects it and continues to require an exact diff --git a/rigforge.sh b/rigforge.sh index 1bef52b..a2ee5f1 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -5301,7 +5301,7 @@ _api_control_json() { } _api_control_history_json() { for f in "${RIGFORGE_CONTROL_STATE:-/var/lib/rigforge-control}/changes"/*.json; do [ -f "$f" ] && jq -c '{change_id, status, reason}' "$f" 2>/dev/null; done | jq -cs '.' || true; } # #519: per-file so one malformed entry drops only itself; || true as the loop's rc leaks through pipefail _api_rigforge_block() { # - jq -n --arg v "$(cat "$SCRIPT_DIR/VERSION" 2>/dev/null || echo unknown)" --arg xv "$XMRIG_VERSION" --arg xc "$XMRIG_COMMIT" --argjson tune "$(_api_tune_json)" --argjson power "$(_api_power_json "$1")" --argjson health "$(_health_json)" --argjson watchdog "$(_watchdog_json)" --argjson config "$(_api_config_json)" --argjson config_meta "$(_api_config_meta_json)" --argjson control "$(_api_control_json)" --argjson control_history "$(_api_control_history_json)" '{version: $v, xmrig_version: $xv, xmrig_commit: $xc, tune: $tune, power: $power, health: $health, watchdog: $watchdog, config: $config, config_meta: $config_meta, control: $control, control_history: $control_history}' + jq -n --arg v "$(cat "$SCRIPT_DIR/VERSION" 2>/dev/null || echo unknown)" --arg appliance "$RIGFORGE_APPLIANCE" --arg stamp "$(cat "$SCRIPT_DIR/RIGFORGE_REF" 2>/dev/null || echo '')" --arg xv "$XMRIG_VERSION" --arg xc "$XMRIG_COMMIT" --argjson tune "$(_api_tune_json)" --argjson power "$(_api_power_json "$1")" --argjson health "$(_health_json)" --argjson watchdog "$(_watchdog_json)" --argjson config "$(_api_config_json)" --argjson config_meta "$(_api_config_meta_json)" --argjson control "$(_api_control_json)" --argjson control_history "$(_api_control_history_json)" '{version: $v, appliance: ($appliance == "1"), stamp: (if $stamp == "" then null else $stamp end), xmrig_version: $xv, xmrig_commit: $xc, tune: $tune, power: $power, health: $health, watchdog: $watchdog, config: $config, config_meta: $config_meta, control: $control, control_history: $control_history}' } # Produce response bodies atomically; the idle timer keeps probes off the request path (#164). api_refresh() { diff --git a/tests/contract/v1/README.md b/tests/contract/v1/README.md index 82648b1..345fe68 100644 --- a/tests/contract/v1/README.md +++ b/tests/contract/v1/README.md @@ -18,6 +18,8 @@ normalized and byte-compared against the committed file on every run — see The feed guard omits `DONATION` from its input config, so `feed.json` pins the zero default and the revision of that effective configuration under a fixed test key. Both values stay byte-strict when the default changes. +It also supplies appliance mode and a fixed `RIGFORGE_REF`, so `rigforge.appliance` and +`rigforge.stamp` stay byte-strict; separate rows cover DIY mode and a missing stamp. `control-status.json` has two producers because it has two servers of truth. `_control_status()` is the single choke point `control_apply`/`control_upgrade` write terminal outcomes through — the right diff --git a/tests/contract/v1/feed.json b/tests/contract/v1/feed.json index cf118a1..8a95041 100644 --- a/tests/contract/v1/feed.json +++ b/tests/contract/v1/feed.json @@ -19,6 +19,7 @@ 1248 ], "rigforge": { + "appliance": true, "config": { "DONATION": 0, "autotune": "disabled", @@ -71,6 +72,7 @@ "hs_per_watt": null, "watts": null }, + "stamp": "ref=0000000000000000000000000000000000000000 version=0.0.0-fixture", "tune": { "applied": null, "autotune": { diff --git a/tests/run.sh b/tests/run.sh index 68929c2..62cd50f 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -7892,7 +7892,7 @@ rfblk="$( printf '%s\n' '{ "pools":[{"url":"h:3333","pass":"PWNEEDLE415"}] }' >"$rfd/config.json" ( source "$SCRIPT" - CONFIG_JSON="$rfd/config.json" + CONFIG_JSON="$rfd/config.json" RIGFORGE_APPLIANCE=0 SCRIPT_DIR="$rfd" CONFIG_META_FILE="$rfd/meta.json" set +e @@ -7908,6 +7908,8 @@ assert_eq "feed: served config still masks pass + config_meta has a revision (co # emits is `__secret__`, so a needle of "secret" would match the MASK and report a leak that is not # one — the assertion has to be able to tell the two apart to mean anything. assert_absent "feed: no pool password anywhere in the served block (contract)" "$rfblk" "PWNEEDLE415" +assert_eq "feed: DIY appliance is a boolean false (#452)" "$(printf '%s' "$rfblk" | jq -c '.appliance')" "false" +assert_eq "feed: missing stamp is null (#452)" "$(printf '%s' "$rfblk" | jq -r 'has("stamp") and .stamp == null')" "true" # missing staged file -> unreadable branch; broken config -> merge-fail branch missing_d=$(mktemp -d "$SANDBOX/cm.XXXXXX") printf '%s\n' "$CFG_236" >"$missing_d/config.json" @@ -9499,13 +9501,14 @@ fi FEEDFX="$(mktemp -d "$SANDBOX/feedfx.XXXXXX")" mkdir -p "$FEEDFX/home" "$FEEDFX/data" "$FEEDFX/control" printf 'v0.0.0-fixture\n' >"$FEEDFX/VERSION" +printf 'ref=0000000000000000000000000000000000000000 version=0.0.0-fixture\n' >"$FEEDFX/RIGFORGE_REF" printf 'fixture-revision-key' >"$FEEDFX/revkey" printf '{ "HOME_DIR": "%s/home", "pools": [{"url": "h:3333", "pass": "secret"}], "watchdog": "enabled", "max_temp_c": 85 }\n' "$FEEDFX" >"$FEEDFX/config.json" printf '%s' '{"status":"rolled_back","change_id":"7777777777777777","source":"control","applied_at":"2026-01-01T00:00:00Z","changed_keys":["watchdog","DONATION"],"reason":"miner did not return to a live hashrate; rolled back and live","backup":"/b","warnings":["thermal protection changed: watchdog"]}' >"$FEEDFX/control/status.json" printf '%s' '{"hashrate":{"total":[1234.5,0,0]},"connection":{"pool":"poolbox.lan:3333","uptime":93700,"failures":0,"accepted":42,"rejected":1},"uptime":93780,"hugepages":[1248,1248]}' >"$FEEDFX/xmrig-body.json" ( source "$SCRIPT" - OS_TYPE=Linux + OS_TYPE=Linux RIGFORGE_APPLIANCE=1 SCRIPT_DIR="$FEEDFX" CONFIG_JSON="$FEEDFX/config.json" CONFIG_META_FILE="$FEEDFX/meta.json" @@ -9513,15 +9516,6 @@ printf '%s' '{"hashrate":{"total":[1234.5,0,0]},"connection":{"pool":"poolbox.la RIGFORGE_API_DATA="$FEEDFX/data" RIGFORGE_CONTROL_STATE="$FEEDFX/control" API_CMD="cat '$FEEDFX/xmrig-body.json'" - # _health_json shells out to the REAL `systemctl is-active` for service_active — API_CMD only - # replaces the worker-API curl, not that. On any Linux box genuinely running the xmrig unit (i.e. - # every fleet rig) the untouched default SERVICE_NAME=xmrig would read "active" there and byte- - # diff this fixture on rigs while passing everywhere else. Point it at a unit name no rig will - # ever have instead of putting $STUBS on PATH: the generic systemctl stub there always exits 0 - # (fine for the tests that use it, which never assert exact service_active/autotune values), which - # would make this read "active" unconditionally instead of fixing anything. Pins service_active: - # false and, since both are gated on it, clock_pct_of_boost/throttling: null too — see - # contract/v1/README.md. SERVICE_NAME="rigforge-contract-fixture-nonexistent" set +e api_refresh 2>/dev/null @@ -9529,6 +9523,8 @@ printf '%s' '{"hashrate":{"total":[1234.5,0,0]},"connection":{"pool":"poolbox.la live_feed="$(jq -S '.generated_at = "NORMALIZED" | .rigforge.version = "NORMALIZED" | .rigforge.xmrig_version = "NORMALIZED" | .rigforge.xmrig_commit = "NORMALIZED"' "$FEEDFX/data/summary.json")" fixture_feed="$(jq -S . "$CONTRACT_DIR/feed.json")" assert_eq "sister-API feed shape matches the fixture (#351)" "$live_feed" "$fixture_feed" +assert_eq "feed: appliance mode is a boolean true (#452)" "$(jq -c '.rigforge.appliance' "$FEEDFX/data/summary.json")" "true" +assert_eq "feed: baked stamp is preserved (#452)" "$(jq -r '.rigforge.stamp' "$FEEDFX/data/summary.json")" "$(cat "$FEEDFX/RIGFORGE_REF")" # --------------------------------------------------------------------------- echo "" From 65f458795a9636304d614a5f8045ff1dad1ffb2f Mon Sep 17 00:00:00 2001 From: "pithead-developer[bot]" <328836012+pithead-developer[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:14:30 -0600 Subject: [PATCH 4/7] Automate docs replay, API allowlist, and uninstall checks (#617) * Automate docs replay, API allowlist, and uninstall checks Part of #586. Implemented by an operator-run GPT session at the owner's request. Co-Authored-By: Claude Opus 5.5 * test: register the allowlist kernel test in CI and drop the replay wrapper (#586) Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: pithead-developer[bot] <328836012+pithead-developer[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: pithead-developer[bot] <4933481+pithead-developer[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 5 + Makefile | 8 +- README.md | 4 + docs/getting-started.md | 16 ++ rigforge.sh | 2 +- tests/README.md | 63 ++++++ tests/docs-replay-commands.sh | 40 ++++ tests/docs_replay.py | 183 +++++++++++++++++ tests/e2e-docs-guest.sh | 321 ++++++++++++++++++++++++++++++ tests/e2e-pithead-allowlist.sh | 123 ++++++++++++ tests/e2e-pithead.sh | 6 + tests/run.sh | 6 +- tests/test-api-allowlist-linux.sh | 115 +++++++++++ tests/test-api-allowlist-probe.sh | 104 ++++++++++ tests/test-docs-replay.py | 166 +++++++++++++++ tests/test-e2e-docs-guest.sh | 151 ++++++++++++++ tests/test-summary-diagnostics.sh | 1 + 17 files changed, 1309 insertions(+), 5 deletions(-) create mode 100644 tests/docs-replay-commands.sh create mode 100644 tests/docs_replay.py create mode 100755 tests/e2e-docs-guest.sh create mode 100644 tests/e2e-pithead-allowlist.sh create mode 100644 tests/test-api-allowlist-linux.sh create mode 100644 tests/test-api-allowlist-probe.sh create mode 100644 tests/test-docs-replay.py create mode 100755 tests/test-e2e-docs-guest.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acdb063..1ad607e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,11 @@ jobs: # /run/systemd/system, no package installs, no fstab/limits/logrotate writes, --runtime enables. - name: Run end-to-end suite run: make test-e2e + - name: Run API allowlist kernel test + run: >- + docker run --rm --privileged -v "$PWD:/src:ro" + alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce + sh -c 'apk add --no-cache bash iproute2 nftables curl jq python3 >/dev/null && bash /src/tests/test-api-allowlist-linux.sh' coverage: name: Coverage (kcov + diff-cover) diff --git a/Makefile b/Makefile index 2ce1f6b..255025e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Local test entry points (mirror the GitHub Actions CI jobs). -.PHONY: help test test-suite test-e2e test-e2e-macos smoke coverage e2e-real e2e-pithead lint lint-topology lint-file-budget fmt lint-yaml lint-md lint-links lint-all lint-actions dev-setup ci +.PHONY: help test test-suite test-e2e test-e2e-macos smoke coverage e2e-real e2e-pithead e2e-docs-replay lint lint-topology lint-file-budget fmt lint-yaml lint-md lint-links lint-all lint-actions dev-setup ci SHELL_FILES = $(shell git ls-files '*.sh') @@ -21,11 +21,17 @@ test-suite: ## rigforge test suite: unit + black-box, every CPU/OS profile simul bash tests/test-docs-clone-pin.sh bash tests/test-staged-rebuild.sh bash tests/test-tool-pin-watch.sh + PYTHONDONTWRITEBYTECODE=1 python3 tests/test-docs-replay.py + bash tests/test-api-allowlist-probe.sh + bash tests/test-e2e-docs-guest.sh bash tests/test-revision-key.sh test-e2e: ## Full end-to-end run in disposable Linux containers (needs Docker) bash tests/e2e/linux.sh +e2e-docs-replay: ## Replay documented setup and two reboots in an explicitly supplied disposable KVM guest + python3 tests/docs_replay.py + test-e2e-macos: ## Native macOS e2e: real rigforge.sh (brew/git/cmake stubbed) — BSD sed, launchd, nohup (macOS only, deprecated, not run in CI) bash tests/e2e/macos.sh diff --git a/README.md b/README.md index 3a9c8dc..49c00f6 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ both CPUs, and the caveats: [Benchmarks](docs/benchmarks.md). On the machine you want to turn into a miner: + + ```bash git clone --branch v1.18.0 https://github.com/p2pool-starter-stack/rigforge.git cd rigforge @@ -97,6 +99,8 @@ chmod +x rigforge.sh sudo ./rigforge.sh ``` + + The script needs root to install packages and tune the system. First-run setup asks for your pool URL and optional password. For an `.onion` pool it offers a validated SOCKS proxy override, defaulting to `127.0.0.1:9050`; install and run Tor yourself. It writes a minimal `config.json`. On Linux, reboot diff --git a/docs/getting-started.md b/docs/getting-started.md index fc2668e..9a840b0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -38,20 +38,28 @@ You don't need to install build dependencies yourself. RigForge installs the too ## 2. Get the code + + ```bash git clone --branch v1.18.0 https://github.com/p2pool-starter-stack/rigforge.git cd rigforge chmod +x rigforge.sh ``` + + Want to see exactly what setup would do to this machine before handing it root? The dry run prints a numbered plan (missing packages, the GRUB diff, HugePages count, units) and changes nothing — it doesn't even need sudo: + + ```bash ./rigforge.sh setup --dry-run ``` + + Have your **pool URL** ready, a `host:port`. For a Pithead stack that's the stack machine's address and its proxy port `3333` (e.g. `stack.lan:3333`); with Pithead you do **not** need a wallet, since the stack handles payouts centrally. @@ -66,10 +74,14 @@ stack handles payouts centrally. ## 3. Run setup + + ```bash sudo ./rigforge.sh ``` + + `setup` is the default command and is safe to re-run. On a fresh machine it walks through: 1. **Dependencies.** Installs the build toolchain and runtime libraries for your OS. @@ -103,10 +115,14 @@ uses `./rigforge.sh` throughout. (`uninstall` removes the symlink.) To apply HugePages and the other kernel tuning, a reboot is **required** on Linux. The script tells you when: + + ```bash sudo reboot ``` + + After the reboot the `xmrig` service starts automatically. (macOS needs no reboot.) --- diff --git a/rigforge.sh b/rigforge.sh index a2ee5f1..1a5860e 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -1381,7 +1381,7 @@ table inet rigforge { type filter hook input priority filter; policy accept; tcp dport { $ports } iifname "lo" accept tcp dport { $ports } ${API_ALLOW_FAMILY:-ip} saddr $API_ALLOW_FROM accept - tcp dport { $ports } drop + tcp dport { $ports } counter drop } } NFT diff --git a/tests/README.md b/tests/README.md index ceebef0..50c04bc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -118,6 +118,69 @@ installed by this diagnostic. Both stop at a failed mutation prerequisite and make restoration failure fail the process; recovery snapshots remain on disk when cleanup cannot prove the original config bytes and runtime were restored. +### Documentation replay on a disposable VM + +`make e2e-docs-replay` is the VM job entry point for the documented installation and +uninstall lifecycle. Run it on a Linux KVM controller with Python 3 and `virsh` access to +the local system libvirt instance. The job requires these environment variables: + +| Variable | Required value | +|---|---| +| `E2E_DOCS_DISPOSABLE` | `1`, acknowledging that setup, uninstall and two reboots will mutate the guest. | +| `E2E_DOCS_VM_UUID` | UUID of a running, transient, disposable Ubuntu cloud VM with AVX2 exposed. Persistent domains are refused. | +| `E2E_DOCS_CONFIG` | Path to the prepared configuration for this run, kept outside the checkout. | + +The VM owner supplies and disposes the guest and a throwaway plain-Stratum endpoint +that issues RandomX jobs and accepts low-difficulty shares. Provision only the test +transport and observation tools beforehand: QEMU guest agent with guest execution +enabled, Git, sudo, jq, curl, nftables and iproute2. The guest needs systemd, a stock +Ubuntu GRUB installation and enough memory for RandomX; it must have no prior +RigForge installation or reserved HugePages. No live rig is a valid fixture. +The prepared config must contain one pool, donation zero and a nonempty +`api_allow_from`. Use `docs-replay` as the pool user and an empty password or `x`; +do not supply a wallet, production endpoint or credential. Leave `ACCESS_TOKEN` +and control disabled and use the default worker location. Only `pools`, `DONATION`, +`api_allow_from`, `HOME_DIR` and optional `threads` are accepted; pool objects accept +only `url`, `user` and `pass`. The guest must be able +to download the documented release and its build dependencies. + +The driver extracts the marked Bash blocks from the README and walkthrough, +checks their quick-start commands agree, and executes them unchanged through the +guest agent. It inserts the prepared config before the documented dry run and +setup. This deliberately tests the release pinned by the documentation; it does +not silently replace that release with the candidate branch. Marker and command +agreement lint runs in `tests/run.sh` without Python. The host-driver regression +and guest-check negative controls run under `make test-suite`. + +The job snapshots the system changes before dry run, checks autostart after a new +boot ID, donation zero, the configured pool as the only miner peer, increasing +accepted shares with no rejects, and a refused base-API write. After uninstall and +a second new boot ID it checks units, processes, listeners, pool sockets, the nft +table, 2 MiB HugePages and GRUB restoration. A stopped or unreachable guest after +reboot is a platform failure (exit 2), never an autostart failure. Other failed +assertions exit 1. The controller emits only fixed progress messages; raw guest +output stays out of public evidence. Always discard the fixture after success, +failure or timeout; the driver never deletes a caller-owned VM. + +The existing worker-to-stack `network` phase also makes a bounded request from a +temporary network namespace, bracketed by successful requests from the allowed +source. It requires the API drop counter to increase and the miner to keep hashing, +then removes its namespace and veth. The production rule now includes that counter. +Local negative controls cover an admitted namespace and an uninstall that leaves +the nft table. The real AVX2 reboot/mining job and reserved-worker network phase +remain required evidence beyond the hardware-free tests and container checks. + +The namespace probe also has an isolated Linux kernel test, using a small HTTP +server and a fixed test hashrate instead of a miner. It proves filtering and +cleanup, including the admitted-source and unchanged-counter negative controls. The +`e2e` CI job runs it with: + +```bash +docker run --rm --privileged -v "$PWD:/src:ro" \ + alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce \ + sh -c 'apk add --no-cache bash iproute2 nftables curl jq python3 >/dev/null && bash /src/tests/test-api-allowlist-linux.sh' +``` + ### The shared rig (miner-0) miner-0 is the e2e bench for both RigForge's release gates and Pithead's e2e testing, and the two diff --git a/tests/docs-replay-commands.sh b/tests/docs-replay-commands.sh new file mode 100644 index 0000000..153d174 --- /dev/null +++ b/tests/docs-replay-commands.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +extract() { + local script + script=$(awk -v name="$1" ' + BEGIN { begin=""; end="" } + $0 == begin { if (stage) bad=1; stage=1; next } + $0 == end { if (stage != 3) bad=1; stage=4; next } + stage == 1 && $0 == "```bash" { stage=2; next } + stage == 2 && $0 == "```" { stage=3; next } + stage == 1 || stage == 3 { if ($0 !~ /^[[:space:]]*$/) bad=1; next } + stage == 2 { + if ($0 ~ /```|\n```bash\nsudo ./rigforge.sh\n```\n" + self.assertEqual(block(text, "setup"), "sudo ./rigforge.sh\n") + self.assertEqual(block(text.replace("rigforge.sh", "rigforge.sh setup"), "setup"), + "sudo ./rigforge.sh setup\n") + + def test_missing_duplicate_empty_reversed_and_malformed_blocks_fail(self): + begin = "" + end = "" + for text in ("", begin + begin + end, end + begin, + begin + "\n```bash\n\n```\n" + end, + begin + "\n```bash\nif\n```\n" + end, + begin + "echo unmarked" + end): + with self.subTest(text=text), self.assertRaises(ReplayError): + block(text, "setup") + + +class Configuration(unittest.TestCase): + def config(self): + return {"pools": [{"url": f"fixture-{os.getpid()}:{10000 + os.getpid() % 40000}", + "user": "docs-replay", "pass": "x"}], + "api_allow_from": str(ipaddress.IPv4Address(os.getpid()))} + + def test_minimal_fixture_is_accepted(self): + check_config(json.dumps(self.config())) + + def test_credentials_and_extra_settings_are_rejected(self): + for field, value in (("ACCESS_TOKEN", "fixture"), ("api", "enabled"), + ("control", "enabled"), ("DONATION", 1)): + data = self.config() + data[field] = value + with self.subTest(field=field), self.assertRaises(ReplayError): + check_config(json.dumps(data)) + for field, value in (("user", "nonfixture"), ("pass", "nonfixture"), ("tls", True)): + data = self.config() + data["pools"][0][field] = value + with self.subTest(field=field), self.assertRaises(ReplayError): + check_config(json.dumps(data)) + + def test_endpoint_cannot_embed_credentials_or_a_path(self): + for transform in (lambda url: "credential@" + url, lambda url: url + "/path"): + data = self.config() + data["pools"][0]["url"] = transform(data["pools"][0]["url"]) + with self.assertRaises(ReplayError): + check_config(json.dumps(data)) + + +class Agent(unittest.TestCase): + def setUp(self): + self.guest = Guest("00000000-0000-4000-8000-000000000001") + + def test_non_uuid_domain_refused(self): + with self.assertRaises(ValueError): + Guest("existing-domain") + + def test_exec_requires_success(self): + for status in ({"exited": True, "exitcode": 1}, + {"exited": True, "signal": 9}, + {"exited": True, "exitcode": 0, "out-truncated": True}): + with self.subTest(status=status), patch.object(self.guest, "agent", side_effect=[{"pid": 42}, status]): + with self.assertRaises(ReplayError): + self.guest.run("true\n") + + def test_exec_preserves_script_and_decodes_output(self): + status = {"exited": True, "exitcode": 0, + "out-data": base64.b64encode(b"done\n").decode()} + with patch.object(self.guest, "agent", side_effect=[{"pid": 42}, status]) as agent: + self.assertEqual(self.guest.run("printf 'done\\n'\n"), "done\n") + arguments = agent.call_args_list[0].kwargs + self.assertEqual(base64.b64decode(arguments["input-data"]), b"printf 'done\\n'\n") + + def test_reboot_requires_new_boot_id(self): + with patch.object(self.guest, "boot_id", side_effect=["before", "before", "after"]), \ + patch.object(self.guest, "start"), patch("docs_replay.time.sleep"): + self.guest.reboot("sudo reboot\n") + + def test_reboot_can_disconnect_before_exec_acknowledgement(self): + with patch.object(self.guest, "boot_id", side_effect=["before", "after"]), \ + patch.object(self.guest, "start", side_effect=PlatformError("disconnected")), \ + patch("docs_replay.time.sleep"): + self.guest.reboot("sudo reboot\n") + + def test_stopped_guest_is_platform_failure(self): + with patch.object(self.guest, "boot_id", return_value="before"), \ + patch.object(self.guest, "start"), \ + patch.object(self.guest, "virsh", return_value="shut off\n"): + with self.assertRaisesRegex(PlatformError, "remained stopped"): + self.guest.reboot("sudo reboot\n", timeout=0) + + def test_running_guest_without_new_boot_is_not_autostart_failure(self): + with patch.object(self.guest, "boot_id", return_value="before"), \ + patch.object(self.guest, "start"), \ + patch.object(self.guest, "virsh", return_value="running\n"): + with self.assertRaisesRegex(PlatformError, "new boot ID"): + self.guest.reboot("sudo reboot\n", timeout=0) + + +class Sequence(unittest.TestCase): + def setUp(self): + self.root = Path(__file__).resolve().parent.parent + self.guest = Mock() + self.guest.virsh.side_effect = ["Persistent: no\n", "running\n", ""] + self.guest.run.side_effect = ["/var/tmp/rigforge-docs.TEST\n"] + [""] * 9 + + def test_documents_drive_commands_across_both_reboots(self): + with patch("builtins.print"): + replay(self.root, self.guest, b"{}") + commands = recipes(self.root) + scripts = [call.args[0] for call in self.guest.run.call_args_list] + self.assertTrue(scripts[1].endswith(commands["clone"])) + self.assertIn("config.json", scripts[2]) + self.assertIn(" baseline ", scripts[3]) + self.assertTrue(scripts[4].endswith(commands["dry-run"])) + self.assertIn(" dry-run-check ", scripts[5]) + self.assertTrue(scripts[6].endswith(commands["setup"])) + self.assertIn(" installed ", scripts[7]) + self.assertIn("uninstall --yes", scripts[8]) + self.assertIn(" removed ", scripts[9]) + self.assertEqual([call.args[0] for call in self.guest.reboot.call_args_list], + [commands["reboot"], commands["reboot"]]) + + def test_persistent_domain_is_refused_before_mutation(self): + self.guest.virsh.side_effect = None + self.guest.virsh.return_value = "Persistent: yes\n" + with self.assertRaisesRegex(ReplayError, "transient"): + replay(self.root, self.guest, b"{}") + self.guest.run.assert_not_called() + + def test_stopped_domain_is_refused_before_mutation(self): + self.guest.virsh.side_effect = ["Persistent: no\n", "shut off\n"] + with self.assertRaisesRegex(PlatformError, "not running"): + replay(self.root, self.guest, b"{}") + self.guest.run.assert_not_called() + + def test_emulated_domain_is_refused_before_mutation(self): + self.guest.virsh.side_effect = ["Persistent: no\n", "running\n", ""] + with self.assertRaisesRegex(PlatformError, "KVM"): + replay(self.root, self.guest, b"{}") + self.guest.run.assert_not_called() + + def test_failed_dry_run_check_prevents_setup(self): + self.guest.run.side_effect = ["/var/tmp/rigforge-docs.TEST\n"] + [""] * 4 + [ReplayError("changed")] + with self.assertRaisesRegex(ReplayError, "changed"): + replay(self.root, self.guest, b"{}") + self.guest.reboot.assert_not_called() + self.assertEqual(self.guest.run.call_count, 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test-e2e-docs-guest.sh b/tests/test-e2e-docs-guest.sh new file mode 100755 index 0000000..4869889 --- /dev/null +++ b/tests/test-e2e-docs-guest.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -uo pipefail +STANDALONE_586=0 +if [ -z "${ROOT:-}" ]; then + STANDALONE_586=1 + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + SANDBOX="$(mktemp -d)" + trap 'rm -rf "$SANDBOX"' EXIT + PASS=0 + FAIL=0 + ok() { + PASS=$((PASS + 1)) + printf ' PASS %s\n' "$1" + } + bad() { + FAIL=$((FAIL + 1)) + printf ' FAIL %s: %s\n' "$1" "$2" >&2 + } + assert_rc() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected $3, got $2"; fi; } + assert_contains() { case "$2" in *"$3"*) ok "$1" ;; *) bad "$1" "missing $3" ;; esac } +fi + +echo "== unit: docs replay guest verification (#586) ==" +T586="$(mktemp -d "$SANDBOX/docs-guest.XXXXXX")" +mkdir -p "$T586/bin" "$T586/clone/data/worker/xmrig/build" "$T586/state" "$T586/grub.d" +POOL_HOST="pool-$PPID" +POOL_ADDRESS="address-$PPID" +POOL_PORT="$((20000 + PPID % 10000))" +API_PORT="$((POOL_PORT + 1))" +API_BIND="bind-$PPID" +LOOPBACK_ADDRESS="loop-$PPID" + +printf 'boot-before\n' >"$T586/boot-id" +printf 'root=fixture quiet\n' >"$T586/cmdline" +printf 'flags avx2 fixture\n' >"$T586/cpuinfo" +printf 'ID=ubuntu\n' >"$T586/os-release" +printf '0\n' >"$T586/hugepages-2m" +printf 'GRUB_CMDLINE_LINUX_DEFAULT="quiet"\n' >"$T586/grub" +printf 'generated grub fixture\n' >"$T586/grub.cfg" +printf 'fstab fixture\n' >"$T586/fstab" +printf 'limits fixture\n' >"$T586/limits" +printf 'modules fixture\n' >"$T586/modules" +: >"$T586/nft-present" +rm "$T586/nft-present" + +jq -n --arg url "$POOL_HOST:$POOL_PORT" '{HOME_DIR:"DYNAMIC_HOME",pools:[{url:$url}]}' >"$T586/clone/config.json" +jq -n --arg url "$POOL_HOST:$POOL_PORT" --arg host "$API_BIND" --argjson port "$API_PORT" '{pools:[{url:$url,enabled:true}],"donate-level":0,"donate-over-proxy":0,http:{host:$host,port:$port,restricted:true,"access-token":null}}' >"$T586/clone/data/worker/xmrig/build/config.json" + +printf '%s\n' '#!/usr/bin/env bash' 'case "$1" in -s) echo Linux ;; -m) echo x86_64 ;; esac' >"$T586/bin/uname" +printf '%s\n' '#!/usr/bin/env bash' 'if [ "$1" = -p ]; then echo systemd; fi' >"$T586/bin/ps" +printf '%s\n' '#!/usr/bin/env bash' \ + 'case "$1" in' \ + 'is-system-running) exit 0 ;;' \ + 'list-unit-files|list-units) [ "${FAKE_SYSTEMCTL_FAIL:-0}" = 1 ] && exit 2; [ "${FAKE_UNITS:-}" = present ] && echo "xmrig.service enabled"; exit 0 ;;' \ + 'is-enabled|is-active) [ "${FAKE_INSTALLED:-0}" = 1 ] ;;' \ + 'esac' >"$T586/bin/systemctl" +printf '%s\n' '#!/usr/bin/env bash' '[ "${FAKE_PROCESSES:-}" = present ] && echo "123 xmrig"; exit 0' >"$T586/bin/pgrep" +printf '%s\n' '#!/usr/bin/env bash' \ + '[ "${FAKE_SS_FAIL:-0}" = 1 ] && exit 2' \ + 'case "$1" in' \ + '-Htnp) [ "${FAKE_PEER:-}" = present ] && printf "ESTAB 0 0 local %s:%s users:xmrig\n" "$FAKE_POOL_ADDRESS" "$FAKE_POOL_PORT" ;;' \ + '*) [ "${FAKE_LISTENER:-}" = present ] && printf "LISTEN 0 0 bind:%s users:xmrig\n" "$FAKE_API_PORT" ;;' \ + 'esac' \ + 'exit 0' >"$T586/bin/ss" +printf '%s\n' '#!/usr/bin/env bash' \ + '[ "${FAKE_NFT_MODE:-}" = fail ] && exit 2' \ + 'if [ -f "$FAKE_NFT_FILE" ]; then printf '\''{"nftables":[{"table":{"family":"inet","name":"rigforge"}}]}\n'\''; else printf '\''{"nftables":[]}\n'\''; fi' >"$T586/bin/nft" +printf '%s\n' '#!/usr/bin/env bash' 'printf "%s STREAM fixture\n" "$FAKE_RESOLVED_ADDRESS"' >"$T586/bin/getent" +printf '%s\n' '#!/usr/bin/env bash' 'printf "1: lo inet %s/8 scope host lo\n" "$FAKE_LOOPBACK_ADDRESS"' >"$T586/bin/ip" +printf '%s\n' '#!/usr/bin/env bash' \ + 'case "$*" in' \ + '*-X?PUT*) [ "${FAKE_CURL_MODE:-}" = transport ] && exit 7; [ "${FAKE_CURL_MODE:-}" = write200 ] && printf 200 || printf 403 ;;' \ + '*) n=0; [ -f "$FAKE_CURL_COUNT" ] && n=$(cat "$FAKE_CURL_COUNT"); n=$((n + 1)); printf "%s" "$n" >"$FAKE_CURL_COUNT"; printf "{\"donate_level\":0,\"hashrate\":{\"total\":[1]},\"connection\":{\"accepted\":%s,\"rejected\":0}}\n" "$n" ;;' \ + 'esac' >"$T586/bin/curl" +printf '%s\n' '#!/usr/bin/env bash' ':' >"$T586/bin/sleep" +chmod +x "$T586/bin"/* + +guest586() { + env PATH="$T586/bin:$PATH" \ + E2E_BOOT_ID_FILE="$T586/boot-id" E2E_CMDLINE_FILE="$T586/cmdline" \ + E2E_CPUINFO_FILE="$T586/cpuinfo" E2E_OS_RELEASE_FILE="$T586/os-release" E2E_HUGEPAGES_2M_FILE="$T586/hugepages-2m" \ + E2E_GRUB_DEFAULT_FILE="$T586/grub" E2E_GRUB_CONFIG_FILE="$T586/grub.cfg" \ + E2E_GRUB_DROPIN_DIR="$T586/grub.d" E2E_FSTAB_FILE="$T586/fstab" E2E_LIMITS_FILE="$T586/limits" \ + E2E_MODULES_FILE="$T586/modules" E2E_MSR_LOAD_FILE="$T586/msr-load" \ + E2E_SHARE_TIMEOUT=1 E2E_SHARE_INTERVAL=1 E2E_ACTIVE_TIMEOUT=1 FAKE_NFT_FILE="$T586/nft-present" \ + FAKE_POOL_ADDRESS="$POOL_ADDRESS" FAKE_RESOLVED_ADDRESS="$POOL_ADDRESS" FAKE_POOL_PORT="$POOL_PORT" FAKE_API_PORT="$API_PORT" \ + FAKE_LOOPBACK_ADDRESS="$LOOPBACK_ADDRESS" FAKE_CURL_COUNT="$T586/curl-count" "$@" \ + bash "$ROOT/tests/e2e-docs-guest.sh" "${CASE_PHASE:?}" "$T586/clone" "$T586/state" +} + +CASE_PHASE=baseline guest586 +assert_rc "guest verifier captures a fresh AVX2 systemd baseline" "$?" 0 +CASE_PHASE=dry-run-check guest586 +assert_rc "guest verifier proves dry-run left stable system state unchanged" "$?" 0 +printf 'boot-installed\n' >"$T586/boot-id" +: >"$T586/nft-present" +: >"$T586/curl-count" +peer_err="$(CASE_PHASE=installed guest586 FAKE_INSTALLED=1 FAKE_PEER=present FAKE_POOL_ADDRESS="other-$PPID" 2>&1)" +peer_rc=$? +assert_rc "guest verifier rejects a same-port peer at another address" "$peer_rc" 1 +assert_contains "peer negative control names the unexpected endpoint" "$peer_err" "xmrig has an unexpected peer" +GENERATED_FIXTURE="$T586/clone/data/worker/xmrig/build/config.json" +cp "$GENERATED_FIXTURE" "$T586/generated-config.saved" +jq --arg url "generated-$PPID:$POOL_PORT" '.pools[0].url = $url' "$GENERATED_FIXTURE" >"$T586/generated-config.changed" +mv "$T586/generated-config.changed" "$GENERATED_FIXTURE" +: >"$T586/curl-count" +pool_config_err="$(CASE_PHASE=installed guest586 FAKE_INSTALLED=1 FAKE_PEER=present 2>&1)" +pool_config_rc=$? +assert_rc "guest verifier rejects a generated pool that differs from prepared config" "$pool_config_rc" 1 +assert_contains "generated pool mismatch is explicit" "$pool_config_err" "generated pool differs from prepared config" +mv "$T586/generated-config.saved" "$GENERATED_FIXTURE" +nft_install_err="$(CASE_PHASE=installed guest586 FAKE_INSTALLED=1 FAKE_PEER=present FAKE_NFT_MODE=fail 2>&1)" +nft_install_rc=$? +assert_rc "installed verification fails closed on nft inventory errors" "$nft_install_rc" 1 +assert_contains "installed nft inventory error is explicit" "$nft_install_err" "nft inventory failed" +for curl_mode in transport write200; do + : >"$T586/curl-count" + api_err="$(CASE_PHASE=installed guest586 FAKE_INSTALLED=1 FAKE_PEER=present FAKE_CURL_MODE="$curl_mode" 2>&1)" + api_rc=$? + assert_rc "guest verifier rejects API write probe mode $curl_mode" "$api_rc" 1 + assert_contains "API write failure mode $curl_mode is explicit" "$api_err" "base API write probe" +done +: >"$T586/curl-count" +CASE_PHASE=installed guest586 FAKE_INSTALLED=1 FAKE_PEER=present +assert_rc "guest verifier proves reboot autostart, pool, shares, API and nft state" "$?" 0 +printf 'boot-removed\n' >"$T586/boot-id" +rm "$T586/nft-present" +CASE_PHASE=removed guest586 +assert_rc "guest verifier proves rebooted uninstall cleanup" "$?" 0 +: >"$T586/nft-present" +removed_err="$(CASE_PHASE=removed guest586 2>&1)" +removed_rc=$? +assert_rc "guest verifier fails when uninstall leaves the nft table" "$removed_rc" 1 +assert_contains "leftover nft negative control names the residue" "$removed_err" "inet rigforge nft table remains" +rm "$T586/nft-present" +for failure in "FAKE_NFT_MODE=fail:nft" "FAKE_SS_FAIL=1:socket" "FAKE_SYSTEMCTL_FAIL=1:unit"; do + setting="${failure%%:*}" + label="${failure##*:}" + command_name="${setting%%=*}" + command_value="${setting#*=}" + probe_err="$(CASE_PHASE=removed guest586 "$command_name=$command_value" 2>&1)" + probe_rc=$? + assert_rc "guest verifier fails closed on $label inventory errors" "$probe_rc" 1 + assert_contains "$label inventory error is explicit" "$probe_err" "inventory failed" +done + +if [ "$STANDALONE_586" = 1 ]; then + printf 'docs guest tests: %s passed, %s failed\n' "$PASS" "$FAIL" + [ "$FAIL" = 0 ] +fi diff --git a/tests/test-summary-diagnostics.sh b/tests/test-summary-diagnostics.sh index c8c1e59..1e9efee 100644 --- a/tests/test-summary-diagnostics.sh +++ b/tests/test-summary-diagnostics.sh @@ -40,6 +40,7 @@ assert_eq "summary diagnostics: multiple records refused" "$(summary_diag_case ' } api8080() { printf '{"hashrate":{}}'; } api8081() { printf '{"rigforge":{"xmrig_api":"unreachable"}}'; } + phase_api_allowlist() { :; } ok() { :; } bad() { printf 'FAIL:%s\n' "$1" From cbca1a00285195b5d3cd60b1846602cbd8c95d8b Mon Sep 17 00:00:00 2001 From: "pithead-developer[bot]" <328836012+pithead-developer[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:14:42 -0600 Subject: [PATCH 5/7] Add an opt-in shared-host resource profile (#611) * Add an opt-in shared-host resource profile Part of #577. Implemented by an operator-run gpt-6-astra session at the owner's request. Co-Authored-By: Claude Opus 5.5 * chore: remove added source comments Co-Authored-By: Claude Sonnet 5.5 * test: allow the nft teardown call in the shared-host uninstall stub Co-Authored-By: Claude Sonnet 5.5 * test: run the shared-host suites under coverage and cover dedicated uninstall and lockdown doctor paths Co-Authored-By: Claude Sonnet 5.5 * test: report shared-host suite output when it fails under run.sh Co-Authored-By: Claude Sonnet 5.5 * test: give the shared-host lifecycle suite the envsubst stub and stay inside the run.sh budget Co-Authored-By: Claude Sonnet 5.5 * test: pass the stub directory only to the lifecycle suite and keep the donation exit status Co-Authored-By: Claude Sonnet 5.5 * fix: size and isolate the shared-host build within the memory ceiling Compute build parallelism from memory_max_mb in shared mode and stop a running miner before compiling, so the compiler and the miner never compete for the same cgroup memory ceiling. Co-Authored-By: Claude Opus 5.5 * test: add an opt-in shared-host tier4-e2e phase on a real rig Switches the rig to shared_host, verifies the effective slice limits, the miner's cgroup, the generated XMRig settings, status and an accepted share, then restores the dedicated configuration and compares unrelated host settings before, during and after. Co-Authored-By: Claude Opus 5.5 * fix: let a capability-less shared-host miner write its log, harden the shared-host e2e phase The shared unit keeps no capabilities, so a root miner could not write the operator-owned xmrig.log after re-own (bench job 3494 saw no stratum job in the log). Re-own now gives the generated config and log to the miner account in shared mode. The e2e phase removes its marker as soon as the profile is applied, prints unit and log diagnostics on failure, survives a missing dataset line and checks the slice by load state and miner cgroup. Co-Authored-By: Claude Opus 5.5 * fix: stop the owned shared-host slice when the profile is disabled Removing the slice's unit file left the started slice active (bench job 3495). Removal now waits until the miner has left the slice, then stops the slice, deletes the owned fragment and reloads systemd; apply, upgrade and setup run it again after their restart. Co-Authored-By: Claude Opus 5.5 * test: clear an empty leftover shared-host slice before the e2e phase applies the profile Co-Authored-By: Claude Opus 5.5 * test: judge shared-host slice removal by fragment and active state systemd synthesizes any slice name on demand, so LoadState stays loaded after the owned fragment is gone (bench job 3502). Co-Authored-By: Claude Opus 5.5 * fix: keep the shared-host config operator-readable and never stop a busy slice Without miner_user the generated config is now root-owned with the operator's group and mode 0640, so non-root doctor and support-bundle still read it. Slice removal now defers while any process (miner or helper) is in the slice, when the cgroup cannot be read, or when the stop fails, and only then deletes the owned fragment. The e2e pre-step requires an existing, empty, non-transient, unreferenced slice, the final check requires an inactive slice, and failure diagnostics no longer print the miner journal. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: pithead-developer[bot] <328836012+pithead-developer[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: pithead-developer[bot] <4933481+pithead-developer[bot]@users.noreply.github.com> --- README.md | 1 + config.reference.json | 1 + docs/configuration.md | 1 + docs/shared-host.md | 74 ++++++ rigforge.sh | 388 ++++++++++++++-------------- tests/README.md | 3 + tests/e2e-pithead.sh | 7 +- tests/e2e-shared-host.sh | 97 +++++++ tests/run.sh | 7 +- tests/test-e2e-gate-fail-closed.sh | 3 +- tests/test-shared-host-lifecycle.sh | 266 +++++++++++++++++++ tests/test-shared-host.sh | 313 ++++++++++++++++++++++ util/shared-host.sh | 296 +++++++++++++++++++++ 13 files changed, 1252 insertions(+), 205 deletions(-) create mode 100644 docs/shared-host.md create mode 100644 tests/e2e-shared-host.sh create mode 100644 tests/test-shared-host-lifecycle.sh create mode 100644 tests/test-shared-host.sh create mode 100644 util/shared-host.sh diff --git a/README.md b/README.md index 49c00f6..fced8a0 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ doesn't prove — deploying from a git checkout at the release tag is the strong | **[Hardware Requirements](docs/hardware.md)** | Worker CPU / RAM / HugePages requirements and the per-CPU tuning profiles. | | **[Benchmarks](docs/benchmarks.md)** | Measured stock-vs-tuned hashrate and efficiency on real hardware, with the method and caveats. | | **[Configuration](docs/configuration.md)** | Every `config.json` key and default, and how the XMRig config is generated. | +| **[Shared-host profile](docs/shared-host.md)** | Opt-in CPU and memory bounds for co-resident workloads. | | **[Operations & Maintenance](docs/operations.md)** | The full command reference, service management, logs, upgrades, and troubleshooting. | | **[How It Works](docs/how-it-works.md)** | What the script actually does: compile, HugePages, MSR, NUMA, governor, service. | | **[Pithead Integration](docs/pithead-integration.md)** | The worker ↔ dashboard contract: discovery via `:3333`, the read-only API on `:8080` (plus the optional enriched sister API on `:8081`), and the token rules. | diff --git a/config.reference.json b/config.reference.json index f3f1c3e..bd202f7 100644 --- a/config.reference.json +++ b/config.reference.json @@ -29,6 +29,7 @@ "hugepages_reserve_extra_mb": 0, "hugepages_pool_ceiling_mb": 0, "threads": null, + "shared_host": false, "HOME_DIR": "DYNAMIC_HOME" } diff --git a/docs/configuration.md b/docs/configuration.md index 9d55018..c4ccf67 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -74,6 +74,7 @@ not read by `parse_config` today, so it isn't in the table below.) | `hugepages_reserve_extra_mb` | `0` | MB of HugePages to leave for the **rest of the box** when RigForge sizes its reservation — for co-locating a miner on a busy host (e.g. a Pithead stack, whose 2MB pages come from the same kernel pool). RigForge stays the sole writer of the reservation; this key just declares how much of it isn't the miner's. Added to both the boot reservation and the runtime pool. When the box's existing reservation already covers stack + miner, RigForge changes nothing and needs no reboot. | | `hugepages_pool_ceiling_mb` | `0` *(no ceiling)* | A **hard cap**, in MB, on the 2MB HugePages pool RigForge will reserve — the opposite direction from `hugepages_reserve_extra_mb`, which *adds* to the computed requirement rather than bounding it. Set it on a RAM-constrained co-resident box: the runtime `vm.nr_hugepages` write is never grown past the ceiling, and neither is the `hugepages=` RigForge merges into the GRUB cmdline, so the pool does not come back up over the ceiling after a reboot (the grow-only runtime write could never bring it down again). An odd MB value floors to the 2MB page below (`5121` → 5120 MB effective) — a cap rounds toward less memory, never more. The **1GB** dataset reservation is a separate pool this key does not bound, so a ceiling below a box's 1GB reservation still leaves it holding more hugepage memory than the ceiling names. | | `threads` | `""` *(auto)* | A **ceiling** on the RandomX thread count — `min(auto-detected, threads)`, never a raise. A co-located miner sets it (e.g. `nproc-2`) to leave the stack its cores. Both the generated `cpu.rx` and the HugePages sizing honour it, so the reservation matches the threads actually run. | +| `shared_host` | `false` | Opt-in bounded coexistence profile. Set an object with `cpu_quota_percent` (1–10000, percentage of one CPU), `memory_max_mb` (3072–1048576 MiB), and optional `existing_host_tuning: "preserve"` for an explicit dedicated-to-shared transition. Requires Linux systemd with unified cgroup v2; it disables global tuning and uses ordinary pages. See [Shared-host profile](shared-host.md). | | `add_to_path` | `false` | When `true`, setup installs a `rigforge` command on your PATH (a symlink in `/usr/local/bin`) so you can run `sudo rigforge ` from any directory. Off by default. `uninstall` removes it. | --- diff --git a/docs/shared-host.md b/docs/shared-host.md new file mode 100644 index 0000000..419ba29 --- /dev/null +++ b/docs/shared-host.md @@ -0,0 +1,74 @@ +# Shared-host profile + +Set `shared_host` when RigForge will share a Linux machine with other workloads. The profile is +opt-in; an omitted key or `false` keeps the dedicated-miner behavior. + +```json +{ + "shared_host": { + "cpu_quota_percent": 100, + "memory_max_mb": 4096 + }, + "threads": 4 +} +``` + +`cpu_quota_percent` is a percentage of one CPU as understood by systemd: `100` permits one CPU, +`200` permits two, and so on. It is a ceiling, not a promise of foreground-workload capacity or a +priority guarantee across unrelated cgroup hierarchies. It accepts integers from 1 through 10000. +`memory_max_mb` is the miner cgroup's resident-memory ceiling, including RandomX dataset +initialization, scratchpads, and helper processes. It accepts 3072 through 1048576 MiB. Set +`threads` when a lower initialization and mining thread ceiling is needed; without it the profile +uses one initialization thread. + +The profile requires Linux systemd with unified cgroup v2 CPU and memory controllers, zero swap in +the miner cgroup, and enough statically visible memory to leave at least 1024 MiB outside the +configured ceiling and existing HugeTLB reservations. RigForge verifies the controls after systemd +activates them and refuses miner activation when the effective values cannot be read back. `status` +and `doctor` report the selected profile, requested limits, effective limits, and unsupported or +stopped states. + +Shared-host operation uses ordinary pages. It does not create or expand persistent HugePages, +change the CPU governor, apply MSR or cache tuning, enable NUMA, or rewrite pre-existing host +settings. XMRig runs with `priority: 1`, `yield: true`, no HugePages or memory pool, no MSR/cache +controls, and one dataset. The miner and RigForge's compile, prefetch, and periodic autotune helpers +run in the owned `rigforge.slice` budget. Full `tune` and `bench` are refused; `tune --now` and +periodic autotune only perform the limited prefetch work supported by the profile. + +A rebuild during setup or `upgrade` sizes compiler parallelism from `memory_max_mb` (one job per +2 GiB, at least one) instead of host RAM, and stops a running miner first so the compiler has the +whole ceiling. Setup restarts the miner after the rebuild; `upgrade` restarts it when it was running +before. If setup or `upgrade` fails after the build has stopped the miner, the miner stays stopped; +fix the cause and run `sudo ./rigforge.sh start`. + +The existing `miner_user` choice remains in force (root when omitted). The unit drops capabilities, +uses `Nice=19` with CPU weight 10, and disables automatic restart so a failed budget check or memory +exhaustion leaves the miner stopped with a reason. Because the unit keeps no capabilities, RigForge +gives the generated XMRig config and `xmrig.log` to the miner account every time it re-owns the +worker directory. Without `miner_user` they are owned by root with the operator's group, and the +config is mode 0640 so non-root `doctor` and `support-bundle` can still read it. This bounds trusted mining workloads; it is not a +security boundary against compromised root code. + +Switching an already installed dedicated unit requires stopping the miner and adding +`"existing_host_tuning": "preserve"` inside `shared_host`. This is an acknowledgement that RigForge +will preserve inherited host tuning; it does not inspect ownership, undo it, or reclaim it. If the +owned profile is later disabled, `apply` removes the shared budget and restores RigForge's dedicated +configuration. The next `apply`, `upgrade`, setup or uninstall +stops and removes the owned `rigforge.slice` once no process runs in it; while the miner or a helper +is still inside, removal waits for a later run of one of those commands. Shared selection or the retained history marker makes uninstall preserve global +tuning, even after disabling the profile, because ownership of inherited settings cannot be proven. + +Apply the profile with `sudo ./rigforge.sh apply` after editing `config.json`. Repeated setup, apply, +upgrade, and reboot preserve the selected profile. + +The opt-in `shared-host` phase of `tests/e2e-pithead.sh` (bench `tier4-e2e`, not part of `all`) +switches a provisioned rig to the profile, checks the effective slice limits, the miner's cgroup, +the generated XMRig settings, `status`, and an accepted share, then restores the dedicated +configuration. It compares HugePages, governors, the MSR module, the kernel command line and the +fstab, limits, modules and GRUB files before, during and after. Measured coexistence with a +foreground workload is the separate bench coexistence scenario owned by #579; the CPU quota and +memory ceiling alone do not establish that workload's service-level budget. + +The XMRig controls follow the [XMRig CPU configuration](https://xmrig.com/docs/miner/config/cpu). +The resource controls follow [systemd resource control](https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html); +HugeTLB accounting follows the [kernel HugeTLB documentation](https://www.kernel.org/doc/html/latest/admin-guide/mm/hugetlbpage.html). diff --git a/rigforge.sh b/rigforge.sh index 1a5860e..f6eaff9 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -139,6 +139,9 @@ if [ "${BASH_SOURCE[0]}" != "${0}" ]; then _RIGFORGE_SOURCED=1; fi # Report which step failed on an unexpected error (skip when sourced by the test suite). [ "$_RIGFORGE_SOURCED" = "0" ] && trap on_err ERR +# shellcheck source=util/shared-host.sh +source "$(_script_dir)/util/shared-host.sh" + # --- Helpers: idempotent edits, build math & GRUB cmdline --- # Append a single line to a file only if that exact line is not already present (idempotent). @@ -165,6 +168,7 @@ compute_build_jobs() { # local mem_kb mem_gb jobs="$1" max mem_kb=$(awk '/^MemTotal:/ {print $2}' "${MEMINFO:-/proc/meminfo}" 2>/dev/null || echo 0) mem_kb="${mem_kb:-0}" # a meminfo with no MemTotal line yields empty (awk exits 0), not the || fallback + [ "${SHARED_HOST:-false}" != true ] || mem_kb=$((SHARED_MEMORY_MAX_MB * 1024)) mem_gb=$((mem_kb / 1024 / 1024)) # Guard on mem_kb (readable at all), not mem_gb (truncates to 0 for BOTH a real sub-1GB host and an # unreadable meminfo) — a sub-1GB host must still hit the cap below (falls to the max<1 floor -> 1 @@ -723,6 +727,7 @@ parse_config() { [[ "$MINER_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || error "miner_user '$MINER_USER' is not a valid system username (lowercase, digits, _ -; max 32 chars)." fi + _shared_host_parse _warn_unknown_config_keys } @@ -733,7 +738,7 @@ parse_config() { # `_` are the comment convention (config.reference.json's own _docs); RIG_NAME is reserved for the # #1 image seed. Warn NAMES only, never values — a fat-fingered token must not land in a log. _warn_unknown_config_keys() { - local known="pools ACCESS_TOKEN ACCESS_TOKENS DONATION autotune add_to_path HOME_DIR api api_port api_bind api_allow_from miner_user RIG_NAME watchdog watchdog_interval_min max_temp_c control control_port control_bind control_upgrade hugepages_reserve_extra_mb hugepages_pool_ceiling_mb threads" + local known="pools ACCESS_TOKEN ACCESS_TOKENS DONATION autotune add_to_path HOME_DIR api api_port api_bind api_allow_from miner_user RIG_NAME watchdog watchdog_interval_min max_temp_c control control_port control_bind control_upgrade hugepages_reserve_extra_mb hugepages_pool_ceiling_mb threads shared_host" local known_pool="url user pass keepalive tls enabled tls-fingerprint socks5" local k lk m lm hit hint unknown_seen=0 while IFS= read -r k; do @@ -993,11 +998,15 @@ compile_xmrig() { cores=$(nproc) jobs=$(compute_build_jobs "$cores") log "Compiling binary ($jobs of $cores cores; output -> $BUILD_LOG)..." + if [ "${SHARED_HOST:-false}" = true ] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + log "Shared host: stopping the miner so the build gets the whole memory ceiling." + sudo systemctl stop "$SERVICE_NAME.service" || error "Could not stop the miner before the shared-host build." + fi mkdir -p xmrig.new/build && cd xmrig.new/build - cmake .. -DWITH_HWLOC=ON >>"$BUILD_LOG" 2>&1 + _shared_host_run cmake .. -DWITH_HWLOC=ON >>"$BUILD_LOG" 2>&1 fi - make -j"$jobs" >>"$BUILD_LOG" 2>&1 + _shared_host_run make -j"$jobs" >>"$BUILD_LOG" 2>&1 echo "$XMRIG_COMMIT" >"$WORKER_ROOT/xmrig.new/.rigforge-commit" printf '0\n' >"$WORKER_ROOT/xmrig.new/.rigforge-donate-min" @@ -1039,18 +1048,6 @@ generate_xmrig_config() { LOG_FILE_PATH="$WORKER_ROOT/xmrig.log" fi - # Default optimization profile. - # - # We deliberately lean on XMRig's own auto-detection rather than matching CPU model names: with - # asm=auto, rx=-1 (auto thread count, sized to L3) and wrmsr=true, XMRig picks the right assembly - # path, thread layout and per-family MSR preset from the detected topology — and stays correct for - # CPUs we'd never enumerate (new Zen/X3D SKUs, Intel hybrid P/E cores, etc.). See issue #44. - # - # On top of auto-detection we set defaults appropriate for a DEDICATED miner (issue #43): - # - yield=false : busy-wait for maximum hashrate (we own the whole box) - # - priority=2 : win scheduling vs. background daemons (XMRig warns >2 can hang a desktop) - # - numa=true : XMRig default; a no-op on single-socket, essential on multi-socket/EPYC - # - init-avx2=-1 : auto — already uses AVX2 for dataset init when the CPU supports it YIELD="false" PRIORITY="2" ASM="\"auto\"" @@ -1062,9 +1059,6 @@ generate_xmrig_config() { HUGE_PAGES="true" MEMORY_POOL="true" ONE_GB_PAGES="true" - # cpu.huge-pages-jit: false, matching XMRig's upstream default. XMRig documents it as only a "very - # small boost on Ryzen, but hashrate is unstable" — not worth the jitter on a production rig (and it - # would add noise to the `tune` search). $JIT maps to cpu.huge-pages-jit in the config below. JIT="false" INIT_AVX2="-1" # Lock down the HTTP API to READ-ONLY (restricted) so it can't be used to *control* the miner @@ -1102,11 +1096,6 @@ generate_xmrig_config() { THREADS="${THREADS}]" fi - # NOTE: we no longer special-case CPUs by model name (the old EPYC / Ryzen-X3D branches). XMRig's - # auto-config is cache-aware and updated every release, so it sizes threads and picks asm/MSR/NUMA - # better — and correctly — for any CPU, including ones a name table would miss or get wrong (the - # old X3D branch pinned threads to ALL cores, which is wrong on dual-CCD parts like the 7950X3D - # where only one CCD has the V-cache). See issue #44. if [ "$OS_TYPE" != "Darwin" ]; then log "Detected CPU: ${CPU_MODEL:-unknown} — using XMRig auto-tuning (threads, asm, MSR, NUMA auto-detected)." # ISA preflight (#338): surface missing AES-NI/AVX2 here, at the moment the rig is being @@ -1180,7 +1169,7 @@ generate_xmrig_config() { } + (if $log == "" then {} else {"log-file": $log} end)' >"$_new" || _config_render_fail "$_new" "Rendering the XMRig config failed" || return 1 if [ -f "$WORKER_ROOT/tune-overrides.json" ]; then - _step=$(jq -s '.[0] * .[1]' "$_new" "$WORKER_ROOT/tune-overrides.json") && printf '%s\n' "$_step" >"$_new" || _config_render_fail "$_new" "Applying $WORKER_ROOT/tune-overrides.json failed (fix or remove it)" || return 1 + _step=$(_shared_host_merge "$_new" "$WORKER_ROOT/tune-overrides.json") && printf '%s\n' "$_step" >"$_new" || _config_render_fail "$_new" "Applying $WORKER_ROOT/tune-overrides.json failed (fix or remove it)" || return 1 log "Applied tuned overrides from tune-overrides.json." fi @@ -1188,6 +1177,7 @@ generate_xmrig_config() { _step=$(jq --argjson cap "$THREADS_CAP" '.cpu.rx = (if (.cpu.rx | type) == "number" and .cpu.rx >= 1 and .cpu.rx <= $cap then .cpu.rx else $cap end)' "$_new") && printf '%s\n' "$_step" >"$_new" || _config_render_fail "$_new" "Clamping cpu.rx to the thread cap failed" || return 1 fi + _shared_host_config "$_new" _config_install "$_new" "${1:-}" || return 1 if [ "$OS_TYPE" == "Linux" ] && [ "${1:-}" != stage ]; then @@ -1214,13 +1204,6 @@ EOF # --- Setup: service, kernel tuning & deployment --- -# Render the miner unit from its template (#140 made this shared: `apply` must re-render when -# miner_user changes, without install_service's enable/start side effects). -# Privilege separation (#140): create the dedicated system user on first use. Never deleted by -# uninstall (we can't prove we created it; an inert nologin user is safer than userdel). Called -# from BOTH install paths — setup (install_service) and apply's unit re-render — because apply is -# the documented config-change path for toggling miner_user; a unit saying User= -# fails with status=217/USER (caught live on miner-0 during the v1.4.0 gate). _ensure_miner_user() { if [ -n "${MINER_USER:-}" ] && ! id -u "$MINER_USER" >/dev/null 2>&1; then log "Creating system user '$MINER_USER' for the miner..." @@ -1230,13 +1213,11 @@ _ensure_miner_user() { _render_xmrig_unit() { local msr_line="" - # Root-side MSR application for an unprivileged miner: no `-` prefix — on an opted-in rig a - # failed MSR write should be VISIBLE, and msr-apply itself exits 0 on the known-benign cases - # (unknown CPU family, module missing) so it never wedges the Restart=always loop. - if [ -n "${MINER_USER:-}" ]; then msr_line="ExecStartPre=+$SCRIPT_DIR/rigforge.sh msr-apply"; fi + [ "${SHARED_HOST:-false}" = true ] || _shared_host_remove + if [ -n "${MINER_USER:-}" ] && [ "${SHARED_HOST:-false}" != true ]; then msr_line="ExecStartPre=+$SCRIPT_DIR/rigforge.sh msr-apply"; fi WORKER_ROOT="$WORKER_ROOT" MINER_USER_EFFECTIVE="${MINER_USER:-root}" MSR_APPLY_LINE="$msr_line" \ envsubst '$BUILD_DIR $CPUPOWER_PATH $WORKER_ROOT $NFT_PATH $MINER_USER_EFFECTIVE $MSR_APPLY_LINE' \ - <"$SCRIPT_DIR/systemd/xmrig.service.template" | sudo tee "$SYSTEMD_DIR/$SERVICE_NAME.service" >/dev/null + <"$SCRIPT_DIR/systemd/xmrig.service.template" | _shared_host_unit | sudo tee "$SYSTEMD_DIR/$SERVICE_NAME.service" >/dev/null } install_service() { @@ -1250,6 +1231,7 @@ install_service() { _ensure_miner_user _render_xmrig_unit + [ "${SHARED_HOST:-false}" != true ] || _reown_worker # Reload systemd daemon sudo systemctl daemon-reload @@ -1268,15 +1250,14 @@ install_service() { # a rebuild it must not restart twice. The unit is rendered, reloaded and enabled here; # nothing is started or restarted. log "Service unit installed and enabled — the caller decides whether to restart." - elif [ "$XMRIG_REBUILD" = true ]; then - # Restart only when the binary was rebuilt; otherwise just ensure it's running (a running - # service is left undisturbed on a no-op re-run). + elif [ "$XMRIG_REBUILD" = true ] || [ "${SHARED_HOST:-false}" = true ]; then log "Restarting XMRig service..." sudo systemctl restart "$SERVICE_NAME.service" else log "No rebuild — ensuring the service is running (no restart)." sudo systemctl start "$SERVICE_NAME.service" fi + [ "${SHARED_HOST:-false}" = true ] || _shared_host_remove SERVICE_INSTALLED=true else warn "Service installation is not supported on $OS_TYPE." @@ -1494,6 +1475,10 @@ install_control() { # `tune` pinned one (so setup + tune stay consistent), or an explicit RIGFORGE_THREADS override (the # documented resize-then-re-tune path). Empty => proposed-grub.sh falls back to its L3 estimate. _rx_setup_threads() { + if [ "${SHARED_HOST:-false}" = true ]; then + RX_SETUP_THREADS="${THREADS_CAP:-}" + return 0 + fi RX_SETUP_THREADS="" if [ -n "${RIGFORGE_THREADS:-}" ]; then RX_SETUP_THREADS="$RIGFORGE_THREADS" @@ -1580,6 +1565,7 @@ _hugepages_avail() { # -> 2MB pages the miner could draw on right now (free + al } _ensure_hugepages() { # + [ "${SHARED_HOST:-false}" != true ] || return 0 local required=$1 current avail target ceiling_pages current=$(cat "$NR_HUGEPAGES_FILE" 2>/dev/null) || current=0 avail=$(_hugepages_avail) @@ -1622,6 +1608,10 @@ _ensure_hugepages() { # } tune_kernel() { + [ "${SHARED_HOST:-false}" != true ] || { + log "Shared host: skipping kernel, MSR and HugePages tuning." + return 0 + } if [ "$OS_TYPE" != "Linux" ]; then log "Skipping kernel tuning (Not supported on $OS_TYPE)." return @@ -1695,6 +1685,10 @@ tune_kernel() { } configure_limits() { + [ "${SHARED_HOST:-false}" != true ] || { + log "Shared host: preserving mounts and host memlock limits." + return 0 + } if [ "$OS_TYPE" != "Linux" ]; then return fi @@ -1812,10 +1806,13 @@ _reown_worker() { esac if [ -n "${WORKER_ROOT:-}" ] && [ -e "$WORKER_ROOT" ]; then sudo chown -R "$owner" "$WORKER_ROOT" 2>/dev/null || true; fi if [ -f "$CONFIG_JSON" ]; then sudo chown "$owner" "$CONFIG_JSON" 2>/dev/null || true; fi - # Privilege separation (#140): the unprivileged miner must keep write access to its autosaved - # config and its log after the blanket re-own above. Every re-owning verb routes through here. - if [ -n "${MINER_USER:-}" ] && [ "$OS_TYPE" = Linux ]; then + [ "$OS_TYPE" = Linux ] || return 0 + if [ "${SHARED_HOST:-false}" = true ] && [ "${RIGFORGE_APPLIANCE:-0}" != 1 ] && [ -d "$WORKER_ROOT" ]; then sudo touch "$WORKER_ROOT/xmrig.log" 2>/dev/null || true; fi + if [ -n "${MINER_USER:-}" ]; then sudo chown "$MINER_USER:$MINER_USER" "$WORKER_ROOT/xmrig/build/config.json" "$WORKER_ROOT/xmrig.log" 2>/dev/null || true + elif [ "${SHARED_HOST:-false}" = true ]; then + sudo chown "root:$REAL_USER" "$WORKER_ROOT/xmrig/build/config.json" "$WORKER_ROOT/xmrig.log" 2>/dev/null || true + sudo chmod 0640 "$WORKER_ROOT/xmrig/build/config.json" 2>/dev/null || true fi } @@ -1881,7 +1878,9 @@ _setup_plan() { _p "compiling XMRig" "skipped (already built; SHA-256 record kept)" fi _p "generating XMRig config" "write $WORKER_ROOT/xmrig/build/config.json$([ -f "$WORKER_ROOT/tune-overrides.json" ] && echo ' + overlay tune-overrides.json')" - if [ "$OS_TYPE" != "Linux" ]; then + if [ "${SHARED_HOST:-false}" = true ]; then + _p "shared host" "CPU quota ${SHARED_CPU_QUOTA}% of one CPU, memory ${SHARED_MEMORY_MAX_MB} MiB; verify cgroup v2, preserve host tuning" + elif [ "$OS_TYPE" != "Linux" ]; then _p "tuning the kernel" "skipped (not supported on $OS_TYPE — no HugePages/MSR here)" _p "configuring limits" "skipped (Linux-only)" _p "installing the service" "none on macOS — run via '$0 start' (nohup) or the launchd login agent" @@ -1986,6 +1985,7 @@ main() { ensure_config_exists CURRENT_STEP="parsing config" parse_config + _shared_host_prepare CURRENT_STEP="checking the build" decide_rebuild CURRENT_STEP="preparing workspace" @@ -2140,6 +2140,7 @@ upgrade() { _persist_appliance_mode check_prerequisites parse_config + _shared_host_prepare decide_rebuild # #413: reinstall the whole release even when the XMRig pin (and therefore build) is unchanged. local was_active=no @@ -2162,6 +2163,7 @@ upgrade() { log "XMRig $XMRIG_VERSION (commit ${XMRIG_COMMIT:0:12}) was already built — regenerated the config and reinstalled the units." fi _upgrade_restart "$was_active" + [ "$OS_TYPE" != Linux ] || [ "${SHARED_HOST:-false}" = true ] || _shared_host_remove _post_upgrade_retune _reown_worker [ "$_api_rc" -eq 0 ] || _sister_api_failed upgrade @@ -2235,6 +2237,8 @@ uninstall() { # Work out the worker root the same way parse_config would, without requiring a valid config. local worker_root worker_root=$(_worker_root_from_config) + local preserve_host=no + _shared_host_preserves_tuning "$worker_root" && preserve_host=yes # 1. systemd service (+ the optional autotune timer, #46) local _mu @@ -2275,44 +2279,44 @@ uninstall() { # 2. logrotate policy sudo rm -f "$LOGROTATE_DIR/xmrig" - # 3. fstab HugePage mounts - remove_line "$FSTAB" "hugetlbfs /dev/hugepages hugetlbfs defaults 0 0" - remove_line "$FSTAB" "hugetlbfs_1g $HUGEPAGES_1G_DIR hugetlbfs pagesize=1G 0 0" - - # 4. memlock limits (current per-user form + the legacy wildcard form, for older installs) - remove_line "$LIMITS_CONF" "$REAL_USER soft memlock unlimited" - remove_line "$LIMITS_CONF" "$REAL_USER hard memlock unlimited" - remove_line "$LIMITS_CONF" "* soft memlock unlimited" - remove_line "$LIMITS_CONF" "* hard memlock unlimited" + _shared_host_remove + if [ "$preserve_host" = no ]; then + remove_line "$FSTAB" "hugetlbfs /dev/hugepages hugetlbfs defaults 0 0" + remove_line "$FSTAB" "hugetlbfs_1g $HUGEPAGES_1G_DIR hugetlbfs pagesize=1G 0 0" - # 5. msr module autoload - sudo rm -f "$MODULES_LOAD_DIR/msr.conf" - remove_line "$MODULES_FILE" "msr" + remove_line "$LIMITS_CONF" "$REAL_USER soft memlock unlimited" + remove_line "$LIMITS_CONF" "$REAL_USER hard memlock unlimited" + remove_line "$LIMITS_CONF" "* soft memlock unlimited" + remove_line "$LIMITS_CONF" "* hard memlock unlimited" - # 6. 1G HugePage mount - if command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$HUGEPAGES_1G_DIR" 2>/dev/null; then - sudo umount "$HUGEPAGES_1G_DIR" 2>/dev/null || true - fi - sudo rmdir "$HUGEPAGES_1G_DIR" 2>/dev/null || true + sudo rm -f "$MODULES_LOAD_DIR/msr.conf" + remove_line "$MODULES_FILE" "msr" - # 7. GRUB cmdline — strip only the params we added - if [ -f "$GRUB_DEFAULT" ]; then - local cur stripped - cur=$(sed -n 's/^GRUB_CMDLINE_LINUX_DEFAULT="\(.*\)"$/\1/p' "$GRUB_DEFAULT" | head -n1) - stripped=$(grub_strip_managed "$cur") - if [ "$cur" != "$stripped" ]; then - sudo cp "$GRUB_DEFAULT" "$GRUB_DEFAULT.bak" - sudo sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT=\"$(_sed_escape_replacement "$stripped")\"|" "$GRUB_DEFAULT" - if command -v update-grub >/dev/null; then - sudo update-grub - REBOOT_REQUIRED=true - else - warn "'update-grub' not found — revert your bootloader config manually." + if command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$HUGEPAGES_1G_DIR" 2>/dev/null; then + sudo umount "$HUGEPAGES_1G_DIR" 2>/dev/null || true + fi + sudo rmdir "$HUGEPAGES_1G_DIR" 2>/dev/null || true + + if [ -f "$GRUB_DEFAULT" ]; then + local cur stripped + cur=$(sed -n 's/^GRUB_CMDLINE_LINUX_DEFAULT="\(.*\)"$/\1/p' "$GRUB_DEFAULT" | head -n1) + stripped=$(grub_strip_managed "$cur") + if [ "$cur" != "$stripped" ]; then + sudo cp "$GRUB_DEFAULT" "$GRUB_DEFAULT.bak" + sudo sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT=\"$(_sed_escape_replacement "$stripped")\"|" "$GRUB_DEFAULT" + if command -v update-grub >/dev/null; then + sudo update-grub + REBOOT_REQUIRED=true + else + warn "'update-grub' not found — revert your bootloader config manually." + fi + log "Reverted RigForge's GRUB kernel parameters." fi - log "Reverted RigForge's GRUB kernel parameters." fi - fi + else + log "Shared-host history: preserving existing HugePages, mounts, MSR and boot settings." + fi # 8. worker build + logs (leave config.json in the repo root) if [ -n "$worker_root" ] && [ -d "$worker_root" ]; then sudo rm -rf "$worker_root" @@ -2566,6 +2570,7 @@ _tune_config() { # # there and exits — process-gone ends the loop too). `http`/`pools`/`log-file`/`background` are stripped # from the config so xmrig doesn't serve the API, mine, or daemonize. `BENCH_TIMEOUT` bounds a stuck run. _xmrig_bench() { # + [ "${SHARED_HOST:-false}" != true ] || error "Offline benchmarks are unavailable in shared_host; use tune --now for a bounded live prefetch trial." local bin="$1" size="$2" cfg="${3:-}" bcfg="" tmpcfg="" out logf xpid deadline freqs="" f local wsamples="" pe0="" pt0="" pmax="" w # #81: power-window state out="$(mktemp 2>/dev/null || echo "/tmp/rf-bo-$$")" @@ -3135,6 +3140,7 @@ tune() { log "Cleared tuning state. Run 'sudo $0 apply' to regenerate the baseline config." return 0 fi + [ "${SHARED_HOST:-false}" != true ] || error "Full tuning is unavailable in shared_host; use tune --now for a bounded live prefetch trial." if [ "$TUNE_MODE" = live ] && [ "$OS_TYPE" != "Linux" ]; then error "tune --live drives the running systemd service and is only supported on Linux." @@ -3490,6 +3496,11 @@ autotune() { # (no unit env) falls through to the config value. local target_env="${AUTOTUNE_TARGET:-}" parse_config + _shared_host_start_guard + if [ "${SHARED_HOST:-false}" = true ] && ! _shared_host_in_slice; then + AUTOTUNE_TARGET="${target_env:-${AUTOTUNE_TARGET:-perf}}" _shared_host_run "$SCRIPT_DIR/rigforge.sh" autotune + return + fi local overrides="$WORKER_ROOT/tune-overrides.json" local n="${AUTOTUNE_SAMPLES:-3}" iv="${AUTOTUNE_INTERVAL:-10}" warm="${AUTOTUNE_WARMUP:-60}" local margin="${AUTOTUNE_MARGIN:-0.01}" modes="${AUTOTUNE_MODES:-0 1 2 3}" @@ -3988,6 +3999,7 @@ _render_duration() { } svc_status() { + [ ! -f "$CONFIG_JSON" ] || (_shared_host_report) || true [ "$OS_TYPE" = "Linux" ] || { mac_status # parse_config in a subshell: it error-exits on a bad config, and ACCESS_TOKEN only exists @@ -4010,6 +4022,7 @@ svc_logs() { journalctl -u "$SERVICE_NAME" -f || true # -f exits 130 on Ctrl-C } svc_start() { + _shared_host_start_guard [ "$OS_TYPE" = "Linux" ] || { mac_start return @@ -4024,6 +4037,7 @@ svc_stop() { sudo systemctl stop "$SERVICE_NAME" && log "Stopped $SERVICE_NAME." } svc_restart() { + _shared_host_start_guard [ "$OS_TYPE" = "Linux" ] || { mac_stop mac_start @@ -4129,25 +4143,16 @@ _miner_deliberately_stopped() { [ "$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)" = inactive ] } -# The config-regen + restart core of `apply`, WITHOUT touching the autotune timer. Used directly by -# tune/autotune — which call it in a loop and own the timer themselves — so they don't re-render the unit -# on every prefetch trial. -# -# `preserve-run-state` (#396) is passed by `apply` and by nothing else. With it, a rig that is -# deliberately stopped is left stopped: the regenerated config sits on disk and takes effect at the next -# deliberate start (the operator's, or the watchdog's own tick once the temperature is back under the -# cutoff). Without it the unconditional restart stands — and `tune`/`autotune`/`_restore_overrides` MUST -# keep it: they call this in a loop and measure the running miner between trials, and tune's --bench leg -# stops the service itself and relies on the re-apply to bring it back, so a blanket preserve would -# strand a tune run instead of a held rig. Opt-in per call site, not a global. _apply_runtime() { # [preserve-run-state] parse_config + _shared_host_start_guard local build="$WORKER_ROOT/xmrig/build" [ -d "$build" ] || error "No built worker at $build. Run 'setup' first." local held=no RIGFORGE_RESTART_FAILED=0 [ "${1:-}" = preserve-run-state ] && _miner_deliberately_stopped && held=yes (cd "$build" && generate_xmrig_config) || return 1 + [ "${SHARED_HOST:-false}" != true ] || _reown_worker if [ "$OS_TYPE" != "Linux" ]; then log "Config regenerated. Restart the miner to apply." elif [ "$held" = yes ]; then @@ -4197,6 +4202,7 @@ apply() { done if [ "$OS_TYPE" = Linux ] && [ -f "$SYSTEMD_DIR/$SERVICE_NAME.service" ]; then parse_config + _shared_host_prepare _ensure_miner_user NFT_PATH=$(command -v nft || echo "/usr/sbin/nft") export NFT_PATH @@ -4204,10 +4210,11 @@ apply() { CPUPOWER_PATH=$(command -v cpupower || echo "/usr/bin/cpupower") export CPUPOWER_PATH _render_xmrig_unit - sudo systemctl daemon-reload 2>/dev/null || true + _shared_host_reload fi local _wd_rc=0 _api_rc=0 _apply_runtime preserve-run-state + [ "$OS_TYPE" != Linux ] || [ "${SHARED_HOST:-false}" = true ] || _shared_host_remove if [ "$OS_TYPE" = Linux ]; then install_autotune >/dev/null 2>&1 || true install_watchdog >/dev/null 2>&1 || _wd_rc=1 @@ -4862,6 +4869,7 @@ watchdog() { error "watchdog drives the live systemd service and is only supported on Linux." fi parse_config + _shared_host_start_guard local fails_f="$WORKER_ROOT/watchdog.fails" hold_f="$WORKER_ROOT/watchdog.thermal-hold" t hr f t=$(_read_temp) # Thermal hold first: WE stopped the miner, so the not-active check below must not short-circuit @@ -5068,6 +5076,7 @@ msr_apply() { [ "$OS_TYPE" = Linux ] || error "msr-apply writes Linux MSRs and is only supported on Linux." [ "$(id -u)" -eq 0 ] || error "msr-apply needs root (it writes /dev/cpu/*/msr) — it is normally run by the systemd unit." parse_config >/dev/null + [ "${SHARED_HOST:-false}" != true ] || error "MSR tuning is disabled in shared_host." local preset regs reg val mask cpu old new preset=$(_msr_detect_preset) if [ -z "$preset" ]; then @@ -5419,12 +5428,16 @@ _check_donation() { doctor() { cmd_version + local issues=0 + if [ -f "$CONFIG_JSON" ]; then + _shared_host_parse + if [ "$SHARED_HOST" = true ]; then _shared_host_report || issues=$((issues + 1)); fi + fi if [ "$OS_TYPE" != "Linux" ]; then warn "doctor's health checks are Linux-only (this host is $OS_TYPE)." - return 0 + return "$issues" fi log "Checking the worker (read-only)..." - local issues=0 case "$(jq -r 'if (.control | tostring) | test("^(enabled|true|on)$") then "enabled" else (.api // "disabled") end' "$CONFIG_JSON" 2>/dev/null || true)" in # #507: doctor has no parse_config, so it re-derives control's api implication here enabled | true | on) @@ -5500,28 +5513,25 @@ EOF fi # HugePages reserved? (the single biggest lever; needs a reboot after setup) - local hp - hp=$(awk '/^HugePages_Total:/ {print $2; exit}' "$MEMINFO" 2>/dev/null) - if [ -n "$hp" ] && [ "$hp" -gt 0 ] 2>/dev/null; then - _ck_ok "HugePages reserved (HugePages_Total=$hp)" - else - _ck_warn "HugePages not reserved — reboot after setup (the GRUB change needs a reboot)" - issues=$((issues + 1)) - fi + if [ "${SHARED_HOST:-false}" != true ]; then + local hp + hp=$(awk '/^HugePages_Total:/ {print $2; exit}' "$MEMINFO" 2>/dev/null) + if [ -n "$hp" ] && [ "$hp" -gt 0 ] 2>/dev/null; then + _ck_ok "HugePages reserved (HugePages_Total=$hp)" + else + _ck_warn "HugePages not reserved — reboot after setup (the GRUB change needs a reboot)" + issues=$((issues + 1)) + fi - # 1GB HugePages (optional bonus) - local g=0 - [ -f "$HUGEPAGES_1G_NR" ] && g=$(cat "$HUGEPAGES_1G_NR" 2>/dev/null || echo 0) - if [ "${g:-0}" -gt 0 ] 2>/dev/null; then - _ck_ok "1GB HugePages reserved ($g)" - else - _ck_warn "1GB HugePages not reserved (optional; needs a pdpe1gb CPU + reboot)" - fi + local g=0 + [ -f "$HUGEPAGES_1G_NR" ] && g=$(cat "$HUGEPAGES_1G_NR" 2>/dev/null || echo 0) + if [ "${g:-0}" -gt 0 ] 2>/dev/null; then + _ck_ok "1GB HugePages reserved ($g)" + else + _ck_warn "1GB HugePages not reserved (optional; needs a pdpe1gb CPU + reboot)" + fi - # CPU ISA support (#338): a rig without AES-NI mines at soft-AES speed (~4x slower) with no - # error anywhere — exactly the "silently failing" #1's acceptance criterion forbids, so it's a - # counted issue. Missing AVX2 only slows dataset init: advisory. Quiet when there's no x86 - # flags line to judge (unknown, not unsupported). + fi local miss_isa miss_isa=$(_cpu_missing_isa) if [[ " $miss_isa " == *" aes "* ]]; then @@ -5554,7 +5564,7 @@ EOF _ck_warn "config sets miner_user='$cfg_mu' but the unit runs as '$svc_user' — re-run 'sudo $0 apply'" issues=$((issues + 1)) fi - if [ -n "$wr" ] && [ -f "$wr/.rigforge-msr-preset" ]; then + if [ "${SHARED_HOST:-false}" != true ] && [ -n "$wr" ] && [ -f "$wr/.rigforge-msr-preset" ]; then local _mp _mp=$(cat "$wr/.rigforge-msr-preset" 2>/dev/null || true) _msr_rdmsr_verify "$_mp" @@ -5647,106 +5657,79 @@ EOF esac fi - # MSR mod applied? (#66) The ~10-15% RandomX gain needs three things, checked in order: the msr - # module loadable, XMRig's own log line confirming it WROTE the prefetcher preset, and — when rdmsr - # (msr-tools) is present — a register read-back that catches a write a hypervisor/lockdown dropped. - # Kernel lockdown (#333) — checked BEFORE the MSR probes below, because it is the CAUSE and they are - # the symptom. Under lockdown, msr_write()/msr_ioctl() call security_locked_down(LOCKDOWN_MSR) and - # return -EPERM *before* consulting the allow_writes filter (arch/x86/kernel/msr.c), so the - # msr.allow_writes=on we put on the cmdline cannot override it. Reading the state beats inferring it - # from Secure Boot: whether Secure Boot implies lockdown is a distro patch, not upstream behaviour. - local ld - ld=$(_lockdown_state) - if [ -z "$ld" ]; then - _ck_info "kernel lockdown state unknown ($LOCKDOWN_FILE unreadable) — run 'doctor' as root, or the lockdown LSM isn't built in" - elif _lockdown_blocks_msr "$ld"; then - _ck_warn "kernel lockdown is ACTIVE ('$ld') — it denies every /dev/cpu/*/msr write, so the MSR mod cannot apply (worth ~5-15% RandomX). msr.allow_writes=on does NOT override it: disable Secure Boot in BIOS ($(_bios_menu "$(_dmi board_vendor)" secure_boot "${TUNE_TARGET:-perf}")), then re-run 'sudo $0 doctor'." - issues=$((issues + 1)) - else - _ck_ok "kernel lockdown: none — MSR writes are permitted" - fi + if [ "${SHARED_HOST:-false}" != true ]; then + local ld + ld=$(_lockdown_state) + if [ -z "$ld" ]; then + _ck_info "kernel lockdown state unknown ($LOCKDOWN_FILE unreadable) — run 'doctor' as root, or the lockdown LSM isn't built in" + elif _lockdown_blocks_msr "$ld"; then + _ck_warn "kernel lockdown is ACTIVE ('$ld') — it denies every /dev/cpu/*/msr write, so the MSR mod cannot apply (worth ~5-15% RandomX). msr.allow_writes=on does NOT override it: disable Secure Boot in BIOS ($(_bios_menu "$(_dmi board_vendor)" secure_boot "${TUNE_TARGET:-perf}")), then re-run 'sudo $0 doctor'." + issues=$((issues + 1)) + else + _ck_ok "kernel lockdown: none — MSR writes are permitted" + fi - if [ -d "$MSR_MODULE_DIR" ]; then - _ck_ok "msr kernel module loaded" - else - # Secure Boot does NOT stop the in-tree, distro-signed `msr` module from loading, so the old - # "disable Secure Boot" hint pointed the wrong way here (#333). A missing module is a modprobe / - # packaging problem; lockdown is reported separately above. - _ck_warn "msr module not loaded — the MSR mod won't apply; check 'sudo modprobe msr' and $MODULES_LOAD_DIR/msr.conf" - issues=$((issues + 1)) - fi - if [ -n "$wr" ]; then - local msrstat="" preset="" - IFS="$(printf '\t')" read -r msrstat preset </dev/null 2>&1; then - _ck_warn "rdmsr not found — install msr-tools to verify the MSRs at the register level (advisory; XMRig's log already confirms the write)" - elif [ -n "$(_msr_preset_regs "$preset")" ]; then - _msr_rdmsr_verify "$preset" - if [ "${_MSR_OK:-0}" -gt 0 ] && [ "${_MSR_OK:-0}" -eq "${_MSR_TOTAL:-0}" ]; then - _ck_ok "MSR registers verified via rdmsr ($_MSR_OK/$_MSR_TOTAL match the $preset preset)" - elif [ -n "$_MSR_BAD" ]; then - # A genuine value mismatch — the write didn't take. This is the real failure (#66). - # #333: when lockdown is active we already know which of the three causes it was — - # say so instead of listing all of them. - if _lockdown_blocks_msr "$ld"; then - _ck_warn "MSR registers don't match the $preset preset ($_MSR_OK/$_MSR_TOTAL ok;${_MSR_BAD}) — kernel lockdown ('$ld') dropped the write; see the lockdown item above" + case "$msrstat" in + ok) + _ck_ok "MSR mod applied — XMRig set the '${preset:-?}' preset (per its log)" + if [ "$(id -u)" -ne 0 ]; then + _ck_warn "run 'doctor' as root (sudo) to verify the MSRs at the register level (advisory; XMRig's log already confirms the write)" + elif ! command -v "$RDMSR_BIN" >/dev/null 2>&1; then + _ck_warn "rdmsr not found — install msr-tools to verify the MSRs at the register level (advisory; XMRig's log already confirms the write)" + elif [ -n "$(_msr_preset_regs "$preset")" ]; then + _msr_rdmsr_verify "$preset" + if [ "${_MSR_OK:-0}" -gt 0 ] && [ "${_MSR_OK:-0}" -eq "${_MSR_TOTAL:-0}" ]; then + _ck_ok "MSR registers verified via rdmsr ($_MSR_OK/$_MSR_TOTAL match the $preset preset)" + elif [ -n "$_MSR_BAD" ]; then + if _lockdown_blocks_msr "$ld"; then + _ck_warn "MSR registers don't match the $preset preset ($_MSR_OK/$_MSR_TOTAL ok;${_MSR_BAD}) — kernel lockdown ('$ld') dropped the write; see the lockdown item above" + else + _ck_warn "MSR registers don't match the $preset preset ($_MSR_OK/$_MSR_TOTAL ok;${_MSR_BAD}) — a hypervisor may have dropped the write, or XMRig changed its preset" + fi + issues=$((issues + 1)) else - _ck_warn "MSR registers don't match the $preset preset ($_MSR_OK/$_MSR_TOTAL ok;${_MSR_BAD}) — a hypervisor may have dropped the write, or XMRig changed its preset" + _ck_warn "couldn't read $_MSR_UNREAD/$_MSR_TOTAL MSR(s) via rdmsr (is the 'msr' module loaded?) — relying on XMRig's log confirmation" fi - issues=$((issues + 1)) + fi + ;; + fail) + if _lockdown_blocks_msr "$ld"; then + _ck_warn "XMRig reports the MSR preset FAILED to set — kernel lockdown ('$ld') is denying the write; see the lockdown item above" else - # No mismatch, but some/all registers were unreadable (e.g. msr module not loaded). Advisory. - _ck_warn "couldn't read $_MSR_UNREAD/$_MSR_TOTAL MSR(s) via rdmsr (is the 'msr' module loaded?) — relying on XMRig's log confirmation" + _ck_warn "XMRig reports the MSR preset FAILED to set — check msr.allow_writes=on and that no hypervisor is masking MSR access" fi + issues=$((issues + 1)) + ;; + *) : ;; + esac + if [ "$msrstat" = none ]; then + [ -z "$log_file" ] && _ck_info "MSR file-log confirmation is disabled; the bounded systemd journal has no confirmation yet" + [ -n "$log_file" ] && [ ! -f "$log_file" ] && _ck_info "MSR unverifiable — no xmrig.log at $log_file and no journal confirmation" fi - ;; - fail) - # #333: msr.allow_writes=on is only the answer when lockdown is NOT the blocker — under - # lockdown the kernel rejects the write before the filter ever runs. - if _lockdown_blocks_msr "$ld"; then - _ck_warn "XMRig reports the MSR preset FAILED to set — kernel lockdown ('$ld') is denying the write; see the lockdown item above" - else - _ck_warn "XMRig reports the MSR preset FAILED to set — check msr.allow_writes=on and that no hypervisor is masking MSR access" - fi - issues=$((issues + 1)) - ;; - # not-found is not proof of not-applied (miner hasn't started a RandomX job yet, or a - # copytruncate rotation just cleared the live log) — stay quiet. - *) : ;; - esac - if [ "$msrstat" = none ]; then - [ -z "$log_file" ] && _ck_info "MSR file-log confirmation is disabled; the bounded systemd journal has no confirmation yet" - [ -n "$log_file" ] && [ ! -f "$log_file" ] && _ck_info "MSR unverifiable — no xmrig.log at $log_file and no journal confirmation" + elif [ -z "$wr" ]; then + _ck_info "MSR unverifiable — no config.json, so the worker root couldn't be resolved" fi - elif [ -z "$wr" ]; then - # #367: no config.json, so the worker root above never resolved. Distinct wording from the - # "resolved but no log" case below — an absent block must not read as a failed check. - _ck_info "MSR unverifiable — no config.json, so the worker root couldn't be resolved" - fi - # CPU governor - local gov - gov=$(cat "$GOVERNOR_FILE" 2>/dev/null || echo "") - if [ "$gov" = "performance" ]; then - _ck_ok "CPU governor = performance" - else - _ck_warn "CPU governor is '${gov:-unknown}' (expected 'performance')" - fi + local gov + gov=$(cat "$GOVERNOR_FILE" 2>/dev/null || echo "") + if [ "$gov" = "performance" ]; then + _ck_ok "CPU governor = performance" + else + _ck_warn "CPU governor is '${gov:-unknown}' (expected 'performance')" + fi - # Hashrate-capping HARDWARE (advisory; #67). RandomX fast-mode is dataset-latency bound, so a - # single memory channel, slow RAM, or a power/boost cap silently leaves performance on the table. - # doctor can't change these — it only flags them. Best-effort, gated on tool/data availability. + fi if command -v "$DMIDECODE" >/dev/null 2>&1; then local mem pop nch spd rated mem=$(_mem_summary) @@ -5824,7 +5807,7 @@ EOF # XMRig's own startup report (HUGE PAGES 100% means the dataset is fully backed). Reuses the # log_file resolved above for the MSR-applied check. - if [ -n "$log_file" ] && [ -f "$log_file" ]; then + if [ "${SHARED_HOST:-false}" != true ] && [ -n "$log_file" ] && [ -f "$log_file" ]; then if grep -qiE 'huge pages.*100%' "$log_file"; then _ck_ok "XMRig log reports HUGE PAGES 100%" elif grep -qi 'huge pages' "$log_file"; then @@ -6264,6 +6247,11 @@ if [ "$_RIGFORGE_SOURCED" = "0" ]; then bios "$@" ;; api-refresh) api_refresh ;; + shared-host-check) _shared_host_check ;; + shared-host-run) + shift + _shared_host_exec "$@" + ;; msr-apply) msr_apply ;; control-apply) _with_control_lock control_apply ;; control-upgrade) _with_control_lock control_upgrade ;; diff --git a/tests/README.md b/tests/README.md index 50c04bc..d235f9f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -26,6 +26,9 @@ GitHub Test suite job, including argument inspection, password rotation and EXIT | **Release e2e (full)** | [`e2e-real.sh`](e2e-real.sh) | Real Linux rig, **manual, root**. Not in CI. | The real thing end to end: build + tune + kernel tuning + service + a real hash, the writable control path (#236) against real systemd (`control` phase), the remote-upgrade chain with real git as the root oneshot (`upgrade` phase, #322), then a clean uninstall. **The release gate.** | `make e2e-real` (see [`RELEASING.md`](../RELEASING.md)) | | **Release e2e (worker↔stack)** | [`e2e-pithead.sh`](e2e-pithead.sh) ([`control` phase](e2e-pithead-control.sh), [`access-tokens` phase](e2e-pithead-tokens.sh)) | Real rig + a **live Pithead stack**, **manual, root**. Not in CI (`connect`/`control`/`access-tokens` also run as a `rigforge`/`tier4-e2e` bench-ci job). | The integration contract: the `network` phase records fixed boolean summary diagnostics (`network-summary`) before enforcing metadata and the XMRig key superset, without logging response bodies or key names; `network-sequence` reproduces the nightly `connect` → `worker-api` → `api-impact` → `network` prefix in one process/snapshot and stops there (separate phase submissions restore between phases); mining round-trip against the stack, the `:8080` API posture (open/token/`restricted`), the writable control path's DONATION apply round trip (#344/#509), a named `ACCESS_TOKENS` entry authenticating `:8081`/`:8082` while staying refused on `:8080` and after revocation (#516); setup/revocation wait up to 30 s per listener for a bearer-free GET to return HTTP 401, recording elapsed time or sanitized curl/HTTP/unit diagnostics (#573), then separately require the revoked bearer to receive HTTP 401, stratum auth accept/reject + rotation against only `PITHEAD_URL` (a temporary plain-Stratum pool preserves the primary `user`, replaces endpoint-specific settings and removes all fallbacks; URL/password enter jq through stdin with constant programs; EXIT restores the original config bytes and runtime even on failure; explicit `stratum-auth` fails with a sanitized `missing` diagnostic when `E2E_STRATUM_PASS` is absent; `all` retains its optional skip), dashboard visibility/drop-off, a missing-minimum-marker upgrade that compiles the candidate miner, verifies fresh compiler/linker output, its zero compiled minimum and reported donation, then proves reductions 1 → 0 and 5 → 2 preserve that new binary; the phase restores the original build, markers, configs and service state even on failure, and the sister-API **hashrate-impact guard** (#99 must not shave H/s under polling load). | `PITHEAD_URL=host:3333 sudo -E make e2e-pithead` (see [`RELEASING.md`](../RELEASING.md)) | +The opt-in `shared-host` phase of `e2e-pithead.sh` is not part of `all` and runs as a `rigforge`/`tier4-e2e` +bench-ci job; [docs/shared-host.md](../docs/shared-host.md) describes what it checks and restores. + `tests/test-sister-reconcile.sh`, registered in `tests/run.sh`, checks startup failures, bounded listener probes (including IPv6 and failed transports), and apply's failure result while retaining later reconciles. The hosted Ubuntu 24.04 `Stock home setup` jobs run historical and current revisions on separate diff --git a/tests/e2e-pithead.sh b/tests/e2e-pithead.sh index 2d2e4d7..8dc5198 100644 --- a/tests/e2e-pithead.sh +++ b/tests/e2e-pithead.sh @@ -510,7 +510,7 @@ phase_dashboard() { "$RIGFORGE" start >/dev/null 2>&1 } -source "$(dirname "${BASH_SOURCE[0]}")/e2e-donation.sh" +source "$(dirname "${BASH_SOURCE[0]}")/e2e-donation.sh" && source "$(dirname "${BASH_SOURCE[0]}")/e2e-shared-host.sh" summary() { printf '\ne2e-pithead: \033[1;32m%d ok\033[0m, ' "$PASS" @@ -524,7 +524,7 @@ summary() { require_preflight "$@" # Validate the phase against its own `phase_` function: a typo dies with the rig untouched. # #183: the lock follows, before snapshot_config (whose _cleanup runs `apply`) and the first API touch. -[ "${1:-all}" = all ] || declare -F "phase_${1//-/_}" >/dev/null || die "unknown phase '$1' (connect|worker-api|api-impact|network|network-sequence|stratum-auth|dashboard|dev-fee|control|control-replay-thermal|control-replay-pools|access-tokens|all)" +[ "${1:-all}" = all ] || declare -F "phase_${1//-/_}" >/dev/null || die "unknown phase '$1' (connect|worker-api|api-impact|network|network-sequence|stratum-auth|dashboard|dev-fee|control|control-replay-thermal|control-replay-pools|access-tokens|shared-host|all)" replay_lock "${1:-all}" snapshot_config @@ -540,13 +540,14 @@ dev-fee) phase_dev_fee ;; control) phase_control ;; control-replay-thermal | control-replay-pools) phase_control_replay "${1##*-}" ;; access-tokens) phase_access_tokens ;; +shared-host) phase_shared_host ;; all) for run_phase in phase_connect phase_worker_api phase_api_impact phase_network phase_stratum_auth phase_dashboard phase_dev_fee phase_control phase_access_tokens; do "$run_phase" [ "$FAIL" -eq 0 ] || break done ;; -*) die "unknown phase '$1' (connect|worker-api|api-impact|network|network-sequence|stratum-auth|dashboard|dev-fee|control|control-replay-thermal|control-replay-pools|access-tokens|all)" ;; +*) die "unknown phase '$1' (connect|worker-api|api-impact|network|network-sequence|stratum-auth|dashboard|dev-fee|control|control-replay-thermal|control-replay-pools|access-tokens|shared-host|all)" ;; esac _cleanup || bad "pre-test config/runtime restoration failed; snapshot retained at $SAVED_CFG" trap - EXIT diff --git a/tests/e2e-shared-host.sh b/tests/e2e-shared-host.sh new file mode 100644 index 0000000..70b20e0 --- /dev/null +++ b/tests/e2e-shared-host.sh @@ -0,0 +1,97 @@ +# shellcheck shell=bash +_shared_host_state() { + local f + printf 'nr_hugepages=%s\n' "$(cat /proc/sys/vm/nr_hugepages 2>/dev/null || echo unreadable)" + printf 'nr_overcommit_hugepages=%s\n' "$(cat /proc/sys/vm/nr_overcommit_hugepages 2>/dev/null || echo unreadable)" + printf 'governors=%s\n' "$(cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor 2>/dev/null | sort | uniq -c | tr -s ' ' | tr '\n' ';')" + printf 'msr_module=%s\n' "$([ -d /sys/module/msr ] && echo loaded || echo absent)" + printf 'cmdline=%s\n' "$(sha256sum &1 | sed 's/^/shared-host: /' + stat -c 'shared-host: log owner %U:%G mode %a size %s' "$WLOG" 2>&1 || true +} + +_shared_host_wait_share() { + local waited=0 + while [ "$waited" -lt "${E2E_SHARED_SHARE_TIMEOUT:-600}" ] && ! grep -q 'accepted (' "$WLOG" 2>/dev/null; do + sleep 5 + waited=$((waited + 5)) + done + grep -q 'accepted (' "$WLOG" 2>/dev/null +} + +phase_shared_host() { + phase "shared-host — bounded miner in rigforge.slice, unrelated host settings unchanged (#577)" + local before during after total huge mem pid cg worker marker_owned=0 peak init + worker=$(dirname "$(dirname "$(dirname "$GEN_CFG")")") + [ -e "$worker/.rigforge-shared-host" ] || marker_owned=1 + before=$(_shared_host_state) + printf 'shared-host: host state before\n%s\n' "$before" + total=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo) + huge=$(awk '/^Hugetlb:/ {print $2}' /proc/meminfo) + mem=4096 + [ "$(((total - ${huge:-0}) / 1024 - 1024))" -ge "$mem" ] || mem=3072 + "$RIGFORGE" stop >/dev/null 2>&1 || true + if [ -d /sys/fs/cgroup/rigforge.slice ] && [ -z "$(systemctl show rigforge.slice -p FragmentPath --value)" ] && + [ "$(systemctl show rigforge.slice -p ActiveState --value)" = active ] && [ "$(systemctl show rigforge.slice -p Transient --value)" = no ] && + [ -z "$(systemctl show rigforge.slice -p DropInPaths -p RequiredBy -p WantedBy --value | tr -d '[:space:]')" ] && + [ -z "$(find /sys/fs/cgroup/rigforge.slice -mindepth 1 -type d)" ] && [ -z "$(cat /sys/fs/cgroup/rigforge.slice/cgroup.procs)" ]; then + printf 'shared-host: stopping an empty rigforge.slice left without a unit file by an earlier run\n' + systemctl stop rigforge.slice || bad "could not stop the empty leftover rigforge.slice" + fi + if ! set_cfg ".shared_host = {cpu_quota_percent: 200, memory_max_mb: $mem, existing_host_tuning: \"preserve\"} | .threads //= 2 | .pools[0].url = \"$PITHEAD_URL\"" soft; then + [ "$marker_owned" = 0 ] || rm -rf "$worker/.rigforge-shared-host" + bad "shared_host apply was refused on this rig (see set_cfg above)" + return 0 + fi + [ "$marker_owned" = 0 ] || rm -rf "$worker/.rigforge-shared-host" + : >"$WLOG" 2>/dev/null || true + if "$RIGFORGE" start >/dev/null 2>&1; then ok "shared-host miner started through the budget guard"; else bad "shared-host miner did not start"; fi + if [ "$(systemctl show xmrig -p Slice --value)" = rigforge.slice ]; then ok "miner unit is placed in rigforge.slice"; else bad "miner unit is outside rigforge.slice"; fi + cg=/sys/fs/cgroup/rigforge.slice + if [ "$(cat "$cg/cpu.max" 2>/dev/null)" = "200000 100000" ] && [ "$(cat "$cg/memory.max" 2>/dev/null)" = "$((mem * 1048576))" ] && + [ "$(cat "$cg/memory.swap.max" 2>/dev/null)" = 0 ]; then + ok "effective slice limits: cpu.max 200000/100000, memory.max ${mem} MiB, swap 0" + else + bad "effective slice limits differ: cpu.max '$(cat "$cg/cpu.max" 2>/dev/null)' memory.max '$(cat "$cg/memory.max" 2>/dev/null)' swap '$(cat "$cg/memory.swap.max" 2>/dev/null)'" + fi + pid=$(systemctl show xmrig -p MainPID --value) + if [ "${pid:-0}" != 0 ] && grep -q '^0::/rigforge.slice/' "/proc/$pid/cgroup" 2>/dev/null; then ok "running miner process is accounted to rigforge.slice"; else bad "running miner process is not in rigforge.slice"; fi + if jq -e '.cpu."huge-pages" == false and .cpu."memory-pool" == false and .cpu.yield == true and .cpu.priority == 1 and + .randomx.wrmsr == false and .randomx.rdmsr == false and .randomx."1gb-pages" == false and .randomx.numa == false' "$GEN_CFG" >/dev/null; then + ok "generated XMRig config uses ordinary pages, yield, priority 1 and no MSR tuning" + else + bad "generated XMRig config kept dedicated tuning" + fi + if "$RIGFORGE" status 2>&1 | grep -q 'effective CPU quota 200000/100000 us'; then ok "status reports the shared profile's effective limits"; else bad "status does not report the effective shared limits"; fi + if wait_for_job 120; then ok "shared-host miner received a stratum job"; else + _shared_host_diagnose + bad "no stratum job within 120s under shared_host" + fi + if _shared_host_wait_share; then + init=$(grep -oE 'dataset ready \([0-9]+ ms\)' "$WLOG" | tail -1 || true) + ok "share accepted under shared_host (${init:-dataset init time not logged})" + else + _shared_host_diagnose + bad "no accepted share within ${E2E_SHARED_SHARE_TIMEOUT:-600}s under shared_host" + fi + peak=$(cat "$cg/memory.peak" 2>/dev/null || echo unavailable) + printf 'shared-host: slice memory.peak=%s bytes, memory.max=%s bytes\n' "$peak" "$((mem * 1048576))" + during=$(_shared_host_state) + if [ "$during" = "$before" ]; then ok "unrelated host settings unchanged while shared"; else bad "host settings changed while shared: $(diff <(printf '%s\n' "$before") <(printf '%s\n' "$during") | tr '\n' ' ')"; fi + "$RIGFORGE" stop >/dev/null 2>&1 || true + if cp "$SAVED_CFG" "$CFG" && "$RIGFORGE" apply >/dev/null 2>&1 && _restore_xmrig; then ok "dedicated configuration and run state restored"; else bad "dedicated restoration failed"; fi + cg=$(systemctl show xmrig -p ControlGroup --value) + if [ -z "$(systemctl show rigforge.slice -p FragmentPath --value 2>/dev/null)" ] && [ "$(systemctl show rigforge.slice -p ActiveState --value)" = inactive ] && + [ "$(systemctl show xmrig -p Slice --value)" != rigforge.slice ] && [ "${cg#/rigforge.slice/}" = "$cg" ]; then ok "owned slice stopped and removed after disabling the profile"; else + systemctl show rigforge.slice -p LoadState -p ActiveState -p FragmentPath 2>&1 | sed 's/^/shared-host: slice /' + bad "rigforge.slice survived disabling the profile" + fi + after=$(_shared_host_state) + if [ "$after" = "$before" ]; then ok "unrelated host settings unchanged after restoration"; else bad "host settings changed after restoration: $(diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") | tr '\n' ' ')"; fi +} diff --git a/tests/run.sh b/tests/run.sh index 805d7db..2ae6816 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -752,6 +752,9 @@ ec1="$(mktemp -d "$SANDBOX/ec1.XXXXXX")" ) assert_eq "#344: SETUP_URL_TRIES=1 leaves no room for a retry" "$([ -f "$ec1/config.json" ] && echo yes || echo no)" "no" assert_rc "config render is atomic and fails closed (#634)" "$(bash "$ROOT/tests/test-config-atomic.sh" "$SCRIPT" >/dev/null 2>&1 && echo 0 || echo 1)" 0 +for _sh in "shared-host profile $PATH" "shared-host-lifecycle lifecycle $STUBS:$PATH"; do + if read -r _f _l _p <<<"$_sh" && _o="$(PATH="$_p" bash "$ROOT/tests/test-$_f.sh" "$SCRIPT" 2>&1)"; then ok "shared-host $_l regressions (#577)"; else bad "shared-host $_l regressions (#577)" "$(printf '%s' "$_o" | tail -5)"; fi +done bash "$ROOT/tests/test-donation.sh" "$SCRIPT" assert_rc "donation runtime and migration regressions (#602)" "$?" 0 echo "== unit: DONATION validation (new) ==" @@ -3267,7 +3270,7 @@ comp_out="$(bash "$SCRIPT" completion bash)" # Hyphenated operator verbs (support-bundle) complete; the two INTERNAL hyphenated verbs (run by # systemd, not operators) are excluded by name. # sort -u: the #149 no-arg pre-check case names the same verbs a second time before dispatch. -dispatch_verbs="$(sed -n '/_RIGFORGE_SOURCED" = "0" \]; then/,/^fi$/p' "$SCRIPT" | grep -oE '^ [a-z |-]+\)' | tr -d ' )' | tr '|' '\n' | grep -E '^[a-z][a-z-]*$' | grep -vE '^(api-refresh|msr-apply|control-apply|control-upgrade)$' | sort -u | tr '\n' ' ')" +dispatch_verbs="$(sed -n '/_RIGFORGE_SOURCED" = "0" \]; then/,/^fi$/p' "$SCRIPT" | grep -oE '^ [a-z |-]+\)' | tr -d ' )' | tr '|' '\n' | grep -E '^[a-z][a-z-]*$' | grep -vE '^(api-refresh|msr-apply|control-apply|control-upgrade|shared-host-check|shared-host-run)$' | sort -u | tr '\n' ' ')" completed_verbs="$(printf '%s\n' "$comp_out" | sed -n 's/^_rigforge_verbs="\(.*\)"$/\1/p' | tr ' ' '\n' | sort | tr '\n' ' ')" assert_eq "completion verbs match the dispatch case exactly (#145)" "$completed_verbs" "$dispatch_verbs" assert_contains "completion: all ten tune flags (#145)" "$comp_out" '--now --short --long --live --bench --confirm --efficiency --perf --history --clear' @@ -5063,6 +5066,8 @@ assert_contains "doctor: rdmsr verifies the registers (#66)" "$out" "verified vi # Readable but wrong values (a silently-dropped write) -> mismatch WARN. out="$(run_doctor_msr "$DOC/rdmsr_bad")" assert_contains "doctor: rdmsr mismatch WARN (#66)" "$out" "don't match the ryzen_19h_zen4 preset" +out="$(LOCKDOWN_FILE="$DOC/lockdown_integrity" run_doctor_msr "$DOC/rdmsr_bad")" +assert_contains "doctor: rdmsr mismatch attributed to lockdown (#333)" "$out" "kernel lockdown ('integrity') dropped the write" # Non-root: the read-back is SKIPPED with an advisory — NOT a false mismatch (regression guard for the gate). out="$(run_doctor_msr "$DOC/rdmsr_ok" user)" assert_contains "doctor: non-root asks for sudo to verify MSRs (#66)" "$out" "run 'doctor' as root" diff --git a/tests/test-e2e-gate-fail-closed.sh b/tests/test-e2e-gate-fail-closed.sh index 7eef4ec..7fbbd59 100644 --- a/tests/test-e2e-gate-fail-closed.sh +++ b/tests/test-e2e-gate-fail-closed.sh @@ -65,7 +65,7 @@ pit_dispatch_case() { # -> what-ran:rc out="$( eval "$PIT_DIE" FAIL=0 - for p in connect worker_api api_impact network network_sequence stratum_auth dashboard dev_fee control access_tokens; do + for p in connect worker_api api_impact network network_sequence stratum_auth dashboard dev_fee control access_tokens shared_host; do eval "phase_$p() { printf '%s ' $p; }" done set -- "$1" @@ -77,6 +77,7 @@ pit_dispatch_case() { # -> what-ran:rc source "$ROOT/tests/test-stratum-auth-gate.sh" assert_eq "dispatch selects the network sequence" "$(pit_dispatch_case network-sequence)" "network_sequence :0" assert_eq "dispatch runs the named phase" "$(pit_dispatch_case worker-api)" "worker_api :0" +assert_eq "dispatch runs the opt-in shared-host phase alone (#577)" "$(pit_dispatch_case shared-host)" "shared_host :0" assert_eq "dispatch runs every phase for 'all'" "$(pit_dispatch_case all)" "connect worker_api api_impact network stratum_auth dashboard dev_fee control access_tokens :0" assert_eq "an underscored typo dies instead of silently passing with nothing run" "$(pit_dispatch_case worker_api)" "nothing:2" assert_eq "an unknown phase dies instead of silently passing" "$(pit_dispatch_case bogus)" "nothing:2" diff --git a/tests/test-shared-host-lifecycle.sh b/tests/test-shared-host-lifecycle.sh new file mode 100644 index 0000000..484a257 --- /dev/null +++ b/tests/test-shared-host-lifecycle.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1090,SC2034,SC2218,SC2329 +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SCRIPT="${1:-$ROOT/rigforge.sh}" +D=$(mktemp -d) +trap 'rm -rf "$D"' EXIT +export RIGFORGE_HOME="$D" RIGFORGE_APPLIANCE=1 +source "$SCRIPT" + +fail() { + printf 'shared host lifecycle regression: %s\n' "$1" >&2 + exit 1 +} +assert_fails() { + local label="$1" + shift + if ("$@") >"$D/result" 2>&1; then fail "$label unexpectedly passed"; fi +} +sudo() { "$@"; } +hostname() { :; } +lscpu() { printf 'Model name: Fixture CPU\nFlags: aes avx2\n'; } + +OS_TYPE=Linux +SCRIPT_DIR="$ROOT" +WORKER_ROOT="$D/worker" +SYSTEMD_DIR="$D/systemd" +CONFIG_JSON="$D/config.json" +POOLS_JSON='[]' +ACCESS_TOKEN='' +DONATION=3 +THREADS_CAP=2 +MINER_USER=rigforge-miner +SHARED_HOST=true +mkdir -p "$WORKER_ROOT/xmrig/build" "$SYSTEMD_DIR" +printf '{"cpu":{"yield":false,"priority":2,"rx":99,"rx/wow":[0,1],"huge-pages":true,"memory-pool":true},"randomx":{"wrmsr":true,"rdmsr":true,"cache_qos":true,"1gb-pages":true,"numa":true},"pools":[{"user":"fixture"}],"http":{"access-token":"sentinel"},"donate-level":99,"autosave":true}\n' >"$WORKER_ROOT/tune-overrides.json" +( + cd "$WORKER_ROOT/xmrig/build" + generate_xmrig_config >/dev/null +) +cfg="$WORKER_ROOT/xmrig/build/config.json" +jq -e ' + .autosave == false and .cpu.yield == true and .cpu.priority == 1 and + .cpu.rx == [-1,-1] and .cpu."*" == .cpu.rx and (.cpu | has("rx/wow") | not) and + .cpu."huge-pages" == false and .cpu."memory-pool" == false and + .randomx.wrmsr == false and .randomx.rdmsr == false and .randomx.cache_qos == false and + .randomx."1gb-pages" == false and .randomx.numa == false and .randomx.init == 2 and + ."donate-level" == 3 and .pools == [] and .http."access-token" == null +' "$cfg" >/dev/null || fail 'hostile tune overrides escaped shared safeguards' +cp "$cfg" "$D/first.json" +( + cd "$WORKER_ROOT/xmrig/build" + generate_xmrig_config >/dev/null +) +cmp -s "$D/first.json" "$cfg" || fail 'repeated generation changed shared safeguards' + +BUILD_DIR="$WORKER_ROOT/xmrig/build" +CPUPOWER_PATH=cpupower +NFT_PATH=nft +_render_xmrig_unit +unit="$SYSTEMD_DIR/$SERVICE_NAME.service" +grep -qFx '# RigForge shared-host profile' "$unit" || fail 'shared unit lacks ownership marker' +grep -qFx 'Slice=rigforge.slice' "$unit" || fail 'miner not placed in budgeted slice' +! grep -q 'frequency-set -g performance' "$unit" || fail 'shared unit retunes governor' +! grep -q '^ExecStartPre=.*msr-apply' "$unit" || fail 'shared unit writes MSRs' +SHARED_HOST=false +_render_xmrig_unit +grep -q 'frequency-set -g performance' "$unit" || fail 'dedicated governor changed' +grep -q '^ExecStartPre=.*msr-apply' "$unit" || fail 'dedicated MSR behavior changed' +! grep -qFx 'Slice=rigforge.slice' "$unit" || fail 'dedicated miner retained shared slice' + +printf '{"shared_host":{"cpu_quota_percent":75,"memory_max_mb":4096}}\n' >"$CONFIG_JSON" +assert_fails 'starting an unbounded unit' svc_start +assert_fails 'restarting an unbounded unit' svc_restart +SHARED_HOST=true +_render_xmrig_unit +LOADED_SLICE=rigforge.slice +LOADED_PRECHECK='rigforge.sh shared-host-check' +systemctl() { + case "$*" in + "show $SERVICE_NAME -p Slice --value") printf '%s\n' "$LOADED_SLICE" ;; + "show $SERVICE_NAME -p ExecStartPre --value") printf '%s\n' "$LOADED_PRECHECK" ;; + *) printf '%s\n' "$*" >>"$D/systemctl-calls" ;; + esac +} +LOADED_SLICE=system.slice +assert_fails 'marked unit with stale loaded slice' svc_start +[ ! -e "$D/systemctl-calls" ] || fail 'stale loaded slice reached systemctl start' +LOADED_SLICE=rigforge.slice +LOADED_PRECHECK='' +assert_fails 'marked unit without loaded startup check' svc_restart +[ ! -e "$D/systemctl-calls" ] || fail 'missing loaded startup check reached systemctl restart' +LOADED_PRECHECK='rigforge.sh shared-host-check' +svc_start >/dev/null +svc_restart >/dev/null +grep -qFx "start $SERVICE_NAME" "$D/systemctl-calls" || fail 'bounded start not passed to systemd' +grep -qFx "restart $SERVICE_NAME" "$D/systemctl-calls" || fail 'bounded restart not passed to systemd' + +assert_fails 'direct offline benchmark' _xmrig_bench unused 1M '' +parse_config() { SHARED_HOST=true; } +assert_fails 'full live tuning' tune --live + +systemd-run() { + printf '%s\n' "$@" >"$D/scope-args" + IFS= read -r -d '' power_command || fail 'helper power command frame is missing' + printf '%s' "$power_command" >"$D/helper-power-command" +} +AUTOTUNE_TARGET=efficiency TUNE_POWER_CMD='printf 42' AUTOTUNE_SAMPLES=2 AUTOTUNE_INTERVAL=3 \ + AUTOTUNE_WARMUP=4 AUTOTUNE_MARGIN=0.05 AUTOTUNE_MODES='0 2' _shared_host_run true +grep -q -- '--slice=rigforge.slice' "$D/scope-args" || fail 'helper did not enter budgeted slice' +grep -q -- '--property=Nice=19' "$D/scope-args" || fail 'helper scheduling was not conservative' +grep -qFx 'printf 42' "$D/helper-power-command" || fail 'helper power command did not reach stdin' +! grep -qF 'printf 42' "$D/scope-args" || fail 'helper power command leaked into systemd arguments' +for setting in 'AUTOTUNE_TARGET=efficiency' 'AUTOTUNE_SAMPLES=2' \ + 'AUTOTUNE_INTERVAL=3' 'AUTOTUNE_WARMUP=4' 'AUTOTUNE_MARGIN=0.05' 'AUTOTUNE_MODES=0 2'; do + grep -qFx -- "--setenv=$setting" "$D/scope-args" || fail "helper lost $setting" +done +_shared_host_check() { [ "$1" = helper ] || fail 'helper used the wrong guard'; } +printf '%s\0' 'printf 42' | (_shared_host_exec bash -c '[ "$TUNE_POWER_CMD" = "printf 42" ]') || + fail 'valid helper power command frame was not exported' +assert_fails 'missing helper power command frame' _shared_host_exec true "$D/autotune-scope" + printf '%s\n' "$AUTOTUNE_TARGET" >"$D/autotune-target" +} +autotune >/dev/null +grep -q 'rigforge.sh autotune' "$D/autotune-scope" || fail 'autotune bypassed budgeted helper scope' +parse_config() { + SHARED_HOST=true + AUTOTUNE_TARGET="$CONFIG_AUTOTUNE_TARGET" +} +CONFIG_AUTOTUNE_TARGET=efficiency +AUTOTUNE_TARGET='' tune --now --perf >/dev/null +grep -qFx perf "$D/autotune-target" || fail 'explicit performance target lost before helper hop' +CONFIG_AUTOTUNE_TARGET=perf +AUTOTUNE_TARGET='' tune --now --efficiency >/dev/null +grep -qFx efficiency "$D/autotune-target" || fail 'explicit efficiency target lost before helper hop' +autotune() { printf 'bounded live trial\n' >"$D/live-trial"; } +tune --now >/dev/null +[ -f "$D/live-trial" ] || fail 'bounded quick tune did not use autotune' + +reown_case() { + rm -f "$D/reown-calls" + ( + id() { printf '0\n'; } + chown() { printf 'chown %s\n' "$*" >>"$D/reown-calls"; } + touch() { printf 'touch %s\n' "$*" >>"$D/reown-calls"; } + chmod() { printf 'chmod %s\n' "$*" >>"$D/reown-calls"; } + OS_TYPE=Linux MINER_USER='' RIGFORGE_APPLIANCE=0 REAL_USER=operator SHARED_HOST="$1" + _reown_worker + ) +} +reown_case true +grep -qFx "touch $WORKER_ROOT/xmrig.log" "$D/reown-calls" || fail 'shared root miner log not created before re-own' +grep -qFx "chown root:operator $WORKER_ROOT/xmrig/build/config.json $WORKER_ROOT/xmrig.log" "$D/reown-calls" || fail 'shared root miner cannot write its log without DAC capabilities' +grep -qFx "chmod 0640 $WORKER_ROOT/xmrig/build/config.json" "$D/reown-calls" || fail 'shared generated config unreadable for the operator' +reown_case false +! grep -q 'root:' "$D/reown-calls" || fail 'dedicated root miner ownership changed' + +printf 'MemTotal: 16777216 kB\n' >"$D/meminfo16" +[ "$(SHARED_HOST=false MEMINFO="$D/meminfo16" compute_build_jobs 8)" = 8 ] || fail 'dedicated build parallelism changed' +[ "$(SHARED_HOST=true SHARED_MEMORY_MAX_MB=3072 MEMINFO="$D/meminfo16" compute_build_jobs 8)" = 1 ] || fail 'shared build sized from host RAM' +[ "$(SHARED_HOST=true SHARED_MEMORY_MAX_MB=8192 MEMINFO="$D/meminfo16" compute_build_jobs 8)" = 4 ] || fail 'shared build ignored its ceiling' +( + git() { + case "$1" in + clone) + mkdir -p xmrig.new/src + printf 'constexpr const int kDefaultDonateLevel = 1;\nconstexpr const int kMinimumDonateLevel = 1;\n' >xmrig.new/src/donate.h + ;; + -C) printf '%s\n' "$XMRIG_COMMIT" ;; + esac + } + nproc() { printf '8\n'; } + systemctl() { + case "$1" in + is-active) return 0 ;; + stop) printf 'stop %s\n' "$2" >>"$D/compile-calls" ;; + esac + } + _shared_host_run() { printf '%s\n' "$*" >>"$D/compile-calls"; } + generate_xmrig_config() { :; } + _activate_xmrig_build() { :; } + MEMINFO="$D/meminfo16" SHARED_HOST=true SHARED_MEMORY_MAX_MB=3072 XMRIG_REBUILD=true XMRIG_VERSION=v0 XMRIG_COMMIT=fixture DONATION=3 + compile_xmrig >/dev/null +) +[ "$(head -n1 "$D/compile-calls")" = "stop $SERVICE_NAME.service" ] || fail 'shared build started beside a running miner' +grep -qFx 'make -j1' "$D/compile-calls" || fail 'shared build parallelism exceeded the memory ceiling' + +printf 'external slice\n' >"$SYSTEMD_DIR/rigforge.slice" +_shared_host_remove +[ -f "$SYSTEMD_DIR/rigforge.slice" ] || fail 'unowned slice was removed' +printf 'Description=RigForge shared-host resource budget\n' >"$SYSTEMD_DIR/rigforge.slice" +RIGFORGE_CGROUP_ROOT="$D/cgroup-remove" +mkdir -p "$RIGFORGE_CGROUP_ROOT/rigforge.slice/run-helper.service" +: >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/cgroup.procs" +printf '4242\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/run-helper.service/cgroup.procs" +systemctl() { printf '%s\n' "$*" >>"$D/remove-calls"; } +_shared_host_remove +[ -f "$SYSTEMD_DIR/rigforge.slice" ] || fail 'owned slice removed while a process still ran in it' +[ ! -e "$D/remove-calls" ] || fail 'slice stopped while a process still ran in it' +: >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/run-helper.service/cgroup.procs" +systemctl() { [ "$1" != stop ] || return 1; } +_shared_host_remove +[ -f "$SYSTEMD_DIR/rigforge.slice" ] || fail 'owned fragment deleted although the slice did not stop' +systemctl() { printf '%s\n' "$*" >>"$D/remove-calls"; } +_shared_host_remove +[ ! -e "$SYSTEMD_DIR/rigforge.slice" ] || fail 'owned slice survived removal' +[ "$(head -n1 "$D/remove-calls")" = 'stop rigforge.slice' ] || fail 'removed slice was left active' + +trace() { printf '%s\n' "$1" >>"$D/trace"; } +assert_order() { + awk -v first="$1" -v second="$2" ' + $0 == first { a = NR } + $0 == second { b = NR } + END { exit !(a > 0 && b > a) } + ' "$D/trace" || fail "$1 was not before $2" +} +_persist_appliance_mode() { :; } +check_prerequisites() { :; } +ensure_config_exists() { :; } +parse_config() { + SHARED_HOST=true + trace parse +} +_shared_host_prepare() { trace prepare; } +decide_rebuild() { XMRIG_REBUILD=false; } +prepare_workspace() { :; } +install_dependencies() { :; } +compile_xmrig() { trace compile; } +generate_xmrig_config() { trace generate; } +tune_kernel() { :; } +configure_limits() { :; } +install_service() { trace service; } +install_autotune() { :; } +install_watchdog() { :; } +install_api() { :; } +install_control() { :; } +install_api_firewall() { :; } +link_cli() { :; } +_reown_worker() { :; } +finish_deployment() { :; } +_upgrade_restart() { trace restart; } +_post_upgrade_retune() { :; } +_ensure_miner_user() { :; } +_render_xmrig_unit() { :; } +_apply_runtime() { trace runtime; } +_autotune_apply_notice() { :; } +_stamp_config_meta() { :; } +_apply_pool_check() { :; } + +: >"$D/trace" +main >/dev/null +assert_order prepare compile +assert_order prepare service +: >"$D/trace" +upgrade >/dev/null +assert_order prepare compile +assert_order prepare restart +: >"$D/trace" +apply >/dev/null +assert_order prepare runtime + +printf 'shared host generation, service and helper lifecycle regressions: PASS\n' diff --git a/tests/test-shared-host.sh b/tests/test-shared-host.sh new file mode 100644 index 0000000..d043943 --- /dev/null +++ b/tests/test-shared-host.sh @@ -0,0 +1,313 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1090,SC2034,SC2329 +set -euo pipefail + +SCRIPT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/rigforge.sh}" +D=$(mktemp -d) +trap 'rm -rf "$D"' EXIT +export RIGFORGE_HOME="$D" RIGFORGE_APPLIANCE=1 +source "$SCRIPT" + +fail() { + printf 'shared host regression: %s\n' "$1" >&2 + exit 1 +} +assert_fails() { + local label="$1" + shift + if ("$@") >"$D/result" 2>&1; then fail "$label unexpectedly passed"; fi +} + +CONFIG_JSON="$D/config.json" +printf '{}\n' >"$CONFIG_JSON" +_shared_host_parse +[ "$SHARED_HOST" = false ] || fail 'missing profile changed dedicated default' + +printf '{"shared_host":false}\n' >"$CONFIG_JSON" +_shared_host_parse +[ "$SHARED_HOST" = false ] || fail 'false profile changed dedicated default' + +printf '{"shared_host":{"cpu_quota_percent":75,"memory_max_mb":4096}}\n' >"$CONFIG_JSON" +_shared_host_parse +[ "$SHARED_HOST" = true ] || fail 'shared profile not enabled' +[ "$SHARED_CPU_QUOTA" = 75 ] || fail 'CPU quota parsed incorrectly' +[ "$SHARED_MEMORY_MAX_MB" = 4096 ] || fail 'memory limit parsed incorrectly' + +for bad in \ + '{"shared_host":true}' \ + '{"shared_host":{}}' \ + '{"shared_host":{"cpu_quota_percent":0,"memory_max_mb":4096}}' \ + '{"shared_host":{"cpu_quota_percent":10001,"memory_max_mb":4096}}' \ + '{"shared_host":{"cpu_quota_percent":75,"memory_max_mb":3071}}' \ + '{"shared_host":{"cpu_quota_percent":75,"memory_max_mb":0}}' \ + '{"shared_host":{"cpu_quota_percent":75.5,"memory_max_mb":4096}}' \ + '{"shared_host":{"cpu_quota_percent":75,"memory_max_mb":4096,"existing_host_tuning":"erase"}}'; do + printf '%s\n' "$bad" >"$CONFIG_JSON" + assert_fails 'invalid shared profile' _shared_host_parse +done + +printf '{"shared_host":{"cpu_quota_percent":75,"memory_max_mb":4096}}\n' >"$CONFIG_JSON" +_shared_host_parse +cat >"$D/generated.json" <<'JSON' +{ + "autosave": true, + "cpu": {"priority": 2, "yield": false, "huge-pages": true, "huge-pages-jit": true, "memory-pool": true, "rx": 6, "rx/wow": [0,1]}, + "randomx": {"1gb-pages": true, "rdmsr": true, "wrmsr": true, "cache_qos": true, "numa": true, "init": -1}, + "opencl": true, "cuda": true, + "pools": [{"user": "fixture"}], "http": {"access-token": "sentinel"}, "donate-level": 3 +} +JSON +THREADS_CAP=4 +_shared_host_config "$D/generated.json" +jq -e ' + .autosave == false and + .cpu.priority == 1 and .cpu.yield == true and + .cpu."huge-pages" == false and .cpu."huge-pages-jit" == false and .cpu."memory-pool" == false and + .cpu.rx == [-1,-1,-1,-1] and .cpu."*" == .cpu.rx and (.cpu | has("rx/wow") | not) and + .randomx."1gb-pages" == false and .randomx.rdmsr == false and .randomx.wrmsr == false and + .randomx.cache_qos == false and .randomx.numa == false and .randomx.init == 4 and + .opencl == false and .cuda == false and + .pools[0].user == "fixture" and .http."access-token" == "sentinel" and ."donate-level" == 3 +' "$D/generated.json" >/dev/null || fail 'shared safeguards or existing operator values lost' +jq '.cpu.rx = [0,-1]' "$D/generated.json" >"$D/smaller.json" +_shared_host_config "$D/smaller.json" +jq -e '.cpu.rx == [0,-1] and .cpu."*" == .cpu.rx' "$D/smaller.json" >/dev/null || fail 'smaller operator thread selection was not preserved' + +SHARED_HOST=false +cp "$D/generated.json" "$D/dedicated.json" +_shared_host_config "$D/dedicated.json" +cmp -s "$D/generated.json" "$D/dedicated.json" || fail 'disabled profile changed generated config' + +SHARED_HOST=true +OS_TYPE=Linux +_ensure_hugepages() { fail 'shared setup resized HugePages'; } +sudo() { fail 'shared setup invoked a privileged host command'; } +tune_kernel +configure_limits + +RIGFORGE_CGROUP_ROOT="$D/cgroup" +MEMINFO="$D/meminfo" +mkdir -p "$RIGFORGE_CGROUP_ROOT/rigforge.slice" "$D/systemd" +SYSTEMD_DIR="$D/systemd" +WORKER_ROOT="$D/worker" +printf 'MemTotal: 8388608 kB\nHugetlb: 0 kB\n' >"$MEMINFO" +systemctl() { + case "$*" in + 'is-active --quiet '*) return 1 ;; + 'show rigforge.slice -p LoadState --value') printf 'not-found\n' ;; + esac +} +systemd-run() { :; } +OS_TYPE=Darwin +assert_fails 'non-Linux shared profile' _shared_host_supported +OS_TYPE=Linux +assert_fails 'missing cgroup controllers' _shared_host_supported +printf 'cpu\n' >"$RIGFORGE_CGROUP_ROOT/cgroup.controllers" +assert_fails 'missing memory controller' _shared_host_supported +printf 'cpu memory\n' >"$RIGFORGE_CGROUP_ROOT/cgroup.controllers" +_shared_host_supported || fail 'available controls rejected' +printf 'MemTotal: 4194304 kB\nHugetlb: 0 kB\n' >"$MEMINFO" +assert_fails 'insufficient RAM headroom' _shared_host_supported +printf 'MemTotal: 8388608 kB\nHugetlb: 4194304 kB\n' >"$MEMINFO" +assert_fails 'existing HugeTLB pool not accounted' _shared_host_supported +printf 'MemTotal: 8388608 kB\nHugetlb: 0 kB\n' >"$MEMINFO" +printf '75000 100000\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/cpu.max" +printf '4294967296\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/memory.max" +printf '0\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/memory.swap.max" +printf '10\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/cpu.weight" +_shared_host_verify_budget >/dev/null || fail 'effective budget rejected' +printf 'max 100000\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/cpu.max" +assert_fails 'unbounded effective CPU' _shared_host_verify_budget +printf '75000 100000\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/cpu.max" +printf 'max\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/memory.max" +assert_fails 'unbounded effective memory' _shared_host_verify_budget +printf '4294967296\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/memory.max" +printf '1\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/memory.swap.max" +assert_fails 'nonzero effective swap' _shared_host_verify_budget +printf '0\n' >"$RIGFORGE_CGROUP_ROOT/rigforge.slice/memory.swap.max" + +_shared_host_report >"$D/report" +grep -q 'Resource profile: shared host' "$D/report" || fail 'selected mode missing from report' +grep -q 'effective CPU quota 75000/100000 us; memory 4294967296 bytes' "$D/report" || fail 'effective limits missing from report' +printf 'memory\n' >"$RIGFORGE_CGROUP_ROOT/cgroup.controllers" +assert_fails 'report with unavailable CPU control' _shared_host_report +grep -q 'Unsupported: shared_host CPU controller is unavailable' "$D/result" || fail 'unsupported report lacks reason' +printf 'cpu memory\n' >"$RIGFORGE_CGROUP_ROOT/cgroup.controllers" +systemctl() { + case "$*" in *' -p ControlGroup --value') printf '/system.slice/fixture.service\n' ;; esac +} +assert_fails 'report with unbounded running miner' _shared_host_report +grep -q 'Miner is outside the shared-host budget' "$D/result" || fail 'unbounded running miner not reported' +systemctl() { + case "$*" in + 'is-active --quiet '*) return 1 ;; + 'show rigforge.slice -p LoadState --value') printf 'not-found\n' ;; + esac +} + +unit=$(printf 'ExecStartPre=+-cpupower frequency-set -g performance\n' | _shared_host_unit) +[[ "$unit" != *frequency-set* ]] || fail 'shared unit still changes CPU governor' +[[ "$unit" == *'Slice=rigforge.slice'* ]] || fail 'shared unit lacks bounded slice' +[[ "$unit" == *'Nice=19'* ]] || fail 'shared unit lacks conservative priority' +[[ "$unit" == *'shared-host-check'* ]] || fail 'shared unit lacks boot-time guard' + +printf '[Service]\n' >"$SYSTEMD_DIR/$SERVICE_NAME.service" +assert_fails 'start with unbounded unit' _shared_host_start_guard +assert_fails 'dedicated transition without acknowledgement' _shared_host_prepare +printf '{"shared_host":{"cpu_quota_percent":75,"memory_max_mb":4096,"existing_host_tuning":"preserve"}}\n' >"$CONFIG_JSON" +systemctl() { + case "$*" in + 'is-active --quiet '*) return 1 ;; + 'show rigforge.slice -p LoadState --value') printf 'not-found\n' ;; + esac +} +sudo() { "$@"; } +_shared_host_prepare >/dev/null || fail 'inactive acknowledged transition rejected' +grep -qFx 'Description=RigForge shared-host resource budget' "$SYSTEMD_DIR/rigforge.slice" || fail 'owned slice not installed' +[ -d "$WORKER_ROOT/.rigforge-shared-host" ] || fail 'shared-host ownership history not recorded' +_shared_host_preserves_tuning "$WORKER_ROOT" || fail 'shared-host history was not preserved' +systemctl() { :; } +assert_fails 'active dedicated transition' _shared_host_prepare + +printf 'Description=external resource slice\n' >"$D/external.slice" +systemctl() { + case "$*" in + 'show rigforge.slice -p FragmentPath --value') printf '%s\n' "$D/external.slice" ;; + 'show rigforge.slice -p LoadState --value') printf 'loaded\n' ;; + 'is-active --quiet '*) return 1 ;; + esac +} +assert_fails 'effective unowned slice' _shared_host_prepare +grep -q 'not owned by RigForge' "$D/result" || fail 'effective slice refusal lacks reason' +grep -qFx 'Description=RigForge shared-host resource budget' "$SYSTEMD_DIR/rigforge.slice" || fail 'unowned effective slice changed owned fragment' +systemctl() { + case "$*" in + 'show rigforge.slice -p LoadState --value') printf 'loaded\n' ;; + 'show rigforge.slice -p ActiveState --value') printf 'active\n' ;; + 'is-active --quiet '*) return 1 ;; + esac +} +assert_fails 'active implicit slice' _shared_host_prepare +grep -q 'implicit or transient' "$D/result" || fail 'implicit slice refusal lacks reason' +systemctl() { + case "$*" in + 'show rigforge.slice -p LoadState --value') printf 'loaded\n' ;; + 'show rigforge.slice -p ActiveState --value') printf 'inactive\n' ;; + 'show rigforge.slice -p Transient --value') printf 'no\n' ;; + 'show rigforge.slice -p RequiredBy -p WantedBy --value') printf '%s\n' "${IMPLICIT_CONSUMERS:-}" ;; + 'is-active --quiet '*) return 1 ;; + esac +} +_shared_host_prepare >/dev/null || fail 'inactive synthesized slice rejected' +IMPLICIT_CONSUMERS=external.service +assert_fails 'inactive implicit slice with external consumer' _shared_host_prepare +IMPLICIT_CONSUMERS=rigforge-external.slice +assert_fails 'inactive implicit slice with external child' _shared_host_prepare +unset IMPLICIT_CONSUMERS +systemctl() { + case "$*" in + 'show rigforge.slice -p LoadState --value') printf 'loaded\n' ;; + 'show rigforge.slice -p ActiveState --value') printf 'inactive\n' ;; + 'show rigforge.slice -p Transient --value') printf 'yes\n' ;; + 'is-active --quiet '*) return 1 ;; + esac +} +assert_fails 'transient implicit slice' _shared_host_prepare +systemctl() { + case "$*" in + 'show rigforge.slice -p LoadState --value') printf 'loaded\n' ;; + 'show rigforge.slice -p ActiveState --value') printf 'inactive\n' ;; + 'show rigforge.slice -p Transient --value') printf 'no\n' ;; + 'show rigforge.slice -p DropInPaths --value') printf '%s\n' "$D/external.slice" ;; + 'is-active --quiet '*) return 1 ;; + esac +} +assert_fails 'implicit slice with drop-in' _shared_host_prepare + +systemctl() { + printf '%s\n' "$*" >>"$D/reload-calls" + [ "$1" != daemon-reload ] +} +assert_fails 'failed shared unit reload' _shared_host_reload +grep -q 'miner remains stopped' "$D/result" || fail 'reload refusal lacks stopped reason' +grep -qFx "stop $SERVICE_NAME" "$D/reload-calls" || fail 'reload failure left miner running' + +printf '{"shared_host":{"cpu_quota_percent":75,"memory_max_mb":4096},"threads":4}\n' >"$CONFIG_JSON" +mkdir -p "$D/data/worker/xmrig/build" +cp "$D/generated.json" "$D/data/worker/xmrig/build/config.json" +_shared_host_in_slice() { return 0; } +_shared_host_check >/dev/null || fail 'safe miner config rejected at startup' +jq '.cpu.rx = [-1,-1,-1,-1,-1]' "$D/generated.json" >"$D/data/worker/xmrig/build/config.json" +assert_fails 'startup with excess miner threads' _shared_host_check +grep -q 'unsafe or stale' "$D/result" || fail 'excess thread refusal lacks reason' +jq '.cpu."*" = [-1,-1,-1]' "$D/generated.json" >"$D/data/worker/xmrig/build/config.json" +assert_fails 'startup with wildcard thread mismatch' _shared_host_check +jq '.cpu.rx = -1' "$D/generated.json" >"$D/data/worker/xmrig/build/config.json" +assert_fails 'startup with ignored numeric thread setting' _shared_host_check +jq '.randomx.init = 5' "$D/generated.json" >"$D/data/worker/xmrig/build/config.json" +assert_fails 'startup with excess dataset init threads' _shared_host_check + +printf '{"shared_host":false}\n' >"$CONFIG_JSON" +mkdir -p "$D/data/worker/.rigforge-shared-host" "$D/modules-load" +FSTAB="$D/fstab" +LIMITS_CONF="$D/limits" +MODULES_FILE="$D/modules" +MODULES_LOAD_DIR="$D/modules-load" +GRUB_DEFAULT="$D/grub" +LOGROTATE_DIR="$D/logrotate" +HUGEPAGES_1G_DIR="$D/hugepages" +BIN_DIR="$D/bin" +printf 'hugetlbfs /dev/hugepages hugetlbfs defaults 0 0\n' >"$FSTAB" +printf '%s soft memlock unlimited\n' "$REAL_USER" >"$LIMITS_CONF" +printf 'msr\n' >"$MODULES_FILE" +printf 'msr\n' >"$MODULES_LOAD_DIR/msr.conf" +printf 'GRUB_CMDLINE_LINUX_DEFAULT="hugepagesz=2M hugepages=4 msr.allow_writes=on"\n' >"$GRUB_DEFAULT" +sudo() { + case "$1" in + systemctl) + shift + systemctl "$@" + ;; + rm) + shift + local item + for item in "$@"; do + case "$item" in -*) ;; "$D"/*) ;; *) fail 'uninstall tried to remove a path outside sandbox' ;; esac + done + command rm "$@" + ;; + nft | update-grub) ;; + rmdir) + shift + [[ "$1" == "$D"/* ]] || fail 'uninstall tried to remove a directory outside sandbox' + command rmdir "$@" || true + ;; + cp) + shift + command cp "$@" + ;; + sed) + shift + [ "$1" = -i ] || fail 'uninstall invoked an unexpected sed' + shift + command sed "$@" >"${!#}.new" && command mv "${!#}.new" "${!#}" + ;; + *) fail 'uninstall invoked an unexpected privileged command' ;; + esac +} +systemctl() { :; } +uninstall --yes >/dev/null +grep -qFx 'hugetlbfs /dev/hugepages hugetlbfs defaults 0 0' "$FSTAB" || fail 'shared uninstall rewrote existing mounts' +grep -qFx "$REAL_USER soft memlock unlimited" "$LIMITS_CONF" || fail 'shared uninstall rewrote existing memlock' +grep -qFx 'msr' "$MODULES_FILE" || fail 'shared uninstall rewrote existing MSR policy' +[ -f "$MODULES_LOAD_DIR/msr.conf" ] || fail 'shared uninstall removed existing MSR module policy' +grep -qFx 'GRUB_CMDLINE_LINUX_DEFAULT="hugepagesz=2M hugepages=4 msr.allow_writes=on"' "$GRUB_DEFAULT" || fail 'shared uninstall rewrote existing boot tuning' + +update-grub() { :; } +rm -rf "$D/data/worker" +printf 'GRUB_CMDLINE_LINUX_DEFAULT="quiet hugepagesz=2M hugepages=4 msr.allow_writes=on"\n' >"$GRUB_DEFAULT" +uninstall --yes >/dev/null +grep -qFx 'GRUB_CMDLINE_LINUX_DEFAULT="quiet"' "$GRUB_DEFAULT" || fail 'dedicated uninstall left managed boot tuning' +[ ! -f "$MODULES_LOAD_DIR/msr.conf" ] || fail 'dedicated uninstall kept MSR module policy' + +printf 'shared host parse, config, host tuning and cgroup regressions: PASS\n' diff --git a/util/shared-host.sh b/util/shared-host.sh new file mode 100644 index 0000000..80377b2 --- /dev/null +++ b/util/shared-host.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 + +_shared_host_parse() { + SHARED_HOST=false SHARED_CPU_QUOTA=0 SHARED_MEMORY_MAX_MB=0 + local profile + profile=$(jq -c 'if has("shared_host") then .shared_host else false end' "$CONFIG_JSON") || error "Cannot read shared_host." + [ "$profile" != false ] || return 0 + jq -e 'type == "object" and (keys - ["cpu_quota_percent", "memory_max_mb", "existing_host_tuning"] | length == 0) + and (.cpu_quota_percent | type == "number" and floor == . and . >= 1 and . <= 10000) + and (.memory_max_mb | type == "number" and floor == . and . >= 3072 and . <= 1048576) + and ((has("existing_host_tuning") | not) or .existing_host_tuning == "preserve")' <<<"$profile" >/dev/null || + error "shared_host requires cpu_quota_percent (integer 1-10000) and memory_max_mb (integer 3072-1048576); existing_host_tuning may only be preserve." + SHARED_HOST=true + SHARED_CPU_QUOTA=$(jq -r .cpu_quota_percent <<<"$profile") + SHARED_MEMORY_MAX_MB=$(jq -r .memory_max_mb <<<"$profile") +} + +_shared_host_config() { + [ "${SHARED_HOST:-false}" = true ] || return 0 + local content + content=$(jq --argjson cap "${THREADS_CAP:-null}" ' + .autosave = false | .cpu.priority = 1 | .cpu.yield = true | + .cpu."huge-pages" = false | .cpu."huge-pages-jit" = false | .cpu."memory-pool" = false | + .randomx."1gb-pages" = false | .randomx.rdmsr = false | .randomx.wrmsr = false | + .randomx.cache_qos = false | .randomx.numa = false | .randomx.init = ($cap // 1) | + .opencl = false | .cuda = false | + if $cap != null then + .cpu.rx as $rx | + .cpu |= with_entries(select((.value | type) != "array" and (.value | type) != "object")) | + .cpu.rx = (if ($rx | type) == "array" and ($rx | length) > 0 and all($rx[]; type == "number" and floor == . and . >= -1) + then $rx[:$cap] + else [range(0; if ($rx | type) == "number" and $rx >= 1 and $rx == ($rx | floor) then ([$rx, $cap] | min) else $cap end) | -1] end) | + .cpu["*"] = .cpu.rx + else . end + ' "$1") || error "Could not apply shared-host XMRig safeguards." + printf '%s\n' "$content" >"$1" +} + +_shared_host_merge() { + if [ "${SHARED_HOST:-false}" = true ]; then + jq -s '.[0] * (.[1] | {cpu: (.cpu // {}), randomx: (.randomx // {})})' "$@" + else + jq -s '.[0] * .[1]' "$@" + fi +} + +_shared_host_supported() { + local controllers total huge + [ "$OS_TYPE" = Linux ] || { + echo "shared_host requires Linux with systemd and cgroup v2" + return 1 + } + controllers=$(cat "${RIGFORGE_CGROUP_ROOT:-/sys/fs/cgroup}/cgroup.controllers" 2>/dev/null) || { + echo "shared_host requires a unified cgroup v2 hierarchy" + return 1 + } + case " $controllers " in *" cpu "*) ;; *) + echo "shared_host CPU controller is unavailable" + return 1 + ;; + esac + case " $controllers " in *" memory "*) ;; *) + echo "shared_host memory controller is unavailable" + return 1 + ;; + esac + command -v systemctl >/dev/null && command -v systemd-run >/dev/null || { + echo "shared_host requires systemctl and systemd-run" + return 1 + } + total=$(awk '/^MemTotal:/ {print $2}' "$MEMINFO" 2>/dev/null) + huge=$(awk '/^Hugetlb:/ {print $2}' "$MEMINFO" 2>/dev/null) + _uint_in_range "$total" 1 1099511627776 && _uint_in_range "$huge" 0 1099511627776 || { + echo "shared_host cannot account for RAM and existing HugeTLB reservations" + return 1 + } + [ "$((total - huge))" -ge "$(((SHARED_MEMORY_MAX_MB + 1024) * 1024))" ] || { + echo "shared_host must leave at least 1024 MiB outside its ceiling and existing HugeTLB pools" + return 1 + } +} + +_shared_host_verify_budget() { + local cg="${RIGFORGE_CGROUP_ROOT:-/sys/fs/cgroup}/rigforge.slice" quota period memory swap weight + read -r quota period <"$cg/cpu.max" 2>/dev/null || { + echo "shared_host CPU quota is unavailable" + return 1 + } + memory=$(cat "$cg/memory.max" 2>/dev/null) swap=$(cat "$cg/memory.swap.max" 2>/dev/null) weight=$(cat "$cg/cpu.weight" 2>/dev/null) + _uint_in_range "$quota" 1 1000000000 && _uint_in_range "$period" 1 1000000 && + [ "$((quota * 100))" -le "$((SHARED_CPU_QUOTA * period))" ] || { + echo "shared_host CPU quota is missing or exceeds the configured ceiling" + return 1 + } + _uint_in_range "$memory" 1 1099511627776 && [ "$memory" -le "$((SHARED_MEMORY_MAX_MB * 1048576))" ] && [ "$swap" = 0 ] || { + echo "shared_host memory ceiling or zero-swap control is unavailable" + return 1 + } + [ "$weight" = 10 ] || { + echo "shared_host CPU weight is not effective" + return 1 + } + printf 'effective CPU quota %s/%s us; memory %s bytes; swap 0; CPU weight 10\n' "$quota" "$period" "$memory" +} + +_shared_host_in_slice() { + local cg + cg=$(awk -F: '$1 == "0" {print $3}' "${RIGFORGE_PROC_CGROUP:-/proc/self/cgroup}" 2>/dev/null) || return 1 + case "$cg" in /rigforge.slice/*) return 0 ;; *) return 1 ;; esac +} + +_shared_host_prepare() { + [ "${SHARED_HOST:-false}" = true ] || return 0 + local reason fragment loaded state transient dropins group consumers unit="$SYSTEMD_DIR/$SERVICE_NAME.service" slice="$SYSTEMD_DIR/rigforge.slice" + reason=$(_shared_host_supported) || error "$reason; miner activation refused." + if [ -e "$unit" ] && ! grep -qFx '# RigForge shared-host profile' "$unit"; then + [ "$(jq -r '.shared_host.existing_host_tuning // empty' "$CONFIG_JSON")" = preserve ] || + error "Dedicated-to-shared transition requires existing_host_tuning: preserve; see docs/shared-host.md." + if systemctl is-active --quiet "$SERVICE_NAME"; then error "Stop the dedicated miner before applying shared_host; existing host tuning will be preserved."; fi + fi + if [ -e "$slice" ] && ! grep -qFx 'Description=RigForge shared-host resource budget' "$slice"; then + error "rigforge.slice already exists without RigForge ownership; refusing to replace it." + fi + fragment=$(systemctl show rigforge.slice -p FragmentPath --value) || error "Cannot inspect the existing resource slice." + loaded=$(systemctl show rigforge.slice -p LoadState --value) || error "Cannot inspect the existing resource slice." + if [ -z "$fragment" ] && [ "$loaded" != not-found ]; then + state=$(systemctl show rigforge.slice -p ActiveState --value) || error "Cannot inspect the resource slice state." + transient=$(systemctl show rigforge.slice -p Transient --value) || error "Cannot inspect resource slice ownership." + dropins=$(systemctl show rigforge.slice -p DropInPaths --value) || error "Cannot inspect resource slice overrides." + group=$(systemctl show rigforge.slice -p ControlGroup --value) || error "Cannot inspect the resource slice cgroup." + consumers=$(systemctl show rigforge.slice -p RequiredBy -p WantedBy --value) || error "Cannot inspect resource slice consumers." + if [ "$loaded" != loaded ] || [ "$state" != inactive ] || [ "$transient" != no ] || [ -n "$dropins$group$consumers" ]; then + error "An implicit or transient rigforge.slice already exists; refusing to change its budget." + fi + fi + if [ -n "$fragment" ] && ! grep -qFx 'Description=RigForge shared-host resource budget' "$fragment"; then + error "The effective rigforge.slice is not owned by RigForge; refusing to shadow it." + fi + mkdir -p "$WORKER_ROOT/.rigforge-shared-host" || error "Could not record preserved host tuning." + sudo tee "$slice" >/dev/null <= 1 and . <= ($cap // 1)) and + ($cap == null or ((.cpu.rx | type == "array" and length >= 1 and length <= $cap and all(.[]; type == "number" and floor == . and . >= -1)) and + .cpu["*"] == .cpu.rx and all(.cpu | to_entries[]; .key == "rx" or .key == "*" or (.value | type != "array" and type != "object"))))' "$cfg" >/dev/null || + error "Shared-host miner configuration is unsafe or stale; run apply." + fi + log "Shared host: $reason" +} + +_shared_host_run() { + if [ "${SHARED_HOST:-false}" != true ]; then + "$@" + return + fi + printf '%s\0' "${TUNE_POWER_CMD:-}" | sudo systemd-run --quiet --wait --pipe --collect --service-type=exec --slice=rigforge.slice \ + --property=Nice=19 --property=MemoryAccounting=true --working-directory="$PWD" \ + --setenv="RIGFORGE_OPERATOR=$REAL_USER" --setenv="RIGFORGE_APPLIANCE=$RIGFORGE_APPLIANCE" \ + --setenv="AUTOTUNE_TARGET=${AUTOTUNE_TARGET:-}" \ + --setenv="AUTOTUNE_SAMPLES=${AUTOTUNE_SAMPLES:-}" --setenv="AUTOTUNE_INTERVAL=${AUTOTUNE_INTERVAL:-}" \ + --setenv="AUTOTUNE_WARMUP=${AUTOTUNE_WARMUP:-}" --setenv="AUTOTUNE_MARGIN=${AUTOTUNE_MARGIN:-}" --setenv="AUTOTUNE_MODES=${AUTOTUNE_MODES:-}" \ + "$SCRIPT_DIR/rigforge.sh" shared-host-run "$@" +} + +_shared_host_exec() { + IFS= read -r -d '' TUNE_POWER_CMD || error "Missing shared-host helper context; refusing to run." + export TUNE_POWER_CMD + _shared_host_check helper + exec "$@" +} + +_shared_host_start_guard() { + [ -f "$CONFIG_JSON" ] || return 0 + _shared_host_parse + [ "$SHARED_HOST" = true ] || return 0 + [ "$OS_TYPE" = Linux ] || error "shared_host requires Linux with systemd and cgroup v2." + if ! grep -qFx '# RigForge shared-host profile' "$SYSTEMD_DIR/$SERVICE_NAME.service"; then + error "shared_host is selected but the service is not bounded; stop it and run apply first." + fi + local slice pre + slice=$(systemctl show "$SERVICE_NAME" -p Slice --value) || error "Cannot inspect the loaded miner service; run apply." + pre=$(systemctl show "$SERVICE_NAME" -p ExecStartPre --value) || error "Cannot inspect the loaded miner startup checks; run apply." + [ "$slice" = rigforge.slice ] || error "The loaded miner service is outside the shared-host budget; run apply." + case "$pre" in *'rigforge.sh shared-host-check'*) ;; *) error "The loaded miner service lacks the shared-host startup check; run apply." ;; esac +} + +_shared_host_remove() { + local slice="$SYSTEMD_DIR/rigforge.slice" cg="${RIGFORGE_CGROUP_ROOT:-/sys/fs/cgroup}/rigforge.slice" procs="" + grep -qFx 'Description=RigForge shared-host resource budget' "$slice" 2>/dev/null || return 0 + if [ -d "$cg" ]; then procs=$(find "$cg" -name cgroup.procs -exec cat {} + 2>/dev/null) || return 0; fi + [ -z "$procs" ] || return 0 + sudo systemctl stop rigforge.slice 2>/dev/null || return 0 + sudo rm -f "$slice" + sudo systemctl daemon-reload +} + +_shared_host_reload() { + if ! sudo systemctl daemon-reload; then + if [ "${SHARED_HOST:-false}" = true ]; then + sudo systemctl stop "$SERVICE_NAME" || true + error "Could not reload the shared-host unit; miner remains stopped." + fi + fi +} + +_shared_host_preserves_tuning() { + [ -d "$1/.rigforge-shared-host" ] || jq -e '.shared_host | type == "object"' "$CONFIG_JSON" >/dev/null 2>&1 +} + +_shared_host_report() { + _shared_host_parse + if [ "$SHARED_HOST" != true ]; then + echo 'Resource profile: dedicated' + return 0 + fi + printf 'Resource profile: shared host; requested CPU %s%% of one CPU, memory %s MiB\n' "$SHARED_CPU_QUOTA" "$SHARED_MEMORY_MAX_MB" + local reason group + reason=$(_shared_host_supported) || { + echo "Unsupported: $reason" + return 1 + } + reason=$(_shared_host_verify_budget) || { + echo "Not enforced: $reason; run apply" + return 1 + } + echo "$reason" + if systemctl is-active --quiet "$SERVICE_NAME"; then + group=$(systemctl show "$SERVICE_NAME" -p ControlGroup --value) + case "$group" in /rigforge.slice/*) ;; *) + echo 'Miner is outside the shared-host budget; stop it and run apply' + return 1 + ;; + esac + else + echo 'Miner is stopped; startup rechecks the controls before initialization' + fi + echo 'HugePages, MSR/cache tuning and governor changes are disabled; host settings are preserved' +} From a985c6e035b4f4e54117edaa2543107406fa50c5 Mon Sep 17 00:00:00 2001 From: "pithead-developer[bot]" <328836012+pithead-developer[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:31:37 -0600 Subject: [PATCH 6/7] Qualify historical RigForge benchmark claims (#639) * Qualify historical RigForge benchmark claims Part of #580. Implemented by an operator-run gpt-6-astra session at the owner's request. Co-Authored-By: Claude Opus 5.5 * docs: drop pre-GA coordination and restore the THP stock-gap caveat Co-Authored-By: Claude Opus 5.5 * docs: source the benchmark provenance and qualify the THP caveat Co-Authored-By: Claude Opus 5.5 * docs: match the README benchmark row to the index wording Co-Authored-By: Claude Opus 5.5 * docs: re-head after removing closing keywords from the PR body (#580 stays open) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: pithead-developer[bot] <328836012+pithead-developer[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: pithead-developer[bot] <4933481+pithead-developer[bot]@users.noreply.github.com> --- README.md | 21 +++---- docs/README.md | 2 +- docs/benchmarks.md | 152 ++++++++++++++++++++++++++++----------------- 3 files changed, 106 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index fced8a0..ce26940 100644 --- a/README.md +++ b/README.md @@ -71,18 +71,17 @@ you like to your stack's single endpoint. ## 📊 Does it actually help? -Measured on a Ryzen **7800X3D**, mining live to a real pool (not a synthetic `--bench`): +RigForge automates upstream XMRig tuning. The benefit depends on the hardware and the starting +OS configuration. -| | Stock XMRig | RigForge | | -|---|--:|--:|:--| -| **Hashrate** | 10,416 H/s | **10,779 H/s** | **+3.5%** | -| **Power** | 86.8 W | **83.5 W** | **−3.8%** | -| **Efficiency** | 120.1 H/s/W | **129.2 H/s/W** | **+7.6%** | +The [historical benchmark report](docs/benchmarks.md) records live-mining observations on a Ryzen +7800X3D and an EPYC 7642. These are illustrative results, not measurements of the RigForge baked +into Pithead 2.0.0. Reported power and efficiency use **CPU-package RAPL**, not wall power; they +do not establish whole-system efficiency or temperature gains. -Without HugePages the CPU stalls on memory, so stock XMRig draws more power for less work. RigForge is -faster and runs cooler. On a 48-core EPYC the gap is +6.6%, and RigForge matched an expert's -hand-tuned config while avoiding a prefetch setting that halves RandomX on that chip. Full method, -both CPUs, and the caveats: [Benchmarks](docs/benchmarks.md). +No percentage uplift is established here for the current candidate. Numeric launch claims require +the [candidate comparison protocol](docs/benchmarks.md#candidate-comparison-protocol) and a dated +report for the exact baked revision. --- @@ -133,7 +132,7 @@ doesn't prove — deploying from a git checkout at the release tag is the strong |---|---| | **[Getting Started](docs/getting-started.md)** | Prerequisites, install, first-run setup, the Linux reboot, and verification. | | **[Hardware Requirements](docs/hardware.md)** | Worker CPU / RAM / HugePages requirements and the per-CPU tuning profiles. | -| **[Benchmarks](docs/benchmarks.md)** | Measured stock-vs-tuned hashrate and efficiency on real hardware, with the method and caveats. | +| **[Benchmarks](docs/benchmarks.md)** | Historical stock-vs-tuned observations, CPU-package (RAPL) power only, not wall power, and the candidate comparison protocol. | | **[Configuration](docs/configuration.md)** | Every `config.json` key and default, and how the XMRig config is generated. | | **[Shared-host profile](docs/shared-host.md)** | Opt-in CPU and memory bounds for co-resident workloads. | | **[Operations & Maintenance](docs/operations.md)** | The full command reference, service management, logs, upgrades, and troubleshooting. | diff --git a/docs/README.md b/docs/README.md index 2a08ba7..3ae7512 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ individual topics once you're up and running. |---|---| | [Getting Started](getting-started.md) | Prerequisites, installation, first-run setup, the Linux reboot, and how to verify the worker is mining. | | [Hardware Requirements](hardware.md) | Worker CPU / RAM / HugePages requirements and the per-CPU tuning profiles RigForge applies. | -| [Benchmarks](benchmarks.md) | Measured stock-vs-tuned hashrate and efficiency (H/s per watt) on real hardware, mining live, with the method and caveats. | +| [Benchmarks](benchmarks.md) | Historical stock-vs-tuned observations, CPU-package (RAPL) power only, not wall power, and the candidate comparison protocol. | | [Configuration](configuration.md) | Every `config.json` key and default, minimal vs. advanced setups, and how the XMRig config is generated. | | [Operations & Maintenance](operations.md) | The full command reference, service management, logs, upgrades, and troubleshooting. | | [How It Works](how-it-works.md) | What the script actually does: dependencies, compile-from-source, HugePages, MSR, NUMA, the governor, and the systemd service. | diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 7c1124d..31f4cc5 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,27 +1,37 @@ # Benchmarks -What RigForge's tuning buys you over stock XMRig. Every number here is measured on real hardware, mining -live, not synthetic `--bench` runs. +RigForge automates setup and tuning of upstream XMRig; it is not a separate mining engine. -> TL;DR: across two very different CPUs, RigForge's one-command tuning beats stock XMRig by +3.5% -> (desktop Ryzen 7800X3D) to +6.6% (48-core EPYC 7642) in hashrate, and +7.6% / +6.0% in efficiency (H/s -> per watt). On the EPYC it also matched an expert's hand-tuned config, and auto-dodged a CPU-specific -> landmine: a prefetch setting that halves RandomX there but wins on the X3D. +**Evidence status, 2026-11-02: historical observations only.** The measurements below were first +committed in `0790840` (#107, 2026-06-12) and are retained unchanged from the report as reviewed +in #580 at revision `ff4e60f8a8e48ac791c4372252edd2ec0308eb20`. Neither is the tested RigForge revision. +The measurement date, baked RigForge SHA, full upstream XMRig commit and build flags are not recorded +in that report. It does not validate the RigForge baked into Pithead 2.0.0 or establish a current +candidate's percentage uplift. -## How it's measured +All power readings below are **CPU-package RAPL**, and efficiency is H/s per CPU-package watt. +Wall power and wall energy are unavailable in this report. These readings do not establish +whole-system efficiency, electricity savings or temperature improvements. -Each configuration runs as its own XMRig process mining to the live pool. After a warm-up to steady -state, we sample the hashrate (XMRig's HTTP API, 60-second average) and CPU-package power (RAPL energy -counter) over several-minute windows, repeated across rounds. RandomX is low-variance and both rigs were -thermally steady, so the means are solid (hashrate within ~0.1%). +## Historical method and its limits -The baselines: +The historical report describes separate XMRig processes mining live, with warm-up followed by +several-minute sampling windows across rounds. Hashrate came from XMRig's HTTP API (60-second +average), and package power from the RAPL energy counter. This was not a standard RandomX +correctness benchmark. Individual trials, spread and uncertainty are not published here, so the +reported means alone do not establish repeatability or a precision bound. -- Stock XMRig: upstream `./xmrig` on a fresh box: no explicit HugePages, prefetcher MSRs at firmware - default, default governor. Transparent HugePages stay at Ubuntu's `madvise` default, as a real user - would have. -- RigForge: `setup`'s kernel work (2 MB + 1 GB HugePages, the per-family MSR prefetcher preset, - `performance` governor) plus the winning knobs from a full live `tune`. +The reported configurations were: + +- Stock XMRig: upstream automatic configuration, described as a fresh OS with no explicit + HugePages reservation, firmware-default prefetcher MSRs, default governor and Ubuntu's + `madvise` Transparent HugePages setting. +- RigForge: `setup`'s kernel work (2 MB + 1 GB HugePages, per-family MSR prefetcher preset, + `performance` governor) plus the winning knobs from a live `tune`. + +A verified clean-baseline restoration between arms, counterbalanced order, identical source/build +and donation settings, and difficulty-weighted accepted work are not documented. The figures below +remain illustrative historical observations, not a controlled comparison of the frozen candidate. ## Rig 1 — Ryzen 7800X3D (desktop) @@ -31,7 +41,7 @@ The baselines: | **RAM** | 2 × 16 GB G.Skill DDR5-**6000** (EXPO, dual-channel) | | **OS / algo** | Ubuntu 24.04, kernel 6.8 · live Monero **rx/0** | -| Configuration | Hashrate (H/s) | Power (W) | Efficiency (H/s per W) | +| Configuration | Hashrate (H/s) | CPU-package power (W, RAPL) | Efficiency (H/s per CPU-package W) | |---|--:|--:|--:| | **Stock XMRig** (no tuning) | 10,416 | 86.8 | 120.1 | | **RigForge** — system tuning only | 10,756 | 83.6 | 128.7 | @@ -39,59 +49,87 @@ The baselines: | **RigForge** — tuned (efficiency) | 10,779 | 83.5 | **129.2** | | **Stock → tuned** | **+3.5%** | **−3.8%** | **+7.6%** | -Stock XMRig here burns more watts for less work. Without HugePages the CPU stalls on memory, drawing -~87 W to produce fewer hashes; tuned is faster and cooler. Performance and efficiency tuning landed on the -same config: RigForge measured the power and found this chip pins ~84 W in any all-core setup, so there's -no hashrate-for-watts trade-off to make, and it didn't invent one. +The reported performance and efficiency tuning runs selected the same configuration. The table +records higher local hashrate and lower package power in those tuned runs; it does not isolate +which tuning change caused the difference or measure cooling or wall-power savings. -## Rig 2 — EPYC 7642 (48-core server) · RigForge vs an expert hand-tune +## Rig 2 — EPYC 7642 (48-core server) -This box was already running a hand-tuned miner (the worker an operator had configured for this EPYC by -hand) at 36,860 H/s. So it's not just "RigForge vs naive XMRig"; it's RigForge's one-command auto-tune -against a human who tuned it themselves. +The historical report also included a hand-tuned worker. It used XMRig 6.25, while the RigForge +run used 6.26. This version mismatch prevents attributing their similar hashrates to automation +versus expert tuning; it is not evidence of equivalence. | | | |---|---| | **CPU** | AMD **EPYC 7642** — 48C/96T, 256 MiB L3, **4 NUMA nodes**, 2300 MHz | | **RAM / OS / algo** | 62 GB · Ubuntu 24.04, kernel 6.8 · live Monero **rx/0** | -| Configuration | Hashrate (H/s) | Power (W) | Efficiency (H/s per W) | +| Configuration | Hashrate (H/s) | CPU-package power (W, RAPL) | Efficiency (H/s per CPU-package W) | |---|--:|--:|--:| | **Stock XMRig** (no tuning) | 34,599 | 228.4 | 151.5 | | **Expert hand-tuned** worker (XMRig 6.25) | 36,860 | 230.2 | 160.1 | | **RigForge** — tuned (XMRig 6.26) | **36,866** | 229.5 | **160.6** | | **Stock → RigForge** | **+6.6%** | ~flat | **+6.0%** | -- RigForge matched the human expert (within 0.02%), and did it with a newer XMRig (6.26 vs 6.25), - `cpu.yield` off (the expert left it on), and 5× fewer HugePages (266 vs 1,280 reserved, both hitting - 100%): the same result with ~2 GB less RAM tied up. -- The auto-tune dodged a landmine. On this EPYC, prefetch mode 2 halves the hashrate (~17,900 H/s), the - exact opposite of the 7800X3D, where mode 2 is the winner. A fixed "golden profile" would get one of - these two chips badly wrong; the per-CPU live tune measured it and stayed on the right mode for each. -- The HugePages win is bigger here (+6.6% vs the X3D's +3.5%): a 4-NUMA EPYC with a per-node dataset leans - much harder on huge pages than a single-die desktop chip. Efficiency and performance tuning again - converged (power ~230 W in any config). +Other reported differences included `cpu.yield` off for RigForge and on for the hand-tuned worker, +and 266 versus 1,280 reserved HugePages (both reported full HugePages use). Prefetch mode 2 was +reported at approximately 17,900 H/s on this EPYC, about half the tuned rows above, while it was selected on the desktop. These +observations motivate measuring each machine separately, not a universal tuning claim. ## Caveats (read before quoting a number) -- Two CPUs, two systems, and RandomX gains vary a lot. A desktop X3D and a 48-core EPYC already differ by - more than 3× in raw hashrate; your CPU, RAM speed, NUMA layout, and kernel all matter. Treat the - percentages as illustrative, not a guarantee. -- Modern kernels narrow the stock gap. Ubuntu 24.04's Transparent HugePages (`madvise`) back some of even - the "stock" allocation with 2 MB pages, so the stock baseline is closer to tuned than on an older kernel - or with THP off. Don't expect the 20–30% some older write-ups quote. -- `setup` does the heavy lifting; `tune` refines. Most of the win is the system tuning; the knob search - confirms you're at the optimum (and, as the EPYC shows, keeps you off the landmines) rather than adding - a big jump on top. - -## Reproduce it - -```bash -# bare baseline: stock upstream XMRig, no system tuning -./xmrig -o -u # no HugePages reserved, MSRs default - -# RigForge: one command does the setup + tuning -sudo ./rigforge.sh # build + HugePages + MSR + governor + service -sudo ./rigforge.sh tune --now --long # full live tune (or just let setup's defaults ride) -sudo ./rigforge.sh doctor # confirms HugePages 100%, MSR applied, governor, clocks -``` +- CPU, RAM, firmware, socket count, NUMA layout, kernel and starting OS state affect results. + Missing fields in the historical record must remain unknown, not inferred from a CPU model. +- Modern kernels likely narrow the stock gap. Ubuntu 24.04's `madvise` Transparent HugePages can back + some of even the stock allocation with 2 MB pages, so the stock baseline may sit closer to tuned than + on an older kernel or with THP off; these tables did not measure that effect. Don't expect the + 20–30% some older write-ups quote. +- Stopping the RigForge service does not undo persistent HugePages, MSR, governor, boot-parameter + or service changes. A stock run on that OS is not automatically a clean baseline. +- These tables cannot support a universal uplift, expert-equivalence claim or wall-efficiency claim. + Keep launch percentages removed or qualified until the exact candidate has suitable evidence. + +## Candidate comparison protocol + +This work remains pending under #580 and gates numeric launch claims. Benchmark/stress tooling +belongs to #533, and shared-host coexistence to #579 with #577/#578 prerequisites. No tuner change is +needed for this evidence task. + +1. **Freeze identity and scope.** Record the Pithead source revision and image identity, baked + RigForge SHA, upstream XMRig commit, compiler, dependencies, build flags, source patches and binary + identity. Test the baked revision, not a newer tag. A required RigForge code change needs a new + RigForge release and Pithead re-pin before it is the baked candidate. Use one representative + desktop and one server/NUMA + system if making claims about both; publish each separately. Record actual sockets, CPU, RAM, + firmware, kernel, thread allocation, ambient and thermal conditions, and measurement scope. +2. **Reserve and predeclare.** Use separate, uncontended hardware through the approved bench-ci + reservation/restoration workflow; leave the active RC/soak host untouched. Before reading results, + declare stabilization, measurement duration, repeat count, exclusions and the smallest effect of + interest. Freeze each arm's selected configuration after tuning and keep search windows out of + evaluation. A short pilot may size the run: 4–6 hours per host plus restores can be a starting point + for hashrate/power, but accepted-share precision may need longer. +3. **Match arms and restore.** Compare upstream stock automatic configuration on a clean OS with + the normal RigForge setup/tune workflow. Keep XMRig source/build, donation, endpoint and workload + fixed; disclose intended tuning differences. RigForge changes XMRig's donation default/minimum; + use a common supported nonzero donation and verify its effective value in both arms. Include a + properly hand-tuned stock arm only for an automation-versus-expert claim. Restore a reproducible + clean OS image/snapshot and verify the clean baseline between arms, including HugePages, MSRs, + governor, kernel parameters and services; reboot where needed. Record the restoration result + rather than treating a stopped service as a reset. +4. **Repeat and counterbalance.** For example, run stock → RigForge → RigForge → stock, then reverse + that order, restoring the required starting state each time. Run a full standard RandomX benchmark + for correctness/repeatability, retaining completion and reference-hash verification, and live + mining for sustained operational results. A smoke pass or one favorable window is not comparative + evidence; the existing benchmark wrapper's parsed H/s alone does not verify the reference hash. +5. **Measure and retain.** Capture local H/s, difficulty-weighted accepted work, rejects/stales, + temperatures, clocks, throttling, wall energy and H/s per wall watt. Keep CPU-package RAPL separate; + label unavailable wall readings unavailable. Retain all trials and exclusions, spread/uncertainty, + material confounders and final restoration evidence. Keep raw logs, machine identities and local + topology private; publish only sanitized evidence references and command outcomes. +6. **Publish and decide.** Produce a dated, reproducible report with tested configurations, all arms + and a per-machine statement of what each result supports. Check that repeated arms are consistent + and report uncertainty against the predeclared effect bound. An inconclusive result or a percentage + that does not reproduce means qualifying/removing that launch claim, not choosing a favorable run. + “No detected difference” does not establish “no overhead” without sufficient precision. Apply the + same limits to README, website and organization copy: automated upstream XMRig tuning, package + versus wall power, and historical versus current-candidate measurements must remain distinct. From bce60120510a9141ae29009897684b18c58d57a2 Mon Sep 17 00:00:00 2001 From: "pithead-developer[bot]" <4933481+pithead-developer[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:03:15 -0600 Subject: [PATCH 7/7] Record the live algorithm through doctor-report and dedupe merged docs Doctor emits its advisory algorithm line via _ck_info with id pool-algorithm so doctor --json carries it; tests/test-pool-fields.sh asserts the JSON row for a non-default algo and its absence for rx/0 (red pre-fix). Status and doctor share one _pool_algorithm predicate. Drops the duplicate docs paragraphs and the stray whitespace edits from the develop merge, and folds the run.sh suite call to the one-line && ok || bad idiom so the file-budget ceiling for run.sh holds with the blank line restored. Co-Authored-By: Claude Code --- docs/pithead-integration.md | 3 --- rigforge.sh | 15 ++++++++------- tests/README.md | 3 --- tests/run.sh | 4 ++-- tests/test-pool-fields.sh | 15 ++++++++++++++- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/pithead-integration.md b/docs/pithead-integration.md index 3aa5ae3..81cdd4b 100644 --- a/docs/pithead-integration.md +++ b/docs/pithead-integration.md @@ -113,9 +113,6 @@ pithead#235): Coordinators can use `rigforge.appliance` to hide git-based upgrade controls on appliance rigs and show `rigforge.stamp` as the baked release provenance instead. -Coordinators can use `rigforge.appliance` to hide git-based upgrade controls on appliance rigs and -show `rigforge.stamp` as the baked release provenance instead. - When a token is set, `:8081` accepts either the exact bearer for existing clients or the lowercase hex HMAC-SHA256 derived with that token as the key and `rigforge:api-read:v1` as the message. The derived bearer grants reads only: `:8082` rejects it and continues to require an exact diff --git a/rigforge.sh b/rigforge.sh index 6063978..82ba963 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -3962,11 +3962,11 @@ mac_disable() { fi } -_print_pool_algorithm() { - jq -r '.algo | select(type == "string" and . != "" and . != "rx/0") | " Algorithm: \(.)"' 2>/dev/null || true +_pool_algorithm() { + jq -r '.algo | select(type == "string" and . != "" and . != "rx/0")' 2>/dev/null || true } _status_api_summary() { - local body hs pool up acc rej hp + local body hs pool up acc rej hp pa body=$(_read_api_summary) if [ -z "$body" ]; then echo "RigForge: worker API not reachable at 127.0.0.1:8080 (miner stopped or still starting)." @@ -3980,7 +3980,8 @@ _status_api_summary() { [ -n "${hs:-}" ] || return 0 # half-up API / unparseable body: stay quiet, platform block follows printf ' %-10s %s H/s\n' "Hashrate:" "$hs" printf ' %-10s %s\n' "Pool:" "$pool" - printf '%s' "$body" | _print_pool_algorithm + pa=$(printf '%s' "$body" | _pool_algorithm) + [ -n "$pa" ] && printf ' Algorithm: %s\n' "$pa" printf ' %-10s %s\n' "Uptime:" "$(_render_duration "$up")" printf ' %-10s %s accepted / %s rejected\n' "Shares:" "$acc" "$rej" [ -n "$hp" ] && printf ' %-10s %s\n' "HugePages:" "$hp" @@ -5417,7 +5418,6 @@ doctor() { fi log "Checking the worker (read-only)..." _config_apply_check - case "$(jq -r 'if (.control | tostring) | test("^(enabled|true|on)$") then "enabled" else (.api // "disabled") end' "$CONFIG_JSON" 2>/dev/null || true)" in # #507: doctor has no parse_config, so it re-derives control's api implication here enabled | true | on) local refresh_state @@ -5439,7 +5439,7 @@ doctor() { fi if [ -f "$CONFIG_JSON" ]; then - local pc_st="" pc_pool="" pc_n1="" pc_n2="" pool0 ph pp proxy daemon_pool + local pc_st="" pc_pool="" pc_n1="" pc_n2="" pool0 ph pp proxy daemon_pool pa pool0=$(jq -r '.pools[0].url // empty' "$CONFIG_JSON" 2>/dev/null || true) # Advisory only: a listening TCP socket does not prove SOCKS/Tor or mining works. while IFS= read -r proxy; do @@ -5454,7 +5454,8 @@ doctor() { _tcp_probe "$ph" "$pp" || _ck_warn "configured SOCKS proxy is unreachable (TCP connect failed) — check pools[].socks5 and install/start Tor if needed" socks-proxy done < <(jq -r '[.pools[]? | select(.enabled != false) | .socks5 | select(. != null and . != "")] | unique[] | if type == "string" and (test("[[:cntrl:]]") | not) then . else "invalid proxy" end' "$CONFIG_JSON" 2>/dev/null) if [ "$svc_up" = y ]; then - ACCESS_TOKEN="$(jq -r '.ACCESS_TOKEN // empty' "$CONFIG_JSON" 2>/dev/null || true)" _read_api_summary | _print_pool_algorithm + pa=$(ACCESS_TOKEN="$(jq -r '.ACCESS_TOKEN // empty' "$CONFIG_JSON" 2>/dev/null || true)" _read_api_summary | _pool_algorithm) + [ -n "$pa" ] && _ck_info "Algorithm: $pa" pool-algorithm if ! ACCESS_TOKEN="$(jq -r '.ACCESS_TOKEN // empty' "$CONFIG_JSON" 2>/dev/null || true)" _check_donation; then issues=$((issues + 1)) fi diff --git a/tests/README.md b/tests/README.md index 5b3bd27..59d3882 100644 --- a/tests/README.md +++ b/tests/README.md @@ -57,9 +57,6 @@ CI median/p90, failures and occupied hours; missing evidence leaves rollout acce The opt-in `shared-host` phase of `e2e-pithead.sh` is not part of `all` and runs as a `rigforge`/`tier4-e2e` bench-ci job; [docs/shared-host.md](../docs/shared-host.md) describes what it checks and restores. -The opt-in `shared-host` phase of `e2e-pithead.sh` is not part of `all` and runs as a `rigforge`/`tier4-e2e` -bench-ci job; [docs/shared-host.md](../docs/shared-host.md) describes what it checks and restores. - `tests/test-sister-reconcile.sh`, registered in `tests/run.sh`, checks startup failures, bounded listener probes (including IPv6 and failed transports), and apply's failure result while retaining later reconciles. The hosted Ubuntu 24.04 `Stock home setup` jobs run historical and current revisions on separate diff --git a/tests/run.sh b/tests/run.sh index a9403b0..c99b74e 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -379,8 +379,7 @@ assert_eq "second-pool socks5 stays on the second pool (#400)" "$(PJ "$c" | jq - # A pool may carry BOTH a pin and a proxy — the two re-attach passes must not clobber each other. c="$(mkconf p_s5fp "{ \"pools\": [{\"url\":\"h:443\",\"tls\":true,\"tls-fingerprint\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"socks5\":\"127.0.0.1:9050\"}] }")" assert_eq "socks5 and tls-fingerprint coexist (#400)" "$(PJ "$c" | jq -c '[(.[0]."tls-fingerprint" != null), (.[0].socks5 == "127.0.0.1:9050")]')" "[true,true]" -bash "$ROOT/tests/test-pool-fields.sh" "$SCRIPT" "$ROOT/tests/contract/v1/feed.json" -assert_rc "XMRig optional pool fields round-trip and validate (#527)" "$?" 0 +bash "$ROOT/tests/test-pool-fields.sh" "$SCRIPT" "$ROOT/tests/contract/v1/feed.json" && ok "XMRig optional pool fields round-trip and validate (#527)" || bad "XMRig optional pool fields round-trip and validate (#527)" "suite failed" # #400 validation: the socks5 address gets the SAME host:port rules as the pool url. Each of these # is rejected for the pool url already; assert the proxy is judged by the same standard. c="$(mkconf p_s5noport "{ \"pools\": [{\"url\":\"h:3333\",\"socks5\":\"127.0.0.1\"}] }")" @@ -499,6 +498,7 @@ assert_rc "no pools rejected" "$?" "1" c="$(mkconf p_emptypools "{ \"pools\": [] }")" parse_rc "$c" "$ROOT" assert_rc "empty pools array rejected" "$?" "1" + # Every config field is validated — bad input fails fast with a clear message rather than producing a # config XMRig would choke on. echo "== unit: config field sanitization ==" diff --git a/tests/test-pool-fields.sh b/tests/test-pool-fields.sh index 60ec69a..024d453 100644 --- a/tests/test-pool-fields.sh +++ b/tests/test-pool-fields.sh @@ -126,10 +126,17 @@ summary_output() { systemctl() { [ "${1:-}" = is-active ]; } lscpu() { printf 'Model name: fixture cpu\n'; } _check_donation() { return 0; } - doctor || true + if [ "$entrypoint" = doctor ]; then + doctor || true + else + doctor_json 2>/dev/null || true + fi fi ) 2>&1 } +row_count() { + printf '%s' "$1" | jq '[.checks[] | select(.id == "pool-algorithm")] | length' 2>/dev/null || echo invalid +} write_config "$D/doctor.json" "$LEGACY" for entrypoint in status doctor; do @@ -139,6 +146,12 @@ for entrypoint in status doctor; do out=$(summary_output rx/0 "$entrypoint") absent "$entrypoint omits the default algorithm" "$out" "Algorithm:" done +eq "doctor --json records a row for a live non-default algorithm" \ + "$(row_count "$(summary_output rx/wow doctor-json)")" "1" +has "doctor --json row message names the live algorithm" \ + "$(summary_output rx/wow doctor-json)" '"message": "Algorithm: rx/wow"' +eq "doctor --json records no row for the default algorithm" \ + "$(row_count "$(summary_output rx/0 doctor-json)")" "0" jq 'del(.algo)' "$FEED" >"$D/summary.json" out=$( ( source "$SCRIPT"