diff --git a/README.md b/README.md index 2be36b0..7e81cd3 100644 --- a/README.md +++ b/README.md @@ -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 ---------------- diff --git a/defaults/main.yml b/defaults/main.yml index 8e4897e..58accdc 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -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:///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: "" diff --git a/files/p2pool_stats.py b/files/p2pool_stats.py index 311c8dd..2e0b645 100644 --- a/files/p2pool_stats.py +++ b/files/p2pool_stats.py @@ -1 +1,168 @@ -PLACEHOLDER \ No newline at end of file +#!/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() diff --git a/tasks/main.yml b/tasks/main.yml index 1729c3a..7dd2305 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -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 + diff --git a/tests/test_p2pool_stats.py b/tests/test_p2pool_stats.py new file mode 100644 index 0000000..ecae184 --- /dev/null +++ b/tests/test_p2pool_stats.py @@ -0,0 +1 @@ +PENDING \ No newline at end of file