Files
Ludarium release export df869819ce
Public source validation / source (push) Successful in 2m16s
Publish Ludarium source
2026-09-03 02:08:58 +02:00

475 lines
21 KiB
Python

#!/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()