#!/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()