Setup Health-Checks
Since Phase 9, the first-run wizard (dvb-warppool-setup) doesn't just check
the Bitcoin Core RPC connection itself — it runs a full diagnostic of:
- Bitcoin Core health — everything reachable over RPC
- Local pool network — ports, UPnP gateway
- External reachability — optional, opt-in, with consent
Every check has a clear privacy model.
Privacy matrix
| Check | What it does | Privacy |
|---|---|---|
| Bitcoin health (RPC calls) | Localhost → Bitcoin Core | ✅ Local |
| Port-bind smoke 3333/3334/34254 | tokio::TcpListener::bind locally | ✅ Local |
| UPnP/NAT-PMP discovery | UDP multicast on the LAN | ✅ LAN-only |
| IP echo via api.ipify.org | HTTPS GET → 3rd party sees your IP | ⚠️ Opt-in |
| Port 8333 probe via bitnodes.io | Pings <IP>:8333 | ⚠️ Opt-in |
| Stratum port guide URLs | We send nothing — we only return 3rd-party URLs for manual testing | ✅ Local (operator contacts the 3rd party themselves in the browser) |
Each opt-in check has its own separate checkbox with explicit consent text. Default: all off. Clicking "Run" without consent enabled → alert + no request.
Bitcoin health in detail
A click on "Bitcoin Core health check" makes 5 RPC calls in parallel:
| RPC | What we extract |
|---|---|
getnetworkinfo | version, subversion |
getblockchaininfo | ibd, chain, blocks, headers, verification_progress, size_on_disk, pruned |
getindexinfo | txindex (on/off) |
getpeerinfo | inbound/outbound peer count |
getzmqnotifications | pubhashblock/pubrawblock endpoints |
Plus automatic warnings:
| Condition | Warning |
|---|---|
ibd=true | Mining not yet possible; the pool should only start after sync |
peers.total < 8 | Tip latency increased; new blocks arrive later |
peers.inbound = 0 on mainnet | Port 8333 is probably not forwarded |
zmq.hashblock=None | Pool falls back to RPC polling (job refresh latency) |
pruned=true | Some features (txindex-based) are limited |
verification_progress < 99.9% (and not IBD) | Still syncing |
Local network
A click on "Local check (ports + UPnP)" does:
- Port-bind smoke for
3333(V1 plain),3334(V1 TLS),34254(Sv2 NOISE). We briefly bind on0.0.0.0:<port>and drop immediately. That tells us whether something else is already running there (a parallel gopool, an old daemon, a port conflict). - UPnP discovery via the
igdcrate (pure-Rust, no avahi/libupnp dependency). Returns the gateway IP + external IP if a UPnP-capable router is on the LAN.
The UPnP result is cached internally → the next opt-in probe automatically uses the external IP without an extra api.ipify.org call.
External probes (with consent)
Three opt-in checks:
1. IP echo (api.ipify.org)
Fetches your public IP via HTTPS. You only need this when UPnP doesn't work (no UPnP-capable router) and you still need the external IP to run the other probes or to give miners a connect address.
2. Port 8333 via bitnodes.io
Bitnodes.io is a public Bitcoin node crawler. We do a GET on
https://bitnodes.io/api/v1/nodes/<IP>-8333/. If bitnodes knows your
node (404 = unknown): probably fine. If not: either the port isn't open
or the node is still too new.
⚠ Side effect: your node will then appear publicly on https://bitnodes.io as a known Bitcoin node. That's desired information for any pool operator — other nodes can connect to yours.
3. Stratum port guide
We do NOT scan from the outside, because that would make us a scanning tool ourselves. Instead, we generate a list of 3rd-party tools with your IP + port number pre-filled for a manual self-test:
- yougetsignal.com port checker (direct link with
?remoteAddress=…&portNumber=…) - canyouseeme.org (manual port entry, because we haven't pinned down their URL format)
The operator clicks the links in a new tab — we send nothing, nobody contacts themselves automatically.
What this looks like in code
Backend in apps/dvb-warppool-setup/src/main.rs:
bitcoin_health(BitcoinHealthReq)— the 5 RPC calls +collect_bitcoin_warningsnetwork_health_local()— port-bind + UPnP viaspawn_blockingexternal_probe(ExternalProbeReq)— 422 without consent, otherwise dispatched
The aggregation logic is extracted as a pure function
collect_bitcoin_warnings() and has 8 unit tests for every
warning edge case (IBD, low-peers, no-inbound, no-zmq, pruned,
verification-progress, no-double-warning-on-IBD, healthy-zero-warnings).
UI in apps/dvb-warppool-setup/src/index.html:
- Bitcoin card with RPC test + health-check button
- Network card with local button + probe rows (checkbox + button per probe)
- Status cards with icons (✅⚠️❌), key-value rows, warning list with ⚠ bullet
Phase 10a — bitcoin.conf snippet generator
Instead of just emitting warnings, since Phase 10a the health check generates a copy-paste-ready bitcoin.conf snippet that fixes the identified problems. No direct write — the operator decides what (if anything) to adopt.
What ends up in the snippet
| Health finding | Snippet line |
|---|---|
zmq.hashblock=None | zmqpubhashblock=tcp://127.0.0.1:28332 |
zmq.hashblock=None AND zmq.rawblock=None | + zmqpubrawblock=tcp://127.0.0.1:28333 |
peers.total < 8 AND peers.outbound < 8 | maxconnections=125 |
peers.inbound == 0 AND chain="main" | listen=1 + comment about port forwarding |
What we deliberately don't suggest:
txindex=1— heavy, many pools don't need it (pruned is fine for solo)server=1— if the RPC test got through at all, this must already be truebind=— the defaultall-interfacesfits almost every case
Format
# ===== dvb-WarpPool Setup-Wizard Recommendations =====
# ZMQ block notifications for immediate job refresh
zmqpubhashblock=tcp://127.0.0.1:28332
zmqpubrawblock=tcp://127.0.0.1:28333
# More peer slots so Bitcoin Core gets more stable peer discovery
maxconnections=125
# enable listening so incoming peers can connect
# (additionally: forward port 8333/tcp on the router to this machine)
listen=1
# ===== End Recommendations =====
Header + footer as wrapper comments so the operator spots the section immediately — no risk of accidentally overwriting existing settings (all recommendations sit inside a marked block).
UI
In the browser:
- 📋 box containing the snippet as monospace code
- "Copy" button (
navigator.clipboard.writeText) - OS-aware path hint:
~/.bitcoin/bitcoin.conf(Linux),~/Library/Application Support/Bitcoin/bitcoin.conf(macOS),%APPDATA%\Bitcoin\bitcoin.conf(Windows) — vianavigator.platform - Fallback alert if the clipboard API is unavailable (e.g. http context without a TLS cert)
Phase 11 — UPnP port forwarding
Instead of just discovering the UPnP gateway, since Phase 11 the
setup wizard can also set port mappings directly — via igd::add_port
exposed as an HTTP endpoint. With a whitelist, consent gate, and a
1h lease as default (no permanent default).
Endpoints
POST /api/network-upnp-forward with body:
{
"port": 3333,
"protocol": "tcp",
"description": "dvb-WarpPool Stratum V1",
"consent": true,
"lease_seconds": 3600
}
POST /api/network-upnp-remove to remove (same {port, protocol, consent} pattern).
Security gates
| Layer | What gets checked |
|---|---|
| Consent | consent=true must be in the body — otherwise 422. A router state change is invasive, so the operator must actively agree. |
| Port whitelist | Only 8333 (Bitcoin P2P), 3333, 3334, 34254 (Stratum V1/TLS/V2). Other ports → 400 + list of allowed ports in the error. Prevents the wizard from being misused as a generic port opener. |
| Protocol whitelist | Only tcp/udp (case-insensitive). Others → 400. |
| Lease cap | Default 3600s (1h). Max 86400s (24h, hard cap). The operator has to renew for longer mappings — prevents "fire-and-forget" mappings that stay open for years. |
| Local-IP detection | Via the UDP-connect trick (bind 0.0.0.0:0 + connect 8.8.8.8:80 → local_addr()). No extra dependency. IPv6 outbound → error (UPnP needs IPv4 NAT). |
What we do NOT do
- No persistent storage of the mappings we set — the wizard is
one-shot. For persistent forwards with auto-renewal see Phase 27
(
[upnp]block inconfig.toml). - No multi-router support —
igd::search_gatewaypicks the first UPnP-capable gateway on the LAN. With mesh setups using two routers, only one of them gets hit.
Phase 27 — renew loop in the daemon
The setup wizard opens a port once with a 1h lease. If the pool runs
longer, the lease has to be extended — otherwise the router drops the
forward. Phase 27 adds a renew loop in the daemon that refreshes the
forwards listed in the [upnp] block every renew_interval_secs.
Opt-in (default enabled = false).
Configuration:
[upnp]
enabled = true
renew_interval_secs = 3000 # 50min, safely below the 3600s lease
[[upnp.forwards]]
port = 3333
protocol = "tcp"
lease_seconds = 3600
description = "Stratum V1"
UPnP add_port is idempotent on the router side (same port + protocol =
extend lease, don't create a second entry). The first renew happens 10s
after daemon start (covers crash recovery), then every
renew_interval_secs seconds (range 60..=86400). Errors are only
logged — the loop keeps running so transient problems don't permanently
kill the forward.
Typical router quirks
- FritzBox default: UPnP-discoverable = yes, but
add_port= blocked. The operator has to enable "Allow UPnP status changes via UPnP" in the web UI. We then returnadd_port: The client is not authorized to map this port. - OpenWrt: UPnP off by default, has to be installed as a package
(
miniupnpd). - ISP routers (Telekom Speedport etc.): UPnP often completely disabled; some models have no UPnP at all.
In all of these cases the wizard shows a clear error with the exact router reason. The operator knows what to do.
Phase 12 — bitcoin.conf parse-existing
Instead of suggesting every recommendation, since Phase 12 the
snippet generator filters out lines that are already configured — if
the operator supplies the path to their bitcoin.conf.
How it works
- UI field "bitcoin.conf path" (optional, OS-aware placeholder)
- Path is sent along with the
/api/bitcoin-healthPOST - Server parses the file with
parse_bitcoin_conf_str(&str) -> BTreeMap<String, Vec<String>>— line-based, tolerant of:- Comments (
# ...) - Empty lines + whitespace
- Section headers
[main][test](ignored, all keys flattened) - Multiple values per key (e.g. two
zmqpubhashblock=lines) - Malformed lines (no
=→ ignored, no crash)
- Comments (
generate_bitcoinconf_snippet(&BitcoinHealth, parsed.as_ref())filters per recommendation:if !parsed.contains_key("zmqpubhashblock") → push recommendation
Default behavior is preserved
If the path is not set: all recommendations as before. If the path is set but the file isn't readable: all recommendations + warning "bitcoin.conf at … not readable".
Example
bitcoin.conf before the health check:
server=1
rpcuser=warppool
zmqpubhashblock=tcp://127.0.0.1:28332
maxconnections=200
What previously would have been suggested in the snippet:
zmqpubhashblock=tcp://127.0.0.1:28332(even though it's already there!)zmqpubrawblock=tcp://127.0.0.1:28333(may be missing)maxconnections=125(even though 200 is already set!)listen=1(may be missing)
What with Phase 12 gets suggested:
- only
zmqpubrawblock=tcp://127.0.0.1:28333andlisten=1— the rest is already configured.
What we do NOT do
- No value-compare: if the operator has
maxconnections=10(bad), we still don't suggestmaxconnections=125— we trust that the operator chose that value deliberately. We only fix "missing keys", not "bad values". - No direct write: we only parse read-only, no backup risk.
- No TOML/JSON format: bitcoin.conf has its own line-based format, not TOML.
Phase 13 — daemon periodic health check
The setup wizard runs once at first-run. The daemon runs continuous checks in the running pool — and pushes every change as an SSE event so the UI can show a dashboard widget.
Wiring
13a — crate extraction: The health logic was extracted out of the
setup wizard into its own crate
warppool-health. Since then the setup
wizard is just a thin axum wrapper; the daemon can call the same
functions directly without code duplication.
13b — periodic task: At startup, the daemon spawns a tokio task that runs the health check every N seconds (default 60). Activated via env:
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS=60 dvb-warppool-daemon
# 0 = off (default on when env not set → 60s)
Per tick:
- Re-resolve RPC auth from
config.node.rpc_cookie_path(preferred) orsecrets.rpc_user/rpc_pass(the cookie gets rotated on Bitcoin Core restart — we read fresh every time) warppool_health::check_bitcoin_health(&client, &url, &auth).awaitcollect_bitcoin_warnings(&h)event_bus.publish(PoolEvent::HealthSnapshot { ... })
SSE event format
{
"type": "health_snapshot",
"at": "2026-05-27T12:00:00Z",
"rpc_ok": true,
"peers_total": 12,
"peers_inbound": 3,
"ibd": false,
"pruned": false,
"zmq_hashblock_ok": true,
"warnings": [
"Few peers (3/8 recommended) — tip latency increased"
]
}
The UI subscribes via EventSource('/api/events'), dispatches on
event.type === 'health_snapshot', and renders a banner/badge with
the warnings array.
What it deliberately does NOT do
- No bitcoin.conf parse in the periodic check — that's setup-wizard-specific because it generates snippet recommendations. The daemon doesn't need it.
- No UPnP check in 13b — UPnP state changes rarely and is operator-side. The setup wizard checks once.
- No delta detection in 13b — we push every snapshot; the UI does the client-side compare if needed. Saves server state.
- No API endpoint
GET /api/admin/healthin 13b — the UI uses SSE. If needed for CLI scripting, that would be a Phase 13c addition.
Operator example
# Live SSE-Stream auf health-events filtern
curl -N http://localhost:18334/api/events 2>/dev/null \
| grep -A1 'event: health_snapshot'
# event: health_snapshot
# data: {"type":"health_snapshot","at":"...","rpc_ok":true,...}
The operator sees, for example, when after weeks of uptime
zmq_hashblock_ok=false suddenly shows up (Bitcoin Core restarted
without ZMQ config) — and can react without anyone having to monitor
hands-on.
What Phase 14 could bring
- UI dashboard widget for the
health_snapshotevents (banner badge with warning count, click → expanded detail view) - Delta detection in the daemon: only push events when warnings have changed (instead of every tick → saves event-bus traffic on unchanged state)
GET /api/admin/healthfor CLI scripts that check on demand without opening an SSE stream
See also
- Notifications —
rpc-down/rpc-recoveredevents are fired by the same health loop - Observability —
warppool_rpc_ready+ RPC latency histogram - Troubleshooting — when the wizard shows "cookie not readable" or UPnP finds no gateway