Sign inSign up

vshyba/playdash

By vshyba

•Updated 10 days ago

External monitor for Proxmox using a remote Chromium with a thin Rust HTTP API

Image
0

50

vshyba/playdash repository overview

⁠playdash

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:

  • Proxmox host → LXC container → Docker (or the binary directly) →
  • playdash → Chrome on DRM via Cage.
  • No X server, Wayland session, or desktop environment needed
  • playdash talks to the browser over the Chrome DevTools Protocol (CDP) directly.

playdash kiosk running on a physical display

⁠Architecture

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).
  • The CDP endpoint binds to localhost inside the container only, on a random free port. All control goes through the authenticated HTTP API.
  • The browser profile persists in /data/chrome-profile (Docker volume), so logins survive restarts.
  • Resolution defaults to the display's preferred (native) mode; force one with cage --resolution WxH if needed.

⁠LXC Config

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

⁠Deploy (Proxmox LXC with /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

⁠Docker images and Compose

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.

⁠API

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.

EndpointWhat it does
GET /health{"status":"ok","chrome":"running"} (no auth)
GET /api/challenge{"challenge":"…","ttl_seconds":300} (no auth)
GET /api/statusCurrent url + title
POST /api/navigate {"url":"https://…"}Only http/https/about:blank; else 400
POST /api/reload / /api/back / /api/forward / /api/homeAs named; back/forward 409 at history ends
POST /api/click {"selector":"#btn"}Click first match (DOM + input dispatch, no JS eval)
POST /api/resetWipe 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.

⁠Dashboard

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.

⁠Environment variables

VariableDefaultNotes
PLAYDASH_KEY(required)Bearer token; never logged; missing/empty → exit at startup
PLAYDASH_PORT7391HTTP listen port (0.0.0.0)
PLAYDASH_START_URLabout:blankStartup page and target of /api/home; validated at startup
PLAYDASH_CHROME_BIN/usr/bin/google-chromeImage ships Chrome Stable + Chromium; compose uses Chromium
PLAYDASH_PROFILE_DIR/data/chrome-profileBrowser 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.

⁠Troubleshooting

  • chrome: "unavailable" in /health → docker compose logs: browser exits are logged and retried after 2 s.
  • Cage fails on /dev/dri → check the render group GID (RENDER_GID in .env) and that WLR_DRM_DEVICES names a real card.
  • First boot of a fresh profile takes a few seconds before CDP is ready.
  • Profile volume created by root → browser can't write; fix ownership to uid 10001.
  • dbus errors from the browser in a minimal container are harmless.

⁠Development

cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test

Tag summary

Content type

Image

Digest

sha256:0bedbb1ee…

Size

500.1 MB

Last updated

10 days ago

docker pull vshyba/playdash