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
|
||||
Reference in new issue
Block a user