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:
2026-11-08 02:37:26 +00:00
co-authored by Claude Opus 5.5
parent fd4f3184d7
commit 946256a3dd
5 changed files with 316 additions and 2 deletions
+27
View File
@@ -22,6 +22,33 @@ Optional:
stop earning once they leave its PPLNS window.
- `monero_p2pool_addpeers` (default `[]`): extra P2Pool peers as `host:port`.
They must be on the same chain.
- `monero_p2pool_stats_host` (default `www.eom.dev`): website host that gets
the public `/p2pool/stats.json` path. Use `""` to keep stats internal.
- `monero_p2pool_stats_ingress_class` (default `nginx`), `monero_p2pool_stats_image`
(default `python:3.13-alpine`).
Public stats
------------
P2Pool runs with `--data-api /api --local-api`. Those files include miner IP
addresses, so they are never served. A `stats` sidecar
(`files/p2pool_stats.py`, standard library only) copies an allowlist of numeric
fields into one JSON document:
{"sidechain": "nano",
"node": {"hashrate_15m", "hashrate_1h", "hashrate_24h", "miners",
"shares_found", "shares_failed", "current_effort",
"average_effort", "reward_share_percent",
"last_share_found_time", "updated_at", "stale"},
"pool": {"hashrate", "miners", "difficulty", "height", "blocks_found",
"last_block_found_time", "pplns_window", "updated_at", "stale"},
"network": {"hashrate", "difficulty", "height", "reward_xmr",
"updated_at", "stale"}}
`node` is this P2Pool instance (everyone mining through it); `pool` is the
whole sidechain. Sections are `null` until P2Pool writes them, and the
endpoint answers 503 until `node` exists. Run the sidecar's tests with
`python3 -m unittest discover -s tests`.
Example Playbook
----------------
+9
View File
@@ -14,6 +14,15 @@ monero_p2pool_addpeers: []
monerod_rpc_secret_name: monerod-rpc
# Public P2Pool stats: node and sidechain hashrate, connected miner count,
# shares and network difficulty, served at https://<host>/p2pool/stats.json.
# Worker IPs and wallet fragments are never included. The path is added to
# an existing website host so it shares that host's TLS certificate; set the
# host to "" to keep the stats cluster-internal (service p2pool-stats:8080).
monero_p2pool_stats_host: www.eom.dev
monero_p2pool_stats_ingress_class: nginx
monero_p2pool_stats_image: python:3.13-alpine
# Required (Vault).
monerod_user: ""
monerod_password: ""
+168 -1
View File
@@ -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()
+111 -1
View File
@@ -198,6 +198,19 @@
secret:
secretName: monerotls
# Mounted into the stats sidecar; the pod annotation below rolls it on change.
- name: Store the P2Pool stats sidecar script
k8s:
state: present
definition:
apiVersion: v1
kind: ConfigMap
metadata:
name: p2pool-stats
namespace: monero
data:
p2pool_stats.py: "{{ lookup('ansible.builtin.file', 'p2pool_stats.py') }}"
- name: deploy p2pool
k8s:
definition:
@@ -215,6 +228,8 @@
metadata:
labels:
app: p2pool
annotations:
monero/stats-script: "{{ lookup('ansible.builtin.file', 'p2pool_stats.py') | hash('sha256') }}"
spec:
containers:
- name: p2pool
@@ -229,7 +244,10 @@
'--rpc-port', '18081',
'--wallet', monero_p2pool_wallet_address,
'--stratum', '0.0.0.0:3333',
'--p2p', '0.0.0.0:37889']
'--p2p', '0.0.0.0:37889',
# JSON stats for the sidecar below. local/stratum lists
# worker IPs, so /api is never served directly.
'--data-api', '/api', '--local-api']
+ ([] if monero_p2pool_chain == 'main' else ['--' ~ monero_p2pool_chain])
+ (['--addpeers', monero_p2pool_addpeers | join(',')] if monero_p2pool_addpeers else [])
+ ['--tls-cert', '/etc/letsencrypt/tls.crt',
@@ -251,6 +269,47 @@
mountPath: /home/p2pool
- name: tls
mountPath: /etc/letsencrypt
- name: api
mountPath: /api
# Serves an allowlisted summary of /api at /p2pool/stats.json.
- name: stats
image: "{{ monero_p2pool_stats_image }}"
command: ["python3", "-I", "/app/p2pool_stats.py"]
env:
- name: P2POOL_API_DIR
value: /api
- name: P2POOL_CHAIN
value: "{{ monero_p2pool_chain }}"
- name: PYTHONDONTWRITEBYTECODE
value: "1"
ports:
- containerPort: 8080
name: stats
readinessProbe:
httpGet:
path: /healthz
port: stats
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
- name: api
mountPath: /api
readOnly: true
- name: stats-app
mountPath: /app
readOnly: true
volumes:
- name: p2pool
persistentVolumeClaim:
@@ -258,6 +317,12 @@
- name: tls
secret:
secretName: tls
- name: api
emptyDir:
sizeLimit: 64Mi
- name: stats-app
configMap:
name: p2pool-stats
- name: monero service
k8s:
@@ -297,3 +362,48 @@
name: p2poolp2p
type: LoadBalancer
- name: p2pool stats service
k8s:
definition:
apiVersion: v1
kind: Service
metadata:
name: p2pool-stats
namespace: monero
spec:
selector:
app: p2pool
ports:
- port: 8080
targetPort: stats
name: http
type: ClusterIP
# Adds one exact path to the website's host. ingress-nginx merges it into that
# host's server block, so it reuses the website's TLS certificate and the
# homepage can fetch it same-origin. Nothing else on the host is affected.
- name: Publish P2Pool stats on the website host
k8s:
state: "{{ 'present' if monero_p2pool_stats_host | length > 0 else 'absent' }}"
definition:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: p2pool-stats
namespace: monero
annotations:
nginx.ingress.kubernetes.io/enable-access-log: "false"
spec:
ingressClassName: "{{ monero_p2pool_stats_ingress_class }}"
rules:
- host: "{{ monero_p2pool_stats_host | default('unset', true) }}"
http:
paths:
- path: /p2pool/stats.json
pathType: Exact
backend:
service:
name: p2pool-stats
port:
number: 8080
+1
View File
@@ -0,0 +1 @@
PENDING