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,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"})
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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