From 384d0a75f05db4468bcbe6730329f14fca083e89 Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Sat, 24 Oct 2026 20:50:57 +0900 Subject: [PATCH] feat: expose controller through decky backend Prepare signs in on load (never connects), system CA bundle for HTTPS, root-owned deploy extraction. Co-Authored-By: Claude Opus 5.5 --- main.py | 38 ++++++++++++++++++++++++++ py_modules/mudfish_decky/client.py | 20 +++++++++++++- py_modules/mudfish_decky/controller.py | 16 +++++++++-- py_modules/mudfish_decky/system.py | 3 +- scripts/deploy.sh | 2 +- src/backend.ts | 25 +++++++++++++++++ tests/test_client.py | 16 +++++++++++ tests/test_controller.py | 16 +++++++++++ 8 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 src/backend.ts diff --git a/main.py b/main.py index 47f74cf..7442007 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,5 @@ +import asyncio +import logging import os import sys @@ -5,9 +7,45 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "py_modules")) import decky # noqa: E402 +from mudfish_decky import system # noqa: E402 +from mudfish_decky.client import MudfishClient # noqa: E402 +from mudfish_decky.controller import Controller # noqa: E402 +from mudfish_decky.store import Store # noqa: E402 + class Plugin: + controller = None + + def _get_controller(self) -> Controller: + if self.controller is None: + installed = system.find_mudfish() + base = system.api_base(installed[1]) if installed else system.DEFAULT_API + store = Store(os.path.join(decky.DECKY_PLUGIN_SETTINGS_DIR, "settings.json")) + self.controller = Controller(MudfishClient(base), system, store) + return self.controller + + async def get_state(self) -> dict: + return await asyncio.to_thread(self._get_controller().state) + + async def connect(self) -> dict: + return await asyncio.to_thread(self._get_controller().connect) + + async def disconnect(self) -> dict: + return await asyncio.to_thread(self._get_controller().disconnect) + + async def set_node(self, choice: str) -> dict: + return await asyncio.to_thread(self._get_controller().set_node, str(choice)) + + async def list_nodes(self) -> list: + return await asyncio.to_thread(self._get_controller().nodes) + async def _main(self): + log = logging.getLogger("mudfish_decky") + log.setLevel(logging.INFO) + for handler in decky.logger.handlers: + log.addHandler(handler) + # Bring up the idle launcher and sign in so nodes are listed. Never starts the tunnel. + await asyncio.to_thread(self._get_controller().prepare) decky.logger.info("Mudfish VPN plugin loaded") async def _unload(self): diff --git a/py_modules/mudfish_decky/client.py b/py_modules/mudfish_decky/client.py index 56ec7a9..57836b8 100644 --- a/py_modules/mudfish_decky/client.py +++ b/py_modules/mudfish_decky/client.py @@ -1,5 +1,7 @@ """HTTP calls to the local Mudfish launcher API and the Mudfish cloud API.""" import json +import os +import ssl import urllib.error import urllib.parse import urllib.request @@ -25,10 +27,26 @@ class SigninError(MudfishError): pass +# Decky's bundled Python ships without a CA store, so point it at SteamOS's system bundle. +SYSTEM_CA_BUNDLES = ["/etc/ssl/certs/ca-certificates.crt", "/etc/ca-certificates/extracted/tls-ca-bundle.pem"] + + +def ssl_context(cafiles=SYSTEM_CA_BUNDLES, factory=ssl.create_default_context): + ctx = factory() + for path in cafiles: + if os.path.isfile(path): + ctx.load_verify_locations(cafile=path) + break + return ctx + + +_SSL = ssl_context() + + def urllib_transport(method, url, body, headers): req = urllib.request.Request(url, data=body, headers=headers, method=method) try: - with urllib.request.urlopen(req, timeout=10) as resp: + with urllib.request.urlopen(req, timeout=10, context=_SSL) as resp: return resp.status, resp.read() except urllib.error.HTTPError as e: return e.code, e.read() diff --git a/py_modules/mudfish_decky/controller.py b/py_modules/mudfish_decky/controller.py index 993d360..ac29e1a 100644 --- a/py_modules/mudfish_decky/controller.py +++ b/py_modules/mudfish_decky/controller.py @@ -43,6 +43,18 @@ class Controller: with self._lock: return self._state() + 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: + installed = self.system.find_mudfish() + if not installed: + return + try: + st = self._signed_in(installed) + self._static_nodes(st) + except Exception as e: + log.warning("prepare: %s", e) + def nodes(self) -> list: with self._lock: self._refresh_rtts() @@ -127,10 +139,10 @@ class Controller: return bool(st.get("core_running") and st.get("client_ready") and self.system.tap_up()) def _signed_in(self, installed) -> dict: - self.system.ensure_daemon(installed[0], self._daemon_up) + self.system.ensure_daemon(installed[0], self.daemon_up) return self.client.ensure_signed_in() - def _daemon_up(self) -> bool: + def daemon_up(self) -> bool: try: self.client.status() return True diff --git a/py_modules/mudfish_decky/system.py b/py_modules/mudfish_decky/system.py index 9a0ff0c..3c240a8 100644 --- a/py_modules/mudfish_decky/system.py +++ b/py_modules/mudfish_decky/system.py @@ -6,6 +6,7 @@ import time import urllib.request from typing import Callable, Optional, Tuple +from .client import ssl_context from .routes import delete_routes # noqa: F401 (re-exported for the controller) MUDFISH_ROOT = "/opt/mudfish" @@ -66,7 +67,7 @@ def route_text(run=subprocess.run) -> str: def public_ip() -> Optional[dict]: try: - with urllib.request.urlopen("https://ipinfo.io/json", timeout=8) as resp: + with urllib.request.urlopen("https://ipinfo.io/json", timeout=8, context=ssl_context()) as resp: data = json.load(resp) return {"ip": data.get("ip"), "country": data.get("country")} except Exception: diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 07f4166..de7ecf4 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -7,6 +7,6 @@ DECK="${DECK:-deck@192.168.1.50}" pnpm build tar --exclude='__pycache__' -cf - dist main.py plugin.json package.json py_modules | ssh "$DECK" ' D=/home/deck/homebrew/plugins/mudfish-decky - sudo rm -rf "$D" && sudo mkdir -p "$D" && sudo tar -xf - -C "$D" && + sudo rm -rf "$D" && sudo mkdir -p "$D" && sudo tar --no-same-owner -xf - -C "$D" && sudo systemctl restart plugin_loader' echo "deployed to $DECK" diff --git a/src/backend.ts b/src/backend.ts new file mode 100644 index 0000000..956889f --- /dev/null +++ b/src/backend.ts @@ -0,0 +1,25 @@ +import { callable } from "@decky/api"; + +export type Phase = "off" | "connecting" | "on" | "error" | "missing"; + +export interface NodeInfo { + sid: number; + label: string; + rtt: number | null; +} + +export interface VpnState { + phase: Phase; + message: string | null; + node_mode: "auto" | "manual"; + node: NodeInfo | null; + ip: string | null; + country: string | null; + connected_since: number | null; +} + +export const getState = callable<[], VpnState>("get_state"); +export const connect = callable<[], VpnState>("connect"); +export const disconnect = callable<[], VpnState>("disconnect"); +export const setNode = callable<[choice: string], VpnState>("set_node"); +export const listNodes = callable<[], NodeInfo[]>("list_nodes"); diff --git a/tests/test_client.py b/tests/test_client.py index c6f9be1..2a74abf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -24,6 +24,22 @@ class FakeTransport: return [c for c in self.calls if c[0] == method and c[1] == url] +class SslContextTest(unittest.TestCase): + def test_uses_first_existing_system_bundle(self): + import ssl + from mudfish_decky.client import ssl_context + loaded = [] + + class Ctx: + def load_verify_locations(self, cafile): + loaded.append(cafile) + + ctx = ssl_context(["/nope/a.pem", __file__, "/nope/b.pem"], factory=lambda: Ctx()) + self.assertIsInstance(ctx, Ctx) + self.assertEqual(loaded, [__file__]) + self.assertIsInstance(ssl_context([], factory=ssl.create_default_context), ssl.SSLContext) + + class ClientTest(unittest.TestCase): def test_ensure_signed_in_noop_when_signed(self): t = FakeTransport({("GET", BASE + "/do/mudrun/status"): [dict(SIGNED_OUT, isSignedIn=True)]}) diff --git a/tests/test_controller.py b/tests/test_controller.py index 0952361..d30c8f4 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -238,6 +238,22 @@ class ControllerTest(unittest.TestCase): self.assertEqual(st["node_mode"], "auto") self.assertEqual(st["node"]["sid"], 11) + def test_prepare_signs_in_and_caches_nodes_without_starting(self): + self.client.st["isSignedIn"] = False + signed = [] + self.client.ensure_signed_in = lambda: signed.append(1) or dict(self.client.st, isSignedIn=True) + c = self.make() + c.prepare() + self.assertEqual(self.system.daemon_calls, 1) + self.assertEqual(signed, [1]) + self.assertEqual(self.client.start_calls, 0) + self.assertEqual([n["sid"] for n in Store(self.path).load()["static_nodes"]], [9, 11, 46, 300]) + + def test_prepare_tolerates_signin_failure(self): + self.client.signin_error = SigninError("401") + self.make().prepare() + self.assertEqual(self.client.start_calls, 0) + def test_nodes_lists_japan_by_ping(self): self.assertEqual([n["sid"] for n in self.make().nodes()], [11, 9, 46])