Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,86 @@ 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).

## [1.10.0] - 2026-07-20

### 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
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".

### 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.

- **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.

- **The egress panel no longer reports a phantom clearnet leak for the XvB stats fetch (#701).**
With `xvb.tor: false`, the #170 posture panel and topology view showed the dashboard's XvB
stats connection as a clearnet leak. That fetch is unconditionally routed over Tor (`socks5h`,
#163) — `xvb.tor` gates only the xmrig-proxy donation dial (#166) — so the panel warned about
a leak that cannot happen. The dashboard's XvB stats route is now Tor whenever XvB is enabled,
matching what the code actually does and what `docs/privacy.md` already documented.

- **The egress panel and network map now list the webhook/ntfy alert sinks (#380).** Both views
derived every dashboard egress except the alert sinks, so a `notifications.tor: false` sink
POSTing to a public endpoint — a real clearnet leak from the host-networked dashboard, which the
egress firewall cannot cover — went uncounted. The new "alert sinks (webhook / ntfy)" entry is
Tor when configured (the default), a counted clearnet leak when Tor is off and any endpoint is
public, and **local** — the LAN carve-out, not a leak — only when every configured endpoint is a
private or loopback IP literal, since a hostname cannot be proven private without a DNS lookup.

### 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.

### 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). 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

### Fixed
Expand Down Expand Up @@ -1053,7 +1133,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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.9.3
1.10.0
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 5 additions & 4 deletions build/dashboard/mining_dashboard/client/xvb_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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}"
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions build/dashboard/mining_dashboard/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions build/dashboard/mining_dashboard/helper/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Bounded reads for every external HTTP fetch (#660).

Each external client (GitHub release checks #224, the three XvB reads, the CoinGecko price feed
#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.
"""

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()``, ``raise_for_status()`` — 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 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.

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)
21 changes: 21 additions & 0 deletions build/dashboard/mining_dashboard/service/control_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions build/dashboard/mining_dashboard/service/data_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
DASHBOARD_ENERGY,
ENABLE_XVB,
GITHUB_RELEASES_API,
GITHUB_RIGFORGE_RELEASES_API,
HASHRATE_DROP_MINUTES,
HASHRATE_DROP_THRESHOLD_PCT,
HOST_IP,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
Loading