diff --git a/py_modules/mudfish_decky/nodes.py b/py_modules/mudfish_decky/nodes.py new file mode 100644 index 0000000..f86a0f7 --- /dev/null +++ b/py_modules/mudfish_decky/nodes.py @@ -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) diff --git a/py_modules/mudfish_decky/routes.py b/py_modules/mudfish_decky/routes.py new file mode 100644 index 0000000..b22c339 --- /dev/null +++ b/py_modules/mudfish_decky/routes.py @@ -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 diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 0000000..7baed72 --- /dev/null +++ b/tests/test_nodes.py @@ -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() diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..cafeb21 --- /dev/null +++ b/tests/test_routes.py @@ -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()