feat: mudfish client and connect/disconnect controller
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
18b8ee1dd7
commit
4e27e29225
7 files changed
+884
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user