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 <noreply@anthropic.com>
This commit is contained in:
ysj7705andClaude Opus 5.5 committed 2026-10-24 20:50:57 +09:00
1 parent 4e27e29225
commit 384d0a75f0
8 files changed
+131 -5

No files matched your search

+38
View File
@@ -1,3 +1,5 @@
import asyncio
import logging
import os import os
import sys import sys
@@ -5,9 +7,45 @@ 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.client import MudfishClient # noqa: E402
from mudfish_decky.controller import Controller # noqa: E402
from mudfish_decky.store import Store # noqa: E402
class Plugin: 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): 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") decky.logger.info("Mudfish VPN plugin loaded")
async def _unload(self): async def _unload(self):
+19 -1
View File
@@ -1,5 +1,7 @@
"""HTTP calls to the local Mudfish launcher API and the Mudfish cloud API.""" """HTTP calls to the local Mudfish launcher API and the Mudfish cloud API."""
import json import json
import os
import ssl
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -25,10 +27,26 @@ class SigninError(MudfishError):
pass 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): def urllib_transport(method, url, body, headers):
req = urllib.request.Request(url, data=body, headers=headers, method=method) req = urllib.request.Request(url, data=body, headers=headers, method=method)
try: 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() return resp.status, resp.read()
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
return e.code, e.read() return e.code, e.read()
+14 -2
View File
@@ -43,6 +43,18 @@ class Controller:
with self._lock: with self._lock:
return self._state() 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: def nodes(self) -> list:
with self._lock: with self._lock:
self._refresh_rtts() 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()) return bool(st.get("core_running") and st.get("client_ready") and self.system.tap_up())
def _signed_in(self, installed) -> dict: 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() return self.client.ensure_signed_in()
def _daemon_up(self) -> bool: def daemon_up(self) -> bool:
try: try:
self.client.status() self.client.status()
return True return True
+2 -1
View File
@@ -6,6 +6,7 @@ import time
import urllib.request import urllib.request
from typing import Callable, Optional, Tuple from typing import Callable, Optional, Tuple
from .client import ssl_context
from .routes import delete_routes # noqa: F401 (re-exported for the controller) from .routes import delete_routes # noqa: F401 (re-exported for the controller)
MUDFISH_ROOT = "/opt/mudfish" MUDFISH_ROOT = "/opt/mudfish"
@@ -66,7 +67,7 @@ def route_text(run=subprocess.run) -> str:
def public_ip() -> Optional[dict]: def public_ip() -> Optional[dict]:
try: 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) data = json.load(resp)
return {"ip": data.get("ip"), "country": data.get("country")} return {"ip": data.get("ip"), "country": data.get("country")}
except Exception: except Exception:
+1 -1
View File
@@ -7,6 +7,6 @@ DECK="${DECK:-deck@192.168.1.50}"
pnpm build pnpm build
tar --exclude='__pycache__' -cf - dist main.py plugin.json package.json py_modules | ssh "$DECK" ' tar --exclude='__pycache__' -cf - dist main.py plugin.json package.json py_modules | ssh "$DECK" '
D=/home/deck/homebrew/plugins/mudfish-decky 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' sudo systemctl restart plugin_loader'
echo "deployed to $DECK" echo "deployed to $DECK"
+25
View File
@@ -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");
+16
View File
@@ -24,6 +24,22 @@ class FakeTransport:
return [c for c in self.calls if c[0] == method and c[1] == url] 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): class ClientTest(unittest.TestCase):
def test_ensure_signed_in_noop_when_signed(self): def test_ensure_signed_in_noop_when_signed(self):
t = FakeTransport({("GET", BASE + "/do/mudrun/status"): [dict(SIGNED_OUT, isSignedIn=True)]}) t = FakeTransport({("GET", BASE + "/do/mudrun/status"): [dict(SIGNED_OUT, isSignedIn=True)]})
+16
View File
@@ -238,6 +238,22 @@ class ControllerTest(unittest.TestCase):
self.assertEqual(st["node_mode"], "auto") self.assertEqual(st["node_mode"], "auto")
self.assertEqual(st["node"]["sid"], 11) 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): def test_nodes_lists_japan_by_ping(self):
self.assertEqual([n["sid"] for n in self.make().nodes()], [11, 9, 46]) self.assertEqual([n["sid"] for n in self.make().nodes()], [11, 9, 46])