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
@@ -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)
|
||||
Reference in new issue
Block a user