git / moonbite-network / explorer/rpc_readonly_proxy.py · raw

#!/usr/bin/env python3"""Read-only JSON-RPC proxy for a wallet-loaded litecoind/bigcoind node.Sits between a PUBLIC caller (e.g. the Railway explorer, reached via aCloudflare Tunnel) and a node whose RPC is bound to loopback. It:  * authenticates the caller with its OWN Basic-auth credentials    (so the real node password never leaves this host),  * allows ONLY a whitelist of read-only methods (block/tx/chain queries),    rejecting every wallet/spend/admin method with a JSON-RPC error,  * forwards permitted calls to the node using the real node credentials.All configuration comes from environment variables — no secrets in this file:  PROXY_BIND            interface to listen on            (default 127.0.0.1)  PROXY_PORT            port to listen on                 (default 9350)  PROXY_USER            username the caller must present   (required)  PROXY_PASSWORD        password the caller must present   (required)  NODE_RPC_URL          upstream node RPC URL      (default http://127.0.0.1:9332/)  NODE_RPC_USER         node rpcuser                       (required)  NODE_RPC_PASSWORD     node rpcpassword                   (required)  PROXY_TIMEOUT         upstream timeout seconds           (default 15)"""import base64import hmacimport jsonimport osimport sysimport urllib.errorimport urllib.requestfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer# The only methods the explorer and the web dashboard need. Everything else# is refused. All are read-only block/tx/chain queries plus the address-page# helpers (scantxoutset for UTXO lookup, validateaddress, estimatesmartfee,# decoderawtransaction) and ONE relay method: sendrawtransaction, which only# broadcasts an already-signed transaction the caller built client-side (the# self-custody wallet's /api/chain/broadcast). It cannot move node-wallet# funds; its single argument is checked to be a hex string below. Wallet,# spend, mining and admin methods (sendtoaddress, createvault/sos/unvault,# generatetoaddress, getbalance ...) are NOT here and are rejected.## Deployment note (audit M5): the Flask dashboard (web_app.py) should point its# RPC URL at THIS proxy (or at a node rpcwhitelist= user limited to the same# set) instead of holding full wallet-capable node credentials, so a future# web-tier bug cannot reach the node's spend/vault RPCs.ALLOWED_METHODS = frozenset({    "getblockcount",    "getblockhash",    "getblock",    "getblockheader",    "getrawtransaction",    "getblockchaininfo",    "getmempoolinfo",    "getrawmempool",    "getnetworkinfo",    "getbestblockhash",    "uptime",    "scantxoutset",    "validateaddress",    "estimatesmartfee",    "decoderawtransaction",    "sendrawtransaction",})_HEX_TX_MAX_CHARS = 400_000  # 200 KB of raw tx: well above any standard txdef _validate_params(method, params):    """Extra per-method argument checks. Returns an error string or None."""    if method == "sendrawtransaction":        # Exactly one positional hex string (optional maxfeerate is refused so        # the node's default fee-rate ceiling always applies).        if not isinstance(params, list) or len(params) != 1:            return "sendrawtransaction takes exactly one argument (hex)"        hexstr = params[0]        if (not isinstance(hexstr, str) or not hexstr                or len(hexstr) % 2 or len(hexstr) > _HEX_TX_MAX_CHARS):            return "sendrawtransaction argument must be a non-empty even-length hex string"        try:            bytes.fromhex(hexstr)        except ValueError:            return "sendrawtransaction argument must be hex"    return NonePROXY_BIND = os.environ.get("PROXY_BIND", "127.0.0.1")PROXY_PORT = int(os.environ.get("PROXY_PORT", "9350"))PROXY_USER = os.environ.get("PROXY_USER", "")PROXY_PASSWORD = os.environ.get("PROXY_PASSWORD", "")NODE_RPC_URL = os.environ.get("NODE_RPC_URL", "http://127.0.0.1:9332/")NODE_RPC_USER = os.environ.get("NODE_RPC_USER", "")NODE_RPC_PASSWORD = os.environ.get("NODE_RPC_PASSWORD", "")PROXY_TIMEOUT = float(os.environ.get("PROXY_TIMEOUT", "15"))_NODE_AUTH = "Basic " + base64.b64encode(    f"{NODE_RPC_USER}:{NODE_RPC_PASSWORD}".encode()).decode()_EXPECTED_CALLER_AUTH = "Basic " + base64.b64encode(    f"{PROXY_USER}:{PROXY_PASSWORD}".encode()).decode()def _rpc_error(id_, message, code=-32601):    return json.dumps({"jsonrpc": "1.0", "id": id_,                       "result": None,                       "error": {"code": code, "message": message}}).encode()def _no_dup_keys(pairs):    """json object_pairs_hook that refuses objects with repeated keys."""    obj = {}    for k, v in pairs:        if k in obj:            raise ValueError(f"duplicate key {k!r} in JSON object")        obj[k] = v    return objclass Handler(BaseHTTPRequestHandler):    server_version = "roproxy/1.0"    def _send(self, status, body, ctype="application/json"):        self.send_response(status)        self.send_header("Content-Type", ctype)        self.send_header("Content-Length", str(len(body)))        self.end_headers()        self.wfile.write(body)    def _unauthorized(self):        self.send_response(401)        self.send_header("WWW-Authenticate", 'Basic realm="rpc"')        self.send_header("Content-Length", "0")        self.end_headers()    def do_GET(self):        # Tiny health endpoint for the tunnel/monitoring (no auth, no node call).        if self.path in ("/health", "/healthz"):            self._send(200, b'{"status":"ok"}')        else:            self._send(404, b'{"error":"not found"}')    def do_POST(self):        # 1) Authenticate the caller against the PROXY credentials.        provided = self.headers.get("Authorization", "")        if not hmac.compare_digest(provided, _EXPECTED_CALLER_AUTH):            self._unauthorized()            return        # 2) Read + parse the JSON-RPC body.        try:            length = int(self.headers.get("Content-Length", "0"))            raw = self.rfile.read(length) if length else b""            # Duplicate keys are rejected outright: Python's json keeps the            # LAST duplicate while the node's UniValue parser returns the            # FIRST, so {"method":"sendtoaddress",...,"method":"getblockcount"}            # would pass our check and run the spend on the node.            req = json.loads(raw.decode("utf-8"), object_pairs_hook=_no_dup_keys)        except (ValueError, TypeError):            self._send(400, _rpc_error(None, "invalid JSON body", -32700))            return        # Support single or batch requests; enforce whitelist on each and        # rebuild a clean request from ONLY the validated fields. The raw        # client bytes are never forwarded to the node.        is_batch = isinstance(req, list)        items = req if is_batch else [req]        clean = []        for it in items:            method = it.get("method") if isinstance(it, dict) else None            if not isinstance(method, str) or method not in ALLOWED_METHODS:                self._send(403, _rpc_error(                    (it.get("id") if isinstance(it, dict) else None),                    f"method '{method}' not permitted by read-only proxy",                    -32601))                return            params = it.get("params", [])            if not isinstance(params, (list, dict)):                self._send(400, _rpc_error(it.get("id"), "params must be array or object", -32602))                return            bad = _validate_params(method, params)            if bad:                self._send(400, _rpc_error(it.get("id"), bad, -32602))                return            clean.append({                "jsonrpc": it.get("jsonrpc", "1.0"),                "id": it.get("id"),                "method": method,                "params": params,            })        forward = json.dumps(clean if is_batch else clean[0]).encode("utf-8")        # 3) Forward to the node with the REAL credentials.        try:            upstream = urllib.request.Request(NODE_RPC_URL, data=forward)            upstream.add_header("Content-Type", "application/json")            upstream.add_header("Authorization", _NODE_AUTH)            with urllib.request.urlopen(upstream, timeout=PROXY_TIMEOUT) as resp:                body = resp.read()                self._send(resp.status, body)        except urllib.error.HTTPError as exc:            # Pass through the node's JSON-RPC error body verbatim.            body = exc.read() or _rpc_error(None, f"node HTTP {exc.code}")            self._send(exc.code, body)        except Exception as exc:  # noqa: BLE001 - report upstream failure cleanly            self._send(502, _rpc_error(None, f"upstream unreachable: {exc}", -32603))    def log_message(self, fmt, *args):        sys.stderr.write(f"{self.address_string()} - {fmt % args}\n")def main():    missing = [n for n, v in (        ("PROXY_USER", PROXY_USER), ("PROXY_PASSWORD", PROXY_PASSWORD),        ("NODE_RPC_USER", NODE_RPC_USER), ("NODE_RPC_PASSWORD", NODE_RPC_PASSWORD),    ) if not v]    if missing:        sys.stderr.write(f"FATAL: missing env vars: {', '.join(missing)}\n")        sys.exit(1)    srv = ThreadingHTTPServer((PROXY_BIND, PROXY_PORT), Handler)    sys.stderr.write(        f"read-only RPC proxy on {PROXY_BIND}:{PROXY_PORT} -> {NODE_RPC_URL} "        f"(allowed: {len(ALLOWED_METHODS)} methods)\n")    try:        srv.serve_forever()    except KeyboardInterrupt:        srv.shutdown()if __name__ == "__main__":    main()