diff --git a/py_modules/mudfish_decky/client.py b/py_modules/mudfish_decky/client.py new file mode 100644 index 0000000..56ec7a9 --- /dev/null +++ b/py_modules/mudfish_decky/client.py @@ -0,0 +1,127 @@ +"""HTTP calls to the local Mudfish launcher API and the Mudfish cloud API.""" +import json +import urllib.error +import urllib.parse +import urllib.request +import uuid +from typing import Callable, Dict, Optional, Tuple + +from .nodes import rtts_from_response + +Transport = Callable[[str, str, Optional[bytes], Dict[str, str]], Tuple[int, bytes]] + +PROG_CONF_FIELDS = [ + "mudfishCreditSavingMode", "mudfishFullvpnSid", "mudfishFullvpnVid", "mudfishFullvpnXp", + "mudfishFullvpnExceptions", "mudfishMtu", "mudfishPreferWfp", "mudfishProtomethod", + "mudfishReBrokerMaxconns", "mudflowCompatv1", "mudflowRttmethod", +] + + +class MudfishError(Exception): + pass + + +class SigninError(MudfishError): + pass + + +def urllib_transport(method, url, body, headers): + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + +def _multipart(fields: dict) -> Tuple[bytes, str]: + boundary = uuid.uuid4().hex + parts = "".join(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n' + for k, v in fields.items()) + return (parts + f"--{boundary}--\r\n").encode(), "multipart/form-data; boundary=" + boundary + + +class MudfishClient: + def __init__(self, base_url: str, transport: Transport = urllib_transport): + self.base_url = base_url + self.transport = transport + + def _call(self, method, url, body=None, headers=None) -> bytes: + code, data = self.transport(method, url, body, headers or {}) + if not 200 <= code < 300: + raise MudfishError(f"HTTP {code} from {url}") + return data + + def _json(self, method, url, body=None, headers=None) -> dict: + data = self._call(method, url, body, headers) + try: + return json.loads(data) + except ValueError: + raise MudfishError(f"invalid response from {url}") + + def _post_json(self, url, payload, headers=None) -> dict: + return self._json("POST", url, json.dumps(payload).encode(), + {"Content-Type": "application/json", **(headers or {})}) + + def status(self) -> dict: + return self._json("GET", self.base_url + "/do/mudrun/status") + + def ensure_signed_in(self) -> dict: + """Replay the launcher's remember-me autologin when the daemon has no session.""" + st = self.status() + if st.get("isSignedIn"): + return st + if not st.get("autologin") and not st.get("token_v3"): + raise SigninError("no saved login") + body, ctype = _multipart({ + "autologin": "ON", "username": st["username"], "password": st["token_v3"], + "item_conf_mode": st["item_conf_mode"], "version": st["version"], + }) + signin = self._json("POST", f"https://{st['api_server']}/signin_ng_v3", body, {"Content-Type": ctype}) + if signin.get("status") != 200: + raise SigninError(f"signin status {signin.get('status')}") + auth = self._post_json(self.base_url + "/do/auth", { + "username": st["username"], "password": st["token_v3"], "remember_me": True, + "token_v3": signin["token_v3"], "jwt": signin["jwt"], "expert_mode": signin.get("expert_mode"), + "lang": signin.get("lang"), "alias_enable": signin.get("alias_enable"), + }) + if auth.get("status") != 200: + raise SigninError(f"auth status {auth.get('status')}") + return self.status() + + def start(self) -> None: + resp = self._json("POST", self.base_url + "/do/mudfish/start", b"") + if resp.get("status") not in (200, 201): + raise MudfishError(f"start status {resp.get('status')}") + + def stop(self) -> None: + resp = self._json("POST", self.base_url + "/do/mudfish/stop", b"") + if resp.get("status") != 200: + raise MudfishError(f"stop status {resp.get('status')}") + + def rtts(self) -> dict: + return rtts_from_response(self._json("GET", self.base_url + "/do/rtt/staticnodes")) + + def _graphql_user(self, jwt, api, fields) -> dict: + resp = self._post_json(f"https://{api}/graphql", {"query": "{ user { %s } }" % fields}, {"Authorization": jwt}) + try: + return resp["data"]["user"] + except (KeyError, TypeError): + raise MudfishError("graphql returned no user") + + def static_nodes(self, jwt, api) -> list: + return self._graphql_user(jwt, api, "staticNodes { sid location }")["staticNodes"] + + def prog_conf(self, jwt, api) -> dict: + return self._graphql_user(jwt, api, "progConf { %s }" % " ".join(PROG_CONF_FIELDS))["progConf"] + + def save_prog_conf(self, jwt, api, conf: dict) -> dict: + resp = self._post_json(f"https://{api}/prog", conf, {"Authorization": jwt}) + if resp.get("status") != 200: + raise MudfishError(resp.get("msg") or f"prog status {resp.get('status')}") + return resp + + def fullvpn_refresh(self, sid: int, exceptions: str) -> None: + body = urllib.parse.urlencode({"sid": sid, "exceptions": exceptions}).encode() + self._call("POST", self.base_url + "/do/fullvpn/refresh", body, + {"Content-Type": "application/x-www-form-urlencoded"}) diff --git a/py_modules/mudfish_decky/controller.py b/py_modules/mudfish_decky/controller.py new file mode 100644 index 0000000..993d360 --- /dev/null +++ b/py_modules/mudfish_decky/controller.py @@ -0,0 +1,256 @@ +"""Connect/disconnect orchestration and the state contract the panel renders. + +The core tunnel is only ever started from connect() (or re-applied by set_node() while already on); +state() and plugin load must never start it, because Full VPN traffic is billed per GB. +""" +import dataclasses +import logging +import threading +import time +from typing import Optional + +from .client import SigninError +from .nodes import Node, japan_nodes, pick_auto +from .routes import host_routes, leftover_routes + +MSG_RELOGIN = "데스크탑 모드에서 미꾸라지(127.0.0.1:8282)에 '로그인 기억하기'로 다시 로그인하세요" +MSG_RTT = "노드 핑을 측정하는 중이에요. 잠시 후 다시 켜 주세요" +MSG_TIMEOUT = "30초 안에 연결되지 않아 연결을 취소했어요" +MSG_MISSING = "데스크탑 모드에서 미꾸라지를 설치하세요 (docs.mudfish.net Steam Deck 가이드)" + +CONNECT_TIMEOUT = 30 +DISCONNECT_TIMEOUT = 10 +PUBLIC_IP_TTL = 30 + +log = logging.getLogger("mudfish_decky") + + +class Controller: + def __init__(self, client, system, store, clock=time.time, sleep=time.sleep): + self.client = client + self.system = system + self.store = store + self.clock = clock + self.sleep = sleep + self._lock = threading.Lock() + self._error: Optional[str] = None + self._static: Optional[list] = None + self._ip_cache = (0.0, None) + + # ---- public API ------------------------------------------------------------------------- + + def state(self) -> dict: + with self._lock: + return self._state() + + def nodes(self) -> list: + with self._lock: + self._refresh_rtts() + return [dataclasses.asdict(n) for n in self._jp_nodes()] + + def connect(self) -> dict: + with self._lock: + self._error = None + installed = self.system.find_mudfish() + if not installed: + return self._state() + try: + st = self._signed_in(installed) + if self._is_on(st): + return self._state() + self._refresh_rtts() + node = self._target_node() + if node is None: + return self._fail(MSG_RTT) + self._apply_sid(st, node.sid) + self.store.update(routes_before=sorted(host_routes(self.system.route_text())), active_sid=node.sid) + self.client.start() + if not self._wait(lambda: self._is_on(self.client.status()), CONNECT_TIMEOUT): + self.client.stop() + return self._fail(MSG_TIMEOUT) + self.store.update(connected_since=self.clock()) + except SigninError: + return self._fail(MSG_RELOGIN) + except Exception as e: + log.exception("connect failed") + return self._fail(f"연결 실패: {e}") + return self._state() + + def disconnect(self) -> dict: + with self._lock: + self._error = None + try: + self.client.stop() + self._wait(lambda: not self.system.tap_up(), DISCONNECT_TIMEOUT) + except Exception as e: + log.exception("disconnect failed") + return self._fail(f"해제 실패: {e}") + before = self.store.load().get("routes_before") + lines = leftover_routes(set(before) if before is not None else None, self.system.route_text()) + if lines: + log.info("removing %d leftover node routes", self.system.delete_routes(lines)) + self.store.update(routes_before=None, connected_since=None, active_sid=None) + return self._state() + + def set_node(self, choice: str) -> dict: + with self._lock: + self._error = None + if choice == "auto": + self.store.update(node_mode="auto", manual_sid=None) + else: + self.store.update(node_mode="manual", manual_sid=int(choice)) + try: + st = self.client.status() + if self._is_on(st): + self._reapply(st) + except SigninError: + return self._fail(MSG_RELOGIN) + except Exception as e: + log.exception("node change failed") + return self._fail(f"노드 변경 실패: {e}") + return self._state() + + # ---- internals ---------------------------------------------------------------------------- + + def _fail(self, message: str) -> dict: + self._error = message + return self._state() + + def _wait(self, cond, seconds) -> bool: + for _ in range(seconds): + if cond(): + return True + self.sleep(1) + return cond() + + def _is_on(self, st: dict) -> bool: + return bool(st.get("core_running") and st.get("client_ready") and self.system.tap_up()) + + def _signed_in(self, installed) -> dict: + self.system.ensure_daemon(installed[0], self._daemon_up) + return self.client.ensure_signed_in() + + def _daemon_up(self) -> bool: + try: + self.client.status() + return True + except Exception: + return False + + def _refresh_rtts(self) -> dict: + try: + rtts = self.client.rtts() + except Exception: + rtts = {} + if rtts: + self.store.update(rtts={str(k): v for k, v in rtts.items()}) + return rtts + return {int(k): v for k, v in (self.store.load().get("rtts") or {}).items()} + + def _cached_rtts(self) -> dict: + return {int(k): v for k, v in (self.store.load().get("rtts") or {}).items()} + + def _static_nodes(self, st: Optional[dict] = None) -> list: + if self._static is None: + saved = self.store.load().get("static_nodes") + if saved: + self._static = saved + elif st and st.get("isSignedIn") and st.get("jwt"): + self._static = self.client.static_nodes(st["jwt"], st["api_server"]) + self.store.update(static_nodes=self._static) + return self._static or [] + + def _jp_nodes(self, st: Optional[dict] = None) -> list: + if st is None: + try: + st = self.client.status() + except Exception: + st = None + return japan_nodes(self._static_nodes(st), self._cached_rtts()) + + def _find(self, sid: int, nodes: list) -> Node: + return next((n for n in nodes if n.sid == sid), Node(sid, f"#{sid}", None)) + + def _target_node(self) -> Optional[Node]: + data = self.store.load() + nodes = self._jp_nodes() + if data.get("node_mode") == "manual" and data.get("manual_sid"): + return self._find(int(data["manual_sid"]), nodes) + return pick_auto(nodes) + + def _apply_sid(self, st: dict, sid: int) -> dict: + """Point the account's Full VPN node at sid (the cloud value, not status, is authoritative).""" + conf = self.client.prog_conf(st["jwt"], st["api_server"]) + if conf.get("mudfishFullvpnSid") == sid and not conf.get("mudfishFullvpnVid"): + return {"need_restart": False, "conf": conf, "changed": False} + conf.update(mudfishFullvpnSid=sid, mudfishFullvpnVid=0) + resp = self.client.save_prog_conf(st["jwt"], st["api_server"], conf) + return {"need_restart": bool(resp.get("need_restart")), "conf": conf, "changed": True} + + def _reapply(self, st: dict) -> None: + node = self._target_node() + if node is None: + return + st = self.client.ensure_signed_in() + result = self._apply_sid(st, node.sid) + self.store.update(active_sid=node.sid) + if not result["changed"]: + return + if result["need_restart"]: + self.client.stop() + self._wait(lambda: not self.system.tap_up(), DISCONNECT_TIMEOUT) + self.client.start() + if not self._wait(lambda: self._is_on(self.client.status()), CONNECT_TIMEOUT): + self.client.stop() + raise RuntimeError(MSG_TIMEOUT) + else: + self.client.fullvpn_refresh(node.sid, result["conf"].get("mudfishFullvpnExceptions") or "") + self._ip_cache = (0.0, None) + + def _public_ip(self) -> Optional[dict]: + at, value = self._ip_cache + if value is None or self.clock() - at > PUBLIC_IP_TTL: + value = self.system.public_ip() + self._ip_cache = (self.clock(), value) + return value + + def _state(self) -> dict: + data = self.store.load() + out = {"phase": "off", "message": None, "node_mode": data.get("node_mode") or "auto", "node": None, + "ip": None, "country": None, "connected_since": None} + if not self.system.find_mudfish(): + out.update(phase="missing", message=MSG_MISSING) + return out + try: + st = self.client.status() + except Exception: + st = None + if st: + self._refresh_rtts() + on = bool(st) and self._is_on(st) + if on: + since = data.get("connected_since") + if since is None: + since = self.clock() + self.store.update(connected_since=since) + ip = self._public_ip() or {} + out.update(phase="on", connected_since=since, ip=ip.get("ip"), country=ip.get("country")) + elif st and st.get("core_running"): + out["phase"] = "connecting" + else: + if data.get("connected_since") is not None: + self.store.update(connected_since=None) + self._ip_cache = (0.0, None) + if self._error and not on: + out.update(phase="error", message=self._error) + + nodes = self._jp_nodes(st) + active = self.store.load().get("active_sid") + if on and active: + node = self._find(int(active), nodes) + elif out["node_mode"] == "manual" and data.get("manual_sid"): + node = self._find(int(data["manual_sid"]), nodes) + else: + node = pick_auto(nodes) + out["node"] = dataclasses.asdict(node) if node else None + return out diff --git a/py_modules/mudfish_decky/store.py b/py_modules/mudfish_decky/store.py new file mode 100644 index 0000000..2efef66 --- /dev/null +++ b/py_modules/mudfish_decky/store.py @@ -0,0 +1,28 @@ +"""Plugin settings persisted as JSON.""" +import json +import os + + +class Store: + def __init__(self, path: str): + self.path = path + + def load(self) -> dict: + try: + with open(self.path) as f: + return json.load(f) + except (OSError, ValueError): + return {} + + def save(self, data: dict) -> None: + os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) + tmp = self.path + ".tmp" + with open(tmp, "w") as f: + json.dump(data, f) + os.replace(tmp, self.path) + + def update(self, **changes) -> dict: + data = self.load() + data.update(changes) + self.save(data) + return data diff --git a/py_modules/mudfish_decky/system.py b/py_modules/mudfish_decky/system.py new file mode 100644 index 0000000..9a0ff0c --- /dev/null +++ b/py_modules/mudfish_decky/system.py @@ -0,0 +1,73 @@ +"""Steam Deck side effects: locating Mudfish, running its daemon, reading routes and the public IP.""" +import json +import os +import subprocess +import time +import urllib.request +from typing import Callable, Optional, Tuple + +from .routes import delete_routes # noqa: F401 (re-exported for the controller) + +MUDFISH_ROOT = "/opt/mudfish" +DAEMON_UNIT = "mudfish-decky" +DEFAULT_API = "http://127.0.0.1:8282" + + +def _version_key(name: str): + try: + return tuple(int(p) for p in name.split(".")) + except ValueError: + return None + + +def find_mudfish(root: str = MUDFISH_ROOT) -> Optional[Tuple[str, str]]: + """(daemon binary, var dir) of the highest installed Mudfish version.""" + if not os.path.isdir(root): + return None + versions = [] + for name in os.listdir(root): + key = _version_key(name) + binary = os.path.join(root, name, "bin", "mudrun-headless") + if key is not None and os.path.isfile(binary): + versions.append((key, name)) + if not versions: + return None + name = max(versions)[1] + return os.path.join(root, name, "bin", "mudrun-headless"), os.path.join(root, name, "var") + + +def api_base(var_dir: str) -> str: + try: + with open(os.path.join(var_dir, ".ipport")) as f: + ipport = f.read().strip() + except OSError: + return DEFAULT_API + return f"http://{ipport}" if ipport else DEFAULT_API + + +def ensure_daemon(bin_path: str, is_up: Callable[[], bool], run=subprocess.run, sleep=time.sleep) -> None: + """Start the launcher as a transient systemd unit so it outlives desktop/game mode switches.""" + if is_up(): + return + run(["systemd-run", f"--unit={DAEMON_UNIT}", "--collect", bin_path, "-B"], capture_output=True, timeout=10) + for _ in range(20): + sleep(0.5) + if is_up(): + return + + +def tap_up() -> bool: + return os.path.exists("/sys/class/net/tap0") + + +def route_text(run=subprocess.run) -> str: + return run(["ip", "-4", "route"], capture_output=True, text=True, timeout=5).stdout + + +def public_ip() -> Optional[dict]: + try: + with urllib.request.urlopen("https://ipinfo.io/json", timeout=8) as resp: + data = json.load(resp) + return {"ip": data.get("ip"), "country": data.get("country")} + except Exception: + return None diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..c6f9be1 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,98 @@ +import json +import unittest + +from mudfish_decky.client import MudfishClient, MudfishError, SigninError + +BASE = "http://127.0.0.1:8282" +SIGNED_OUT = {"status": 200, "isSignedIn": False, "username": "u", "token_v3": "123", + "item_conf_mode": "simple", "version": "6.6.0", "api_server": "api.mudfish.net", "jwt": ""} +SIGNIN_OK = {"status": 200, "token_v3": "456", "jwt": "JWT", "expert_mode": 0, "lang": "ko_KR", "alias_enable": 0} + + +class FakeTransport: + def __init__(self, routes): + self.routes = routes # (method, url) -> list of response dicts/bytes, consumed in order + self.calls = [] + + def __call__(self, method, url, body, headers): + self.calls.append((method, url, body, headers)) + queue = self.routes[(method, url)] + resp = queue.pop(0) if len(queue) > 1 else queue[0] + return 200, resp if isinstance(resp, bytes) else json.dumps(resp).encode() + + def calls_to(self, method, url): + return [c for c in self.calls if c[0] == method and c[1] == url] + + +class ClientTest(unittest.TestCase): + def test_ensure_signed_in_noop_when_signed(self): + t = FakeTransport({("GET", BASE + "/do/mudrun/status"): [dict(SIGNED_OUT, isSignedIn=True)]}) + MudfishClient(BASE, t).ensure_signed_in() + self.assertEqual(t.calls_to("POST", "https://api.mudfish.net/signin_ng_v3"), []) + + def test_ensure_signed_in_replays_autologin(self): + t = FakeTransport({ + ("GET", BASE + "/do/mudrun/status"): [SIGNED_OUT, dict(SIGNED_OUT, isSignedIn=True, jwt="JWT")], + ("POST", "https://api.mudfish.net/signin_ng_v3"): [SIGNIN_OK], + ("POST", BASE + "/do/auth"): [{"status": 200, "msg": "OK"}], + }) + st = MudfishClient(BASE, t).ensure_signed_in() + self.assertTrue(st["isSignedIn"]) + (_, _, body, headers), = t.calls_to("POST", "https://api.mudfish.net/signin_ng_v3") + self.assertIn("multipart/form-data; boundary=", headers["Content-Type"]) + text = body.decode() + self.assertIn('name="autologin"\r\n\r\nON\r\n', text) + self.assertIn('name="password"\r\n\r\n123\r\n', text) + (_, _, auth_body, _), = t.calls_to("POST", BASE + "/do/auth") + auth = json.loads(auth_body) + self.assertEqual(auth["password"], "123") + self.assertIs(auth["remember_me"], True) + self.assertEqual(auth["jwt"], "JWT") + self.assertEqual(auth["token_v3"], "456") + + def test_ensure_signed_in_raises_on_401(self): + t = FakeTransport({ + ("GET", BASE + "/do/mudrun/status"): [SIGNED_OUT], + ("POST", "https://api.mudfish.net/signin_ng_v3"): [{"status": 401}], + }) + with self.assertRaises(SigninError): + MudfishClient(BASE, t).ensure_signed_in() + + def test_start_accepts_201(self): + t = FakeTransport({("POST", BASE + "/do/mudfish/start"): [{"status": 201}]}) + MudfishClient(BASE, t).start() + + def test_start_raises_on_500(self): + t = FakeTransport({("POST", BASE + "/do/mudfish/start"): [{"status": 500}]}) + with self.assertRaises(MudfishError): + MudfishClient(BASE, t).start() + + def test_rtts_while_measuring_is_empty(self): + t = FakeTransport({("GET", BASE + "/do/rtt/staticnodes"): [{"status": 300, "n_checked": 3}]}) + self.assertEqual(MudfishClient(BASE, t).rtts(), {}) + + def test_save_prog_conf_sends_authorization_header(self): + t = FakeTransport({("POST", "https://api.mudfish.net/prog"): [{"status": 200, "need_restart": False}]}) + resp = MudfishClient(BASE, t).save_prog_conf("JWT", "api.mudfish.net", {"mudfishFullvpnSid": 11}) + self.assertFalse(resp["need_restart"]) + (_, _, body, headers), = t.calls + self.assertEqual(headers["Authorization"], "JWT") + self.assertEqual(json.loads(body), {"mudfishFullvpnSid": 11}) + + def test_prog_conf_reads_graphql(self): + conf = {"mudfishFullvpnSid": 0, "mudfishFullvpnExceptions": ""} + t = FakeTransport({("POST", "https://api.mudfish.net/graphql"): + [{"data": {"user": {"progConf": conf}}}]}) + self.assertEqual(MudfishClient(BASE, t).prog_conf("JWT", "api.mudfish.net"), conf) + self.assertIn("mudfishFullvpnExceptions", json.loads(t.calls[0][2])["query"]) + + def test_fullvpn_refresh_posts_form_and_accepts_empty_body(self): + t = FakeTransport({("POST", BASE + "/do/fullvpn/refresh"): [b""]}) + MudfishClient(BASE, t).fullvpn_refresh(504, "") + (_, _, body, headers), = t.calls + self.assertEqual(headers["Content-Type"], "application/x-www-form-urlencoded") + self.assertEqual(body, b"sid=504&exceptions=") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..0952361 --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,246 @@ +import os +import tempfile +import unittest + +from mudfish_decky.client import SigninError +from mudfish_decky.controller import MSG_MISSING, MSG_RELOGIN, MSG_RTT, MSG_TIMEOUT, Controller +from mudfish_decky.store import Store +from tests.test_nodes import STATIC + +BEFORE = "default via 192.168.1.1 dev wlan0 proto dhcp src 192.168.1.50 metric 600\n" +NEW_ROUTES = "1.201.174.247 via 192.168.1.1 dev wlan0\n2.58.243.110 via 192.168.1.1 dev wlan0\n" + + +class FakeSystem: + def __init__(self): + self.installed = True + self.tap = False + self.routes = BEFORE + self.deleted = [] + self.daemon_calls = 0 + self.ip = {"ip": "13.73.234.162", "country": "JP"} + + def find_mudfish(self): + return ("/opt/mudfish/6.6.0/bin/mudrun-headless", "/opt/mudfish/6.6.0/var") if self.installed else None + + def ensure_daemon(self, bin_path, is_up): + self.daemon_calls += 1 + + def tap_up(self): + return self.tap + + def route_text(self): + return self.routes + + def delete_routes(self, lines): + self.deleted.extend(lines) + return len(lines) + + def public_ip(self): + return self.ip + + +class FakeClient: + def __init__(self, system): + self.system = system + self.st = {"status": 200, "isSignedIn": True, "core_running": False, "core_ready": False, + "client_ready": False, "jwt": "JWT", "api_server": "api.mudfish.net"} + self.signin_error = None + self.rtt_map = {9: 38.5, 11: 31.0} + self.conf = {"mudfishFullvpnSid": 0, "mudfishFullvpnVid": 0, "mudfishFullvpnExceptions": "", "mudfishMtu": 1350} + self.saved = [] + self.start_calls = 0 + self.stop_calls = 0 + self.refresh_calls = [] + self.need_restart = True + self.ready_on_start = True + + def status(self): + return dict(self.st) + + def ensure_signed_in(self): + if self.signin_error: + raise self.signin_error + return dict(self.st) + + def start(self): + self.start_calls += 1 + self.st["core_running"] = True + if self.ready_on_start: + self.st.update(core_ready=True, client_ready=True) + self.system.tap = True + self.system.routes = BEFORE + NEW_ROUTES + + def stop(self): + self.stop_calls += 1 + self.st.update(core_running=False, core_ready=False, client_ready=False) + self.system.tap = False + + def rtts(self): + return dict(self.rtt_map) + + def static_nodes(self, jwt, api): + return STATIC + + def prog_conf(self, jwt, api): + return dict(self.conf) + + def save_prog_conf(self, jwt, api, conf): + self.saved.append(dict(conf)) + self.conf = dict(conf) + return {"status": 200, "need_restart": self.need_restart} + + def fullvpn_refresh(self, sid, exceptions): + self.refresh_calls.append((sid, exceptions)) + + +class ControllerTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = os.path.join(self.tmp.name, "settings.json") + self.system = FakeSystem() + self.client = FakeClient(self.system) + self.now = 1000.0 + + def tearDown(self): + self.tmp.cleanup() + + def make(self): + return Controller(self.client, self.system, Store(self.path), clock=lambda: self.now, sleep=lambda s: None) + + def test_state_missing_when_not_installed(self): + self.system.installed = False + st = self.make().state() + self.assertEqual(st["phase"], "missing") + self.assertEqual(st["message"], MSG_MISSING) + + def test_state_off_never_starts_core(self): + st = self.make().state() + self.assertEqual(st["phase"], "off") + self.assertEqual(self.client.start_calls, 0) + + def test_state_off_shows_auto_pick(self): + st = self.make().state() + self.assertEqual(st["node_mode"], "auto") + self.assertEqual(st["node"], {"sid": 11, "label": "Osaka - SakuraNet 4", "rtt": 31.0}) + + def test_connect_auto_picks_lowest_and_saves_sid(self): + st = self.make().connect() + self.assertEqual(self.client.conf["mudfishFullvpnSid"], 11) + self.assertEqual(self.client.conf["mudfishFullvpnVid"], 0) + self.assertEqual(self.client.conf["mudfishMtu"], 1350) + self.assertEqual(st["phase"], "on") + self.assertEqual(st["connected_since"], 1000.0) + self.assertEqual((st["ip"], st["country"]), ("13.73.234.162", "JP")) + + def test_connect_skips_save_when_sid_unchanged(self): + self.client.conf["mudfishFullvpnSid"] = 11 + self.make().connect() + self.assertEqual(self.client.saved, []) + + def test_connect_auto_without_rtts_does_not_start(self): + self.client.rtt_map = {} + st = self.make().connect() + self.assertEqual(st["phase"], "error") + self.assertEqual(st["message"], MSG_RTT) + self.assertEqual(self.client.start_calls, 0) + + def test_connect_uses_cached_rtts_when_measuring(self): + c = self.make() + c.state() # caches rtts + self.client.rtt_map = {} # mid-round: endpoint has no rtts + self.assertEqual(self.make().connect()["phase"], "on") + + def test_connect_signin_failure_shows_relogin(self): + self.client.signin_error = SigninError("401") + st = self.make().connect() + self.assertEqual(st["phase"], "error") + self.assertEqual(st["message"], MSG_RELOGIN) + self.assertEqual(self.client.start_calls, 0) + + def test_connect_timeout_stops_core(self): + self.client.ready_on_start = False + st = self.make().connect() + self.assertEqual(self.client.stop_calls, 1) + self.assertEqual(st["phase"], "error") + self.assertEqual(st["message"], MSG_TIMEOUT) + + def test_connect_manual_uses_saved_sid(self): + c = self.make() + c.set_node("9") + c.connect() + self.assertEqual(self.client.conf["mudfishFullvpnSid"], 9) + + def test_disconnect_deletes_only_new_routes_and_keeps_sid(self): + c = self.make() + c.connect() + saves = len(self.client.saved) + st = c.disconnect() + self.assertEqual(self.system.deleted, + ["1.201.174.247 via 192.168.1.1 dev wlan0", "2.58.243.110 via 192.168.1.1 dev wlan0"]) + self.assertEqual(len(self.client.saved), saves) + self.assertEqual(self.client.conf["mudfishFullvpnSid"], 11) + self.assertEqual(st["phase"], "off") + self.assertIsNone(Store(self.path).load().get("connected_since")) + + def test_disconnect_without_snapshot_deletes_nothing(self): + self.client.st.update(core_running=True, client_ready=True) + self.system.tap = True + self.system.routes = BEFORE + NEW_ROUTES + self.make().disconnect() + self.assertEqual(self.system.deleted, []) + + def test_state_on_survives_restart_uses_saved_since(self): + Store(self.path).save({"connected_since": 100.0}) + self.client.st.update(core_running=True, core_ready=True, client_ready=True) + self.system.tap = True + st = self.make().state() + self.assertEqual(st["phase"], "on") + self.assertEqual(st["connected_since"], 100.0) + self.assertEqual(self.client.start_calls, 0) + + def test_state_on_without_saved_since_starts_clock_now(self): + self.client.st.update(core_running=True, core_ready=True, client_ready=True) + self.system.tap = True + self.assertEqual(self.make().state()["connected_since"], 1000.0) + + def test_state_connecting_when_core_running_not_ready(self): + self.client.st.update(core_running=True) + self.assertEqual(self.make().state()["phase"], "connecting") + + def test_set_node_while_off_only_saves_choice(self): + st = self.make().set_node("9") + self.assertEqual(self.client.saved, []) + self.assertEqual(Store(self.path).load()["manual_sid"], 9) + self.assertEqual(st["node_mode"], "manual") + self.assertEqual(st["node"]["sid"], 9) + + def test_set_node_while_on_refreshes(self): + c = self.make() + c.connect() + self.client.need_restart = False + st = c.set_node("9") + self.assertEqual(self.client.refresh_calls, [(9, "")]) + self.assertEqual(self.client.start_calls, 1) + self.assertEqual(st["node"]["sid"], 9) + + def test_set_node_while_on_restarts_when_needed(self): + c = self.make() + c.connect() + self.client.need_restart = True + c.set_node("9") + self.assertEqual((self.client.stop_calls, self.client.start_calls), (1, 2)) + + def test_set_node_back_to_auto(self): + c = self.make() + c.set_node("9") + st = c.set_node("auto") + self.assertEqual(st["node_mode"], "auto") + self.assertEqual(st["node"]["sid"], 11) + + def test_nodes_lists_japan_by_ping(self): + self.assertEqual([n["sid"] for n in self.make().nodes()], [11, 9, 46]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_system.py b/tests/test_system.py new file mode 100644 index 0000000..b29a0d1 --- /dev/null +++ b/tests/test_system.py @@ -0,0 +1,56 @@ +import os +import tempfile +import types +import unittest + +from mudfish_decky.system import api_base, ensure_daemon, find_mudfish + + +def make_install(root, version): + os.makedirs(os.path.join(root, version, "bin")) + os.makedirs(os.path.join(root, version, "var")) + open(os.path.join(root, version, "bin", "mudrun-headless"), "w").close() + + +class SystemTest(unittest.TestCase): + def test_find_mudfish_picks_highest_version(self): + with tempfile.TemporaryDirectory() as root: + for v in ["5.11.0", "6.6.0", "6.10.1"]: + make_install(root, v) + os.makedirs(os.path.join(root, "6.99.0")) # no binary: ignored + self.assertEqual(find_mudfish(root), + (os.path.join(root, "6.10.1", "bin", "mudrun-headless"), os.path.join(root, "6.10.1", "var"))) + + def test_find_mudfish_none_when_missing(self): + with tempfile.TemporaryDirectory() as root: + self.assertIsNone(find_mudfish(os.path.join(root, "nope"))) + + def test_api_base_reads_ipport(self): + with tempfile.TemporaryDirectory() as var: + with open(os.path.join(var, ".ipport"), "w") as f: + f.write("127.0.0.1:9123") + self.assertEqual(api_base(var), "http://127.0.0.1:9123") + + def test_api_base_default(self): + with tempfile.TemporaryDirectory() as var: + self.assertEqual(api_base(var), "http://127.0.0.1:8282") + + def test_ensure_daemon_noop_when_up(self): + calls = [] + ensure_daemon("/bin/x", lambda: True, run=lambda cmd, **kw: calls.append(cmd)) + self.assertEqual(calls, []) + + def test_ensure_daemon_starts_transient_unit(self): + calls, ups = [], iter([False, False, True]) + + def run(cmd, **kw): + calls.append(cmd) + return types.SimpleNamespace(returncode=0) + + ensure_daemon("/opt/mudfish/6.6.0/bin/mudrun-headless", lambda: next(ups), run=run, sleep=lambda s: None) + self.assertEqual(calls, [["systemd-run", "--unit=mudfish-decky", "--collect", + "/opt/mudfish/6.6.0/bin/mudrun-headless", "-B"]]) + + +if __name__ == "__main__": + unittest.main()