diff --git a/CLAUDE.md b/CLAUDE.md index b169651..6875c94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +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 +- **System `ping` / TCP connect** — per peer from the host (`ping_batch()`, parallel, private IPs skipped): one ICMP ping (1 s timeout); if the peer filters ICMP, falls back to a TCP connect on the peer port from its address (1 s timeout). Shown as `Ping` next to stellar-core's `Latency` in the `-p`/`-g` table; TCP values are marked with a trailing `t` (legend printed below the table), `—` if neither answers ### Output sections (in order) @@ -76,7 +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 +- **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. Peers with `Latency > LATENCY_PING_RATIO (3) × Ping` get a `⚡` between the two columns (2-column cell, since `⚡` is double-width in terminals); a legend line is printed below the table only when a `⚡` or `t` marker is present - **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 a3fc791..320a7a0 100755 --- a/pi-stat +++ b/pi-stat @@ -43,6 +43,7 @@ HORIZON_URL = "http://127.0.0.1:8000" # queried from inside the contai CFG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "stellar-core.cfg") DEFAULT_REFRESH = 5 REFRESH_WARN_RATIO = 0.8 # warn if a cycle takes >= 80% of the refresh interval +LATENCY_PING_RATIO = 3 # mark peers with ⚡ when core latency > 3× ping/TCP RTT CONFIG_PATHS = [ os.environ.get("PI_STAT_CONFIG"), os.path.join(os.path.expanduser("~"), ".config", "pi-stat", "config.json"), @@ -179,19 +180,44 @@ def ping_rtt(ip, timeout_s=1): 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): +def tcp_rtt(ip, port, timeout_s=1): + """TCP connect time to ip:port in ms (≈ 1 RTT: SYN → SYN/ACK), or None if not connectable.""" + t = time.perf_counter() + try: + with socket.create_connection((ip, port), timeout=timeout_s): + return (time.perf_counter() - t) * 1000 + except OSError: + return None + +def probe_rtt(ip, port): + """ICMP ping first; if the peer filters ICMP, fall back to a TCP connect on its peer port. + Returns (rtt_ms, "icmp" | "tcp") or (None, None).""" + if HAS_PING: + v = ping_rtt(ip) + if v is not None: + return v, "icmp" + if port: + v = tcp_rtt(ip, port) + if v is not None: + return v, "tcp" + return None, None + +def ping_batch(addrs): + """Probe all public peer addresses ("ip:port") in parallel. + Returns {ip: (rtt_ms, kind)}; private/invalid IPs are skipped.""" + targets = {} + for addr in addrs: + ip, _, port = addr.rpartition(":") try: - if not ipaddress.ip_address(ip).is_private: - targets.append(ip) + if ipaddress.ip_address(ip).is_private: + continue except ValueError: - pass - if not HAS_PING or not targets: + continue + targets[ip] = int(port) if port.isdigit() else None + if not targets: return {} with ThreadPoolExecutor(max_workers=min(32, len(targets))) as ex: - return dict(zip(targets, ex.map(ping_rtt, targets))) + return dict(zip(targets, ex.map(lambda t: probe_rtt(*t), targets.items()))) def fmt_ms(v): """Format a millisecond value: one decimal below 10 ms, integer above.""" @@ -199,6 +225,11 @@ def fmt_ms(v): return "—" return f"{v:.1f}ms" if v < 10 else f"{v:.0f}ms" +def fmt_ping(res): + """Ping cell: ICMP value plain, TCP-connect value marked with a trailing 't'.""" + v, kind = res if res else (None, None) + return f"{fmt_ms(v)} {'t' if kind == 'tcp' else ' '}" + def load_config(): """Read the first existing config file (JSON object). Returns {} if none is usable.""" for path in CONFIG_PATHS: @@ -600,7 +631,8 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e # --- 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]) + # ICMP first, TCP connect to the peer port as fallback (marked 't'). + ping_ms = ping_batch([p.get("address", "") for p in inbound + outbound]) def _is_private(ip): try: @@ -696,10 +728,12 @@ 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} {'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}" + HDR = f" {hdr_num_prefix}{'NodeID':<{col_w}} {'Version':<{ver_w}}{_ip_hdr_part}{_geo_hdr_part} {'Latency':>8} {'':2} {'Ping':>9} {'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} {'':2} {fmt_ping(None):>9} {node_up_str:<8}{_traffic_node} {node_flow_str}" SEP = " " + "─" * (len(HDR) - 2) + lat_flagged = [] # peers marked ⚡ (for the legend) + def show(peers, is_out): for p in sorted(peers, key=lambda p: p.get("elapsed", 0), reverse=True): raw_addr = p.get("address", "?").rsplit(":", 1)[0] @@ -715,7 +749,16 @@ 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)) + ping_res = ping_ms.get(raw_addr) + ping_s = fmt_ping(ping_res) + # ⚡ when stellar-core's latency exceeds the network RTT by more than + # LATENCY_PING_RATIO (possible VPN exit / relay in front of the node). + # "⚡" is 2 columns wide in the terminal, so the blank is 2 spaces. + rtt = ping_res[0] if ping_res else None + lat_flag = ("⚡" if isinstance(lat, (int, float)) and lat != 86400000 + and rtt and lat > LATENCY_PING_RATIO * rtt else " ") + if lat_flag == "⚡": + lat_flagged.append(pid) up = fmt_up(p.get("elapsed", 0)) rx = p.get("byte_read", 0) or 0 tx = p.get("byte_write", 0) or 0 @@ -744,7 +787,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} {ping_s:>7} {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} {lat_flag} {ping_s:>9} {up:<8}{_traffic_row} {flow}") print() print(HDR) @@ -758,6 +801,15 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e print() print(" Outbound") show(outbound, True) + legend = [] + if lat_flagged: + legend.append(f"⚡ = Latency > {LATENCY_PING_RATIO}× Ping (possible VPN exit / relay)") + if any(kind == "tcp" for _, kind in ping_ms.values()): + legend.append("Ping: t = TCP connect to peer port (ICMP filtered)") + if legend: + print() + for line in legend: + print(f" {line}") print() return outbound_numbered, container