Skip to content

Commit 23fcd27

Browse files
committed
release: v1.12.0 (promote develop to main via merge)
2 parents 22dd8f2 + 3220f57 commit 23fcd27

12 files changed

Lines changed: 387 additions & 46 deletions

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,50 @@ All notable changes to RigForge are documented here. The format is based on
77

88
## [Unreleased]
99

10+
## [1.12.0] - 2026-07-19
11+
12+
The pithead#597 producer release: the control-upgrade `/status` contract a one-click worker
13+
upgrade can poll without string-matching, a fail-closed anti-beacon throttle, corrected
14+
security docs, and the remote-upgrade chain codified as a repeatable real-hardware e2e phase.
15+
16+
### Added
17+
18+
- **`control-upgrade` status contract: `started` / `noop` / `throttled` + version echo (#320).**
19+
Consumer findings from pithead's one-click worker upgrade (pithead#597): the oneshot now writes a
20+
non-terminal `started` record the moment it claims a staged intent (so a poller can tell mid-build
21+
from queued, and a `started` that outlives the oneshot means the run was lost); the already-on-target
22+
refusal reports `noop` and the throttle window reports `throttled` instead of collapsing into
23+
`failed`; a successful upgrade's `reason` names the landed version. Additive to the ADR 0002 D3/D6
24+
model — D6 amended in place, contract documented in Operations › Writable control path.
25+
- **`tests/e2e-real.sh` grows an `upgrade` phase (#322).** Codifies the real-hardware
26+
control-upgrade run that caught both real bugs in this chain (#308's dubious-ownership silent git
27+
death, #318's origin/HEAD refusal) — invisible to the unit suite by design, since it stubs git. A
28+
noop leg proves the wire → path unit → root oneshot → `/status` round trip; a rollback leg forges
29+
a local tag unreachable from `origin/main` and asserts the D10 refusal rolls back with the tree,
30+
`VERSION`, and miner intact and the D6 throttle stamped; an opt-in `E2E_UPGRADE_TARGET` leg drives
31+
a real forward upgrade. Runs inside `all` between `control` and `perf`, with the same
32+
snapshot-and-revert guarantees. Also the producer half of pithead#597's cross-repo gate.
33+
34+
### Fixed
35+
36+
- **The anti-beacon throttle fails CLOSED on an unusable state dir (#321).** It used to proceed
37+
unthrottled (with a warn) when it couldn't take its lock — inverting the guard's whole purpose on
38+
exactly the kind of degraded box an attacker might arrange. Now the upgrade is refused with a
39+
`failed` status whose reason names the throttle state, distinct from `throttled` so a consumer
40+
doesn't retry-later forever against a broken rig.
41+
- **Disabling the control path re-stops the receiver explicitly.** On the v1.12.0 pre-tag gate,
42+
a loaded systemctl transiently dropped the stop half of `disable --now`: the unit files were
43+
removed but the running receiver was left orphaned — a live write surface on a rig whose config
44+
said control was off. The disable path now issues one explicit re-stop (no-op when the stop
45+
landed). The `control` e2e phase also gained the polled receiver-up gate the `upgrade` phase
46+
ships with — its single-shot check hit the same pinned-CPU cold-start race on the gate run.
47+
- **Security-doc drift on the remote-upgrade trust model (#321).** SECURITY.md claimed the remote
48+
path is "verified by SHA256SUMS hash only" — it never was; the real anchor is the ADR 0002 D5
49+
design (git checkout of the release tag over TLS, `origin/main` ancestry, immutable releases), and
50+
the doc now says so. ADR 0002 D8's "a flock serializes concurrent triggers" corrected in place:
51+
the only flock guards the throttle stamp; serialization is `Type=oneshot` + newest-wins spool
52+
draining.
53+
1054
## [1.11.2] - 2026-07-18
1155

1256
Consistency-audit patch: CLI-surface and docs cleanups, plus a real fix to the remote-upgrade

RELEASING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ promoted to `main` and tagged. The steps below build the release commit on `deve
2929
sudo reboot # HugePages (1G + GRUB cmdline) take effect on boot; reconnect
3030
sudo bash tests/e2e-real.sh verify # doctor (HugePages/MSR/governor/service) + bench (real H/s) + a short tune + a live auto-tune pass
3131
sudo bash tests/e2e-real.sh control # the writable control path (#236) against real systemd: enable, POST a change, poll to applied, revert
32+
sudo bash tests/e2e-real.sh upgrade # the remote-upgrade chain (#308/#322) with REAL git: noop + refused-tag rollback legs, revert (opt-in forward leg: E2E_UPGRADE_TARGET=vX.Y.Z)
3233
sudo bash tests/e2e-real.sh perf # offline bench vs the committed per-host baseline + best-ever history (the release perf gate)
3334
sudo bash tests/e2e-real.sh teardown # uninstall + assert a clean revert
3435
```

SECURITY.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,16 @@ the key-custody burden wasn't worth it for this project's users, who deploy from
121121
that trade ever changes (a real download-based user base), the signing machinery lives in the
122122
git history of `.github/workflows/release.yml` (#137, #205).
123123

124-
The same trust boundary governs the planned remote-upgrade path (`control-upgrade`, #308 — opt-in,
125-
off by default). There a rig would fetch a release and run it as root unattended, verified by
126-
`SHA256SUMS` hash only. That path removes the human who would otherwise eyeball a bad release, so it
127-
inherits the origin gap above as a deliberately accepted risk: a full GitHub-account compromise could
128-
push malicious root code across a fleet. We accept that rather than plan around a compromise of
129-
GitHub itself — the proportionate mitigation is hardening the account (hardware-key 2FA, tight
130-
release and branch-protection permissions), not re-adding release signing.
124+
The same trust boundary governs the remote-upgrade path (`control-upgrade`, #308 — opt-in, off by
125+
default, [ADR 0002](docs/adr/0002-remote-worker-upgrade.md)). There a rig fetches a release and runs
126+
it as root unattended — but not via the release bundles or `SHA256SUMS` at all: the fetch is a
127+
**git checkout of the release tag over TLS to GitHub**, gated on the tag's commit being reachable
128+
from `origin/main` and on GitHub's immutable releases locking the tag→commit binding. Checksums play
129+
no role on that path (ADR 0002 D5). It removes the human who would otherwise eyeball a bad release,
130+
so it inherits the origin gap above as a deliberately accepted risk: a full GitHub-account
131+
compromise could push malicious root code across a fleet. We accept that rather than plan around a
132+
compromise of GitHub itself — the proportionate mitigation is hardening the account (hardware-key
133+
2FA, tight release and branch-protection permissions), not re-adding release signing.
131134

132135
For the strongest guarantee available today, deploy from a git checkout at the release tag —
133136
the commit hash is the integrity anchor, and the XMRig source it builds is itself pinned and

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.11.2
1+
1.12.0

docs/adr/0002-remote-worker-upgrade.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ The receiver (`util/control-server.py`) gains a `POST /upgrade` distinct from `/
3333

3434
### D3. Unprivileged receiver stages only; the privileged oneshot fetches and runs
3535

36-
As in ADR 0001 D2, the network-facing receiver stays unprivileged (`DynamicUser`): it authenticates, checks the flag, and **stages** the request; it never fetches or runs code itself. The privileged path-triggered oneshot does the fetch-and-upgrade through the existing gated flow. The response is `202 Accepted` with a change id; the consumer polls `/status` for the terminal outcome.
36+
As in ADR 0001 D2, the network-facing receiver stays unprivileged (`DynamicUser`): it authenticates, checks the flag, and **stages** the request; it never fetches or runs code itself. The privileged path-triggered oneshot does the fetch-and-upgrade through the existing gated flow. The response is `202 Accepted` with a change id; the consumer polls `/status` to a terminal outcome (a non-terminal `started` appears first while the oneshot runs — #320, see D6).
3737

3838
### D4. Dashboard-supplied target, bounded by monotonic + reachability guards
3939

@@ -43,7 +43,7 @@ The staged body is tiny: `{"version": "vX.Y.Z"}`, and it **is** the target. The
4343
- **Reachable-from-main** — refuse a commit that is not an ancestor of `origin/main` (D10; amended by #318).
4444
- **Immutable releases + tag protection** (D10) make the tag→commit binding a platform guarantee.
4545

46-
The rig still `git`-fetches the tag directly from GitHub — the code has to come from somewhere, and that fetch happens only on an actual upgrade (rare, throttled), not on every trigger — but it makes no separate version-check dial. Anything that fails a guard is terminal `failed`. This trades away rig-*independent* confirmation of "is this THE latest" (the dashboard, over Tor, is the deriver) for privacy, fewer moving parts, and no fleet rate-limit coupling; the anti-rollback + reachability guards are what make trusting a supplied target safe.
46+
The rig still `git`-fetches the tag directly from GitHub — the code has to come from somewhere, and that fetch happens only on an actual upgrade (rare, throttled), not on every trigger — but it makes no separate version-check dial. Anything that fails a guard ends terminal: `failed`, except the two benign refusals distinguished since #320`noop` (already on the target) and `throttled` (inside the D6 window). This trades away rig-*independent* confirmation of "is this THE latest" (the dashboard, over Tor, is the deriver) for privacy, fewer moving parts, and no fleet rate-limit coupling; the anti-rollback + reachability guards are what make trusting a supplied target safe.
4747

4848
### D5. Trust model: hash-only, git-tag checkout, no signing (settled)
4949

@@ -55,6 +55,8 @@ GitHub is therefore accepted as the trust root. The residual risk is explicit: a
5555

5656
`control-upgrade` is fail-closed and every step is rollback-guarded, reusing the upgrade flow's existing rebuild-only-if-the-pin-changed logic. A **pre-dial throttle stamp** on the rig bounds how often it will reach out to GitHub, so a looping or hostile consumer cannot turn the fleet into a beacon or a request amplifier. Outcomes use the same `/status` surface as control-apply, extended with a **`failed`** terminal (with reason) alongside `applied` / `rolled_back`, so a pre-apply refusal (non-latest, throttled, fetch/build error) surfaces a clear terminal result to the consumer's poll rather than hanging in a non-terminal state.
5757

58+
*Amended (#320, consumer feedback from pithead #597):* the status vocabulary grew three additive members so a poller never has to string-match `reason`. A non-terminal **`started`** is written the moment the oneshot claims the intent (D8 move done), so "mid-run" and "oneshot died mid-run" are distinguishable from "queued"; **`noop`** replaces `failed` for the already-on-target refusal (idempotent, not an error); **`throttled`** replaces `failed` for the D6 throttle refusal (retry-later, not an error). `applied`'s `reason` echoes the landed version. All other refusals stay `failed`.
59+
5860
### D7. Auth and source-pinning inherited from ADR 0001 D4/D9
5961

6062
`/upgrade` reuses the control path's Bearer `ACCESS_TOKEN` and the `api_allow_from` source pin (which D1 already requires for `control: enabled`), and the same nftables scoping (#142). The stack host is the only trusted writer; the miner never accepts an upgrade trigger from a miner-advertised or arbitrary host. The cleartext-token-on-LAN posture (ADR 0001 D9) applies unchanged — the mining LAN is the trust boundary.
@@ -71,7 +73,7 @@ The **root builder oneshot cannot be meaningfully sandboxed**, and the earlier D
7173

7274
It comes from the **verb**, which treats the staged file as untrusted input: `systemd.path` validates nothing, so the oneshot's first act is to `rename()` the intent into a root-owned `processing/` dir the `DynamicUser` cannot write — freezing it against any swap — and *then* refuse a symlink and parse it as a claim (a single whitelisted field, `version` matching `^v[0-9]+\.[0-9]+\.[0-9]+$`, never sourced/evaled). Doing the move first is deliberate: checking for a symlink *before* the move would be a TOCTOU (the `DynamicUser` owns the spool and could swap the file in the window); after the move the file lives in a root-only dir and can't be swapped. That plus the D4/D6/D10 validation (target-newer-and-reachable bounds, throttle, anti-rollback) and a **rollback that reverts a failed forward build** — not just a failed liveness check, so a build error never leaves the tree pinned to an unbuilt release — is where this path is actually defended.
7375

74-
`systemd.path` validates nothing about the staged file — it only watches for its existence — so the spool file is fully attacker-controlled input from the root unit's perspective (the same "observed content is data, not a command" boundary, in code). Defenses: the receiver writes atomically (`rename()` into place, `StateDirectoryMode=0700`); the root oneshot's **first action is `rename()` the trigger into a root-owned dir the `DynamicUser` cannot write** (atomically removing any swap/symlink/TOCTOU window), reads it once with `O_NOFOLLOW`, and parses it as a claim — a single whitelisted field (tag matching `^v[0-9]+\.[0-9]+\.[0-9]+$`), never sourced/evaled, never passed unquoted to a shell, the repo URL hardcoded in the unit. A `flock` serializes concurrent triggers.
76+
`systemd.path` validates nothing about the staged file — it only watches for its existence — so the spool file is fully attacker-controlled input from the root unit's perspective (the same "observed content is data, not a command" boundary, in code). Defenses: the receiver writes atomically (`rename()` into place, `StateDirectoryMode=0700`); the root oneshot's **first action is `rename()` the trigger into a root-owned dir the `DynamicUser` cannot write** (atomically removing any swap/symlink/TOCTOU window), reads it once with `O_NOFOLLOW`, and parses it as a claim — a single whitelisted field (tag matching `^v[0-9]+\.[0-9]+\.[0-9]+$`), never sourced/evaled, never passed unquoted to a shell, the repo URL hardcoded in the unit. Concurrent triggers are serialized by systemd itself: the `Type=oneshot` unit runs one instance at a time, and each run drains the spool newest-wins, so superseded intents are dropped rather than replayed. *(Amended #321: an earlier version of this decision claimed "a `flock` serializes concurrent triggers" — the only `flock` on this path guards the D6 throttle stamp, released before the build starts; the verb itself is serialized by the oneshot semantics above. If oneshot semantics are ever not enough, take a verb-scoped lock spanning fetch/build.)*
7577

7678
### D9. Protect hashrate by spatial build isolation, not just priority (the no-perf-impact requirement)
7779

@@ -100,7 +102,7 @@ Low-cost integrity controls that need no standing signing key, shoring up the Gi
100102
- New opt-in surface, off by default, fail-closed. A rig that does not enable `control_upgrade` carries zero new capability; a rig on `control` alone is unchanged.
101103
- Residual risk accepted: GitHub-account/release compromise → fleet-wide root RCE (D5). Mitigation is account hardening, not signing.
102104
- Producer for pithead #597. Backwards compatible (a new MINOR capability). It must ship in a RigForge release before pithead #597 can run end-to-end; pithead #596 (version badge) has no RigForge dependency and ships first.
103-
- Consumer contract: `POST :8082/upgrade {"version":"vX.Y.Z"}``202 Accepted` + change id → poll `:8082/status` for `applied` / `rolled_back` / `failed`. The host never trusts a host/port/token from the intent; the rig bounds the dashboard-supplied target with monotonic anti-rollback + reachable-from-main guards (D4/D10) rather than making its own version-check dial.
105+
- Consumer contract: `POST :8082/upgrade {"version":"vX.Y.Z"}``202 Accepted` + change id → poll `:8082/status` for a non-terminal `started`, then terminal `applied` (reason names the landed version) / `rolled_back` / `noop` (already on target) / `throttled` (retry after the window) / `failed` (#320 amendment, D6). The host never trusts a host/port/token from the intent; the rig bounds the dashboard-supplied target with monotonic anti-rollback + reachable-from-main guards (D4/D10) rather than making its own version-check dial.
104106

105107
## Open questions (resolved 2026-07-17 — see Resolution above)
106108

docs/operations.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,13 @@ and health-gates the swap with rollback. It refuses anything that isn't a real r
601601
the running one. Default off, and never on just because `control` is. The threat model and
602602
decisions are in [ADR 0002](adr/0002-remote-worker-upgrade.md).
603603

604+
Polling an upgrade works like polling a config change, with a slightly richer vocabulary (#320):
605+
`GET :8082/status?change_id=<id>` serves a non-terminal `started` once the root oneshot has claimed
606+
the intent (so a poller can tell "mid-build" from "queued" — and a `started` that outlives the
607+
oneshot means the run was lost), then one terminal outcome: `applied` (the `reason` names the
608+
landed version), `rolled_back`, `noop` (already on the requested version — idempotent, not an
609+
error), `throttled` (inside the anti-beacon window — retry later), or `failed` (with `reason`).
610+
604611
`"control": "disabled"` + `apply` removes the units cleanly; `uninstall` removes them too. Linux-only.
605612
The design and the decisions behind it are recorded in
606613
[ADR 0001](../docs/adr/0001-writable-worker-config-control-path.md).

docs/pithead-integration.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,11 @@ and [ADR 0001](adr/0001-writable-worker-config-control-path.md).
188188
recent* change's outcome, which a concurrent change (another dashboard edit, a local `apply`, an
189189
autotune restart) can step on between your `POST` and your poll. To avoid that race, poll
190190
`GET :8082/status?change_id=<the 16-hex id from the 202>` — it returns *that* change's recorded
191-
outcome (`applied`/`rejected`/`rolled_back`/`failed` + `reason`/`backup`/`changed_keys`/`warnings`), or `404`
192-
if it isn't among the last ~20 recorded. Same bearer auth as `/status`. Pair it with
191+
outcome (for a config change: `applied`/`rejected`/`rolled_back`/`failed` +
192+
`reason`/`backup`/`changed_keys`/`warnings`), or `404` if it isn't among the last ~20 recorded. Same
193+
bearer auth as `/status`. The same endpoint serves remote-*upgrade* change ids with a richer
194+
vocabulary (non-terminal `started`, terminal `noop`/`throttled` alongside the above — #320); see
195+
[Operations › Remote upgrade](operations.md#writable-control-path-opt-in). Pair it with
193196
`config_meta.revision` on the read feed to confirm the effective config actually moved.
194197

195198
**Prefill from a live read (`rigforge.config`).** The enriched feed exposes the rig's current

0 commit comments

Comments
 (0)