netpath 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netpath/__init__.py +1 -0
- netpath/__main__.py +3 -0
- netpath/asn.py +108 -0
- netpath/cli.py +526 -0
- netpath/country.py +134 -0
- netpath/diagnosis.py +93 -0
- netpath/display.py +473 -0
- netpath/globe.py +205 -0
- netpath/iperf.py +59 -0
- netpath/mtr.py +197 -0
- netpath/rum.py +55 -0
- netpath/servers.py +69 -0
- netpath/speedtest.py +112 -0
- netpath-0.1.0.dist-info/METADATA +135 -0
- netpath-0.1.0.dist-info/RECORD +18 -0
- netpath-0.1.0.dist-info/WHEEL +4 -0
- netpath-0.1.0.dist-info/entry_points.txt +2 -0
- netpath-0.1.0.dist-info/licenses/LICENSE +21 -0
netpath/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
netpath/__main__.py
ADDED
netpath/asn.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def resolve_host(hostname: str) -> str | None:
|
|
6
|
+
try:
|
|
7
|
+
return socket.gethostbyname(hostname)
|
|
8
|
+
except (socket.gaierror, OSError):
|
|
9
|
+
return None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_hosts_parallel(hostnames: list[str], workers: int = 50) -> dict[str, str]:
|
|
13
|
+
"""Returns {hostname: ip} for successful resolutions."""
|
|
14
|
+
results = {}
|
|
15
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
16
|
+
future_to_host = {ex.submit(resolve_host, h): h for h in hostnames}
|
|
17
|
+
for future in as_completed(future_to_host):
|
|
18
|
+
host = future_to_host[future]
|
|
19
|
+
ip = future.result()
|
|
20
|
+
if ip:
|
|
21
|
+
results[host] = ip
|
|
22
|
+
return results
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cymru_query(ips: list[str], timeout: int = 30) -> str:
|
|
26
|
+
"""Raw Cymru bulk whois query. Returns the full response text."""
|
|
27
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
28
|
+
s.settimeout(timeout)
|
|
29
|
+
s.connect(("whois.cymru.com", 43))
|
|
30
|
+
s.sendall(("begin\nverbose\n" + "\n".join(ips) + "\nend\n").encode())
|
|
31
|
+
chunks = []
|
|
32
|
+
while True:
|
|
33
|
+
try:
|
|
34
|
+
data = s.recv(8192)
|
|
35
|
+
if not data:
|
|
36
|
+
break
|
|
37
|
+
chunks.append(data)
|
|
38
|
+
except socket.timeout:
|
|
39
|
+
break
|
|
40
|
+
s.close()
|
|
41
|
+
return b"".join(chunks).decode(errors="replace")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cymru_bulk_lookup(ips: list[str], timeout: int = 30) -> dict[str, str]:
|
|
45
|
+
"""
|
|
46
|
+
Bulk ASN lookup via Team Cymru whois.
|
|
47
|
+
Returns {ip: "AS12345"} for all IPs with known ASNs.
|
|
48
|
+
"""
|
|
49
|
+
if not ips:
|
|
50
|
+
return {}
|
|
51
|
+
try:
|
|
52
|
+
response = _cymru_query(ips, timeout)
|
|
53
|
+
except OSError:
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
result: dict[str, str] = {}
|
|
57
|
+
for line in response.splitlines():
|
|
58
|
+
line = line.strip()
|
|
59
|
+
if not line or line.startswith("Bulk") or "|" not in line:
|
|
60
|
+
continue
|
|
61
|
+
parts = [p.strip() for p in line.split("|")]
|
|
62
|
+
if len(parts) < 2:
|
|
63
|
+
continue
|
|
64
|
+
asn_num, ip = parts[0], parts[1]
|
|
65
|
+
if asn_num and asn_num not in ("NA", ""):
|
|
66
|
+
result[ip] = f"AS{asn_num}"
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cymru_bulk_lookup_rich(ips: list[str], timeout: int = 30) -> dict[str, dict]:
|
|
71
|
+
"""
|
|
72
|
+
Bulk Cymru whois returning full record per IP.
|
|
73
|
+
Response format: ASN | IP | prefix | country | registry | date | name
|
|
74
|
+
Returns {ip: {asn, prefix, name}}.
|
|
75
|
+
"""
|
|
76
|
+
if not ips:
|
|
77
|
+
return {}
|
|
78
|
+
try:
|
|
79
|
+
response = _cymru_query(ips, timeout)
|
|
80
|
+
except OSError:
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
result: dict[str, dict] = {}
|
|
84
|
+
for line in response.splitlines():
|
|
85
|
+
line = line.strip()
|
|
86
|
+
if not line or line.startswith("Bulk") or "|" not in line:
|
|
87
|
+
continue
|
|
88
|
+
parts = [p.strip() for p in line.split("|")]
|
|
89
|
+
if len(parts) < 3:
|
|
90
|
+
continue
|
|
91
|
+
asn_num = parts[0]
|
|
92
|
+
ip = parts[1]
|
|
93
|
+
prefix = parts[2] if len(parts) > 2 else ""
|
|
94
|
+
name = parts[6].split(",")[0].strip() if len(parts) > 6 else ""
|
|
95
|
+
if asn_num and asn_num not in ("NA", "") and ip:
|
|
96
|
+
result[ip] = {
|
|
97
|
+
"asn": f"AS{asn_num}",
|
|
98
|
+
"prefix": prefix,
|
|
99
|
+
"name": name,
|
|
100
|
+
}
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def normalize_asn(asn: str) -> str:
|
|
105
|
+
asn = asn.upper().strip()
|
|
106
|
+
if not asn.startswith("AS"):
|
|
107
|
+
asn = f"AS{asn}"
|
|
108
|
+
return asn
|
netpath/cli.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import queue
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import typer
|
|
9
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
10
|
+
|
|
11
|
+
from netpath import __version__
|
|
12
|
+
from netpath import country as country_mod
|
|
13
|
+
from netpath import display, globe as globe_mod, iperf as iperf_mod, mtr, rum as rum_mod, servers, speedtest
|
|
14
|
+
from netpath.asn import normalize_asn
|
|
15
|
+
from netpath.diagnosis import diagnose
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
help="netpath — probe throughput, latency, and packet loss to an ASN.",
|
|
19
|
+
add_completion=False,
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# ── shared options ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
_COUNT = typer.Option(3, "--count", "-n", help="Max servers / endpoints to test")
|
|
26
|
+
_DUR = typer.Option(5, "--duration", "-d", help="iperf3 seconds per direction")
|
|
27
|
+
_CYCLES = typer.Option(10, "--cycles", "-c", help="Probe cycles (mtr) / probes (traceroute)")
|
|
28
|
+
_NO_TPUT = typer.Option(False, "--no-throughput", help="Skip throughput test")
|
|
29
|
+
_CF_TOK = typer.Option(None, "--cf-token",
|
|
30
|
+
envvar="NETPATH_CF_TOKEN",
|
|
31
|
+
help="Cloudflare API token with radar:read (or set NETPATH_CF_TOKEN)")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── internal helpers ──────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def _extract_as_path(hubs: list[dict]) -> list[str]:
|
|
37
|
+
asns: list[str] = []
|
|
38
|
+
for h in hubs:
|
|
39
|
+
asn = h.get("ASN", "")
|
|
40
|
+
if asn and asn != "AS???" and (not asns or asns[-1] != asn):
|
|
41
|
+
asns.append(asn)
|
|
42
|
+
return asns
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _classify_path(hubs: list[dict], target_asn: str) -> dict:
|
|
46
|
+
"""
|
|
47
|
+
Determine whether the traceroute reached target_asn.
|
|
48
|
+
Returns {complete, rtt_ms, entry_transit_asn}.
|
|
49
|
+
complete is True only when target_asn appears in at least one hub's ASN field.
|
|
50
|
+
rtt_ms is the Avg RTT of the last hub inside target_asn (None when incomplete).
|
|
51
|
+
entry_transit_asn is the last non-AS??? ASN before target_asn (complete) or
|
|
52
|
+
the last non-AS??? ASN seen (incomplete); None if no resolvable ASNs exist.
|
|
53
|
+
"""
|
|
54
|
+
target_norm = normalize_asn(target_asn)
|
|
55
|
+
|
|
56
|
+
complete = any(
|
|
57
|
+
normalize_asn(h.get("ASN", "")) == target_norm
|
|
58
|
+
for h in hubs
|
|
59
|
+
if h.get("ASN") and h.get("ASN") != "AS???"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if complete:
|
|
63
|
+
rtt_ms = None
|
|
64
|
+
for h in reversed(hubs):
|
|
65
|
+
if (h.get("ASN") and normalize_asn(h["ASN"]) == target_norm
|
|
66
|
+
and h.get("host") not in ("???", None, "")
|
|
67
|
+
and h.get("Avg", 0) > 0):
|
|
68
|
+
rtt_ms = h["Avg"]
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
entry_transit_asn = None
|
|
72
|
+
prev_asn = None
|
|
73
|
+
for h in hubs:
|
|
74
|
+
asn = h.get("ASN", "")
|
|
75
|
+
if not asn or asn == "AS???":
|
|
76
|
+
continue
|
|
77
|
+
asn_norm = normalize_asn(asn)
|
|
78
|
+
if asn_norm == target_norm:
|
|
79
|
+
entry_transit_asn = prev_asn
|
|
80
|
+
break
|
|
81
|
+
prev_asn = asn_norm
|
|
82
|
+
else:
|
|
83
|
+
rtt_ms = None
|
|
84
|
+
entry_transit_asn = None
|
|
85
|
+
for h in hubs:
|
|
86
|
+
asn = h.get("ASN", "")
|
|
87
|
+
if asn and asn != "AS???":
|
|
88
|
+
entry_transit_asn = normalize_asn(asn)
|
|
89
|
+
|
|
90
|
+
return {"complete": complete, "rtt_ms": rtt_ms, "entry_transit_asn": entry_transit_asn}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_ping_avg(output: str) -> float | None:
|
|
94
|
+
m = re.search(r'rtt min/avg/max/mdev = [\d.]+/([\d.]+)/', output)
|
|
95
|
+
if m:
|
|
96
|
+
return float(m.group(1))
|
|
97
|
+
m = re.search(r'round-trip min/avg/max/stddev = [\d.]+/([\d.]+)/', output)
|
|
98
|
+
if m:
|
|
99
|
+
return float(m.group(1))
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run_ping_probe(host: str, duration: int, result_q: queue.Queue) -> None:
|
|
104
|
+
count = min(duration, 5)
|
|
105
|
+
try:
|
|
106
|
+
proc = subprocess.run(
|
|
107
|
+
["ping", "-c", str(count), "-i", "1", host],
|
|
108
|
+
capture_output=True, text=True, timeout=count + 10,
|
|
109
|
+
)
|
|
110
|
+
if proc.returncode != 0:
|
|
111
|
+
stderr_lower = proc.stderr.lower()
|
|
112
|
+
if "permission" in stderr_lower or "operation not permitted" in stderr_lower:
|
|
113
|
+
result_q.put(None)
|
|
114
|
+
return
|
|
115
|
+
result_q.put(_parse_ping_avg(proc.stdout))
|
|
116
|
+
except FileNotFoundError:
|
|
117
|
+
result_q.put(None)
|
|
118
|
+
except PermissionError:
|
|
119
|
+
result_q.put(None)
|
|
120
|
+
except subprocess.TimeoutExpired:
|
|
121
|
+
result_q.put(None)
|
|
122
|
+
except Exception:
|
|
123
|
+
result_q.put(None)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_deps(no_throughput: bool) -> bool:
|
|
127
|
+
if not mtr.available():
|
|
128
|
+
display.error("mtr not found — install with: brew install mtr")
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
return no_throughput
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _trace(host: str, cycles: int) -> tuple[list[dict], str]:
|
|
134
|
+
try:
|
|
135
|
+
return mtr.run(host, cycles=cycles), "mtr"
|
|
136
|
+
except mtr.MtrPermissionError:
|
|
137
|
+
return mtr.run_traceroute(host, probes=cycles), "traceroute"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _fetch_rum(asn: str, cf_token: str | None) -> dict | None:
|
|
141
|
+
if not cf_token:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
return rum_mod.fetch_asn_quality(asn, cf_token)
|
|
145
|
+
except ValueError as e:
|
|
146
|
+
display.warn(f"Cloudflare RUM: {e}")
|
|
147
|
+
return None
|
|
148
|
+
except Exception:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _run_test(host: str, port: int, server_meta: dict, target_asn: str,
|
|
153
|
+
cycles: int, duration: int, skip_throughput: bool,
|
|
154
|
+
cf_token: str | None = None, show_server_heading: bool = True,
|
|
155
|
+
json_mode: bool = False) -> dict:
|
|
156
|
+
"""Run trace + optional throughput test. Returns enriched result dict."""
|
|
157
|
+
result: dict = {"as_path": [], "last_rtt_ms": None, "rum": None,
|
|
158
|
+
"hubs": [], "bufferbloat_ms": None,
|
|
159
|
+
"download_mbps": None, "upload_mbps": None, "verdict": {},
|
|
160
|
+
"path_complete": False, "verified_rtt_ms": None,
|
|
161
|
+
"entry_transit_asn": None}
|
|
162
|
+
|
|
163
|
+
if show_server_heading and not json_mode:
|
|
164
|
+
display.server_heading(server_meta)
|
|
165
|
+
|
|
166
|
+
if not json_mode:
|
|
167
|
+
display.console.print(f" [dim]Tracing path ({cycles} probes)…[/dim]")
|
|
168
|
+
try:
|
|
169
|
+
if not json_mode:
|
|
170
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
|
171
|
+
console=display.console, transient=True) as p:
|
|
172
|
+
p.add_task(f"probing → {host}", total=None)
|
|
173
|
+
hubs, method = _trace(host, cycles)
|
|
174
|
+
else:
|
|
175
|
+
hubs, method = _trace(host, cycles)
|
|
176
|
+
except RuntimeError as e:
|
|
177
|
+
if not json_mode:
|
|
178
|
+
display.error(f"trace: {e}")
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
if method == "traceroute" and not json_mode:
|
|
182
|
+
display.console.print(" [dim](mtr unavailable — using traceroute + Cymru ASN lookup)[/dim]\n")
|
|
183
|
+
|
|
184
|
+
if not json_mode:
|
|
185
|
+
display.path_table(hubs, target_asn)
|
|
186
|
+
display.as_path_summary(hubs)
|
|
187
|
+
|
|
188
|
+
result["as_path"] = _extract_as_path(hubs)
|
|
189
|
+
result["hubs"] = hubs
|
|
190
|
+
|
|
191
|
+
# last_rtt_ms: last responsive hop regardless of ASN (backward compat for asn subcommand)
|
|
192
|
+
for _h in reversed(hubs):
|
|
193
|
+
if _h.get("host") not in ("???", None, "") and _h.get("Avg", 0) > 0:
|
|
194
|
+
result["last_rtt_ms"] = _h["Avg"]
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
classification = _classify_path(hubs, target_asn)
|
|
198
|
+
result["path_complete"] = classification["complete"]
|
|
199
|
+
result["verified_rtt_ms"] = classification["rtt_ms"]
|
|
200
|
+
result["entry_transit_asn"] = classification["entry_transit_asn"]
|
|
201
|
+
|
|
202
|
+
rum_data = _fetch_rum(target_asn, cf_token)
|
|
203
|
+
result["rum"] = rum_data
|
|
204
|
+
|
|
205
|
+
if skip_throughput:
|
|
206
|
+
if rum_data and not json_mode:
|
|
207
|
+
display.rum_only_panel(rum_data, target_asn)
|
|
208
|
+
verdict = diagnose(result)
|
|
209
|
+
result["verdict"] = verdict
|
|
210
|
+
if not json_mode:
|
|
211
|
+
display.verdict_panel(verdict)
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
# iperf3 measures the actual path from this host to the server in target_asn.
|
|
215
|
+
# Fall back to HTTP speedtest only if iperf3 is unavailable (that measures
|
|
216
|
+
# user → Cloudflare, not user → target ASN).
|
|
217
|
+
if iperf_mod.available():
|
|
218
|
+
if not json_mode:
|
|
219
|
+
display.console.print(
|
|
220
|
+
f" [dim]Measuring throughput via iperf3 to {host}:{port} ({duration}s each direction)…[/dim]"
|
|
221
|
+
)
|
|
222
|
+
idle_rtt = result["last_rtt_ms"]
|
|
223
|
+
ping_q: queue.Queue = queue.Queue()
|
|
224
|
+
ping_thread = threading.Thread(
|
|
225
|
+
target=_run_ping_probe, args=(host, duration, ping_q), daemon=True
|
|
226
|
+
)
|
|
227
|
+
ping_thread.start()
|
|
228
|
+
try:
|
|
229
|
+
if not json_mode:
|
|
230
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
|
231
|
+
console=display.console, transient=True) as p:
|
|
232
|
+
p.add_task(f"iperf3 → {host} ↑↓", total=None)
|
|
233
|
+
upload, download = iperf_mod.run_bidirectional(host, port, duration)
|
|
234
|
+
else:
|
|
235
|
+
upload, download = iperf_mod.run_bidirectional(host, port, duration)
|
|
236
|
+
ping_thread.join(timeout=duration + 10)
|
|
237
|
+
try:
|
|
238
|
+
loaded_rtt = ping_q.get_nowait()
|
|
239
|
+
except queue.Empty:
|
|
240
|
+
loaded_rtt = None
|
|
241
|
+
if idle_rtt is not None and loaded_rtt is not None:
|
|
242
|
+
result["bufferbloat_ms"] = round(loaded_rtt - idle_rtt, 1)
|
|
243
|
+
result["download_mbps"] = download.get("recv_bps", download.get("bps", 0)) / 1e6
|
|
244
|
+
result["upload_mbps"] = upload.get("bps", 0) / 1e6
|
|
245
|
+
verdict = diagnose(result)
|
|
246
|
+
result["verdict"] = verdict
|
|
247
|
+
if not json_mode:
|
|
248
|
+
display.throughput_and_rum(upload, download, rum=rum_data,
|
|
249
|
+
server=f"{host} (iperf3)")
|
|
250
|
+
display.bufferbloat_line(idle_rtt, loaded_rtt)
|
|
251
|
+
display.verdict_panel(verdict)
|
|
252
|
+
return result
|
|
253
|
+
except RuntimeError as e:
|
|
254
|
+
ping_thread.join(timeout=5)
|
|
255
|
+
if not json_mode:
|
|
256
|
+
display.warn(f"iperf3 to {host}:{port} failed: {e}")
|
|
257
|
+
if rum_data:
|
|
258
|
+
display.rum_only_panel(rum_data, target_asn)
|
|
259
|
+
verdict = diagnose(result)
|
|
260
|
+
result["verdict"] = verdict
|
|
261
|
+
if not json_mode:
|
|
262
|
+
display.verdict_panel(verdict)
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
# iperf3 not installed — fall back to HTTP speedtest as a baseline.
|
|
266
|
+
# This measures user → Cloudflare, NOT user → target ASN.
|
|
267
|
+
if not json_mode:
|
|
268
|
+
display.console.print(
|
|
269
|
+
f" [dim]iperf3 not installed — showing Cloudflare baseline "
|
|
270
|
+
f"(install iperf3 for cross-ASN measurement)…[/dim]"
|
|
271
|
+
)
|
|
272
|
+
try:
|
|
273
|
+
if not json_mode:
|
|
274
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
|
275
|
+
console=display.console, transient=True) as p:
|
|
276
|
+
p.add_task("speed.cloudflare.com ↑↓", total=None)
|
|
277
|
+
st_result = speedtest.run(duration=duration)
|
|
278
|
+
else:
|
|
279
|
+
st_result = speedtest.run(duration=duration)
|
|
280
|
+
|
|
281
|
+
upload, download = speedtest.extract_stats(st_result)
|
|
282
|
+
result["download_mbps"] = download.get("recv_bps", download.get("bps", 0)) / 1e6
|
|
283
|
+
result["upload_mbps"] = upload.get("bps", 0) / 1e6
|
|
284
|
+
if not json_mode:
|
|
285
|
+
display.throughput_and_rum(upload, download, rum=rum_data,
|
|
286
|
+
server="speed.cloudflare.com (baseline — not cross-ASN)")
|
|
287
|
+
|
|
288
|
+
except RuntimeError as e:
|
|
289
|
+
if not json_mode:
|
|
290
|
+
display.warn(f"speedtest: {e}")
|
|
291
|
+
if rum_data:
|
|
292
|
+
display.rum_only_panel(rum_data, target_asn)
|
|
293
|
+
|
|
294
|
+
verdict = diagnose(result)
|
|
295
|
+
result["verdict"] = verdict
|
|
296
|
+
if not json_mode:
|
|
297
|
+
display.verdict_panel(verdict)
|
|
298
|
+
return result
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── asn subcommand ────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
@app.command()
|
|
304
|
+
def asn(
|
|
305
|
+
target: str = typer.Argument(..., help="Target ASN, e.g. AS15169 or 15169"),
|
|
306
|
+
count: int = _COUNT,
|
|
307
|
+
duration: int = _DUR,
|
|
308
|
+
cycles: int = _CYCLES,
|
|
309
|
+
no_throughput: bool = _NO_TPUT,
|
|
310
|
+
cf_token: str | None = _CF_TOK,
|
|
311
|
+
output_json: bool = typer.Option(False, "--json", help="Output results as JSON to stdout; suppresses terminal display"),
|
|
312
|
+
globe: bool = typer.Option(False, "--globe", "-g", help="Open interactive 3D globe visualization after probe"),
|
|
313
|
+
):
|
|
314
|
+
"""Test latency, packet loss, and throughput to servers in a specific ASN."""
|
|
315
|
+
asn_norm = normalize_asn(target)
|
|
316
|
+
if globe and output_json:
|
|
317
|
+
print("Warning: --globe is ignored when --json is set", file=sys.stderr)
|
|
318
|
+
globe = False
|
|
319
|
+
if not output_json:
|
|
320
|
+
display.header(__version__)
|
|
321
|
+
skip_throughput = _check_deps(no_throughput)
|
|
322
|
+
|
|
323
|
+
if not output_json:
|
|
324
|
+
display.console.print(f"[dim]Scanning iperf3 servers in [bold]{asn_norm}[/bold]…[/dim]\n")
|
|
325
|
+
if not output_json:
|
|
326
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
|
327
|
+
console=display.console, transient=True) as progress:
|
|
328
|
+
progress.add_task("Resolving hostnames + bulk ASN lookup via Cymru…", total=None)
|
|
329
|
+
found = servers.find_servers_in_asn(asn_norm, max_count=count)
|
|
330
|
+
else:
|
|
331
|
+
found = servers.find_servers_in_asn(asn_norm, max_count=count)
|
|
332
|
+
|
|
333
|
+
if not found:
|
|
334
|
+
if output_json:
|
|
335
|
+
print(json.dumps({"error": f"No public iperf3 servers found in {asn_norm}"}, indent=2))
|
|
336
|
+
else:
|
|
337
|
+
display.error(
|
|
338
|
+
f"No public iperf3 servers found in {asn_norm}.\n"
|
|
339
|
+
" Try: https://github.com/R0GGER/public-iperf3-servers"
|
|
340
|
+
)
|
|
341
|
+
raise typer.Exit(1)
|
|
342
|
+
|
|
343
|
+
if not output_json:
|
|
344
|
+
display.console.print(f"[green]✓[/green] Found [bold]{len(found)}[/bold] server(s) in {asn_norm}\n")
|
|
345
|
+
|
|
346
|
+
if output_json:
|
|
347
|
+
server = found[0]
|
|
348
|
+
result = _run_test(
|
|
349
|
+
host=server["HOST"], port=server["port"],
|
|
350
|
+
server_meta=server, target_asn=asn_norm,
|
|
351
|
+
cycles=cycles, duration=duration,
|
|
352
|
+
skip_throughput=skip_throughput, cf_token=cf_token,
|
|
353
|
+
json_mode=True,
|
|
354
|
+
)
|
|
355
|
+
upload_mbps = result.get("upload_mbps")
|
|
356
|
+
download_mbps = result.get("download_mbps")
|
|
357
|
+
output = {
|
|
358
|
+
"asn": asn_norm,
|
|
359
|
+
"target_host": server["HOST"],
|
|
360
|
+
"path": [
|
|
361
|
+
{
|
|
362
|
+
"hop": hub.get("count"),
|
|
363
|
+
"host": hub.get("host"),
|
|
364
|
+
"asn": hub.get("ASN"),
|
|
365
|
+
"loss_pct": hub.get("Loss%"),
|
|
366
|
+
"avg_ms": hub.get("Avg"),
|
|
367
|
+
"best_ms": hub.get("Best"),
|
|
368
|
+
"worst_ms": hub.get("Wrst"),
|
|
369
|
+
"p50_ms": hub.get("p50"),
|
|
370
|
+
"p95_ms": hub.get("p95"),
|
|
371
|
+
"p99_ms": hub.get("p99"),
|
|
372
|
+
}
|
|
373
|
+
for hub in result.get("hubs", [])
|
|
374
|
+
],
|
|
375
|
+
"throughput": (
|
|
376
|
+
{"upload_mbps": upload_mbps, "download_mbps": download_mbps}
|
|
377
|
+
if upload_mbps is not None or download_mbps is not None
|
|
378
|
+
else None
|
|
379
|
+
),
|
|
380
|
+
"bufferbloat_ms": result.get("bufferbloat_ms"),
|
|
381
|
+
"rum": result.get("rum"),
|
|
382
|
+
"verdict": result.get("verdict", {}),
|
|
383
|
+
}
|
|
384
|
+
print(json.dumps(output, indent=2))
|
|
385
|
+
else:
|
|
386
|
+
last_hubs: list[dict] = []
|
|
387
|
+
for server in found:
|
|
388
|
+
r = _run_test(
|
|
389
|
+
host=server["HOST"], port=server["port"],
|
|
390
|
+
server_meta=server, target_asn=asn_norm,
|
|
391
|
+
cycles=cycles, duration=duration,
|
|
392
|
+
skip_throughput=skip_throughput, cf_token=cf_token,
|
|
393
|
+
)
|
|
394
|
+
last_hubs = r["hubs"]
|
|
395
|
+
if globe and last_hubs:
|
|
396
|
+
globe_mod.render({asn_norm: last_hubs})
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ── country subcommand ────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
@app.command()
|
|
402
|
+
def country(
|
|
403
|
+
code: str = typer.Argument(..., help="ISO country code, e.g. US, GB, IL"),
|
|
404
|
+
top: int = typer.Option(10, "--top", "-t", help="Number of top ASNs to test"),
|
|
405
|
+
count: int = _COUNT,
|
|
406
|
+
duration: int = _DUR,
|
|
407
|
+
cycles: int = _CYCLES,
|
|
408
|
+
no_throughput: bool = _NO_TPUT,
|
|
409
|
+
cf_token: str | None = _CF_TOK,
|
|
410
|
+
globe: bool = typer.Option(False, "--globe", "-g", help="Open interactive 3D globe visualization after probes"),
|
|
411
|
+
):
|
|
412
|
+
"""Test the top N ASNs (by allocated IPv4 address space) for a country."""
|
|
413
|
+
code = code.upper()
|
|
414
|
+
display.header(__version__)
|
|
415
|
+
skip_throughput = _check_deps(no_throughput)
|
|
416
|
+
|
|
417
|
+
display.console.print(
|
|
418
|
+
f"[dim]Ranking top {top} ASNs for [bold]{code}[/bold] "
|
|
419
|
+
f"via RIPE allocation data + Cymru…[/dim]\n"
|
|
420
|
+
)
|
|
421
|
+
try:
|
|
422
|
+
top_asns = country_mod.get_top_asns(code, top_n=top)
|
|
423
|
+
except Exception as e:
|
|
424
|
+
display.error(f"ASN lookup failed: {e}")
|
|
425
|
+
raise typer.Exit(1)
|
|
426
|
+
|
|
427
|
+
display.console.print(f"[green]✓[/green] Top {len(top_asns)} ASNs for [bold]{code}[/bold]:\n")
|
|
428
|
+
for i, a in enumerate(top_asns, 1):
|
|
429
|
+
name = display.clean_asn_name(a["name"])
|
|
430
|
+
meta_parts = []
|
|
431
|
+
if a.get("addresses"):
|
|
432
|
+
meta_parts.append(f"{a['addresses']:,} IPs")
|
|
433
|
+
if a.get("prefix_count"):
|
|
434
|
+
n = a["prefix_count"]
|
|
435
|
+
meta_parts.append(f"{n} prefix{'es' if n != 1 else ''}")
|
|
436
|
+
meta = f" [dim]{' · '.join(meta_parts)}[/dim]" if meta_parts else ""
|
|
437
|
+
display.console.print(f" {i:>2}. [bold yellow]{a['asn']}[/bold yellow] {name}{meta}")
|
|
438
|
+
display.console.print()
|
|
439
|
+
|
|
440
|
+
# Run HTTP speedtest ONCE as the user's own baseline before per-ISP tests
|
|
441
|
+
if not skip_throughput:
|
|
442
|
+
display.console.print("[dim]Measuring your connection baseline (speed.cloudflare.com)…[/dim]")
|
|
443
|
+
try:
|
|
444
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
|
445
|
+
console=display.console, transient=True) as p:
|
|
446
|
+
p.add_task("speed.cloudflare.com ↑↓", total=None)
|
|
447
|
+
st_result = speedtest.run(duration=duration)
|
|
448
|
+
ul, dl = speedtest.extract_stats(st_result)
|
|
449
|
+
display.baseline_panel(ul, dl)
|
|
450
|
+
except RuntimeError as e:
|
|
451
|
+
display.warn(f"Baseline speedtest failed: {e}")
|
|
452
|
+
|
|
453
|
+
# Warm the server cache once — all subsequent find_servers_in_asn calls are free
|
|
454
|
+
display.console.print("[dim]Fetching + resolving iperf3 server list…[/dim]")
|
|
455
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
|
456
|
+
console=display.console, transient=True) as progress:
|
|
457
|
+
progress.add_task("DNS + bulk ASN lookup via Cymru…", total=None)
|
|
458
|
+
servers._fetch_and_resolve()
|
|
459
|
+
display.console.print()
|
|
460
|
+
|
|
461
|
+
summary_rows: list[dict] = []
|
|
462
|
+
hubs_for_globe: dict[str, list[dict]] = {}
|
|
463
|
+
|
|
464
|
+
for i, asn_info in enumerate(top_asns, 1):
|
|
465
|
+
asn_str = asn_info["asn"]
|
|
466
|
+
isp_name = asn_info["name"]
|
|
467
|
+
|
|
468
|
+
display.isp_section(i, asn_str, isp_name,
|
|
469
|
+
asn_info.get("addresses", 0),
|
|
470
|
+
asn_info.get("prefix_count", 0))
|
|
471
|
+
|
|
472
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
|
473
|
+
console=display.console, transient=True) as p:
|
|
474
|
+
p.add_task(f"Searching iperf3 servers in {asn_str}…", total=None)
|
|
475
|
+
asn_servers = servers.find_servers_in_asn(asn_str, max_count=count)
|
|
476
|
+
|
|
477
|
+
if asn_servers:
|
|
478
|
+
server = asn_servers[0]
|
|
479
|
+
can_test_throughput = not no_throughput and iperf_mod.available()
|
|
480
|
+
r = _run_test(
|
|
481
|
+
host=server["HOST"], port=server["port"],
|
|
482
|
+
server_meta=server, target_asn=asn_str,
|
|
483
|
+
cycles=cycles, duration=duration,
|
|
484
|
+
skip_throughput=not can_test_throughput,
|
|
485
|
+
show_server_heading=True,
|
|
486
|
+
cf_token=cf_token,
|
|
487
|
+
)
|
|
488
|
+
else:
|
|
489
|
+
test_ip = country_mod.get_test_ip_for_asn(asn_str)
|
|
490
|
+
if not test_ip:
|
|
491
|
+
display.warn(f"Could not find a test IP for {asn_str} — skipping")
|
|
492
|
+
summary_rows.append({"asn": asn_str, "name": display.clean_asn_name(isp_name),
|
|
493
|
+
"as_path": [], "last_rtt_ms": None, "rum": None,
|
|
494
|
+
"path_complete": False, "verified_rtt_ms": None,
|
|
495
|
+
"entry_transit_asn": None})
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
display.console.print(
|
|
499
|
+
f" [dim]→ {test_ip} (traceroute target — no iperf3 server in {asn_str})[/dim]\n"
|
|
500
|
+
)
|
|
501
|
+
meta = {"HOST": test_ip, "SITE": isp_name, "COUNTRY": code,
|
|
502
|
+
"asn": asn_str, "port": 5201}
|
|
503
|
+
r = _run_test(
|
|
504
|
+
host=test_ip, port=5201,
|
|
505
|
+
server_meta=meta, target_asn=asn_str,
|
|
506
|
+
cycles=cycles, duration=duration,
|
|
507
|
+
skip_throughput=True,
|
|
508
|
+
show_server_heading=False,
|
|
509
|
+
cf_token=cf_token,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
summary_rows.append({
|
|
513
|
+
"asn": asn_str,
|
|
514
|
+
"name": display.clean_asn_name(isp_name),
|
|
515
|
+
**r,
|
|
516
|
+
})
|
|
517
|
+
if globe:
|
|
518
|
+
hubs_for_globe[asn_str] = r.get("hubs", [])
|
|
519
|
+
|
|
520
|
+
display.country_summary(code, summary_rows)
|
|
521
|
+
if globe and hubs_for_globe:
|
|
522
|
+
globe_mod.render(hubs_for_globe)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def run():
|
|
526
|
+
app()
|