Based on sillygoose but stripped down to display a webpage and offer a second endpoint to adjust the page.
  • Python 35.4%
  • Shell 35.4%
  • JavaScript 21.2%
  • CSS 5.7%
  • HTML 2.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Kurt Anderson 89b08e68f5 Judge the stream-stalled trigger over a 1m window, not one poll
A single flat 10s sample, or a reader attaching before an on-demand
pull's first bytes, fired and resolved within seconds across every
camera. Now bytes must stay flat for {$MEDIAMTX.STALL.WINDOW} with a
reader attached throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:43:20 -07:00
api Make camera rotation a toggle on any layout 2026-10-06 17:13:17 -07:00
files Make camera rotation a toggle on any layout 2026-10-06 17:13:17 -07:00
icons Add the QS Bug source icon 2026-09-22 09:42:07 -07:00
scripts Make camera rotation a toggle on any layout 2026-10-06 17:13:17 -07:00
web Make camera rotation a toggle on any layout 2026-10-06 17:13:17 -07:00
web-cameras Raise the rotating grid's pre-load lead from 10s to 15s 2026-10-06 17:31:24 -07:00
zabbix Judge the stream-stalled trigger over a 1m window, not one poll 2026-10-09 13:43:20 -07:00
.gitignore Add admin remote input for the kiosk display via Chrome DevTools Protocol 2026-09-18 18:15:56 -07:00
check.sh Add USB media mode: loop videos from an approved USB drive via mpv 2026-09-23 14:53:44 -07:00
Dell-minipc-bios.md Set time as UTC so Ubuntu installer runs properly 2026-09-24 15:48:00 -07:00
provision.sh Monitor camera playback quality and NVR errors for the 4K comparison 2026-10-06 14:56:39 -07:00
README-commands.md Make camera rotation a toggle on any layout 2026-10-06 17:13:17 -07:00
README.md Raise the rotating grid's pre-load lead from 10s to 15s 2026-10-06 17:31:24 -07:00

tv-wall-dashboard

Turns a generic x86 mini PC into a headless kiosk box: it boots straight into a full-screen Google Chrome window pointed at an internal dashboard URL, and exposes a small web control panel (on the same box) to change that URL or the browser's zoom level without SSH'ing in.

Intended to live on the corporate intranet only — the control panel is Basic Auth-gated, and plain HTTP unless a dedicated cert has been pushed to the box (see "Setup"), in which case it's HTTPS with a redirect from port 80.

Already provisioned a box and just need to manage it day-to-day (update camera feeds, restart a service, bounce a display)? See README-commands.md instead of this file.

Requirements

  • A generic x86 mini PC (NUC-style), fresh minimal Ubuntu 26.04 LTS Server install ("Resolute Raccoon"), using the installer's "Ubuntu Server (minimized)" profile (headless, no desktop environment). Previously Debian; standardized on Ubuntu for org-wide consistency. provision.sh's apt-component handling (enabling universe/multiverse for the VA-API driver) and package names are Ubuntu's — not tested against Debian or other distros anymore.
  • systemd-based init (standard for Ubuntu). Not tested against init systems that don't ship systemd (e.g. antiX).
  • No snap: provision.sh purges snapd entirely (and anything preinstalled under it) and pins it out via an apt preference, since Chrome comes from a specific, pinned .deb and everything else from apt — there's no snap surface on this box at all.
  • DRM/Widevine: unlike the distro's own chromium package (a pure open-source build with no proprietary codecs), Google Chrome bundles the Widevine CDM directly (/opt/google/chrome/WidevineCdm/), confirmed present on the (former Debian) test box. That resolves the DRM gap the distro's chromium had as a side effect of the HEVC-motivated browser switch below — not itself tested end-to-end against a real DRM-protected stream (e.g. YouTube TV), since it isn't this project's use case.

Installing Ubuntu headless

Use the Ubuntu Server ISO. At the installer's "Choose type of install" screen, pick "Ubuntu Server (minimized)" rather than the default — this is the direct equivalent of Debian's tasksel-deselected minimal install, and simpler: the Server ISO never offers a desktop environment to deselect in the first place, so there's no separate step for that.

Import your SSH public key during install (the installer prompts for this — by GitHub/Launchpad username, fetched over the network, or pasted directly), or ssh-copy-id afterwards. Either way, get key-based login working before starting the setup below.

Some install runs leave the default systemd target as graphical.target even with no desktop packages present — harmless (multi-user.target is still a dependency either way) but not what a headless box should have. No manual fix needed: provision.sh normalizes this automatically.

Setup

A fresh minimal install has no git yet, so get the repo onto the box by some other means first — e.g. from a machine that already has it cloned:

scp -r tv-wall-dashboard <user>@<box>:
ssh -t <user>@<box>
cd tv-wall-dashboard
sudo ./provision.sh

provision.sh is idempotent — safe to re-run, and the only interactive step. That one sudo call needs a password (nothing on a fresh box is passwordless yet), but the script's first action is to grant the invoking user passwordless sudo for everything after — so this is the last password prompt you'll need. It also installs git, so from here on you can git clone/git pull the repo directly instead of scp'ing it again.

It will also prompt once for a Basic Auth username/password for the control panel. Reboot afterwards to verify the kiosk session comes up.

If this box has a dedicated TLS cert, push it to ~/tls-certs/<hostname>.pfx (password-protected PKCS#12) before running provision.sh — it converts the .pfx to the PEM cert+key nginx needs, prompting for its password each time the .pfx is newer than the already-converted files, and switches the control panel to HTTPS (with a redirect from port 80) automatically. A box with no .pfx pushed keeps running plain HTTP, same as before.

Backing up and restoring a box

A provisioned box accumulates real state that only exists on that one machine and isn't in git: the dashboard URL/zoom per display, camera grid layouts, camera display-label overrides, applied-URL history, bookmarked favorites, the real camera RTSP credentials, and the control panel's Basic Auth password. provision.sh is careful never to overwrite any of this once present, but a fresh OS install wipes the whole disk — so before wiping a box for a fresh build, pull its state down first:

./scripts/backup-box.sh <host>

This SSHes in, finds whichever of the following exist, and saves them (plus each file's owner/group/mode) under the gitignored backups/<host>/:

  • /etc/kiosk-dashboard/config*.json — per-display dashboard URL/zoom
  • /var/lib/kiosk-dashboard/history.json and favorites.json
  • /var/lib/kiosk-dashboard/camera-layout*.json — per-display camera grid layout
  • /var/lib/kiosk-dashboard/camera-labels.json — operator-set camera display-label overrides
  • /etc/kiosk-cameras/cameras.env — contains live RTSP credentials
  • /etc/nginx/kiosk-dashboard.htpasswd — contains the control panel's Basic Auth hash
  • ~/tls-certs/<hostname>.pfx — a password-protected TLS cert, if this box has a dedicated one (see "Setup")

backups/ is gitignored specifically because of the last three — treat that folder like any other secrets dump, never commit it.

Deliberately not backed up: the camera HQ lease file, mediamtx.yml, web-cameras/cameras.json, and /etc/nginx/ssl/*.crt/*.key (all regenerated — the first three from cameras.env, the cert/key from the backed-up .pfx — not hand-maintained), the NSS certificate trust store (derived from files/certs/*.pem, a separate vendored input, not per-box state), and the passwordless-sudo grant under /etc/sudoers.d/ (re-created fresh by provision.sh itself, and dev/test-only regardless — see "Security" below).

Also not backed up: /etc/kiosk-dashboard/remote-input-enabled, the flag that turns on the control panel's screen control (see "Remote page interaction"). A rebuilt box comes back with screen control off. If it had it on before, turn it on again with sudo touch /etc/kiosk-dashboard/remote-input-enabled. Leaving it out is deliberate: it's an access decision to make again for each box, not state to carry over.

After a fresh install and a normal sudo ./provision.sh run, restore the backup before rebooting:

./scripts/restore-box.sh backups/<host>/<timestamp> <host>

This only pushes content for files that actually differ from what's already on the box (by hash, not mtime) and always re-applies each file's recorded ownership/mode (falling back to provision.sh's own conventions if an older backup has no recorded ownership). The diff check matters because rewriting cameras.env — even with identical content — retriggers cameras-apply.path, which regenerates mediamtx.yml/cameras.json and restarts mediamtx, dropping every live camera connection; skipping unchanged files avoids that when restoring onto a box that's already live (e.g. re-syncing config rather than recovering from a fresh install). Rebooting afterwards brings the kiosk session up pointed at the restored config.

Adding control panel users

The control panel is meant for exec admins, so it supports more than the one Basic Auth login provision.sh creates interactively at setup time. Two ways to add or update one (same underlying file, same htpasswd-computed bcrypt hash, cost factor 12):

  • SSH: sudo /opt/kiosk-dashboard/kiosk-add-user.sh <username> — prompts for a password (hidden input, typed twice). Works whether <username> is new or already exists (updates its password either way).
  • Control panel: the "Add a control panel user" section at the bottom of the page (below the per-display sections) — a username/password form, gated by the same Basic Auth as everything else in the panel. Under the hood this queues the request for a root-run script to apply (the API itself has no access to /etc/nginx/, by design — see "How it works"), which typically lands within a second or two.

Removing a user isn't exposed in either of those — it's intentionally SSH-only: sudo <edit> /etc/nginx/kiosk-dashboard.htpasswd and delete the matching username:hash line by hand (e.g. sudo sed -i '/^<username>:/d' /etc/nginx/kiosk-dashboard.htpasswd). Takes effect immediately, no service restart needed.

Changing the dashboard

Visit http://<box-hostname-or-ip>/ (or https:// if this box has a dedicated TLS cert — see "Setup"), sign in with the Basic Auth credentials set during provisioning. The control panel shows one section per currently-connected display output (see "Multi-display support" below), each with its own Dashboard and Camera feeds tab pair (see "Camera grid" below for the latter) — switching between them switches what that display is showing, not just which form is on screen.

On the Dashboard tab, submit a new URL and/or zoom level. The display goes blank for a couple of seconds while Chrome restarts to pick up the change. The page header names the box's own hostname, so it's obvious which kiosk you're controlling if you have more than one control panel open at once.

Previously-used URLs appear under History — click one to apply it immediately (no separate Apply click needed). Reset to default puts the screen back on the IP-address landing page without needing to know or retype it.

Favorites, shown above the form, work the same way (click to apply immediately, current zoom setting preserved) but are a curated list rather than an automatic record of recent URLs — see "How it works" for where that list lives.

Every row in both lists has a star: grey means "not a favorite," gold means "is a favorite." Clicking a grey star in History adds that URL to Favorites, titled after that page's <title> when one could be fetched (falls back to the URL itself otherwise — e.g. for a JS-rendered dashboard whose real title is only set after the page loads, which a plain fetch can't see). Rename a favorite by editing favorites.json directly if you want something nicer. Clicking a star in Favorites removes it from the list.

Multi-display support

Production hardware (Dell Pro Micro) has two video outputs (HDMI + DisplayPort), and one box can drive both TVs at once when both are cabled up. GET /api/displays reports which connectors are currently connected (via files/list-active-connectors.sh, a plain /sys/class/drm/*/status scan — no extra tooling needed), and the control panel renders one full Dashboard+Camera-feeds section per active display, labeled by output type ("DisplayPort"/"HDMI"). /api/config and /api/camera-layout both take a required ?display=<connector> query param and read/write a file named for that connector (config.<connector>.json, camera-layout.<connector>.json) — each display's dashboard URL and camera layout are independent. History, Favorites, and camera display-label overrides stay single shared files (history.json, favorites.json, camera-labels.json), usable from any display — a "Dashboard" tab resumes the single most-recently-applied real URL overall, not necessarily what that specific display was last showing, and a renamed camera looks the same on every output, since it's still the same physical camera regardless of which display is showing it.

Status: the control panel, API, kiosk session, and camera grid are all multi-display now — see "Camera grid" below for the grid page's own per-display layout and cross-display HQ arbitration. kiosk-browser.sh is a per-connector supervisor: it rescans for active connectors roughly every 15 seconds and launches one independent Chrome instance per connector (own profile/cache dir under /dev/shm), each pinned to its own physical output by a labwc window rule generated at provision time (provision.sh writes one <windowRule> per connector this exact GPU reports — connected or not — to ~kiosk/.config/labwc/rc.xml). Plugging in a second TV is picked up on the next rescan without a reboot; unplugging one tears down just that connector's Chrome.

Routing a specific Chrome window to a specific physical output is done by window title, not --class/app ID — Chromium's own app-ID handling is unreliable under native Wayland, and labwc has no IPC to query/target a window any other way. Each connector's Chrome is launched (without --kiosk, so it's still a movable window) pointed at a tiny local redirect page titled kiosk-<connector>, which waits briefly before navigating to the real configured URL — long enough for Chrome to actually paint and map that title, which the window rule matches (title="kiosk-<connector>*", the trailing wildcard accounting for Chrome's own " - Google Chrome" title suffix) to move the window onto its output and fullscreen it. A brief non-fullscreen flash of that redirect page on every relaunch is an accepted cosmetic tradeoff, not a bug. Reconfiguring one display (kiosk-apply-config.sh, still triggered by any change under /etc/kiosk-dashboard/) diffs each active connector's own config against a per-connector hash cached under /run/ and restarts only the one that actually changed, via a tracked PID file — the other display is never disturbed.

Display resolutions are matched down to the lowest common one. The DisplayPort output usually drives its TV through a passive DP++ -> HDMI adapter, which caps it at 1080p (the GPU driver drops every mode above the adapter's limit), while the native HDMI port runs the same model of TV at 4K — so the same page renders at a quarter of the size on one screen. files/match-output-modes.sh, run by kiosk-browser.sh at startup and on every ~15s rescan (a replugged TV comes back in its own preferred mode), finds the largest resolution every connected display supports and lowers any display above it to that resolution, at the highest refresh up to 60 Hz. It only ever lowers: a single display, or displays that already match, are left in whatever mode the TV prefers. A display with no mode at exactly the target resolution is left alone and logged once. Changes are logged as kiosk-output-modes in the journal. An active DP -> HDMI 2.0 adapter removes the cap, and with it the need to lower the other TV.

Crash recovery is deliberately layered, not one mechanism, since a plain Chrome crash and a bug in the supervisor script itself need different handling:

  • A Chrome instance crashing or getting killed is handled entirely by kiosk-browser.sh's own per-connector loop — it notices, relaunches within ~2s, and the other display is never touched.

  • kiosk-browser.sh runs as its own systemd unit (kiosk-browser.service, Restart=always), not as a background job spawned from labwc's autostart script — confirmed directly on real hardware that the fire-and-forget version left nothing watching it: if its own process died (as opposed to Chrome), the currently-running displays kept working, but a subsequent crash of either one then went completely unrecovered, invisibly, with kiosk-session.service still reporting healthy throughout (it only ever tracked labwc). systemd now restarts it directly, typically within ~2s.

  • Because a restart of the whole script is now a routine, systemd-driven event rather than a rare full-session restart, its startup deliberately does not blindly kill every Chrome instance the way it used to — it checks each active connector's own supervising loop (/run/kiosk-browser/<connector>.loop.pid) and adopts it if it's still alive, leaving that display's Chrome completely undisturbed. Only a connector whose loop actually died gets its Chrome killed and restarted — confirmed directly that killing just the per-connector loop (leaving its Chrome running) orphans that Chrome permanently otherwise, since nothing else ever notices. Two things had to be true at once for this to actually work, both confirmed the hard way on real hardware: kiosk-browser.service sets KillMode=process (systemd's default, control-group, kills every remaining process in the unit's cgroup whenever the main one exits — which would silently defeat adoption by leaving nothing to adopt in the first place), and the script's own duplicate-instance dedupe check (guards against systemd starting a new instance before an old one has fully exited) has to explicitly spare every currently-recorded loop PID — a per-connector loop is just another bash kiosk-browser.sh process with no distinguishing arguments, indistinguishable by command line from a stray duplicate top-level instance, so an unqualified dedupe kills the exact survivors adoption exists to find. KillMode=process also means a deliberate stop (systemctl stop, or BindsTo=kiosk-session.service cascading a real session restart) needs its own explicit cleanup rather than relying on systemd to do it — a TERM trap in the script tears down every connector gracefully in that case, reusing the same stop_connector an unplugged connector already goes through.

  • Each rescan also probes every Chrome display's page (renderer-health-check.py), recovering it after 2 consecutive failures. A page is unhealthy if any of these is true:

    • It stops answering a DevTools command. The Andon page's renderer livelocked like this on 2026-09-21/22.
    • It answers but gets no animation frame within 3s, meaning it's no longer drawing. On 2026-09-30, HDMI-A-1 lost its GPU context and sat frozen on yesterday's data for hours while still answering DevTools.
    • Chrome logs 50+ GPU-fatal errors a minute (the same incident).

    "Not drawing" recoveries are limited to one every 10 minutes per display, in case a switched-off TV ever looks the same.

  • A display's Chrome is also recycled when its renderer passes KIOSK_RENDERER_MEM_LIMIT_MB (default 3072). The FSD Andon page leaks ~1 GB/hour, and that leak led to both failures above, so it gets a few-second reload every couple of hours instead.

  • Each rescan writes /run/kiosk-browser/<connector>.health.json (plus the older plain-timestamp .heartbeat). It holds the probe result, renderer memory, and this boot's fault-recovery and memory-recycle counts. zabbix/kiosk-displays.conf and the "TV wall kiosk displays" template read it (see zabbix/README.md).

  • Chrome and mpv output goes through kiosk-log-filter.py before the journal. It collapses repeated lines into one summary per minute. On 2026-09-30, the GPU error flood (~40 lines/s) filled journald's 150M cap, and journald purged 12 hours of every service's logs, including the start of the incident.

On a box with an already-provisioned single display, provisioning migrates the legacy config.json/camera-layout.json forward to config.<connector>.json/camera-layout.<connector>.json (copied, not moved) whenever exactly one connector is active at provision time; with zero or multiple connectors active, it warns and leaves migration for manual resolution rather than guessing which display the old files belonged to. Both bare config.json and camera-layout.json are dead weight once migrated — nothing reads either one anymore (the camera grid page now reads its layout through the API too — see "Camera grid" below).

USB media mode

Instead of its dashboards, a box can loop a folder of videos from a USB drive — fullscreen, on every connected display, with audio through each TV's own HDMI/DP output. It's decided once per boot, before the kiosk session starts (kiosk-media-detect.service):

At boot What the displays show
No USB drive Normal dashboards, exactly as before
A USB drive that hasn't been approved A "USB media detected — awaiting approval" page. The drive is not mounted.
An approved drive with videos in /kiosk-videos/ Every video in that folder, looping, via mpv
An approved drive with no /kiosk-videos/ (or no videos in it) Normal dashboards

Preparing a drive

  • Put the videos in a folder named kiosk-videos at the drive's root. Anything elsewhere on the drive is ignored, as is anything in subfolders.
  • They play in filename order (byte-wise), so prefix names to set it: 01-safety.mp4, 02-welcome.mkv, ...
  • Extensions picked up: mp4 m4v mkv mov avi webm ts m2ts mts mpg mpeg wmv flv (case-insensitive). Playback is mpv/ffmpeg, so essentially any codec plays; H.264/HEVC/VP9/AV1 decode on the GPU via VA-API.
  • Format the drive exFAT (or FAT32). NTFS and ext4 also mount; on ext4 the files must be world-readable, since the player runs as the unprivileged kiosk user. macOS's ._* companion files are skipped automatically.

Approving a drive

The on-screen page deliberately doesn't say how to approve a drive — that lives here, and in the notice shown when you SSH in while a drive is waiting. With the drive plugged in:

ssh qsadmin@<box>
sudo /opt/kiosk-dashboard/kiosk-media-allow.sh

It shows the drive (vendor, model, label, USB serial), asks for confirmation, adds it to /etc/kiosk-media/allowed-drives, and switches every display to playback right away — no reboot. From then on that drive goes straight to playback at every boot.

A drive is identified by its USB ID_SERIAL (vendor, model and serial number) — not its filesystem, so reformatting it or swapping the videos keeps it approved. Some cheap drives report no serial number of their own; the script warns before approving one of those, since its ID then matches every drive of the same model. --list, --revoke <ID_SERIAL>, and --rescan (re-check drives now, e.g. an approved stick swapped in without rebooting) are in README-commands.md.

Behavior worth knowing

  • Removing the drive returns the box to its dashboards within seconds (a udev rule re-evaluates on removal). Inserting one into a running box does nothing until the next boot or kiosk-media-allow.sh — so plugging a stick into a live wall can't change what it shows by itself.
  • Each display plays the playlist independently — two displays are not frame-synced.
  • The drive is mounted read-only, nosuid,nodev,noexec, at /media/kiosk-usb. Unapproved drives are never mounted at all.
  • While in media mode the control panel shows a banner, dashboard changes are saved but only take effect once the drive is removed, and the live preview pane can't capture (it only works with Chrome).
  • mpv reuses the same per-connector supervision as Chrome (kiosk-browser.sh): same labwc window rules for placement, same pid files, same 2s respawn if it exits. Its output is in journalctl -t kiosk-browser.<connector>, including a Playing: <file> line each time a video starts.
  • journalctl -t kiosk-media is the durable record of what was plugged in and played: every mode decision logs the drive in use (USB serial, vendor, model, label, filesystem UUID, playlist hash) and each queued file, or the identity of an unapproved drive that was attached, plus who approved or revoked which drive and when. /run/kiosk-media/ holds only the current boot's state.

Audio routing

Each TV gets its own audio, straight to ALSA (no PipeWire/Pulse). media-audio-device.py matches each display's EDID monitor name against the name each HDMI/DP audio device reports in its ELD. That can't tell two identical TVs apart — then it logs a warning and uses the ALSA default, and you set the mapping once in /etc/kiosk-media/media.conf:

AUDIO_DEVICE_DP_1=plughw:CARD=PCH,DEV=3
AUDIO_DEVICE_HDMI_A_2=plughw:CARD=PCH,DEV=7   # or "none" to mute

aplay -l lists the devices; speaker-test -D plughw:CARD=PCH,DEV=3 -c 2 -t wav tells you which TV each one is. Both /etc/kiosk-media/allowed-drives and media.conf are included in scripts/backup-box.sh.

Camera grid

An optional page showing every configured RTSP camera feed on one screen, bridged into something Chrome can actually render (browsers can't play RTSP directly). Not part of the base install — set it up by editing /etc/kiosk-cameras/cameras.env with real credentials:

CAMERA_1_NAME=Loading Dock
CAMERA_1_URL=rtsp://user:pass@192.0.2.10:554/h264Preview_01_main
CAMERA_2_NAME=Front Gate
CAMERA_2_URL=rtsp://user:pass@192.0.2.10:554/h264Preview_02_main

Each tile can optionally overlay this NAME= as a fully opaque label, scaled to the tile's own rendered size (so it stays big enough to cover a camera's own burned-in name/timestamp at any grid size) — off by default, since labeling a feed is the camera owner's job (burned into the stream at the source), not the dashboard's. Toggle it per display via the "Display labels" checkbox on the control panel's Camera feeds tab, saved (and validated) alongside that display's layout through the existing /api/camera-layout endpoint; treat it as a temporary override for a feed that isn't labeled at the source yet, not the primary way to label a camera. It defaults to the bottom-right corner, opposite the usual bottom-left burned-in label, but the adjacent "Position" select can move it bottom-left or bottom-center too, for a feed whose own label lands somewhere else.

The text of that label doesn't have to be cameras.env's own CAMERA_<N>_NAME, though. Click the pencil (✎) next to a feed's name under "Available feeds" in the control panel to give it a friendlier display name — e.g. an engineer's own "10-B3 NW" shown instead as "Tool A Primary Feed" for everyone else. This is stored separately, in /var/lib/kiosk-dashboard/camera-labels.json, so cameras.env itself is never touched; clearing the field reverts to the engineer's original name. It's global, not per-display (the same physical camera looks the same on every output), and reaches the TV wall on the same ~15s poll cadence as a layout or "Display labels" change. Because cameras.json's cam<N> slugs are assigned purely by a camera's position in cameras.env, reordering or removing a camera there can repoint an existing override onto a different physical camera after the next save — the same caveat that already applies to a saved layout's placed cells.

Numbered from 1 with no gaps — the file applies itself (no restart needed) as soon as it's saved. Switch the kiosk to it from the control panel's Camera feeds tab — clicking that tab both opens the layout editor and switches the kiosk over, the same way clicking back to Dashboard resumes whatever real dashboard was showing before (the most recent History entry). The grid page's own URL is never added to History or Favorites — it's a fixed, built-in target, not something to manage as a bookmark.

The grid page is fully display-aware: the URL the control panel applies for it is grid.html?display=<connector> (see cameraGridUrlFor() in web/app.js), and grid.js reads its own connector from location.search to fetch its layout via GET /api/camera-layout?display=<connector> — the same per-display file the control panel's Camera feeds tab writes to — rather than a static file. (files/nginx/tv-wall-cameras.conf narrowly proxies just /api/camera-layout and /api/camera-hq-lease to the control-plane API for this; it's not a blanket /api/ mirror, since this loopback vhost has no reason to give the on-screen grid page reach into POST /api/config//api/favorites.)

Two displays can independently show the camera grid at once, each with its own layout — but only one of them is ever allowed to actually pull a full-quality ("_hq") stream at a time, arbitrated by a server-side lease (POST /api/camera-hq-lease, /var/lib/kiosk-dashboard/camera-hq-lease.json, ~40s TTL against grid.js's 15s poll/renew cadence). This exists because two separate Chrome processes (one per connector, each with its own --user-data-dir — see "Multi-display support") share no storage to coordinate an HQ request between themselves the way two tabs in the same browser could. A display that locally qualifies for HQ (see MAX_CONCURRENT_HQ above) always starts at sub-quality and only upgrades once (and if) the lease is actually granted — a granted/denied flip re-points just that one <video> element, never a full page reload. Switching a display away from Cameras releases its held lease immediately server-side (apply_config()), rather than waiting out the TTL, so the other display doesn't see a visible handoff delay. Confirmed directly on real dual-output hardware: putting both displays into a single-camera view simultaneously granted the lease to exactly one of them, the other stayed at sub-quality throughout, and journalctl -u mediamtx showed zero RTP packet loss for the whole test.

Check which stream variant your cameras actually expose, and how many you can run at once, before assuming "main" is safe to use everywhere. Built and tested against a 14-channel Reolink-style NVR whose "main" stream turned out to be 4K H.265; the "sub" stream was 640x360 H.264. mediamtx's own logs (journalctl -u mediamtx) name the codec for each path as it connects — check there rather than assuming your hardware behaves the same way.

This kiosk runs Google Chrome, not the distro's own chromium package, specifically so HEVC/H.265 decode works at all: open-source Chromium builds generally ship with it compiled out entirely (patent licensing — see Debian bug #1056562, which describes an upstream limitation, not a Debian-only one), so every "main" tile just stayed blank without it, no matter the hardware. Ubuntu's own chromium "package" is snap-only in most releases anyway — another reason not to use it here (see "No snap" under Requirements). Google's Chrome build has shipped hardware-accelerated HEVC decode via VA-API since Chrome 108, and it does decode this NVR's real "main" streams — confirmed directly on the (former Debian) test box (VA-API driver: intel-media-va-driver-non-free, real 4K H.265 playback through the actual mediamtx→HLS→hls.js pipeline, in the real kiosk Wayland session rather than headless Chrome, which has its own unrelated VA-API-initialization limitation). Not yet re-confirmed on Ubuntu — same package names, but a distro/kernel change is exactly the kind of thing that can alter VA-API behavior even so; re-verify from scratch before relying on this. Using Google's own Chrome binary and Intel's own already-licensed VA-API decode path was the more comfortable posture here for the patent-encumbrance question a project shipping its own HEVC-capable decoder (e.g. a custom Chromium build) would otherwise have to answer itself — still worth confirming against your org's own licensing posture before relying on this elsewhere.

Decode isn't the constraint anymore — this NVR's ability to serve many concurrent "main" streams is. With cameras-apply.sh forcing RTSP source pulls onto TCP (pathDefaults: rtspTransport: tcp in the generated mediamtx.yml — ruling out plain UDP unreliability as the cause), pulling this NVR's "main" streams showed real, sustained RTP packet loss and corrupted frames (mediamtx logging "RTP packets lost" and "invalid fragmentation unit" repeatedly) once more than a few were pulled at once. A controlled test (each concurrency level held for 30s, atop the ~14 "sub" streams this box normally runs) gave real numbers: 1 concurrent "main" stream — 0 packets lost, clean; 2 — 58 lost in one burst; 3 — 9 lost in one burst; 4 — four separate bursts (144 packets total) plus 31 "invalid fragmentation unit" errors, i.e. actual decode-breaking corruption, not just borderline loss; all fourteen on "main" — sustained loss across several cameras within seconds. Not a clean step function (2 lost more than 3 in that run — the NVR's encoder hiccups are bursty, not linear in concurrency), but 1 is the only level with zero loss. That points at the NVR's own encoder/output capacity, not Chrome, this box, or the network.

"Main" is off by default. CAMERA_HQ in cameras.env (see cameras.env.example) must be set to yes for any of the "main"/HQ behavior below to apply. Off, cameras-apply.sh doesn't define the cam<N>_hq paths or list an hqPath in cameras.json at all, so every tile on every layout, full-screen ones included, plays "sub". A "sub" stream on this NVR is roughly 640x360–896x512 H.264 at 0.1–0.9 Mbps, against ~6.5 Mbps for one 4K "main", so one "main" pull costs about as much as 15 "sub" ones. Several boxes share this NVR and nothing coordinates "main" pulls between boxes (the HQ lease below only arbitrates between one box's own outputs), so sub-only keeps NVR load even and tied to how many tiles are on screen. The cost is soft full-screen layouts: noticeable on a 1080p TV, poor on a 4K one.

Rather than picking "main" vs "sub" once per camera by hand, cameras.env holds only the "main" URL and cameras-apply.sh derives the "sub" variant automatically (substituting _main → _sub), then mediamtx exposes both as separate paths (cam<N> for sub, cam<N>_hq for main), each sourceOnDemand — the NVR only sees a pull for whichever one is actually being watched right now, not every configured camera at every quality all the time. The camera grid page itself (grid.js) decides which one to request per camera, based on how many cameras are concurrently on screen: see MAX_CONCURRENT_HQ there, currently 1 because that's the only concurrency level confirmed fully clean above — raise it only after confirming your own NVR handles more concurrent "main" pulls cleanly (journalctl -u mediamtx after each change, not just eyeballing the tile). If a camera's URL doesn't contain the literal _main substring, no "sub" variant can be derived and it's served as a single always-the-same-quality path, same as before this existed. This is a policy fixable from this box; how many concurrent "main" streams the NVR itself can actually serve is still a question for whoever owns that NVR.

The control panel's Camera feeds tab picks the on-screen layout: uniform grids (1 feed, 2x2, 3x3, 4x3, 4x4), All (every configured camera, auto-flowing, capped at 5 columns so a wide 4K screen doesn't end up with one very wide, very short row), and two "featured" layouts built around a single main camera: PiP (main feed full-screen, a second one overlaid in a corner) and 1 + 2 (main feed large, two smaller ones beside it).

The Rotate every 30 seconds checkbox applies to any of them except All, on a wall-clock cycle so a reload or a second display stays in step:

  • A grid pages through every configured camera, cols×rows at a time, the whole grid swapping to the next group every 30 seconds. It's for sites with more cameras than fit legibly on one screen. A final under-full group shows empty tiles rather than stretching to fill the grid. A grid with at least as many cells as there are cameras has only one group, so rotating changes nothing. Cell assignments are ignored while rotating, but kept, so turning it off restores them.
  • 1 feed rotating shows one camera at a time, full-screen (full resolution with CAMERA_HQ on). See below for how the switch is kept seamless.
  • A featured layout rotates only its main slot, through every camera that isn't one of its secondaries, the same way 1 feed does. The secondaries stay put.

Layouts saved before the checkbox existed as "3x3 Rotating" or "1 feed Rotating" (mode: "rotate") are read as a rotating 3x3 or 1 feed grid.

Every non-square shape (4x3, PiP's overlay, 1+2's split) crops the video to fill its box (object-fit: cover) rather than letterboxing it — real cropping, not distortion, and the only way to avoid black bars on cells that aren't 16:9.

The two featured layouts get their own HQ policy, not the uniform grids' concurrency threshold below: the main slot (cells[0]) always requests "main"/HQ, every secondary slot always requests "sub", full stop — regardless of MAX_CONCURRENT_HQ. That's deliberate: the whole point of "featured" is one clearly-highlighted feed, so dropping it to sub-quality the moment a PiP inset also happens to be on screen would defeat the layout. It also keeps concurrent "main" pulls at exactly 1 no matter how many secondaries are shown — see the concurrency numbers below for why that's the only level confirmed safe against this NVR.

A single-feed rotation (1 feed, or a featured layout's main slot) hides that connect delay by starting the next camera's stream early, in a second <video> stacked underneath the one on screen, and only swapping it in once it's actually playing. The lead depends on which stream it is: ROTATE_WARM_LEAD_SUB_MS (25s) for "sub", which took 10–20s from a cold start to playable on kiosk-demo (4s segments, its keyframe interval), and ROTATE_WARM_LEAD_HQ_MS (7s) for "main", which took 4.6–6.7s (2s segments). It was 10s until a 4K trial on wall01-sj1 (2026-10-06); 7s produced no late swaps there. A camera that never starts playing (e.g. an NVR channel returning 404) is skipped: the previous camera stays up through its slot, and the one after it still warms up on time.

A rotating grid of more than one tile does the same per tile, always on "sub": each tile starts loading its camera for the next page ROTATE_WARM_LEAD_GRID_MS (15s) before the swap, underneath the one on screen, so the whole page normally changes at once. Without it, each tile sat black after every swap while its stream started. On wall01-sj1 the NVR pull itself is up in under a second, but a sub stream took 9–11s to become playable (mediamtx cuts 4s segments, the keyframe interval, before the player can start). For those 15s the next page's streams are pulled alongside the current ones (a 3x3 adds up to 9 sub streams, about 0.3 Mbps each). The display's warm-up time items in Zabbix show how much margin the 15s leaves.

With CAMERA_HQ on, warming "main" means 2 concurrent "main" pulls for part of every dwell, above the 1 measured clean below. That's accepted because a glitch in a not-yet-visible stream costs nothing on screen. cameras-apply.sh then also shortens how long mediamtx keeps a stream pulled after its last viewer: hlsMuxerCloseAfter from 60s to 30s, and sourceOnDemandCloseAfter from 10s to 1s on the _hq paths. 30s is about as low as it can safely go. mediamtx holds a new muxer's first playlist request open until the muxer has cut its first segments, and a held request doesn't count as use. At 10s, most tiles of a freshly-loaded grid got a 500 on their cold "sub" stream and never played (confirmed on kiosk-demo). Even at 30s, the previous camera's "main" pull lingers ~30s after each swap, so 2–3 concurrent "main" pulls rather than 1–2. Sub-only keeps mediamtx's defaults.

mediamtx also serves Prometheus-format stream stats (per-path state, bytes, readers, frame errors) on 127.0.0.1:9998/metrics. Loopback only; cameras-apply.sh enables it. It's read by the box's zabbix_agent2 through a UserParameter, using the "MediaMTX by Zabbix agent active" template in this repo's zabbix/ directory; zabbix/README.md has the deployment steps.

One tradeoff of sourceOnDemand: mediamtx doesn't pull from the NVR at all until something actually asks for a path, so the first time a given camera/quality is viewed (page load, or a layout change that brings a previously-idle tile on screen) there's a short connect delay before video appears, rather than the instant-always-warm playback a continuously-pulled source gives. Traded deliberately for keeping NVR load tied to what's actually on screen rather than to how many cameras are configured at all.

Resetting to the default page

The reset button (or POST /api/reset) switches the screen back to the same local landing page a freshly-provisioned box starts on. It's a separate endpoint rather than just allowing a file:// URL through the normal form, because the general /api/config endpoint deliberately only accepts http:///https:// URLs — letting the network-facing control panel point Chrome at an arbitrary local file would be a real local-file-disclosure risk.

How it works

  • /etc/kiosk-dashboard/config.<connector>.json holds {"url": ..., "zoom": ...} per active display connector (e.g. config.DP-1.json) — the source of truth for what's on that particular screen. See "Multi-display support" above for how a connector maps to a physical output.
  • kiosk-browser.sh runs as the kiosk user, as its own systemd unit (kiosk-browser.service, Restart=always) tied to the labwc (Wayland) session on tty1 rather than launched as a background job from it — so systemd itself recovers it if it dies, not just the Chrome instances it launches (see "Multi-display support" for why, and for the per-connector crash-recovery details). It's a per-connector supervisor: one independent Chrome instance per active connector, each re-reading its own config file every time it (re)launches, and each launched without --kiosk so labwc's window rule can still move it onto its own output before fullscreening it (see "Multi-display support" for the title-based routing mechanism).
  • Until a real dashboard URL is set for a given connector, config.default.json points at a local file:// landing page that kiosk-browser.sh regenerates on every launch with the box's current IP, so the screen says where to go instead of showing blank/example content.
  • The control-plane API (api/kiosk_dashboard_api.py, stdlib Python, bound to loopback only) validates and writes to the config file for whichever ?display=<connector> was given. It has no elevated privileges — it can only write files it already owns.
  • Every time a real (http/https) URL is applied, it's recorded to /var/lib/kiosk-dashboard/history.json immediately — deliberately outside /etc/kiosk-dashboard, so writing history doesn't itself trigger a Chrome restart the way an actual config change does. Recording on apply (rather than only once a URL is replaced by the next one) means a just-applied URL shows up in History right away, so it can be starred as a favorite without first navigating away from it. Applying also triggers a best-effort, timeout-bounded server-side fetch of that URL's static <title> (fetch_page_title), stored alongside the history entry — used as the default favorite name instead of the raw URL. Never blocks or fails the apply itself: any error (timeout, no <title>, TLS trust failure against an internally-issued cert Python doesn't trust the way Chrome's NSS store does, a JS-rendered dashboard that only sets its real title after load) just falls back to the URL, same as before this existed.
  • /var/lib/kiosk-dashboard/favorites.json holds a simple {"title": "url"} map of shortcuts, for the same reason kept outside /etc/kiosk-dashboard. Editable directly on the box, or through the control panel's star toggle (POST/DELETE /api/favorites). Missing or invalid file reads back as no favorites, rather than an error.
  • A systemd .path unit watches the config directory and triggers a oneshot service that kills the running Chrome process; the respawn loop in kiosk-browser.sh picks the new config back up on the next launch.
  • nginx serves the static control panel (web/) and reverse-proxies /api/ to the backend, gating both with Basic Auth.
  • The camera grid is a second, independent nginx server block bound to 127.0.0.1:8081 only — no Basic Auth, because it's unreachable from the network in the first place. mediamtx (a single fetched-and- checksummed static binary, not an apt package) remuxes each configured RTSP source to HLS, also loopback-only (127.0.0.1:8888), with every other protocol it can speak (RTSP/RTMP/WebRTC/SRT/MoQ) turned off. cameras-apply.sh regenerates mediamtx's config and a credential-free {name, path} manifest from cameras.env and restarts mediamtx, triggered by its own .path unit watching /etc/kiosk-cameras/ — a directory of its own so that editing camera credentials never touches /etc/kiosk-dashboard and never restarts Chrome the way an actual dashboard-config change does. The grid page (web-cameras/grid.html, vendored hls.js) only ever fetches that manifest, never cameras.env or mediamtx.yml — it has no way to see a credential.
  • Camera display-label overrides (see "Camera grid") live in their own /var/lib/kiosk-dashboard/camera-labels.json, a {path: label} map the API writes directly (no privileged hand-off needed, unlike the two mechanisms above — /var/lib/kiosk-dashboard is already writable by its own user) via GET /api/camera-labels/POST /api/camera-label. GET /api/cameras applies it on top of cameras.json's own name before returning each camera, under the same name key every existing caller already reads, plus the untouched original under rawName. The grid page can't use that endpoint (it never authenticates, by design), so it fetches /api/camera-labels itself and merges client-side, polling it the same way it already polls the layout endpoint.
  • Adding/updating a control panel user (see "Adding control panel users") from the API follows the same "unprivileged process requests a privileged write" shape as the two mechanisms above: POST /api/users computes the bcrypt hash itself (htpasswd -n, which only prints to stdout — the API never touches /etc/nginx/ directly, since it has no access there at all) and drops a username:hash line under /var/lib/kiosk-dashboard/pending-users/; kiosk-users-apply.path watches that directory and a root-run oneshot splices the line into /etc/nginx/kiosk-dashboard.htpasswd. The plaintext password is never written to disk anywhere.
  • Every mutating control-panel action (config apply, reset, camera layout save, camera label set/clear, favorites add/remove, user add/update, and every screen-control click, typed text, key, scroll and reload) is written to a durable audit trail — nginx forwards the authenticated Basic Auth username as X-Forwarded-User ($remote_user, set once auth_basic succeeds), and the API logs an AUDIT: ... line to its own journal (journalctl -u kiosk-dashboard-api) for each one, e.g. AUDIT: applied config for display DP-1 (url=https://...), by jsmith. Persists across reboots (see the persistent journal storage step above). User add/update gets a second confirmation line from kiosk-users-apply itself once the write actually lands, since that's the one action where the real privileged write happens somewhere other than the API process. /api/camera-hq-lease is deliberately not included — it's automatic client polling from the on-screen camera grid, not an admin action, and never carries an authenticated username anyway (proxied through the no-Basic-Auth camera vhost).

Security

This runs on an internal, semi-trusted network (see the intranet-only note above) — the threat model is "anyone who can reach this box's IP," not the open internet. Written down here so it's ready for an infosec review rather than reconstructed on demand.

Implications

  • Anyone on the same network segment who has (or observes, or guesses) the Basic Auth credentials can change what's on screen, view URL history, and reset the box — the control panel doesn't distinguish between operators.
  • Basic Auth credentials are base64-encoded, not encrypted, and this is plain HTTP — anyone able to sniff traffic on that network segment can recover the password.
  • Basic Auth has no CSRF protection. A browser that has already cached credentials for this box's origin will attach them automatically to a same-origin request — including one triggered by an unrelated page in another tab, if that browser is on the same network.
  • Multiple named accounts are supported (see "Adding control panel users"). Every mutating action is now attributed to a username in the audit log (see "How it works"), but history.json itself still doesn't carry that identity — it's a capped, UI-facing convenience list, not the audit trail — so the control panel's own History tab still shows that a URL changed, not who changed it. For "who," check the journal.
  • /etc/kiosk-dashboard/remote-input-enabled widens what a login can do. While that file exists, every control-panel user can also click and type into whatever page a TV is showing (screen control, see "Remote page interaction"). That includes a page that's logged in to another system under the kiosk's own session. It's off by default and created only by hand (sudo touch). provision.sh never creates it, and ls on that path shows whether a box has it. Every action is audit-logged with the user.

Mitigations already in place

  • Basic Auth is set at the nginx server level, not per-location, so a future added endpoint can't accidentally ship unauthenticated.
  • nginx rate-limits requests per client IP (90r/m, burst=40 nodelay), enough for the live preview and screen control's polling plus normal use, while still slowing Basic Auth brute-forcing. A rejected request gets nginx's own error page, which the control panel reports as "Request failed (HTTP 503)" rather than treating it as a bug.
  • The control-plane API binds to loopback only (127.0.0.1:8090) — it's unreachable from the network directly; nginx is the only path in, and is what actually terminates the network-facing request.
  • The API's systemd unit is sandboxed (ProtectSystem=strict, NoNewPrivileges, PrivateTmp, RestrictAddressFamilies, MemoryDenyWriteExecute), with ReadWritePaths scoped to exactly the two directories it needs. A compromised API process can't touch the rest of the filesystem.
  • User-submitted URLs are restricted to http:///https:// — the API rejects file:// and other schemes outright, so the network-facing control panel can never point Chrome at an arbitrary local file. The one exception (the internal landing page) only exists behind /api/reset, which takes no user input at all.
  • Request bodies are capped (MAX_BODY_BYTES = 4096 in the API, client_max_body_size 16k in nginx).
  • Camera RTSP credentials (/etc/kiosk-cameras/cameras.env, root:root 600) never reach mediamtx.yml readers or the browser as raw credentials beyond what mediamtx itself needs to make the upstream RTSP connection — the grid page only ever fetches a name+path manifest with no URLs or credentials in it. The whole surface (mediamtx's HLS output and the page serving it) is loopback-only, same reasoning as the control-plane API.

Known gaps

  • TLS is opt-in per box, not the default. A box only gets HTTPS if a dedicated cert has been pushed to ~/tls-certs/<hostname>.pfx ahead of provisioning (see "Setup") — anything else stays plain HTTP, so credentials and traffic are cleartext on the LAN for any box without one pushed. Revisit if a box without a cert ever needs to cross a less-trusted network segment.
  • No CSRF tokens on the state-changing endpoints (POST /api/config, /api/reset, ...). The only application-level defense is that the API requires Content-Type: application/json, which a cross-site page can't send without a CORS preflight that nginx never approves. Screen control (/api/input/*) adds an explicit check on top: it refuses any request whose Origin names a different host than the panel's own.
  • Passwordless sudo, granted by provision.sh itself as a dev/test convenience (flagged in its own comment) — a hardening pass needs to remove this before a box leaves the test network.
  • Multiple accounts, still no rotation policy or lockout: adding a second admin (see "Adding control panel users") doesn't add any per-account lifecycle management beyond the shared nginx rate limit — no expiry, no lockout after failed attempts. Actions taken are now attributed to a username in the audit log (see "How it works").
  • Basic Auth hash storage (/etc/nginx/kiosk-dashboard.htpasswd, bcrypt via htpasswd -B): permissions are now scoped to root + www-data (nginx's worker) by provision.sh, rather than htpasswd's world-readable default. The bcrypt cost factor is 12 (-C 12, consistently across provision.sh's bootstrap call, kiosk-add-user.sh, and the control panel's own user-add path) — up from htpasswd -B's weak default of 5, though still worth periodically reviewing against current recommendations. A user created before this change keeps whatever cost factor it was originally created with unless re-added.
  • Physical/disk security is out of scope entirely — no disk encryption, no boot protection. Physical access to the box means access to /etc/kiosk-dashboard, the Basic Auth hash file, everything.
  • Chrome's profile persists logins across reboots and relocation. Only the HTTP disk cache is redirected to volatile storage (--disk-cache-dir=/dev/shm/chrome-cache) — cookies, localStorage, and any saved session live in the normal on-disk profile (/home/kiosk/.config/google-chrome/Default/) and travel with the box's disk through a reboot or a move to a different location, with or without external input hardware attached. Deliberate: this device leaving its original site isn't treated as a threat here, but a box that ends up somewhere else (or is otherwise physically accessed) still carries whatever session was live on it. Not mitigated — no automatic session clearing, no --incognito (see below for why incognito isn't the right tool here regardless).
  • Chrome's password manager is disabled fleet-wide (PasswordManagerEnabled: false in files/chrome-policy-kiosk.json, applied to every box via the same Chrome enterprise-policy mechanism as RestoreOnStartup above) — no save-password prompts, no autofill of previously-saved credentials. Confirmed directly as a real, not hypothetical, gap: a kiosk display's Chrome had a genuine saved login autofilled into a page's password field, surfaced by scripts/cdp-input.py's elements listing. This is a narrower fix than --incognito on purpose — session cookies/localStorage still persist (the multi-tenant dashboards this box displays are meant to stay logged in across routine Chrome restarts; incognito would force a fresh login on every one), only the separate credential-storage/autofill feature is off.
  • CDP-based remote input (see below): the debug port itself stays loopback-only, same as the control-plane API, and is only reachable over an SSH tunnel — never a new web-facing proxy. So this gap is really "SSH access to this box isn't scoped/IAM-managed yet": today it's full shell access per key, not the narrowly-scoped, forwarding-only credential this should use. That scoping is still deferred, same as the rest of this box's SSH access — see below.

Layout

  • provision.sh — single provisioning script, run once on the target box.
  • files/ — systemd units, the kiosk browser/apply scripts, nginx site configs, default dashboard config, cameras.env.example, and USB media mode's detect/allow scripts, udev rule and SSH login notice.
  • api/ — the control-plane backend.
  • web/ — the static control panel UI.
  • web-cameras/ — the static camera-grid page and its vendored hls.js.
  • check.sh — shellcheck + Python syntax/JSON checks.
  • README-commands.md — day-to-day host command reference, kept separate from this file so it's quick to scan.

Testing

Ubuntu 26.04 migration: provisioned and rebooted end-to-end on real hardware, starting from a fresh Ubuntu 26.04 Server Minimized Installation. Everything below this note was originally tested against the box while it still ran Debian 13, before the org-wide standardization on Ubuntu. Since then, a fresh Ubuntu 26.04.1 LTS ("Resolute Raccoon") Minimized Installation was inspected directly and confirmed: /etc/apt/sources.list.d/ubuntu.sources already has universe+multiverse enabled by default, so provision.sh's apt-component step is a no-op safety net rather than a required action; every package in the install list has a valid apt candidate; and Ubuntu's seatd package gates its socket by the video group exactly like Debian's (confirmed by inspecting its shipped /etc/default/seatd), so the useradd group list needed no change. One real, previously unknown conflict was found and fixed: Ubuntu 26.04's chrony defaults to NTS (encrypted NTP) against Canonical's pool, and something on this network breaks that TLS handshake (TCP/4460 connects, the handshake never completes), so the clock free-drifts until apt starts rejecting every Release file as "not valid yet" — a confusing failure that looks like a package-verification bug but is actually a clock problem. provision.sh now points chrony at whatever DNS servers DHCP handed the box instead (this network's domain controllers double as NTP servers, and DHCP already hands each site its own local DCs as DNS servers, so this needs no per-site hardcoding), confirmed working and idempotent by running that step directly against the real box. A full sudo ./provision.sh run and reboot has since completed successfully from a clean install: snapd (present by default here, along with a core24 and a hwctl snap) purged and stayed gone, and VA-API HEVC decode re-verified rather than assumed carried over from Debian. A follow-up run against another fresh install is planned to confirm repeatability.

Provisioned and rebooted end-to-end on real hardware (Debian 13, generic mini PC): kiosk session boots correctly, cursor is hidden, GPU negotiates on its own, and the control-plane API/control panel — including history and reset — all verified working. check.sh passes.

Camera grid: provisioned end-to-end against a real 14-channel NVR — mediamtx connected and produced live HLS for 13 of 14 configured channels (the 14th returned 404 Stream Not Found on both stream variants, most likely an unpopulated NVR channel rather than a configuration problem — worth checking directly against that channel if it's expected to be live). Confirmed loopback-only: the camera vhost is unreachable from the box's real IP, and the existing control panel is unaffected.

Google Chrome + VA-API HEVC decode: verified against this box's real, non-headless Wayland kiosk session (headless Chrome has its own unrelated VA-API-initialization limitation, so that wasn't a valid proxy for this test) — real 4K H.265 "main" streams played through the actual mediamtx→HLS→hls.js pipeline, confirmed via readyState/ currentTime/videoWidth×videoHeight advancing on the real video element, not just a canPlayType capability flag. Also confirmed: the existing internal-CA cert trust (/home/kiosk/.local/share/pki/nssdb) carries over unchanged to Chrome — its main process was directly observed (lsof) holding that exact NSS database open. Separately confirmed as a real constraint, not a Chrome/decode problem: this NVR shows sustained RTP packet loss once more than a few concurrent "main" streams are pulled, even with RTSP source pulls forced onto TCP — see "Camera grid" above for the numbers. cameras.env.example defaults to "sub" accordingly.

Not yet confirmed: the actual on-screen rendering quality of the grid page in the kiosk session itself (verified independently by whoever has eyes on the physical display) — this was checked via the video element's own decode/playback state, not by looking at the screen.

Still open: systemd-analyze verify on the unit files, and validating the static-IP/hardening pass once the "lab network" requirements are defined (see the TODO in provision.sh).

Remote page interaction

Occasionally someone needs to actually interact with — click, fill in — the page currently on screen, not just change which URL is shown. There are two ways. For a less technical user, the control panel's Screen control (see below; off unless turned on per box) clicks and types on a live screenshot. For admins, this section's cdp-input.py works over SSH, separate from the control panel's URL/layout feature set: it drives the live DOM directly, by CSS selector, one discrete action per invocation. Rather than VNC/RDP (heavyweight, and notoriously flaky on Linux), this uses the Chrome DevTools Protocol (CDP):

  • Input.dispatchMouseEvent / Input.insertText — inject a click or fill in a text field. DOM.getBoxModel gives exact element coordinates to click, rather than guessing pixels.
  • Page.captureScreenshot — a single static capture, to confirm an action landed where expected without needing eyes on the physical display.

Scoped to exactly the one browser tab, with none of a full-desktop remoting tool's compositing/encoding overhead. Each connector's Chrome (files/kiosk-browser.sh) launches with its own loopback-only debug port — one per connector, since multi-display support means each output already runs its own independent Chrome process with its own profile:

  • DP-1 → 9222, DP-2 → 9223, DP-3 → 9224, DP-4 → 9225
  • HDMI-A-1 → 9226, HDMI-A-2 → 9227, HDMI-A-3 → 9228, HDMI-A-4 → 9229
  • eDP-1 → 9230

scripts/cdp-input.py (run from your own workstation, never on the box) opens the ssh -L tunnel to the target connector's port, resolves a CSS selector over CDP, and drives it — see README-commands.md's "Remote page interaction" section for the exact commands. Its elements action lists clickable/fillable elements (inputs, buttons, links) with a suggested selector for each, so you don't have to already know a page's DOM before targeting it — confirmed directly that this needed a deliberate safeguard: a real kiosk profile's Chrome had a genuine saved login autofilled into a page's password field, and an early version of this listing printed that real credential in plain text. Password field values are always shown as [hidden], unconditionally, not just when autofill happens to be involved.

A selector alone doesn't always pick one specific element — a table of a dozen rows produces a dozen identical button[aria-label="Preview channel"] matches, same tag/attributes/text, with no selector that picks just one. Two things address this: every elements line also shows its nearest table-row/list-item text (a channel name, say), so otherwise- identical entries read as distinct; and interact — a numbered list you filter by typing, pick a number from, then click/type into — acts on the exact resolved element you picked rather than re-running the selector, so which one of the dozen is never ambiguous.

elements/interact's default scan only matches semantic markup (button/a/input/etc.) — a plain <div class="channel-row"> with just a JS click handler and no button/role attached falls outside it entirely, confirmed directly against a real app that renders exactly that. There's no general CSS-only way to detect "has a click listener," so interact supports switching what it scans mid-session instead of requiring a restart: type scan <selector> (e.g. scan .channel-row) to rescan with a different selector without tearing down and rebuilding the SSH tunnel and CDP session — the new selector stays in effect for later picks in the same session, until you scan again or quit. type also selects-all before inserting text, confirmed directly this was needed: without it, typing an empty string to clear an already-filled field was a genuine no-op (Input.insertText inserts at the cursor, it doesn't replace), and a search box's native "×" clear button isn't a real DOM node CDP can see or click at all.

interact is deliberately ad-hoc, live-operator use only, not something to script or automate: it refuses to run at all without a real terminal on stdin, failing fast under cron/CI/any unattended caller rather than hanging on a prompt that will never be answered. click/type/elements/screenshot all take their target as an explicit argument and remain the ones safe to put in a script. It declares its one dependency (websockets) via PEP 723 inline script metadata, so uv run scripts/cdp-input.py ... on the corporate network resolves it through the existing internal package registry automatically; off-network, pip install websockets once and run with plain python3 instead.

A real hardened box blocks this tunnel by default. This fleet's general-purpose SSH hardening baseline (/etc/ssh/sshd_config.d/60-qscape.conf, from a separate, org-wide hardening repo — general-purpose there, with no awareness of this one box's remote-input need) sets AllowTcpForwarding no, discovered directly against a real box, not anticipated in advance. provision.sh now also installs files/ssh/61-cdp-remote-input.conf, a narrow Match User $SUDO_USER block that re-enables local (-L) forwarding, restricted via PermitOpen to exactly this box's own per-connector debug ports — everyone and everything else still gets 60-qscape.conf's no. A Match block's values override an earlier unconditional value of the same keyword for a matching connection regardless of the two files' relative sort order, so this carve-out doesn't depend on load ordering, only on staying inside a Match block rather than becoming a top-level directive.

This traces back to a throwaway spike against Debian's chromium (before the switch to Google Chrome, and before multi-display support existed): a single Chrome launched with --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 (loopback only, same as the control-plane API), tunneled over ssh -L, driven with a small Python/websockets script — a real click and real keystrokes landed in the DOM exactly as dispatched, then the debug port was reverted out of kiosk-browser.sh entirely (nothing left running). CDP itself is identical between Chromium and Chrome, but re-verify against a real Chrome + two-output box before relying on this operationally.

Transport: SSH, not a new web-facing proxy. The debug port never leaves loopback; ssh -L is what reaches it, riding the same trust as normal admin SSH access. That means access control for this is just SSH access control — and OpenSSH can scope a key down to only this capability, distinct from full shell access:

restrict,port-forwarding,permitopen="127.0.0.1:9222" ssh-ed25519 AAAA... <comment>

restrict strips everything (no shell/PTY, no other forwarding) except what's explicitly re-enabled; permitopen limits forwarding to exactly that one loopback port — repeat the directive, one per connector's port, for a key that should reach more than one display. Not yet applied: today every admin key has full shell access to the box, same as everything else in README-commands.md. If SSH access to this box is ever brought under IAM (short-lived certs, centrally managed authorized_keys, etc.), "who can remote-input into this kiosk" becomes the same policy question as any other SSH-gated access — one audit trail (sshd's own), no bespoke web auth layer to build or maintain, and no new listening port on the box beyond sshd, which is already there. Picking this scoping up once the standard hardening package (applied to all Linux hosts here) is in place, so the SSH/IAM side of this lands on top of that rather than being designed in isolation.

Automated live preview (control panel)

Separate from cdp-input.py screenshot above: the control panel's "Live preview" pane (next to each display's Dashboard/Camera feeds tabs) shows an automatically refreshed (every 15s) screenshot of what that display is currently showing, via GET /api/screenshot?display=<connector> on the control-plane API (api/kiosk_dashboard_api.py).

The key difference from the manual command above: kiosk_dashboard_api.py already runs on the box itself, on the same loopback as Chrome's debug ports, so this connects to 127.0.0.1:<port> directly — no ssh -L tunnel, no subprocess, nothing an operator's workstation needs to do. It's reachable by any control-panel user, gated by the same nginx Basic Auth as the rest of the panel (see "Security") — no separate auth layer.

This is a deliberate, narrow exception to this section's "transport: SSH, not a new web-facing proxy" framing above — that principle is about the interactive capability (dispatching real clicks/keystrokes into the live DOM). The live preview endpoint only ever calls read-only Page.* methods (captureScreenshot, plus getFrameTree for the page token); it cannot drive the page, so it reuses the control panel's existing trust boundary rather than opening a new one. Screen control, below, is the interactive exception, and it's opt-in per box for that reason. A control-panel user can already set a display to show any URL they choose, so being able to also just view it doesn't expand what they can already do.

Implementation notes: api/kiosk_dashboard_api.py carries a third copy of the connector→debug-port table (CDP_CONNECTOR_PORTS, alongside files/kiosk-browser.sh's connector_debug_port() and cdp-input.py's own CONNECTOR_PORTS — all three must stay in sync by hand). It talks CDP over the websockets package, the same one cdp-input.py already depends on — installed system-wide via provision.sh's python3-websockets apt package, since kiosk-dashboard-api.service runs the API with plain /usr/bin/python3, no venv.

Screen control (control panel)

For the rare case where something on a TV has to be changed by hand, such as a field on a dashboard page, a slide-show timer, or a dialog to dismiss, and whoever needs to do it can't use SSH and cdp-input.py.

Off by default, opt-in per box. It's on only while /etc/kiosk-dashboard/remote-input-enabled exists. provision.sh never creates it. The API checks for it on every request, so no restart is needed:

sudo touch /etc/kiosk-dashboard/remote-input-enabled   # turn on
sudo rm /etc/kiosk-dashboard/remote-input-enabled      # turn off

When it's on, any control-panel user can use it. There are no separate roles; who did what is in the audit log.

Using it. Each display's Live preview pane gets a Control screen… button. It's hidden while the box plays USB media, since mpv has no Chrome to control. The button opens a large screenshot of the display that refreshes every 3s:

  • Click or tap the picture to click that spot on the TV. A red ring marks where the click went until the next picture arrives.
  • If the click lands on a text field, a box opens to type into it. It shows the field's current value, except for a password field. Send replaces the field's contents. Press Enter after is ticked by default, since many fields (including Perspective's numeric fields) only save on Enter. Type text… opens the same box for whatever field already has the cursor.
  • Enter, Esc, Tab, Backspace: sent to whatever has focus.
  • Scroll ↑ / ↓: a mouse-wheel turn of most of a screen at the spot last clicked (the middle of the screen until then), so it scrolls whatever area is under that spot.
  • Reload page: reloads the page in the same Chrome, after a confirmation. On the leaky Andon page the old page's memory isn't freed first, so a reload can trip the 3 GB memory recycle within a few minutes. That's harmless; the display just restarts. A Perspective page keeps its session across a reload, so values typed into it are kept. They're lost when Chrome restarts, at a memory recycle or a config apply.

Safety.

  • A click can't land on the wrong page. Every screenshot comes with an X-Page-Token naming that exact page load (Chrome target id plus the main frame's loaderId), and every action sends it back. If the page has since reloaded, navigated, or Chrome restarted, the API refuses with a 409 and the new token, and the panel says "The screen changed — look again and retry."
  • Text only goes into a text field. Typing is refused unless an editable element (a text-like <input>, a <textarea> or a contenteditable) has focus, so it can't trigger a page's keyboard shortcuts.
  • Coordinates don't depend on zoom. The panel sends points as fractions of the screenshot, and the API scales them to the page's CSS viewport (Page.getLayoutMetrics). The display's zoom (--force-device-scale-factor) doesn't matter.
  • Every action is audit-logged with the user, e.g. AUDIT: screen control: click on DP-1 at (1595, 1068), by jsmith and typed '15' + Enter into input 'number' on DP-1, by jsmith. Typed text is logged, except a password field's, which shows as [hidden].
  • Cross-origin requests are refused (see "Security").

API. These are all POST /api/input/<kind>?display=<connector>, with a JSON body that always includes page (the token):

  • click {fx, fy} returns what got focus;
  • type {text (≤500), enter};
  • key {key: Enter|Escape|Tab|Backspace};
  • scroll {fx, fy, direction: up|down};
  • reload {}.

With the flag off they return 403. GET /api/info reports remote_input: true|false. The clicks and keys are the same CDP events cdp-input.py sends (Input.dispatchMouseEvent, Ctrl+A then Input.insertText, Input.dispatchKeyEvent). The health probe's own CDP connection doesn't conflict with them.