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
@@ -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,18 +76,118 @@ 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
|
||||
try:
|
||||
st = self._signed_in(installed)
|
||||
self._static_nodes(st)
|
||||
except SigninError as e:
|
||||
log.warning("prepare: saved login rejected: %s", e)
|
||||
self._error = MSG_RELOGIN
|
||||
self._mark_need_login()
|
||||
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
|
||||
return self._state()
|
||||
if self._tunnel_present():
|
||||
self._notice = MSG_UPDATE_NEED_OFF
|
||||
return self._state()
|
||||
current = self.system.version_of(installed[0])
|
||||
try:
|
||||
st = self._signed_in(installed)
|
||||
self._static_nodes(st)
|
||||
except SigninError as e:
|
||||
log.warning("prepare: saved login rejected: %s", e)
|
||||
self._error = MSG_RELOGIN
|
||||
self._mark_need_login()
|
||||
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.warning("prepare: %s", 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:
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user