feat: mudfish client and connect/disconnect controller

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ysj7705andClaude Opus 5.5 committed 2026-10-24 20:47:21 +09:00
1 parent 18b8ee1dd7
commit 4e27e29225
7 files changed
+884

No files matched your search

+127
View File
@@ -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"})
+256
View File
@@ -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
+28
View File
@@ -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
+73
View File
@@ -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
+98
View File
@@ -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()
+246
View File
@@ -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()
+56
View File
@@ -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()