Files
proxmox/scripts/test-all-rpc-nodes.py
defiQUG cb47cce074 Complete markdown files cleanup and organization
- Organized 252 files across project
- Root directory: 187 → 2 files (98.9% reduction)
- Moved configuration guides to docs/04-configuration/
- Moved troubleshooting guides to docs/09-troubleshooting/
- Moved quick start guides to docs/01-getting-started/
- Moved reports to reports/ directory
- Archived temporary files
- Generated comprehensive reports and documentation
- Created maintenance scripts and guides

All files organized according to established standards.
2026-01-06 01:46:25 -08:00

484 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Thorough RPC node testing for ChainID 138 (Besu RPC nodes).
What it does:
- Probes all known RPC nodes (VMIDs 2400-2402, 2500-2508) on HTTP port 8545
- Attempts multiple Host headers to work around Besu rpc-http-host-allowlist
- Runs a JSON-RPC method matrix and records latency + key health signals
- Writes a timestamped Markdown + JSON report under ./reports/
Usage:
python3 scripts/test-all-rpc-nodes.py
python3 scripts/test-all-rpc-nodes.py --port 8545 --timeout 4 --threads 8
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from urllib.error import URLError, HTTPError
from urllib.request import Request, urlopen
CHAIN_ID_EXPECTED_HEX = "0x8a"
NET_VERSION_EXPECTED = "138"
RPC_NODES: List[Dict[str, str]] = [
# ThirdWeb RPC nodes
{"vmid": "2400", "ip": "192.168.11.240", "group": "thirdweb", "name": "thirdweb-rpc-1"},
{"vmid": "2401", "ip": "192.168.11.241", "group": "thirdweb", "name": "thirdweb-rpc-2"},
{"vmid": "2402", "ip": "192.168.11.242", "group": "thirdweb", "name": "thirdweb-rpc-3"},
# Public RPC nodes
{"vmid": "2500", "ip": "192.168.11.250", "group": "public", "name": "besu-rpc-1"},
{"vmid": "2501", "ip": "192.168.11.251", "group": "public", "name": "besu-rpc-2"},
{"vmid": "2502", "ip": "192.168.11.252", "group": "public", "name": "besu-rpc-3"},
{"vmid": "2503", "ip": "192.168.11.253", "group": "public", "name": "besu-rpc-ali-0x8a"},
{"vmid": "2504", "ip": "192.168.11.254", "group": "public", "name": "besu-rpc-ali-0x1"},
# Named RPC nodes
{"vmid": "2505", "ip": "192.168.11.201", "group": "named", "name": "besu-rpc-luis-0x8a"},
{"vmid": "2506", "ip": "192.168.11.202", "group": "named", "name": "besu-rpc-luis-0x1"},
{"vmid": "2507", "ip": "192.168.11.203", "group": "named", "name": "besu-rpc-putu-0x8a"},
{"vmid": "2508", "ip": "192.168.11.204", "group": "named", "name": "besu-rpc-putu-0x1"},
]
DEFAULT_HOST_HEADER_CANDIDATES = [
# Most permissive/common
"localhost",
"127.0.0.1",
# Known public domains from docs/scripts (helpful if allowlist is domain-based)
"rpc-http-pub.d-bis.org",
"rpc.d-bis.org",
"rpc2.d-bis.org",
"rpc.public-0138.defi-oracle.io",
]
@dataclass
class RpcCallResult:
ok: bool
latency_ms: Optional[float]
response: Optional[Dict[str, Any]]
error: Optional[str]
def _http_post_json(url: str, payload: Dict[str, Any], headers: Dict[str, str], timeout: float) -> RpcCallResult:
data = json.dumps(payload).encode("utf-8")
req = Request(url, data=data, method="POST")
for k, v in headers.items():
req.add_header(k, v)
req.add_header("Content-Type", "application/json")
start = time.perf_counter()
try:
with urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
latency_ms = (time.perf_counter() - start) * 1000.0
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return RpcCallResult(ok=False, latency_ms=latency_ms, response=None, error=f"invalid_json: {raw[:200]}")
return RpcCallResult(ok=True, latency_ms=latency_ms, response=parsed, error=None)
except HTTPError as e:
latency_ms = (time.perf_counter() - start) * 1000.0
try:
raw = e.read().decode("utf-8", errors="replace")
except Exception:
raw = ""
return RpcCallResult(ok=False, latency_ms=latency_ms, response=None, error=f"http_error:{e.code}:{raw[:200]}")
except URLError as e:
latency_ms = (time.perf_counter() - start) * 1000.0
return RpcCallResult(ok=False, latency_ms=latency_ms, response=None, error=f"url_error:{e.reason}")
except Exception as e:
latency_ms = (time.perf_counter() - start) * 1000.0
return RpcCallResult(ok=False, latency_ms=latency_ms, response=None, error=f"exception:{type(e).__name__}:{e}")
def rpc_call(
url: str,
method: str,
params: Optional[List[Any]] = None,
host_header: Optional[str] = None,
timeout: float = 4.0,
request_id: int = 1,
) -> RpcCallResult:
headers: Dict[str, str] = {}
if host_header:
headers["Host"] = host_header
payload = {
"jsonrpc": "2.0",
"method": method,
"params": params if params is not None else [],
"id": request_id,
}
return _http_post_json(url, payload, headers=headers, timeout=timeout)
def extract_jsonrpc_error_message(resp: Dict[str, Any]) -> Optional[str]:
if not isinstance(resp, dict):
return None
err = resp.get("error")
if isinstance(err, dict):
msg = err.get("message")
if isinstance(msg, str):
return msg
# Some proxies respond differently
msg = resp.get("message")
if isinstance(msg, str):
return msg
return None
def hex_to_int(hex_str: str) -> Optional[int]:
if not isinstance(hex_str, str):
return None
if not hex_str.startswith("0x"):
return None
try:
return int(hex_str, 16)
except Exception:
return None
def choose_working_host_header(
base_url: str, timeout: float, candidates: List[Optional[str]]
) -> Tuple[Optional[str], RpcCallResult]:
"""
Try eth_chainId with different Host headers. Return first that doesn't look like host-allowlist denial.
candidates may include None meaning "no explicit Host header override".
"""
last = RpcCallResult(ok=False, latency_ms=None, response=None, error="not_tested")
for host in candidates:
res = rpc_call(base_url, "eth_chainId", params=[], host_header=host, timeout=timeout, request_id=1)
last = res
if not res.ok or not res.response:
continue
err_msg = extract_jsonrpc_error_message(res.response)
if err_msg and "Host not authorized" in err_msg:
continue
# If result exists, we consider it a success path
if isinstance(res.response, dict) and "result" in res.response:
return host, res
# If no explicit host denial, keep it as acceptable (some clients respond errors for other reasons)
if not err_msg:
return host, res
return None, last
def test_node(
node: Dict[str, str],
port: int,
timeout: float,
host_header_candidates: List[str],
) -> Dict[str, Any]:
ip = node["ip"]
base_url = f"http://{ip}:{port}"
candidates: List[Optional[str]] = [None] + host_header_candidates
chosen_host, probe = choose_working_host_header(base_url, timeout=timeout, candidates=candidates)
result: Dict[str, Any] = {
"vmid": node["vmid"],
"ip": ip,
"name": node["name"],
"group": node["group"],
"url": base_url,
"host_header_used": chosen_host,
"reachable": False,
"authorized": False,
"probe": {
"ok": probe.ok,
"latency_ms": probe.latency_ms,
"error": probe.error,
"response": probe.response,
},
"checks": {},
"timings_ms": {},
}
# If we couldn't even POST successfully, we stop here.
if not probe.ok or not probe.response:
return result
result["reachable"] = True
probe_err = extract_jsonrpc_error_message(probe.response) if isinstance(probe.response, dict) else None
if probe_err and "Host not authorized" in probe_err:
result["authorized"] = False
return result
result["authorized"] = True
method_errors: Dict[str, str] = {}
def call(method: str, params: Optional[List[Any]] = None, rid: int = 1) -> RpcCallResult:
return rpc_call(base_url, method, params=params, host_header=chosen_host, timeout=timeout, request_id=rid)
def get_result(res: RpcCallResult) -> Tuple[Any, Optional[str]]:
"""
Returns (result_value, error_string). error_string includes both transport and JSON-RPC errors.
"""
if not res.ok:
return None, res.error or "request_failed"
if not isinstance(res.response, dict):
return None, "invalid_response"
if "error" in res.response:
msg = extract_jsonrpc_error_message(res.response) or "jsonrpc_error"
return None, msg
return res.response.get("result"), None
# 1) Chain ID
chain = call("eth_chainId", [], 2)
result["timings_ms"]["eth_chainId"] = chain.latency_ms
chain_id, chain_err = get_result(chain)
if chain_err:
method_errors["eth_chainId"] = chain_err
result["checks"]["eth_chainId"] = chain_id
result["checks"]["eth_chainId_ok"] = chain_id == CHAIN_ID_EXPECTED_HEX
# 2) net_version
netv = call("net_version", [], 3)
result["timings_ms"]["net_version"] = netv.latency_ms
net_version, netv_err = get_result(netv)
if netv_err:
method_errors["net_version"] = netv_err
result["checks"]["net_version"] = net_version
result["checks"]["net_version_ok"] = net_version == NET_VERSION_EXPECTED
# 3) web3_clientVersion
cv = call("web3_clientVersion", [], 4)
result["timings_ms"]["web3_clientVersion"] = cv.latency_ms
client_version, cv_err = get_result(cv)
if cv_err:
method_errors["web3_clientVersion"] = cv_err
result["checks"]["client_version"] = client_version
# 4) Block number
bn = call("eth_blockNumber", [], 5)
result["timings_ms"]["eth_blockNumber"] = bn.latency_ms
bn_hex, bn_err = get_result(bn)
if bn_err:
method_errors["eth_blockNumber"] = bn_err
bn_int = hex_to_int(bn_hex) if isinstance(bn_hex, str) else None
result["checks"]["block_number_hex"] = bn_hex
result["checks"]["block_number"] = bn_int
# 5) Syncing
syncing = call("eth_syncing", [], 6)
result["timings_ms"]["eth_syncing"] = syncing.latency_ms
sync_val, sync_err = get_result(syncing)
if sync_err:
method_errors["eth_syncing"] = sync_err
# Besu returns false or an object
result["checks"]["syncing"] = sync_val
result["checks"]["syncing_ok"] = (sync_val is False)
# 6) Peer count
peers = call("net_peerCount", [], 7)
result["timings_ms"]["net_peerCount"] = peers.latency_ms
peer_hex, peers_err = get_result(peers)
if peers_err:
method_errors["net_peerCount"] = peers_err
peer_int = hex_to_int(peer_hex) if isinstance(peer_hex, str) else None
result["checks"]["peer_count_hex"] = peer_hex
result["checks"]["peer_count"] = peer_int
# 7) Latest block header sanity (optional)
latest = call("eth_getBlockByNumber", ["latest", False], 8)
result["timings_ms"]["eth_getBlockByNumber_latest"] = latest.latency_ms
blk, latest_err = get_result(latest)
if latest_err:
method_errors["eth_getBlockByNumber"] = latest_err
if isinstance(blk, dict):
result["checks"]["latest_block_hash"] = blk.get("hash")
result["checks"]["latest_block_timestamp_hex"] = blk.get("timestamp")
# 8) Gas price
gp = call("eth_gasPrice", [], 9)
result["timings_ms"]["eth_gasPrice"] = gp.latency_ms
gp_hex, gp_err = get_result(gp)
if gp_err:
method_errors["eth_gasPrice"] = gp_err
result["checks"]["gas_price_hex"] = gp_hex
result["checks"]["gas_price_wei"] = hex_to_int(gp_hex) if isinstance(gp_hex, str) else None
# 9) txpool_status (may be disabled)
txp = call("txpool_status", [], 10)
result["timings_ms"]["txpool_status"] = txp.latency_ms
txp_val, txp_err = get_result(txp)
if txp_err:
method_errors["txpool_status"] = txp_err
if txp_val is not None:
result["checks"]["txpool_status"] = txp_val
result["checks"]["txpool_supported"] = True
else:
result["checks"]["txpool_supported"] = False
if method_errors:
result["checks"]["method_errors"] = method_errors
# Aggregate latency
# Avg over successful calls only (exclude timeouts/errors)
latencies: List[float] = []
for method, v in result["timings_ms"].items():
if isinstance(v, (int, float)) and method not in method_errors:
latencies.append(float(v))
result["checks"]["avg_latency_ms"] = (sum(latencies) / len(latencies)) if latencies else None
return result
def ensure_reports_dir(project_root: str) -> str:
reports_dir = os.path.join(project_root, "reports")
os.makedirs(reports_dir, exist_ok=True)
return reports_dir
def render_markdown_report(summary: Dict[str, Any], nodes: List[Dict[str, Any]]) -> str:
ts = summary["generated_at"]
ok = summary["authorized_ok_count"]
total = summary["total_nodes"]
reachable = summary["reachable_count"]
lines: List[str] = []
lines.append(f"# RPC Nodes Test Report (ChainID 138)\n")
lines.append(f"- Generated: **{ts}**\n")
lines.append(f"- Nodes: **{total}** (reachable: **{reachable}**, authorized+responding: **{ok}**)\n")
lines.append("")
lines.append("## Summary\n")
lines.append("")
lines.append("| VMID | Name | IP | Reachable | Authorized | ChainId | NetVersion | Block | Peers | Syncing | Avg Latency (ms) | Host Header Used |")
lines.append("|------|------|----|-----------|------------|---------|------------|-------|-------|---------|------------------|------------------|")
for n in nodes:
chk = n.get("checks", {})
chain = chk.get("eth_chainId")
netv = chk.get("net_version")
block = chk.get("block_number")
peers = chk.get("peer_count")
syncing_ok = chk.get("syncing_ok")
avg_lat = chk.get("avg_latency_ms")
lines.append(
"| {vmid} | {name} | {ip} | {reachable} | {authorized} | {chain} | {netv} | {block} | {peers} | {sync} | {lat} | {host} |".format(
vmid=n.get("vmid"),
name=n.get("name"),
ip=n.get("ip"),
reachable="" if n.get("reachable") else "",
authorized="" if n.get("authorized") else "",
chain=chain if chain is not None else "-",
netv=netv if netv is not None else "-",
block=block if block is not None else "-",
peers=peers if peers is not None else "-",
sync="" if syncing_ok is True else ("⚠️" if syncing_ok is False else "-"),
lat=f"{avg_lat:.1f}" if isinstance(avg_lat, (int, float)) else "-",
host=n.get("host_header_used") or "-",
)
)
lines.append("")
lines.append("## Cluster Consistency\n")
lines.append("")
lines.append(f"- Block range (authorized nodes): **{summary.get('min_block')}** → **{summary.get('max_block')}** (Δ **{summary.get('block_spread')}**)\n")
lines.append(f"- Expected chainId: **{CHAIN_ID_EXPECTED_HEX}**; nodes matching: **{summary.get('chainid_match_count')}**\n")
lines.append(f"- Expected net_version: **{NET_VERSION_EXPECTED}**; nodes matching: **{summary.get('netversion_match_count')}**\n")
lines.append("")
lines.append("## Notes\n")
lines.append("")
lines.append("- If a node is **reachable but not authorized**, it likely has `rpc-http-host-allowlist` restrictions. This report attempts common Host headers (`localhost`, known RPC domains) to work around that.\n")
lines.append("- If a node is **not reachable**, its either stopped, firewalled, or the network path from this runner to `192.168.11.0/24` is down.\n")
lines.append("")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=8545)
ap.add_argument("--timeout", type=float, default=4.0)
ap.add_argument("--threads", type=int, default=8)
ap.add_argument(
"--host-header",
action="append",
default=[],
help="Additional Host header candidate (repeatable).",
)
args = ap.parse_args()
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
reports_dir = ensure_reports_dir(project_root)
host_candidates = DEFAULT_HOST_HEADER_CANDIDATES + list(args.host_header or [])
now_utc = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
generated_at = now_utc.isoformat().replace("+00:00", "Z")
stamp = now_utc.strftime("%Y%m%d_%H%M%S")
results: List[Dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=max(1, args.threads)) as ex:
futs = [
ex.submit(test_node, node, args.port, args.timeout, host_candidates)
for node in RPC_NODES
]
for f in as_completed(futs):
results.append(f.result())
# Stable ordering for output
results.sort(key=lambda r: int(r.get("vmid", "0")))
authorized_nodes = [r for r in results if r.get("authorized")]
blocks = [r.get("checks", {}).get("block_number") for r in authorized_nodes]
blocks_int = [b for b in blocks if isinstance(b, int)]
min_block = min(blocks_int) if blocks_int else None
max_block = max(blocks_int) if blocks_int else None
block_spread = (max_block - min_block) if (min_block is not None and max_block is not None) else None
summary = {
"generated_at": generated_at,
"total_nodes": len(results),
"reachable_count": sum(1 for r in results if r.get("reachable")),
"authorized_ok_count": sum(1 for r in results if r.get("authorized")),
"chainid_match_count": sum(1 for r in authorized_nodes if r.get("checks", {}).get("eth_chainId") == CHAIN_ID_EXPECTED_HEX),
"netversion_match_count": sum(1 for r in authorized_nodes if r.get("checks", {}).get("net_version") == NET_VERSION_EXPECTED),
"min_block": min_block,
"max_block": max_block,
"block_spread": block_spread,
"port": args.port,
"timeout_s": args.timeout,
"threads": args.threads,
"host_header_candidates": host_candidates,
}
out_json = os.path.join(reports_dir, f"rpc_nodes_test_{stamp}.json")
out_md = os.path.join(reports_dir, f"rpc_nodes_test_{stamp}.md")
with open(out_json, "w", encoding="utf-8") as f:
json.dump({"summary": summary, "nodes": results}, f, indent=2, sort_keys=False)
md = render_markdown_report(summary, results)
with open(out_md, "w", encoding="utf-8") as f:
f.write(md)
print(f"Wrote JSON report: {out_json}")
print(f"Wrote Markdown report: {out_md}")
print("")
print(f"Reachable: {summary['reachable_count']}/{summary['total_nodes']}")
print(f"Authorized+responding: {summary['authorized_ok_count']}/{summary['total_nodes']}")
if block_spread is not None:
print(f"Block spread: {block_spread} (min={min_block}, max={max_block})")
return 0
if __name__ == "__main__":
raise SystemExit(main())