feat: phone login via QR when the saved Mudfish login expires
When autologin is rejected the panel offers '휴대폰으로 로그인 (QR)'. It opens a 5-minute LAN relay to the launcher UI guarded by a one-time token (first phone to open the QR URL is the only client allowed) and shows the URL as a QR code. The relay closes as soon as a new session appears, on cancel, on expiry, and on unload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8f1c05372
commit
7513fef6f8
12 files changed
+586
-7
No files matched your search
@@ -21,6 +21,8 @@ MSG_MISSING = "데스크탑 모드에서 미꾸라지를 설치하세요 (docs.m
|
||||
MSG_STILL_UP = "터널이 아직 남아 있어요. 끄기를 다시 눌러 주세요"
|
||||
MSG_NO_RESPONSE = "미꾸라지가 응답하지 않아요. 터널이 남아 있을 수 있으니 끄기를 눌러 주세요"
|
||||
MSG_SLEEP = "절전에서 깨어나 VPN을 껐어요. 필요하면 다시 켜 주세요"
|
||||
MSG_LOGGED_IN = "로그인됐어요. 이제 켤 수 있어요"
|
||||
MSG_NO_LAN = "와이파이 주소를 찾지 못해 휴대폰 로그인을 열 수 없어요"
|
||||
|
||||
CONNECT_TIMEOUT = 30
|
||||
DISCONNECT_TIMEOUT = 10
|
||||
@@ -32,15 +34,20 @@ log = logging.getLogger("mudfish_decky")
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, client, system, store, clock=time.time, sleep=time.sleep):
|
||||
def __init__(self, client, system, store, clock=time.time, sleep=time.sleep, relay_factory=None):
|
||||
self.client = client
|
||||
self.system = system
|
||||
self.store = store
|
||||
self.clock = clock
|
||||
self.sleep = sleep
|
||||
self.relay_factory = relay_factory
|
||||
self._lock = threading.Lock()
|
||||
self._error: Optional[str] = None
|
||||
self._notice: Optional[str] = None
|
||||
self._need_login = False
|
||||
self._failed_jwt: Optional[str] = None
|
||||
self._relay = None
|
||||
self._relay_url: Optional[str] = None
|
||||
self._static: Optional[list] = None
|
||||
self._ip_cache = (0.0, None)
|
||||
self._sleep_offset: Optional[float] = None
|
||||
@@ -60,6 +67,10 @@ class Controller:
|
||||
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)
|
||||
|
||||
@@ -77,6 +88,7 @@ class Controller:
|
||||
started = False
|
||||
try:
|
||||
st = self._signed_in(installed)
|
||||
self._need_login = False
|
||||
if self._is_on(st):
|
||||
return self._state()
|
||||
self._refresh_rtts()
|
||||
@@ -116,6 +128,7 @@ class Controller:
|
||||
if self._is_on(st):
|
||||
self._reapply(st)
|
||||
except SigninError:
|
||||
self._mark_need_login()
|
||||
return self._fail(MSG_RELOGIN)
|
||||
except Exception as e:
|
||||
log.exception("node change failed")
|
||||
@@ -137,9 +150,31 @@ class Controller:
|
||||
self._error = MSG_STILL_UP
|
||||
return True
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Plugin unload (Deck shutdown/restart, Decky restart): never leave a tunnel behind."""
|
||||
def start_login(self) -> dict:
|
||||
"""Open the short-lived LAN relay to the launcher UI; the panel shows its URL as a QR code."""
|
||||
with self._lock:
|
||||
if self._relay is not None and self._relay.active:
|
||||
return self._state()
|
||||
ip = self.system.lan_ip()
|
||||
if not ip or self.relay_factory is None:
|
||||
self._notice = MSG_NO_LAN
|
||||
return self._state()
|
||||
relay = self.relay_factory()
|
||||
port = relay.start()
|
||||
self._relay = relay
|
||||
self._relay_url = f"http://{ip}:{port}/_mfd/{relay.token}"
|
||||
log.info("login relay open on %s:%d for %ss", ip, port, int(relay.expires_at - self.clock()))
|
||||
return self._state()
|
||||
|
||||
def stop_login(self) -> dict:
|
||||
with self._lock:
|
||||
self._close_relay()
|
||||
return self._state()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Plugin unload (Deck shutdown/restart, Decky restart): never leave a tunnel or relay behind."""
|
||||
with self._lock:
|
||||
self._close_relay()
|
||||
if self._tunnel_present():
|
||||
log.info("unload; turning VPN off")
|
||||
self._teardown()
|
||||
@@ -150,11 +185,42 @@ class Controller:
|
||||
self._error = message
|
||||
return self._state()
|
||||
|
||||
def _mark_need_login(self) -> None:
|
||||
"""Remember which session failed so state() can tell when a new login (e.g. from the phone) lands."""
|
||||
self._need_login = True
|
||||
try:
|
||||
st = self.client.status()
|
||||
self._failed_jwt = st.get("jwt") if st.get("isSignedIn") else None
|
||||
except Exception:
|
||||
self._failed_jwt = None
|
||||
|
||||
def _abort(self, started: bool, message: str) -> dict:
|
||||
if message == MSG_RELOGIN:
|
||||
self._mark_need_login()
|
||||
if started and not self._teardown():
|
||||
message = MSG_STILL_UP
|
||||
return self._fail(message)
|
||||
|
||||
def _track_login(self, st: Optional[dict]) -> None:
|
||||
"""Drop an expired relay; once a new session appears after a failed login, close the relay."""
|
||||
if self._relay is not None and not self._relay.active:
|
||||
self._close_relay()
|
||||
if not (self._need_login and st and st.get("isSignedIn") and st.get("jwt")):
|
||||
return
|
||||
if st["jwt"] == self._failed_jwt:
|
||||
return
|
||||
self._need_login = False
|
||||
self._failed_jwt = None
|
||||
if self._error == MSG_RELOGIN:
|
||||
self._error = None
|
||||
self._close_relay()
|
||||
self._notice = MSG_LOGGED_IN
|
||||
|
||||
def _close_relay(self) -> None:
|
||||
if self._relay is not None:
|
||||
self._relay.stop()
|
||||
self._relay = self._relay_url = None
|
||||
|
||||
def _wait(self, cond, seconds) -> bool:
|
||||
for _ in range(seconds):
|
||||
if cond():
|
||||
@@ -291,7 +357,8 @@ class Controller:
|
||||
def _state(self) -> dict:
|
||||
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}
|
||||
"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)
|
||||
return out
|
||||
@@ -299,6 +366,10 @@ class Controller:
|
||||
st = self.client.status()
|
||||
except Exception:
|
||||
st = None
|
||||
self._track_login(st)
|
||||
out["need_login"] = self._need_login
|
||||
if self._relay is not None:
|
||||
out["login"] = {"url": self._relay_url, "expires_at": self._relay.expires_at}
|
||||
tap = self.system.tap_up()
|
||||
on = bool(st) and self._is_on(st)
|
||||
out["tunnel"] = tap or bool(st and st.get("core_running"))
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Short-lived LAN relay to the Mudfish launcher UI so a phone can log in by scanning a QR code.
|
||||
|
||||
The launcher only listens on 127.0.0.1. While a login is pending we open a random LAN port that
|
||||
forwards raw TCP to it, guarded by a one-time token: the first client to request /_mfd/<token> becomes
|
||||
the only client allowed through; everyone else gets 403. The relay closes itself after `ttl` seconds.
|
||||
"""
|
||||
import logging
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
TOKEN_PREFIX = "/_mfd/"
|
||||
PEEK_TIMEOUT = 10
|
||||
|
||||
log = logging.getLogger("mudfish_decky")
|
||||
|
||||
_REDIRECT = b"HTTP/1.1 302 Found\r\nLocation: /\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
_FORBIDDEN = b"HTTP/1.1 403 Forbidden\r\nContent-Length: 9\r\nConnection: close\r\n\r\nForbidden"
|
||||
|
||||
|
||||
def parse_request_path(data: bytes) -> Optional[str]:
|
||||
"""Path from an HTTP request line, or None if the first line is incomplete or not HTTP."""
|
||||
line, sep, _ = data.partition(b"\r\n")
|
||||
if not sep:
|
||||
return None
|
||||
parts = line.split(b" ")
|
||||
if len(parts) != 3 or not parts[2].startswith(b"HTTP/"):
|
||||
return None
|
||||
try:
|
||||
return parts[1].decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
class Gate:
|
||||
def __init__(self, token: str):
|
||||
self.token = token
|
||||
self.client: Optional[str] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def decide(self, ip: str, path: Optional[str]) -> str:
|
||||
is_token = path is not None and path.split("?", 1)[0] == TOKEN_PREFIX + self.token
|
||||
with self._lock:
|
||||
if self.client is None and is_token:
|
||||
self.client = ip
|
||||
if ip != self.client:
|
||||
return "deny"
|
||||
return "authorize" if is_token else "pipe"
|
||||
|
||||
|
||||
def _pipe(src, dst):
|
||||
try:
|
||||
while True:
|
||||
data = src.recv(65536)
|
||||
if not data:
|
||||
break
|
||||
dst.sendall(data)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
for s in (src, dst):
|
||||
try:
|
||||
s.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class LoginRelay:
|
||||
def __init__(self, target=("127.0.0.1", 8282), ttl: int = 300, clock=time.time):
|
||||
self.target = target
|
||||
self.ttl = ttl
|
||||
self.clock = clock
|
||||
self.token = secrets.token_urlsafe(16)
|
||||
self.gate = Gate(self.token)
|
||||
self.expires_at = 0.0
|
||||
self._srv: Optional[socket.socket] = None
|
||||
self._stopped = threading.Event()
|
||||
self._conns = set()
|
||||
self._conns_lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self._srv is not None and not self._stopped.is_set() and self.clock() < self.expires_at
|
||||
|
||||
def start(self, host: str = "0.0.0.0") -> int:
|
||||
self._srv = socket.create_server((host, 0))
|
||||
self._srv.settimeout(1)
|
||||
self.expires_at = self.clock() + self.ttl
|
||||
threading.Thread(target=self._serve, daemon=True).start()
|
||||
return self._srv.getsockname()[1]
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stopped.set()
|
||||
if self._srv is not None:
|
||||
# shutdown() first: close() alone does not stop a listener another thread is blocked on.
|
||||
for op in (lambda: self._srv.shutdown(socket.SHUT_RDWR), self._srv.close):
|
||||
try:
|
||||
op()
|
||||
except OSError:
|
||||
pass
|
||||
with self._conns_lock:
|
||||
conns, self._conns = self._conns, set()
|
||||
for c in conns:
|
||||
try:
|
||||
c.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _track(self, sock):
|
||||
with self._conns_lock:
|
||||
self._conns.add(sock)
|
||||
|
||||
def _serve(self):
|
||||
while self.active:
|
||||
try:
|
||||
conn, addr = self._srv.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
self._track(conn)
|
||||
threading.Thread(target=self._handle, args=(conn, addr[0]), daemon=True).start()
|
||||
self.stop()
|
||||
log.info("login relay closed")
|
||||
|
||||
def _handle(self, conn, ip):
|
||||
try:
|
||||
conn.settimeout(PEEK_TIMEOUT)
|
||||
path = parse_request_path(conn.recv(4096, socket.MSG_PEEK))
|
||||
decision = self.gate.decide(ip, path)
|
||||
if decision in ("authorize", "deny"):
|
||||
conn.recv(4096) # consume the request; closing with unread data would send RST
|
||||
conn.sendall(_REDIRECT if decision == "authorize" else _FORBIDDEN)
|
||||
conn.shutdown(socket.SHUT_WR)
|
||||
conn.close()
|
||||
if decision == "authorize":
|
||||
log.info("login relay: phone %s authorized", ip)
|
||||
return
|
||||
conn.settimeout(None)
|
||||
upstream = socket.create_connection(self.target, timeout=5)
|
||||
upstream.settimeout(None)
|
||||
self._track(upstream)
|
||||
threading.Thread(target=_pipe, args=(conn, upstream), daemon=True).start()
|
||||
_pipe(upstream, conn)
|
||||
except OSError:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -71,6 +71,16 @@ def route_text(run=subprocess.run) -> str:
|
||||
return run(["ip", "-4", "route"], capture_output=True, text=True, timeout=5).stdout
|
||||
|
||||
|
||||
def lan_ip(run=subprocess.run) -> Optional[str]:
|
||||
"""The Deck's source address for outbound traffic (its Wi-Fi/LAN IP when the VPN is off)."""
|
||||
try:
|
||||
out = run(["ip", "-4", "route", "get", "1.1.1.1"], capture_output=True, text=True, timeout=5).stdout
|
||||
except Exception:
|
||||
return None
|
||||
parts = out.split()
|
||||
return parts[parts.index("src") + 1] if "src" in parts[:-1] else None
|
||||
|
||||
|
||||
def public_ip() -> Optional[dict]:
|
||||
try:
|
||||
with urllib.request.urlopen("https://ipinfo.io/json", timeout=8, context=ssl_context()) as resp:
|
||||
|
||||
Reference in new issue
Block a user