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
This commit is contained in:
+168
-1
@@ -1 +1,168 @@
|
||||
PLACEHOLDER
|
||||
#!/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()
|
||||
|
||||
Reference in New Issue
Block a user