From 3c28c9dac21ff1afb901a86b9abcc0599e7d3a55 Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Sat, 24 Oct 2026 21:58:32 +0900 Subject: [PATCH] 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/. - 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 --- main.py | 25 +++- py_modules/mudfish_decky/controller.py | 144 +++++++++++++++++++++-- py_modules/mudfish_decky/installer.py | 157 +++++++++++++++++++++++++ py_modules/mudfish_decky/system.py | 14 +++ src/Panel.tsx | 79 +++++++++++-- src/backend.ts | 16 +++ tests/test_controller.py | 153 +++++++++++++++++++++++- tests/test_installer.py | 144 +++++++++++++++++++++++ tests/test_system.py | 13 ++ 9 files changed, 718 insertions(+), 27 deletions(-) create mode 100644 py_modules/mudfish_decky/installer.py create mode 100644 tests/test_installer.py diff --git a/main.py b/main.py index 9accb87..77ac72a 100644 --- a/main.py +++ b/main.py @@ -8,13 +8,14 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "py_modules")) import decky # noqa: E402 -from mudfish_decky import system # noqa: E402 +from mudfish_decky import installer, system # noqa: E402 from mudfish_decky.client import MudfishClient # noqa: E402 from mudfish_decky.controller import Controller # noqa: E402 from mudfish_decky.relay import LoginRelay # noqa: E402 from mudfish_decky.store import Store # noqa: E402 RESUME_POLL_SECONDS = 2 +LATEST_POLL_TICKS = 1800 # every hour at 2s per tick LOGIN_RELAY_SECONDS = 300 @@ -30,6 +31,7 @@ class Plugin: self.controller = Controller( MudfishClient(base), system, store, relay_factory=lambda: LoginRelay(target=(target.hostname, target.port or 80), ttl=LOGIN_RELAY_SECONDS), + installer=installer, ) return self.controller @@ -39,6 +41,15 @@ class Plugin: async def stop_login(self) -> dict: return await asyncio.to_thread(self._get_controller().stop_login) + async def install_mudfish(self) -> dict: + return await asyncio.to_thread(self._get_controller().install) + + async def update_mudfish(self) -> dict: + return await asyncio.to_thread(self._get_controller().update) + + async def uninstall_mudfish(self) -> dict: + return await asyncio.to_thread(self._get_controller().uninstall) + async def get_state(self) -> dict: return await asyncio.to_thread(self._get_controller().state) @@ -64,13 +75,23 @@ class Plugin: await asyncio.to_thread(controller.prepare) decky.logger.info("Mudfish VPN plugin loaded") # Watch for sleep/resume even while the panel is closed; a resumed tunnel is turned off. + # Also look for a newer Mudfish release now and then (the controller limits it to once a day). + ticks = 0 while True: try: await asyncio.to_thread(controller.check_resume, system.sleep_offset()) + if ticks % LATEST_POLL_TICKS == 0: + await asyncio.to_thread(controller.refresh_latest) except Exception as e: - decky.logger.warning("resume check failed: %s", e) + decky.logger.warning("background check failed: %s", e) + ticks += 1 await asyncio.sleep(RESUME_POLL_SECONDS) async def _unload(self): await asyncio.to_thread(self._get_controller().shutdown) decky.logger.info("Mudfish VPN plugin unloaded") + + async def _uninstall(self): + # Removing the plugin removes Mudfish too (user decision): VPN off, daemon stopped, /opt/mudfish gone. + await asyncio.to_thread(self._get_controller().uninstall) + decky.logger.info("Mudfish removed with the plugin") diff --git a/py_modules/mudfish_decky/controller.py b/py_modules/mudfish_decky/controller.py index 72ce4f6..36a66f8 100644 --- a/py_modules/mudfish_decky/controller.py +++ b/py_modules/mudfish_decky/controller.py @@ -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() diff --git a/py_modules/mudfish_decky/installer.py b/py_modules/mudfish_decky/installer.py new file mode 100644 index 0000000..994bb33 --- /dev/null +++ b/py_modules/mudfish_decky/installer.py @@ -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//, 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/; 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) diff --git a/py_modules/mudfish_decky/system.py b/py_modules/mudfish_decky/system.py index 48211a4..b955c0f 100644 --- a/py_modules/mudfish_decky/system.py +++ b/py_modules/mudfish_decky/system.py @@ -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: diff --git a/src/Panel.tsx b/src/Panel.tsx index 9a5e27b..bb6d77c 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -1,5 +1,6 @@ import { ButtonItem, + ConfirmModal, DropdownItem, Field, PanelSection, @@ -13,7 +14,11 @@ import { connect, disconnect, getState, + installMudfish, + isNewer, listNodes, + uninstallMudfish, + updateMudfish, NodeInfo, Phase, setNode, @@ -134,6 +139,42 @@ export function Panel() { ...nodes.map((n) => ({ data: String(n.sid), label: `${n.label} · ${rttText(n.rtt)}` })), ]; const selected = state.node_mode === "auto" ? "auto" : String(state.node?.sid ?? "auto"); + const version = state.mudfish?.version ?? null; + const latest = state.mudfish?.latest ?? null; + const missing = state.phase === "missing"; + + const confirmUninstall = () => + showModal( + run(uninstallMudfish)} + />, + ); + + if (missing) { + return ( + + + + + + + {state.message && ( + + + + )} + + run(installMudfish)}> + {busy ? "설치 중… (1~2분)" : `미꾸라지 설치${latest ? ` (${latest})` : ""}`} + + + + ); + } return ( @@ -173,15 +214,35 @@ export function Panel() { {advanced && ( - - run(() => setNode(String(opt.data)))} - /> - + <> + + run(() => setNode(String(opt.data)))} + /> + + + + {version ?? "?"} + {latest && !isNewer(latest, version) ? " (최신)" : ""} + + + {isNewer(latest, version) && ( + + run(updateMudfish)}> + {busy ? "업데이트 중…" : `미꾸라지 업데이트 (${version} → ${latest})`} + + + )} + + + 미꾸라지 제거 + + + )} ); diff --git a/src/backend.ts b/src/backend.ts index 4af6e9e..f8a4120 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -19,6 +19,7 @@ export interface VpnState { tunnel: boolean; need_login: boolean; login: { url: string; expires_at: number } | null; + mudfish: { version: string | null; latest: string | null } | null; } export const getState = callable<[], VpnState>("get_state"); @@ -28,3 +29,18 @@ export const setNode = callable<[choice: string], VpnState>("set_node"); export const listNodes = callable<[], NodeInfo[]>("list_nodes"); export const startLogin = callable<[], VpnState>("start_login"); export const stopLogin = callable<[], VpnState>("stop_login"); +export const installMudfish = callable<[], VpnState>("install_mudfish"); +export const updateMudfish = callable<[], VpnState>("update_mudfish"); +export const uninstallMudfish = callable<[], VpnState>("uninstall_mudfish"); + +/** True when version `a` is newer than `b` (dotted numeric versions). */ +export function isNewer(a: string | null | undefined, b: string | null | undefined): boolean { + if (!a || !b) return false; + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d > 0; + } + return false; +} diff --git a/tests/test_controller.py b/tests/test_controller.py index 4a9cf38..7ebce85 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -3,8 +3,10 @@ import tempfile import unittest from mudfish_decky.client import AuthError, SigninError -from mudfish_decky.controller import (MSG_LOGGED_IN, MSG_MISSING, MSG_NO_LAN, MSG_NO_RESPONSE, MSG_RELOGIN, MSG_RTT, - MSG_SLEEP, MSG_STILL_UP, MSG_TIMEOUT, Controller) +from mudfish_decky.controller import (MSG_INSTALLED, MSG_LOGGED_IN, MSG_MISSING, MSG_NO_LAN, MSG_NO_RESPONSE, + MSG_RELOGIN, MSG_REMOVED, MSG_RTT, MSG_SLEEP, MSG_STILL_UP, MSG_TIMEOUT, + MSG_UP_TO_DATE, MSG_UPDATE_NEED_OFF, MSG_UPDATED, Controller) +from mudfish_decky.installer import InstallError from mudfish_decky.store import Store from tests.test_nodes import STATIC @@ -21,15 +23,31 @@ class FakeSystem: self.daemon_calls = 0 self.ip = {"ip": "13.73.234.162", "country": "JP"} self.lan = "192.168.1.50" + self.version = "6.6.0" + self.daemon_bins = [] + self.daemon_stops = 0 + self.nm_removed = 0 def lan_ip(self): return self.lan def find_mudfish(self): - return ("/opt/mudfish/6.6.0/bin/mudrun-headless", "/opt/mudfish/6.6.0/var") if self.installed else None + if not self.installed: + return None + return (f"/opt/mudfish/{self.version}/bin/mudrun-headless", f"/opt/mudfish/{self.version}/var") + + def version_of(self, bin_path): + return bin_path.split("/")[3] def ensure_daemon(self, bin_path, is_up): self.daemon_calls += 1 + self.daemon_bins.append(bin_path) + + def stop_daemon(self): + self.daemon_stops += 1 + + def remove_nm_connection(self): + self.nm_removed += 1 def tap_up(self): return self.tap @@ -45,6 +63,39 @@ class FakeSystem: return self.ip +class FakeInstaller: + MUDFISH_ROOT = "/opt/mudfish" + + def __init__(self, system): + self.system = system + self.latest = "6.7.0" + self.calls = [] + + def latest_version(self): + self.calls.append("latest") + return self.latest + + def download(self, version): + self.calls.append(("download", version)) + return b"pkg" + + def install_package(self, data, root): + self.calls.append(("install", root)) + self.system.installed = True + self.system.version = self.latest + return self.latest + + def carry_login(self, old_var, new_var): + self.calls.append(("carry", old_var, new_var)) + + def prune_versions(self, root, keep): + self.calls.append(("prune", sorted(keep))) + + def remove_all(self, root): + self.calls.append(("remove_all", root)) + self.system.installed = False + + class FakeRelay: token = "tok" expires_at = 1300.0 @@ -436,6 +487,102 @@ class ControllerTest(unittest.TestCase): c.shutdown() self.assertFalse(self.relay.active) + # ---- install / update / uninstall -------------------------------------------------------- + + def make_with_installer(self): + self.installer = FakeInstaller(self.system) + return Controller(self.client, self.system, Store(self.path), clock=lambda: self.now, + sleep=lambda s: None, installer=self.installer) + + def test_state_reports_versions(self): + Store(self.path).save({"latest_version": "6.7.0"}) + st = self.make_with_installer().state() + self.assertEqual(st["mudfish"], {"version": "6.6.0", "latest": "6.7.0"}) + + def test_state_missing_reports_no_version(self): + self.system.installed = False + st = self.make_with_installer().state() + self.assertEqual(st["phase"], "missing") + self.assertIsNone(st["mudfish"]["version"]) + + def test_install_downloads_latest_and_starts_daemon_without_connecting(self): + self.system.installed = False + st = self.make_with_installer().install() + self.assertIn(("download", "6.7.0"), self.installer.calls) + self.assertIn(("install", "/opt/mudfish"), self.installer.calls) + self.assertEqual(self.system.daemon_bins[-1], "/opt/mudfish/6.7.0/bin/mudrun-headless") + self.assertEqual(self.client.start_calls, 0) + self.assertEqual(st["phase"], "off") + self.assertEqual(st["message"], MSG_INSTALLED.format("6.7.0")) + self.assertEqual(st["mudfish"]["version"], "6.7.0") + + def test_install_failure_shows_message(self): + self.system.installed = False + c = self.make_with_installer() + self.installer.install_package = lambda data, root: (_ for _ in ()).throw(InstallError("checksum")) + st = c.install() + self.assertEqual(st["phase"], "missing") + self.assertIn("checksum", st["message"]) + + def test_update_refused_while_tunnel_up(self): + c = self.make_with_installer() + c.connect() + st = c.update() + self.assertEqual(st["message"], MSG_UPDATE_NEED_OFF) + self.assertNotIn(("download", "6.7.0"), self.installer.calls) + self.assertEqual(st["phase"], "on") + + def test_update_carries_login_restarts_daemon_and_prunes(self): + c = self.make_with_installer() + st = c.update() + self.assertIn(("carry", "/opt/mudfish/6.6.0/var", "/opt/mudfish/6.7.0/var"), self.installer.calls) + self.assertEqual(self.system.daemon_stops, 1) + self.assertEqual(self.system.daemon_bins[-1], "/opt/mudfish/6.7.0/bin/mudrun-headless") + self.assertIn(("prune", ["6.6.0", "6.7.0"]), self.installer.calls) + self.assertEqual(self.client.start_calls, 0) + self.assertEqual(st["message"], MSG_UPDATED.format("6.7.0")) + + def test_update_when_already_latest_does_nothing(self): + self.make_with_installer() + self.installer.latest = "6.6.0" + st = Controller(self.client, self.system, Store(self.path), clock=lambda: self.now, + sleep=lambda s: None, installer=self.installer).update() + self.assertEqual(st["message"], MSG_UP_TO_DATE) + self.assertNotIn(("download", "6.6.0"), self.installer.calls) + + def test_refresh_latest_checks_at_most_daily(self): + c = self.make_with_installer() + c.refresh_latest() + c.refresh_latest() + self.assertEqual(self.installer.calls.count("latest"), 1) + self.now += 86401 + c.refresh_latest() + self.assertEqual(self.installer.calls.count("latest"), 2) + self.assertEqual(Store(self.path).load()["latest_version"], "6.7.0") + + def test_uninstall_turns_off_and_removes_everything(self): + c = self.make_with_installer() + self.relay = FakeRelay() + c.relay_factory = lambda: self.relay + c.connect() + c.start_login() + st = c.uninstall() + self.assertEqual(self.client.stop_calls, 1) + self.assertFalse(self.relay.active) + self.assertEqual(self.system.daemon_stops, 1) + self.assertEqual(self.system.nm_removed, 1) + self.assertIn(("remove_all", "/opt/mudfish"), self.installer.calls) + self.assertEqual(Store(self.path).load(), {}) + self.assertEqual(st["phase"], "missing") + self.assertEqual(st["message"], MSG_REMOVED) + + def test_uninstall_when_missing_still_cleans_up(self): + self.system.installed = False + c = self.make_with_installer() + c.uninstall() + self.assertIn(("remove_all", "/opt/mudfish"), self.installer.calls) + self.assertEqual(self.client.start_calls, 0) + # ---- sleep / shutdown -------------------------------------------------------------------- def test_resume_jump_turns_tunnel_off_with_notice(self): diff --git a/tests/test_installer.py b/tests/test_installer.py new file mode 100644 index 0000000..b3a46d9 --- /dev/null +++ b/tests/test_installer.py @@ -0,0 +1,144 @@ +import hashlib +import io +import os +import stat +import tarfile +import tempfile +import unittest + +from mudfish_decky.installer import (InstallError, carry_login, install_package, latest_version, parse_makeself, + prune_versions, remove_all) + +FILES = { + "./bin/mudrun-headless": b"#!bin", + "./bin/mudfish": b"#!bin", + "./bin/pkg_linux_setup.sh": b"#!/bin/sh\nrm -rf /\n", + "./sbin/mudovpn": b"#!sbin", + "./etc/htdocs-www-6.7.0.tar": b"tar", + "./share/mudrun_logo.png": b"png", +} + + +def make_payload(files=FILES, extra=None): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:bz2") as tar: + for name, data in files.items(): + info = tarfile.TarInfo(name) + info.size = len(data) + info.mode = 0o755 + tar.addfile(info, io.BytesIO(data)) + if extra: + extra(tar) + return buf.getvalue() + + +def make_package(payload, version="6.7.0", md5=None, skip=12): + header = [ + "#!/bin/sh", + "# This script was generated using Makeself 2.5.0", + f'MD5="{md5 or hashlib.md5(payload).hexdigest()}"', + 'SHA="0000000000000000000000000000000000000000000000000000000000000000"', + f'label="Mudfish Cloud VPN v{version}"', + 'script="./bin/pkg_linux_setup.sh"', + f'targetdir="{version}"', + f'filesizes="{len(payload)}"', + f'skip="{skip}"', + ] + header += ["# filler"] * (skip - 1 - len(header)) + ["eval $finish; exit $res"] + return ("\n".join(header) + "\n").encode() + payload + + +class ParseTest(unittest.TestCase): + def test_latest_version_picks_highest(self): + html = ('x' + 'y' + 'z') + self.assertEqual(latest_version(fetch=lambda url: html), "6.10.0") + + def test_latest_version_none_when_page_has_none(self): + self.assertIsNone(latest_version(fetch=lambda url: "")) + + def test_parse_makeself(self): + payload = make_payload() + header, body = parse_makeself(make_package(payload)) + self.assertEqual(header["targetdir"], "6.7.0") + self.assertEqual(body, payload) + + def test_parse_rejects_non_makeself(self): + with self.assertRaises(InstallError): + parse_makeself(b"not found") + + +class InstallTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = os.path.join(self.tmp.name, "mudfish") + + def tearDown(self): + self.tmp.cleanup() + + def test_install_package_lays_out_version_dir(self): + self.assertEqual(install_package(make_package(make_payload()), self.root), "6.7.0") + base = os.path.join(self.root, "6.7.0") + binary = os.path.join(base, "bin", "mudrun-headless") + self.assertTrue(os.path.isfile(binary)) + self.assertEqual(stat.S_IMODE(os.stat(binary).st_mode), 0o755) + self.assertEqual(stat.S_IMODE(os.stat(os.path.join(base, "share", "mudrun_logo.png")).st_mode), 0o644) + self.assertTrue(os.path.isdir(os.path.join(base, "var"))) + self.assertFalse(os.path.exists(os.path.join(base, "bin", "pkg_linux_setup.sh"))) + self.assertEqual(sorted(os.listdir(self.root)), ["6.7.0"]) + + def test_md5_mismatch_installs_nothing(self): + with self.assertRaises(InstallError): + install_package(make_package(make_payload(), md5="0" * 32), self.root) + self.assertFalse(os.path.exists(os.path.join(self.root, "6.7.0"))) + + def test_path_traversal_installs_nothing(self): + bad = make_payload({"../../evil": b"x", "./bin/mudrun-headless": b"#!bin"}) + with self.assertRaises(InstallError): + install_package(make_package(bad), self.root) + self.assertFalse(os.path.exists(os.path.join(self.tmp.name, "evil"))) + self.assertFalse(os.path.exists(os.path.join(self.root, "6.7.0"))) + + def test_symlink_member_rejected(self): + def add_link(tar): + link = tarfile.TarInfo("./bin/link") + link.type = tarfile.SYMTYPE + link.linkname = "/etc/shadow" + tar.addfile(link) + with self.assertRaises(InstallError): + install_package(make_package(make_payload(extra=add_link)), self.root) + + def test_missing_daemon_binary_rejected(self): + with self.assertRaises(InstallError): + install_package(make_package(make_payload({"./bin/mudfish": b"x"})), self.root) + + def test_bad_version_string_rejected(self): + with self.assertRaises(InstallError): + install_package(make_package(make_payload(), version="../x"), self.root) + + def test_carry_login_copies_conf_files(self): + old, new = os.path.join(self.tmp.name, "old"), os.path.join(self.tmp.name, "new") + os.makedirs(old) + os.makedirs(new) + for name in (".conf", ".conf.db", ".cache.db"): + with open(os.path.join(old, name), "w") as f: + f.write(name) + carry_login(old, new) + self.assertEqual(sorted(os.listdir(new)), [".conf", ".conf.db"]) + + def test_prune_versions_keeps_listed(self): + for v in ("6.5.5", "6.6.0", "6.7.0"): + os.makedirs(os.path.join(self.root, v, "bin")) + prune_versions(self.root, keep=["6.7.0", "6.6.0"]) + self.assertEqual(sorted(os.listdir(self.root)), ["6.6.0", "6.7.0"]) + + def test_remove_all(self): + os.makedirs(os.path.join(self.root, "6.6.0", "bin")) + remove_all(self.root) + self.assertFalse(os.path.exists(self.root)) + remove_all(self.root) # idempotent + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_system.py b/tests/test_system.py index 803438e..f3a9d02 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -35,6 +35,19 @@ class SystemTest(unittest.TestCase): with tempfile.TemporaryDirectory() as var: self.assertEqual(api_base(var), "http://127.0.0.1:8282") + def test_version_of_binary_path(self): + from mudfish_decky.system import version_of + self.assertEqual(version_of("/opt/mudfish/6.6.0/bin/mudrun-headless"), "6.6.0") + + def test_stop_daemon_and_remove_nm_connection_commands(self): + from mudfish_decky.system import remove_nm_connection, stop_daemon + calls = [] + run = lambda cmd, **kw: calls.append(cmd) # noqa: E731 + stop_daemon(run=run) + remove_nm_connection(run=run) + self.assertEqual(calls, [["systemctl", "stop", "mudfish-decky"], + ["nmcli", "connection", "delete", "mudfish-tap"]]) + def test_lan_ip_from_route_get(self): from mudfish_decky.system import lan_ip out = "1.1.1.1 via 192.168.1.1 dev wlan0 src 192.168.1.50 uid 0 \n cache \n"