Skip to content

Commit 1b617fe

Browse files
authored
Merge pull request #732 from p2pool-starter-stack/develop-v1.11
v1.11: collapse fleet-resilience & hardening features into develop
2 parents 4526ce2 + 215157a commit 1b617fe

43 files changed

Lines changed: 3800 additions & 145 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,93 @@ Pithead ships as **one product, one version** — the version lives in the top-l
99
[`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut
1010
per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
1111

12+
## [Unreleased]
13+
14+
### Added
15+
16+
- **Confirm-gated config editing for operationally-disruptive settings** (#719). The dashboard
17+
config editor can now commit a small set of disruptive-but-recoverable settings behind a
18+
type-to-confirm, instead of refusing them outright: the four service data directories (a move
19+
re-syncs), the stratum port (rigs must repoint), the Monero/Tari clearnet initial-sync toggles
20+
(the host IP is exposed during IBD, then auto-reverts to Tor), and enabling Monero pruning. Each
21+
renders editable with a "you'll type `APPLY` to confirm" affordance; the typed confirmation rides
22+
to the host approval gate, which requires it before the change proceeds and records the apply in
23+
the control audit log as a distinct `commit-confirmed` action.
24+
25+
### Changed
26+
27+
- **The control-channel security perimeter stays host-only** (#719). Type-to-confirm is UX
28+
friction, not a security control — a compromised dashboard that can set a field can also fill the
29+
confirm box — so the perimeter is unchanged: wallets and view keys, the dashboard login and onion
30+
settings, the control channel itself, the Tor egress firewall, the stratum password, node
31+
endpoints and credentials, and the per-rig hosts and tokens all remain refused from the dashboard,
32+
as does the heavier direction of a confirm-gated key (disabling pruning forces a full re-sync).
33+
- **Dashboard-confirmed data-directory moves are allowlisted to the stack's data root** (#728).
34+
#719 made the four `*_DATA_DIR` moves confirmable from the dashboard behind a typed `APPLY`, but
35+
the destination was still checked only by the host-side blocklist (`assert_safe_dir`), which
36+
passes any non-catastrophic absolute path. A confirmed move from the dashboard is now further
37+
held to an allowlist: the new location must sit under the stack's own data root (the install
38+
dir's `data/`) or a parent the stack already keeps data in, else the move is refused even with
39+
the typed confirmation and stays host-CLI only. The host `./pithead apply` path keeps the wider
40+
blocklist — a shell operator already has filesystem-wide reach; only the dashboard-reachable move
41+
is tightened, closing the destination trust-escalation the confirm-gate opened.
42+
- **Opt-in local miner** (#593). A box that runs the stack 24/7 can mine with its spare CPU by
43+
co-locating a RigForge worker on the stack host. `./pithead setup` now asks "Also mine on this
44+
machine with its spare CPU?" (off by default; also the new `local_miner.enabled` config flag),
45+
and setup/apply print the two values a RigForge install needs — the stack's own stratum URL
46+
(loopback `127.0.0.1:3333`, or the configured `p2pool.stratum_bind`/`stratum_port`) and the
47+
stratum secret already in `.env`. The co-located worker self-registers through the proxy like any
48+
other rig. Pithead only declares the intent and hands off those values; RigForge owns all
49+
host-level tuning (HugePages, GRUB, MSR, governor) and the miner service. See
50+
[docs/workers.md](docs/workers.md#mine-on-the-stack-host-itself).
51+
- **Warm XvB donation state on a backup stack (#249).** On a two-host failover pair — same wallet,
52+
workers listing both hosts in `pools[]` — the backup's XvB donation controller used to cold-start
53+
when the fleet failed over to it: the closed-loop split restarted from the feedforward estimate
54+
and re-ramped for hours, over- or under-shooting the credited tier until it reconverged. The
55+
controller's commanded donation fraction is now persisted, so a plain restart resumes the warmed
56+
split instead of re-seeding cold. A backup can also point `xvb.standby.source` at the primary
57+
dashboard's new read-only `/api/xvb-standby` endpoint; it periodically pulls the primary's
58+
controller state and holds it as standby (inspectable in `/api/state`), then adopts it the first
59+
time it actually donates at failover — so the split resumes warm. One-way (backup pulls from
60+
primary), inert unless configured, and never acted on while the primary is authoritative (an idle
61+
backup has no workers, so its controller stays on P2Pool). The pull follows the dashboard's
62+
privacy-safe egress rule: an `.onion` source, a public IP, or any hostname rides the bridge Tor
63+
SOCKS (the primary sees a Tor exit, never the backup's IP); only a provably-private/loopback IP
64+
literal dials direct as a LAN hop — so the pull never opens a clearnet path, and the Security
65+
panel reports its route.
66+
- **Opt-in fail-closed miner hold on an unrecoverable dashboard health failure** (#490). New
67+
`dashboard.fail_closed` toggle, default **off**. The dashboard is an observability layer, not the
68+
mining datapath (`xmrig-proxy``p2pool``monerod` runs independently of it), so by default an
69+
unhealthy condition only alerts (Telegram/Healthchecks/webhook) and shows a badge while mining
70+
continues. Set it `true` and a genuinely unrecoverable failure — the SQLite database failing to
71+
rebuild after its own auto-heal attempt (disk full, permissions), or the `dashboard` container
72+
itself crash-looping past the #337 debounce — holds `p2pool` and `xmrig-proxy` using the same
73+
#35 sync-gate stop/start mechanism, with a `Miner held (fail-closed)` badge. Unlike the sync
74+
gate's one-way latch it re-checks every cycle and releases on its own once the condition clears.
75+
A transient write blip, a slow query, or a single failed external fetch never trips it — those
76+
still only alert. Gated by the #33 control-approval path like other `dashboard.*` toggles.
77+
- **`/api/state` exposure for three of the #196 telemetry-backbone series (Tier-1).** The backbone
78+
PR (#600) added five persisted SQLite tables with capture, storage, and retention, but shipped
79+
without surfacing them to the client. This slice exposes three — `blocks` (pool block-found
80+
events), `disk_growth` (hourly monerod-DB-size + host-disk-usage samples), and `xvb_history`
81+
(~5-min XvB-credited scalar samples) — as range-filtered arrays under those same keys, bounded
82+
at the existing 700-point chart cap for the two higher-cadence series. `network_history` and
83+
`worker_history` are a separate (Tier-2) slice, not touched here. No chart renders any of these
84+
series yet — that's a further follow-up.
85+
86+
### Security
87+
88+
- **The out-of-band audit trail is now flood-capped per worker (#724).** The `rig-edit` audit
89+
source (#530) reads a worker's reported change id off the unauthenticated LAN worker feed. The
90+
#530 deterministic row id collapses *repeats* of one change id to a single row, but not *distinct*
91+
ones — so a malicious or compromised device presenting as a worker could report a fresh random
92+
change id every poll, writing a new permanent `audit_events` row each ~30s cycle and slowly
93+
filling the dashboard database (the table has no pruning). New `rig-edit` rows are now capped per
94+
worker per rolling hour; beyond the cap the extra rows are dropped behind a single `rate-limited`
95+
marker row and a logged warning, so the flood stays visible instead of growing the table without
96+
limit. A genuine occasional rig change still records normally, and the non-attacker-controllable
97+
`host-edit` and mirrored `control.log` rows are unaffected.
98+
1299
## [1.10.2] - 2026-07-21
13100

14101
### Fixed

SECURITY.md

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,21 @@ The stack's defaults:
6565
API (over the stack's Tor SOCKS), refuses any mismatch or non-release tag, and limits attempts
6666
to one per 10 minutes — the container cannot choose an image, tag, or registry. Enabling the
6767
channel without a dashboard password is a validation error, on a published onion it additionally
68-
requires Tor client authorization, and every mutation is audited host-side. Commits are default-denied against an explicit allowlist of
69-
operational settings: a commit that changes any env key off that list — in every direction
70-
(enabling, changing, or disabling) — is refused, as is anything the change preview flags
71-
destructive. Wallets, dashboard auth and onion exposure, the control channel itself, the Tor
72-
egress firewall, clearnet toggles, node endpoints, binds, and every credential are off the
73-
list, and a key added in the future stays un-committable until deliberately listed. Those
74-
edits must be applied from the host CLI; out-of-band approval is tracked in
75-
[#338](https://github.com/p2pool-starter-stack/pithead/issues/338).
68+
requires Tor client authorization, and every mutation is audited host-side. Commits are default-denied against an explicit allowlist. Low-risk
69+
operational settings commit directly; a small set of operationally-disruptive ones — data-directory
70+
moves, the stratum port, enabling clearnet initial sync, and enabling pruning — commit only behind
71+
a typed confirmation in the dashboard, and only in that direction. A dashboard-confirmed
72+
data-directory move is further held to an **allowlist** (#728): the new location must sit under the
73+
stack's own data root (the install dir's `data/`) or a parent the stack already keeps data in;
74+
a move to any other absolute path is refused even with the typed confirmation and stays host-CLI
75+
only. The host CLI keeps its wider blocklist check — a shell operator already has filesystem-wide
76+
reach. Everything else is refused in
77+
every direction, as is anything the change preview flags destructive (including the heavy direction
78+
of a confirm-gated key, e.g. disabling pruning, which forces a full re-sync). The security
79+
perimeter — wallets and view keys, dashboard auth and onion exposure, the control channel itself,
80+
the Tor egress firewall, node endpoints, binds, every credential, and the per-rig hosts and tokens —
81+
is never dashboard-committable, with or without the typed confirmation. A key added in the
82+
future stays un-committable until deliberately listed. Those edits must be applied from the host CLI.
7683
- Attack visibility (#349): Caddy writes a JSON access log for every dashboard vhost (LAN and
7784
onion), and the control channel's host-side audit log records who changed what (setting names
7885
only, never values). The dashboard surfaces both read-only — a burst of 401s is the
@@ -82,6 +89,18 @@ The stack's defaults:
8289
anonymous prober stored XSS against the operator. Both logs are size-bounded (Caddy's native
8390
rolling; a trim-before-append cap in the audit writer). Neither ever records a secret: Caddy
8491
redacts credential headers by default, and the audit writer logs key names only.
92+
- Out-of-band change detection (#530): the audit trail above only sees requests the dashboard
93+
itself handled. Its poll loop separately watches for a `config.json` change with no matching
94+
control-channel commit, and a worker control-API report for a change the dashboard never sent,
95+
and appends both — `host-edit` / `rig-edit` — to the same trail, keys or worker names only. The
96+
persisted trail (mirrored `control.log` rows plus these two out-of-band kinds) lives in the
97+
dashboard's own database, not just the log tail, so the Security panel's hour/day/month grouping
98+
covers more than `control.log`'s own trimmed window. The `rig-edit` source reads off the
99+
unauthenticated worker feed, so it is rate-capped per worker (#724): a rig reporting distinct
100+
change_ids on every poll can add at most a bounded number of rows per hour before the rest are
101+
dropped behind a single `rate-limited` marker — one LAN device can't grow the database without
102+
limit. The `host-edit` and mirrored `control.log` rows are not attacker-controllable and are not
103+
capped.
85104

86105
### Telegram control commands (#338)
87106

build/dashboard/mining_dashboard/config/config.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,15 @@ def _nonneg(v):
429429
if c.strip()
430430
]
431431

432+
# Opt-in fail-closed miner hold on an UNRECOVERABLE health failure (#490), `dashboard.fail_closed`,
433+
# default false. The dashboard is an observability layer, not the mining datapath (xmrig-proxy ->
434+
# p2pool -> monerod is independent of it), so the default posture is alert-only: a cosmetic
435+
# dashboard fault must never idle the fleet. `true` reuses the #35 sync-gate's own mechanism
436+
# (stop/start SYNC_GATE_CONTAINERS) to hold the miner until the failure clears — see
437+
# DataService._apply_fail_closed_gate for what counts as "unrecoverable" (narrowly: DB recovery
438+
# itself failing, or the dashboard container crash-looping — never a transient blip).
439+
DASHBOARD_FAIL_CLOSED = os.environ.get("DASHBOARD_FAIL_CLOSED", "false").strip().lower() == "true"
440+
432441
# Debounce: a node must be unreachable this long before it's declared DOWN, and reachable
433442
# this long before recovery — so a single transient timeout or a brief restart doesn't
434443
# kick every miner to their backups (and back) on a blip.
@@ -689,6 +698,21 @@ def _telegram_event_enabled(name, default=True):
689698
# Switching overhead (ms) to account for connection ramp-up time
690699
XVB_SWITCH_OVERHEAD_MS = 5000
691700

701+
# --- XvB warm-standby, backup stack (#249, config.json: xvb.standby.source) ---
702+
# On a two-host failover pair (same wallet, workers list both in pools[]), the BACKUP points this at
703+
# the PRIMARY's dashboard so it can pull the primary's XvB controller state and hold it as standby —
704+
# so when workers fail over, the backup resumes the donation split warm instead of cold-ramping for
705+
# hours. Blank (the default) = off: an ordinary single stack never pulls. The value is the full URL
706+
# of the primary's read-only endpoint (e.g. https://<primary-onion>/api/xvb-standby, or a LAN
707+
# http://host:port/api/xvb-standby); an .onion source rides the bridge Tor SOCKS, a LAN source dials
708+
# direct — no new clearnet egress either way. It is a capability URL (it can carry the primary's
709+
# dashboard basic-auth as userinfo), so pithead renders it into the owner-only .env and masks it in
710+
# the control-channel config, exactly like the Healthchecks ping URL.
711+
XVB_STANDBY_SOURCE = os.environ.get("XVB_STANDBY_SOURCE", "").strip()
712+
# How often the backup re-pulls the primary's state (seconds). A code-level constant, not a knob:
713+
# the state moves slowly (the closed loop steps once per 10-min cycle), so a minute is ample.
714+
XVB_STANDBY_INTERVAL_S = 60
715+
692716
# --- Data Retention Policies ---
693717
HISTORY_RETENTION_SEC = 30 * 24 * 3600 # 30 Days
694718
# How long an offline worker lingers in the live "Workers Alive" table before it falls off (#182).

build/dashboard/mining_dashboard/main.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from mining_dashboard.service.data_service import DataService
1616
from mining_dashboard.service.storage_service import StateManager
1717
from mining_dashboard.service.telegram_commands import TelegramCommandBot
18+
from mining_dashboard.service.xvb_standby import XvbStandbyPuller
1819
from mining_dashboard.web.server import create_app
1920

2021
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
@@ -37,20 +38,29 @@ def build_app() -> web.Application:
3738
# On-demand Telegram command interface (#45). Reads the snapshot data_service already collects;
3839
# a no-op unless telegram.enabled + telegram.commands.enabled + bot_token + chat_id are set.
3940
telegram_bot = TelegramCommandBot(data_service)
41+
# Backup-stack warm-standby puller (#249): pulls the primary's XvB controller state so a
42+
# failover resumes warm. Inert unless xvb.standby.source is configured.
43+
xvb_standby = XvbStandbyPuller(state_manager)
4044

4145
async def start_background_tasks(app):
4246
"""Initializes background services upon web application startup."""
4347
app["data_task"] = asyncio.create_task(data_service.run())
4448
app["algo_task"] = asyncio.create_task(algo_service.run())
4549
app["telegram_task"] = asyncio.create_task(telegram_bot.run())
50+
app["xvb_standby_task"] = asyncio.create_task(xvb_standby.run())
4651

4752
async def cleanup_background_tasks(app):
4853
"""Stops background tasks and closes resources on shutdown."""
4954
app["data_task"].cancel()
5055
app["algo_task"].cancel()
5156
app["telegram_task"].cancel()
57+
app["xvb_standby_task"].cancel()
5258
await asyncio.gather(
53-
app["data_task"], app["algo_task"], app["telegram_task"], return_exceptions=True
59+
app["data_task"],
60+
app["algo_task"],
61+
app["telegram_task"],
62+
app["xvb_standby_task"],
63+
return_exceptions=True,
5464
)
5565
if "state_manager" in app:
5666
app["state_manager"].close()

build/dashboard/mining_dashboard/service/alert_service.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,16 @@ async def process(self, **signals):
765765
self._wallet_edges(signals.get("observed_wallet", ""))
766766
except Exception as exc: # never let the tripwire break the data loop
767767
logger.debug("Wallet baseline update failed (%s)", type(exc).__name__)
768+
# Keep the container-health debounce state current even with every sink off (#490):
769+
# `dashboard.fail_closed` reads `self.containers.is_confirmed_bad("dashboard")` off
770+
# the same tracker this alerting path would otherwise be the only feeder for. No
771+
# alert fires here — just the state update the alerting branch below does anyway.
772+
containers = signals.get("containers")
773+
if containers is not None:
774+
try:
775+
self.containers.update(containers, now=signals.get("now"))
776+
except Exception as exc: # never let a tracker bug break the data loop
777+
logger.debug("Container-health update failed (%s)", type(exc).__name__)
768778
return []
769779
try:
770780
alerts = self.evaluate(**signals)

0 commit comments

Comments
 (0)