External monitor for Proxmox using a remote Chromium with a thin Rust HTTP API
50
A tiny appliance that turns a small box with a video output — e.g. an Intel N5105 mini-PC — into a remotely controlled kiosk display. It runs a real browser (Chrome/Chromium) in kiosk mode on the physical display and exposes a small authenticated HTTP API to control it: navigate, reload, back/forward, click, and reset.
Typical setup:

LAN ──HTTP──▶ playdash (Rust, 0.0.0.0:7391) ──CDP WS──▶ Chrome/Chromium (127.0.0.1:<ephemeral>)
│ renders via
▼
Cage (Wayland on /dev/dri, DRM/KMS)
playdash launches and supervises the browser (restart on crash, reconnect CDP, clean shutdown on SIGTERM/SIGINT)./data/chrome-profile (Docker volume), so logins survive restarts.cage --resolution WxH if needed.You need to pass /dev/dri/card0 to the LXC container, from the host. I did it like this for N5105.
lxc.cgroup2.devices.allow: c 226:0 rwm
lxc.cgroup2.devices.allow: c 226:128 rwm
lxc.mount.entry: /dev/dri/card0 dev/dri/card0 none bind,optional,create=file
lxc.mount.entry: /dev/dri/renderD128 dev/dri/renderD128 none bind,optional,create=file
lxc.mount.entry: /dev/tty0 dev/tty0 none bind,optional,create=file
/dev/dri)getent group render
cp .env.example .env # set PLAYDASH_KEY and RENDER_GID (getent group render)
docker compose up -d --build
That's the whole deployment. The compose file encodes everything: init,
restart: unless-stopped, shm_size: 1g, /dev/dri/card0 + render group,
Cage entrypoint on DRM with software (pixman) rendering, and Chromium with
--ozone-platform=wayland --disable-gpu --no-sandbox (the sandbox can't work
under Docker's default seccomp; see below).
Verify:
curl http://HOST:7391/health
curl -H "Authorization: Bearer $PLAYDASH_KEY" http://HOST:7391/api/status
Published images are available on Docker Hub:
vshyba/playdash:latest uses Cage with a physical DRM display. The
container needs access to /dev/dri/card0 and the active virtual terminal
(/dev/tty0 and /dev/tty).vshyba/playdash:xvfb uses Xvfb for hosts without a screen.Example for a headless Docker host:
services:
playdash:
image: vshyba/playdash:xvfb
entrypoint: ["/usr/local/bin/playdash-xvfb"]
environment:
PLAYDASH_KEY: ${PLAYDASH_KEY}
TZ: ${TZ:-}
DISPLAY: ${DISPLAY:-:99}
WLR_BACKENDS: x11
ports:
- "7391:7391"
volumes:
- playdash-profile:/data/chrome-profile
volumes:
playdash-profile:
Example for a host with a physical DRM display:
services:
playdash:
image: vshyba/playdash:latest
entrypoint: ["/usr/bin/cage", "--", "/usr/local/bin/playdash"]
devices:
- /dev/dri/card0:/dev/dri/card0
- /dev/tty0:/dev/tty0
- /dev/tty:/dev/tty
group_add:
- "${RENDER_GID:-994}"
environment:
PLAYDASH_KEY: ${PLAYDASH_KEY}
TZ: ${TZ:-}
PLAYDASH_CHROME_BIN: /usr/bin/chromium
PLAYDASH_CHROME_ARGS: >-
--ozone-platform=wayland --disable-gpu --no-sandbox
XDG_RUNTIME_DIR: /run/user/10001
WLR_BACKENDS: drm
WLR_DRM_DEVICES: /dev/dri/card0
WLR_RENDERER: pixman
WLR_LIBINPUT_NO_DEVICES: "1"
ports:
- "7391:7391"
volumes:
- playdash-profile:/data/chrome-profile
volumes:
playdash-profile:
These examples use published images and do not build locally. The repository
compose.yaml remains useful for development and can build the image with
docker compose up -d --build. Set TZ in .env if the browser should
follow the host timezone.
The health, challenge, and bundled static dashboard routes are public. All
control API routes require credentials. Two auth schemes are accepted on
every /api/* control route:
Authorization: Bearer <PLAYDASH_KEY> — the direct path. Simple, but the
key crosses the wire in cleartext.X-Challenge + X-Digest — a one-shot request signature, used by the
bundled dashboard. The signature covers the nonce, HTTP method, path,
query, and body. The bearer key itself is not sent on the wire.This prevents moving a captured request signature to another action and makes signatures single-use with a 5-minute TTL. It does not make an HTTP connection safe from an active network attacker: serve the dashboard over HTTPS (or behind a trusted TLS reverse proxy), because HTTP-delivered JavaScript can be replaced and the key can then be stolen. Direct bearer auth is still supported so shell scripts and existing integrations keep working.
| Endpoint | What it does |
|---|---|
GET /health | {"status":"ok","chrome":"running"} (no auth) |
GET /api/challenge | {"challenge":"…","ttl_seconds":300} (no auth) |
GET /api/status | Current url + title |
POST /api/navigate {"url":"https://…"} | Only http/https/about:blank; else 400 |
POST /api/reload / /api/back / /api/forward / /api/home | As named; back/forward 409 at history ends |
POST /api/click {"selector":"#btn"} | Click first match (DOM + input dispatch, no JS eval) |
POST /api/reset | Wipe profile and restart browser fresh (destructive) |
GET / | Bundled React dashboard (no auth; client-side challenge-response) |
Bearer example:
curl -X POST -H "Authorization: Bearer $PLAYDASH_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/dashboard"}' http://HOST:7391/api/navigate
curl -X POST -H "Authorization: Bearer $PLAYDASH_KEY" http://HOST:7391/api/reload
Challenge-response example (shell):
KEY=$PLAYDASH_KEY
CH=$(curl -s http://HOST:7391/api/challenge | jq -r .challenge)
BODY='{"url":"https://example.com/dashboard"}'
SIGNING_INPUT=$(printf 'POST\n/api/navigate\n%s%s' "$BODY" "$CH")
DIG=$(printf '%s' "$SIGNING_INPUT" | openssl dgst -sha256 -hmac "$KEY" -hex | awk '{print $NF}')
curl -X POST -H "X-Challenge: $CH" -H "X-Digest: $DIG" -H "Content-Type: application/json" \
-d "$BODY" http://HOST:7391/api/navigate
There is deliberately no arbitrary-JavaScript/eval endpoint.
http://HOST:7391/ serves a small single-page React app that drives every
control endpoint above. It asks for your PLAYDASH_KEY once per browser
tab, stores it in sessionStorage, and then uses challenge-response for
every request — the key itself is never put on the wire. The bundle is
embedded into the playdash binary (React, ReactDOM and pure-JS SHA-256 for
the HMAC), so it works without internet access. For security, use HTTPS:
over plain HTTP on a LAN, an active network attacker can replace the
JavaScript and steal the key.
The bearer key is held in the browser tab only. Closing the tab discards it. There is no server-side session.
| Variable | Default | Notes |
|---|---|---|
PLAYDASH_KEY | (required) | Bearer token; never logged; missing/empty → exit at startup |
PLAYDASH_PORT | 7391 | HTTP listen port (0.0.0.0) |
PLAYDASH_START_URL | about:blank | Startup page and target of /api/home; validated at startup |
PLAYDASH_CHROME_BIN | /usr/bin/google-chrome | Image ships Chrome Stable + Chromium; compose uses Chromium |
PLAYDASH_PROFILE_DIR | /data/chrome-profile | Browser user-data dir |
PLAYDASH_CHROME_ARGS | (empty) | Extra flags, e.g. --ozone-platform=wayland; security flags (--remote-debugging*, --user-data-dir) are ignored |
DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR etc. pass through untouched.
chrome: "unavailable" in /health → docker compose logs: browser exits are logged and retried after 2 s./dev/dri → check the render group GID (RENDER_GID in .env) and that WLR_DRM_DEVICES names a real card.cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
Content type
Image
Digest
sha256:0bedbb1ee…
Size
500.1 MB
Last updated
10 days ago
docker pull vshyba/playdash