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:
1 parent
4e27e29225
commit
384d0a75f0
8 files changed
+131
-5
No files matched your search
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
@@ -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");
|
||||||
@@ -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)]})
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user