feat: add ICMP ping column to peer table

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 08:21:36 +00:00
parent 6bca2c62e6
commit 602592e304
2 changed files with 59 additions and 3 deletions
+2
View File
@@ -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 - **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 - **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` - **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) ### 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 - **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) - **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 - **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 - **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 - **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 - **Config precedence** — `config_refresh()` reads the config value; an explicit `-i SEC` argument overrides it
+57 -3
View File
@@ -18,6 +18,7 @@ import socket
import subprocess import subprocess
import sys import sys
import time import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.request import urlopen, Request from urllib.request import urlopen, Request
from urllib.error import URLError from urllib.error import URLError
@@ -151,6 +152,53 @@ def geo_str(ip):
return ", ".join(p for p in geo if p) return ", ".join(p for p in geo if p)
return "" 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(): def load_config():
"""Read the first existing config file (JSON object). Returns {} if none is usable.""" """Read the first existing config file (JSON object). Returns {} if none is usable."""
for path in CONFIG_PATHS: 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) _all_ips.append(node_ip_str)
geo_batch([ip for ip in _all_ips if ip]) 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): def _is_private(ip):
try: try:
return ipaddress.ip_address(ip).is_private 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")) ratio_total = max(ratio_total, len("RX/TX"))
_traffic_hdr = f" {'RX/TX':>{ratio_total}}" _traffic_hdr = f" {'RX/TX':>{ratio_total}}"
_traffic_node = f" {fmt_ratio_cell(None, ratio_l_w, ratio_r_w):>{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" 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} {node_up_str:<8}{_traffic_node} {node_flow_str}" 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) SEP = " " + "─" * (len(HDR) - 2)
def show(peers, is_out): 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", "")) ver = fmt_version(p.get("ver", ""))
lat = p.get("latency", "?") lat = p.get("latency", "?")
lat_s = "n/a" if lat == 86400000 else f"{lat}ms" 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)) up = fmt_up(p.get("elapsed", 0))
rx = p.get("byte_read", 0) or 0 rx = p.get("byte_read", 0) or 0
tx = p.get("byte_write", 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: else:
_parts = fmt_ratio_parts(rx, tx) _parts = fmt_ratio_parts(rx, tx)
_traffic_row = f" {fmt_ratio_cell(_parts, ratio_l_w, ratio_r_w):>{ratio_total}}" _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()
print(HDR) print(HDR)