You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/adr/0002-remote-worker-upgrade.md
+6-4Lines changed: 6 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -33,7 +33,7 @@ The receiver (`util/control-server.py`) gains a `POST /upgrade` distinct from `/
33
33
34
34
### D3. Unprivileged receiver stages only; the privileged oneshot fetches and runs
35
35
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).
37
37
38
38
### D4. Dashboard-supplied target, bounded by monotonic + reachability guards
39
39
@@ -43,7 +43,7 @@ The staged body is tiny: `{"version": "vX.Y.Z"}`, and it **is** the target. The
43
43
-**Reachable-from-main** — refuse a commit that is not an ancestor of `origin/main` (D10; amended by #318).
44
44
-**Immutable releases + tag protection** (D10) make the tag→commit binding a platform guarantee.
45
45
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.
47
47
48
48
### D5. Trust model: hash-only, git-tag checkout, no signing (settled)
49
49
@@ -55,6 +55,8 @@ GitHub is therefore accepted as the trust root. The residual risk is explicit: a
55
55
56
56
`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.
57
57
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
+
58
60
### D7. Auth and source-pinning inherited from ADR 0001 D4/D9
59
61
60
62
`/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
71
73
72
74
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.
73
75
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.)*
75
77
76
78
### D9. Protect hashrate by spatial build isolation, not just priority (the no-perf-impact requirement)
77
79
@@ -100,7 +102,7 @@ Low-cost integrity controls that need no standing signing key, shoring up the Gi
100
102
- 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.
101
103
- Residual risk accepted: GitHub-account/release compromise → fleet-wide root RCE (D5). Mitigation is account hardening, not signing.
102
104
- 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.
104
106
105
107
## Open questions (resolved 2026-07-17 — see Resolution above)
0 commit comments