feat: japan node ranking and leftover route detection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e60873b75a
commit
18b8ee1dd7
4 files changed
+168
No files matched your search
@@ -0,0 +1,40 @@
|
|||||||
|
"""Japan node filtering and ping ranking."""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
JAPAN_PREFIX = "JP "
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Node:
|
||||||
|
sid: int
|
||||||
|
label: str
|
||||||
|
rtt: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
def rtts_from_response(resp: dict) -> dict:
|
||||||
|
"""Map sid -> average RTT (ms) from /do/rtt/staticnodes, skipping failed probes.
|
||||||
|
|
||||||
|
While Mudfish is mid-round the endpoint answers {"status": 300} without "rtts".
|
||||||
|
"""
|
||||||
|
return {r["sid"]: float(r["rttavg"]) for r in resp.get("rtts", []) if r.get("success", 0) > 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _label(location: str) -> str:
|
||||||
|
start, end = location.find("("), location.rfind(")")
|
||||||
|
if start == -1 or end <= start:
|
||||||
|
return location
|
||||||
|
return location[start + 1:end]
|
||||||
|
|
||||||
|
|
||||||
|
def japan_nodes(static_nodes: list, rtts: dict) -> list:
|
||||||
|
"""Japan nodes sorted by ping; unmeasured nodes go last, by label."""
|
||||||
|
nodes = [Node(n["sid"], _label(n["location"]), rtts.get(n["sid"]))
|
||||||
|
for n in static_nodes if n["location"].startswith(JAPAN_PREFIX)]
|
||||||
|
measured = sorted((n for n in nodes if n.rtt is not None), key=lambda n: n.rtt)
|
||||||
|
unmeasured = sorted((n for n in nodes if n.rtt is None), key=lambda n: n.label)
|
||||||
|
return measured + unmeasured
|
||||||
|
|
||||||
|
|
||||||
|
def pick_auto(nodes: list) -> Optional[Node]:
|
||||||
|
return next((n for n in nodes if n.rtt is not None), None)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Detect and remove the per-node host routes Mudfish leaves behind after it stops."""
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def host_routes(route_text: str) -> set:
|
||||||
|
"""Host routes (no prefix length, no proto, with a gateway) from `ip -4 route` output."""
|
||||||
|
routes = set()
|
||||||
|
for line in route_text.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if not parts or "/" in parts[0] or parts[0] == "default":
|
||||||
|
continue
|
||||||
|
if "proto" in parts or "via" not in parts:
|
||||||
|
continue
|
||||||
|
routes.add(" ".join(parts))
|
||||||
|
return routes
|
||||||
|
|
||||||
|
|
||||||
|
def leftover_routes(before: Optional[set], after_text: str) -> list:
|
||||||
|
"""Host routes added since `before`, excluding tunnel routes. No snapshot means delete nothing."""
|
||||||
|
if before is None:
|
||||||
|
return []
|
||||||
|
added = host_routes(after_text) - set(before)
|
||||||
|
return sorted(r for r in added if " dev tap" not in f" {r}")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_routes(lines: list, run=subprocess.run) -> int:
|
||||||
|
deleted = 0
|
||||||
|
for line in lines:
|
||||||
|
try:
|
||||||
|
result = run(["ip", "route", "del", *line.split()], capture_output=True, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if result.returncode == 0:
|
||||||
|
deleted += 1
|
||||||
|
return deleted
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from mudfish_decky.nodes import japan_nodes, pick_auto, rtts_from_response
|
||||||
|
|
||||||
|
STATIC = [
|
||||||
|
{"sid": 9, "location": "JP Asia (Tokyo - Linode3 IDC)"},
|
||||||
|
{"sid": 11, "location": "JP Asia (Osaka - SakuraNet 4)"},
|
||||||
|
{"sid": 46, "location": "JP Asia (Hokkaido - SakuraNet 6)"},
|
||||||
|
{"sid": 300, "location": "US West (Los Angeles - HostUS)"},
|
||||||
|
]
|
||||||
|
RESP = {"status": 200, "rtts": [
|
||||||
|
{"sid": 9, "rttavg": 38.5, "success": 4, "failure": 0},
|
||||||
|
{"sid": 11, "rttavg": 31.0, "success": 4, "failure": 0},
|
||||||
|
{"sid": 46, "rttavg": 0.0, "success": 0, "failure": 4},
|
||||||
|
{"sid": 300, "rttavg": 5.0, "success": 4, "failure": 0},
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
class NodesTest(unittest.TestCase):
|
||||||
|
def test_rtts_skip_failed(self):
|
||||||
|
self.assertEqual(rtts_from_response(RESP), {9: 38.5, 11: 31.0, 300: 5.0})
|
||||||
|
|
||||||
|
def test_rtts_empty_while_measuring(self):
|
||||||
|
self.assertEqual(rtts_from_response({"status": 300, "n_checked": 11}), {})
|
||||||
|
|
||||||
|
def test_japan_only_sorted_by_rtt_unmeasured_last(self):
|
||||||
|
nodes = japan_nodes(STATIC, rtts_from_response(RESP))
|
||||||
|
self.assertEqual([n.sid for n in nodes], [11, 9, 46])
|
||||||
|
self.assertEqual(nodes[0].label, "Osaka - SakuraNet 4")
|
||||||
|
self.assertIsNone(nodes[2].rtt)
|
||||||
|
|
||||||
|
def test_pick_auto_lowest(self):
|
||||||
|
self.assertEqual(pick_auto(japan_nodes(STATIC, rtts_from_response(RESP))).sid, 11)
|
||||||
|
|
||||||
|
def test_pick_auto_none_without_rtts(self):
|
||||||
|
self.assertIsNone(pick_auto(japan_nodes(STATIC, {})))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from mudfish_decky.routes import delete_routes, host_routes, leftover_routes
|
||||||
|
|
||||||
|
BEFORE = """default via 192.168.1.1 dev wlan0 proto dhcp src 192.168.1.50 metric 600
|
||||||
|
192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.50 metric 600
|
||||||
|
"""
|
||||||
|
AFTER = BEFORE + """1.201.174.247 via 192.168.1.1 dev wlan0
|
||||||
|
2.58.243.110 via 192.168.1.1 dev wlan0
|
||||||
|
10.254.100.5 via 10.254.100.1 dev tap0
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class RoutesTest(unittest.TestCase):
|
||||||
|
def test_host_routes_excludes_default_prefix_and_proto(self):
|
||||||
|
self.assertEqual(host_routes(BEFORE), set())
|
||||||
|
|
||||||
|
def test_host_routes_normalizes_trailing_space(self):
|
||||||
|
self.assertEqual(host_routes("1.2.3.4 via 192.168.1.1 dev wlan0 \n"),
|
||||||
|
{"1.2.3.4 via 192.168.1.1 dev wlan0"})
|
||||||
|
|
||||||
|
def test_leftover_routes_only_new_non_tap(self):
|
||||||
|
self.assertEqual(leftover_routes(host_routes(BEFORE), AFTER),
|
||||||
|
["1.201.174.247 via 192.168.1.1 dev wlan0", "2.58.243.110 via 192.168.1.1 dev wlan0"])
|
||||||
|
|
||||||
|
def test_leftover_routes_none_snapshot_deletes_nothing(self):
|
||||||
|
self.assertEqual(leftover_routes(None, AFTER), [])
|
||||||
|
|
||||||
|
def test_leftover_routes_keeps_preexisting_host_routes(self):
|
||||||
|
before = host_routes(AFTER.replace("2.58.243.110", "9.9.9.9"))
|
||||||
|
self.assertNotIn("1.201.174.247 via 192.168.1.1 dev wlan0", leftover_routes(before, AFTER))
|
||||||
|
|
||||||
|
def test_delete_routes_runs_ip_route_del(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def run(cmd, **kw):
|
||||||
|
calls.append(cmd)
|
||||||
|
return types.SimpleNamespace(returncode=0)
|
||||||
|
|
||||||
|
self.assertEqual(delete_routes(["1.2.3.4 via 192.168.1.1 dev wlan0"], run=run), 1)
|
||||||
|
self.assertEqual(calls, [["ip", "route", "del", "1.2.3.4", "via", "192.168.1.1", "dev", "wlan0"]])
|
||||||
|
|
||||||
|
def test_delete_routes_counts_failures_as_not_deleted(self):
|
||||||
|
def run(cmd, **kw):
|
||||||
|
raise OSError("boom")
|
||||||
|
|
||||||
|
self.assertEqual(delete_routes(["1.2.3.4 via 192.168.1.1 dev wlan0"], run=run), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user