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
+701
-10
No files matched your search
@@ -8,13 +8,14 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "py_modules"))
|
|||||||
|
|
||||||
import decky # noqa: E402
|
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.client import MudfishClient # noqa: E402
|
||||||
from mudfish_decky.controller import Controller # noqa: E402
|
from mudfish_decky.controller import Controller # noqa: E402
|
||||||
from mudfish_decky.relay import LoginRelay # noqa: E402
|
from mudfish_decky.relay import LoginRelay # noqa: E402
|
||||||
from mudfish_decky.store import Store # noqa: E402
|
from mudfish_decky.store import Store # noqa: E402
|
||||||
|
|
||||||
RESUME_POLL_SECONDS = 2
|
RESUME_POLL_SECONDS = 2
|
||||||
|
LATEST_POLL_TICKS = 1800 # every hour at 2s per tick
|
||||||
LOGIN_RELAY_SECONDS = 300
|
LOGIN_RELAY_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ class Plugin:
|
|||||||
self.controller = Controller(
|
self.controller = Controller(
|
||||||
MudfishClient(base), system, store,
|
MudfishClient(base), system, store,
|
||||||
relay_factory=lambda: LoginRelay(target=(target.hostname, target.port or 80), ttl=LOGIN_RELAY_SECONDS),
|
relay_factory=lambda: LoginRelay(target=(target.hostname, target.port or 80), ttl=LOGIN_RELAY_SECONDS),
|
||||||
|
installer=installer,
|
||||||
)
|
)
|
||||||
return self.controller
|
return self.controller
|
||||||
|
|
||||||
@@ -39,6 +41,15 @@ class Plugin:
|
|||||||
async def stop_login(self) -> dict:
|
async def stop_login(self) -> dict:
|
||||||
return await asyncio.to_thread(self._get_controller().stop_login)
|
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:
|
async def get_state(self) -> dict:
|
||||||
return await asyncio.to_thread(self._get_controller().state)
|
return await asyncio.to_thread(self._get_controller().state)
|
||||||
|
|
||||||
@@ -64,13 +75,23 @@ class Plugin:
|
|||||||
await asyncio.to_thread(controller.prepare)
|
await asyncio.to_thread(controller.prepare)
|
||||||
decky.logger.info("Mudfish VPN plugin loaded")
|
decky.logger.info("Mudfish VPN plugin loaded")
|
||||||
# Watch for sleep/resume even while the panel is closed; a resumed tunnel is turned off.
|
# 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:
|
while True:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(controller.check_resume, system.sleep_offset())
|
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:
|
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)
|
await asyncio.sleep(RESUME_POLL_SECONDS)
|
||||||
|
|
||||||
async def _unload(self):
|
async def _unload(self):
|
||||||
await asyncio.to_thread(self._get_controller().shutdown)
|
await asyncio.to_thread(self._get_controller().shutdown)
|
||||||
decky.logger.info("Mudfish VPN plugin unloaded")
|
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")
|
||||||
@@ -17,7 +17,13 @@ from .routes import host_routes, leftover_routes
|
|||||||
MSG_RELOGIN = "데스크탑 모드에서 미꾸라지(127.0.0.1:8282)에 '로그인 기억하기'로 다시 로그인하세요"
|
MSG_RELOGIN = "데스크탑 모드에서 미꾸라지(127.0.0.1:8282)에 '로그인 기억하기'로 다시 로그인하세요"
|
||||||
MSG_RTT = "노드 핑을 측정하는 중이에요. 잠시 후 다시 켜 주세요"
|
MSG_RTT = "노드 핑을 측정하는 중이에요. 잠시 후 다시 켜 주세요"
|
||||||
MSG_TIMEOUT = "30초 안에 연결되지 않아 연결을 취소했어요"
|
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_STILL_UP = "터널이 아직 남아 있어요. 끄기를 다시 눌러 주세요"
|
||||||
MSG_NO_RESPONSE = "미꾸라지가 응답하지 않아요. 터널이 남아 있을 수 있으니 끄기를 눌러 주세요"
|
MSG_NO_RESPONSE = "미꾸라지가 응답하지 않아요. 터널이 남아 있을 수 있으니 끄기를 눌러 주세요"
|
||||||
MSG_SLEEP = "절전에서 깨어나 VPN을 껐어요. 필요하면 다시 켜 주세요"
|
MSG_SLEEP = "절전에서 깨어나 VPN을 껐어요. 필요하면 다시 켜 주세요"
|
||||||
@@ -29,18 +35,27 @@ DISCONNECT_TIMEOUT = 10
|
|||||||
PUBLIC_IP_TTL = 30
|
PUBLIC_IP_TTL = 30
|
||||||
RTT_TTL = 600 # cached pings older than this are ignored for the auto pick
|
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
|
RESUME_JUMP = 3.0 # seconds of suspended time that count as a sleep/resume
|
||||||
|
LATEST_CHECK_INTERVAL = 86400
|
||||||
|
|
||||||
log = logging.getLogger("mudfish_decky")
|
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:
|
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.client = client
|
||||||
self.system = system
|
self.system = system
|
||||||
self.store = store
|
self.store = store
|
||||||
self.clock = clock
|
self.clock = clock
|
||||||
self.sleep = sleep
|
self.sleep = sleep
|
||||||
self.relay_factory = relay_factory
|
self.relay_factory = relay_factory
|
||||||
|
self.installer = installer
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._error: Optional[str] = None
|
self._error: Optional[str] = None
|
||||||
self._notice: Optional[str] = None
|
self._notice: Optional[str] = None
|
||||||
@@ -61,6 +76,9 @@ class Controller:
|
|||||||
def prepare(self) -> None:
|
def prepare(self) -> None:
|
||||||
"""Plugin load: bring up the idle launcher and sign in so the node list is ready. Never connects."""
|
"""Plugin load: bring up the idle launcher and sign in so the node list is ready. Never connects."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._prepare()
|
||||||
|
|
||||||
|
def _prepare(self) -> None:
|
||||||
installed = self.system.find_mudfish()
|
installed = self.system.find_mudfish()
|
||||||
if not installed:
|
if not installed:
|
||||||
return
|
return
|
||||||
@@ -74,6 +92,103 @@ class Controller:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("prepare: %s", 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:
|
def nodes(self) -> list:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._refresh_rtts()
|
self._refresh_rtts()
|
||||||
@@ -358,9 +473,12 @@ class Controller:
|
|||||||
data = self.store.load()
|
data = self.store.load()
|
||||||
out = {"phase": "off", "message": None, "node_mode": data.get("node_mode") or "auto", "node": None,
|
out = {"phase": "off", "message": None, "node_mode": data.get("node_mode") or "auto", "node": None,
|
||||||
"ip": None, "country": None, "connected_since": None, "tunnel": False,
|
"ip": None, "country": None, "connected_since": None, "tunnel": False,
|
||||||
"need_login": False, "login": None}
|
"need_login": False, "login": None, "mudfish": None}
|
||||||
if not self.system.find_mudfish():
|
installed = self.system.find_mudfish()
|
||||||
out.update(phase="missing", message=MSG_MISSING)
|
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
|
return out
|
||||||
try:
|
try:
|
||||||
st = self.client.status()
|
st = self.client.status()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
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:
|
def api_base(var_dir: str) -> str:
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(var_dir, ".ipport")) as f:
|
with open(os.path.join(var_dir, ".ipport")) as f:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ButtonItem,
|
ButtonItem,
|
||||||
|
ConfirmModal,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
Field,
|
Field,
|
||||||
PanelSection,
|
PanelSection,
|
||||||
@@ -13,7 +14,11 @@ import {
|
|||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
getState,
|
getState,
|
||||||
|
installMudfish,
|
||||||
|
isNewer,
|
||||||
listNodes,
|
listNodes,
|
||||||
|
uninstallMudfish,
|
||||||
|
updateMudfish,
|
||||||
NodeInfo,
|
NodeInfo,
|
||||||
Phase,
|
Phase,
|
||||||
setNode,
|
setNode,
|
||||||
@@ -134,6 +139,42 @@ export function Panel() {
|
|||||||
...nodes.map((n) => ({ data: String(n.sid), label: `${n.label} · ${rttText(n.rtt)}` })),
|
...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 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 (
|
return (
|
||||||
<PanelSection>
|
<PanelSection>
|
||||||
@@ -173,6 +214,7 @@ export function Panel() {
|
|||||||
</ButtonItem>
|
</ButtonItem>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
{advanced && (
|
{advanced && (
|
||||||
|
<>
|
||||||
<PanelSectionRow>
|
<PanelSectionRow>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
label="일본 노드"
|
label="일본 노드"
|
||||||
@@ -182,6 +224,25 @@ export function Panel() {
|
|||||||
onChange={(opt) => run(() => setNode(String(opt.data)))}
|
onChange={(opt) => run(() => setNode(String(opt.data)))}
|
||||||
/>
|
/>
|
||||||
</PanelSectionRow>
|
</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>
|
</PanelSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface VpnState {
|
|||||||
tunnel: boolean;
|
tunnel: boolean;
|
||||||
need_login: boolean;
|
need_login: boolean;
|
||||||
login: { url: string; expires_at: number } | null;
|
login: { url: string; expires_at: number } | null;
|
||||||
|
mudfish: { version: string | null; latest: string | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getState = callable<[], VpnState>("get_state");
|
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 listNodes = callable<[], NodeInfo[]>("list_nodes");
|
||||||
export const startLogin = callable<[], VpnState>("start_login");
|
export const startLogin = callable<[], VpnState>("start_login");
|
||||||
export const stopLogin = callable<[], VpnState>("stop_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
@@ -3,8 +3,10 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from mudfish_decky.client import AuthError, SigninError
|
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,
|
from mudfish_decky.controller import (MSG_INSTALLED, MSG_LOGGED_IN, MSG_MISSING, MSG_NO_LAN, MSG_NO_RESPONSE,
|
||||||
MSG_SLEEP, MSG_STILL_UP, MSG_TIMEOUT, Controller)
|
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 mudfish_decky.store import Store
|
||||||
from tests.test_nodes import STATIC
|
from tests.test_nodes import STATIC
|
||||||
|
|
||||||
@@ -21,15 +23,31 @@ class FakeSystem:
|
|||||||
self.daemon_calls = 0
|
self.daemon_calls = 0
|
||||||
self.ip = {"ip": "13.73.234.162", "country": "JP"}
|
self.ip = {"ip": "13.73.234.162", "country": "JP"}
|
||||||
self.lan = "192.168.1.50"
|
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):
|
def lan_ip(self):
|
||||||
return self.lan
|
return self.lan
|
||||||
|
|
||||||
def find_mudfish(self):
|
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):
|
def ensure_daemon(self, bin_path, is_up):
|
||||||
self.daemon_calls += 1
|
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):
|
def tap_up(self):
|
||||||
return self.tap
|
return self.tap
|
||||||
@@ -45,6 +63,39 @@ class FakeSystem:
|
|||||||
return self.ip
|
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:
|
class FakeRelay:
|
||||||
token = "tok"
|
token = "tok"
|
||||||
expires_at = 1300.0
|
expires_at = 1300.0
|
||||||
@@ -436,6 +487,102 @@ class ControllerTest(unittest.TestCase):
|
|||||||
c.shutdown()
|
c.shutdown()
|
||||||
self.assertFalse(self.relay.active)
|
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 --------------------------------------------------------------------
|
# ---- sleep / shutdown --------------------------------------------------------------------
|
||||||
|
|
||||||
def test_resume_jump_turns_tunnel_off_with_notice(self):
|
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:
|
with tempfile.TemporaryDirectory() as var:
|
||||||
self.assertEqual(api_base(var), "http://127.0.0.1:8282")
|
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):
|
def test_lan_ip_from_route_get(self):
|
||||||
from mudfish_decky.system import lan_ip
|
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"
|
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