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

+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()