#!/usr/bin/env python3 """Shared control-surface primitives for Ludarium emulator sidecars. Every isolated player sidecar exposes the same bounded surface: a token-authenticated HTTP server on :8765 with /health, /v1/status, /v1/launch and /v1/action. Only the emulator binary, its game-path allowlist, its window identity and its hotkey map differ, so a concrete controller supplies only those. Keeping the surface in one module is a safety property, not only tidiness. The Xwayland display resolution below was found and fixed against the live Eden runtime; a per-sidecar copy silently missed that fix. Anything a future Azahar, xemu, Cemu or Vita3K controller needs belongs here so it inherits the same behaviour. The module has no third-party dependencies: the sidecar images are minimized and must not regain a package manager surface. """ import hmac import io import json import os import signal import subprocess import tarfile import tempfile import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path, PurePosixPath CONTROL_PORT = 8765 MIN_REQUEST_BYTES = 2 MAX_REQUEST_BYTES = 8192 # The Ludarium game-data vault stores one revision of at most 64 MiB, so an archive larger than that # could never be kept. Refusing it here keeps the failure at the sidecar instead of half-way upstream. MAX_SAVE_DATA_BYTES = 64 * 1024 * 1024 MAX_SAVE_DATA_ENTRIES = 20000 MAX_SAVE_DATA_ENTRY_BYTES = 256 * 1024 * 1024 MAX_SAVE_DATA_EXPANDED_BYTES = 512 * 1024 * 1024 SETUIDGID = "/usr/bin/s6-setuidgid" RUNTIME_USER = "abc" XDOTOOL = "/usr/bin/xdotool" def respond(handler, status, body): payload = json.dumps(body).encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "application/json") handler.send_header("Content-Length", str(len(payload))) handler.end_headers() handler.wfile.write(payload) def authorized(handler, token): supplied = handler.headers.get("X-Ludarium-Control-Token", "") return bool(token) and hmac.compare_digest(token, supplied) def resolve_game_path(value, games, extensions, platforms=None): """Resolve a runtime-relative game path to an exact file inside the read-only mount. This is the sidecar's last defence. The API already validates and prefixes the path, but the controller must never execute anything it cannot prove lives inside the games mount, carries an allowlisted extension and is a regular file. Symlinks are resolved before the containment check, so a link out of the mount fails closed. """ relative = PurePosixPath(value) if relative.is_absolute() or not relative.parts: raise ValueError("Unsafe game path") if any(part in ("", ".", "..") for part in relative.parts): raise ValueError("Unsafe game path") if platforms is not None and (len(relative.parts) < 2 or relative.parts[0] not in platforms): raise ValueError("Unsafe game path") root = Path(games).resolve() target = (root / Path(*relative.parts)).resolve() if root not in target.parents: raise ValueError("Game is outside the read-only games mount") if target.suffix.lower() not in extensions: raise ValueError("Game is not an allowlisted read-only image") if not target.is_file(): raise ValueError("Game is not a present read-only file") return target def desktop_display(process_name): """Resolve the Xwayland display owned by the active emulator desktop. PixelFlux allocates a new display number after container restarts while retaining older socket files. Reading the running desktop process is authoritative; the newest socket is only a bounded fallback for the short interval before the emulator has published its environment. """ try: process = subprocess.run( ["/usr/bin/pgrep", "-o", "-x", process_name], capture_output=True, text=True, timeout=2, check=False) pid = process.stdout.strip() if process.returncode == 0 and pid.isdigit(): environment = subprocess.run( [SETUIDGID, RUNTIME_USER, "/bin/cat", f"/proc/{pid}/environ"], capture_output=True, timeout=2, check=False) for item in environment.stdout.split(b"\0"): if item.startswith(b"DISPLAY=:"): display = item.removeprefix(b"DISPLAY=").decode("ascii", errors="ignore") if display[1:].isdigit(): return display except (OSError, subprocess.SubprocessError): pass sockets = [] try: for socket in Path("/tmp/.X11-unix").glob("X*"): if socket.name[1:].isdigit(): sockets.append((socket.stat().st_mtime_ns, int(socket.name[1:]))) except OSError: pass if sockets: return f":{max(sockets)[1]}" return os.environ.get("DISPLAY", ":0") def resolve_save_paths(save_root, save_paths): """Resolve the subdirectories of a profile that actually hold save data. An emulator profile is not a save directory. Dolphin's holds shader caches and dumps that dwarf the bounded revision size, so capturing the whole profile would fail on any real installation. Naming the save-bearing subdirectories keeps a capture to what an operator wants back, and resolving them here means a wrong path is visible in /v1/status instead of silently capturing the wrong thing. """ if save_root is None: return [] root = Path(save_root) if not root.is_dir(): return [] root = root.resolve() if not save_paths: return [root] resolved = [] for relative in save_paths: candidate = PurePosixPath(relative) if candidate.is_absolute() or any(part in ("", ".", "..") for part in candidate.parts): continue target = (root / Path(*candidate.parts)).resolve() if root in target.parents and target.is_dir(): resolved.append(target) return resolved def export_save_data(save_root, save_paths=()): """Pack an emulator's app-owned save directories into one bounded gzip archive. Only regular files are packed and every ownership, device and symlink attribute is dropped, so the archive describes save content and nothing about the container it came from. Paths stay relative to the profile root, so a restore lands exactly where the capture came from. """ root = Path(save_root).resolve() if save_root is not None else None directories = resolve_save_paths(save_root, save_paths) if not directories: raise ValueError("No save-data directory is present for this runtime") buffer = io.BytesIO() packed = 0 with tarfile.open(fileobj=buffer, mode="w:gz") as archive: for directory in directories: for path in sorted(directory.rglob("*")): if path.is_symlink() or not path.is_file(): continue packed += 1 if packed > MAX_SAVE_DATA_ENTRIES: raise ValueError("The save directories hold more files than one revision may carry") info = archive.gettarinfo(str(path), arcname=str(path.relative_to(root).as_posix())) info.uid = info.gid = 0 info.uname = info.gname = "" info.mode = 0o644 info.mtime = int(info.mtime) with path.open("rb") as content: archive.addfile(info, content) if buffer.tell() > MAX_SAVE_DATA_BYTES: raise ValueError("The save-data archive exceeds the bounded revision size") if packed == 0: raise ValueError("The configured save directories hold no save data yet") return buffer.getvalue() def import_save_data(save_root, payload, save_paths=()): """Restore a previously exported archive over an emulator's save directories. The archive is operator data that has travelled through the vault, so every member is checked before extraction: only plain relative files are written, nothing may escape the root, and a member must land inside one of the configured save directories. A restore therefore cannot reach an emulator's configuration even if the stored archive was crafted to try. """ root = Path(save_root).resolve() if save_root is not None else None directories = resolve_save_paths(save_root, save_paths) if root is None or not root.is_dir() or not directories: raise ValueError("No save-data directory is present for this runtime") if len(payload) > MAX_SAVE_DATA_BYTES: raise ValueError("The save-data archive exceeds the bounded revision size") with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive: members = archive.getmembers() if len(members) > MAX_SAVE_DATA_ENTRIES: raise ValueError("The save-data archive holds more files than one revision may carry") expanded = 0 validated = [] for member in members: if not member.isfile(): raise ValueError(f"The save-data archive contains an unsupported entry '{member.name}'") if member.size < 0 or member.size > MAX_SAVE_DATA_ENTRY_BYTES: raise ValueError(f"The save-data archive entry '{member.name}' is too large") expanded += member.size if expanded > MAX_SAVE_DATA_EXPANDED_BYTES: raise ValueError("The expanded save-data archive exceeds the restore limit") relative = PurePosixPath(member.name) if relative.is_absolute() or any(part in ("", ".", "..") for part in relative.parts): raise ValueError(f"The save-data archive contains an unsafe path '{member.name}'") destination = (root / Path(*relative.parts)).resolve() if root not in destination.parents: raise ValueError(f"The save-data archive escapes the save directory at '{member.name}'") if not any(directory == destination or directory in destination.parents for directory in directories): raise ValueError(f"The save-data archive targets '{member.name}' outside the save directories") validated.append((member, destination)) restored = 0 for member, destination in validated: destination.parent.mkdir(parents=True, exist_ok=True) source = archive.extractfile(member) if source is None: raise ValueError(f"The save-data archive entry '{member.name}' is unreadable") temporary = None try: with source, tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as output: temporary = Path(output.name) copied = 0 while chunk := source.read(1024 * 1024): copied += len(chunk) if copied > member.size or copied > MAX_SAVE_DATA_ENTRY_BYTES: raise ValueError(f"The save-data archive entry '{member.name}' exceeds its declared size") output.write(chunk) if copied != member.size: raise ValueError(f"The save-data archive entry '{member.name}' is truncated") os.chmod(temporary, 0o644) os.replace(temporary, destination) temporary = None restored += 1 finally: if temporary is not None: temporary.unlink(missing_ok=True) return restored def runtime_environment(process_name): return { **os.environ, "HOME": "/config", "DISPLAY": desktop_display(process_name), "XDG_RUNTIME_DIR": "/config/.XDG", "WAYLAND_DISPLAY": "wayland-0", } def visible_windows(environment, pid=None, name=None): """List visible window ids for the launched title. The launched process id is authoritative: it is the exact emulator Ludarium started, so a hotkey can never reach an unrelated window such as an emulator's own menu or file browser. Matching on the window name stays as a bounded fallback for runtimes that do not publish _NET_WM_PID. """ for query in ([["--pid", str(pid)]] if pid else []) + ([["--name", name]] if name else []): found = subprocess.run( [SETUIDGID, RUNTIME_USER, XDOTOOL, "search", "--onlyvisible", *query], env=environment, capture_output=True, text=True, timeout=5, check=False) windows = [line for line in found.stdout.splitlines() if line.isdigit()] if windows: return windows return [] class EmulatorRuntime: """Owns the single title process a sidecar may run at a time.""" def __init__(self, name, process_name, launch_argv, games, extensions, hotkeys, save_mode, platforms=None, window_name=None, ready_seconds=1.0, save_root=None, save_paths=()): self.name = name self.process_name = process_name self.launch_argv = launch_argv self.games = games self.extensions = extensions self.hotkeys = hotkeys self.save_mode = save_mode self.platforms = platforms self.window_name = window_name self.ready_seconds = ready_seconds self.save_root = save_root self.save_paths = tuple(save_paths) self.lock = threading.Lock() self.active = None def log(self, message): print(f"{self.name}-controller: {message}", flush=True) def resolve(self, value): return resolve_game_path(value, self.games, self.extensions, self.platforms) def environment(self): return runtime_environment(self.process_name) def resolved_save_paths(self): return resolve_save_paths(self.save_root, self.save_paths) @property def save_data_available(self): return len(self.resolved_save_paths()) > 0 def status(self): directories = self.resolved_save_paths() with self.lock: running = self.active is not None and self.active.poll() is None return {"running": running, "pid": self.active.pid if running else None, "saveMode": self.save_mode, "saveData": len(directories) > 0, # Naming what will be captured makes a misconfigured path visible here rather # than at the moment an operator tries to keep their progress. "saveRoot": self.save_root, "savePaths": [str(directory) for directory in directories]} def terminate_locked(self): if self.active is None or self.active.poll() is not None: self.active = None return False self.active.send_signal(signal.SIGTERM) try: self.active.wait(timeout=8) except subprocess.TimeoutExpired: self.active.kill() self.active.wait(timeout=3) self.active = None return True def stop(self): with self.lock: return self.terminate_locked() def launch(self, target): environment = self.environment() with self.lock: self.terminate_locked() self.active = subprocess.Popen( [SETUIDGID, RUNTIME_USER, *self.launch_argv(target)], env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) process = self.active pid = process.pid time.sleep(self.ready_seconds) if process.poll() is not None: self.log(f"{self.name} exited early with code {process.returncode}; " f"display={environment['DISPLAY']}; game={target}") return None return pid def hotkey(self, key): environment = self.environment() with self.lock: pid = self.active.pid if self.active is not None and self.active.poll() is None else None windows = visible_windows(environment, pid, self.window_name) if not windows: self.log(f"no visible {self.name} title window for hotkey {key}; " f"display={environment['DISPLAY']}; pid={pid}") return False subprocess.run( [SETUIDGID, RUNTIME_USER, XDOTOOL, "windowactivate", "--sync", windows[-1], "key", key], env=environment, timeout=5, check=True) return True def build_handler(runtime, token): class Handler(BaseHTTPRequestHandler): server_version = f"Ludarium{runtime.name.capitalize()}Controller/1" def log_message(self, fmt, *args): runtime.log(fmt % args) def do_GET(self): if self.path == "/health": return respond(self, 200, {"status": "healthy"}) if not authorized(self, token): return respond(self, 404, {"message": "Not found"}) if self.path == "/v1/status": return respond(self, 200, runtime.status()) if self.path == "/v1/save-data": return self.export_save_data() return respond(self, 404, {"message": "Not found"}) def export_save_data(self): if not runtime.save_data_available: return respond(self, 409, {"message": f"No {runtime.name} save directory is configured"}) try: payload = export_save_data(runtime.save_root, runtime.save_paths) except (ValueError, OSError, tarfile.TarError) as error: runtime.log(f"save-data export failed: {error}") return respond(self, 409, {"message": str(error)}) self.send_response(200) self.send_header("Content-Type", "application/gzip") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def do_POST(self): if not authorized(self, token): return respond(self, 404, {"message": "Not found"}) if self.path == "/v1/save-data": return self.import_save_data() body = {} try: length = int(self.headers.get("Content-Length", "0")) if length < MIN_REQUEST_BYTES or length > MAX_REQUEST_BYTES: raise ValueError("Invalid request size") body = json.loads(self.rfile.read(length)) if not isinstance(body, dict): raise ValueError("Invalid request body") if self.path == "/v1/launch": return self.launch(body) if self.path == "/v1/action": return self.action(body) return respond(self, 404, {"message": "Not found"}) except (ValueError, json.JSONDecodeError) as error: runtime.log(f"rejected {self.path}: {error}; " f"path={body.get('path') if isinstance(body, dict) else None!r}") return respond(self, 400, {"message": str(error)}) except (OSError, subprocess.SubprocessError) as error: return respond(self, 503, {"message": f"{runtime.name} control operation failed", "detail": str(error)}) def import_save_data(self): if not runtime.save_data_available: return respond(self, 409, {"message": f"No {runtime.name} save directory is configured"}) length = int(self.headers.get("Content-Length", "0")) if length <= 0 or length > MAX_SAVE_DATA_BYTES: return respond(self, 400, {"message": "The save-data archive is empty or exceeds its bound"}) payload = self.rfile.read(length) try: written = import_save_data(runtime.save_root, payload, runtime.save_paths) except (ValueError, OSError, tarfile.TarError, EOFError) as error: runtime.log(f"save-data restore rejected: {error}") return respond(self, 400, {"message": str(error)}) runtime.log(f"restored {written} save-data files") return respond(self, 202, {"state": "restored", "files": written}) def launch(self, body): target = runtime.resolve(body.get("path", "")) pid = runtime.launch(target) if pid is None: return respond(self, 503, {"message": f"{runtime.name} exited before the title started"}) return respond(self, 202, {"state": "starting", "pid": pid, "saveMode": runtime.save_mode}) def action(self, body): action = body.get("action", "") if action == "stop": if not runtime.stop(): return respond(self, 409, {"message": f"No Ludarium-started {runtime.name} title is running"}) return respond(self, 202, {"state": "accepted", "action": action}) if action not in runtime.hotkeys: raise ValueError("Unsupported action") if not runtime.hotkey(runtime.hotkeys[action]): return respond(self, 409, {"message": f"No running {runtime.name} title window was found"}) return respond(self, 202, {"state": "accepted", "action": action}) return Handler def serve(runtime, token, port=CONTROL_PORT): if not token: raise SystemExit(f"A {runtime.name} control token is required") ThreadingHTTPServer(("0.0.0.0", port), build_handler(runtime, token)).serve_forever()