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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ per the process in [`docs/releasing.md`](docs/releasing.md).

## [Unreleased]

## [1.5.0] - 2026-07-13

### Added

- **Worker Inspect: view and edit a rig's config from the dashboard (#185).** Click a worker's name in
Expand Down Expand Up @@ -163,6 +165,12 @@ per the process in [`docs/releasing.md`](docs/releasing.md).

### Fixed

- **The `payout_confirmed` alert is now listed and silenceable (#381/#462 follow-up).** The
confirmed-on-chain-payout alert shipped on-by-default but `pithead` never rendered
`telegram.events.payout_confirmed`, so it couldn't be turned off and was missing from the event
reference. It's now wired end to end (config.json → `.env` → dashboard) and documented in the
Telegram alert tables alongside the others; set `telegram.events.payout_confirmed: false` to silence
it.
- **The shared bench-lock opens read-only, matching RigForge byte-for-byte (#499, ports rigforge#252).**
The tier-4 harness's `rig_lock` helper (`tests/integration/lib.sh`) opened its flock file write-open
(`exec 9>`); on a shared box where the lock was first created by a non-root reserve,
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.4.0
1.5.0
2 changes: 1 addition & 1 deletion build/dashboard/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "mining-dashboard"
# Keep in lockstep with the top-level VERSION file — the single source of truth for the stack version
# (#44). A shell test (tests/stack/run.sh) fails if these drift; the dashboard *displays* the version
# from VERSION (baked in as PITHEAD_VERSION, #58), so this is packaging metadata only.
version = "1.4.0"
version = "1.5.0"
description = "Monitoring dashboard and XvB switching engine for Pithead"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion build/dashboard/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions config.reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
"high_reject_rate": true,
"block_found": true,
"payout_found": true,
"payout_confirmed": true,
"container_unhealthy": true
},
"daily_summary_time": "08:00",
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ plain HTTP, edit `config.json` and run `./pithead apply`.
| `telegram.enabled` | `false` | Push operational alerts (node down/recovered, worker offline/back, sync finished) to Telegram. Off by default. Requires `bot_token` + `chat_id` to actually send. Full walkthrough: [Telegram Bot](telegram.md). |
| `telegram.bot_token` | `""` | Your BotFather bot token. A secret — stored owner-only in `.env`, git-ignored, and never logged. Get one from [@BotFather](https://t.me/BotFather). |
| `telegram.chat_id` | `""` | Where alerts are sent and the only chat the command interface answers. A Telegram group id (negative, e.g. `-1001234567890`) or a personal chat id. See [how to find it](telegram.md#3-find-your-chat-id). |
| `telegram.events.*` | all `true` | Per-event toggles: `stack_online`, `node_down`, `node_recovered`, `worker_offline`, `worker_recovered`, `worker_joined`, `worker_left`, `sync_finished`, `disk_space`, `db_unhealthy`, `xvb_no_share`, `xvb_registration`, `clearnet_exposed`, `new_release`, `daily_summary`, `hashrate_low`, `hashrate_loss`, `hugepages`, `low_ram`, `wallet_changed`, `high_reject_rate`. Each defaults to on once Telegram is enabled; set one `false` to silence just that alert. Full list: [Telegram Bot](telegram.md#choosing-which-alerts-you-get). |
| `telegram.events.*` | all `true` | Per-event toggles: `stack_online`, `node_down`, `node_recovered`, `worker_offline`, `worker_recovered`, `worker_joined`, `worker_left`, `sync_finished`, `disk_space`, `db_unhealthy`, `db_reset`, `xvb_no_share`, `xvb_registration`, `clearnet_exposed`, `new_release`, `daily_summary`, `hashrate_low`, `hashrate_loss`, `hugepages`, `low_ram`, `wallet_changed`, `high_reject_rate`, `block_found`, `payout_found`, `payout_confirmed`, `container_unhealthy`. Each defaults to on once Telegram is enabled; set one `false` to silence just that alert. Full list: [Telegram Bot](telegram.md#choosing-which-alerts-you-get). |
| `telegram.daily_summary_time` | `08:00` | Local time (24-hour `HH:MM`) to push the once-a-day status digest, when the `daily_summary` event is on. Uses the dashboard's timezone (`dashboard.timezone`). A malformed value disables the digest. |
| `telegram.commands.enabled` | `false` | Turn on the interactive command interface — the bot answers read-only status queries (`/status`, `/hashrate`, `/workers`, `/luck`, `/earnings`, and more) from the configured `chat_id` (every other chat is ignored). Off by default; alerts work without it. Long-polls over Tor, so it needs no inbound port. Full command list: [Telegram › Commands](telegram.md#commands). |
| `notifications.webhooks` | `[]` | Generic JSON webhook alert sinks (#380): every alert the stack produces is POSTed to each listed URL as `{"event", "text", "ts"}` — for Gotify, Home Assistant, or any endpoint that accepts a POST. Empty list keeps it off. The URLs are secrets (query strings often carry tokens): owner-only `.env`, never logged or printed. Sinks carry every event; `telegram.events` gates Telegram only. See [Telegram › Webhook and ntfy sinks](telegram.md#webhook-and-ntfy-sinks). |
Expand Down
6 changes: 6 additions & 0 deletions docs/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ transition, not a stream:
| 🚨 **Payout wallet changed** | The wallet p2pool mines to differs from the last one seen — every future reward goes to the new address. Fires on **every** change, including one you made yourself with `./pithead apply`, so treat it as a confirmation; if you didn't change it, your rewards are being redirected. Addresses appear truncated to 8 characters (full addresses never leave the host), and the dashboard shows a matching top-bar warning for 72 hours — the dashboard warning works even with Telegram off. |
| 🎉 **Block found** | The P2Pool sidechain found a Monero block. Pool-wide: every miner with a PPLNS share gets paid from it. |
| 💰 **Payout incoming** | That block pays **you** — this node held a share in the PPLNS window when it was found. The amount lands in your wallet once the block matures (about two hours). |
| ✅ **Payout confirmed** | A P2Pool payout (Monero or Tari) matured and was seen **on-chain** by the view-only wallet — ground truth that a reward actually arrived, beside the estimate. Fires once per confirmed payout. Only when a view key is set (see [Payout confirmation](dashboard.md#payout-confirmation)). |
| 🆕 **New release** | A newer Pithead release is available (the same signal as the dashboard header badge). |
| 🚀 **Pithead online** | Sent once when the dashboard starts — a heartbeat that the stack is up (and confirms the bot works after setup). |
| 📅 **Daily summary** | A once-a-day retrospective of the last 24h across your whole fleet — date/time, an **incident roll-up** (what went wrong during the day, or an all-clear), **24h hashrate** with the **P2Pool / XvB split**, **shares found in the day**, an **estimated daily earnings** figure, and a **per-machine 24h breakdown** — pushed at a set local time (**08:00** by default; `telegram.daily_summary_time`). |
Expand Down Expand Up @@ -177,6 +178,7 @@ block and set it to `false` — any event you don't list stays on:
| `high_reject_rate` | `true` | Pool-wide reject rate over the trailing hour crossed 5% (the same threshold as the dashboard's ⚠ flag) / dropped back |
| `block_found` | `true` | The P2Pool sidechain found a Monero block (pool-wide — every miner with a PPLNS share gets paid) |
| `payout_found` | `true` | That block pays you — this node held a share in the PPLNS window when it was found |
| `payout_confirmed` | `true` | A payout (Monero or Tari) confirmed on-chain by the view-only wallet |
| `container_unhealthy` | `true` | A stack container is crash-looping or stuck failing its healthcheck / recovered |

Run `./pithead apply` after editing.
Expand Down Expand Up @@ -280,6 +282,10 @@ How the gating works:
until you tap it, and if you don't within `confirm_timeout` seconds (default 60) the request is
**denied**, never queued. Only the operator who issued the command can confirm it, and each button
is one-shot.
- **Per-operator rate limit.** Each allow-listed operator can be issued at most a handful of confirm
prompts per rolling hour; past that, `/restart` / `/apply` from that operator are dropped without a
prompt (an anti-fatigue guard so a stuck or compromised session can't spam approvals). The budget is
keyed per operator, so one hitting the cap never locks the others out. Not configurable.
- **A fixed, small action set.** The two verbs are the whole surface — a Telegram message **selects**
one of them, it never becomes a host command. There is no arbitrary execution.
- **It rides the config-editor's channel, not a new one.** Both verbs act by dropping a typed intent
Expand Down
11 changes: 8 additions & 3 deletions pithead
Original file line number Diff line number Diff line change
Expand Up @@ -3307,7 +3307,7 @@ render_env() {
local tg_ev_xvb_no_share tg_ev_clearnet_exposed tg_ev_xvb_registration tg_ev_new_release tg_ev_stack_online
local tg_ev_daily_summary tg_summary_time tg_ev_hashrate_low tg_ev_hashrate_loss
local tg_ev_hugepages tg_ev_low_ram tg_ev_wallet_changed tg_ev_high_reject_rate
local tg_ev_block_found tg_ev_payout_found tg_ev_container_unhealthy
local tg_ev_block_found tg_ev_payout_found tg_ev_payout_confirmed tg_ev_container_unhealthy
local hr_drop_threshold hr_drop_minutes
tg_ev_node_down=$(tg_event node_down)
tg_ev_node_recovered=$(tg_event node_recovered)
Expand All @@ -3333,6 +3333,7 @@ render_env() {
tg_ev_high_reject_rate=$(tg_event high_reject_rate)
tg_ev_block_found=$(tg_event block_found)
tg_ev_payout_found=$(tg_event payout_found)
tg_ev_payout_confirmed=$(tg_event payout_confirmed)
tg_ev_container_unhealthy=$(tg_event container_unhealthy)
# Degradation detector (#99): drop-below-% and sustained-minutes; defaults 50 / 10.
hr_drop_threshold=$(jq -r '.dashboard.hashrate_drop_threshold // 50' "$CONFIG_FILE")
Expand Down Expand Up @@ -3501,6 +3502,7 @@ TELEGRAM_EVENT_WALLET_CHANGED=$tg_ev_wallet_changed
TELEGRAM_EVENT_HIGH_REJECT_RATE=$tg_ev_high_reject_rate
TELEGRAM_EVENT_BLOCK_FOUND=$tg_ev_block_found
TELEGRAM_EVENT_PAYOUT_FOUND=$tg_ev_payout_found
TELEGRAM_EVENT_PAYOUT_CONFIRMED=$tg_ev_payout_confirmed
TELEGRAM_EVENT_CONTAINER_UNHEALTHY=$tg_ev_container_unhealthy
HASHRATE_DROP_THRESHOLD_PCT=$hr_drop_threshold
HASHRATE_DROP_MINUTES=$hr_drop_minutes
Expand Down Expand Up @@ -4622,7 +4624,7 @@ CONTROL_DASHBOARD_EDITABLE_KEYS='P2POOL_FLAGS P2POOL_PORT
TELEGRAM_EVENT_HASHRATE_LOW TELEGRAM_EVENT_HASHRATE_LOSS
TELEGRAM_EVENT_HUGEPAGES TELEGRAM_EVENT_LOW_RAM
TELEGRAM_EVENT_HIGH_REJECT_RATE TELEGRAM_EVENT_BLOCK_FOUND
TELEGRAM_EVENT_PAYOUT_FOUND TELEGRAM_EVENT_CONTAINER_UNHEALTHY'
TELEGRAM_EVENT_PAYOUT_FOUND TELEGRAM_EVENT_PAYOUT_CONFIRMED TELEGRAM_EVENT_CONTAINER_UNHEALTHY'

control_approval_gate() { # <staged-file>
local staged="$1" porcelain
Expand Down Expand Up @@ -5034,7 +5036,10 @@ control_worker_apply() { # <claimed-file> <id> <actor> <control-dir>
local worker changes
worker=$(jq -r '.worker // ""' "$file")
# The worker name is a config.json lookup key AND (in name-auth) a bearer; pin its charset.
if ! printf '%s' "$worker" | grep -qE '^[!-~]{1,128}$'; then
# LC_ALL=C so [!-~] is the printable-ASCII BYTE range: under a UTF-8 locale GNU grep reads the
# range by collation order and rejects ordinary names like "rig1" (caught by the release gate on a
# UTF-8 box; CI runs under C and missed it). jq's test() above is codepoint-based and unaffected.
if ! printf '%s' "$worker" | LC_ALL=C grep -qE '^[!-~]{1,128}$'; then
_wa_reject "malformed or missing 'worker' name in the request."
return 0
fi
Expand Down
20 changes: 15 additions & 5 deletions tests/integration/e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,9 @@ provision() {
# --- Phase 2: safety backup of the live stack -------------------------------
backup_stack() {
log "Taking a safety backup of the live stack (the rollback anchor)"
on_bench "cd '$CANONICAL_DIR' && ./pithead backup -y >/dev/null 2>&1" || die "pithead backup failed."
# ponytail: --no-encrypt because v1.4 refuses to write a plaintext archive unattended without
# PITHEAD_BACKUP_PASSPHRASE; this rollback anchor never leaves the bench, so plaintext is fine here.
on_bench "cd '$CANONICAL_DIR' && ./pithead backup -y --no-encrypt >/dev/null 2>&1" || die "pithead backup failed."
SAFETY_ARCHIVE="$(on_bench "ls -t '$CANONICAL_DIR'/backups/pithead-backup-*.tar.gz 2>/dev/null | head -n1")"
[ -n "$SAFETY_ARCHIVE" ] || die "Backup ran but produced no archive."
ok "safety backup: $SAFETY_ARCHIVE"
Expand All @@ -357,10 +359,15 @@ borrow_miner() {
MINER_CFG_BACKUP="$MINER_XMRIG_CONFIG.e2e-orig.$(on_miner 'date +%Y%m%d-%H%M%S')"
on_miner "cp -a '$MINER_XMRIG_CONFIG' '$MINER_CFG_BACKUP'" || die "Failed to back up the miner config."
step "miner config backed up → $MINER_CFG_BACKUP"
# Reorder pools so the test-bench pool is primary (index 0); keep the rest as failover. Non-destructive
# and fully reversible from the backup above. Assumes a test-bench pool already exists in the config.
# Point the rig at the bench: inject a bench pool if the config has none (clone pool[0] so
# user/pass/keepalive carry over, override url→bench and force plain stratum), then reorder so the
# bench pool is primary and the rest stay as failover. Non-destructive, fully reversible from the
# backup above. ponytail: hardcodes :3333 (the seeded canonical stratum_port default, which the bench runs).
on_miner "
jq '.pools |= ([.[] | select(.url | ascii_downcase | contains(\"$BENCH_HOST\"))] + [.[] | select(.url | ascii_downcase | contains(\"$BENCH_HOST\") | not)])' \
jq --arg b '$BENCH_HOST' '
(if any(.pools[]?; .url | ascii_downcase | contains(\$b)) then .
else .pools = ([ (.pools[0]) + {url: (\$b + \":3333\"), tls: false, daemon: false} ] + .pools) end)
| .pools |= ([.[] | select(.url | ascii_downcase | contains(\$b))] + [.[] | select(.url | ascii_downcase | contains(\$b) | not)])' \
'$MINER_XMRIG_CONFIG' > '$MINER_XMRIG_CONFIG.e2e.tmp' \
&& mv '$MINER_XMRIG_CONFIG.e2e.tmp' '$MINER_XMRIG_CONFIG' && chmod 600 '$MINER_XMRIG_CONFIG'
" || die "Failed to repoint the miner config."
Expand Down Expand Up @@ -392,9 +399,12 @@ run_harness() {
local phases
case "$MODE" in
check) phases="--check" ;;
targeted) phases="--readiness --auth-fail-closed --lifecycle" ;; # --readiness/--check run first below
targeted) phases="--auth-fail-closed --lifecycle" ;; # readiness/check run inline first (below); NOT here — run.sh returns after --readiness
matrix) phases="--safety-backup --lifecycle --fault-injection --auth-fail-closed --hardening" ;;
esac
# RigForge integration (#185/#235/#260) is only meaningful with a REAL rig mining through the stack.
# The phase self-skips if no rigforge rig is connected, so this gate is just to avoid the noise.
[ "$BORROW_MINER" = "1" ] && [ "$MODE" != "check" ] && phases="$phases --rigforge"
log "Running the live harness on $BENCH_HOST (mode=$MODE, detached so an SSH drop can't kill it)"
step "phases: $phases (workers=$WORKERS)"

Expand Down
Loading