#!/usr/bin/env python3 """XMR Utility Network - Node Guardian agent v0.1 (Beta). Read-only health reporter for YOUR OWN Monero node. It: * reads monerod's local RPC (get_info only - a read-only call), * reads free disk space of the data directory and the system load, * prints a report signed with an Ed25519 key that never leaves this machine. It does NOT: send anything anywhere, open a port, accept commands, run remote code, touch your wallet, or need root. You decide where the report goes (paste it into https://xmr.rugward.com/guardian to check it, or verify it offline with `verify`). No hostname, IP address or wallet data is included. Usage: python3 xun-guardian.py init # create the signing key (once) python3 xun-guardian.py pubkey # show your public key + key id python3 xun-guardian.py report [--rpc URL] [--data-dir DIR] > report.json python3 xun-guardian.py verify report.json --pubkey Needs Python 3.8+ and either the `cryptography` package or OpenSSL 3.x. License: MIT. """ import argparse, base64, hashlib, json, os, shutil, subprocess, sys, tempfile, time, urllib.parse, urllib.request VERSION = "xun-guardian/0.1" KEY_DIR = os.path.expanduser(os.environ.get("XUN_GUARDIAN_HOME", "~/.xun-guardian")) KEY_FILE = os.path.join(KEY_DIR, "ed25519.pem") INFO_FIELDS = ("height", "target_height", "synchronized", "status", "nettype", "version", "outgoing_connections_count", "incoming_connections_count", "busy_syncing", "offline", "database_size", "free_space", "update_available", "restricted", "top_block_hash", "difficulty", "tx_pool_size") def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() def unb64u(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def die(msg, code=2): print("error: " + msg, file=sys.stderr); sys.exit(code) # ---- Ed25519 (cryptography if present, else OpenSSL 3 CLI) ----------------- def _crypto(): if os.environ.get("XUN_GUARDIAN_FORCE_OPENSSL"): return None try: from cryptography.hazmat.primitives.asymmetric import ed25519 from cryptography.hazmat.primitives import serialization return ed25519, serialization except Exception: return None def _openssl(args, data=None): r = subprocess.run(["openssl"] + args, input=data, capture_output=True) if r.returncode != 0: die("openssl failed: " + r.stderr.decode(errors="replace").strip()[:200]) return r.stdout def gen_key(): os.makedirs(KEY_DIR, mode=0o700, exist_ok=True) if os.path.exists(KEY_FILE): die("key already exists at " + KEY_FILE + " (delete it yourself to start over)") c = _crypto() if c: ed, ser = c pem = ed.Ed25519PrivateKey.generate().private_bytes(ser.Encoding.PEM, ser.PrivateFormat.PKCS8, ser.NoEncryption()) else: pem = _openssl(["genpkey", "-algorithm", "ed25519"]) fd = os.open(KEY_FILE, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "wb") as f: f.write(pem) def pubkey_raw(): if not os.path.exists(KEY_FILE): die("no key yet - run: python3 xun-guardian.py init") c = _crypto() if c: ed, ser = c k = ser.load_pem_private_key(open(KEY_FILE, "rb").read(), None) return k.public_key().public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw) return _openssl(["pkey", "-in", KEY_FILE, "-pubout", "-outform", "DER"])[-32:] def sign(msg): c = _crypto() if c: ed, ser = c return ser.load_pem_private_key(open(KEY_FILE, "rb").read(), None).sign(msg) with tempfile.TemporaryDirectory() as d: p = os.path.join(d, "m"); open(p, "wb").write(msg) return _openssl(["pkeyutl", "-sign", "-inkey", KEY_FILE, "-rawin", "-in", p]) def verify_sig(pub, msg, sig): c = _crypto() if c: ed, _ = c try: ed.Ed25519PublicKey.from_public_bytes(pub).verify(sig, msg); return True except Exception: return False der = bytes.fromhex("302a300506032b6570032100") + pub with tempfile.TemporaryDirectory() as d: paths = {} for n, b in (("k", der), ("m", msg), ("s", sig)): paths[n] = os.path.join(d, n); open(paths[n], "wb").write(b) r = subprocess.run(["openssl", "pkeyutl", "-verify", "-pubin", "-keyform", "DER", "-inkey", paths["k"], "-rawin", "-in", paths["m"], "-sigfile", paths["s"]], capture_output=True) return r.returncode == 0 def key_id(pub): return hashlib.sha256(pub).hexdigest()[:16] # ---- report ------------------------------------------------------------------- def read_info(rpc): u = urllib.parse.urlparse(rpc) if u.scheme not in ("http", "https") or not u.hostname: die("bad --rpc URL") body = json.dumps({"jsonrpc": "2.0", "id": "0", "method": "get_info"}).encode() req = urllib.request.Request(rpc.rstrip("/") + "/json_rpc", data=body, headers={"content-type": "application/json"}) try: with urllib.request.urlopen(req, timeout=10) as r: res = json.loads(r.read(1_000_000)).get("result") or {} except Exception as e: return {"reachable": False, "error": type(e).__name__} out = {k: res[k] for k in INFO_FIELDS if k in res} out["reachable"] = True return out def host_info(data_dir): h = {} if data_dir: try: du = shutil.disk_usage(data_dir); h["disk_free_bytes"] = du.free; h["disk_total_bytes"] = du.total except OSError: h["disk_error"] = "data dir not readable" try: h["load_1m"] = round(os.getloadavg()[0], 2); h["cpus"] = os.cpu_count() except (OSError, AttributeError): pass return h def make_report(rpc, data_dir): pub = pubkey_raw() rep = {"v": 1, "agent": VERSION, "ts": int(time.time()), "key_id": key_id(pub), "pubkey": b64u(pub), "node": read_info(rpc), "host": host_info(data_dir)} payload = json.dumps(rep, sort_keys=True, separators=(",", ":"), ensure_ascii=True) return {"payload": payload, "sig": b64u(sign(payload.encode()))} def main(): ap = argparse.ArgumentParser(description="Read-only, signed health reports for your own Monero node.") sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("init"); sub.add_parser("pubkey") r = sub.add_parser("report") r.add_argument("--rpc", default="http://127.0.0.1:18081", help="monerod RPC base URL (default: local mainnet port)") r.add_argument("--data-dir", default=os.path.expanduser("~/.bitmonero"), help="monerod data directory (for free disk space)") v = sub.add_parser("verify") v.add_argument("file"); v.add_argument("--pubkey", required=True, help="the base64url public key you saved after init") a = ap.parse_args() if a.cmd == "init": gen_key(); p = pubkey_raw() print("key created:", KEY_FILE, "(stays on this machine)") print("public key:", b64u(p)); print("key id: ", key_id(p)); print("Save the public key somewhere else - you verify reports with it.") elif a.cmd == "pubkey": p = pubkey_raw(); print(b64u(p)); print("key id:", key_id(p), file=sys.stderr) elif a.cmd == "report": print(json.dumps(make_report(a.rpc, a.data_dir), indent=1)) elif a.cmd == "verify": try: doc = json.load(open(a.file)); payload = doc["payload"]; sig = unb64u(doc["sig"]); pub = unb64u(a.pubkey.strip()) except Exception: die("not a guardian report / bad public key") if len(pub) != 32: die("public key must be 32 bytes (base64url)") ok = verify_sig(pub, payload.encode(), sig) rep = json.loads(payload) if ok and rep.get("pubkey") != b64u(pub): ok = False print("signature:", "VALID" if ok else "INVALID"); print("report time:", time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(rep.get("ts", 0)))) sys.exit(0 if ok else 1) if __name__ == "__main__": main()