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