This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user