From eb63f469789c5a44f22e138c1880a830e75e022a Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Tue, 28 Jul 2026 18:21:35 +0200 Subject: [PATCH] Drop the iframe wrapper page and fix two real certificate bugs Video and audio no longer share one iframe-based /screen/ page. Firefox refuses to load iframe content signed by a certificate whose warning hasn't been accepted at the top level, with no way to click through inside the iframe (Chrome is more lenient, which is why this briefly looked fine there). "Open Screen" is now a plain top-level link straight to noVNC's own URL (new tab, normal Accept-the-Risk applies), and the "Enable Sound" toggle moved to the main setup page - meant to be left open in its own tab while playing. Because that page now hosts a persistent AudioContext/WebSocket, its auto-refresh no longer fires just because a game is running (only during active installs), since a full-page refresh would have killed that connection every 3s. Two real certificate bugs found and fixed along the way: - CN=NY (a meaningless placeholder) -> CN=localhost + SAN (DNS:localhost, IP:127.0.0.1). Firefox validates the WebSocket-Secure connection's cert against the hostname independently of the page load and rejected the CN mismatch there even after the page-level warning was accepted. - Worse: adding -addext without also pinning basicConstraints=CA:FALSE left the cert defaulting to CA:TRUE - i.e. flagged as a Certificate Authority, not a server cert. Firefox hard-refuses that with no override option at all (not a normal clickable warning), which is why "the warning appears but won't let me proceed" even after the CN fix. Fixed by explicitly setting basicConstraints=critical,CA:FALSE plus keyUsage/extendedKeyUsage=serverAuth. Confirmed fixed: Firefox now shows the same clickable self-signed warning Chrome always did, and the noVNC connection completes successfully after accepting it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz --- CLAUDE.md | 8 +++--- Dockerfile | 13 ++++++++- TODO.md | 38 ++++++++++++++++++++++----- scripts/setup_server.py | 58 +++++++++++++++++------------------------ 4 files changed, 73 insertions(+), 44 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ece4b14..d518041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,8 @@ There is **no single shared desktop/display for video** — unlike the other noV - **`POST /start`: each game gets its own ephemeral X session, not a shared one.** `_free_display_num()` picks an unused display from `GAME_DISPLAY_NUMS` (`:90`-`:99`, one per `MAX_CONCURRENT_GAMES = 10` slot); if none is free, the attempt is refused and `game_errors[name]` is set to a message shown on that game's row (no slot ever gets allocated for it). Otherwise: clean any stale `/tmp/.X{N}-lock`/`/tmp/.X11-unix/X{N}` for that display (the same reason the other noVNC-family projects do this on startup — a prior Xvnc for that display might not have cleaned up after itself), then start `Xvnc :{N}`, `fluxbox`, and `websockify` (bridging `59{N}` → `80{N}`, no `-D`/daemonize flag — needs to stay a normal foreground child so its `Popen` handle actually reflects whether it's still alive, unlike the old shared-desktop `server.sh` which didn't care), wait ~1s for Xvnc to bind, then `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/, env={..., "DISPLAY": f":{N}"})`. All four `Popen` handles plus the display/port numbers are tracked together in `running_procs[name]`. - `POST /stop`: tears down all four processes for that game (game first, then websockify/fluxbox/Xvnc), each `terminate()`d then escalated to `kill()` after a 5s grace period, and cleans up the stale-lock files for that display. - `is_running(name)` does the same full teardown **lazily** if the game process exited on its own (quit or crash) — it won't leave an idle Xvnc/websockify pair holding a slot forever just because nobody clicked Stop. `render_page()` calls this for every tracked game on every load, so the slot count and each row's status stay accurate without polling. -- Each running game's row shows its own "Open Screen" link (only while `Running`) — a plain same-origin relative link to `GET /screen/` (see Audio below for what that route actually renders). -- Per-game install progress (`install_state`), start errors (`game_errors`), and the running-process table (`running_procs`) are all guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page auto-refreshes every 3s while any install is in flight or any game is running, so a game that crashed shows its slot freed up on the next load without the user having to do anything. +- Each running game's row shows its own "Open Screen" link (only while `Running`) — a plain top-level link straight to that game's own noVNC URL (`https://:/`, opens in a new tab). Deliberately **not** an iframe — see Audio below for why. +- Per-game install progress (`install_state`), start errors (`game_errors`), and the running-process table (`running_procs`) are all guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page auto-refreshes every 3s **only** while an install is actively in flight — deliberately *not* also while a game is running (unlike earlier in this project's history), since this page now also hosts the persistent audio toggle (see below) and a full-page refresh would kill that connection. One consequence: a game that crashes/exits on its own won't show its slot as freed until the next manual reload (`is_running()`'s lazy cleanup still runs on that next load, just not automatically every 3s anymore). - **Manifests on the share today**: `stuntcar` (DOSBox), `monkey`/`monkey2`/`atlantis`/`indy3`/`tentacle` (ScummVM). `t7g` still has no manifest and won't appear on the setup page until one's added. **Playing a game — two overlapping paths right now**: @@ -70,7 +70,9 @@ There is **no single shared desktop/display for video** — unlike the other noV - `server.sh` starts `pulseaudio --start --exit-idle-time=-1` once, then `${SCRIPTS_HOME}/pcm_ws_bridge.py ${AUDIO_PORT} parec --device=@DEFAULT_MONITOR@ --format=s16le --rate=48000 --channels=2 --raw --latency-msec=20` (backgrounded) before `exec`-ing `setup_server.py`. **`@DEFAULT_MONITOR@` is the correct macro** — `@DEFAULT_SINK@.monitor` (concatenating the sink macro with a literal `.monitor` suffix) looks like it should work but fails with `Stream error: Invalid argument`; this cost real debugging time, don't reintroduce it. - `pcm_ws_bridge.py` and `pcm-worklet.js` are copies from `docker-common/scripts/` (same copy-not-symlink convention as `signals.sh`) — genuinely reusable, nothing dosbox-specific in either file. `pcm_ws_bridge.py` is a small hand-rolled stdlib-only WebSocket server (handshake via `socket`/`hashlib`/`base64`, manual binary frame writing) since no `websocat`-equivalent package exists in this Debian release and adding a pip dependency would break `setup_server.py`'s stdlib-only philosophy; it just runs an arbitrary command and relays its stdout to every connected client as binary frames, one-directional (server → browser) by design. `pcm-worklet.js` is the matching `AudioWorkletProcessor`: converts incoming Int16 PCM to Float32 and plays it through a small ring buffer that absorbs network jitter. - **Audio is a single shared mix, not per-game** — every running game's audio ends up mixed into PulseAudio's one default sink (normal PulseAudio behavior, multiple clients to one sink just mix), and there is exactly one bridge process/port (`AUDIO_PORT`) for the container's entire lifetime, independent of any individual game's start/stop. This was a deliberate simplification from an earlier per-slot-isolated design (one sink/bridge/port per game slot, mirroring the video architecture) that was designed and then dropped as unnecessary complexity — the trade-off is that with more than one game running you can't tell their audio apart. -- `GET /screen/` (in `setup_server.py`) is what the "Open Screen" link on the main page actually points at: a small server-rendered page with an ` @@ -395,18 +393,10 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): path = urlparse(self.path).path if path == "/": - self._send_html(render_page()) + host = self.headers.get("Host", "localhost").rsplit(":", 1)[0] + self._send_html(render_page(host)) elif path == "/pcm-worklet.js": self._send_file(os.path.join(SCRIPTS_HOME, "pcm-worklet.js"), "application/javascript") - elif path.startswith("/screen/"): - name = path[len("/screen/"):] - with state_lock: - info = running_procs.get(name) - if info is None: - self._send_html(f"

{html.escape(name)} is not running.

", status=404) - return - host = self.headers.get("Host", "localhost").rsplit(":", 1)[0] - self._send_html(render_screen_page(name, info["novnc_port"], host)) else: self._send_html("Not found", status=404)