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

+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"