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:
1 parent
027a029b47
commit
3c28c9dac2
9 files changed
+718
-27
No files matched your search
+150
-3
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user