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:

  1. Bitcoin Core health — everything reachable over RPC
  2. Local pool network — ports, UPnP gateway
  3. External reachability — optional, opt-in, with consent

Every check has a clear privacy model.

Privacy matrix

CheckWhat it doesPrivacy
Bitcoin health (RPC calls)Localhost → Bitcoin Core✅ Local
Port-bind smoke 3333/3334/34254tokio::TcpListener::bind locally✅ Local
UPnP/NAT-PMP discoveryUDP multicast on the LAN✅ LAN-only
IP echo via api.ipify.orgHTTPS GET → 3rd party sees your IP⚠️ Opt-in
Port 8333 probe via bitnodes.ioPings <IP>:8333⚠️ Opt-in
Stratum port guide URLsWe 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:

RPCWhat we extract
getnetworkinfoversion, subversion
getblockchaininfoibd, chain, blocks, headers, verification_progress, size_on_disk, pruned
getindexinfotxindex (on/off)
getpeerinfoinbound/outbound peer count
getzmqnotificationspubhashblock/pubrawblock endpoints

Plus automatic warnings:

ConditionWarning
ibd=trueMining not yet possible; the pool should only start after sync
peers.total < 8Tip latency increased; new blocks arrive later
peers.inbound = 0 on mainnetPort 8333 is probably not forwarded
zmq.hashblock=NonePool falls back to RPC polling (job refresh latency)
pruned=trueSome features (txindex-based) are limited
verification_progress < 99.9% (and not IBD)Still syncing

Local network

A click on "Local check (ports + UPnP)" does:

  1. Port-bind smoke for 3333 (V1 plain), 3334 (V1 TLS), 34254 (Sv2 NOISE). We briefly bind on 0.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).
  2. UPnP discovery via the igd crate (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.

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_warnings
  • network_health_local() — port-bind + UPnP via spawn_blocking
  • external_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 findingSnippet line
zmq.hashblock=Nonezmqpubhashblock=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 < 8maxconnections=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 true
  • bind= — the default all-interfaces fits 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) — via navigator.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

LayerWhat gets checked
Consentconsent=true must be in the body — otherwise 422. A router state change is invasive, so the operator must actively agree.
Port whitelistOnly 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 whitelistOnly tcp/udp (case-insensitive). Others → 400.
Lease capDefault 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 detectionVia 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 in config.toml).
  • No multi-router supportigd::search_gateway picks 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 return add_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

  1. UI field "bitcoin.conf path" (optional, OS-aware placeholder)
  2. Path is sent along with the /api/bitcoin-health POST
  3. 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)
  4. 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:28333 and listen=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 suggest maxconnections=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:

  1. Re-resolve RPC auth from config.node.rpc_cookie_path (preferred) or secrets.rpc_user/rpc_pass (the cookie gets rotated on Bitcoin Core restart — we read fresh every time)
  2. warppool_health::check_bitcoin_health(&client, &url, &auth).await
  3. collect_bitcoin_warnings(&h)
  4. 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/health in 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_snapshot events (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/health for CLI scripts that check on demand without opening an SSE stream

See also

  • Notificationsrpc-down / rpc-recovered events are fired by the same health loop
  • Observabilitywarppool_rpc_ready + RPC latency histogram
  • Troubleshooting — when the wizard shows "cookie not readable" or UPnP finds no gateway