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
+701 -10

No files matched your search

+23 -2
View File
@@ -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")
+123 -5
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,6 +76,9 @@ 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
@@ -74,6 +92,103 @@ class Controller:
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 self._state()
if self._tunnel_present():
self._notice = MSG_UPDATE_NEED_OFF
return self._state()
current = self.system.version_of(installed[0])
try:
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.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:
self._refresh_rtts()
@@ -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:
+61
View File
@@ -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(
<ConfirmModal
strTitle="미꾸라지 제거"
strDescription="VPN을 끄고 미꾸라지(/opt/mudfish 전체, 로그인 정보 포함)와 이 플러그인의 설정을 지워요. 미꾸라지 계정은 그대로예요."
strOKButtonText="제거"
strCancelButtonText="취소"
onOK={() => run(uninstallMudfish)}
/>,
);
if (missing) {
return (
<PanelSection>
<PanelSectionRow>
<Field label="상태" focusable={false}>
<StatusValue state={state} />
</Field>
</PanelSectionRow>
{state.message && (
<PanelSectionRow>
<Field focusable={false} description={state.message} />
</PanelSectionRow>
)}
<PanelSectionRow>
<ButtonItem layout="below" disabled={busy} onClick={() => run(installMudfish)}>
{busy ? "설치 중… (1~2분)" : `미꾸라지 설치${latest ? ` (${latest})` : ""}`}
</ButtonItem>
</PanelSectionRow>
</PanelSection>
);
}
return (
<PanelSection>
@@ -173,6 +214,7 @@ export function Panel() {
</ButtonItem>
</PanelSectionRow>
{advanced && (
<>
<PanelSectionRow>
<DropdownItem
label="일본 노드"
@@ -182,6 +224,25 @@ export function Panel() {
onChange={(opt) => run(() => setNode(String(opt.data)))}
/>
</PanelSectionRow>
<PanelSectionRow>
<Field label="미꾸라지" focusable={false}>
{version ?? "?"}
{latest && !isNewer(latest, version) ? " (최신)" : ""}
</Field>
</PanelSectionRow>
{isNewer(latest, version) && (
<PanelSectionRow>
<ButtonItem layout="below" disabled={busy || active} onClick={() => run(updateMudfish)}>
{busy ? "업데이트 중…" : `미꾸라지 업데이트 (${version} → ${latest})`}
</ButtonItem>
</PanelSectionRow>
)}
<PanelSectionRow>
<ButtonItem layout="below" disabled={busy} onClick={confirmUninstall}>
미꾸라지 제거
</ButtonItem>
</PanelSectionRow>
</>
)}
</PanelSection>
);
+16
View File
@@ -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;
}
+150 -3
View File
@@ -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):
+144
View File
@@ -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 = ('<a href="/releases/mudfish-6.5.5-linux-x86_64.sh">x</a>'
'<a href="/releases/mudfish-6.10.0-linux-x86_64.sh">y</a>'
'<a href="/releases/mudfish-6.6.0-linux-x86_64.sh">z</a>')
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: "<html></html>"))
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"<html>not found</html>")
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()
+13
View File
@@ -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"