From a5ef51c46e8bf65e511f4cca7faeeb867de0f781 Mon Sep 17 00:00:00 2001
From: Vijit Singh
Date: Sun, 19 Jul 2026 16:18:51 -0500
Subject: [PATCH 01/11] docs: the targeted e2e run is a required pre-release
gate; genericize bench hostnames (#685)
docs/dev/releasing.md documents the two required matrix runs (readiness in
release.sh stage 2, the borrowed-rig e2e.sh --mode targeted before the cut)
and the post-deploy --check sweep with its expected parked-bench baseline.
Private bench hostnames come out of the repo: the release-server allocation
table names roles instead of boxes (each box's ~/README.md carries its
specifics), and the stray box names in comments, one changelog entry, and
the rig-lock busy message are generic.
Co-authored-by: Claude Fable 5
---
CHANGELOG.md | 13 +++++++++-
.../client/tari/tari_wallet_client.py | 2 +-
.../mining_dashboard/service/tor_heal.py | 4 +--
.../dashboard/tests/service/test_tor_heal.py | 2 +-
build/tari-wallet/entrypoint.sh | 2 +-
docs/dev/release-server.md | 21 ++++++++--------
docs/dev/releasing.md | 25 +++++++++++++++++++
tests/integration/lib.sh | 2 +-
tests/integration/run.sh | 2 +-
tests/stack/run.sh | 2 +-
10 files changed, 56 insertions(+), 19 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc464bbb..b83eb49b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,17 @@ Pithead ships as **one product, one version** — the version lives in the top-l
[`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut
per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
+## [Unreleased]
+
+### Changed
+
+- **The release process requires the targeted end-to-end run.** `docs/dev/releasing.md` now
+ states that the borrowed-rig `e2e.sh --mode targeted` pass on the release candidate is a
+ required pre-release gate — `release.sh`'s `--readiness` assessment alone is not enough — and
+ documents the post-deploy `--check` sweep and its expected parked-bench baseline. Private
+ bench hostnames in docs, comments, and one harness message are replaced with generic role
+ names; each box's specifics live in its own `~/README.md`, not the repo.
+
## [1.9.3] - 2026-07-19
### Fixed
@@ -1053,7 +1064,7 @@ tabbed earnings panel.
time out, so the dead-man's-switch pings, the Telegram bot, and XvB stats all stop while mining
(onion circuits) keeps working — the stack looks healthy as three features die. A new doctor check
makes one request through Tor's SOCKS to a no-content endpoint and WARNs with the fix (restart the
- tor container to pick fresh guards) when clearnet exits fail. Found live on pithead-prod after the
+ tor container to pick fresh guards) when clearnet exits fail. Found live on the production stack after the
v1.3.0 deploy.
- **A release-box checkout that has run the stack no longer fails `lint-toml` (#421).** taplo globs
the filesystem, not the git index, so the generated (git-ignored) `build/tari/config.toml` left by
diff --git a/build/dashboard/mining_dashboard/client/tari/tari_wallet_client.py b/build/dashboard/mining_dashboard/client/tari/tari_wallet_client.py
index 0940eba9..6100e6c2 100644
--- a/build/dashboard/mining_dashboard/client/tari/tari_wallet_client.py
+++ b/build/dashboard/mining_dashboard/client/tari/tari_wallet_client.py
@@ -14,7 +14,7 @@
# The direction/status gate is generous on purpose: p2pool's Tari coinbase is a one-sided output to
# wallet_payment_address, and which enum a view-only wallet reports it under
# (COINBASE_CONFIRMED vs ONE_SIDED_CONFIRMED vs a plain INBOUND) is the one item pinned to tier-4
-# (gouda). Keeping the accept-set here isolated means tightening it after that check is a one-line
+# (the live bench). Keeping the accept-set here isolated means tightening it after that check is a one-line
# change. Enum values are the wallet.proto constants (TransactionStatus / TransactionDirection).
_DIRECTION_INBOUND = 1 # TRANSACTION_DIRECTION_INBOUND
_COINBASE_STATUSES = frozenset(
diff --git a/build/dashboard/mining_dashboard/service/tor_heal.py b/build/dashboard/mining_dashboard/service/tor_heal.py
index 107c1847..8540e1b2 100644
--- a/build/dashboard/mining_dashboard/service/tor_heal.py
+++ b/build/dashboard/mining_dashboard/service/tor_heal.py
@@ -3,7 +3,7 @@
Tor can bootstrap to 100% yet sit on a FAILING GUARD: circuits build but clearnet exits time
out at the 60s cutoff, so every Tor-clearnet feature (Healthchecks pings, the Telegram bot,
XvB stats) dies at once while mining — onion / already-established circuits — keeps working
-and the stack looks healthy. Seen live on pithead-prod after the v1.3.0 deploy: ~6 hours dark
+and the stack looks healthy. Seen live in production after the v1.3.0 deploy: ~6 hours dark
until a manual ``docker compose restart tor`` reselected guards. The doctor check (v1.3.1)
detects this state; this monitor is the heal half.
@@ -32,7 +32,7 @@
healed.
The restart goes through the same start/stop-only docker-control proxy as the #31 failover.
-The manual leg is ``./pithead restart tor``. Real stuck-guard recovery is tier 4 (gouda).
+The manual leg is ``./pithead restart tor``. Real stuck-guard recovery is tier 4 (the live bench).
"""
import asyncio
diff --git a/build/dashboard/tests/service/test_tor_heal.py b/build/dashboard/tests/service/test_tor_heal.py
index 323fc7df..908829d5 100644
--- a/build/dashboard/tests/service/test_tor_heal.py
+++ b/build/dashboard/tests/service/test_tor_heal.py
@@ -3,7 +3,7 @@
The decision core is what must be right: the healer restarts tor ONLY when egress is broken
past the sustained threshold AND the cooldown has elapsed AND the per-outage restart budget
isn't spent. Each guard is pinned separately, so inverting or deleting any of them fails a
-test. The actual container restart against a real stuck guard is tier 4 (gouda).
+test. The actual container restart against a real stuck guard is tier 4 (the live bench).
"""
from unittest.mock import patch
diff --git a/build/tari-wallet/entrypoint.sh b/build/tari-wallet/entrypoint.sh
index dd15bf12..78b008dc 100755
--- a/build/tari-wallet/entrypoint.sh
+++ b/build/tari-wallet/entrypoint.sh
@@ -53,7 +53,7 @@ birthday="$(resolve_birthday)"
echo "Starting view-only Tari payout wallet (birthday $birthday, base node $BASE_NODE_GRPC) (#462)..."
# Point the wallet at the LOCAL base node. The exact config-override key for the base-node peer is
-# the one item pinned to tier-4 (gouda) — confirmed there against a live minotari_console_wallet
+# the one item pinned to tier-4 (the live bench) — confirmed there against a live minotari_console_wallet
# alongside whether the merge-mine coinbase surfaces via GetCompletedTransactions. Passed via the
# Tari config env-override convention so it is NON-secret and stays out of argv.
export MINOTARI_WALLET__BASE_NODE__GRPC_BASE_NODE_ADDRESS="/dns4/${BASE_NODE_GRPC%%:*}/tcp/${BASE_NODE_GRPC##*:}"
diff --git a/docs/dev/release-server.md b/docs/dev/release-server.md
index cfe8bbf3..5ac434ce 100644
--- a/docs/dev/release-server.md
+++ b/docs/dev/release-server.md
@@ -217,20 +217,21 @@ tests/integration/run.sh --host you@server --dir pithead --readiness
## Bench allocation and the rig lock
-Ten boxes are shared between this repo's tier-4 harness, RigForge's release gates, and
+The bench boxes are shared between this repo's tier-4 harness, RigForge's release gates, and
production mining. Two automations cycling the same rig's services corrupt each other's results
-(the 2026-07-10 miner-0 incident: an operator "fixed" a service an e2e run had deliberately
-stopped), so ownership is static and every run takes a kernel lock.
+(a real 2026-07 incident: an operator "fixed" a service an e2e run had deliberately stopped),
+so ownership is static and every run takes a kernel lock.
-Static allocation — each box states its owner in `/etc/bench-role`:
+Static allocation — each box states its owner in `/etc/bench-role`, and each box's own
+`~/README.md` carries its specifics (hostname, role, data roots):
-| Box | Owner | Use |
+| Box role | Owner | Use |
|---|---|---|
-| miner-0 | RigForge | `e2e-real` / tune gates. Pithead never touches its services. |
-| miner-1, miner-2 | Pithead | Tier-4 loaner rigs: `e2e.sh` repoints one at the test bench for a run, then reverts it. Verify `systemctl is-active xmrig` after any remote restart. |
-| miner-3 … miner-7 | Production | Mining only. No test traffic. |
-| gouda | Pithead | Test bench + release box (the tier-4 target). |
-| pithead-prod | Production | Production stack; deploys only. |
+| RigForge bench rig | RigForge | `e2e-real` / tune gates. Pithead never touches its services. |
+| Loaner rigs (two) | Pithead | Tier-4: `e2e.sh` repoints one at the test bench for a run, then reverts it. Verify `systemctl is-active xmrig` after any remote restart. |
+| Fleet rigs | Production | Mining only. No test traffic. |
+| Test bench | Pithead | Test bench + release box (the tier-4 target). |
+| Production host | Production | Production stack; deploys only. |
The run lock. Both harnesses take a `flock` on `/var/lock/rig-e2e.lock` before the first
service-touching action and hold it on an inherited FD for the whole run, so the kernel releases
diff --git a/docs/dev/releasing.md b/docs/dev/releasing.md
index 2b1649a0..ddb5f7a1 100644
--- a/docs/dev/releasing.md
+++ b/docs/dev/releasing.md
@@ -110,6 +110,31 @@ required, blocking pre-release gate. A release must not be promoted or published
matrix is green against the real Monero + Tari nodes. This is what makes every published version
a single, validated bundle.
+Two runs of the matrix are required, and the automated one is the smaller of the two:
+
+1. `release.sh` stage 2 runs the non-destructive `--readiness` assessment against the live
+ stack (via `RELEASE_INTEGRATION_ARGS`). It proves the box is fit to cut from — it does not
+ mine, restart anything, or touch a rig.
+2. Before cutting, run the targeted end-to-end matrix on the release candidate with a borrowed
+ loaner rig:
+
+ ```bash
+ BENCH_HOST= MINER_HOST= tests/integration/e2e.sh --mode targeted
+ ```
+
+ This deploys the candidate to the bench's dedicated e2e checkout, repoints the rig at it
+ (under the [rig lock](release-server.md#bench-allocation-and-the-rig-lock), with automatic
+ restore of both), and proves what `--readiness` cannot: a real miner mining through the
+ stack, the lifecycle phase (restart, apply secret-preservation, node-down failover), and
+ fail-closed auth. If the release's diff touches the worker or control-descriptor path, add
+ the `--rigforge-control` legs (needs a rig with its control API enabled). The readiness
+ gate alone does not satisfy this requirement; abort the release on any failure.
+
+After deploying the published release to the bench, run the non-destructive live sweep as the
+closing check: `tests/integration/run.sh --local --dir --check`. On a bench with no
+miners connected, exactly two failures are expected — `workers online` and `stratum total
+hashes` — anything else is a regression.
+
## Signed releases
Every promoted image digest and the install bundle carry a cosign key signature
diff --git a/tests/integration/lib.sh b/tests/integration/lib.sh
index 94910291..7ad9dddc 100644
--- a/tests/integration/lib.sh
+++ b/tests/integration/lib.sh
@@ -304,7 +304,7 @@ rig_lock() { # rig_lock [shared]
echo "rig busy ($(cat "$hf" 2>/dev/null || echo unknown)) — waiting..." >&2
flock $mode 9
else
- echo "miner-0 busy: $(cat "$hf" 2>/dev/null || echo unknown). Retry with RIG_LOCK_WAIT=1 to queue." >&2
+ echo "rig busy: $(cat "$hf" 2>/dev/null || echo unknown). Retry with RIG_LOCK_WAIT=1 to queue." >&2
exit 75 # EX_TEMPFAIL — callers can tell "busy, retry later" from a real failure
fi
fi
diff --git a/tests/integration/run.sh b/tests/integration/run.sh
index b2b37327..6cd0cb29 100755
--- a/tests/integration/run.sh
+++ b/tests/integration/run.sh
@@ -1957,7 +1957,7 @@ run_rigforge_control() {
# The write path resolves the rig's host + token from workers.list[] (#506), or the deprecated
# dashboard.workers[] fallback if that's what the box's baseline already carries. Use the
- # baseline's descriptor if it already pins this rig's host (the gouda/prod case, whichever shape);
+ # baseline's descriptor if it already pins this rig's host (the live-box case, whichever shape);
# otherwise inject one into workers.list[] from --rig-host + IT_RIG_TOKEN (local-only, so the token
# never leaves the bench). No source for either -> skip: we won't drive an edit we can't address,
# nor mutate what we can't restore.
diff --git a/tests/stack/run.sh b/tests/stack/run.sh
index b75862a4..2a038724 100755
--- a/tests/stack/run.sh
+++ b/tests/stack/run.sh
@@ -5857,7 +5857,7 @@ upg455_fail=$(
assert_not_contains "failed upgrade does NOT move the current pointer (#455)" "$upg455_fail" "symlink"
echo "== black-box: deploy-box layout (#455) =="
-# A sandboxed source-checkout install whose chain data dirs share one root — the prod/gouda
+# A sandboxed source-checkout install whose chain data dirs share one root — the live deploy-box
# layout. Proves the default resolution, the apply-time migration, and the upgrade-time
# symlink end to end through the real CLI (docker/sudo stubbed).
L="$SANDBOX/boxroot/pithead-v9.9.9"
From ad9b5fcf1845565e2fd5d5046fe8a03ada40bf0c Mon Sep 17 00:00:00 2001
From: Vijit Singh
Date: Mon, 20 Jul 2026 19:04:25 -0500
Subject: [PATCH 02/11] security(#660): shared response-size cap for the
dashboard's external API fetches (#686)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* security(#660): shared response-size cap for every external HTTP fetch
All five external call sites — the GitHub release check (#224), the three
XvB reads (stats, reward estimates, winners), and the CoinGecko price
feed (#651) — read via a shared helper/http.py bounded_get that streams
the body and cuts it at 1 MiB. Over-cap raises a RequestException
subclass, so each client's existing fail-silent contract (None / keep
last good) applies unchanged. Known residual from the #642/#646 security
reviews; the local Telegram/healthchecks/monerod clients keep their own
contracts and stay out of scope.
Closes #660
Co-Authored-By: Claude Fable 5
* security(#660): review fixes — scope the claim to the five capped clients, pin the at-cap boundary, widen the drift guard
The changelog headline claimed "every external dashboard fetch" is
size-capped, but the Tor heal probe, Telegram long-poll, and
healthchecks ping keep their own contracts — the entry now names the
actual scope: the update check, the price feed, and the XvB client's
calls. A new tier-1 test pins the strictly-greater cap (a body of
exactly max_bytes succeeds; a regression to >= fails it), and the
wiring drift guard now rejects any direct requests.( in the
client modules, not just requests.get(.
Co-Authored-By: Claude Fable 5
* style: ruff format on the at-cap boundary test
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Claude Fable 5
---
CHANGELOG.md | 9 ++
.../mining_dashboard/client/xvb_client.py | 9 +-
.../dashboard/mining_dashboard/helper/http.py | 56 ++++++++++
.../mining_dashboard/service/price_feed.py | 4 +-
.../service/update_checker.py | 4 +-
.../dashboard/tests/client/test_xvb_client.py | 52 ++++-----
build/dashboard/tests/helper/test_http.py | 105 ++++++++++++++++++
.../tests/service/test_price_feed.py | 10 +-
.../tests/service/test_update_checker.py | 8 +-
9 files changed, 215 insertions(+), 42 deletions(-)
create mode 100644 build/dashboard/mining_dashboard/helper/http.py
create mode 100644 build/dashboard/tests/helper/test_http.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b83eb49b..686cede5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,15 @@ per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
bench hostnames in docs, comments, and one harness message are replaced with generic role
names; each box's specifics live in its own `~/README.md`, not the repo.
+### Security
+
+- **The dashboard's external API fetches are size-capped** (#660). The GitHub release check, the
+ CoinGecko price feed, and the XvB client's calls (stats, reward estimates, winners, register)
+ now stream their responses through a shared `bounded_get` helper that cuts the body at 1 MiB,
+ so a hostile or broken endpoint can no longer make these clients buffer an unbounded payload.
+ Over-cap reads follow each client's existing failure contract (no result / keep the last good
+ one).
+
## [1.9.3] - 2026-07-19
### Fixed
diff --git a/build/dashboard/mining_dashboard/client/xvb_client.py b/build/dashboard/mining_dashboard/client/xvb_client.py
index 592d8b13..03337d18 100644
--- a/build/dashboard/mining_dashboard/client/xvb_client.py
+++ b/build/dashboard/mining_dashboard/client/xvb_client.py
@@ -5,6 +5,7 @@
import requests
from mining_dashboard.config.config import TOR_SOCKS_PROXY, XVB_SUBMIT_URL
+from mining_dashboard.helper.http import bounded_get
from mining_dashboard.helper.utils import parse_hashrate
# The four donor tiers XvB publishes an expected per-player reward for. These are exactly the tier
@@ -159,7 +160,7 @@ def get_stats(self):
# the request carries the wallet, so a clearnet fetch would correlate IP <-> wallet (#163).
proxies = {"http": self.tor_proxy, "https": self.tor_proxy} if self.tor_proxy else None
try:
- response = requests.get(self.url, params=params, timeout=20, proxies=proxies)
+ response = bounded_get(self.url, params=params, timeout=20, proxies=proxies)
if response.status_code == 200:
return self._parse_html(response.text)
else:
@@ -184,7 +185,7 @@ def get_reward_estimates(self):
"""
proxies = {"http": self.tor_proxy, "https": self.tor_proxy} if self.tor_proxy else None
try:
- response = requests.get(self.reward_estimate_url, timeout=20, proxies=proxies)
+ response = bounded_get(self.reward_estimate_url, timeout=20, proxies=proxies)
if response.status_code != 200:
self.logger.error(
f"XvB reward-estimate fetch failed with status code: {response.status_code}"
@@ -221,7 +222,7 @@ def get_recent_wins(self):
proxies = {"http": self.tor_proxy, "https": self.tor_proxy} if self.tor_proxy else None
try:
- response = requests.get(self.winners_url, timeout=20, proxies=proxies)
+ response = bounded_get(self.winners_url, timeout=20, proxies=proxies)
if response.status_code != 200:
self.logger.error(
f"XvB winners fetch failed with status code: {response.status_code}"
@@ -275,7 +276,7 @@ def register(self):
# wallet, so a clearnet call would correlate IP <-> wallet (#163).
proxies = {"http": self.tor_proxy, "https": self.tor_proxy} if self.tor_proxy else None
try:
- response = requests.get(self.submit_url, params=params, timeout=20, proxies=proxies)
+ response = bounded_get(self.submit_url, params=params, timeout=20, proxies=proxies)
body = (response.text or "").strip()
low = body.lower()
diff --git a/build/dashboard/mining_dashboard/helper/http.py b/build/dashboard/mining_dashboard/helper/http.py
new file mode 100644
index 00000000..accea86c
--- /dev/null
+++ b/build/dashboard/mining_dashboard/helper/http.py
@@ -0,0 +1,56 @@
+"""Bounded reads for every external HTTP fetch (#660).
+
+Each external client (GitHub release checks #224, the three XvB reads, the CoinGecko price feed
+#651) is individually tolerant of a bad *parse*, but nothing bounded what got *read*: a hostile or
+broken endpoint could hand any of them a multi-GB body and the process would buffer it all before
+parsing. ``bounded_get`` streams the body and cuts it at a cap far above any legitimate payload;
+over-cap raises a ``requests.RequestException`` subclass, so every caller's existing failure
+contract (return ``None`` / keep the last good result) applies unchanged.
+"""
+
+import json
+
+import requests
+
+# Generous by orders of magnitude: the largest legitimate payload here (the XvB winners file) is
+# well under 100 KiB; the GitHub/CoinGecko JSON bodies are a few KiB.
+MAX_RESPONSE_BYTES = 1024 * 1024
+
+
+class ResponseTooLarge(requests.RequestException):
+ """Response body exceeded the cap. Subclasses ``RequestException`` so callers' existing
+ fail-silent handling treats it like any other transport failure."""
+
+
+class BoundedResponse:
+ """The slice of ``requests.Response`` the clients actually use: ``status_code``, ``text``,
+ ``json()`` — backed by the capped body."""
+
+ def __init__(self, status_code, content, encoding):
+ self.status_code = status_code
+ self.content = content
+ self.encoding = encoding
+
+ @property
+ def text(self):
+ return self.content.decode(self.encoding or "utf-8", errors="replace")
+
+ def json(self):
+ return json.loads(self.text)
+
+
+def bounded_get(url, max_bytes=MAX_RESPONSE_BYTES, timeout=20, **kwargs):
+ """``requests.get`` with a hard response-size cap.
+
+ Streams the body and raises ``ResponseTooLarge`` once it exceeds ``max_bytes``, instead of
+ buffering an unbounded payload. Passes ``proxies`` / ``params`` / ``headers`` through
+ unchanged; raises exactly what ``requests.get`` raises otherwise.
+ """
+ with requests.get(url, stream=True, timeout=timeout, **kwargs) as resp:
+ chunks, size = [], 0
+ for chunk in resp.iter_content(chunk_size=65536):
+ size += len(chunk)
+ if size > max_bytes:
+ raise ResponseTooLarge(f"response body exceeded {max_bytes} bytes: {url}")
+ chunks.append(chunk)
+ return BoundedResponse(resp.status_code, b"".join(chunks), resp.encoding)
diff --git a/build/dashboard/mining_dashboard/service/price_feed.py b/build/dashboard/mining_dashboard/service/price_feed.py
index f926b78b..167f4700 100644
--- a/build/dashboard/mining_dashboard/service/price_feed.py
+++ b/build/dashboard/mining_dashboard/service/price_feed.py
@@ -19,6 +19,8 @@
import requests
+from mining_dashboard.helper.http import bounded_get
+
logger = logging.getLogger("PriceFeed")
COINGECKO_SIMPLE_PRICE = "https://api.coingecko.com/api/v3/simple/price"
@@ -62,7 +64,7 @@ def fetch(self):
return None
proxies = {"http": self.tor_proxy, "https": self.tor_proxy} if self.tor_proxy else None
try:
- resp = requests.get(
+ resp = bounded_get(
COINGECKO_SIMPLE_PRICE,
params={
"ids": ",".join(COINGECKO_IDS.values()),
diff --git a/build/dashboard/mining_dashboard/service/update_checker.py b/build/dashboard/mining_dashboard/service/update_checker.py
index 92d43fde..7fe9811d 100644
--- a/build/dashboard/mining_dashboard/service/update_checker.py
+++ b/build/dashboard/mining_dashboard/service/update_checker.py
@@ -14,6 +14,8 @@
import requests
+from mining_dashboard.helper.http import bounded_get
+
logger = logging.getLogger("UpdateChecker")
@@ -53,7 +55,7 @@ def latest_release(self):
failure (network, non-200, malformed JSON). Routed through Tor when a proxy is set."""
proxies = {"http": self.tor_proxy, "https": self.tor_proxy} if self.tor_proxy else None
try:
- resp = requests.get(
+ resp = bounded_get(
self.api_url,
timeout=20,
proxies=proxies,
diff --git a/build/dashboard/tests/client/test_xvb_client.py b/build/dashboard/tests/client/test_xvb_client.py
index e0d026f2..99360583 100644
--- a/build/dashboard/tests/client/test_xvb_client.py
+++ b/build/dashboard/tests/client/test_xvb_client.py
@@ -45,7 +45,7 @@ def test_missing_wallet_returns_none():
def test_get_stats_success_parses_html():
client = XvbClient("49abc")
resp = MagicMock(status_code=200, text=SAMPLE_HTML)
- with patch.object(xvb_mod.requests, "get", return_value=resp) as mock_get:
+ with patch.object(xvb_mod, "bounded_get", return_value=resp) as mock_get:
stats = client.get_stats()
assert stats == {"fail_count": 2, "avg_1h": 1500.0, "avg_24h": 3000.0}
assert mock_get.call_args.kwargs["params"] == {"address": "49abc"}
@@ -56,7 +56,7 @@ def test_get_stats_routes_through_tor_proxy():
# operator's home IP — the request carries the wallet, so a clearnet fetch would correlate them.
client = XvbClient("49abc")
resp = MagicMock(status_code=200, text=SAMPLE_HTML)
- with patch.object(xvb_mod.requests, "get", return_value=resp) as mock_get:
+ with patch.object(xvb_mod, "bounded_get", return_value=resp) as mock_get:
client.get_stats()
proxies = mock_get.call_args.kwargs["proxies"]
assert proxies["https"].startswith("socks5h://") # socks5h resolves the host via Tor too
@@ -66,26 +66,26 @@ def test_get_stats_routes_through_tor_proxy():
def test_get_stats_honours_explicit_proxy():
client = XvbClient("49abc", tor_proxy="socks5h://127.0.0.1:9999")
resp = MagicMock(status_code=200, text=SAMPLE_HTML)
- with patch.object(xvb_mod.requests, "get", return_value=resp) as mock_get:
+ with patch.object(xvb_mod, "bounded_get", return_value=resp) as mock_get:
client.get_stats()
assert mock_get.call_args.kwargs["proxies"]["https"] == "socks5h://127.0.0.1:9999"
def test_get_stats_non_200_returns_none():
client = XvbClient("49abc")
- with patch.object(xvb_mod.requests, "get", return_value=MagicMock(status_code=503)):
+ with patch.object(xvb_mod, "bounded_get", return_value=MagicMock(status_code=503)):
assert client.get_stats() is None
def test_get_stats_network_error_returns_none():
client = XvbClient("49abc")
- with patch.object(xvb_mod.requests, "get", side_effect=requests.RequestException("boom")):
+ with patch.object(xvb_mod, "bounded_get", side_effect=requests.RequestException("boom")):
assert client.get_stats() is None
def test_get_stats_unexpected_error_returns_none():
client = XvbClient("49abc")
- with patch.object(xvb_mod.requests, "get", side_effect=ValueError("kaboom")):
+ with patch.object(xvb_mod, "bounded_get", side_effect=ValueError("kaboom")):
assert client.get_stats() is None
@@ -111,7 +111,7 @@ def test_parse_reward_estimates_malformed_or_empty_is_empty_dict():
def test_get_reward_estimates_success_routes_over_tor():
client = XvbClient("49abc")
resp = MagicMock(status_code=200, text=SAMPLE_REWARD_TXT)
- with patch.object(xvb_mod.requests, "get", return_value=resp) as mock_get:
+ with patch.object(xvb_mod, "bounded_get", return_value=resp) as mock_get:
est = client.get_reward_estimates()
assert est["donor_whale"] == 6.17
proxies = mock_get.call_args.kwargs["proxies"]
@@ -120,7 +120,7 @@ def test_get_reward_estimates_success_routes_over_tor():
def test_get_reward_estimates_non_200_returns_none():
client = XvbClient("49abc")
- with patch.object(xvb_mod.requests, "get", return_value=MagicMock(status_code=503)):
+ with patch.object(xvb_mod, "bounded_get", return_value=MagicMock(status_code=503)):
assert client.get_reward_estimates() is None
@@ -136,13 +136,13 @@ def test_get_reward_estimates_unparseable_body_returns_none():
def test_get_reward_estimates_network_error_returns_none():
client = XvbClient("49abc")
- with patch.object(xvb_mod.requests, "get", side_effect=requests.RequestException("boom")):
+ with patch.object(xvb_mod, "bounded_get", side_effect=requests.RequestException("boom")):
assert client.get_reward_estimates() is None
def test_get_reward_estimates_unexpected_error_returns_none():
client = XvbClient("49abc")
- with patch.object(xvb_mod.requests, "get", side_effect=ValueError("kaboom")):
+ with patch.object(xvb_mod, "bounded_get", side_effect=ValueError("kaboom")):
assert client.get_reward_estimates() is None
@@ -208,7 +208,7 @@ def test_parse_winners_bounds_hostile_input():
def test_get_recent_wins_success_routes_over_tor():
client = XvbClient(_WIN_WALLET)
resp = MagicMock(status_code=200, text=SAMPLE_WINNERS_TXT)
- with patch.object(xvb_mod.requests, "get", return_value=resp) as mock_get:
+ with patch.object(xvb_mod, "bounded_get", return_value=resp) as mock_get:
wins = client.get_recent_wins()
assert len(wins) == 2
assert mock_get.call_args.args[0].endswith("winners_recent_full_pub.txt")
@@ -219,17 +219,17 @@ def test_get_recent_wins_no_wins_is_empty_list_not_none():
# An empty list is a successful "no wins yet" read — distinct from a failed fetch (None).
client = XvbClient("49abcSOMEOTHERWALLETxyz99")
resp = MagicMock(status_code=200, text=SAMPLE_WINNERS_TXT)
- with patch.object(xvb_mod.requests, "get", return_value=resp):
+ with patch.object(xvb_mod, "bounded_get", return_value=resp):
assert client.get_recent_wins() == []
def test_get_recent_wins_failures_return_none():
client = XvbClient(_WIN_WALLET)
- with patch.object(xvb_mod.requests, "get", return_value=MagicMock(status_code=503)):
+ with patch.object(xvb_mod, "bounded_get", return_value=MagicMock(status_code=503)):
assert client.get_recent_wins() is None
- with patch.object(xvb_mod.requests, "get", side_effect=requests.RequestException("boom")):
+ with patch.object(xvb_mod, "bounded_get", side_effect=requests.RequestException("boom")):
assert client.get_recent_wins() is None
- with patch.object(xvb_mod.requests, "get", side_effect=ValueError("kaboom")):
+ with patch.object(xvb_mod, "bounded_get", side_effect=ValueError("kaboom")):
assert client.get_recent_wins() is None
@@ -253,20 +253,20 @@ class TestRegister:
def test_disabled_when_no_endpoint(self):
# Disable sentinel => empty submit_url => never reach out at all.
client = XvbClient("49abc", submit_url="")
- with patch.object(xvb_mod.requests, "get") as mock_get:
+ with patch.object(xvb_mod, "bounded_get") as mock_get:
assert client.register() == REG_DISABLED
mock_get.assert_not_called()
def test_missing_wallet_is_invalid(self):
client = XvbClient("", submit_url=_SUBMIT)
- with patch.object(xvb_mod.requests, "get") as mock_get:
+ with patch.object(xvb_mod, "bounded_get") as mock_get:
assert client.register() == REG_INVALID
mock_get.assert_not_called()
assert XvbClient("placeholder", submit_url=_SUBMIT).register() == REG_INVALID
def test_2xx_is_registered_and_sends_full_wallet(self):
client = XvbClient("49fullwalletaddress", submit_url=_SUBMIT)
- with patch.object(xvb_mod.requests, "get", return_value=_resp(200, "OK")) as mock_get:
+ with patch.object(xvb_mod, "bounded_get", return_value=_resp(200, "OK")) as mock_get:
assert client.register() == REG_OK
# Registration takes the FULL wallet address as ?address=...
assert mock_get.call_args.kwargs["params"] == {"address": "49fullwalletaddress"}
@@ -276,37 +276,37 @@ def test_already_registered_422_is_idempotent_success(self):
# The real steady state for an entered wallet (verified live): 422 + this body.
client = XvbClient("49abc", submit_url=_SUBMIT)
resp = _resp(422, "ERROR: Wallet Address Already Registered")
- with patch.object(xvb_mod.requests, "get", return_value=resp):
+ with patch.object(xvb_mod, "bounded_get", return_value=resp):
assert client.register() == REG_OK
def test_invalid_wallet_422(self):
client = XvbClient("49abc", submit_url=_SUBMIT)
resp = _resp(422, "ERROR: Invalid Wallet Address")
- with patch.object(xvb_mod.requests, "get", return_value=resp):
+ with patch.object(xvb_mod, "bounded_get", return_value=resp):
assert client.register() == REG_INVALID
def test_no_share_body_is_not_eligible(self):
# Best-effort: a body mentioning the PPLNS share => retry quietly, not a failure.
client = XvbClient("49abc", submit_url=_SUBMIT)
resp = _resp(422, "ERROR: No share in PPLNS window")
- with patch.object(xvb_mod.requests, "get", return_value=resp):
+ with patch.object(xvb_mod, "bounded_get", return_value=resp):
assert client.register() == REG_NOT_ELIGIBLE
def test_5xx_is_transient_error(self):
client = XvbClient("49abc", submit_url=_SUBMIT)
- with patch.object(xvb_mod.requests, "get", return_value=_resp(500, "Server error!")):
+ with patch.object(xvb_mod, "bounded_get", return_value=_resp(500, "Server error!")):
assert client.register() == REG_ERROR
def test_unrecognised_body_is_error(self):
client = XvbClient("49abc", submit_url=_SUBMIT)
- with patch.object(xvb_mod.requests, "get", return_value=_resp(418, "teapot")):
+ with patch.object(xvb_mod, "bounded_get", return_value=_resp(418, "teapot")):
assert client.register() == REG_ERROR
def test_routes_through_configured_tor_proxy_by_default(self):
# #163: the call carries the full wallet, so it ALWAYS rides the bridge Tor SOCKS like
# get_stats — a default-constructed client (as main.py builds it) uses TOR_SOCKS_PROXY.
client = XvbClient("49abc", submit_url=_SUBMIT) # no tor_proxy => the configured default
- with patch.object(xvb_mod.requests, "get", return_value=_resp(200, "OK")) as mock_get:
+ with patch.object(xvb_mod, "bounded_get", return_value=_resp(200, "OK")) as mock_get:
client.register()
proxies = mock_get.call_args.kwargs["proxies"]
assert proxies["https"] == xvb_mod.TOR_SOCKS_PROXY
@@ -315,12 +315,12 @@ def test_routes_through_configured_tor_proxy_by_default(self):
def test_network_error_is_transient_error(self):
client = XvbClient("49abc", submit_url=_SUBMIT)
- with patch.object(xvb_mod.requests, "get", side_effect=requests.RequestException("boom")):
+ with patch.object(xvb_mod, "bounded_get", side_effect=requests.RequestException("boom")):
assert client.register() == REG_ERROR
def test_unexpected_error_is_transient_error(self):
client = XvbClient("49abc", submit_url=_SUBMIT)
- with patch.object(xvb_mod.requests, "get", side_effect=ValueError("kaboom")):
+ with patch.object(xvb_mod, "bounded_get", side_effect=ValueError("kaboom")):
assert client.register() == REG_ERROR
diff --git a/build/dashboard/tests/helper/test_http.py b/build/dashboard/tests/helper/test_http.py
new file mode 100644
index 00000000..5b8daa8d
--- /dev/null
+++ b/build/dashboard/tests/helper/test_http.py
@@ -0,0 +1,105 @@
+"""Tier 1 — bounded_get (#660): the shared response-size cap for external HTTP fetches."""
+
+import re
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+import requests
+
+from mining_dashboard.helper.http import ResponseTooLarge, bounded_get
+
+
+def _streaming_resp(chunks, status_code=200, encoding="utf-8"):
+ resp = MagicMock(status_code=status_code, encoding=encoding)
+ resp.iter_content.return_value = iter(chunks)
+ resp.__enter__.return_value = resp
+ resp.__exit__.return_value = False
+ return resp
+
+
+class TestBoundedGet:
+ def test_under_cap_returns_body(self):
+ with patch(
+ "mining_dashboard.helper.http.requests.get",
+ return_value=_streaming_resp([b'{"tag_nam', b'e": "v1.2.3"}']),
+ ) as g:
+ resp = bounded_get("https://example.test/x", timeout=20)
+ assert resp.status_code == 200
+ assert resp.text == '{"tag_name": "v1.2.3"}'
+ assert resp.json() == {"tag_name": "v1.2.3"}
+ # The read is streamed, never buffered whole by requests itself.
+ assert g.call_args.kwargs["stream"] is True
+
+ def test_exactly_at_cap_returns_body(self):
+ # The cap is strictly greater-than: a body of exactly max_bytes succeeds.
+ with patch(
+ "mining_dashboard.helper.http.requests.get",
+ return_value=_streaming_resp([b"x" * 1024, b"x" * 1024]),
+ ):
+ resp = bounded_get("https://example.test/x", max_bytes=2048)
+ assert resp.content == b"x" * 2048
+
+ def test_over_cap_raises_request_exception(self):
+ # ResponseTooLarge subclasses RequestException, so every caller's existing
+ # fail-silent handling (return None / keep last good) applies unchanged.
+ with patch(
+ "mining_dashboard.helper.http.requests.get",
+ return_value=_streaming_resp([b"x" * 1024] * 3),
+ ):
+ with pytest.raises(requests.RequestException):
+ bounded_get("https://example.test/x", max_bytes=2048)
+
+ def test_stops_reading_at_the_cap(self):
+ # The over-cap chunk is the last one consumed — the rest of a multi-GB body is never read.
+ chunks = iter([b"x" * 1024, b"x" * 1024, b"x" * 1024])
+ with patch(
+ "mining_dashboard.helper.http.requests.get",
+ return_value=_streaming_resp(chunks),
+ ):
+ with pytest.raises(ResponseTooLarge):
+ bounded_get("https://example.test/x", max_bytes=1024)
+ assert next(chunks, None) is not None # at least one chunk was left unread
+
+ def test_kwargs_pass_through(self):
+ with patch(
+ "mining_dashboard.helper.http.requests.get",
+ return_value=_streaming_resp([b"ok"]),
+ ) as g:
+ bounded_get(
+ "https://example.test/x",
+ timeout=20,
+ proxies={"https": "socks5h://tor:9050"},
+ params={"a": "b"},
+ headers={"User-Agent": "pithead-dashboard"},
+ )
+ kw = g.call_args.kwargs
+ assert kw["timeout"] == 20
+ assert kw["proxies"] == {"https": "socks5h://tor:9050"}
+ assert kw["params"] == {"a": "b"}
+ assert kw["headers"] == {"User-Agent": "pithead-dashboard"}
+
+ def test_bad_encoding_degrades_not_crashes(self):
+ resp_obj = _streaming_resp([b"\xff\xfe"], encoding=None)
+ with patch("mining_dashboard.helper.http.requests.get", return_value=resp_obj):
+ resp = bounded_get("https://example.test/x")
+ assert isinstance(resp.text, str) # errors="replace", never a decode crash
+
+
+class TestWiringDriftGuard:
+ def test_external_clients_route_through_bounded_get(self):
+ """Every external fetch module goes via bounded_get — any direct requests.( is drift."""
+ pkg = Path(__file__).resolve().parents[2] / "mining_dashboard"
+ external = [
+ pkg / "service" / "update_checker.py",
+ pkg / "service" / "price_feed.py",
+ pkg / "client" / "xvb_client.py",
+ ]
+ direct_call = re.compile(r"requests\.(get|post|put|delete|head|request)\(")
+ for mod in external:
+ src = mod.read_text()
+ hit = direct_call.search(src)
+ assert hit is None, (
+ f"{mod.name}: unbounded {hit.group() if hit else ''} slipped back in"
+ )
+ assert "bounded_get(" in src, f"{mod.name}: no bounded_get call found"
diff --git a/build/dashboard/tests/service/test_price_feed.py b/build/dashboard/tests/service/test_price_feed.py
index fbc19238..8d8a5db4 100644
--- a/build/dashboard/tests/service/test_price_feed.py
+++ b/build/dashboard/tests/service/test_price_feed.py
@@ -50,7 +50,7 @@ def test_fetches_both_coins_over_tor(self):
c = CoinGeckoClient("USD", tor_proxy="socks5h://t:9050")
payload = {"monero": {"usd": 333.97}, "minotari": {"usd": 0.0004}}
with patch(
- "mining_dashboard.service.price_feed.requests.get",
+ "mining_dashboard.service.price_feed.bounded_get",
return_value=self._resp(200, payload),
) as g:
assert c.fetch() == {"xmr": 333.97, "tari": 0.0004}
@@ -66,15 +66,13 @@ def test_fetches_both_coins_over_tor(self):
def test_non_200_is_silent_none(self):
c = CoinGeckoClient("USD")
- with patch(
- "mining_dashboard.service.price_feed.requests.get", return_value=self._resp(429)
- ):
+ with patch("mining_dashboard.service.price_feed.bounded_get", return_value=self._resp(429)):
assert c.fetch() is None
def test_network_error_is_silent_none(self):
c = CoinGeckoClient("USD")
with patch(
- "mining_dashboard.service.price_feed.requests.get",
+ "mining_dashboard.service.price_feed.bounded_get",
side_effect=requests.RequestException("offline"),
):
assert c.fetch() is None
@@ -85,7 +83,7 @@ def test_non_alphabetic_currency_never_dials_out(self):
# must mean NO request at all, not a sanitized one.
for label in ("US Dollar$", "usd&x=exfil", "a" * 6, ""):
c = CoinGeckoClient(label, tor_proxy="socks5h://t:9050")
- with patch("mining_dashboard.service.price_feed.requests.get") as g:
+ with patch("mining_dashboard.service.price_feed.bounded_get") as g:
assert c.fetch() is None
g.assert_not_called()
diff --git a/build/dashboard/tests/service/test_update_checker.py b/build/dashboard/tests/service/test_update_checker.py
index fb7c716f..0b350549 100644
--- a/build/dashboard/tests/service/test_update_checker.py
+++ b/build/dashboard/tests/service/test_update_checker.py
@@ -59,7 +59,7 @@ def _resp(self, status=200, payload=None):
def test_parses_tag_and_url(self):
c = GitHubReleaseClient("https://api/releases/latest", tor_proxy="socks5h://t:9050")
with patch(
- "mining_dashboard.service.update_checker.requests.get",
+ "mining_dashboard.service.update_checker.bounded_get",
return_value=self._resp(200, {"tag_name": "v1.4.0", "html_url": "https://h/v1.4.0"}),
) as g:
assert c.latest_release() == {"tag": "v1.4.0", "url": "https://h/v1.4.0"}
@@ -72,14 +72,14 @@ def test_parses_tag_and_url(self):
def test_non_200_is_silent_none(self):
c = GitHubReleaseClient("u")
with patch(
- "mining_dashboard.service.update_checker.requests.get", return_value=self._resp(404)
+ "mining_dashboard.service.update_checker.bounded_get", return_value=self._resp(404)
):
assert c.latest_release() is None
def test_network_error_is_silent_none(self):
c = GitHubReleaseClient("u")
with patch(
- "mining_dashboard.service.update_checker.requests.get",
+ "mining_dashboard.service.update_checker.bounded_get",
side_effect=requests.RequestException("offline"),
):
assert c.latest_release() is None
@@ -87,7 +87,7 @@ def test_network_error_is_silent_none(self):
def test_missing_fields_is_none(self):
c = GitHubReleaseClient("u")
with patch(
- "mining_dashboard.service.update_checker.requests.get",
+ "mining_dashboard.service.update_checker.bounded_get",
return_value=self._resp(200, {"tag_name": "v1.0.0"}),
): # no html_url
assert c.latest_release() is None
From 0bbb040c09bdbe8458455dd669816892aaa1f4b3 Mon Sep 17 00:00:00 2001
From: Vijit Singh
Date: Mon, 20 Jul 2026 19:18:18 -0500
Subject: [PATCH 03/11] feat(#596): per-worker RigForge "new version available"
badge (#688)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* security(#660): shared response-size cap for every external HTTP fetch
All five external call sites — the GitHub release check (#224), the three
XvB reads (stats, reward estimates, winners), and the CoinGecko price
feed (#651) — read via a shared helper/http.py bounded_get that streams
the body and cuts it at 1 MiB. Over-cap raises a RequestException
subclass, so each client's existing fail-silent contract (None / keep
last good) applies unchanged. Known residual from the #642/#646 security
reviews; the local Telegram/healthchecks/monerod clients keep their own
contracts and stay out of scope.
Closes #660
Co-Authored-By: Claude Fable 5
* feat(#596): per-worker RigForge new-version badge
One fleet-wide, hourly, Tor-routed fetch of the latest RigForge release
(UpdateChecker.latest_release_cached, gated on the same
dashboard.check_for_updates flag) cached as raw {tag, url}; each rig's
badge is derived at the render seam from its live reported version
(rigforge_update_for), so derived state never outlives its inputs (#664)
and a rig on X can never badge X available. compute_update normalizes
the rig's bare 1.11.2 against the tag's v1.11.2. Badges render in the
Workers Alive table and Worker Inspect; rigs reporting no version show
no badge (unknown, not up-to-date).
Closes #596
Co-Authored-By: Claude Fable 5
* fix(#596): review fixes — snapshot restore drops rigforge_release; docstring + catalog row
- Snapshot restore popped `update` (#664) but not `rigforge_release`, so with
check_for_updates flipped off a restored snapshot re-seeded stale per-worker
badges until the first poll cycle. Pop it alongside `update`; the checker
re-fetches on its own cadence. Extends the #664 restore test (fails without
the pop).
- update_checker docstring said the check is opt-in (default off) — the config
default and docs/configuration.md say on by default. Docstring now matches.
- testing-strategy scenario catalog (Section I) gains the #596 badge row:
tier 1 covered today, live badge owed to the #597 gouda loaner session.
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Claude Fable 5
---
CHANGELOG.md | 9 +++
.../mining_dashboard/config/config.py | 6 ++
.../mining_dashboard/service/data_service.py | 21 ++++++
.../service/update_checker.py | 32 +++++---
.../web/static/components.mjs | 12 ++-
.../web/static/workerview.mjs | 12 +++
build/dashboard/mining_dashboard/web/views.py | 26 ++++++-
.../tests/frontend/components.test.mjs | 14 ++++
.../tests/frontend/fixtures/state.json | 3 +
.../tests/frontend/workerview.test.mjs | 17 +++++
.../tests/service/test_data_service.py | 15 ++++
.../tests/service/test_update_checker.py | 33 +++++++++
build/dashboard/tests/web/test_views.py | 74 +++++++++++++++++++
docs/configuration.md | 2 +-
docs/dashboard.md | 5 ++
docs/dev/testing-strategy.md | 1 +
docs/privacy.md | 2 +-
docs/workers.md | 14 ++++
18 files changed, 283 insertions(+), 15 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 686cede5..d6bc8498 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,15 @@ per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
## [Unreleased]
+### Added
+
+- **Per-worker RigForge "new version available" badge** (#596). A rig whose reported RigForge
+ version is older than the latest published release gets a clickable badge in the Workers Alive
+ table and in Worker Inspect, linking to the release notes — the worker-level twin of the
+ header's stack-release badge. Notify-only; one hourly, Tor-routed, fail-silent fetch covers the
+ whole fleet, gated on the same `dashboard.check_for_updates` flag. Rigs that report no version
+ (plain xmrig, sister API off) show no badge — unknown, not "up to date".
+
### Changed
- **The release process requires the targeted end-to-end run.** `docs/dev/releasing.md` now
diff --git a/build/dashboard/mining_dashboard/config/config.py b/build/dashboard/mining_dashboard/config/config.py
index 863908c4..1408024b 100644
--- a/build/dashboard/mining_dashboard/config/config.py
+++ b/build/dashboard/mining_dashboard/config/config.py
@@ -359,6 +359,12 @@ def _nonneg(v):
"GITHUB_RELEASES_API",
"https://api.github.com/repos/p2pool-starter-stack/pithead/releases/latest",
)
+# Latest RigForge release, for the per-worker "new version available" badge (#596). Same check,
+# same Tor route, same dashboard.check_for_updates gate as the stack's own release check above.
+GITHUB_RIGFORGE_RELEASES_API = os.environ.get(
+ "GITHUB_RIGFORGE_RELEASES_API",
+ "https://api.github.com/repos/p2pool-starter-stack/rigforge/releases/latest",
+)
UPDATE_CHECK_INTERVAL = int(float(os.environ.get("UPDATE_CHECK_INTERVAL", "3600")))
# Donation tier to target (config.json: xvb.donation_level). The XvB raffle picks
diff --git a/build/dashboard/mining_dashboard/service/data_service.py b/build/dashboard/mining_dashboard/service/data_service.py
index 7513926e..498a9c3d 100644
--- a/build/dashboard/mining_dashboard/service/data_service.py
+++ b/build/dashboard/mining_dashboard/service/data_service.py
@@ -41,6 +41,7 @@
DASHBOARD_ENERGY,
ENABLE_XVB,
GITHUB_RELEASES_API,
+ GITHUB_RIGFORGE_RELEASES_API,
HASHRATE_DROP_MINUTES,
HASHRATE_DROP_THRESHOLD_PCT,
HOST_IP,
@@ -419,6 +420,15 @@ def __init__(self, state_manager, proxy_client, xvb_client):
enabled=CHECK_FOR_UPDATES,
interval=UPDATE_CHECK_INTERVAL,
)
+ # RigForge latest-release check (#596): the same flag, throttle and Tor route, pointed at
+ # the RigForge repo. ONE fleet-wide fetch — the per-worker "rig is behind" verdict is
+ # derived at the render seam from each rig's live reported version, never stored (#664).
+ self.rigforge_update_checker = UpdateChecker(
+ GitHubReleaseClient(GITHUB_RIGFORGE_RELEASES_API, TOR_SOCKS_PROXY),
+ None,
+ enabled=CHECK_FOR_UPDATES,
+ interval=UPDATE_CHECK_INTERVAL,
+ )
# Live XMR/XTM price feed (#520's auto half): off unless dashboard.energy.price_feed is
# set. Same Tor SOCKS route as the update check — CoinGecko only ever sees a Tor exit.
self.price_feed = PriceFeed(
@@ -543,6 +553,10 @@ def __init__(self, state_manager, proxy_client, xvb_client):
# JUST changed — the very upgrade the restored badge advertised. The checker
# recomputes it on its own cadence; never resurrect the pre-upgrade banner.
loaded_snapshot.pop("update", None)
+ # Same rule for the fleet-wide RigForge release (#596): with the flag now off, a
+ # restored `rigforge_release` would keep serving stale per-worker badges until the
+ # first poll cycle. The checker re-fetches on its cadence; drop it on restore.
+ loaded_snapshot.pop("rigforge_release", None)
self.latest_data.update(loaded_snapshot)
self.workers_rejected = bool(self.latest_data.get("workers_rejected", False))
self.miner_released = bool(self.latest_data.get("miner_released", False))
@@ -1335,6 +1349,13 @@ async def run(self):
self.latest_data["update"] = await asyncio.to_thread(
self.update_checker.maybe_check, time.time()
)
+ # 8b. The RigForge counterpart (#596): cache the latest RigForge release
+ # (raw {tag, url}); build_workers derives each rig's badge from it. Written
+ # unconditionally — the accessor returns None without dialing when the check
+ # is disabled, so a snapshot-restored release can't outlive a flag flip.
+ self.latest_data["rigforge_release"] = await asyncio.to_thread(
+ self.rigforge_update_checker.latest_release_cached, time.time()
+ )
# 9. Live XMR/XTM prices over Tor (#520) — ONLY when dashboard.energy.price_feed
# is set (default off, so the appliance never dials CoinGecko unbidden). The
diff --git a/build/dashboard/mining_dashboard/service/update_checker.py b/build/dashboard/mining_dashboard/service/update_checker.py
index 7fe9811d..f47b6f67 100644
--- a/build/dashboard/mining_dashboard/service/update_checker.py
+++ b/build/dashboard/mining_dashboard/service/update_checker.py
@@ -1,12 +1,13 @@
"""New-release check (#224) — notify-only.
-When `dashboard.check_for_updates` is enabled (default OFF), the dashboard periodically asks GitHub
+Unless `dashboard.check_for_updates` is disabled (default ON), the dashboard periodically asks GitHub
for the latest published release and, if it's newer than the running version, surfaces a header badge
linking to it (`build_state` -> `state.update`). It never updates anything — it's a callout so the
operator knows to upgrade on their own terms (the one-click upgrade is the separate #59).
-Privacy: the check is **opt-in (default off)** and routed over the bridge **Tor SOCKS** (reusing
-`TOR_SOCKS_PROXY`, like the XvB stats fetch #163), so enabling it doesn't reveal the host IP to GitHub.
+Privacy: the check is **on by default** because it's routed over the bridge **Tor SOCKS** (reusing
+`TOR_SOCKS_PROXY`, like the XvB stats fetch #163), so it doesn't reveal the host IP to GitHub;
+set `dashboard.check_for_updates` to false to opt out.
Every failure path is silent (returns ``None``) so an offline / Tor-only stack just shows no badge.
"""
@@ -85,19 +86,32 @@ def __init__(self, client, running_version, enabled, interval=3600):
self.enabled = enabled
self.interval = interval
self._last = 0.0
+ self.release = None
self.result = None
- def maybe_check(self, now):
- """Return the cached ``{available, ...}`` (or ``None``). Performs the (blocking) fetch only
- when enabled and the throttle window has elapsed — call via ``asyncio.to_thread``."""
+ def latest_release_cached(self, now):
+ """Return the cached raw ``{tag, url}`` of the latest release (or ``None``). Performs the
+ (blocking) fetch only when enabled and the throttle window has elapsed — call via
+ ``asyncio.to_thread``. This is the many-consumers accessor (#596): one throttled fleet-wide
+ fetch, compared against as many running versions as the caller has."""
if not self.enabled:
- self.result = None
return None
if self._last and (now - self._last) < self.interval:
- return self.result
+ return self.release
self._last = now
rel = self.client.latest_release()
+ if rel:
+ self.release = rel
+ # On a failed fetch keep the previous release — a blip shouldn't drop a real "update available".
+ return self.release
+
+ def maybe_check(self, now):
+ """Return the cached ``{available, ...}`` (or ``None``) for this checker's own running
+ version. Same throttle/cache contract as ``latest_release_cached``."""
+ if not self.enabled:
+ self.result = None
+ return None
+ rel = self.latest_release_cached(now)
if rel:
self.result = compute_update(self.running, rel["tag"], rel["url"])
- # On a failed fetch keep the previous result — a blip shouldn't drop a real "update available".
return self.result
diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs
index b7d1d5da..95b27695 100644
--- a/build/dashboard/mining_dashboard/web/static/components.mjs
+++ b/build/dashboard/mining_dashboard/web/static/components.mjs
@@ -743,6 +743,16 @@ function RigForgeChips({ rf }) {
)}`;
}
+// Per-worker RigForge new-release callout (#596) — the worker-level twin of UpdateBadge. Shown
+// only when the server derived that this rig's reported RigForge version is older than the latest
+// release (same dashboard.check_for_updates gate). Notify-only — a link to the release notes; the
+// one-click rig upgrade is the separate #597.
+const RigUpdateBadge = ({ up }) =>
+ up && up.available && up.url
+ ? html` rf ${up.latest} available ↗`
+ : null;
+
// Pool-wide proxy share totals (Issue #82) — a footer under the table. Hidden until the proxy
// has reported any shares so it isn't an all-zero line on a fresh start.
const ProxyTotals = ({ summary }) => {
@@ -812,7 +822,7 @@ function WorkersTable({ workers, summary, ui, onSort, hostIp, stratumPort, onIns
w.api_ok === false
? html` api ⚠`
: null
- }<${RigForgeChips} rf=${w.rigforge} />
+ }<${RigForgeChips} rf=${w.rigforge} /><${RigUpdateBadge} up=${w.rigforge_update} />
${w.ip}
${uptimeCell(w)}
${w.h60_str}
diff --git a/build/dashboard/mining_dashboard/web/static/workerview.mjs b/build/dashboard/mining_dashboard/web/static/workerview.mjs
index a41f6aab..3624b10f 100644
--- a/build/dashboard/mining_dashboard/web/static/workerview.mjs
+++ b/build/dashboard/mining_dashboard/web/static/workerview.mjs
@@ -252,6 +252,18 @@ export class WorkerInspect extends Component {
<${InfoCard} label="Hashrate (1m)" value=${detail.hashrate || "—"} />
<${InfoCard} label="RigForge" value=${detail.rigforge ? detail.rigforge.version || "yes" : "—"} />
+ ${
+ // This rig runs an older RigForge (#596) — notify-only, mirrors the header's
+ // stack-level new-release badge. The one-click rig upgrade is the separate #597.
+ detail.rigforge_update &&
+ detail.rigforge_update.available &&
+ detail.rigforge_update.url
+ ? html`
diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py
index 63fc72c8..e0c2cf24 100644
--- a/build/dashboard/mining_dashboard/web/views.py
+++ b/build/dashboard/mining_dashboard/web/views.py
@@ -48,7 +48,7 @@
)
from mining_dashboard.service.egress import egress_posture_from_config, topology_from_config
from mining_dashboard.service.metrics import build_metrics, share_reject_pct
-from mining_dashboard.service.update_checker import parse_semver
+from mining_dashboard.service.update_checker import compute_update, parse_semver
from mining_dashboard.version import resolve_version
logger = logging.getLogger("WebViews")
@@ -834,7 +834,23 @@ def build_system(data):
}
-def build_workers(workers):
+def rigforge_update_for(worker, release):
+ """The per-worker RigForge new-release callout (#596): ``{available, latest, url}`` or ``None``.
+
+ Derived at the render seam from the rig's live reported version and the fleet-wide cached
+ latest release — never stored, so it can't outlive its inputs (the #664 lesson: a rig running
+ X must never badge "X available"). ``compute_update`` normalizes the rig's bare ``1.11.2``
+ against the release tag's ``v1.11.2``. No reported version (plain xmrig, sister API off) or no
+ cached release → ``None``, an honest "unknown", not a false "up to date"."""
+ if not worker or not release:
+ return None
+ version = (worker.get("rigforge") or {}).get("version")
+ if not version:
+ return None
+ return compute_update(version, release.get("tag"), release.get("url"))
+
+
+def build_workers(workers, rigforge_release=None):
"""Worker rows as data: raw numeric fields (for client-side sorting) alongside their
formatted display strings, plus a pool token for the badge. Online first, then by name."""
rows = []
@@ -888,6 +904,8 @@ def build_workers(workers):
# RigForge enriched feed (#235): version badge + health/power/tune/watchdog
# chips, or None for a plain-xmrig worker (renders nothing extra).
"rigforge": _rigforge_display(worker.get("rigforge")),
+ # {available, latest, url} | None — this rig runs an older RigForge (#596).
+ "rigforge_update": rigforge_update_for(worker, rigforge_release),
}
)
except Exception as e:
@@ -1541,6 +1559,8 @@ def build_worker_detail(name, data, state_mgr):
"status": worker.get("status") if worker else None,
"hashrate": format_hashrate(worker.get("h60", 0)) if worker else None,
"rigforge": _rigforge_display(worker.get("rigforge")) if worker else None,
+ # {available, latest, url} | None — this rig runs an older RigForge (#596).
+ "rigforge_update": rigforge_update_for(worker, (data or {}).get("rigforge_release")),
"writable_keys": sorted(WORKER_WRITABLE_KEYS),
"last_applied": state_mgr.get_last_applied_worker_config(name),
"history": history,
@@ -1644,7 +1664,7 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH
),
"xvb_calc": build_xvb_calc(metrics, state_mgr),
"tari": build_tari(data),
- "workers": build_workers(data.get("workers", [])),
+ "workers": build_workers(data.get("workers", []), data.get("rigforge_release")),
# Fleet power draw / efficiency and (once a price is set) net profit after power (#260),
# with live feed prices (#520) when dashboard.energy.price_feed is on.
"energy": build_energy(data.get("workers", []), data.get("prices")),
diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs
index 8b1675f1..fcdd7aa0 100644
--- a/build/dashboard/tests/frontend/components.test.mjs
+++ b/build/dashboard/tests/frontend/components.test.mjs
@@ -603,6 +603,20 @@ test('WorkersTable renders the RigForge version badge + chips when present, noth
assert.match(html, /142 W · 86.9 H\/s·W/);
});
+test('WorkersTable badges a rig running an older RigForge — and only that rig (#596)', () => {
+ const s = clone();
+ s.workers[0].rigforge_update = { available: true, latest: 'v1.11.2', url: 'https://h/v1.11.2' };
+ s.workers[1].rigforge_update = null; // current / plain-xmrig rig -> no badge
+ const html = renderApp({ state: s });
+ assert.match(html, /rf v1\.11\.2 available/); // the accent callout renders
+ assert.match(html, /A newer RigForge release is available: v1\.11\.2/); // tooltip
+ assert.equal(html.match(/rf v1\.11\.2 available/g).length, 1); // exactly one rig badged
+
+ const none = clone();
+ none.workers.forEach((w) => (w.rigforge_update = null));
+ assert.doesNotMatch(renderApp({ state: none }), /RigForge release is available/);
+});
+
test('Tari status gates the ✔ on a live gRPC channel, never on active-but-dead (#278/#313)', () => {
// The ✔ must mean the merge-mine channel is actually up. A dead channel that still reads "active"
// must show status-warn and NO check — otherwise a TRANSIENT_FAILURE reads as healthy (#278/#313).
diff --git a/build/dashboard/tests/frontend/fixtures/state.json b/build/dashboard/tests/frontend/fixtures/state.json
index 307bde57..d8c67535 100644
--- a/build/dashboard/tests/frontend/fixtures/state.json
+++ b/build/dashboard/tests/frontend/fixtures/state.json
@@ -355,6 +355,7 @@
"level": "ok",
"percent": "0%",
"total": "0.0",
+ "unit": "GB",
"used": "0.0",
"width": "0%"
},
@@ -637,6 +638,7 @@
],
"version": null
},
+ "rigforge_update": null,
"status": "online",
"uptime": 0,
"uptime_str": "0m 0s"
@@ -676,6 +678,7 @@
],
"version": null
},
+ "rigforge_update": null,
"status": "offline",
"uptime": 0,
"uptime_str": "0m 0s"
diff --git a/build/dashboard/tests/frontend/workerview.test.mjs b/build/dashboard/tests/frontend/workerview.test.mjs
index 7acde8c2..337d7f82 100644
--- a/build/dashboard/tests/frontend/workerview.test.mjs
+++ b/build/dashboard/tests/frontend/workerview.test.mjs
@@ -233,3 +233,20 @@ test("no applied config versions yet falls back to an explanatory message", () =
const out = renderToString(readyInstance({ ...DETAIL, hashrate_by_config: [] }).render());
assert.match(out, /No applied config changes to correlate hashrate against yet/);
});
+
+// --- RigForge new-release callout (#596) ----------------------------------------------------
+
+test("Inspect surfaces the RigForge new-release callout only when the server derived one (#596)", () => {
+ const behind = {
+ ...DETAIL,
+ rigforge: { version: "1.11.1", stats: [] },
+ rigforge_update: { available: true, latest: "v1.11.2", url: "https://h/v1.11.2" },
+ };
+ const out = renderToString(readyInstance(behind).render());
+ assert.match(out, /New RigForge release v1\.11\.2 available/);
+ assert.match(out, /href="https:\/\/h\/v1\.11\.2"/);
+
+ // Current rig / plain xmrig: the server sends null -> no callout, no error.
+ const current = { ...DETAIL, rigforge_update: null };
+ assert.doesNotMatch(renderToString(readyInstance(current).render()), /New RigForge release/);
+});
diff --git a/build/dashboard/tests/service/test_data_service.py b/build/dashboard/tests/service/test_data_service.py
index 30c17fb5..3c404a62 100644
--- a/build/dashboard/tests/service/test_data_service.py
+++ b/build/dashboard/tests/service/test_data_service.py
@@ -495,11 +495,26 @@ def test_restored_snapshot_never_resurrects_the_update_badge(self):
sm.load_snapshot.return_value = {
"total_live_h15": 5000,
"update": {"available": True, "latest": "v1.9.1", "url": "u"},
+ # #596: same rule for the fleet-wide RigForge release — restored with the flag now
+ # off, it would keep serving stale per-worker badges until the first poll cycle.
+ "rigforge_release": {"tag": "v1.11.2", "url": "u"},
}
svc = DataService(sm, MagicMock(), MagicMock())
assert svc.latest_data.get("update") in (None, {}) # never the restored dict
+ assert svc.latest_data.get("rigforge_release") is None # nor the RigForge one (#596)
assert svc.latest_data["total_live_h15"] == 5000 # the rest of the snapshot survives
+ def test_rigforge_checker_wired_to_the_rigforge_api_under_the_same_flag(self):
+ # #596 wiring: one fleet-wide RigForge release checker, pointed at the RigForge repo,
+ # gated on the SAME dashboard.check_for_updates flag as the stack's own check.
+ sm = MagicMock()
+ sm.load_snapshot.return_value = None
+ svc = DataService(sm, MagicMock(), MagicMock())
+ assert svc.rigforge_update_checker.client.api_url == ds_mod.GITHUB_RIGFORGE_RELEASES_API
+ assert "rigforge" in svc.rigforge_update_checker.client.api_url
+ assert svc.rigforge_update_checker.enabled == svc.update_checker.enabled
+ assert svc.rigforge_update_checker.client.tor_proxy == svc.update_checker.client.tor_proxy
+
def test_ignores_non_dict_snapshot(self):
sm = MagicMock()
sm.load_snapshot.return_value = None
diff --git a/build/dashboard/tests/service/test_update_checker.py b/build/dashboard/tests/service/test_update_checker.py
index 0b350549..920f010d 100644
--- a/build/dashboard/tests/service/test_update_checker.py
+++ b/build/dashboard/tests/service/test_update_checker.py
@@ -48,6 +48,12 @@ def test_major_and_minor_ordering(self):
assert compute_update("1.9.9", "v2.0.0", "u")["latest"] == "v2.0.0"
assert compute_update("1.2.0", "v1.10.0", "u")["latest"] == "v1.10.0" # 10 > 2, not lexical
+ def test_bare_rig_version_vs_v_prefixed_tag(self):
+ # The rig reports bare "1.11.2"; RigForge release tags are "v1.11.2" (#596). Equality
+ # must hold across the format difference — a current rig never badges its own version.
+ assert compute_update("1.11.2", "v1.11.2", "u") is None
+ assert compute_update("1.11.1", "v1.11.2", "u")["latest"] == "v1.11.2"
+
class TestGitHubReleaseClient:
def _resp(self, status=200, payload=None):
@@ -133,3 +139,30 @@ def test_failed_fetch_keeps_previous_result(self):
def test_up_to_date_yields_none(self):
uc = UpdateChecker(_FakeClient({"tag": "v0.1.0", "url": "u"}), "0.1.0", enabled=True)
assert uc.maybe_check(1000) is None
+
+
+class TestLatestReleaseCached:
+ """The raw-release accessor (#596): one throttled fleet-wide fetch, many consumers."""
+
+ def test_disabled_never_calls_and_returns_none(self):
+ c = _FakeClient({"tag": "v1.11.2", "url": "u"})
+ uc = UpdateChecker(c, None, enabled=False)
+ assert uc.latest_release_cached(1000) is None
+ assert c.calls == 0
+
+ def test_returns_raw_release_and_throttles(self):
+ c = _FakeClient({"tag": "v1.11.2", "url": "https://h/v1.11.2"})
+ uc = UpdateChecker(c, None, enabled=True, interval=3600)
+ assert uc.latest_release_cached(1000) == {"tag": "v1.11.2", "url": "https://h/v1.11.2"}
+ assert uc.latest_release_cached(1000 + 1800) == {
+ "tag": "v1.11.2",
+ "url": "https://h/v1.11.2",
+ }
+ assert c.calls == 1 # within the window: cached, no network
+ uc.latest_release_cached(1000 + 3601)
+ assert c.calls == 2
+
+ def test_failed_fetch_keeps_previous_release(self):
+ uc = UpdateChecker(_FakeClient(None), None, enabled=True, interval=0)
+ uc.release = {"tag": "v1.11.2", "url": "u"}
+ assert uc.latest_release_cached(2000)["tag"] == "v1.11.2" # a blip keeps the cache
diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py
index cfdfae8d..3afdee7b 100644
--- a/build/dashboard/tests/web/test_views.py
+++ b/build/dashboard/tests/web/test_views.py
@@ -51,6 +51,7 @@
host_display_addr,
parse_window,
recent_wallet_change,
+ rigforge_update_for,
visible_update,
)
@@ -942,6 +943,79 @@ def test_reject_flag_set_on_high_reject_rate(self):
assert "10.0%" in row["reject_flag"]["title"]
+class TestRigforgeUpdate:
+ """The per-worker RigForge new-release callout (#596), derived at the render seam."""
+
+ _REL = {"tag": "v1.11.2", "url": "https://h/v1.11.2"}
+ _W = {"name": "r", "ip": "10.0.0.1", "status": "online", "active_pool": "3333"}
+
+ def test_behind_rig_gets_the_callout(self):
+ w = {**self._W, "rigforge": {"version": "1.11.1"}}
+ out = rigforge_update_for(w, self._REL)
+ assert out == {"available": True, "latest": "v1.11.2", "url": "https://h/v1.11.2"}
+
+ def test_current_rig_never_badges_its_own_version(self):
+ # The rig reports bare "1.11.2"; the tag is "v1.11.2" — equality must hold across the
+ # format difference (the #664 self-consistency guard, per-worker edition).
+ w = {**self._W, "rigforge": {"version": "1.11.2"}}
+ assert rigforge_update_for(w, self._REL) is None
+
+ def test_newer_or_unparseable_rig_version_is_none(self):
+ assert rigforge_update_for({**self._W, "rigforge": {"version": "9.0.0"}}, self._REL) is None
+ assert (
+ rigforge_update_for({**self._W, "rigforge": {"version": "nightly"}}, self._REL) is None
+ )
+
+ def test_no_version_or_no_release_is_none(self):
+ # A plain-:8080 rig reports no version: no badge, not a false "up to date". No cached
+ # release (check disabled / offline): same.
+ assert rigforge_update_for(self._W, self._REL) is None
+ assert rigforge_update_for({**self._W, "rigforge": {"version": "1.11.1"}}, None) is None
+
+ def test_build_workers_attaches_per_row(self):
+ rows = build_workers(
+ [
+ {**self._W, "name": "behind", "rigforge": {"version": "1.11.1"}},
+ {**self._W, "name": "current", "rigforge": {"version": "1.11.2"}},
+ {**self._W, "name": "plain"},
+ ],
+ self._REL,
+ )
+ by = {r["name"]: r["rigforge_update"] for r in rows}
+ assert by["behind"]["latest"] == "v1.11.2"
+ assert by["current"] is None
+ assert by["plain"] is None
+
+ def test_build_workers_without_release_attaches_none(self):
+ rows = build_workers([{**self._W, "rigforge": {"version": "1.11.1"}}])
+ assert rows[0]["rigforge_update"] is None
+
+ def test_build_state_feeds_the_cached_release_through(self):
+ data = _data(
+ workers=[{**self._W, "rigforge": {"version": "1.11.1"}}],
+ rigforge_release=self._REL,
+ )
+ st = build_state(data, _state_mgr(), "all")
+ assert st["workers"][0]["rigforge_update"]["latest"] == "v1.11.2"
+
+ def test_worker_detail_attaches_the_callout(self):
+ from mining_dashboard.service.storage_service import StateManager
+
+ sm = StateManager(db_path=":memory:")
+ try:
+ d = build_worker_detail(
+ "r",
+ {
+ "workers": [{**self._W, "rigforge": {"version": "1.11.1"}}],
+ "rigforge_release": self._REL,
+ },
+ sm,
+ )
+ finally:
+ sm.close()
+ assert d["rigforge_update"]["latest"] == "v1.11.2"
+
+
class TestRejectFlag:
"""The per-worker reject-rate flag (Issue #82)."""
diff --git a/docs/configuration.md b/docs/configuration.md
index 97a8afd4..b5580950 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -139,7 +139,7 @@ control channel will commit, are unaffected either way.
| `dashboard.control.enabled` | `false` _(off)_ | Security-relevant, default off. `true` turns on the dashboard's **Configuration view**: edit `config.json` from the browser, preview the changes, and apply them. The dashboard container never runs `pithead` itself — it writes a typed change request into a spool directory and a root systemd unit on the host (`pithead-control`) validates and applies it (see [Dashboard › Configuration view](dashboard.md#configuration-view)). Fails closed: enabling it without a `dashboard.auth.password` is a validation error, because this channel can change the payout wallet. |
| `dashboard.timezone` | `auto` | Timezone for the dashboard's timestamps and charts. `auto` = the host machine's timezone (auto-detected, falling back to `Etc/UTC`); set an IANA name (e.g. `America/Chicago`) to override. |
| `dashboard.data_dir` | `auto` | Where the dashboard's database lives. `auto` = `./data/dashboard`, unless the four other `*.data_dir` all point under one parent directory — then the dashboard joins them at `/dashboard`, and the first `upgrade`/`apply` moves data from the old default there automatically (see [Data directories](#data-directories)). |
-| `dashboard.check_for_updates` | `true` _(on)_ | The dashboard periodically asks GitHub whether a newer Pithead release exists and, if so, shows a header badge linking to it (e.g. "New release v1.4.0 available"). Notify-only: it never updates anything; you upgrade with `./pithead upgrade` on your own terms. On by default because the check is routed over Tor (the same bridge SOCKS as the XvB fetch, `socks5h` so the DNS lookup goes through Tor too), so GitHub sees a Tor exit, not your IP. It's cached (hourly) and fails silently offline. Set to `false` to opt out entirely. See [Privacy › Runtime egress](privacy.md#runtime-egress). |
+| `dashboard.check_for_updates` | `true` _(on)_ | The dashboard periodically asks GitHub whether a newer Pithead release exists and, if so, shows a header badge linking to it (e.g. "New release v1.4.0 available"). Notify-only: it never updates anything; you upgrade with `./pithead upgrade` on your own terms. On by default because the check is routed over Tor (the same bridge SOCKS as the XvB fetch, `socks5h` so the DNS lookup goes through Tor too), so GitHub sees a Tor exit, not your IP. It's cached (hourly) and fails silently offline. The same flag also covers the per-worker [RigForge new-release badge](workers.md#rigforge-new-release-badge) — one more hourly, Tor-routed fetch of the latest RigForge release, compared against every rig's reported version. Set to `false` to opt out of both. See [Privacy › Runtime egress](privacy.md#runtime-egress). |
| `dashboard.hashrate_drop_threshold` | `50` | Percent below the recent normal that counts as a hashrate drop for the `hashrate_loss` alert and its chart marker. `50` = fire when total fleet hashrate falls to half its baseline. Raise it to catch smaller dips, lower it to only flag near-total outages. |
| `dashboard.hashrate_drop_minutes` | `10` | How many minutes the hashrate must stay below the threshold before the drop is reported — the debounce that keeps a brief blip from pinging you. |
| `dashboard.tari_required` | `true` | How much a Tari problem holds up the rest of the stack. Monero is required to mine, so its behavior isn't configurable: a monerod outage always rejects workers (stops `xmrig-proxy` so miners fail over to their backup pools), and the miner is always held until monerod finishes syncing. Tari is only needed for merge-mining, so this one flag decides how much it blocks. `true` (default): a Tari outage also rejects workers, the miner waits for Tari's initial sync too, and a Tari-only (re)sync shows the full-screen Sync view. `false` (non-blocking): keep mining Monero through a Tari outage, start mining as soon as Monero is synced (Tari finishes in the background), and keep the normal dashboard, with a `Tari syncing` indicator, instead of the takeover screen. |
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 27c0a2dc..afb80e88 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -240,6 +240,11 @@ but its miner isn't, the rig stays in the table with a **miner down** chip rathe
offline. Point the rig's descriptor at the enriched feed to turn this on — see
[Connecting Miners › RigForge enriched feed](workers.md#rigforge-enriched-feed).
+With `dashboard.check_for_updates` on, a rig reporting a RigForge version older than the latest
+published release also gets a clickable `rf vX.Y.Z available ↗` badge — the per-worker twin of the
+header's new-release badge, notify-only, linking to the RigForge release notes. See
+[Connecting Miners › RigForge new-release badge](workers.md#rigforge-new-release-badge).
+
Each rig shows accepted and rejected share counts (invalid shares folded into the rejected column as
`3 (+2 inv)` when present). A rig whose reject rate climbs past ~5% gets a red **⚠** flag next to its
rejected count — a rig submitting stale or bad shares (bad overclock, flaky network, clock drift)
diff --git a/docs/dev/testing-strategy.md b/docs/dev/testing-strategy.md
index d3c027e8..d66fde4f 100644
--- a/docs/dev/testing-strategy.md
+++ b/docs/dev/testing-strategy.md
@@ -143,6 +143,7 @@ it — the worker-API **auth model is the #315 none/name/token matrix**, not the
| Worker Inspect edit lands on the rig: `editable` true, a `max_temp_c` nudge via `/api/control/worker-apply` hits the rig's `/status` + records history, reverted | live rig control API on | 4 ▶ (`run.sh --rigforge-control`, #513) |
| Rig-side edit reflects: a direct rig control-API change shows in the enriched feed; a `config.json` hand-edit shows in the masked prefill | live rig + direct dial | 4 ▶ (`run.sh --rigforge-control`, #516) |
| Control-apply auto-rollback (rigforge#236): a hashrate-tanking change is recorded `rolled_back` in the worker-apply result + per-worker history | live rig + fault-injection | 4 ▶ (`run.sh --rigforge-control`, #517; operator supplies `IT_RIG_ROLLBACK_CHANGES`) |
+| Per-worker RigForge new-release badge (#596): one fleet-wide latest-release fetch (hourly throttle, disabled = never dials) compared against each rig's reported `version`, bare-vs-`v`-prefixed SemVer | `dashboard.check_for_updates` + rig-reported version | 1 ✅ (`test_update_checker` comparison/throttle/no-dial; `test_views` + node tests for the per-worker plumbing) · 4 (deferred — live badge on a real rig, owed to the #597 gouda loaner session) |
| Stratum auth accept/reject: matching `pass` mines, wrong/missing `pass` rejected, rotation | live proxy `--access-password` | 4 (deferred — a headless xmrig login probe, real proxy binary) |
| Dev-fee independence (#173): proxy `--donate-level` and rig `DONATION` both honored | live proxy + rig | 4 (deferred) |
diff --git a/docs/privacy.md b/docs/privacy.md
index 816f037c..30fc5795 100644
--- a/docs/privacy.md
+++ b/docs/privacy.md
@@ -77,7 +77,7 @@ What the running stack sends to the internet, connection by connection.
| Dashboard **XvB raffle registration** (#263) | `xmrvsbeast.com` | your Monero **wallet** (no longer your IP) | ✅ Tor (`socks5h`, same path as the stats fetch) | on, only if XvB enabled; fires once you have a PPLNS share | `XVB_ENABLED=false`, or set `XVB_SUBMIT_URL` to a disable sentinel (`off`), to stop it |
| Dashboard **XvB winners fetch** (raffle-wins display) | `xmrvsbeast.com` | nothing — the winners file is public and the request carries no wallet | ✅ Tor (`socks5h`, same path as the stats fetch) | on, only if XvB enabled | `XVB_ENABLED=false` stops it |
| **XvB donation mining** (only while donating) | `na.xmrvsbeast.com:4247` via Tor | — | ✅ **Tor** (per-pool `socks5`, DNS proxy-side) by default (#166) | on while donating | opt out with `xvb.tor: false` (exposes IP for max yield); `xvb.enabled: false` stops it entirely |
-| Dashboard **update check** (#224) | `api.github.com` | nothing about you — GitHub sees a **Tor exit**, not your IP | ✅ Tor (`socks5h`) | **on** | `dashboard.check_for_updates: false` to opt out; cached, fails silently offline |
+| Dashboard **update check** (#224, plus the per-worker RigForge badge #596) | `api.github.com` | nothing about you — GitHub sees a **Tor exit**, not your IP; the RigForge check sends no rig versions, it only reads the latest release | ✅ Tor (`socks5h`) | **on** | `dashboard.check_for_updates: false` opts out of both; cached, fails silently offline |
| **Caddy** TLS (dashboard HTTPS) | local only | — | n/a — `tls internal`, **no ACME / no external CA** | on | clean (no egress) |
| **Telegram** bot (#121) | `api.telegram.org` | nothing about you — Telegram sees a **Tor exit**, not your IP | ✅ **always** Tor (`socks5h`, #340) | **off** | opt-in; both the alert sends and the command poll ride Tor |
| Dashboard **Healthchecks** ping (#79) | `hc-ping.com` (or self-hosted) | nothing about you — the endpoint sees a **Tor exit**, not your IP | ✅ **always** Tor (`socks5h`) | opt-in (set `healthchecks.ping_url`; off until set) | the ping URL must be Tor-reachable (hosted, public, or an onion self-hosted instance) — there is no clearnet mode |
diff --git a/docs/workers.md b/docs/workers.md
index 17cc6011..aae4be13 100644
--- a/docs/workers.md
+++ b/docs/workers.md
@@ -322,6 +322,20 @@ control port.
[TODO: verify upstream — confirm the `rigforge.control` mirror ships in RigForge; until then this
parses to nothing and the row stays `accepted`.]
+#### RigForge new-release badge
+
+When `dashboard.check_for_updates` is on, the dashboard also compares each rig's reported RigForge
+version against the latest published RigForge release and badges the rigs that are behind — in the
+Workers Alive table and in Worker Inspect, next to the version. The badge is notify-only and links
+to the release notes; upgrading the rig is still done on the rig.
+
+One fetch covers the whole fleet: the release check is hourly, rides the same Tor route as the
+stack's own new-release check, and fails silent offline (no badge, no error). The same
+`dashboard.check_for_updates` flag gates both checks — off means neither dials GitHub.
+
+Only rigs on the enriched `8081` feed report a version. A plain-xmrig rig on `8080` has no version
+to compare, so it shows no badge — that reads as "unknown", not "up to date".
+
---
## New to mining? Start with RigForge
From 10aeb9646bac3f99b7bc4ba65dc2812b4ee62199 Mon Sep 17 00:00:00 2001
From: Vijit Singh
Date: Mon, 20 Jul 2026 19:19:55 -0500
Subject: [PATCH 04/11] =?UTF-8?q?security(#698):=20bound=20the=20remaining?=
=?UTF-8?q?=20external=20GETs=20=E2=80=94=20Tor=20probe,=20Healthchecks=20?=
=?UTF-8?q?ping,=20Telegram=20getUpdates=20(#699)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* security(#660): shared response-size cap for every external HTTP fetch
All five external call sites — the GitHub release check (#224), the three
XvB reads (stats, reward estimates, winners), and the CoinGecko price
feed (#651) — read via a shared helper/http.py bounded_get that streams
the body and cuts it at 1 MiB. Over-cap raises a RequestException
subclass, so each client's existing fail-silent contract (None / keep
last good) applies unchanged. Known residual from the #642/#646 security
reviews; the local Telegram/healthchecks/monerod clients keep their own
contracts and stay out of scope.
Closes #660
Co-Authored-By: Claude Fable 5
* security(#660): review fixes — scope the claim to the five capped clients, pin the at-cap boundary, widen the drift guard
The changelog headline claimed "every external dashboard fetch" is
size-capped, but the Tor heal probe, Telegram long-poll, and
healthchecks ping keep their own contracts — the entry now names the
actual scope: the update check, the price feed, and the XvB client's
calls. A new tier-1 test pins the strictly-greater cap (a body of
exactly max_bytes succeeds; a regression to >= fails it), and the
wiring drift guard now rejects any direct requests.( in the
client modules, not just requests.get(.
Co-Authored-By: Claude Fable 5
* security(#698): bound the remaining external GETs — Tor probe, Healthchecks ping, Telegram getUpdates
The #660 cap now covers every external GET. The Tor egress probe and
the Healthchecks ping are one-word conversions: both already treat any
RequestException as failure, so an over-cap body follows the existing
contract. The Telegram long-poll keeps its (connect, read) timeout
tuple — bounded_get streams, but headers only arrive once the hold
ends, so the read timeout still spans it — and gains a getUpdates
limit of 10: the offset only advances after a batch is parsed, so an
uncapped batch that tripped the size cap would be re-fetched forever
and wedge the poll loop. Offset priming drains batch by batch to keep
skipping the whole startup backlog. BoundedResponse grows the
raise_for_status the long-poll already called, raising HTTPError to
match requests' contract.
Closes #698
Co-Authored-By: Claude Fable 5
* style: ruff format on the at-cap boundary test
Co-Authored-By: Claude Fable 5
* test(#698): pin the long-poll (connect, read) timeout tuple
Issue #698's first hold-while-converting invariant — the read timeout
must outlast Telegram's long-poll hold — had no test: dropping the
tuple would abort every legitimate poll mid-hold and nothing would
fail. Assert it behaviourally (read > requested hold) in the existing
getUpdates wiring test instead of pinning the exact numbers.
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Claude Fable 5
---
CHANGELOG.md | 4 ++-
.../dashboard/mining_dashboard/helper/http.py | 12 +++++--
.../mining_dashboard/service/healthchecks.py | 3 +-
.../service/telegram_commands.py | 19 +++++++----
.../mining_dashboard/service/tor_heal.py | 3 +-
build/dashboard/tests/helper/test_http.py | 21 +++++++++++-
.../tests/service/test_healthchecks.py | 34 +++++++++----------
.../tests/service/test_telegram_commands.py | 33 ++++++++++++------
.../dashboard/tests/service/test_tor_heal.py | 4 +--
9 files changed, 90 insertions(+), 43 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6bc8498..226a69d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,7 +36,9 @@ per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
now stream their responses through a shared `bounded_get` helper that cuts the body at 1 MiB,
so a hostile or broken endpoint can no longer make these clients buffer an unbounded payload.
Over-cap reads follow each client's existing failure contract (no result / keep the last good
- one).
+ one). The remaining external GETs — the Tor egress probe, the Healthchecks ping, and the
+ Telegram `getUpdates` long-poll — ride the same cap; `getUpdates` also caps its batch at 10
+ updates so a capped batch can never wedge the poll loop on an offset it cannot advance.
## [1.9.3] - 2026-07-19
diff --git a/build/dashboard/mining_dashboard/helper/http.py b/build/dashboard/mining_dashboard/helper/http.py
index accea86c..cdb8c181 100644
--- a/build/dashboard/mining_dashboard/helper/http.py
+++ b/build/dashboard/mining_dashboard/helper/http.py
@@ -1,8 +1,9 @@
"""Bounded reads for every external HTTP fetch (#660).
Each external client (GitHub release checks #224, the three XvB reads, the CoinGecko price feed
-#651) is individually tolerant of a bad *parse*, but nothing bounded what got *read*: a hostile or
-broken endpoint could hand any of them a multi-GB body and the process would buffer it all before
+#651, the Tor egress probe, the Healthchecks ping, the Telegram getUpdates long-poll) is
+individually tolerant of a bad *parse*, but nothing bounded what got *read*: a hostile or broken
+endpoint could hand any of them a multi-GB body and the process would buffer it all before
parsing. ``bounded_get`` streams the body and cuts it at a cap far above any legitimate payload;
over-cap raises a ``requests.RequestException`` subclass, so every caller's existing failure
contract (return ``None`` / keep the last good result) applies unchanged.
@@ -24,7 +25,7 @@ class ResponseTooLarge(requests.RequestException):
class BoundedResponse:
"""The slice of ``requests.Response`` the clients actually use: ``status_code``, ``text``,
- ``json()`` — backed by the capped body."""
+ ``json()``, ``raise_for_status()`` — backed by the capped body."""
def __init__(self, status_code, content, encoding):
self.status_code = status_code
@@ -38,6 +39,11 @@ def text(self):
def json(self):
return json.loads(self.text)
+ def raise_for_status(self):
+ # HTTPError subclasses RequestException, matching requests' own contract.
+ if self.status_code >= 400:
+ raise requests.HTTPError(f"HTTP {self.status_code}")
+
def bounded_get(url, max_bytes=MAX_RESPONSE_BYTES, timeout=20, **kwargs):
"""``requests.get`` with a hard response-size cap.
diff --git a/build/dashboard/mining_dashboard/service/healthchecks.py b/build/dashboard/mining_dashboard/service/healthchecks.py
index 902921a6..7d8a5393 100644
--- a/build/dashboard/mining_dashboard/service/healthchecks.py
+++ b/build/dashboard/mining_dashboard/service/healthchecks.py
@@ -34,6 +34,7 @@
HEALTHCHECKS_PING_URL,
TOR_SOCKS_PROXY,
)
+from mining_dashboard.helper.http import bounded_get
logger = logging.getLogger("Healthchecks")
@@ -111,7 +112,7 @@ def ping(self):
return False
try:
- resp = requests.get(self.url, timeout=_PING_TIMEOUT_SEC, proxies=self._proxies)
+ resp = bounded_get(self.url, timeout=_PING_TIMEOUT_SEC, proxies=self._proxies)
if 200 <= resp.status_code < 300:
if self._last_ping_ok is False:
logger.info(
diff --git a/build/dashboard/mining_dashboard/service/telegram_commands.py b/build/dashboard/mining_dashboard/service/telegram_commands.py
index bd207ed6..c16c2276 100644
--- a/build/dashboard/mining_dashboard/service/telegram_commands.py
+++ b/build/dashboard/mining_dashboard/service/telegram_commands.py
@@ -17,6 +17,7 @@
TELEGRAM_ENABLED,
TOR_SOCKS_PROXY,
)
+from mining_dashboard.helper.http import bounded_get
from mining_dashboard.helper.utils import (
effective_hashrate,
format_disk_size,
@@ -40,6 +41,10 @@
# Quiet retry after a failed poll — a Tor-only / offline host can't reach api.telegram.org, so a
# persistently-blocked bot backs off instead of hot-looping (and never spams ERROR; #59 discipline).
POLL_ERROR_BACKOFF_SECONDS = 15
+# getUpdates batch cap. The offset can only advance after a batch is *parsed*, so a batch that
+# trips bounded_get's size cap would be re-fetched forever — bounding the batch keeps the worst
+# case (10 updates at Telegram's own per-message field limits) far under the cap instead.
+GETUPDATES_LIMIT = 10
# The commands the bot answers. All are read-only status queries — the bot can never change the
# stack (start/stop/apply live on the CLI), so a leaked chat can at worst read status, not act.
@@ -733,10 +738,11 @@ async def run(self):
def _prime_offset(self):
"""Advance the offset past any pending backlog without acting on it, so a command queued
- while the dashboard was down isn't run on startup."""
+ while the dashboard was down isn't run on startup. Drains batch by batch: getUpdates
+ returns at most GETUPDATES_LIMIT updates per call, and returns immediately (timeout 0)
+ while a backlog remains."""
try:
- updates = self._get_updates(0)
- if updates:
+ while updates := self._get_updates(0):
self._offset = updates[-1].get("update_id", 0) + 1
except Exception as exc:
logger.debug("Telegram offset prime skipped (%s)", type(exc).__name__)
@@ -746,13 +752,14 @@ def _get_updates(self, poll_timeout):
# Ask Telegram for callback_query updates too when control commands are on — that is how a
# tapped inline confirm button arrives (#338); the read-only bot stays messages-only.
allowed = '["message","callback_query"]' if self.control_enabled else '["message"]'
- params = {"timeout": poll_timeout, "allowed_updates": allowed}
+ params = {"timeout": poll_timeout, "allowed_updates": allowed, "limit": GETUPDATES_LIMIT}
if self._offset is not None:
params["offset"] = self._offset
url = f"{self._api_base}/bot{self._token}/getUpdates"
# The read timeout must outlast Telegram's long-poll hold, or requests aborts the request
- # the server is legitimately keeping open; (connect, read) tuple.
- resp = requests.get(
+ # the server is legitimately keeping open; (connect, read) tuple. bounded_get streams, but
+ # the read timeout still covers the hold: headers only arrive once the hold ends.
+ resp = bounded_get(
url, params=params, timeout=(10, poll_timeout + 10), proxies=self._proxies
)
resp.raise_for_status()
diff --git a/build/dashboard/mining_dashboard/service/tor_heal.py b/build/dashboard/mining_dashboard/service/tor_heal.py
index 8540e1b2..d069f8ed 100644
--- a/build/dashboard/mining_dashboard/service/tor_heal.py
+++ b/build/dashboard/mining_dashboard/service/tor_heal.py
@@ -42,6 +42,7 @@
import requests
from mining_dashboard.config.config import TOR_AUTO_HEAL, TOR_SOCKS_PROXY
+from mining_dashboard.helper.http import bounded_get
logger = logging.getLogger("TorHeal")
@@ -100,7 +101,7 @@ def __init__(self, docker_control, enabled=None, probe=None, notify=None, clock=
def _probe_egress():
"""One SOCKS request through the tor container; True iff a clearnet exit answered."""
try:
- requests.get(
+ bounded_get(
PROBE_URL,
timeout=PROBE_TIMEOUT_SEC,
proxies={"http": TOR_SOCKS_PROXY, "https": TOR_SOCKS_PROXY},
diff --git a/build/dashboard/tests/helper/test_http.py b/build/dashboard/tests/helper/test_http.py
index 5b8daa8d..b99a5554 100644
--- a/build/dashboard/tests/helper/test_http.py
+++ b/build/dashboard/tests/helper/test_http.py
@@ -7,7 +7,7 @@
import pytest
import requests
-from mining_dashboard.helper.http import ResponseTooLarge, bounded_get
+from mining_dashboard.helper.http import BoundedResponse, ResponseTooLarge, bounded_get
def _streaming_resp(chunks, status_code=200, encoding="utf-8"):
@@ -85,6 +85,13 @@ def test_bad_encoding_degrades_not_crashes(self):
resp = bounded_get("https://example.test/x")
assert isinstance(resp.text, str) # errors="replace", never a decode crash
+ def test_raise_for_status_matches_requests_contract(self):
+ # The Telegram long-poll calls raise_for_status(); an HTTP error must surface as a
+ # RequestException so the caller's existing catch-and-backoff applies unchanged.
+ BoundedResponse(200, b"", "utf-8").raise_for_status() # 2xx: no raise
+ with pytest.raises(requests.RequestException):
+ BoundedResponse(502, b"", "utf-8").raise_for_status()
+
class TestWiringDriftGuard:
def test_external_clients_route_through_bounded_get(self):
@@ -94,6 +101,8 @@ def test_external_clients_route_through_bounded_get(self):
pkg / "service" / "update_checker.py",
pkg / "service" / "price_feed.py",
pkg / "client" / "xvb_client.py",
+ pkg / "service" / "tor_heal.py",
+ pkg / "service" / "healthchecks.py",
]
direct_call = re.compile(r"requests\.(get|post|put|delete|head|request)\(")
for mod in external:
@@ -103,3 +112,13 @@ def test_external_clients_route_through_bounded_get(self):
f"{mod.name}: unbounded {hit.group() if hit else ''} slipped back in"
)
assert "bounded_get(" in src, f"{mod.name}: no bounded_get call found"
+
+ def test_telegram_get_updates_routes_through_bounded_get(self):
+ """The getUpdates long-poll is bounded. The module's requests.post( sends (sendMessage,
+ answerCallbackQuery) keep their own contract — tiny echo bodies of caller-authored
+ payloads — so only GETs are drift here."""
+ pkg = Path(__file__).resolve().parents[2] / "mining_dashboard"
+ src = (pkg / "service" / "telegram_commands.py").read_text()
+ hit = re.search(r"requests\.(get|head|request)\(", src)
+ assert hit is None, f"telegram_commands.py: unbounded {hit.group() if hit else ''}"
+ assert "bounded_get(" in src
diff --git a/build/dashboard/tests/service/test_healthchecks.py b/build/dashboard/tests/service/test_healthchecks.py
index 0e683cdf..89d80d7a 100644
--- a/build/dashboard/tests/service/test_healthchecks.py
+++ b/build/dashboard/tests/service/test_healthchecks.py
@@ -59,7 +59,7 @@ def test_no_url_is_a_silent_noop(self, caplog):
# A blank ping URL is simply "off" — ping() does nothing, opens no socket, logs nothing.
c = _client(ping_url="")
with (
- patch.object(hc_mod.requests, "get") as get,
+ patch.object(hc_mod, "bounded_get") as get,
caplog.at_level(logging.DEBUG, logger="Healthchecks"),
):
assert c.ping() is False
@@ -79,7 +79,7 @@ def test_healthy_ping_hits_the_url(self):
# Pure liveness: every ping hits the configured URL (no /fail path — health-aware was
# dropped; node-health alerting is the Telegram alerter's job, #121).
c = _client()
- with patch.object(hc_mod.requests, "get", return_value=_resp(200)) as get:
+ with patch.object(hc_mod, "bounded_get", return_value=_resp(200)) as get:
assert c.ping() is True
get.assert_called_once()
assert get.call_args.args[0] == "https://hc-ping.com/abc"
@@ -92,7 +92,7 @@ def test_404_returns_false_and_does_not_advance_throttle(self, caplog):
clock = _Clock(1000.0)
c = _client(clock=clock, interval_seconds=60)
with (
- patch.object(hc_mod.requests, "get", return_value=_resp(404)),
+ patch.object(hc_mod, "bounded_get", return_value=_resp(404)),
caplog.at_level(logging.WARNING, logger="Healthchecks"),
):
assert c.ping() is False
@@ -104,18 +104,18 @@ def test_200_still_returns_true_and_advances_throttle(self):
# Regression guard: the happy path must keep working once status is checked.
clock = _Clock(1000.0)
c = _client(clock=clock, interval_seconds=60)
- with patch.object(hc_mod.requests, "get", return_value=_resp(200)):
+ with patch.object(hc_mod, "bounded_get", return_value=_resp(200)):
assert c.ping() is True
assert c.ping() is False # throttled, since the first ping DID advance the clock
clock.t += 61
- with patch.object(hc_mod.requests, "get", return_value=_resp(200)):
+ with patch.object(hc_mod, "bounded_get", return_value=_resp(200)):
assert c.ping() is True
def test_two_consecutive_404s_warn_only_once(self, caplog):
clock = _Clock(1000.0)
c = _client(clock=clock, interval_seconds=60)
with (
- patch.object(hc_mod.requests, "get", return_value=_resp(404)),
+ patch.object(hc_mod, "bounded_get", return_value=_resp(404)),
caplog.at_level(logging.WARNING, logger="Healthchecks"),
):
assert c.ping() is False
@@ -127,9 +127,9 @@ def test_404_then_200_logs_recovery(self, caplog):
clock = _Clock(1000.0)
c = _client(clock=clock, interval_seconds=60)
with caplog.at_level(logging.DEBUG, logger="Healthchecks"):
- with patch.object(hc_mod.requests, "get", return_value=_resp(404)):
+ with patch.object(hc_mod, "bounded_get", return_value=_resp(404)):
assert c.ping() is False
- with patch.object(hc_mod.requests, "get", return_value=_resp(200)):
+ with patch.object(hc_mod, "bounded_get", return_value=_resp(200)):
assert c.ping() is True
recoveries = [r for r in caplog.records if "recovered" in r.message]
assert len(recoveries) == 1
@@ -137,7 +137,7 @@ def test_404_then_200_logs_recovery(self, caplog):
def test_5xx_also_rejected(self):
c = _client()
- with patch.object(hc_mod.requests, "get", return_value=_resp(503)):
+ with patch.object(hc_mod, "bounded_get", return_value=_resp(503)):
assert c.ping() is False
@@ -146,7 +146,7 @@ def test_ping_passes_socks_proxies(self):
# tor_proxy set → the ping rides the bridge Tor SOCKS (host IP hidden from the endpoint).
proxy = "socks5h://172.28.0.25:9050"
c = _client(tor_proxy=proxy)
- with patch.object(hc_mod.requests, "get", return_value=_resp(200)) as get:
+ with patch.object(hc_mod, "bounded_get", return_value=_resp(200)) as get:
assert c.ping() is True
assert get.call_args.kwargs["proxies"] == {"http": proxy, "https": proxy}
@@ -157,7 +157,7 @@ def test_from_config_always_routes_over_tor(self):
patch.object(hc_mod, "TOR_SOCKS_PROXY", "socks5h://172.28.0.25:9050"),
):
c = HealthchecksClient.from_config()
- with patch.object(hc_mod.requests, "get") as get:
+ with patch.object(hc_mod, "bounded_get") as get:
c.ping()
assert get.call_args.kwargs["proxies"]["https"] == "socks5h://172.28.0.25:9050"
@@ -166,7 +166,7 @@ class TestThrottle:
def test_second_immediate_ping_is_throttled(self):
clock = _Clock(1000.0)
c = _client(clock=clock, interval_seconds=60)
- with patch.object(hc_mod.requests, "get", return_value=_resp(200)) as get:
+ with patch.object(hc_mod, "bounded_get", return_value=_resp(200)) as get:
assert c.ping() is True # first ping goes out
assert c.ping() is False # within the interval → skipped
get.assert_called_once()
@@ -174,7 +174,7 @@ def test_second_immediate_ping_is_throttled(self):
def test_ping_again_after_interval_elapses(self):
clock = _Clock(1000.0)
c = _client(clock=clock, interval_seconds=60)
- with patch.object(hc_mod.requests, "get", return_value=_resp(200)) as get:
+ with patch.object(hc_mod, "bounded_get", return_value=_resp(200)) as get:
assert c.ping() is True
clock.t += 61 # interval elapsed
assert c.ping() is True
@@ -183,7 +183,7 @@ def test_ping_again_after_interval_elapses(self):
def test_zero_interval_pings_every_call(self):
clock = _Clock(1000.0)
c = _client(clock=clock, interval_seconds=0)
- with patch.object(hc_mod.requests, "get", return_value=_resp(200)) as get:
+ with patch.object(hc_mod, "bounded_get", return_value=_resp(200)) as get:
assert c.ping() is True
assert c.ping() is True
assert get.call_count == 2
@@ -195,7 +195,7 @@ def test_network_error_is_swallowed_and_retries(self, caplog):
c = _client(clock=clock, interval_seconds=60)
with (
patch.object(
- hc_mod.requests, "get", side_effect=requests.exceptions.ConnectionError("offline")
+ hc_mod, "bounded_get", side_effect=requests.exceptions.ConnectionError("offline")
) as get,
caplog.at_level(logging.DEBUG, logger="Healthchecks"),
):
@@ -210,8 +210,8 @@ def test_success_after_failure_advances_throttle(self):
clock = _Clock(1000.0)
c = _client(clock=clock, interval_seconds=60)
with patch.object(
- hc_mod.requests,
- "get",
+ hc_mod,
+ "bounded_get",
side_effect=[requests.exceptions.ConnectionError("x"), _resp(200)],
):
assert c.ping() is False # failed, no throttle advance
diff --git a/build/dashboard/tests/service/test_telegram_commands.py b/build/dashboard/tests/service/test_telegram_commands.py
index 277a0ea5..9c4c5122 100644
--- a/build/dashboard/tests/service/test_telegram_commands.py
+++ b/build/dashboard/tests/service/test_telegram_commands.py
@@ -631,30 +631,41 @@ def test_get_updates_parses_results_over_tor(monkeypatch):
seen = {}
def fake_get(url, params=None, timeout=None, proxies=None):
- seen.update(url=url, params=params, proxies=proxies)
+ seen.update(url=url, params=params, proxies=proxies, timeout=timeout)
return _Resp({"ok": True, "result": [{"update_id": 8}]})
- monkeypatch.setattr(tc.requests, "get", fake_get)
- assert bot._get_updates(0) == [{"update_id": 8}]
+ monkeypatch.setattr(tc, "bounded_get", fake_get)
+ assert bot._get_updates(tc.LONG_POLL_SECONDS) == [{"update_id": 8}]
assert "bottok" in seen["url"] and seen["params"]["offset"] == 7 # token + offset forwarded
assert seen["proxies"] == {"http": "socks5h://tor:9050", "https": "socks5h://tor:9050"}
+ # (connect, read) tuple with the read timeout outlasting Telegram's long-poll hold — drop the
+ # tuple (or shrink the read side) and every legitimate long poll aborts mid-hold (#698).
+ connect, read = seen["timeout"]
+ assert read > seen["params"]["timeout"] >= 0 and connect > 0
+ # Batch cap: without it, an over-cap batch could never be parsed, so the offset could never
+ # advance past it and the poll loop would re-fetch it forever (#660 follow-up).
+ assert seen["params"]["limit"] == tc.GETUPDATES_LIMIT
def test_get_updates_not_ok_returns_empty(monkeypatch):
bot = _make_bot()
- monkeypatch.setattr(tc.requests, "get", lambda *a, **k: _Resp({"ok": False}))
+ monkeypatch.setattr(tc, "bounded_get", lambda *a, **k: _Resp({"ok": False}))
assert bot._get_updates(0) == []
def test_prime_offset_skips_backlog(monkeypatch):
+ # Backlog spanning two limit-capped batches: prime drains both, then the empty batch stops it.
bot = _make_bot()
- monkeypatch.setattr(
- tc.requests,
- "get",
- lambda *a, **k: _Resp({"ok": True, "result": [{"update_id": 3}, {"update_id": 9}]}),
+ batches = iter(
+ [
+ _Resp({"ok": True, "result": [{"update_id": 3}, {"update_id": 9}]}),
+ _Resp({"ok": True, "result": [{"update_id": 12}]}),
+ _Resp({"ok": True, "result": []}),
+ ]
)
+ monkeypatch.setattr(tc, "bounded_get", lambda *a, **k: next(batches))
bot._prime_offset()
- assert bot._offset == 10 # past the last pending update
+ assert bot._offset == 13 # past the last pending update, across batches
def test_prime_offset_swallows_error(monkeypatch):
@@ -663,7 +674,7 @@ def test_prime_offset_swallows_error(monkeypatch):
def boom(*a, **k):
raise OSError("offline")
- monkeypatch.setattr(tc.requests, "get", boom)
+ monkeypatch.setattr(tc, "bounded_get", boom)
bot._prime_offset() # must not raise
assert bot._offset is None
@@ -1093,7 +1104,7 @@ def fake_get(url, params=None, **kw):
seen["allowed"] = params["allowed_updates"]
return _Resp({"ok": True, "result": []})
- monkeypatch.setattr(tc.requests, "get", fake_get)
+ monkeypatch.setattr(tc, "bounded_get", fake_get)
on._get_updates(0)
assert "callback_query" in seen["allowed"]
off._get_updates(0)
diff --git a/build/dashboard/tests/service/test_tor_heal.py b/build/dashboard/tests/service/test_tor_heal.py
index 908829d5..6f34ee7e 100644
--- a/build/dashboard/tests/service/test_tor_heal.py
+++ b/build/dashboard/tests/service/test_tor_heal.py
@@ -282,13 +282,13 @@ async def start(self, *a, **k):
class TestProbe:
def test_any_http_response_counts_as_egress(self):
- with patch("mining_dashboard.service.tor_heal.requests.get") as get:
+ with patch("mining_dashboard.service.tor_heal.bounded_get") as get:
assert TorEgressHealer._probe_egress() is True
assert get.call_args.kwargs["proxies"]["https"].startswith("socks5h://")
def test_network_failure_is_broken_egress(self):
with patch(
- "mining_dashboard.service.tor_heal.requests.get",
+ "mining_dashboard.service.tor_heal.bounded_get",
side_effect=requests.ConnectionError("circuit timeout"),
):
assert TorEgressHealer._probe_egress() is False
From 2f807496eee68e43fa4cbd49b80ef6d9ee91556c Mon Sep 17 00:00:00 2001
From: Vijit Singh
Date: Mon, 20 Jul 2026 19:20:50 -0500
Subject: [PATCH 05/11] docs: add missing egress to the architecture diagram
(#694)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* docs: add missing egress to the architecture diagram — CoinGecko price feed, worker-API LAN polling, XvB raffle calls
Co-Authored-By: Claude Fable 5
* docs: worker-API prose — config applies are the host runner's dial, not the dashboard's
Review fix: #185 deliberately keeps the rig tokens out of the dashboard
container — the dashboard spools an intent and the host-side control
runner makes the HTTP call. Attribute the apply dial correctly, and drop
the absolute 'never touches the internet' (the stats probe dials the
rig's address directly; the code allows non-LAN miner IPs) in favor of
what the code guarantees: direct LAN dials that never route over Tor.
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Claude Fable 5
---
docs/architecture.md | 23 ++++++++++++++++-------
1 file changed, 16 insertions(+), 7 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index 4e052162..f90114d4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -36,6 +36,7 @@ flowchart TB
HC(["🩺 Healthchecks.io dead-man's switch"])
XvB(["🎲 XMRvsBeast pool + stats"])
GitHub(["🐙 GitHub release check"])
+ Coin(["💱 CoinGecko price feed"])
subgraph stack ["🐳 Pithead"]
direction TB
@@ -57,6 +58,7 @@ flowchart TB
You ==>|HTTPS| Caddy
Caddy --> Dashboard
Workers ==>|"Stratum 3333"| Proxy
+ Dashboard ==>|"worker API · LAN"| Workers
%% Dashboard internal control + monitoring (never leaves the box)
Dashboard -.->|controls| Proxy
@@ -67,8 +69,9 @@ flowchart TB
Dashboard ==>|"🚨 alerts + commands · 🟢 Tor"| Tor
Dashboard ==>|"🔔 webhook/ntfy alerts · 🟢 Tor"| Tor
Dashboard ==>|"🩺 liveness ping · 🟢 Tor"| Tor
- Dashboard ==>|"📈 XvB stats · 🟢 Tor"| Tor
+ Dashboard ==>|"📈 XvB stats + raffle · 🟢 Tor"| Tor
Dashboard ==>|"🆕 update check · 🟢 Tor"| Tor
+ Dashboard ==>|"💱 XMR/XTM prices · 🟢 Tor"| Tor
Proxy ==>|hashrate| P2Pool
Proxy ==>|"hashrate · 🟢 Tor"| Tor
@@ -87,13 +90,14 @@ flowchart TB
Net -.-> HC
Net -.-> XvB
Net -.-> GitHub
+ Net -.-> Coin
classDef ext fill:#1e293b,stroke:#64748b,color:#e2e8f0;
classDef ctrl fill:#1d4ed8,stroke:#93c5fd,color:#eff6ff;
classDef priv fill:#6d28d9,stroke:#c4b5fd,color:#f5f3ff;
classDef mine fill:#047857,stroke:#6ee7b7,color:#ecfdf5;
- class You,Workers,Net,Telegram,Hooks,HC,XvB,GitHub ext;
+ class You,Workers,Net,Telegram,Hooks,HC,XvB,GitHub,Coin ext;
class Caddy,Dashboard ctrl;
class Tor,DockerProxy priv;
class Proxy,P2Pool,Monerod,Tari mine;
@@ -105,11 +109,16 @@ flowchart TB
Reading the diagram: thick arrows carry inbound connections and every path that **leaves the box** —
each egress edge is tagged with its route, and **🟢 Tor** means it exits through the Tor daemon (a Tor
exit IP, never your host's). Dotted arrows are the dashboard's internal control and monitoring, which
-never leave the machine. The dashboard makes five outbound calls — the **Telegram** bot (alerts +
-commands), the **webhook/ntfy** alert sinks, the **Healthchecks.io** liveness ping, the **XvB** stats
-fetch, and the **GitHub** release check — and all five are Tor-routed, so enabling any of them never
-reveals where your stack runs (the webhook/ntfy sinks have a `notifications.tor: false` opt-out for
-LAN endpoints Tor can't reach; see [Telegram › Webhook and ntfy sinks](telegram.md#webhook-and-ntfy-sinks)). Node
+never leave the machine. The dashboard makes six outbound internet calls — the **Telegram** bot
+(alerts + commands), the **webhook/ntfy** alert sinks, the **Healthchecks.io** liveness ping, the
+**XvB** calls (stats, raffle registration, winners), the **GitHub** release check, and the opt-in
+**CoinGecko** price feed (`dashboard.energy.price_feed`, XMR + XTM spot prices) — and all six are
+Tor-routed, so enabling any of them never reveals where your stack runs (the webhook/ntfy sinks have
+a `notifications.tor: false` opt-out for LAN endpoints Tor can't reach; see
+[Telegram › Webhook and ntfy sinks](telegram.md#webhook-and-ntfy-sinks)). The dashboard also polls
+each rig's RigForge API for worker stats, and config applies travel the same path — dialed by the
+host-side control runner, so the rig tokens never enter the dashboard container (#185). Both are
+direct **LAN** connections to your rigs; they don't route over Tor, so they carry no Tor tag. Node
colors group services by role: 🟦 control plane (Caddy, Dashboard), 🟪 privacy and isolation (Tor,
Docker socket proxies), and 🟩 the mining core. In remote-node mode the bundled 🟠 Monero node isn't
started, and P2Pool talks to your external node instead.
From b3545883732d3c10b3f4c5ff241d570f186f8deb Mon Sep 17 00:00:00 2001
From: Vijit Singh
Date: Mon, 20 Jul 2026 19:27:15 -0500
Subject: [PATCH 06/11] fix(#689): only remove control-runner units the acting
checkout owns (#692)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(#689): only remove control-runner units the acting checkout owns
The pithead-control.{path,service} names are box-global, but a release
bench holds several checkouts (live stack + e2e harness + bundle-smoke
tmp dirs). Both removal paths — provision_control_runner's disabled
branch and the e2e teardown reap — deleted whatever units were
installed, stranding the live stack's dashboard control requests (the
config editor sat at "Previewing…" until the next apply reinstalled the
units; hit live on the bench 2026-07-19, twice in one afternoon).
Key both removals on the service unit's ExecStart matching $PWD, with
grep -F throughout — versioned dirs carry dots (pithead-v1.9.3), and a
regex dot could claim a sibling's unit as our own. Foreign-owned units
are left in place (the e2e reap counts that as success); a dangling
path unit with no service file is still reaped. PITHEAD_UNIT_DIR env
seam added for the tier-1 tests.
Co-Authored-By: Claude Fable 5
* fix(#689): compare unit ownership by physical path; add the changelog entry
Review fixes for the ownership check. The literal ExecStart=$PWD match
missed that one checkout has two path spellings: production units carry
the versioned dir, while an operator's disable apply runs through the
`current` symlink — the apply saw its own unit as foreign and never
removed it (fails safe, never converges). Both removal paths now extract
the ExecStart path from the service unit and compare physical
resolutions (deepest existing ancestor via cd/pwd -P, remainder kept
verbatim; unparseable ExecStart stays foreign). The e2e teardown mirror
in tests/integration/run.sh gets the same treatment, and the tier-1
block gains the versioned-spelling-via-symlink case — it fails on the
pre-fix literal comparison, and the foreign-owner cases still hold.
Also the missing Keep-a-Changelog entry under [Unreleased].
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Claude Fable 5
---
CHANGELOG.md | 11 +++++++++
docs/operations.md | 6 +++++
pithead | 35 +++++++++++++++++++++++----
tests/integration/run.sh | 27 +++++++++++++++++----
tests/stack/run.sh | 51 ++++++++++++++++++++++++++++++++++++++++
5 files changed, 122 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 226a69d3..cecc3983 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,17 @@ per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
whole fleet, gated on the same `dashboard.check_for_updates` flag. Rigs that report no version
(plain xmrig, sister API off) show no badge — unknown, not "up to date".
+### Fixed
+
+- **Removing the control runner no longer strands a sibling checkout's stack (#689).** The
+ `pithead-control.{path,service}` unit names are global to the host, but a bench box holds
+ several checkouts at once — and a checkout applying with dashboard control off (or the e2e
+ harness tearing down) removed whatever units were installed, including the live stack's,
+ leaving its config editor stuck at "Previewing…" until the next apply. Both removal paths now
+ check the service unit's `ExecStart` and only touch units owned by the acting checkout,
+ comparing physical paths so the `current` symlink and the versioned directory it targets
+ count as the same checkout.
+
### Changed
- **The release process requires the targeted end-to-end run.** `docs/dev/releasing.md` now
diff --git a/docs/operations.md b/docs/operations.md
index d83ca6ef..2672e6df 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -134,6 +134,12 @@ on, and remove them when it is off:
- `pithead-control.service` — a root oneshot running `pithead control-run-pending` from the
install directory. Fixed command, no parameters from the container.
+The unit names are global to the host, so removal is ownership-checked: a checkout with the flag
+off only removes units whose `ExecStart` points at itself, comparing physical paths so the
+`current` symlink and the versioned directory it targets count as the same checkout. Another
+checkout on the same box (an e2e harness, a bundle smoke test) therefore cannot delete the live
+stack's runner and strand its queued requests.
+
The runner dispatches exactly three actions: `apply --dry-run --porcelain` (preview), `apply -y`
(commit), and a release upgrade — the dashboard's
[Upgrade button](dashboard.md#upgrading-from-the-dashboard), for which the runner re-derives the
diff --git a/pithead b/pithead
index 27c1bc99..c4adaa7b 100755
--- a/pithead
+++ b/pithead
@@ -5852,9 +5852,34 @@ control_run_pending() {
provision_control_runner() {
[ "$OS_TYPE" == "Linux" ] || return 0
command -v systemctl >/dev/null 2>&1 || return 0
- local unit_dir="/etc/systemd/system"
+ local unit_dir="${PITHEAD_UNIT_DIR:-/etc/systemd/system}"
if [ "${DASHBOARD_CONTROL_ENABLED:-false}" != "true" ]; then
if [ -e "$unit_dir/pithead-control.path" ] || [ -e "$unit_dir/pithead-control.service" ]; then
+ # The unit names are box-global but a box can hold several checkouts (release bench:
+ # live stack + e2e harness + bundle-smoke tmp dirs). Only remove units whose ExecStart
+ # points at THIS checkout — deleting a sibling's runner strands its dashboard control
+ # requests unprocessed (the editor hangs at "Previewing…" until that stack's next
+ # apply/upgrade reinstalls the units).
+ # Ownership compares PHYSICAL paths: one checkout has two spellings — the `current`
+ # symlink and the versioned dir it points at (production units carry the versioned
+ # spelling). A literal $PWD compare would call our own unit foreign and never remove
+ # it. If the unit's dir is gone, resolve the deepest existing ancestor and keep the
+ # rest verbatim; an unparseable ExecStart is foreign (fail safe, leave it alone).
+ if [ -e "$unit_dir/pithead-control.service" ]; then
+ local owner_dir dir tail
+ owner_dir=$(sed -n 's|^ExecStart=\(/.*\)/pithead control-run-pending$|\1|p' \
+ "$unit_dir/pithead-control.service" | head -n 1)
+ dir="$owner_dir" tail=""
+ while [ -n "$dir" ] && [ "$dir" != "/" ] && [ ! -d "$dir" ]; do
+ tail="/$(basename "$dir")$tail"
+ dir=$(dirname "$dir")
+ done
+ if [ -z "$owner_dir" ] ||
+ [ "$(cd "$dir" 2>/dev/null && pwd -P)$tail" != "$(pwd -P)" ]; then
+ log "Leaving the dashboard control runner units alone (#33) — they belong to another checkout."
+ return 0
+ fi
+ fi
log "Removing the dashboard control runner units (#33)..."
sudo systemctl disable --now pithead-control.path >/dev/null 2>&1 || true
sudo rm -f "$unit_dir/pithead-control.path" "$unit_dir/pithead-control.service"
@@ -5862,9 +5887,11 @@ provision_control_runner() {
fi
return 0
fi
- # Already installed for this checkout — keep the routine apply sudo-free.
- if grep -qs "PathExistsGlob=$CONTROL_DIR/requests/\*.json" "$unit_dir/pithead-control.path" &&
- grep -qs "ExecStart=$PWD/pithead control-run-pending" "$unit_dir/pithead-control.service"; then
+ # Already installed for this checkout — keep the routine apply sudo-free. (-F: both paths
+ # are literals — versioned dirs carry dots (pithead-v1.9.3), and the glob star must not
+ # read as a regex repeat.)
+ if grep -qsF "PathExistsGlob=$CONTROL_DIR/requests/*.json" "$unit_dir/pithead-control.path" &&
+ grep -qsF "ExecStart=$PWD/pithead control-run-pending" "$unit_dir/pithead-control.service"; then
return 0
fi
log "Installing the dashboard control runner (systemd path unit, #33)..."
diff --git a/tests/integration/run.sh b/tests/integration/run.sh
index 6cd0cb29..0d2df159 100755
--- a/tests/integration/run.sh
+++ b/tests/integration/run.sh
@@ -1401,19 +1401,38 @@ _onion_reachable_external() {
rx "$snippet" | grep -q "PROBE-OK"
}
-# Reap the root pithead-control systemd units on the box, unconditionally and idempotently (#477).
+# Reap the root pithead-control systemd units THIS checkout installed, idempotently (#477).
# The hardening phase installs pithead-control.{path,service} to exercise the #33 spool; the restore
# apply is supposed to remove them, but that removal runs EARLY in apply (provision_control_runner) —
# before container recreation + the tor restart — so a restore apply that dies partway (render/preflight
# failure, or wait_status_ok timing out mid-apply) leaves the ROOT path unit watching the control spool
# past the phase and beyond. A later apply is convergent and would clean it, but only if it runs. This
-# teardown mirrors provision_control_runner's removal branch (pithead:5242) and runs regardless of the
-# restore apply's exit code. No-ops where there's no systemd (macOS/dev). Returns non-zero ONLY if a
-# unit survives (e.g. sudo unavailable) so the caller can warn loudly instead of silently passing.
+# teardown mirrors provision_control_runner's removal branch and runs regardless of the restore
+# apply's exit code. Units owned by ANOTHER checkout are left in place and count as success: the
+# unit names are box-global, and on a shared bench the live stack's runner uses them too — reaping
+# it strands that dashboard's control requests (config editor stuck at "Previewing…"). rx runs in
+# $IT_REMOTE_DIR, so the snippet's working dir is this run's checkout on the box. Ownership
+# compares PHYSICAL paths, mirroring the provision_control_runner branch: one checkout has two
+# spellings (the `current` symlink vs the versioned dir production units carry). No-ops where
+# there's no systemd (macOS/dev). Returns non-zero ONLY if a unit WE own survives (e.g. sudo
+# unavailable) so the caller can warn loudly instead of silently passing.
_remove_control_units() {
rx '
command -v systemctl >/dev/null 2>&1 || exit 0
ud=/etc/systemd/system
+ if [ -e "$ud/pithead-control.service" ]; then
+ owner_dir=$(sed -n "s|^ExecStart=\(/.*\)/pithead control-run-pending\$|\1|p" \
+ "$ud/pithead-control.service" | head -n 1)
+ dir=$owner_dir tail=""
+ while [ -n "$dir" ] && [ "$dir" != "/" ] && [ ! -d "$dir" ]; do
+ tail="/$(basename "$dir")$tail"
+ dir=$(dirname "$dir")
+ done
+ if [ -z "$owner_dir" ] ||
+ [ "$(cd "$dir" 2>/dev/null && pwd -P)$tail" != "$(pwd -P)" ]; then
+ exit 0
+ fi
+ fi
if [ -e "$ud/pithead-control.path" ] || [ -e "$ud/pithead-control.service" ]; then
sudo systemctl disable --now pithead-control.path >/dev/null 2>&1 || true
sudo rm -f "$ud/pithead-control.path" "$ud/pithead-control.service"
diff --git a/tests/stack/run.sh b/tests/stack/run.sh
index 2a038724..e9f89336 100755
--- a/tests/stack/run.sh
+++ b/tests/stack/run.sh
@@ -6385,6 +6385,57 @@ unset SUT sut_out
unset run_wizard w1_cfg w2_cfg pointer_out core_reads shape_reads
+echo "== unit: provision_control_runner only removes units this checkout owns (#33) =="
+# The pithead-control.{path,service} names are box-global, but a release bench holds several
+# checkouts at once (live stack + e2e harness + bundle-smoke tmp dirs). A checkout with control
+# disabled used to remove whatever units were installed — including the LIVE stack's runner,
+# stranding its dashboard control requests (config editor stuck at "Previewing…"). The removal
+# branch keys on the service unit's ExecStart: foreign owner → leave alone; own units → remove;
+# a dangling path unit with no service file → still reaped.
+PCR="$SANDBOX/pcr"
+mkdir -p "$PCR/units" "$PCR/bin"
+# uname stub: the OS gate reads `uname -s` at source time; report Linux so the branch runs on dev
+# Macs too. systemctl stub satisfies the command -v gate.
+printf '#!/usr/bin/env bash\n[ "$1" = "-s" ] && { echo Linux; exit 0; }\nexec uname "$@"\n' >"$PCR/bin/uname"
+printf '#!/usr/bin/env bash\nexit 0\n' >"$PCR/bin/systemctl"
+chmod +x "$PCR/bin/uname" "$PCR/bin/systemctl"
+
+pcr_run() { # — seed units owned by owner-dir ('-' = no service file), run the removal branch from run-dir, echo sudo calls
+ rm -f "$PCR/units/pithead-control.service" "$PCR/units/pithead-control.path"
+ [ "$1" != "-" ] && printf '[Service]\nExecStart=%s/pithead control-run-pending\n' "$1" >"$PCR/units/pithead-control.service"
+ printf '[Path]\nPathExistsGlob=/x/requests/*.json\n' >"$PCR/units/pithead-control.path"
+ (
+ cd "$2" || exit
+ PATH="$PCR/bin:$PATH"
+ # shellcheck disable=SC1090
+ source "$STACK"
+ set +e
+ log() { :; }
+ sudo() { echo "sudo:$*"; } # record instead of executing; the disable call's output is redirected in-function
+ PITHEAD_UNIT_DIR="$PCR/units" DASHBOARD_CONTROL_ENABLED=false provision_control_runner
+ )
+}
+
+assert_eq "foreign owner -> units left alone (no sudo rm)" "$(pcr_run /srv/code/other-checkout "$PCR")" ""
+assert_contains "own units -> removed" "$(pcr_run "$PCR" "$PCR")" \
+ "sudo:rm -f $PCR/units/pithead-control.path $PCR/units/pithead-control.service"
+assert_contains "dangling path unit (no service file) -> still reaped" "$(pcr_run - "$PCR")" "sudo:rm -f"
+# Versioned install dirs carry dots (pithead-v1.9.3). Ownership must compare the ExecStart path
+# as an exact string, never a regex: with the dots read as "any char", a sibling whose path
+# differs only at those positions would falsely match as our own — and get removed.
+mkdir -p "$PCR/v1.9.3" "$PCR/v1x9y3"
+assert_eq "foreign owner differing only at regex-dot positions -> left alone" \
+ "$(pcr_run "$PCR/v1x9y3" "$PCR/v1.9.3")" ""
+# One checkout, two spellings: production units carry the versioned dir in ExecStart, and an
+# operator's disable apply runs through the `current` symlink. Ownership compares physical
+# paths, so the unit is recognized as our own and removed — a literal $PWD compare would call
+# it foreign and the disable would never converge.
+mkdir -p "$PCR/versions/pithead-v1.9.3"
+ln -s "$PCR/versions/pithead-v1.9.3" "$PCR/current"
+assert_contains "own unit under its versioned spelling, run via the current symlink -> removed" \
+ "$(pcr_run "$PCR/versions/pithead-v1.9.3" "$PCR/current")" "sudo:rm -f"
+unset PCR pcr_run
+
# ---------------------------------------------------------------------------
echo ""
printf 'pithead tests: \033[1;32m%d passed\033[0m, ' "$PASS"
From 6285fc351e56b7db9ccc20642f1a555d4bb91892 Mon Sep 17 00:00:00 2001
From: Vijit Singh
Date: Mon, 20 Jul 2026 19:33:05 -0500
Subject: [PATCH 07/11] fix(#695,#696): unedited Save & preview shows zero
changes (#700)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(#695,#696): unedited Save & preview shows zero changes
Two spurious rows appeared in the dashboard's Review changes modal on a
bundle-deployed box with nothing edited:
- SCRIPT_DIR now resolves with pwd -P (#695), so every $PWD-derived .env
path (CLEARNET_STATE_DIR, CONTROL_DIR, CADDY_LOG_DIR, the Tari wallet
secret file) renders the same physical spelling however pithead is
invoked — the deploy symlink interactively and the systemd control
runner on the real dir used to diff the same directory against itself,
and alternating invocations churned .env back and forth.
CLEARNET_STATE_DIR joins CONTROL_DIR/CADDY_LOG_DIR as a silent
internal path in describe_change.
- The #504 energy preview check merges the config.reference.json
defaults into BOTH sides before comparing (#696): the editor
round-trips the reference-merged form, so a config.json that never
set dashboard.energy staged the materialized defaults and raised
"Energy calculator settings updated" on every unedited save.
The test sandbox is canonicalized with pwd -P so macOS's /var symlink
doesn't leak into path assertions now that pithead resolves it.
Closes #695. Closes #696.
Co-Authored-By: Claude Fable 5
* fix(#696): gate audit-key derivation merges energy defaults like the preview leg
The verifier pass on #700 caught the commit-side sibling of the preview
fix: control_approval_gate's synthetic DASHBOARD_ENERGY audit key used
the old strict comparison, so any reference-merged dashboard commit on
a config.json with a sparse dashboard.energy block audited a phantom
energy change. Same merge-both-sides expression as the preview leg, and
the unedited round-trip test now commits and asserts the audit line
carries no DASHBOARD_ENERGY.
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Claude Fable 5
---
CHANGELOG.md | 14 ++++++++++++++
pithead | 24 +++++++++++++++++++-----
tests/stack/run.sh | 41 +++++++++++++++++++++++++++++++++++++++--
3 files changed, 72 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cecc3983..2be5a6e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,20 @@ per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
comparing physical paths so the `current` symlink and the versioned directory it targets
count as the same checkout.
+- **An unedited Save & preview shows zero changes (#695, #696).** On a bundle-deployed box the
+ Review changes modal reported two changes with nothing edited. First, a path "change" such as
+ `CLEARNET_STATE_DIR: /srv/code/current/... → /srv/code/pithead-vX.Y.Z/...`: pithead resolved
+ its own directory with a logical `pwd`, so `.env` paths derived from the checkout dir took the
+ spelling of whoever invoked it — the deploy symlink interactively, the physical dir under the
+ control runner's systemd unit — and the same directory diffed against itself. The script now
+ canonicalizes with `pwd -P`, and `CLEARNET_STATE_DIR` joins its siblings (`CONTROL_DIR`,
+ `CADDY_LOG_DIR`) as a silent internal path in the change preview. Second, a permanent
+ "Energy calculator settings updated" row on any box whose `config.json` never set
+ `dashboard.energy`: the editor round-trips the reference-merged form, so the staged copy
+ carries the materialized energy defaults, and the preview compared them against the absent
+ block. The comparison — in the preview row and the commit's audit-key derivation alike — now
+ merges the reference defaults into both sides, so only a real value change raises the row.
+
### Changed
- **The release process requires the targeted end-to-end run.** `docs/dev/releasing.md` now
diff --git a/pithead b/pithead
index c4adaa7b..fe3e5ce9 100755
--- a/pithead
+++ b/pithead
@@ -86,7 +86,11 @@ if [ "${BASH_SOURCE[0]}" != "${0}" ]; then _STACK_SOURCED=1; fi
if [ "$_STACK_SOURCED" = "0" ]; then
# Always operate from the directory containing this script, so the stack can be managed from
# anywhere (./pithead, an absolute path, cron, systemd, ...). All paths below are relative to it.
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ # -P (#695): resolve symlinks so every $PWD-derived .env value (CLEARNET_STATE_DIR, CONTROL_DIR,
+ # ...) renders the same physical path however pithead is invoked — an interactive apply through
+ # the `current ->` deploy symlink and the systemd control runner on the real dir used to render
+ # different strings for the same directory, so an unedited preview showed a path "change".
+ SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
cd "$SCRIPT_DIR" || error "Cannot enter the script directory: $SCRIPT_DIR"
# Absolute path to this script — run_chain (#94) re-invokes it once per chained step.
PITHEAD_SELF="$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")"
@@ -4467,8 +4471,8 @@ describe_change() {
msg="Dashboard configuration editing disabled (#33) — the control runner units are removed; the dashboard container is recreated."
fi
;;
- CONTROL_DIR | CADDY_LOG_DIR)
- # Fixed paths under ./data — internal, change only when the checkout moves.
+ CLEARNET_STATE_DIR | CONTROL_DIR | CADDY_LOG_DIR)
+ # Fixed paths under ./data — internal, change only when the checkout moves (#695).
msg=""
;;
DASHBOARD_ONION_ENABLED)
@@ -5071,9 +5075,13 @@ control_approval_gate() { #
# (#349) without a third dry-run. Names only, never values. dashboard.energy (#504) is
# config.json-only, so it never appears in the env porcelain — fold a synthetic DASHBOARD_ENERGY
# name into the list when that block changed, else an energy-only commit would audit no key.
+ # Reference defaults merged into both sides (#696), same as the preview leg: the editor
+ # round-trips the reference-merged form, and materialized defaults are not a change.
local keys
keys=$(porcelain_keys "$porcelain")
- if ! jq -e --slurpfile live "$CONFIG_FILE" '(.dashboard.energy // {}) == ($live[0].dashboard.energy // {})' "$staged" >/dev/null 2>&1; then
+ if ! jq -e --slurpfile live "$CONFIG_FILE" --slurpfile ref "$REFERENCE_CONFIG" \
+ '(($ref[0].dashboard.energy // {}) + ($live[0].dashboard.energy // {}))
+ == (($ref[0].dashboard.energy // {}) + (.dashboard.energy // {}))' "$staged" >/dev/null 2>&1; then
keys="${keys:+$keys }DASHBOARD_ENERGY"
fi
printf '%s' "$keys"
@@ -5172,7 +5180,13 @@ control_preview() { #
# arms Apply and the commit lands it in config.json. The approval gate allowlists exactly
# this config.json-only block; any OTHER config.json-only delta still refuses (see
# control_approval_gate). INFO never flips destructive, so the existing verdict stands.
- if ! jq -e --slurpfile live "$CONFIG_FILE" '(.dashboard.energy // {}) == ($live[0].dashboard.energy // {})' "$staged" >/dev/null 2>&1; then
+ # Compare with the reference defaults merged into BOTH sides (#696): the editor round-trips
+ # the reference-merged form, so on a config.json that never set dashboard.energy the staged
+ # copy carries the materialized defaults — an absent block and explicit defaults are the
+ # same settings, not a change.
+ if ! jq -e --slurpfile live "$CONFIG_FILE" --slurpfile ref "$REFERENCE_CONFIG" \
+ '(($ref[0].dashboard.energy // {}) + ($live[0].dashboard.energy // {}))
+ == (($ref[0].dashboard.energy // {}) + (.dashboard.energy // {}))' "$staged" >/dev/null 2>&1; then
result=$(printf '%s' "$result" | jq '.changes += [{flag:"INFO",key:"dashboard.energy",msg:"Energy calculator settings (dashboard.energy) — electricity price / currency / XMR price updated."}]')
fi
control_write_result "$cdir/results" "$id" "$result"
diff --git a/tests/stack/run.sh b/tests/stack/run.sh
index e9f89336..a9cfc255 100755
--- a/tests/stack/run.sh
+++ b/tests/stack/run.sh
@@ -39,8 +39,10 @@ run_sourced() {
)
}
-# A throwaway sandbox dir, cleaned on exit.
-SANDBOX="$(mktemp -d)"
+# A throwaway sandbox dir, cleaned on exit. Physical path (#695): pithead canonicalizes its
+# own directory with pwd -P, so a sandbox spelled through a symlink (macOS /var -> /private/var)
+# would render .env paths that no longer string-match the $SANDBOX-based assertions.
+SANDBOX="$(cd "$(mktemp -d)" && pwd -P)"
trap 'rm -rf "$SANDBOX"' EXIT
# A fake docker that records calls and answers the few queries setup/apply make.
@@ -4424,6 +4426,21 @@ assert_contains "PITHEAD_CONFIG_FILE override is honoured" "$out" "37890" # nano
out="$(cd "$C" && DOCKER_LOG="$CTRL_LOG" PATH="$C/bin:$PATH" ./pithead apply --dry-run --porcelain 2>/dev/null)"
assert_eq "without the override, config.json shows no changes" "$out" ""
+echo "== black-box: symlink-invoked stack renders physical paths (#695) =="
+# A stack managed through a deploy symlink (`current -> pithead-vX.Y.Z`) must render the same
+# .env as one managed from the physical dir: SCRIPT_DIR resolves with pwd -P, so an unedited
+# preview through the symlink shows zero changes and an apply never rewrites the $PWD-derived
+# paths (CLEARNET_STATE_DIR & co.) to the symlink spelling.
+ln -sfn "$C" "$SANDBOX/current-link"
+out="$(cd "$SANDBOX/current-link" && DOCKER_LOG="$CTRL_LOG" PATH="$C/bin:$PATH" ./pithead apply --dry-run --porcelain 2>/dev/null)"
+assert_rc "dry-run through the symlink exits 0" "$?" "0"
+assert_eq "unedited preview through the symlink shows zero changes (#695)" "$out" ""
+out="$(cd "$SANDBOX/current-link" && DOCKER_LOG="$CTRL_LOG" PATH="$C/bin:$PATH" ./pithead apply -y 2>&1)"
+assert_rc "apply through the symlink succeeds" "$?" "0"
+assert_contains "clearnet state dir keeps the physical path" "$(cat "$C/.env")" "CLEARNET_STATE_DIR=$C/data/clearnet-state"
+assert_not_contains "the symlink spelling never reaches .env" "$(cat "$C/.env")" "current-link"
+rm -f "$SANDBOX/current-link"
+
echo "== black-box: apply --dry-run is read-only re: node credential generation (#556) =="
# Direct CLI leg: a fresh/hand-edited local-node config with placeholder/empty creds must not have
# config.json rewritten by a --dry-run preview — the read-only contract #556 reported broken
@@ -4904,6 +4921,26 @@ assert_eq "energy cost landed in config.json" "$(jq -r '.dashboard.energy.cost_p
assert_eq "energy currency landed in config.json" "$(jq -r '.dashboard.energy.currency' "$C/config.json")" "EUR"
assert_contains "energy commit audits the synthetic key name (#504)" "$(grep '"action":"commit","status":"applied"' "$AUDIT" | tail -n 1)" "DASHBOARD_ENERGY"
+# Unedited editor round-trip (#696): the form serves the reference-merged config and posts the
+# merged document back, so a save with NO edits must preview as zero changes. The live energy
+# block above is partial — the merge materializes the remaining reference defaults (tari_price,
+# price_feed) into the staged copy, and defaults against an absent value are the same settings,
+# not an "Energy calculator settings updated" row.
+UUIDE="55555555-5555-4555-8555-555555555555"
+jq -s --arg id "$UUIDE" '{id:$id, action:"preview", actor:"admin",
+ config:((.[0] | del(._docs)) * .[1])}' "$ROOT/config.reference.json" "$C/config.json" >"$REQS/$UUIDE.json"
+run_pending >/dev/null
+assert_eq "unedited merged round-trip previews" "$(jq -r '.status' "$RESULTS/$UUIDE.json" 2>/dev/null)" "previewed"
+assert_eq "unedited merged round-trip shows zero changes (#696)" "$(jq -r '.changes | length' "$RESULTS/$UUIDE.json" 2>/dev/null)" "0"
+# Audit leg of the same contract: committing that unedited round-trip must not record a phantom
+# DASHBOARD_ENERGY key — the gate's audit comparison merges the reference defaults too (#696).
+printf '{"id":"%s","action":"commit","actor":"admin"}\n' "$UUIDE" >"$REQS/$UUIDE.json"
+run_pending >/dev/null
+assert_eq "unedited merged round-trip commits" "$(jq -r '.status' "$RESULTS/$UUIDE.json" 2>/dev/null)" "applied"
+assert_not_contains "unedited commit audits no phantom DASHBOARD_ENERGY key (#696)" \
+ "$(grep '"action":"commit","status":"applied"' "$AUDIT" | tail -n 1)" "DASHBOARD_ENERGY"
+rm -f "$RESULTS/$UUIDE.json" "$STAGED/$UUIDE.json"
+
# NEGATIVE — the #504 security teeth: an energy edit BUNDLED with a change that is NOT on the env
# allowlist (monero.rpc_lan_access -> MONERO_RPC_BIND) must be REFUSED. The energy exemption must
# not become a carrier for other config: the gate re-derives the env change set host-side and the
From 9b7a0c590e786a39648cfab286182f88a98f3bcb Mon Sep 17 00:00:00 2001
From: Vijit Singh
Date: Mon, 20 Jul 2026 19:33:40 -0500
Subject: [PATCH 08/11] feat(#597): one-click remote worker upgrade (#691)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* security(#660): shared response-size cap for every external HTTP fetch
All five external call sites — the GitHub release check (#224), the three
XvB reads (stats, reward estimates, winners), and the CoinGecko price
feed (#651) — read via a shared helper/http.py bounded_get that streams
the body and cuts it at 1 MiB. Over-cap raises a RequestException
subclass, so each client's existing fail-silent contract (None / keep
last good) applies unchanged. Known residual from the #642/#646 security
reviews; the local Telegram/healthchecks/monerod clients keep their own
contracts and stay out of scope.
Closes #660
Co-Authored-By: Claude Fable 5
* feat(#596): per-worker RigForge new-version badge
One fleet-wide, hourly, Tor-routed fetch of the latest RigForge release
(UpdateChecker.latest_release_cached, gated on the same
dashboard.check_for_updates flag) cached as raw {tag, url}; each rig's
badge is derived at the render seam from its live reported version
(rigforge_update_for), so derived state never outlives its inputs (#664)
and a rig on X can never badge X available. compute_update normalizes
the rig's bare 1.11.2 against the tag's v1.11.2. Badges render in the
Workers Alive table and Worker Inspect; rigs reporting no version show
no badge (unknown, not up-to-date).
Closes #596
Co-Authored-By: Claude Fable 5
* feat(#597): one-click remote worker upgrade
Fuses the two existing control templates: worker-apply's rig resolution
(address + bearer from the HOST config, never the intent; charset
guards; per-drain budget — one upgrade per drain) and the stack
upgrade's throttled host-side target re-derivation (RigForge releases
API over Tor, 10-min tag cache + pre-dial stamp so a flooding container
costs at most one egress per window). POST :8082/upgrade with the
host-derived tag; poll :8082/status matching change_id to
applied/rolled_back/failed with a 12-min cap (builds ~10 min); rig-side
throttle refusals map to a calm 'throttled' retry-later status.
Dashboard: POST /api/control/worker-upgrade spools {worker, version}
only and 202s immediately; a rig already reporting the target version
no-ops without spooling (never burns the rig's 6h window). Inspect
gains an arm→confirm Upgrade button where the #596 badge shows.
Requires rig >= v1.11.2 (documented; earlier ancestry guard refuses
fresh-clone upgrades). Per-rig only — no upgrade-all.
Closes #597
Co-Authored-By: Claude Fable 5
* fix(#596): review fixes — snapshot restore drops rigforge_release; docstring + catalog row
- Snapshot restore popped `update` (#664) but not `rigforge_release`, so with
check_for_updates flipped off a restored snapshot re-seeded stale per-worker
badges until the first poll cycle. Pop it alongside `update`; the checker
re-fetches on its own cadence. Extends the #664 restore test (fails without
the pop).
- update_checker docstring said the check is opt-in (default off) — the config
default and docs/configuration.md say on by default. Docstring now matches.
- testing-strategy scenario catalog (Section I) gains the #596 badge row:
tier 1 covered today, live badge owed to the #597 gouda loaner session.
Co-Authored-By: Claude Fable 5
* test(#597): pin the poll-cap timeout, GitHub tag derive, and rig-refusal paths
Review fixes for #691. The sec-review's headline 90s poll cap gains a test
seam (CONTROL_WU_POLL_CAP) and a stack case proving the timeout→accepted
fallback; three new cases cover the GitHub tag derivation (fresh derive +
cache write, unreachable-over-Tor refusal, no-usable-tag refusal) that every
accept case had pre-cached away; one covers the rig non-202 refusal (where
an old rig < v1.11.2 surfaces) incl. the 500-char cap on rig-supplied error
text. testing-strategy Section I gains the #597 catalog row.
Co-Authored-By: Claude Fable 5
* test(#597): pin the non-JSON 400 and submit-failure 500 endpoint branches
The develop merge narrowed the patch-coverage diff to this PR's own lines,
surfacing the two uncovered error branches the review had flagged.
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Claude Fable 5
---
CHANGELOG.md | 9 +
.../service/control_service.py | 21 ++
.../dashboard/mining_dashboard/web/server.py | 43 +++
.../web/static/workerview.mjs | 65 +++-
.../tests/frontend/workerview.test.mjs | 61 ++++
.../tests/service/test_control_service.py | 15 +
build/dashboard/tests/web/test_server.py | 88 +++++
docs/dashboard.md | 9 +
docs/dev/testing-strategy.md | 1 +
docs/workers.md | 27 ++
pithead | 182 ++++++++++
tests/stack/run.sh | 311 ++++++++++++++++++
12 files changed, 826 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2be5a6e5..b4cc540e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,15 @@ per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
### Added
+- **One-click remote worker upgrade** (#597). Where the per-worker badge shows and the rig is
+ editable, Worker Inspect gains an Upgrade rig… button: arm, confirm, and the rig installs the
+ latest RigForge release itself (rig ≥ v1.11.2 with its default-off `control_upgrade` flag chain
+ enabled). The intent carries the worker name and confirmed version only; the host runner
+ re-derives the real target from the RigForge release API over Tor (throttled, cached), resolves
+ the rig's address and bearer from `config.json`, dials over the LAN, and polls the rig to a
+ terminal applied / rolled-back / failed with a hard cap. Already-current rigs no-op without
+ dialing; a rig-side throttle refusal reads as retry-later. Per-rig only — no "upgrade all".
+
- **Per-worker RigForge "new version available" badge** (#596). A rig whose reported RigForge
version is older than the latest published release gets a clickable badge in the Workers Alive
table and in Worker Inspect, linking to the release notes — the worker-level twin of the
diff --git a/build/dashboard/mining_dashboard/service/control_service.py b/build/dashboard/mining_dashboard/service/control_service.py
index 3e35e0ad..2b9e4147 100644
--- a/build/dashboard/mining_dashboard/service/control_service.py
+++ b/build/dashboard/mining_dashboard/service/control_service.py
@@ -262,6 +262,27 @@ def submit_worker_apply(worker, changes, actor="", intent_id=None):
return rid
+def submit_worker_upgrade(worker, version, actor="", intent_id=None):
+ """Spool a worker RigForge-upgrade intent (#597). Carries ONLY the worker NAME and the version
+ the operator confirmed seeing — never a host, port, or token (the host runner resolves the
+ rig's real address and bearer from workers.list[], exactly like worker-apply), and the version
+ is a proposal, never a target: the host re-derives the real latest RigForge release over Tor
+ and refuses a mismatch. Returns the request id (always a UUID)."""
+ rid = str(uuid.UUID(intent_id)) if intent_id else str(uuid.uuid4())
+ request = {
+ "id": rid,
+ "action": "worker-upgrade",
+ "actor": actor,
+ "worker": worker,
+ "version": version,
+ }
+ tmp = os.path.join(config.CONTROL_REQUESTS_DIR, f".{rid}.tmp")
+ with open(tmp, "w") as f:
+ json.dump(request, f)
+ os.replace(tmp, os.path.join(config.CONTROL_REQUESTS_DIR, f"{rid}.json"))
+ return rid
+
+
# The config keys the Worker Inspect editor may change — the exact writable allowlist the rig's
# control API enforces (rigforge WRITABLE, #236). Validated here (fail-closed, defence in depth), on
# the host runner, and finally by the rig itself. NOT writable: identity, filesystem paths, the API
diff --git a/build/dashboard/mining_dashboard/web/server.py b/build/dashboard/mining_dashboard/web/server.py
index 517b919b..a5b3158c 100644
--- a/build/dashboard/mining_dashboard/web/server.py
+++ b/build/dashboard/mining_dashboard/web/server.py
@@ -8,6 +8,7 @@
from mining_dashboard.config import config
from mining_dashboard.service import audit_service, control_service
from mining_dashboard.service.metrics import build_metrics, share_reject_pct
+from mining_dashboard.service.update_checker import parse_semver
from mining_dashboard.web.prometheus import CONTENT_TYPE as PROMETHEUS_CONTENT_TYPE
from mining_dashboard.web.prometheus import render_prometheus
from mining_dashboard.web.views import (
@@ -252,6 +253,45 @@ async def handle_worker_apply(request):
return web.json_response({"id": rid, **res})
+async def handle_worker_upgrade(request):
+ """One-click RigForge upgrade for a single rig (#597), via the HOST-side control runner.
+
+ Mirrors the stack's own upgrade (#59) at the per-worker level: the body's version is only what
+ the operator confirmed seeing (the badge's latest, #596); the host re-derives the real target
+ from the RigForge release API over Tor and refuses a mismatch, then resolves the rig's address
+ + bearer from config.json — this container never holds the token and cannot choose what gets
+ installed. Returns 202 + the request id immediately (a rig build can take minutes); the client
+ polls /api/control/result. A rig already reporting the requested version short-circuits to a
+ no-op without spooling — a dial would just burn the rig's own 6h upgrade throttle."""
+ _require_control_header(request)
+ try:
+ body = await request.json()
+ except Exception:
+ raise web.HTTPBadRequest(text="Body must be JSON.") from None
+ worker = body.get("worker")
+ version = body.get("version")
+ if not isinstance(worker, str) or not worker:
+ raise web.HTTPBadRequest(text="'worker' must be a non-empty string.")
+ if not isinstance(version, str) or not re.fullmatch(r"v\d+\.\d+\.\d+", version):
+ raise web.HTTPBadRequest(text="'version' must look like vX.Y.Z.")
+ data = request.app["latest_data"] or {}
+ live = next((w for w in data.get("workers", []) if w.get("name") == worker), None)
+ running = ((live or {}).get("rigforge") or {}).get("version")
+ # The rig reports bare "1.11.2", the badge proposes tag "v1.11.2" — compare parsed (#596).
+ if running and parse_semver(running) and parse_semver(running) == parse_semver(version):
+ return web.json_response(
+ {"status": "noop", "worker": worker, "note": f"already on {version}"}
+ )
+ try:
+ rid = control_service.submit_worker_upgrade(
+ worker, version, request.headers.get("X-Auth-User", "")
+ )
+ except Exception:
+ logger.exception("Error submitting worker-upgrade")
+ return web.json_response({"error": "Failed to submit the worker upgrade."}, status=500)
+ return web.json_response({"id": rid, "status": "pending"}, status=202)
+
+
async def handle_control_result(request):
"""Client-side polling endpoint for a 202'd preview/commit."""
try:
@@ -351,6 +391,9 @@ def create_app(state_manager, latest_data_ref):
# writable-key change to its rig. Gated with the rest of the control channel.
web.get("/api/worker", handle_worker_detail),
web.post("/api/control/worker-apply", handle_worker_apply),
+ # One-click rig upgrade (#597): spools name + confirmed version only; the host
+ # re-derives the real target and dials the rig. Same gate as the rest.
+ web.post("/api/control/worker-upgrade", handle_worker_upgrade),
]
)
diff --git a/build/dashboard/mining_dashboard/web/static/workerview.mjs b/build/dashboard/mining_dashboard/web/static/workerview.mjs
index 3624b10f..f62429e0 100644
--- a/build/dashboard/mining_dashboard/web/static/workerview.mjs
+++ b/build/dashboard/mining_dashboard/web/static/workerview.mjs
@@ -26,11 +26,15 @@ import {
const CONTROL_HEADERS = { "Content-Type": "application/json", "X-Pithead-Control": "1" };
const POLL_MS = 2000;
const POLL_MAX = 40; // ~80s — the host dials the rig then polls its /status
+// ~5 min — covers spool latency + the host runner's own 90s rig-poll cap (#597). A rebuild that
+// outlives the cap lands as "accepted"; the badge clears on its own once the rig reports the
+// new version, so polling longer here buys nothing.
+const UPGRADE_POLL_MAX = 150;
// Poll the shared control-result endpoint until a terminal outcome lands, skipping the interim
// "running". The apply can briefly out-run the dashboard, so tolerate a transient fetch failure.
-async function pollWorkerResult(id) {
- for (let i = 0; i < POLL_MAX; i++) {
+async function pollWorkerResult(id, max = POLL_MAX) {
+ for (let i = 0; i < max; i++) {
await new Promise((r) => setTimeout(r, POLL_MS));
let res;
try {
@@ -56,6 +60,10 @@ const STATUS_META = {
rolled_back: { cls: "status-bad", label: "Rolled back" },
failed: { cls: "status-bad", label: "Failed" },
error: { cls: "status-bad", label: "Error" },
+ // Worker-upgrade extras (#597): a rig already on the target is a calm no-op, and the rig's own
+ // 6h anti-beacon throttle is retry-later, not a fault (the host runner maps it server-side).
+ noop: { cls: "status-ok", label: "Already up to date" },
+ throttled: { cls: "status-warn", label: "Throttled by the rig — retry later" },
};
function StatusLine({ result }) {
@@ -142,6 +150,11 @@ export class WorkerInspect extends Component {
jsonError: null,
busy: false,
result: null,
+ // One-click rig upgrade (#597): a two-step arm → confirm, its own in-flight flag (a build
+ // can run minutes) and its own result line, independent of the config editor's.
+ upgArmed: false,
+ upgBusy: false,
+ upgResult: null,
};
this.dialogRef = createRef();
}
@@ -225,6 +238,27 @@ export class WorkerInspect extends Component {
}
}
+ // One-click rig upgrade (#597). POSTs {worker, version} only — the version is the badge's
+ // latest, a proposal the HOST re-derives and the rig bounds; this client never picks a target.
+ // 202 means spooled: poll with the long budget (the rig may rebuild its miner, ~10 min).
+ async upgrade() {
+ const version = this.state.detail.rigforge_update.latest;
+ this.setState({ upgArmed: false, upgBusy: true, upgResult: { status: "running" } });
+ try {
+ const res = await fetch("/api/control/worker-upgrade", {
+ method: "POST",
+ headers: CONTROL_HEADERS,
+ body: JSON.stringify({ worker: this.props.name, version }),
+ });
+ let out = await res.json();
+ if (res.status === 202 && out.id) out = await pollWorkerResult(out.id, UPGRADE_POLL_MAX);
+ this.setState({ upgBusy: false, upgResult: out });
+ this.load(); // an applied upgrade clears the badge once the rig reports the new version
+ } catch (e) {
+ this.setState({ upgBusy: false, upgResult: { status: "error", error: String(e) } });
+ }
+ }
+
render() {
const { phase, detail, error } = this.state;
const { name, onClose } = this.props;
@@ -243,7 +277,8 @@ export class WorkerInspect extends Component {
}
renderBody(detail) {
- const { mode, tableEdits, editText, jsonError, busy, result } = this.state;
+ const { mode, tableEdits, editText, jsonError, busy, result, upgArmed, upgBusy, upgResult } =
+ this.state;
const canEdit = detail.control_enabled && detail.editable;
return html`
${
- // This rig runs an older RigForge (#596) — notify-only, mirrors the header's
- // stack-level new-release badge. The one-click rig upgrade is the separate #597.
+ // This rig runs an older RigForge (#596) — the badge links to the release notes;
+ // with the control channel on and an operator-set host, the one-click upgrade
+ // button (#597) appears beside it: arm → confirm → POST → poll (a rig rebuild can
+ // take ~10 min; the rig rolls back on a build that doesn't come back live).
detail.rigforge_update &&
detail.rigforge_update.available &&
detail.rigforge_update.url
? html`
diff --git a/build/dashboard/tests/frontend/workerview.test.mjs b/build/dashboard/tests/frontend/workerview.test.mjs
index 337d7f82..ba7fb4b9 100644
--- a/build/dashboard/tests/frontend/workerview.test.mjs
+++ b/build/dashboard/tests/frontend/workerview.test.mjs
@@ -234,6 +234,67 @@ test("no applied config versions yet falls back to an explanatory message", () =
assert.match(out, /No applied config changes to correlate hashrate against yet/);
});
+// --- One-click rig upgrade (#597) -------------------------------------------------------------
+
+const UPG_DETAIL = {
+ ...DETAIL,
+ rigforge: { version: "1.11.1", stats: [] },
+ rigforge_update: { available: true, latest: "v1.11.2", url: "https://h/v1.11.2" },
+};
+
+test("upgrade button gates on rigforge_update + an editable, control-enabled worker (#597)", () => {
+ assert.match(renderToString(readyInstance(UPG_DETAIL).render()), /Upgrade rig…/);
+ // Notify-only without an operator-set host or with control off — badge yes, button no.
+ const noEdit = renderToString(readyInstance({ ...UPG_DETAIL, editable: false }).render());
+ assert.match(noEdit, /New RigForge release/);
+ assert.doesNotMatch(noEdit, /Upgrade rig…/);
+ const noCtl = renderToString(readyInstance({ ...UPG_DETAIL, control_enabled: false }).render());
+ assert.doesNotMatch(noCtl, /Upgrade rig…/);
+ // No update derived -> no badge, no button.
+ const current = renderToString(readyInstance({ ...UPG_DETAIL, rigforge_update: null }).render());
+ assert.doesNotMatch(current, /Upgrade rig…|New RigForge release/);
+});
+
+test("arming swaps the button for confirm/cancel; cancel disarms (#597)", () => {
+ const inst = readyInstance(UPG_DETAIL);
+ inst.state.upgArmed = true;
+ const armed = renderToString(inst.render());
+ assert.match(armed, /Confirm upgrade/);
+ assert.match(armed, /Cancel/);
+ assert.doesNotMatch(armed, /Upgrade rig…/);
+});
+
+test("upgrade() POSTs {worker, version} and renders the terminal result (#597)", async () => {
+ const inst = readyInstance(UPG_DETAIL);
+ let posted = null;
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async (url, opts) => {
+ if (posted === null && url === "/api/control/worker-upgrade") {
+ posted = { url, body: JSON.parse(opts.body) };
+ return { status: 200, json: async () => ({ status: "noop", note: "already on v1.11.2" }) };
+ }
+ return { ok: true, status: 200, json: async () => UPG_DETAIL }; // the load() refresh
+ };
+ try {
+ await inst.upgrade();
+ await new Promise((r) => setImmediate(r)); // flush the fire-and-forget load() refresh
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ assert.equal(posted.url, "/api/control/worker-upgrade");
+ assert.deepEqual(posted.body, { worker: "rig1", version: "v1.11.2" });
+ assert.equal(inst.state.upgBusy, false);
+ assert.match(renderToString(inst.render()), /Already up to date/);
+});
+
+test("terminal statuses render their calm/red variants (#597)", () => {
+ const inst = readyInstance(UPG_DETAIL);
+ inst.state.upgResult = { status: "throttled", reason: "throttled: retry after the window" };
+ assert.match(renderToString(inst.render()), /Throttled by the rig — retry later/);
+ inst.state.upgResult = { status: "rolled_back", reason: "miner did not return live" };
+ assert.match(renderToString(inst.render()), /Rolled back/);
+});
+
// --- RigForge new-release callout (#596) ----------------------------------------------------
test("Inspect surfaces the RigForge new-release callout only when the server derived one (#596)", () => {
diff --git a/build/dashboard/tests/service/test_control_service.py b/build/dashboard/tests/service/test_control_service.py
index e9076284..8fb999fb 100644
--- a/build/dashboard/tests/service/test_control_service.py
+++ b/build/dashboard/tests/service/test_control_service.py
@@ -338,6 +338,21 @@ def test_submit_worker_apply_spools_tokenless_intent(self, spool):
# No secret / addressing leaks into the container-writable spool.
assert "host" not in req and "port" not in req and "token" not in req
+ def test_submit_worker_upgrade_spools_name_and_version_only(self, spool):
+ # #597: same tokenless contract as worker-apply — the version is a proposal the host
+ # re-derives; the rig's address + bearer never enter the container-writable spool.
+ rid = control_service.submit_worker_upgrade("rig1", "v1.11.2", actor="admin")
+ uuid.UUID(rid)
+ req = json.loads((spool / "requests" / f"{rid}.json").read_text())
+ assert req == {
+ "id": rid,
+ "action": "worker-upgrade",
+ "actor": "admin",
+ "worker": "rig1",
+ "version": "v1.11.2",
+ }
+ assert "host" not in req and "port" not in req and "token" not in req
+
def test_writable_key_allowlist_has_no_intra_repo_drift():
"""#515: the worker writable-key allowlist is hardcoded in THREE places kept in sync only by
diff --git a/build/dashboard/tests/web/test_server.py b/build/dashboard/tests/web/test_server.py
index 295ffc7a..0d11747c 100644
--- a/build/dashboard/tests/web/test_server.py
+++ b/build/dashboard/tests/web/test_server.py
@@ -626,6 +626,9 @@ async def worker_client(aiohttp_client, control_spool, monkeypatch):
"status": "online",
"active_pool": "3333",
"h60": 5100,
+ # Bare rig-reported version (#596/#597) — the upgrade noop guard compares it
+ # (parsed) against the v-prefixed proposal.
+ "rigforge": {"version": "1.11.0"},
}
]
}
@@ -709,6 +712,91 @@ async def test_worker_routes_absent_when_control_disabled(self, client):
).status == 404
+class TestWorkerUpgrade:
+ """The one-click rig upgrade route (#597): spool-only, name + confirmed version, no waiting."""
+
+ async def test_requires_control_header(self, worker_client):
+ resp = await worker_client.post(
+ "/api/control/worker-upgrade", json={"worker": "rig1", "version": "v1.11.2"}
+ )
+ assert resp.status == 403 # CSRF guard
+
+ async def test_malformed_worker_or_version_rejected(self, worker_client):
+ for body in (
+ {"version": "v1.11.2"}, # missing worker
+ {"worker": "", "version": "v1.11.2"}, # empty worker
+ {"worker": "rig1"}, # missing version
+ {"worker": "rig1", "version": "1.11.2"}, # bare — the intent carries the tag form
+ {"worker": "rig1", "version": "v1.11.2;rm"}, # junk after the tag
+ ):
+ resp = await worker_client.post(
+ "/api/control/worker-upgrade", json=body, headers=CONTROL_HEADERS
+ )
+ assert resp.status == 400, body
+
+ async def test_spools_name_and_version_only_and_returns_202(
+ self, worker_client, control_spool, monkeypatch
+ ):
+ rid = str(uuid.uuid4())
+ monkeypatch.setattr(control_service.uuid, "uuid4", lambda: uuid.UUID(rid))
+ resp = await worker_client.post(
+ "/api/control/worker-upgrade",
+ json={"worker": "rig1", "version": "v1.11.2"},
+ headers=CONTROL_HEADERS,
+ )
+ # Always 202 — a rig build can run minutes, so the client polls /api/control/result.
+ assert resp.status == 202
+ body = await resp.json()
+ assert body["status"] == "pending" and body["id"] == rid
+ # The intent carries ONLY the worker name + proposed version — never host/port/token.
+ req = json.loads((control_spool / "requests" / f"{rid}.json").read_text())
+ assert req["action"] == "worker-upgrade"
+ assert req["worker"] == "rig1" and req["version"] == "v1.11.2"
+ assert "host" not in req and "port" not in req and "token" not in req
+ assert "changes" not in req
+
+ async def test_non_json_body_is_a_400(self, worker_client):
+ resp = await worker_client.post(
+ "/api/control/worker-upgrade", data=b"not json", headers=CONTROL_HEADERS
+ )
+ assert resp.status == 400
+ assert "must be JSON" in await resp.text()
+
+ async def test_submit_failure_is_a_500_without_detail(self, worker_client, monkeypatch):
+ def boom(*a, **k):
+ raise OSError("spool dir gone")
+
+ monkeypatch.setattr(control_service, "submit_worker_upgrade", boom)
+ resp = await worker_client.post(
+ "/api/control/worker-upgrade",
+ json={"worker": "rig1", "version": "v1.11.2"},
+ headers=CONTROL_HEADERS,
+ )
+ assert resp.status == 500
+ # The body carries a generic message, never the exception text.
+ assert "spool dir gone" not in await resp.text()
+
+ async def test_noop_when_rig_already_reports_the_version(self, worker_client, control_spool):
+ # The fixture rig reports bare "1.11.0"; proposing tag v1.11.0 must short-circuit —
+ # no spool, no host dial, no burn of the rig's own 6h upgrade throttle.
+ resp = await worker_client.post(
+ "/api/control/worker-upgrade",
+ json={"worker": "rig1", "version": "v1.11.0"},
+ headers=CONTROL_HEADERS,
+ )
+ assert resp.status == 200
+ assert (await resp.json())["status"] == "noop"
+ assert list((control_spool / "requests").glob("*.json")) == []
+
+ async def test_route_absent_when_control_disabled(self, client):
+ resp = await client.post(
+ "/api/control/worker-upgrade",
+ json={"worker": "rig1", "version": "v1.11.2"},
+ headers=CONTROL_HEADERS,
+ )
+ assert resp.status == 404
+
+
class TestWorkerApplyEdgeCases:
async def test_apply_bad_body_and_missing_worker(self, worker_client):
# Non-JSON body → 400.
diff --git a/docs/dashboard.md b/docs/dashboard.md
index afb80e88..27e45dbd 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -291,6 +291,15 @@ To make a rig editable, give it `host`, `token`, and (unless it's the default `8
in its [`workers.list[]`](configuration.md#configuration-reference) descriptor. Without a host, or
without a token, the rig isn't a write target and the panel says so.
+When the rig's [new-release badge](#workers-alive) shows and the rig is editable, an **Upgrade
+rig…** button appears beside it: arm it, confirm, and the rig upgrades its own RigForge to the
+latest release — the per-worker twin of the stack's one-click upgrade. The rig may rebuild its
+miner (about ten minutes when the XMRig pin changed) and rolls itself back if the miner doesn't
+come back live. The panel shows the outcome (applied / rolled back / failed); a repeat click inside
+the rig's own six-hour upgrade window reads as "throttled — retry later", not an error. See
+[Connecting Miners › One-click rig upgrade](workers.md#one-click-rig-upgrade) for what the rig
+must enable and how the target is derived.
+
How it stays safe:
- **The dashboard never holds the rig's token.** It spools the worker name and the change into the
diff --git a/docs/dev/testing-strategy.md b/docs/dev/testing-strategy.md
index d66fde4f..b4c738c9 100644
--- a/docs/dev/testing-strategy.md
+++ b/docs/dev/testing-strategy.md
@@ -144,6 +144,7 @@ it — the worker-API **auth model is the #315 none/name/token matrix**, not the
| Rig-side edit reflects: a direct rig control-API change shows in the enriched feed; a `config.json` hand-edit shows in the masked prefill | live rig + direct dial | 4 ▶ (`run.sh --rigforge-control`, #516) |
| Control-apply auto-rollback (rigforge#236): a hashrate-tanking change is recorded `rolled_back` in the worker-apply result + per-worker history | live rig + fault-injection | 4 ▶ (`run.sh --rigforge-control`, #517; operator supplies `IT_RIG_ROLLBACK_CHANGES`) |
| Per-worker RigForge new-release badge (#596): one fleet-wide latest-release fetch (hourly throttle, disabled = never dials) compared against each rig's reported `version`, bare-vs-`v`-prefixed SemVer | `dashboard.check_for_updates` + rig-reported version | 1 ✅ (`test_update_checker` comparison/throttle/no-dial; `test_views` + node tests for the per-worker plumbing) · 4 (deferred — live badge on a real rig, owed to the #597 gouda loaner session) |
+| One-click rig upgrade (#597): the #596 badge proposes a version, the host re-derives the target from the RigForge release API over Tor and dials the rig's `/upgrade`; refusals (non-latest, GitHub unreachable/no tag, old rig < v1.11.2 non-202), the anti-beacon throttle, and the poll-cap timeout→accepted fallback | `/api/control/worker-upgrade` + host runner + badge render | 1 ✅ (`test_server.py` + `test_control_service.py`, `tests/stack` #597 cases, `workerview.test.mjs`) · 4 (deferred — gouda loaner: badge → click → applied → badge clears, repeat-click throttle, old-rig refusal) |
| Stratum auth accept/reject: matching `pass` mines, wrong/missing `pass` rejected, rotation | live proxy `--access-password` | 4 (deferred — a headless xmrig login probe, real proxy binary) |
| Dev-fee independence (#173): proxy `--donate-level` and rig `DONATION` both honored | live proxy + rig | 4 (deferred) |
diff --git a/docs/workers.md b/docs/workers.md
index aae4be13..a179a8da 100644
--- a/docs/workers.md
+++ b/docs/workers.md
@@ -336,6 +336,33 @@ stack's own new-release check, and fails silent offline (no badge, no error). Th
Only rigs on the enriched `8081` feed report a version. A plain-xmrig rig on `8080` has no version
to compare, so it shows no badge — that reads as "unknown", not "up to date".
+#### One-click rig upgrade
+
+With the control channel on (`dashboard.control.enabled`) and the rig editable (an operator-set
+`host` + `token` in `workers.list[]`), the badge gains an **Upgrade rig…** button in
+[Worker Inspect](dashboard.md#worker-inspect): arm, confirm, and the rig checks out and installs
+the latest RigForge release itself — the per-worker twin of the stack's one-click upgrade.
+
+The rig side must opt in: RigForge's `control_upgrade` capability (default off, on top of its
+`control` flag, bearer token, and source pin) and RigForge **v1.11.2 or newer** — earlier versions
+refuse legitimate upgrades on a fresh clone. See
+[RigForge › ADR 0002](https://github.com/p2pool-starter-stack/rigforge/blob/main/docs/adr/0002-remote-upgrade.md)
+for the rig's own guard chain (monotonic version, tag must be an ancestor of `main`, rollback when
+the miner doesn't come back live, and a six-hour throttle between attempts).
+
+What the dashboard sends is a proposal, not a target: the host-side runner re-derives the real
+latest release from the RigForge release API over Tor and refuses a mismatch, then resolves the
+rig's address and bearer from `config.json` — the container never holds the token and cannot choose
+what gets installed. The dial to the rig itself rides the mining LAN, like every control-path dial.
+A rig already on the latest release short-circuits to a no-op without dialing, so the rig's
+six-hour window isn't burned; a repeat click inside that window surfaces as retry-later.
+
+An upgrade can rebuild the rig's miner (about ten minutes when the XMRig pin changed). The runner
+polls the rig to a terminal outcome with a hard cap; past the cap the panel reports the upgrade
+still running and the badge clears on its own once the rig's next poll reports the new version.
+Upgrades are per-rig only — there is deliberately no "upgrade all" button, so one click can never
+stagger rebuilds across the whole farm.
+
---
## New to mining? Start with RigForge
diff --git a/pithead b/pithead
index fe3e5ce9..90f2d96d 100755
--- a/pithead
+++ b/pithead
@@ -5756,6 +5756,184 @@ control_worker_apply() { #
control_audit "$auditf" "$id" "$actor" "worker-apply" "accepted"
}
+control_worker_upgrade() { #
+ # One-click RigForge upgrade for a single rig (#597) — fuses the two existing templates:
+ # control_worker_apply's rig resolution/guards/dial (address + bearer from the HOST config,
+ # never the intent) and control_upgrade's throttled host-side target re-derivation over Tor
+ # (the container proposes a version; GitHub decides the real target; a mismatch is refused).
+ # The rig bounds whatever tag we send with its own monotonic + ancestry guards and rolls back
+ # a build that doesn't come back live — rollback coverage is rig-side (rigforge#322).
+ local file="$1" id="$2" actor="$3" cdir="$4"
+ local results="$cdir/results" auditf="$cdir/audit/control.log"
+ control_audit "$auditf" "$id" "$actor" "worker-upgrade" "started"
+ _wu_reject() { # — refused before dialing the rig; nothing changed
+ control_write_result "$results" "$id" "$(jq -n --arg e "$1" '{status:"rejected",error:$e,ts:(now|floor)}')"
+ control_audit "$auditf" "$id" "$actor" "worker-upgrade" "rejected"
+ }
+ _wu_fail() { # — the dial started and did not complete
+ control_write_result "$results" "$id" "$(jq -n --arg e "$1" '{status:"failed",error:$e,ts:(now|floor)}')"
+ control_audit "$auditf" "$id" "$actor" "worker-upgrade" "failed"
+ }
+ local worker proposed
+ worker=$(jq -r '.worker // ""' "$file")
+ # Same charset pin as worker-apply: the name is a config.json lookup key. LC_ALL=C so [!-~]
+ # is the printable-ASCII BYTE range regardless of the host locale (#185's UTF-8 lesson).
+ if ! printf '%s' "$worker" | LC_ALL=C grep -qE '^[!-~]{1,128}$'; then
+ _wu_reject "malformed or missing 'worker' name in the request."
+ return 0
+ fi
+ proposed=$(jq -r '.version // ""' "$file")
+ if ! printf '%s' "$proposed" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
+ _wu_reject "malformed or missing 'version' in the upgrade request."
+ return 0
+ fi
+ # Resolve the rig's ADDRESS + BEARER from the HOST's config.json — never the intent (same
+ # fail-closed contract as worker-apply: only an operator-set host can be a target).
+ local host cport token
+ host=$(jq -r --arg n "$worker" "$WORKER_LIST_JQ"'worker_list[] | select(.name == $n) | .host // ""' "$CONFIG_FILE" 2>/dev/null | head -1)
+ cport=$(jq -r --arg n "$worker" "$WORKER_LIST_JQ"'worker_list[] | select(.name == $n) | .control_port // 8082' "$CONFIG_FILE" 2>/dev/null | head -1)
+ token=$(jq -r --arg n "$worker" "$WORKER_LIST_JQ"'worker_list[] | select(.name == $n) | .token // ""' "$CONFIG_FILE" 2>/dev/null | head -1)
+ if [ -z "$host" ]; then
+ _wu_reject "worker '$worker' has no configured host in workers.list[] (or the deprecated dashboard.workers[]) — set host + control_port + token to upgrade it."
+ return 0
+ fi
+ if ! printf '%s' "$host" | grep -qE '^[A-Za-z0-9._-]{1,253}$'; then
+ _wu_reject "worker '$worker' has an invalid host."
+ return 0
+ fi
+ if ! printf '%s' "$cport" | grep -qE '^[0-9]{1,5}$' || [ "$cport" -lt 1 ] || [ "$cport" -gt 65535 ]; then
+ _wu_reject "worker '$worker' has an invalid control_port."
+ return 0
+ fi
+ if [ -z "$token" ]; then
+ _wu_reject "worker '$worker' has no token in workers.list[] (or the deprecated dashboard.workers[]) — the rig's control API is bearer-mandatory."
+ return 0
+ fi
+ # Per-drain budget: an upgrade blocks the single-threaded root runner on the rig's build
+ # (minutes, vs seconds for worker-apply), so exactly ONE dials per drain. v1 is per-worker
+ # only — no "upgrade all" — and a real fleet upgrade is one rig at a time by design.
+ if [ "${CONTROL_WU_BUDGET:-0}" -le 0 ]; then
+ _wu_reject "another worker upgrade is already in this cycle — retry in a moment."
+ return 0
+ fi
+ CONTROL_WU_BUDGET=$((CONTROL_WU_BUDGET - 1))
+ # Host-side re-derivation of the target from the RigForge release API over Tor — load-bearing:
+ # the rig deliberately computes no "latest" itself (ADR 0002 D4), it bounds the tag we send.
+ # The derived tag is cached for 10 minutes and the dial itself is stamp-throttled to one per
+ # 10 minutes (claimed BEFORE the dial, control_upgrade's anti-beacon lesson): a compromised
+ # container flooding well-formed intents costs at most one GitHub/Tor egress per window,
+ # while a legitimate rig-after-rig fleet upgrade reuses the cached tag.
+ local tagf="$cdir/staged/.rigforge-latest-tag" stampf="$cdir/staged/.rigforge-latest-stamp" tag=""
+ if [ -n "$(find "$tagf" -mmin -10 2>/dev/null)" ]; then
+ tag=$(cat "$tagf" 2>/dev/null)
+ fi
+ if ! printf '%s' "$tag" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
+ if [ -n "$(find "$stampf" -mmin -10 2>/dev/null)" ]; then
+ _wu_reject "a RigForge release lookup was attempted less than 10 minutes ago and no usable tag is cached — retry in a few minutes."
+ return 0
+ fi
+ touch "$stampf"
+ local prefix socks rel
+ prefix=$(env_get NETWORK_PREFIX 2>/dev/null)
+ [ -n "$prefix" ] || prefix="172.28.0"
+ socks="${prefix}.25:9050"
+ if ! rel=$(curl -fsS --max-time 60 --socks5-hostname "$socks" \
+ -H 'Accept: application/vnd.github+json' \
+ "https://api.github.com/repos/p2pool-starter-stack/rigforge/releases/latest" 2>/dev/null); then
+ _wu_reject "could not reach the GitHub release API over Tor — nothing was changed. Check './pithead doctor' and retry."
+ return 0
+ fi
+ tag=$(printf '%s' "$rel" | jq -r '.tag_name // ""' 2>/dev/null)
+ if ! printf '%s' "$tag" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
+ _wu_reject "the GitHub release API returned no usable RigForge release tag — nothing was changed."
+ return 0
+ fi
+ printf '%s' "$tag" >"$tagf"
+ fi
+ if [ "$proposed" != "$tag" ]; then
+ _wu_reject "requested version $proposed is not the latest published RigForge release ($tag) — reload the dashboard and retry."
+ return 0
+ fi
+ control_write_result "$results" "$id" "$(jq -n --arg w "$worker" --arg v "$tag" '{status:"running",worker:$w,version:$v,ts:(now|floor)}')"
+ # POST the upgrade to the rig's control API — direct LAN dial like worker-apply, NOT Tor. The
+ # body carries the HOST-derived tag only; the token rides one header, never the URL or result.
+ local url="http://$host:$cport/upgrade" bodyf="$cdir/staged/.$id.body" code
+ if ! code=$(curl -sS -o "$bodyf" -w '%{http_code}' --max-time 15 \
+ -H "Authorization: Bearer $token" -H "Content-Type: application/json" \
+ --data "$(jq -n --arg v "$tag" '{version:$v}')" "$url" 2>/dev/null); then
+ rm -f "$bodyf"
+ _wu_fail "could not reach worker '$worker' control API at $host:$cport — nothing was changed."
+ return 0
+ fi
+ if [ "$code" != "202" ]; then
+ local rig_err
+ # Rig-supplied text is attacker-influenceable (a compromised rig / LAN MITM); cap it.
+ rig_err=$(jq -r '.error // ""' "$bodyf" 2>/dev/null | head -c 500)
+ rm -f "$bodyf"
+ _wu_reject "worker '$worker' refused the upgrade (HTTP $code): ${rig_err:-no detail}."
+ return 0
+ fi
+ local change_id
+ change_id=$(jq -r '.change_id // ""' "$bodyf" 2>/dev/null | head -c 64)
+ rm -f "$bodyf"
+ # Poll the rig's /status for THIS change_id's terminal outcome. The cap is a deliberate
+ # trade: a no-rebuild upgrade (the common case — git checkout + restart) reaches terminal in
+ # well under 90s, while a pin-change rebuild (~10 min) times out to "accepted" below and the
+ # badge (#596) clears on its own when the rig reports the new version. Polling the full build
+ # would hand a hostile/hung rig 12 minutes of the single-threaded root drain per intent
+ # (sec-review finding) — 90s keeps the stall bound in worker-apply's envelope. There is no
+ # in-progress status (rigforge#320): a non-matching change_id just means pending. Terminals
+ # are applied / rolled_back / failed; a rig-side throttle refusal arrives as
+ # failed+"throttled" and is surfaced as retry-later, not an error. The cap is overridable
+ # (CONTROL_WU_POLL_CAP) so the stack tests can prove the timeout→accepted fallback in seconds.
+ local sbody scode status reason deadline=$((SECONDS + ${CONTROL_WU_POLL_CAP:-90}))
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ sleep 5
+ sbody="$cdir/staged/.$id.status"
+ if ! scode=$(curl -sS -o "$sbody" -w '%{http_code}' --max-time 10 \
+ -H "Authorization: Bearer $token" "http://$host:$cport/status" 2>/dev/null); then
+ rm -f "$sbody"
+ continue
+ fi
+ [ "$scode" = "200" ] || {
+ rm -f "$sbody"
+ continue
+ }
+ # Only trust a status whose change_id matches ours — the rig may still be showing a
+ # PREVIOUS change's terminal state (no in-progress status, rigforge#320).
+ if [ "$(jq -r '.change_id // ""' "$sbody" 2>/dev/null)" != "$change_id" ]; then
+ rm -f "$sbody"
+ continue
+ fi
+ status=$(jq -r '.status // ""' "$sbody")
+ case "$status" in
+ applied | rolled_back | failed)
+ # reason is rig-supplied (attacker-influenceable); cap it before it is stored/rendered.
+ reason=$(jq -r '.reason // ""' "$sbody" | head -c 500)
+ rm -f "$sbody"
+ # The rig collapses its refusals into failed+free-text (rigforge#320); its 6h
+ # anti-beacon throttle is retry-later, not a fault — give it its own status so the
+ # dashboard renders it calm instead of red.
+ if [ "$status" = "failed" ] && printf '%s' "$reason" | grep -qi "throttl"; then
+ status="throttled"
+ fi
+ control_write_result "$results" "$id" "$(jq -n --arg s "$status" --arg c "$change_id" --arg w "$worker" --arg v "$tag" --arg r "$reason" \
+ '{status:$s,change_id:$c,worker:$w,version:$v,reason:(if $r=="" then null else $r end),ts:(now|floor)}')"
+ control_audit "$auditf" "$id" "$actor" "worker-upgrade" "$status"
+ return 0
+ ;;
+ esac
+ rm -f "$sbody"
+ done
+ # Accepted but no terminal status inside the cap — the upgrade is running on the rig. Record
+ # accepted, not failure: the badge (#596) clears on its own when the rig's next summary poll
+ # reports the new version ('applied' echoes no version, rigforge#320 — the summary is the
+ # confirmation of record either way).
+ control_write_result "$results" "$id" "$(jq -n --arg c "$change_id" --arg w "$worker" --arg v "$tag" \
+ '{status:"accepted",change_id:$c,worker:$w,version:$v,note:"upgrade still running on the rig — check the rig if the badge has not cleared in a while",ts:(now|floor)}')"
+ control_audit "$auditf" "$id" "$actor" "worker-upgrade" "accepted"
+}
+
control_process_request() { #
local file="$1" cdir="$2" id action actor size
# Refuse a symlinked / non-regular claimed file (graft #437): a symlink dropped in requests/
@@ -5799,6 +5977,7 @@ control_process_request() { #
commit) control_commit "$id" "$actor" "$cdir" ;;
upgrade) control_upgrade "$file" "$id" "$actor" "$cdir" ;;
worker-apply) control_worker_apply "$file" "$id" "$actor" "$cdir" ;;
+ worker-upgrade) control_worker_upgrade "$file" "$id" "$actor" "$cdir" ;;
restart | apply) control_lifecycle "$action" "$id" "$actor" "$cdir" ;;
*)
control_write_result "$cdir/results" "$id" "$(jq -n '{status:"rejected",error:"unknown action",ts:(now|floor)}')"
@@ -5837,6 +6016,9 @@ control_run_pending() {
# the runner on a network round-trip, so cap how many dial per drain (the rest reject with a retry
# hint). control_worker_apply reads + decrements this in the same shell.
CONTROL_WA_BUDGET=5
+ # Worker-upgrade budget (#597): an upgrade blocks the runner on a rig build (minutes), so
+ # exactly one runs per drain; the rest reject with a retry hint.
+ CONTROL_WU_BUDGET=1
names=$(cd "$cdir/requests" 2>/dev/null && ls -1tr -- *.json 2>/dev/null) || true
if [ -z "$names" ]; then
log "No pending control requests."
diff --git a/tests/stack/run.sh b/tests/stack/run.sh
index a9cfc255..468b5983 100755
--- a/tests/stack/run.sh
+++ b/tests/stack/run.sh
@@ -6111,6 +6111,317 @@ assert_eq "worker-apply accept path records the rig's changed_keys" \
assert_contains "worker-apply accept is audited as applied" \
"$(cat "$WA3/audit/control.log")" '"action":"worker-apply","status":"applied"'
+# ---------------------------------------------------------------------------
+echo "== control channel: worker upgrade fails closed (#597) =="
+# control_worker_upgrade fuses the worker-apply template (rig resolved from the HOST config, never
+# the intent) with the stack-upgrade template (host-side target re-derivation, throttled). These are
+# the pre-dial fail-closed guards.
+WU="$SANDBOX/ctrl597"
+mkdir -p "$WU/staged" "$WU/results" "$WU/audit"
+cat >"$WU/config.json" <<'EOF'
+{ "workers": { "list": [
+ { "name": "rig1", "host": "10.0.0.9", "control_port": 8082, "token": "tok-rig1" },
+ { "name": "rig2", "host": "10.0.0.8" }
+] } }
+EOF
+wu_case() { #