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):