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/globe.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
import webbrowser
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
from netpath.display import LATENCY_GREEN_MS, LATENCY_YELLOW_MS, console
|
|
11
|
+
|
|
12
|
+
_BATCH_URL = "http://ip-api.com/batch"
|
|
13
|
+
_BATCH_SIZE = 100
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_private(host: str) -> bool:
|
|
17
|
+
try:
|
|
18
|
+
return ipaddress.ip_address(host).is_private
|
|
19
|
+
except ValueError:
|
|
20
|
+
return False # Hostname — let ip-api.com resolve it
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _geolocate(hosts: list[str]) -> dict[str, dict]:
|
|
24
|
+
"""Batch geolocate hosts/IPs. Returns {host: {lat, lon}} for successful lookups."""
|
|
25
|
+
results: dict[str, dict] = {}
|
|
26
|
+
for i in range(0, len(hosts), _BATCH_SIZE):
|
|
27
|
+
batch = hosts[i : i + _BATCH_SIZE]
|
|
28
|
+
payload = [{"query": h, "fields": "query,lat,lon,status"} for h in batch]
|
|
29
|
+
try:
|
|
30
|
+
resp = requests.post(_BATCH_URL, json=payload, timeout=10)
|
|
31
|
+
except requests.RequestException as e:
|
|
32
|
+
raise RuntimeError(f"Geolocation request failed: {e}") from e
|
|
33
|
+
if resp.status_code == 429:
|
|
34
|
+
raise RuntimeError("ip-api.com rate limit reached (HTTP 429)")
|
|
35
|
+
if not resp.ok:
|
|
36
|
+
raise RuntimeError(f"ip-api.com returned HTTP {resp.status_code}")
|
|
37
|
+
for item in resp.json():
|
|
38
|
+
if item.get("status") == "success":
|
|
39
|
+
results[item["query"]] = {"lat": item["lat"], "lon": item["lon"]}
|
|
40
|
+
return results
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _arc_color(delta_ms: float) -> str:
|
|
44
|
+
if delta_ms < LATENCY_GREEN_MS:
|
|
45
|
+
return "rgba(0,255,128,0.8)"
|
|
46
|
+
if delta_ms < LATENCY_YELLOW_MS:
|
|
47
|
+
return "rgba(255,220,0,0.8)"
|
|
48
|
+
return "rgba(255,60,60,0.9)"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_html(points: list[dict], arcs: list[dict]) -> str:
|
|
52
|
+
pts = json.dumps(points)
|
|
53
|
+
acs = json.dumps(arcs)
|
|
54
|
+
return f"""<!DOCTYPE html>
|
|
55
|
+
<html>
|
|
56
|
+
<head>
|
|
57
|
+
<meta charset="utf-8">
|
|
58
|
+
<title>netpath — AS path globe</title>
|
|
59
|
+
<style>
|
|
60
|
+
body{{margin:0;background:#0a0a1a;overflow:hidden}}
|
|
61
|
+
#g{{width:100vw;height:100vh}}
|
|
62
|
+
#legend{{position:fixed;top:16px;right:16px;background:rgba(0,0,0,.75);
|
|
63
|
+
color:#ccc;font:13px monospace;padding:12px 16px;
|
|
64
|
+
border-radius:8px;border:1px solid #333}}
|
|
65
|
+
.r{{display:flex;align-items:center;gap:8px;margin:3px 0}}
|
|
66
|
+
.c{{width:12px;height:12px;border-radius:50%;flex-shrink:0}}
|
|
67
|
+
</style>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<div id="g"></div>
|
|
71
|
+
<div id="legend">
|
|
72
|
+
<b>Latency delta per hop</b>
|
|
73
|
+
<div class="r"><div class="c" style="background:#00ff80"></div>< 20 ms</div>
|
|
74
|
+
<div class="r"><div class="c" style="background:#ffdc00"></div>20–79 ms</div>
|
|
75
|
+
<div class="r"><div class="c" style="background:#ff3c3c"></div>≥ 80 ms</div>
|
|
76
|
+
</div>
|
|
77
|
+
<script src="https://unpkg.com/globe.gl"></script>
|
|
78
|
+
<script>
|
|
79
|
+
const G=Globe()
|
|
80
|
+
.globeImageUrl('https://unpkg.com/three-globe/example/img/earth-dark.jpg')
|
|
81
|
+
.backgroundImageUrl('https://unpkg.com/three-globe/example/img/night-sky.png')
|
|
82
|
+
.pointsData({pts})
|
|
83
|
+
.pointLat(d=>d.lat).pointLng(d=>d.lon)
|
|
84
|
+
.pointLabel(d=>d.label)
|
|
85
|
+
.pointColor(()=>'#00cfff').pointRadius(.4).pointAltitude(.01)
|
|
86
|
+
.arcsData({acs})
|
|
87
|
+
.arcStartLat(d=>d.sLat).arcStartLng(d=>d.sLon)
|
|
88
|
+
.arcEndLat(d=>d.eLat).arcEndLng(d=>d.eLon)
|
|
89
|
+
.arcColor(d=>d.color).arcAltitudeAutoScale(.4)
|
|
90
|
+
.arcStroke(1.5).arcDashLength(.4).arcDashGap(.2).arcDashAnimateTime(2000)
|
|
91
|
+
(document.getElementById('g'));
|
|
92
|
+
G.controls().autoRotate=true;
|
|
93
|
+
G.controls().autoRotateSpeed=.5;
|
|
94
|
+
</script>
|
|
95
|
+
</body>
|
|
96
|
+
</html>"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render(hubs_by_asn: dict[str, list[dict]]) -> None:
|
|
100
|
+
"""Geolocate hops, generate a Globe.gl HTML file, and open it in the browser."""
|
|
101
|
+
candidates: list[str] = []
|
|
102
|
+
seen_hosts: set[str] = set()
|
|
103
|
+
for hubs in hubs_by_asn.values():
|
|
104
|
+
for hub in hubs:
|
|
105
|
+
host = hub.get("host") or ""
|
|
106
|
+
if not host or host == "???":
|
|
107
|
+
continue
|
|
108
|
+
if _is_private(host):
|
|
109
|
+
continue
|
|
110
|
+
if host not in seen_hosts:
|
|
111
|
+
seen_hosts.add(host)
|
|
112
|
+
candidates.append(host)
|
|
113
|
+
|
|
114
|
+
if not candidates:
|
|
115
|
+
console.print(
|
|
116
|
+
Panel(
|
|
117
|
+
" All hops are unresolvable or private IPs — globe skipped.",
|
|
118
|
+
title="[bold yellow]Globe[/bold yellow]",
|
|
119
|
+
border_style="yellow",
|
|
120
|
+
expand=False,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
geo = _geolocate(candidates)
|
|
127
|
+
except RuntimeError as e:
|
|
128
|
+
console.print(
|
|
129
|
+
Panel(
|
|
130
|
+
f" Geolocation failed: {e}\n Terminal probe results are unaffected.",
|
|
131
|
+
title="[bold yellow]Globe warning[/bold yellow]",
|
|
132
|
+
border_style="yellow",
|
|
133
|
+
expand=False,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if not geo:
|
|
139
|
+
console.print(
|
|
140
|
+
Panel(
|
|
141
|
+
" No hops could be geolocated — globe skipped.",
|
|
142
|
+
title="[bold yellow]Globe[/bold yellow]",
|
|
143
|
+
border_style="yellow",
|
|
144
|
+
expand=False,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
points: list[dict] = []
|
|
150
|
+
arcs: list[dict] = []
|
|
151
|
+
seen_pts: set[tuple[float, float]] = set()
|
|
152
|
+
|
|
153
|
+
for asn_str, hubs in hubs_by_asn.items():
|
|
154
|
+
geo_hops: list[dict] = []
|
|
155
|
+
for hub in hubs:
|
|
156
|
+
host = hub.get("host") or ""
|
|
157
|
+
if host and geo.get(host):
|
|
158
|
+
geo_hops.append({
|
|
159
|
+
"lat": geo[host]["lat"],
|
|
160
|
+
"lon": geo[host]["lon"],
|
|
161
|
+
"count": hub.get("count", 0),
|
|
162
|
+
"asn": hub.get("ASN") or asn_str,
|
|
163
|
+
"avg_ms": float(hub.get("Avg") or 0),
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
for hop in geo_hops:
|
|
167
|
+
key = (hop["lat"], hop["lon"])
|
|
168
|
+
if key not in seen_pts:
|
|
169
|
+
seen_pts.add(key)
|
|
170
|
+
points.append({
|
|
171
|
+
"lat": hop["lat"],
|
|
172
|
+
"lon": hop["lon"],
|
|
173
|
+
"label": f"Hop {hop['count']} · {hop['asn']}",
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
for i in range(1, len(geo_hops)):
|
|
177
|
+
prev, curr = geo_hops[i - 1], geo_hops[i]
|
|
178
|
+
delta = curr["avg_ms"] - prev["avg_ms"]
|
|
179
|
+
arcs.append({
|
|
180
|
+
"sLat": prev["lat"],
|
|
181
|
+
"sLon": prev["lon"],
|
|
182
|
+
"eLat": curr["lat"],
|
|
183
|
+
"eLon": curr["lon"],
|
|
184
|
+
"color": _arc_color(delta),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
if not points:
|
|
188
|
+
console.print(
|
|
189
|
+
Panel(
|
|
190
|
+
" No hops could be geolocated — globe skipped.",
|
|
191
|
+
title="[bold yellow]Globe[/bold yellow]",
|
|
192
|
+
border_style="yellow",
|
|
193
|
+
expand=False,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
html = _build_html(points, arcs)
|
|
199
|
+
try:
|
|
200
|
+
out = Path(tempfile.mkdtemp()) / "netpath-globe.html"
|
|
201
|
+
out.write_text(html, encoding="utf-8")
|
|
202
|
+
if not webbrowser.open(out.as_uri()):
|
|
203
|
+
console.print(f" [dim]Globe saved — open manually: {out}[/dim]")
|
|
204
|
+
except Exception as e:
|
|
205
|
+
console.print(f" [yellow]Globe write failed: {e}[/yellow]")
|
netpath/iperf.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def available() -> bool:
|
|
7
|
+
return shutil.which("iperf3") is not None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_bidirectional(host: str, port: int = 5201, duration: int = 5) -> tuple[dict, dict]:
|
|
11
|
+
"""
|
|
12
|
+
Run iperf3 upload then download to host:port.
|
|
13
|
+
Returns (upload_stats, download_stats) in the format display expects.
|
|
14
|
+
Raises RuntimeError on failure.
|
|
15
|
+
"""
|
|
16
|
+
ul_raw = _run(host, port, duration, reverse=False)
|
|
17
|
+
dl_raw = _run(host, port, duration, reverse=True)
|
|
18
|
+
return _extract(ul_raw, reverse=False), _extract(dl_raw, reverse=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run(host: str, port: int, duration: int, reverse: bool) -> dict:
|
|
22
|
+
cmd = ["iperf3", "-c", host, "-p", str(port), "-t", str(duration), "-J"]
|
|
23
|
+
if reverse:
|
|
24
|
+
cmd.append("-R")
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=duration + 30)
|
|
28
|
+
except subprocess.TimeoutExpired:
|
|
29
|
+
raise RuntimeError("iperf3 timed out")
|
|
30
|
+
|
|
31
|
+
raw = r.stdout or r.stderr
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(raw)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
raise RuntimeError((r.stderr or r.stdout).strip() or "iperf3 failed (no output)")
|
|
36
|
+
|
|
37
|
+
if r.returncode != 0:
|
|
38
|
+
err = data.get("error", "")
|
|
39
|
+
raise RuntimeError(err or (r.stderr.strip() or "iperf3 exited non-zero"))
|
|
40
|
+
|
|
41
|
+
return data
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _extract(data: dict, reverse: bool) -> dict:
|
|
45
|
+
end = data.get("end", {})
|
|
46
|
+
if reverse:
|
|
47
|
+
s = end.get("sum_received", end.get("sum", {}))
|
|
48
|
+
return {
|
|
49
|
+
"bps": s.get("bits_per_second", 0),
|
|
50
|
+
"recv_bps": s.get("bits_per_second", 0),
|
|
51
|
+
"bytes": s.get("bytes", 0),
|
|
52
|
+
}
|
|
53
|
+
else:
|
|
54
|
+
s = end.get("sum_sent", end.get("sum", {}))
|
|
55
|
+
return {
|
|
56
|
+
"bps": s.get("bits_per_second", 0),
|
|
57
|
+
"bytes": s.get("bytes", 0),
|
|
58
|
+
"retransmits": s.get("retransmits"),
|
|
59
|
+
}
|
netpath/mtr.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import statistics
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from .asn import cymru_bulk_lookup
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _percentile(sorted_data: list, p: float) -> float:
|
|
12
|
+
"""Nearest-rank percentile from a pre-sorted list. p is 0–100."""
|
|
13
|
+
n = len(sorted_data)
|
|
14
|
+
if n == 0:
|
|
15
|
+
return 0.0
|
|
16
|
+
idx = min(math.ceil(p / 100.0 * n) - 1, n - 1)
|
|
17
|
+
return sorted_data[max(0, idx)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _enrich_percentiles(hub: dict) -> None:
|
|
21
|
+
"""Add p50, p95, p99 to a hub dict using Avg+z*StDev estimation."""
|
|
22
|
+
if hub.get("Loss%", 0.0) >= 100.0:
|
|
23
|
+
hub["p50"] = None
|
|
24
|
+
hub["p95"] = None
|
|
25
|
+
hub["p99"] = None
|
|
26
|
+
return
|
|
27
|
+
avg = hub.get("Avg", 0.0)
|
|
28
|
+
std = hub.get("StDev", 0.0)
|
|
29
|
+
hub["p50"] = round(avg, 2)
|
|
30
|
+
hub["p95"] = round(avg + 1.645 * std, 2)
|
|
31
|
+
hub["p99"] = round(avg + 2.326 * std, 2)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def available() -> bool:
|
|
35
|
+
return shutil.which("mtr") is not None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_SOCKET_ERR_MARKERS = ("failure to open", "operation not permitted", "permission denied")
|
|
39
|
+
_SUID_REFUSED = "should not run suid"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MtrPermissionError(RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run(host: str, cycles: int = 10) -> list[dict]:
|
|
47
|
+
"""
|
|
48
|
+
Run mtr in JSON report mode. Raises MtrPermissionError on raw socket
|
|
49
|
+
denial so the caller can fall back to traceroute.
|
|
50
|
+
"""
|
|
51
|
+
cmd = ["mtr", "--json", "--report", f"--report-cycles={cycles}", "--aslookup", host]
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=cycles * 4 + 30)
|
|
55
|
+
except subprocess.TimeoutExpired:
|
|
56
|
+
raise RuntimeError("mtr timed out")
|
|
57
|
+
|
|
58
|
+
if result.returncode != 0:
|
|
59
|
+
stderr_lower = result.stderr.strip().lower()
|
|
60
|
+
if _SUID_REFUSED in stderr_lower or any(m in stderr_lower for m in _SOCKET_ERR_MARKERS):
|
|
61
|
+
raise MtrPermissionError(result.stderr.strip())
|
|
62
|
+
raise RuntimeError(result.stderr.strip() or "mtr exited non-zero")
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads(result.stdout)
|
|
66
|
+
hubs = data["report"]["hubs"]
|
|
67
|
+
for hub in hubs:
|
|
68
|
+
_enrich_percentiles(hub)
|
|
69
|
+
return hubs
|
|
70
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
71
|
+
raise RuntimeError(f"Failed to parse mtr output: {e}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── traceroute fallback ───────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def _parse_traceroute_output(output: str) -> list[dict]:
|
|
77
|
+
hubs = []
|
|
78
|
+
for line in output.splitlines():
|
|
79
|
+
line = line.strip()
|
|
80
|
+
if not line or not line[0].isdigit():
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
m = re.match(r'^(\d+)\s+(.*)', line)
|
|
84
|
+
if not m:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
hop_num = int(m.group(1))
|
|
88
|
+
rest = m.group(2).strip()
|
|
89
|
+
|
|
90
|
+
if re.match(r'^[\*\s]+$', rest):
|
|
91
|
+
hubs.append({
|
|
92
|
+
"count": hop_num, "host": "???", "ASN": "AS???",
|
|
93
|
+
"Loss%": 100.0, "Avg": 0.0, "Best": 0.0, "Wrst": 0.0, "StDev": 0.0,
|
|
94
|
+
"p50": None, "p95": None, "p99": None,
|
|
95
|
+
})
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
tokens = rest.split()
|
|
99
|
+
host = tokens[0]
|
|
100
|
+
|
|
101
|
+
rtts = []
|
|
102
|
+
stars = 0
|
|
103
|
+
i = 1
|
|
104
|
+
while i < len(tokens):
|
|
105
|
+
if tokens[i] == "*":
|
|
106
|
+
stars += 1
|
|
107
|
+
i += 1
|
|
108
|
+
elif i + 1 < len(tokens) and tokens[i + 1] == "ms":
|
|
109
|
+
try:
|
|
110
|
+
rtts.append(float(tokens[i]))
|
|
111
|
+
except ValueError:
|
|
112
|
+
pass
|
|
113
|
+
i += 2
|
|
114
|
+
else:
|
|
115
|
+
i += 1
|
|
116
|
+
|
|
117
|
+
total = len(rtts) + stars
|
|
118
|
+
loss_pct = (stars / total * 100.0) if total > 0 else 0.0
|
|
119
|
+
|
|
120
|
+
if not rtts:
|
|
121
|
+
hubs.append({
|
|
122
|
+
"count": hop_num, "host": host, "ASN": "AS???",
|
|
123
|
+
"Loss%": 100.0, "Avg": 0.0, "Best": 0.0, "Wrst": 0.0, "StDev": 0.0,
|
|
124
|
+
"p50": None, "p95": None, "p99": None,
|
|
125
|
+
})
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
avg = sum(rtts) / len(rtts)
|
|
129
|
+
sorted_rtts = sorted(rtts)
|
|
130
|
+
hubs.append({
|
|
131
|
+
"count": hop_num,
|
|
132
|
+
"host": host,
|
|
133
|
+
"ASN": "AS???",
|
|
134
|
+
"Loss%": loss_pct,
|
|
135
|
+
"Avg": round(avg, 2),
|
|
136
|
+
"Best": round(min(rtts), 2),
|
|
137
|
+
"Wrst": round(max(rtts), 2),
|
|
138
|
+
"StDev": round(statistics.stdev(rtts) if len(rtts) > 1 else 0.0, 2),
|
|
139
|
+
"p50": round(_percentile(sorted_rtts, 50), 2),
|
|
140
|
+
"p95": round(_percentile(sorted_rtts, 95), 2),
|
|
141
|
+
"p99": round(_percentile(sorted_rtts, 99), 2),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
return hubs
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _all_stars(hubs: list[dict]) -> bool:
|
|
148
|
+
return bool(hubs) and all(h["host"] == "???" for h in hubs)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_traceroute_cmd(host: str, tcp: bool = False) -> list[dict]:
|
|
152
|
+
"""
|
|
153
|
+
Run one traceroute pass.
|
|
154
|
+
Parameters tuned for fast failure: 1s wait, 15 hops, 2 probes → 30s worst case.
|
|
155
|
+
tcp=True uses TCP SYN to port 443 (requires pcap — may fail on macOS without privs).
|
|
156
|
+
"""
|
|
157
|
+
cmd = ["/usr/sbin/traceroute", "-n", "-w", "1", "-m", "15", "-q", "2"]
|
|
158
|
+
if tcp:
|
|
159
|
+
cmd += ["-P", "tcp", "-p", "443"]
|
|
160
|
+
cmd.append(host)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
164
|
+
except subprocess.TimeoutExpired:
|
|
165
|
+
raise RuntimeError("traceroute timed out")
|
|
166
|
+
|
|
167
|
+
if result.returncode != 0:
|
|
168
|
+
raise RuntimeError(result.stderr.strip() or "traceroute failed")
|
|
169
|
+
|
|
170
|
+
return _parse_traceroute_output(result.stdout)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run_traceroute(host: str, probes: int = 5) -> list[dict]:
|
|
174
|
+
"""
|
|
175
|
+
Fallback when mtr lacks raw socket access.
|
|
176
|
+
Tries UDP first; if all hops are filtered, retries with TCP SYN (port 443).
|
|
177
|
+
TCP requires pcap — if that also fails (common on macOS), returns the
|
|
178
|
+
filtered UDP result rather than raising an error.
|
|
179
|
+
"""
|
|
180
|
+
hubs = _run_traceroute_cmd(host, tcp=False)
|
|
181
|
+
|
|
182
|
+
if _all_stars(hubs):
|
|
183
|
+
try:
|
|
184
|
+
tcp_hubs = _run_traceroute_cmd(host, tcp=True)
|
|
185
|
+
if not _all_stars(tcp_hubs):
|
|
186
|
+
hubs = tcp_hubs
|
|
187
|
+
except RuntimeError:
|
|
188
|
+
pass # pcap unavailable or path still filtered — keep UDP result
|
|
189
|
+
|
|
190
|
+
ips = [h["host"] for h in hubs if h["host"] != "???"]
|
|
191
|
+
if ips:
|
|
192
|
+
ip_asn = cymru_bulk_lookup(ips)
|
|
193
|
+
for hub in hubs:
|
|
194
|
+
if hub["host"] != "???":
|
|
195
|
+
hub["ASN"] = ip_asn.get(hub["host"], "AS???")
|
|
196
|
+
|
|
197
|
+
return hubs
|
netpath/rum.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
CF_API_BASE = "https://api.cloudflare.com/client/v4/radar"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fetch_asn_quality(asn: str, token: str, date_range: str = "7d") -> dict | None:
|
|
7
|
+
"""
|
|
8
|
+
Fetch Cloudflare Radar speed + latency summary for an ASN.
|
|
9
|
+
Token needs the radar:read permission (free Cloudflare account).
|
|
10
|
+
Returns a flat dict of metrics, or None on failure.
|
|
11
|
+
"""
|
|
12
|
+
asn_num = asn.lstrip("ASas")
|
|
13
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
14
|
+
params = {"asn": asn_num, "dateRange": date_range}
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
resp = requests.get(
|
|
18
|
+
f"{CF_API_BASE}/quality/speed/summary",
|
|
19
|
+
params=params,
|
|
20
|
+
headers=headers,
|
|
21
|
+
timeout=10,
|
|
22
|
+
)
|
|
23
|
+
if resp.status_code == 401:
|
|
24
|
+
raise ValueError("Cloudflare token invalid or missing radar:read permission")
|
|
25
|
+
resp.raise_for_status()
|
|
26
|
+
data = resp.json()
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise
|
|
29
|
+
except Exception:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
if not data.get("success"):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
# The summary lives under result.summary_0
|
|
36
|
+
summary = data.get("result", {}).get("summary_0", {})
|
|
37
|
+
if not summary:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def _f(key: str) -> float | None:
|
|
41
|
+
v = summary.get(key)
|
|
42
|
+
try:
|
|
43
|
+
return float(v) if v is not None else None
|
|
44
|
+
except (TypeError, ValueError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"dl_mbps": _f("bandwidthDownload"),
|
|
49
|
+
"ul_mbps": _f("bandwidthUpload"),
|
|
50
|
+
"latency_idle": _f("latencyIdle"),
|
|
51
|
+
"latency_loaded": _f("latencyLoaded"),
|
|
52
|
+
"jitter": _f("jitter"),
|
|
53
|
+
"packet_loss": _f("packetLoss"),
|
|
54
|
+
"date_range": date_range,
|
|
55
|
+
}
|
netpath/servers.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from .asn import resolve_hosts_parallel, cymru_bulk_lookup, normalize_asn
|
|
3
|
+
|
|
4
|
+
SERVERS_URL = "https://export.iperf3serverlist.net/listed_iperf3_servers.json"
|
|
5
|
+
|
|
6
|
+
# Module-level cache so the country command doesn't re-fetch + re-resolve for each ASN
|
|
7
|
+
_resolved_cache: list[dict] | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_port(port_str: str | None) -> int:
|
|
11
|
+
if not port_str:
|
|
12
|
+
return 5201
|
|
13
|
+
try:
|
|
14
|
+
return int(str(port_str).split("-")[0].split(",")[0].strip())
|
|
15
|
+
except (ValueError, AttributeError):
|
|
16
|
+
return 5201
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _fetch_and_resolve() -> list[dict]:
|
|
20
|
+
"""
|
|
21
|
+
Fetch the server list, resolve all hostnames, and bulk-lookup ASNs.
|
|
22
|
+
Result is cached for the lifetime of the process.
|
|
23
|
+
"""
|
|
24
|
+
global _resolved_cache
|
|
25
|
+
if _resolved_cache is not None:
|
|
26
|
+
return _resolved_cache
|
|
27
|
+
|
|
28
|
+
resp = requests.get(SERVERS_URL, timeout=15)
|
|
29
|
+
resp.raise_for_status()
|
|
30
|
+
raw = resp.json()
|
|
31
|
+
if isinstance(raw, dict):
|
|
32
|
+
for key in ("servers", "data", "results"):
|
|
33
|
+
if key in raw:
|
|
34
|
+
raw = raw[key]
|
|
35
|
+
break
|
|
36
|
+
|
|
37
|
+
seen: set[str] = set()
|
|
38
|
+
unique: list[dict] = []
|
|
39
|
+
for s in raw:
|
|
40
|
+
host = (s.get("IP/HOST") or "").strip()
|
|
41
|
+
if host and host not in seen:
|
|
42
|
+
seen.add(host)
|
|
43
|
+
unique.append({**s, "HOST": host})
|
|
44
|
+
|
|
45
|
+
host_to_ip = resolve_hosts_parallel([s["HOST"] for s in unique])
|
|
46
|
+
ip_to_asn = cymru_bulk_lookup(list(set(host_to_ip.values())))
|
|
47
|
+
|
|
48
|
+
enriched = []
|
|
49
|
+
for s in unique:
|
|
50
|
+
ip = host_to_ip.get(s["HOST"])
|
|
51
|
+
if not ip:
|
|
52
|
+
continue
|
|
53
|
+
enriched.append({
|
|
54
|
+
**s,
|
|
55
|
+
"ip": ip,
|
|
56
|
+
"asn": ip_to_asn.get(ip, "AS???"),
|
|
57
|
+
"port": parse_port(s.get("PORT")),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
_resolved_cache = enriched
|
|
61
|
+
return enriched
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def find_servers_in_asn(asn: str, max_count: int = 3) -> list[dict]:
|
|
65
|
+
"""Return up to max_count iperf3 servers in the given ASN."""
|
|
66
|
+
target = normalize_asn(asn)
|
|
67
|
+
all_servers = _fetch_and_resolve()
|
|
68
|
+
found = [s for s in all_servers if s["asn"] == target]
|
|
69
|
+
return found[:max_count] if max_count > 0 else found
|
netpath/speedtest.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP-based throughput measurement — no dedicated server required.
|
|
3
|
+
|
|
4
|
+
Downloads from / uploads to speed.cloudflare.com, which has CDN nodes
|
|
5
|
+
peered inside every major ISP globally. The measured throughput reflects
|
|
6
|
+
the path from this host to the nearest Cloudflare PoP, which is typically
|
|
7
|
+
inside the local ISP's network.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import time
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
CF_DOWN_URL = "https://speed.cloudflare.com/__down"
|
|
15
|
+
CF_UP_URL = "https://speed.cloudflare.com/__up"
|
|
16
|
+
|
|
17
|
+
# Payload sizes: ramp up to saturate the link quickly
|
|
18
|
+
_DOWN_BYTES = 25_000_000 # 25 MB download probe
|
|
19
|
+
_UP_BYTES = 10_000_000 # 10 MB upload probe
|
|
20
|
+
_TIMEOUT = 30 # seconds per direction
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _download(duration: int = 5) -> dict:
|
|
24
|
+
"""Stream a download from Cloudflare and return throughput stats."""
|
|
25
|
+
start = time.monotonic()
|
|
26
|
+
total = 0
|
|
27
|
+
first_byte: float | None = None
|
|
28
|
+
|
|
29
|
+
with requests.get(
|
|
30
|
+
CF_DOWN_URL,
|
|
31
|
+
params={"bytes": _DOWN_BYTES},
|
|
32
|
+
stream=True,
|
|
33
|
+
timeout=_TIMEOUT,
|
|
34
|
+
) as resp:
|
|
35
|
+
resp.raise_for_status()
|
|
36
|
+
deadline = start + duration
|
|
37
|
+
for chunk in resp.iter_content(chunk_size=65_536):
|
|
38
|
+
if first_byte is None:
|
|
39
|
+
first_byte = time.monotonic()
|
|
40
|
+
total += len(chunk)
|
|
41
|
+
if time.monotonic() >= deadline:
|
|
42
|
+
break
|
|
43
|
+
|
|
44
|
+
elapsed = time.monotonic() - start
|
|
45
|
+
ttfb_ms = (first_byte - start) * 1000 if first_byte else None
|
|
46
|
+
return {
|
|
47
|
+
"bps": (total * 8) / elapsed if elapsed > 0 else 0,
|
|
48
|
+
"bytes": total,
|
|
49
|
+
"elapsed": round(elapsed, 2),
|
|
50
|
+
"ttfb_ms": round(ttfb_ms, 1) if ttfb_ms else None,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _upload(duration: int = 5) -> dict:
|
|
55
|
+
"""POST a zero-filled payload to Cloudflare and return throughput stats."""
|
|
56
|
+
payload_size = min(_UP_BYTES, duration * 20_000_000) # ~20 MB/s cap on payload
|
|
57
|
+
start = time.monotonic()
|
|
58
|
+
|
|
59
|
+
resp = requests.post(
|
|
60
|
+
CF_UP_URL,
|
|
61
|
+
data=bytes(payload_size),
|
|
62
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
63
|
+
timeout=_TIMEOUT,
|
|
64
|
+
)
|
|
65
|
+
resp.raise_for_status()
|
|
66
|
+
|
|
67
|
+
elapsed = time.monotonic() - start
|
|
68
|
+
return {
|
|
69
|
+
"bps": (payload_size * 8) / elapsed if elapsed > 0 else 0,
|
|
70
|
+
"bytes": payload_size,
|
|
71
|
+
"elapsed": round(elapsed, 2),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run(duration: int = 5) -> dict:
|
|
76
|
+
"""
|
|
77
|
+
Run download + upload speed test against Cloudflare.
|
|
78
|
+
Returns {"download": {...}, "upload": {...}, "server": "speed.cloudflare.com"}.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
download = _download(duration)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
raise RuntimeError(f"Download test failed: {e}")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
upload = _upload(duration)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
raise RuntimeError(f"Upload test failed: {e}")
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"download": download,
|
|
92
|
+
"upload": upload,
|
|
93
|
+
"server": "speed.cloudflare.com",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_stats(result: dict) -> tuple[dict, dict]:
|
|
98
|
+
"""Return (upload_stats, download_stats) in the format display expects."""
|
|
99
|
+
dl = result["download"]
|
|
100
|
+
ul = result["upload"]
|
|
101
|
+
upload_stats = {
|
|
102
|
+
"bps": ul["bps"],
|
|
103
|
+
"bytes": ul["bytes"],
|
|
104
|
+
"retransmits": None,
|
|
105
|
+
}
|
|
106
|
+
download_stats = {
|
|
107
|
+
"bps": dl["bps"],
|
|
108
|
+
"recv_bps": dl["bps"],
|
|
109
|
+
"bytes": dl["bytes"],
|
|
110
|
+
"ttfb_ms": dl.get("ttfb_ms"),
|
|
111
|
+
}
|
|
112
|
+
return upload_stats, download_stats
|