Files
ansible-role-monero/files/p2pool_stats.py
T
claudeandClaude Opus 5.5 946256a3dd Publish sanitized P2Pool hashrate stats
Enable P2Pool's --data-api/--local-api and add a small stdlib-only sidecar
that copies an allowlist of numeric fields (node/sidechain hashrate,
connected miners, shares, network difficulty) to /p2pool/stats.json. Worker
IPs and wallet fragments in local/stratum are never served.

An Ingress adds that single path to the website host (www.eom.dev by
default), so the homepage can read it same-origin and reuse its TLS cert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018STDcc5BaYxPMNA9S8Wycs
2026-11-08 02:37:26 +00:00

169 lines
6.5 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MIT-0
"""Publish a sanitized summary of P2Pool's --data-api files over HTTP.
P2Pool writes JSON stats into its --data-api directory. Some of them
(local/stratum "workers", local/p2p peers) contain miner IP addresses and
wallet fragments, so the directory itself must never be served. This sidecar
reads only the files it needs, copies an allowlist of numeric fields, and
serves the result at a single path. Standard library only.
"""
import json
import os
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MAX_FILE_BYTES = 256 * 1024
# P2Pool rewrites network/stats on every Monero block (~2 min) and
# local/stratum as jobs go out; older files are still served but flagged
# "stale" so the website can say when they were last reported.
STALE_AFTER = 15 * 60
MONERO_BLOCK_TIME = 120
ATOMIC_UNITS = 10 ** 12
CACHE_SECONDS = 10
def _number(value):
"""Finite, non-negative int/float only; bools and strings are rejected."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
if value != value or value in (float("inf"), float("-inf")) or value < 0:
return None
return value
def read_json(path, now):
"""Return (data, mtime) or (None, None) if missing, oversized or malformed."""
try:
with open(path, "rb") as handle:
stat = os.fstat(handle.fileno())
if stat.st_size > MAX_FILE_BYTES:
return None, None
data = json.loads(handle.read(MAX_FILE_BYTES + 1))
except (OSError, ValueError):
return None, None
return (data, int(stat.st_mtime)) if isinstance(data, dict) else (None, None)
def _pick(source, mapping):
return {out: _number(source.get(key)) for out, key in mapping.items()}
def _stamp(section, mtime, now):
section["updated_at"] = mtime
section["stale"] = now - mtime > STALE_AFTER
return section
def summarize(api_dir, sidechain, now=None):
now = time.time() if now is None else now
summary = {"version": 1, "generated_at": int(now), "sidechain": sidechain,
"node": None, "pool": None, "network": None}
stratum, mtime = read_json(os.path.join(api_dir, "local", "stratum"), now)
if stratum is not None:
node = _pick(stratum, {
"hashrate_15m": "hashrate_15m", "hashrate_1h": "hashrate_1h",
"hashrate_24h": "hashrate_24h", "shares_found": "shares_found",
"shares_failed": "shares_failed", "current_effort": "current_effort",
"average_effort": "average_effort",
"reward_share_percent": "block_reward_share_percent",
"last_share_found_time": "last_share_found_time",
})
# Count workers without copying them: each entry holds an IP address.
workers = stratum.get("workers")
node["miners"] = len(workers) if isinstance(workers, list) else _number(stratum.get("connections"))
summary["node"] = _stamp(node, mtime, now)
pool, mtime = read_json(os.path.join(api_dir, "pool", "stats"), now)
stats = pool.get("pool_statistics") if pool else None
if isinstance(stats, dict):
summary["pool"] = _pick(stats, {
"hashrate": "hashRate", "miners": "miners",
"difficulty": "sidechainDifficulty", "height": "sidechainHeight",
"blocks_found": "totalBlocksFound",
"last_block_found_time": "lastBlockFoundTime",
"pplns_window": "pplnsWindowSize",
})
_stamp(summary["pool"], mtime, now)
network, mtime = read_json(os.path.join(api_dir, "network", "stats"), now)
if network is not None:
difficulty = _number(network.get("difficulty"))
reward = _number(network.get("reward"))
summary["network"] = {
"difficulty": difficulty,
"hashrate": difficulty / MONERO_BLOCK_TIME if difficulty is not None else None,
"height": _number(network.get("height")),
"reward_xmr": reward / ATOMIC_UNITS if reward is not None else None,
}
_stamp(summary["network"], mtime, now)
return summary
class Cache:
def __init__(self, api_dir, sidechain):
self.api_dir, self.sidechain = api_dir, sidechain
self.lock = threading.Lock()
self.body, self.built, self.ok = b"", 0.0, False
def get(self):
with self.lock:
now = time.time()
if now - self.built >= CACHE_SECONDS:
summary = summarize(self.api_dir, self.sidechain, now)
self.body = json.dumps(summary, separators=(",", ":")).encode()
self.ok = summary["node"] is not None
self.built = now
return self.body, self.ok
def make_handler(cache, path):
class Handler(BaseHTTPRequestHandler):
server_version = "p2pool-stats"
sys_version = ""
def _send(self, status, body=b"", content_type="text/plain; charset=utf-8"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "public, max-age=30")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def do_GET(self):
if self.path.split("?", 1)[0] == "/healthz":
return self._send(200, b"ok\n")
if self.path.split("?", 1)[0] != path:
return self._send(404, b"not found\n")
body, ok = cache.get()
# 503 tells the website to keep its fallback text, but the body is
# still valid JSON so pool/network stats remain inspectable.
self._send(200 if ok else 503, body, "application/json")
do_HEAD = do_GET
def log_message(self, *args):
pass # Requests carry visitor IPs; nothing here needs them.
return Handler
def main():
api_dir = os.environ.get("P2POOL_API_DIR", "/api")
sidechain = os.environ.get("P2POOL_CHAIN", "main")
path = os.environ.get("STATS_PATH", "/p2pool/stats.json")
port = int(os.environ.get("PORT", "8080"))
server = ThreadingHTTPServer(("0.0.0.0", port), make_handler(Cache(api_dir, sidechain), path))
print(f"serving {path} from {api_dir} on :{port}", file=sys.stderr, flush=True)
server.serve_forever()
if __name__ == "__main__":
main()