feat: install, update and remove Mudfish from the panel

- Install: download the latest package from mudfish.net, verify the
  Makeself MD5, unpack the bzip2 tar ourselves (no installer script is
  executed; path traversal and links rejected) into /opt/mudfish/<ver>.
- Update: checked at most daily; button-only; refused while the tunnel is
  up; carries the saved login (.conf, .conf.db) into the new version,
  restarts the daemon and keeps only the previous version.
- Remove: button with confirmation, and automatically when the plugin is
  uninstalled: VPN off, daemon stopped, mudfish-tap removed,
  /opt/mudfish deleted, plugin settings cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ysj7705andClaude Opus 5.5 committed 2026-10-24 21:58:32 +09:00
1 parent 027a029b47
commit 3c28c9dac2
9 files changed
+718 -27

No files matched your search

+131 -13
View File
@@ -17,7 +17,13 @@ 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 가이드)"
MSG_MISSING = "미꾸라지가 설치되어 있지 않아요. 아래 버튼으로 설치하세요"
MSG_INSTALLED = "미꾸라지 {}를 설치했어요"
MSG_UPDATED = "미꾸라지 {}로 업데이트했어요"
MSG_UPDATE_NEED_OFF = "업데이트하려면 먼저 VPN을 꺼 주세요"
MSG_UP_TO_DATE = "이미 최신 버전이에요"
MSG_NO_RELEASE = "미꾸라지 최신 버전을 찾지 못했어요"
MSG_REMOVED = "미꾸라지를 제거했어요"
MSG_STILL_UP = "터널이 아직 남아 있어요. 끄기를 다시 눌러 주세요"
MSG_NO_RESPONSE = "미꾸라지가 응답하지 않아요. 터널이 남아 있을 수 있으니 끄기를 눌러 주세요"
MSG_SLEEP = "절전에서 깨어나 VPN을 껐어요. 필요하면 다시 켜 주세요"
@@ -29,18 +35,27 @@ DISCONNECT_TIMEOUT = 10
PUBLIC_IP_TTL = 30
RTT_TTL = 600 # cached pings older than this are ignored for the auto pick
RESUME_JUMP = 3.0 # seconds of suspended time that count as a sleep/resume
LATEST_CHECK_INTERVAL = 86400
log = logging.getLogger("mudfish_decky")
def _version_key(version: str):
try:
return tuple(int(p) for p in version.split("."))
except (AttributeError, ValueError):
return ()
class Controller:
def __init__(self, client, system, store, clock=time.time, sleep=time.sleep, relay_factory=None):
def __init__(self, client, system, store, clock=time.time, sleep=time.sleep, relay_factory=None, installer=None):
self.client = client
self.system = system
self.store = store
self.clock = clock
self.sleep = sleep
self.relay_factory = relay_factory
self.installer = installer
self._lock = threading.Lock()
self._error: Optional[str] = None
self._notice: Optional[str] = None
@@ -61,18 +76,118 @@ class Controller:
def prepare(self) -> None:
"""Plugin load: bring up the idle launcher and sign in so the node list is ready. Never connects."""
with self._lock:
self._prepare()
def _prepare(self) -> None:
installed = self.system.find_mudfish()
if not installed:
return
try:
st = self._signed_in(installed)
self._static_nodes(st)
except SigninError as e:
log.warning("prepare: saved login rejected: %s", e)
self._error = MSG_RELOGIN
self._mark_need_login()
except Exception as e:
log.warning("prepare: %s", e)
# ---- Mudfish install / update / uninstall --------------------------------------------------
def refresh_latest(self, force: bool = False) -> None:
"""Look up the newest Mudfish release at most once a day (network; call off the UI path)."""
with self._lock:
self._refresh_latest(force)
def _refresh_latest(self, force: bool = False) -> Optional[str]:
data = self.store.load()
checked = data.get("latest_checked_at")
if not force and checked is not None and self.clock() - checked < LATEST_CHECK_INTERVAL:
return data.get("latest_version")
try:
latest = self.installer.latest_version()
except Exception as e:
log.warning("latest version check failed: %s", e)
return data.get("latest_version")
self.store.update(latest_version=latest, latest_checked_at=self.clock())
return latest
def install(self) -> dict:
with self._lock:
self._error = self._notice = None
if self.system.find_mudfish():
return self._state()
try:
version = self._refresh_latest(force=True)
if not version:
return self._fail(MSG_NO_RELEASE)
version = self.installer.install_package(self.installer.download(version),
self.installer.MUDFISH_ROOT)
log.info("installed mudfish %s", version)
self._start_daemon_and_prepare()
self._notice = MSG_INSTALLED.format(version)
except Exception as e:
log.exception("install failed")
return self._fail(f"설치 실패: {e}")
return self._state()
def update(self) -> dict:
with self._lock:
self._error = self._notice = None
installed = self.system.find_mudfish()
if not installed:
return
return self._state()
if self._tunnel_present():
self._notice = MSG_UPDATE_NEED_OFF
return self._state()
current = self.system.version_of(installed[0])
try:
st = self._signed_in(installed)
self._static_nodes(st)
except SigninError as e:
log.warning("prepare: saved login rejected: %s", e)
self._error = MSG_RELOGIN
self._mark_need_login()
latest = self._refresh_latest(force=True)
if not latest or _version_key(latest) <= _version_key(current):
self._notice = MSG_UP_TO_DATE
return self._state()
version = self.installer.install_package(self.installer.download(latest),
self.installer.MUDFISH_ROOT)
new = self.system.find_mudfish()
self.installer.carry_login(installed[1], new[1])
self.system.stop_daemon()
self._start_daemon_and_prepare()
self.installer.prune_versions(self.installer.MUDFISH_ROOT, keep=[version, current])
log.info("updated mudfish %s -> %s", current, version)
self._notice = MSG_UPDATED.format(version)
except Exception as e:
log.warning("prepare: %s", e)
log.exception("update failed")
return self._fail(f"업데이트 실패: {e}")
return self._state()
def uninstall(self) -> dict:
"""Turn everything off and remove Mudfish and this plugin's saved data. Never raises."""
with self._lock:
self._error = self._notice = None
self._close_relay()
try:
if self._tunnel_present():
self._teardown()
except Exception as e:
log.warning("uninstall: teardown failed: %s", e)
for step in (self.system.stop_daemon, self.system.remove_nm_connection,
lambda: self.installer.remove_all(self.installer.MUDFISH_ROOT)):
try:
step()
except Exception as e:
log.warning("uninstall step failed: %s", e)
self.store.save({})
self._static = None
self._need_login = False
self._failed_jwt = None
log.info("mudfish removed")
self._notice = MSG_REMOVED
return self._state()
def _start_daemon_and_prepare(self) -> None:
installed = self.system.find_mudfish()
self.system.ensure_daemon(installed[0], self.daemon_up)
self._prepare()
def nodes(self) -> list:
with self._lock:
@@ -358,9 +473,12 @@ class Controller:
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, "tunnel": False,
"need_login": False, "login": None}
if not self.system.find_mudfish():
out.update(phase="missing", message=MSG_MISSING)
"need_login": False, "login": None, "mudfish": None}
installed = self.system.find_mudfish()
out["mudfish"] = {"version": self.system.version_of(installed[0]) if installed else None,
"latest": data.get("latest_version")}
if not installed:
out.update(phase="missing", message=self._error or self._notice or MSG_MISSING)
return out
try:
st = self.client.status()
+157
View File
@@ -0,0 +1,157 @@
"""Install, update and remove Mudfish Cloud VPN for Linux without running its installer script.
The official package is a Makeself archive: a shell header of `skip` lines followed by a bzip2 tar
whose MD5 is in the header. On SteamOS the bundled setup script only copies files into
/opt/mudfish/<version>/, so we verify the payload, unpack it ourselves and lay out the same tree.
"""
import hashlib
import io
import os
import re
import shutil
import tarfile
import urllib.request
from .client import ssl_context
MUDFISH_ROOT = "/opt/mudfish"
DOWNLOAD_PAGE = "https://mudfish.net/download"
RELEASE_URL = "https://mudfish.net/releases/mudfish-{version}-linux-x86_64.sh"
VERSION_RE = re.compile(r"^\d+(\.\d+)+$")
RELEASE_RE = re.compile(r"mudfish-(\d+(?:\.\d+)+)-linux-x86_64\.sh")
HEADER_LIMIT = 256 * 1024
LOGIN_FILES = (".conf", ".conf.db")
SKIPPED_FILES = {"pkg_linux_setup.sh"}
LAYOUT = {"bin": 0o755, "sbin": 0o755, "etc": 0o644, "share": 0o644}
class InstallError(Exception):
pass
def _version_key(v: str):
return tuple(int(p) for p in v.split("."))
def _fetch_text(url: str) -> str:
with urllib.request.urlopen(url, timeout=20, context=ssl_context()) as resp:
return resp.read().decode("utf-8", "replace")
def _fetch_bytes(url: str) -> bytes:
with urllib.request.urlopen(url, timeout=120, context=ssl_context()) as resp:
return resp.read()
def latest_version(fetch=_fetch_text):
versions = set(RELEASE_RE.findall(fetch(DOWNLOAD_PAGE)))
return max(versions, key=_version_key) if versions else None
def download(version: str, fetch=_fetch_bytes) -> bytes:
if not VERSION_RE.match(version):
raise InstallError(f"bad version {version!r}")
return fetch(RELEASE_URL.format(version=version))
def parse_makeself(data: bytes):
"""(header fields, payload bytes) of a Makeself archive."""
head = data[:HEADER_LIMIT]
if not head.startswith(b"#!") or b"Makeself" not in head:
raise InstallError("not a Mudfish package")
fields = dict(re.findall(rb'^(\w+)="([^"\n]*)"$', head, re.M))
header = {k.decode(): v.decode() for k, v in fields.items()}
try:
skip = int(header["skip"])
except (KeyError, ValueError):
raise InstallError("package header has no skip")
offset = 0
for _ in range(skip):
offset = data.index(b"\n", offset) + 1
size = header.get("filesizes", "").split()
payload = data[offset:offset + int(size[0])] if size else data[offset:]
return header, payload
def _safe_members(tar: tarfile.TarFile) -> list:
members = []
for m in tar.getmembers():
parts = os.path.normpath(m.name).split(os.sep)
if os.path.isabs(m.name) or ".." in parts:
raise InstallError(f"unsafe path in package: {m.name}")
if not (m.isfile() or m.isdir()):
raise InstallError(f"unexpected entry type in package: {m.name}")
members.append(m)
return members
def _lay_out(staging: str, dest: str) -> None:
os.makedirs(dest)
for sub, mode in LAYOUT.items():
src = os.path.join(staging, sub)
os.makedirs(os.path.join(dest, sub), mode=0o755)
if not os.path.isdir(src):
continue
for name in os.listdir(src):
path = os.path.join(src, name)
if name in SKIPPED_FILES or not os.path.isfile(path):
continue
target = os.path.join(dest, sub, name)
shutil.copyfile(path, target)
os.chmod(target, mode)
os.makedirs(os.path.join(dest, "var"), mode=0o755)
def install_package(data: bytes, root: str = MUDFISH_ROOT) -> str:
"""Verify and install a Mudfish package under root/<version>; returns the version."""
header, payload = parse_makeself(data)
version = header.get("targetdir", "")
if not VERSION_RE.match(version):
raise InstallError(f"bad version in package: {version!r}")
if hashlib.md5(payload).hexdigest() != header.get("MD5", "").lower():
raise InstallError("package checksum mismatch")
os.makedirs(root, exist_ok=True)
staging, fresh = os.path.join(root, f".{version}.unpack"), os.path.join(root, f".{version}.new")
final = os.path.join(root, version)
for d in (staging, fresh):
shutil.rmtree(d, ignore_errors=True)
try:
try:
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:bz2") as tar:
tar.extractall(staging, members=_safe_members(tar), filter="data")
except (tarfile.TarError, EOFError, OSError) as e:
raise InstallError(f"cannot unpack package: {e}")
if not os.path.isfile(os.path.join(staging, "bin", "mudrun-headless")):
raise InstallError("package has no mudrun-headless")
_lay_out(staging, fresh)
if os.path.isdir(final): # reinstall: keep login/state
shutil.rmtree(os.path.join(fresh, "var"))
shutil.move(os.path.join(final, "var"), os.path.join(fresh, "var"))
shutil.rmtree(final)
os.rename(fresh, final)
finally:
shutil.rmtree(staging, ignore_errors=True)
shutil.rmtree(fresh, ignore_errors=True)
return version
def carry_login(old_var: str, new_var: str) -> None:
"""Copy the launcher's saved login/settings into a newly installed version."""
for name in LOGIN_FILES:
src = os.path.join(old_var, name)
if os.path.isfile(src):
shutil.copy2(src, os.path.join(new_var, name))
def prune_versions(root: str, keep: list) -> None:
if not os.path.isdir(root):
return
for name in os.listdir(root):
path = os.path.join(root, name)
if os.path.isdir(path) and name not in keep and (VERSION_RE.match(name) or name.startswith(".")):
shutil.rmtree(path, ignore_errors=True)
def remove_all(root: str = MUDFISH_ROOT) -> None:
if os.path.isdir(root):
shutil.rmtree(root)
+14
View File
@@ -37,6 +37,20 @@ def find_mudfish(root: str = MUDFISH_ROOT) -> Optional[Tuple[str, str]]:
return os.path.join(root, name, "bin", "mudrun-headless"), os.path.join(root, name, "var")
def version_of(bin_path: str) -> str:
"""'/opt/mudfish/6.6.0/bin/mudrun-headless' -> '6.6.0'."""
return os.path.basename(os.path.dirname(os.path.dirname(bin_path)))
def stop_daemon(run=subprocess.run) -> None:
run(["systemctl", "stop", DAEMON_UNIT], capture_output=True, timeout=20)
def remove_nm_connection(run=subprocess.run) -> None:
"""Mudfish registers a NetworkManager connection for tap0; it is normally removed on stop."""
run(["nmcli", "connection", "delete", "mudfish-tap"], capture_output=True, timeout=10)
def api_base(var_dir: str) -> str:
try:
with open(os.path.join(var_dir, ".ipport")) as f: