From 602592e3043a4c05892138dc910c10757f434382 Mon Sep 17 00:00:00 2001 From: olieske Date: Wed, 23 Sep 2026 08:21:36 +0000 Subject: [PATCH] feat: add ICMP ping column to peer table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ping_batch() pings each peer's public IP in parallel from the host (system ping, 1 packet, 1 s timeout, private IPs skipped) - New "Ping" column next to stellar-core's "Latency" in the -p/-g table; "—" when the peer filters ICMP - Low ping with high latency hints at a VPN exit / relay in front of the actual node Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 ++ pi-stat | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d9b7e35..b169651 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,7 @@ Single self-contained script (`pi-stat`). Logic is split into helper functions a - **PostgreSQL** — `psql` inside the container; used for peer DB counts and pubkey→IP mapping - **ZFS CLI** — `zpool`/`zfs` commands on the host for disk health (`z01pool`); if `zpool` is not found (`HAS_ZFS = False`), falls back to `shutil.disk_usage()` — works on Linux without ZFS, WSL2, and Windows - **ip-api.com** — batch geolocation (Country, City), cached in `_geo_cache`; only fetched with `-g` +- **System `ping`** — one ICMP ping per peer IP from the host (`ping_batch()`, parallel, 1 s timeout, private IPs skipped); shown as `Ping` next to stellar-core's `Latency` in the `-p`/`-g` table, `—` if the peer filters ICMP ### Output sections (in order) @@ -75,6 +76,7 @@ The refresh deadline is anchored to the start of the cycle (`cycle_start`), not - **Dynamic column widths** — all table columns sized at runtime from actual data length - **NAT detection** — inbound peers with private IPs are replaced with their DB-stored public IP (`db_peer_addr` from PostgreSQL) - **Validator matching** — `load_validators()` parses `stellar-core.cfg` and indexes by IP, name, and public key; matched peers show a truncated key + `(Vn)` label +- **Ping vs. Latency** — `Latency` is stellar-core's round trip to the peer *process*, `Ping` is ICMP to the peer's IP; a low ping with high latency hints at a VPN exit / relay in front of the actual node - **Geo caching** — `_geo_cache` is a module-level dict; `geo_batch()` only fetches IPs not already cached - **Flag parsing** — manual `sys.argv` parsing; `-g` implies `-p`; `-i` accepts an optional next argument as the refresh interval - **Config precedence** — `config_refresh()` reads the config value; an explicit `-i SEC` argument overrides it diff --git a/pi-stat b/pi-stat index 2d4c5de..a3fc791 100755 --- a/pi-stat +++ b/pi-stat @@ -18,6 +18,7 @@ import socket import subprocess import sys import time +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from urllib.request import urlopen, Request from urllib.error import URLError @@ -151,6 +152,53 @@ def geo_str(ip): return ", ".join(p for p in geo if p) return "" +HAS_PING = shutil.which("ping") is not None +_PING_RE = re.compile(r"[=<]\s*([\d.,]+)\s*ms", re.IGNORECASE) + +def ping_rtt(ip, timeout_s=1): + """Single ICMP ping via the system 'ping'. Returns RTT in ms, or None (no reply / filtered).""" + if os.name == "nt": + cmd = ["ping", "-n", "1", "-w", str(timeout_s * 1000), ip] + else: + cmd = ["ping", "-c", "1", "-W", str(timeout_s), "-n", ip] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s + 2, + env={**os.environ, "LC_ALL": "C"}) + except Exception: + return None + if r.returncode != 0: + return None + # Linux: "time=37.5 ms", Windows: "time=37ms" / "Zeit<1ms" + for line in r.stdout.splitlines(): + if "ttl" in line.lower(): + mt = _PING_RE.search(line) + if mt: + try: + return float(mt.group(1).replace(",", ".")) + except ValueError: + pass + return None + +def ping_batch(ips): + """Ping all public IPs in parallel. Returns {ip: rtt_ms or None}; private/invalid IPs are skipped.""" + targets = [] + for ip in set(ips): + try: + if not ipaddress.ip_address(ip).is_private: + targets.append(ip) + except ValueError: + pass + if not HAS_PING or not targets: + return {} + with ThreadPoolExecutor(max_workers=min(32, len(targets))) as ex: + return dict(zip(targets, ex.map(ping_rtt, targets))) + +def fmt_ms(v): + """Format a millisecond value: one decimal below 10 ms, integer above.""" + if v is None: + return "—" + return f"{v:.1f}ms" if v < 10 else f"{v:.0f}ms" + def load_config(): """Read the first existing config file (JSON object). Returns {} if none is usable.""" for path in CONFIG_PATHS: @@ -549,6 +597,11 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e _all_ips.append(node_ip_str) geo_batch([ip for ip in _all_ips if ip]) + # --- Ping from this host to each peer's known IP --- + # Compared with stellar-core's "Latency", a low ping but high + # latency hints at a VPN exit / relay in front of the actual node. + ping_ms = ping_batch([p.get("address", "").rsplit(":", 1)[0] for p in inbound + outbound]) + def _is_private(ip): try: return ipaddress.ip_address(ip).is_private @@ -643,8 +696,8 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e ratio_total = max(ratio_total, len("RX/TX")) _traffic_hdr = f" {'RX/TX':>{ratio_total}}" _traffic_node = f" {fmt_ratio_cell(None, ratio_l_w, ratio_r_w):>{ratio_total}}" - HDR = f" {hdr_num_prefix}{'NodeID':<{col_w}} {'Version':<{ver_w}}{_ip_hdr_part}{_geo_hdr_part} {'Latency':>8} {'Uptime':<8}{_traffic_hdr} Flow" - NODE_ROW = f" {node_row_prefix}{node_id_str:<{col_w}} {node_ver_str:<{ver_w}}{_ip_node_part}{_geo_node_part} {'—':>8} {node_up_str:<8}{_traffic_node} {node_flow_str}" + HDR = f" {hdr_num_prefix}{'NodeID':<{col_w}} {'Version':<{ver_w}}{_ip_hdr_part}{_geo_hdr_part} {'Latency':>8} {'Ping':>7} {'Uptime':<8}{_traffic_hdr} Flow" + NODE_ROW = f" {node_row_prefix}{node_id_str:<{col_w}} {node_ver_str:<{ver_w}}{_ip_node_part}{_geo_node_part} {'—':>8} {'—':>7} {node_up_str:<8}{_traffic_node} {node_flow_str}" SEP = " " + "─" * (len(HDR) - 2) def show(peers, is_out): @@ -662,6 +715,7 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e ver = fmt_version(p.get("ver", "")) lat = p.get("latency", "?") lat_s = "n/a" if lat == 86400000 else f"{lat}ms" + ping_s = fmt_ms(ping_ms.get(raw_addr)) up = fmt_up(p.get("elapsed", 0)) rx = p.get("byte_read", 0) or 0 tx = p.get("byte_write", 0) or 0 @@ -690,7 +744,7 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e else: _parts = fmt_ratio_parts(rx, tx) _traffic_row = f" {fmt_ratio_cell(_parts, ratio_l_w, ratio_r_w):>{ratio_total}}" - print(f" {row_prefix}{node_col:<{col_w}} {ver:<{ver_w}}{_ip_row_part}{_geo_row_part} {lat_s:>8} {up:<8}{_traffic_row} {flow}") + print(f" {row_prefix}{node_col:<{col_w}} {ver:<{ver_w}}{_ip_row_part}{_geo_row_part} {lat_s:>8} {ping_s:>7} {up:<8}{_traffic_row} {flow}") print() print(HDR)