Drop the iframe wrapper page and fix two real certificate bugs
Video and audio no longer share one iframe-based /screen/<name> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
This commit is contained in:
@@ -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/<name>, 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/<name>` (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://<host>:<novnc_port>/`, 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/<name>` (in `setup_server.py`) is what the "Open Screen" link on the main page actually points at: a small server-rendered page with an `<iframe>` onto that game's noVNC URL, plus an "Enable Sound" button (browsers require a user gesture before audio can start) that creates an `AudioContext`, loads `/pcm-worklet.js` via `audioWorklet.addModule`, and opens a `WebSocket` to `AUDIO_PORT`, piping incoming binary messages into the worklet node. The host used to build the iframe/WebSocket URLs is read server-side from the incoming request's `Host:` header — no client-side hostname-guessing JS needed for this page (unlike the old global noVNC link it replaced).
|
||||
- **The "Enable Sound" toggle lives on the main setup page (`render_page()`), not a per-game page** — creates an `AudioContext`, loads `/pcm-worklet.js` via `audioWorklet.addModule`, opens a `WebSocket` to `AUDIO_PORT`, and pipes incoming binary messages into the worklet node. Meant to be left running in its own tab for as long as you're playing (hence the auto-refresh restriction above). `host` is read server-side from the incoming request's `Host:` header — no client-side hostname-guessing JS needed.
|
||||
- **Video and audio are deliberately on separate pages, not one `<iframe>`-based wrapper.** An earlier version had `GET /screen/<name>` render an iframe onto noVNC plus the audio button on one page — dropped after finding that 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 when only tested there). Now "Open Screen" just opens noVNC's own URL directly in a new tab, where the normal top-level "Accept the Risk and Continue" flow applies.
|
||||
- **The self-signed cert's `CN`/SAN/extensions all matter for Firefox specifically.** Generated with `CN=localhost` + `subjectAltName=DNS:localhost,IP:127.0.0.1` (not the meaningless placeholder `CN=NY` this project originally inherited) — Firefox validates the WebSocket-Secure connection's certificate against the hostname *independently* of the page load, and rejected a CN mismatch there even after the top-level HTTPS warning had been accepted. Only covers access via `localhost`/`127.0.0.1`; a LAN IP or other hostname will hit the same mismatch, since a static cert can't be pre-populated with every possible address. **Also needs explicit `basicConstraints=critical,CA:FALSE` plus `keyUsage`/`extendedKeyUsage=serverAuth`** — without them, `openssl req -x509 -addext ...` defaults to `CA:TRUE`, and Firefox hard-refuses (no override option at all, unlike a normal self-signed warning) a CA-flagged cert used as a TLS server cert. If the cert's `openssl req` invocation in the Dockerfile is ever touched again, keep all of these `-addext` flags together.
|
||||
- Realistic end-to-end latency lands somewhere in the tens-to-~150ms range (`parec`'s own buffering plus the worklet's small jitter-absorbing ring buffer) — not literally "tens of ms" best-case, but nowhere near the 1-3s a compressed-stream (`ffmpeg` → mp3/ogg → `<audio>` tag) approach would cost, which is why that approach was rejected up front.
|
||||
|
||||
**Signal handling** (`scripts/server_start.sh` + `scripts/signals.sh`): identical pattern to the other noVNC-family projects — Docker sends `SIGTERM` to PID 1 on `docker stop`, `server_start.sh` traps it, forwards it to the child process tree, waits for the descendants to exit, and re-raises `128 + signal number` as its own exit code. Generic (`$APP` passed as an argument), not specific to `server.sh`.
|
||||
|
||||
+12
-1
@@ -20,7 +20,18 @@ RUN apt-get update && \
|
||||
# every game's audio ends up mixed into Pulse's one default sink, captured by pcm_ws_bridge.py.
|
||||
RUN printf 'pcm.!default pulse\nctl.!default pulse\n' > /etc/asound.conf
|
||||
|
||||
RUN openssl req -x509 -nodes -newkey rsa:2048 -keyout /tmp/novnc.key -out /tmp/novnc.pem -days 3650 -subj "/C=US/ST=NY/L=NY/O=NY/OU=NY/CN=NY emailAddress=email@example.com"
|
||||
# CN/SAN set to localhost/127.0.0.1: some browsers (Firefox) validate the WebSocket-Secure
|
||||
# connection's certificate against the hostname independently of the page load, and reject a
|
||||
# CN mismatch there even after you've clicked through the warning for the HTML page itself.
|
||||
# basicConstraints/keyUsage/extendedKeyUsage must be set explicitly to mark this as a leaf
|
||||
# (end-entity) server cert - without them, openssl req -x509 defaults to CA:TRUE, which
|
||||
# Firefox refuses outright (not a clickable warning, a hard block) for a TLS server cert.
|
||||
RUN openssl req -x509 -nodes -newkey rsa:2048 -keyout /tmp/novnc.key -out /tmp/novnc.pem -days 3650 \
|
||||
-subj "/C=US/ST=NY/L=NY/O=NY/OU=NY/CN=localhost" \
|
||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
|
||||
-addext "basicConstraints=critical,CA:FALSE" \
|
||||
-addext "keyUsage=critical,digitalSignature,keyEncipherment" \
|
||||
-addext "extendedKeyUsage=serverAuth"
|
||||
RUN touch /root/.Xauthority
|
||||
|
||||
RUN mkdir -p ${GAMES_HOME}
|
||||
|
||||
@@ -92,9 +92,35 @@
|
||||
`server.sh` now starts `pulseaudio --start --exit-idle-time=-1` and the bridge
|
||||
(`pcm_ws_bridge.py 7199 parec --device=@DEFAULT_MONITOR@ ...`) once, before `exec`-ing
|
||||
`setup_server.py` — both live for the container's whole lifetime, independent of any
|
||||
game's start/stop. `setup_server.py` gained `GET /screen/<name>` (an iframe onto the
|
||||
game's noVNC screen plus an "Enable Sound" button — browsers require a user gesture
|
||||
before audio can start) and `GET /pcm-worklet.js`; the "Open Screen" link is now a plain
|
||||
same-origin relative link (`/screen/<name>`) instead of the old client-side-JS-built
|
||||
cross-port link, since the wrapper page itself now handles connecting to the right ports
|
||||
(reading the actual host from the request's own `Host:` header server-side).
|
||||
game's start/stop.
|
||||
|
||||
**No iframe, no per-game screen page** — tried an `<iframe>`-based `/screen/<name>` wrapper
|
||||
first (embedding noVNC + an Enable Sound button on one page), but 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 it
|
||||
briefly looked like it worked). Settled on: "Open Screen" is a plain top-level link straight
|
||||
to noVNC's own URL (opens in a new tab — normal "Accept the Risk and Continue" applies
|
||||
there), and the one "Enable Sound" toggle lives on the main setup page instead, meant to be
|
||||
left open in its own tab for as long as you're playing. Also regenerated the self-signed
|
||||
cert with `CN=localhost` + SAN (`DNS:localhost`, `IP:127.0.0.1`) instead of the old
|
||||
meaningless `CN=NY` — Firefox validates the WebSocket-Secure connection's cert against the
|
||||
hostname independently of the page load and was rejecting the CN mismatch there even after
|
||||
the page-level warning was accepted. Only fixes access via `localhost`/`127.0.0.1`; a LAN IP
|
||||
or other hostname would hit the same mismatch again, since a static cert can't cover every
|
||||
possible address in advance.
|
||||
|
||||
**A second, worse cert bug turned up right after**: adding `-addext` without also pinning
|
||||
`basicConstraints=CA:FALSE` left `openssl req -x509` defaulting to `CA:TRUE` — i.e. the
|
||||
generated cert claimed to be a Certificate Authority, not a normal server certificate.
|
||||
Firefox hard-refuses a CA-flagged cert used as a TLS server cert (not an overridable
|
||||
"Advanced -> Accept the Risk" warning, just a dead end), which is exactly why "the warning
|
||||
appears but won't let me proceed" even after the CN fix above. Chrome is lenient about this
|
||||
too, which is why it kept looking fine there. Fixed by explicitly setting
|
||||
`basicConstraints=critical,CA:FALSE`, `keyUsage=critical,digitalSignature,keyEncipherment`,
|
||||
and `extendedKeyUsage=serverAuth` in the same `openssl req -x509 -addext ...` call.
|
||||
|
||||
Because the setup page now hosts a persistent `AudioContext`/`WebSocket`, its
|
||||
`<meta http-equiv="refresh">` auto-refresh was narrowed to only fire while an install is
|
||||
actively in progress — it used to also refresh continuously whenever any game was running
|
||||
(to catch a crashed game's slot being freed), which would have torn down the live audio
|
||||
connection every 3 seconds.
|
||||
|
||||
+24
-34
@@ -33,9 +33,21 @@ Audio is deliberately *not* per-game: server.sh starts one PulseAudio daemon
|
||||
and one pcm_ws_bridge.py (from docker-common) for the whole container's
|
||||
lifetime, capturing PulseAudio's single default sink. Every running game's
|
||||
audio mixes together there (normal PulseAudio behavior, no per-game routing
|
||||
needed), and every /screen/<name> page connects to that same shared
|
||||
AUDIO_PORT - simpler than per-slot audio isolation, at the cost of not being
|
||||
needed) - simpler than per-slot audio isolation, at the cost of not being
|
||||
able to tell games' audio apart when more than one is running.
|
||||
|
||||
Video and audio are deliberately on separate pages/tabs, not one wrapper
|
||||
page with an <iframe>: an iframe onto the noVNC HTTPS port hits browsers
|
||||
(Firefox in particular) refusing to load embedded content signed by a
|
||||
certificate whose warning hasn't been accepted at the top level, with no
|
||||
way to click through *inside* the iframe. So "Open Screen" is a plain
|
||||
top-level link straight to noVNC's own URL (opens in a new tab, normal
|
||||
"Accept the Risk and Continue" applies), and the one "Enable Sound" toggle
|
||||
lives on this page instead, meant to be left open in its own tab for as
|
||||
long as you're playing. Because of that, this page's auto-refresh is
|
||||
deliberately limited to only while an install is actively in progress -
|
||||
refreshing while a game is running would tear down the live AudioContext/
|
||||
WebSocket every few seconds.
|
||||
"""
|
||||
|
||||
import html
|
||||
@@ -253,7 +265,7 @@ def stop_game(name):
|
||||
running_procs.pop(name, None)
|
||||
|
||||
|
||||
def render_page():
|
||||
def render_page(host):
|
||||
games = discover_games()
|
||||
sizes = get_zip_sizes(games)
|
||||
with state_lock:
|
||||
@@ -265,7 +277,9 @@ def render_page():
|
||||
slots_used = len(novnc_ports)
|
||||
|
||||
any_pending = any(v in ("downloading", "extracting") for v in pending.values())
|
||||
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending or slots_used else ""
|
||||
# Deliberately not refreshing just because slots_used > 0: this page hosts the one
|
||||
# persistent "Enable Sound" AudioContext/WebSocket, and a full-page refresh would kill it.
|
||||
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending else ""
|
||||
|
||||
def button(action, name, label):
|
||||
return (
|
||||
@@ -285,7 +299,7 @@ def render_page():
|
||||
screen_html = ""
|
||||
if name in novnc_ports:
|
||||
status_html = '<span class="status busy">Running</span>'
|
||||
screen_html = f'<a href="/screen/{name}" target="_blank">Open Screen</a>'
|
||||
screen_html = f'<a href="https://{host}:{novnc_ports[name]}/" target="_blank">Open Screen</a>'
|
||||
actions = button("stop", name, "Stop")
|
||||
elif status in ("downloading", "extracting"):
|
||||
status_html = f'<span class="status busy">{status}...</span>'
|
||||
@@ -322,33 +336,17 @@ button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }}
|
||||
.status.busy {{ color: #a70; font-weight: bold; }}
|
||||
.status.err {{ color: #c22; }}
|
||||
.slots {{ margin-bottom: 1.5em; }}
|
||||
#audio-btn {{ padding: 0.4em 1em; margin-bottom: 1.5em; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="audio-btn">Enable Sound</button>
|
||||
<p class="slots">{slots_used}/{MAX_CONCURRENT_GAMES} game slots in use</p>
|
||||
<h1>DOSBox games</h1>
|
||||
<table>
|
||||
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th><th></th></tr>
|
||||
{''.join(rows) if rows else '<tr><td colspan="7">No game manifests found on the share</td></tr>'}
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def render_screen_page(name, novnc_port, host):
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{html.escape(name)}</title>
|
||||
<style>
|
||||
html, body {{ margin: 0; padding: 0; height: 100%; overflow: hidden; }}
|
||||
iframe {{ border: none; width: 100%; height: 100%; }}
|
||||
#audio-btn {{ position: fixed; top: 1em; right: 1em; z-index: 10; padding: 0.5em 1em; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="audio-btn">Enable Sound</button>
|
||||
<iframe src="https://{host}:{novnc_port}/vnc.html?autoconnect=true"></iframe>
|
||||
<script>
|
||||
document.getElementById('audio-btn').addEventListener('click', async () => {{
|
||||
const btn = document.getElementById('audio-btn');
|
||||
@@ -360,7 +358,7 @@ document.getElementById('audio-btn').addEventListener('click', async () => {{
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onmessage = (event) => node.port.postMessage(event.data, [event.data]);
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sound enabled';
|
||||
btn.textContent = 'Sound enabled - keep this tab open while playing';
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
@@ -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"<p>{html.escape(name)} is not running.</p>", 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user