# Kiosk client — Fedora Silverblue The target client for a wall display is **Fedora Silverblue running GNOME Kiosk, auto-logged in as `kiosk`, showing `https://mirror.reeselink.com/watch` fullscreen in Firefox.** One box takes about five minutes over SSH and needs no keyboard, no monitor access, and no console. Everything is in this directory and is idempotent — re-run it to repair a kiosk or to change its URL. | File | Lands at | Why | |---|---|---| | `install-kiosk.sh` | run on the client as root | the whole install, one pass | | `check-kiosk.sh` | run on the client as root | read-only report; nothing needs the console | | `gnome-kiosk-script` | `~kiosk/.local/bin/gnome-kiosk-script` | the kiosk application + watchdog loop | | `kiosk.conf` | `~kiosk/.config/mirror/kiosk.conf` | **the one per-client value: the URL** | | `policies.json` | `/etc/firefox/policies/policies.json` | no updates, accounts, telemetry, first-run pages | | `window-config.ini` | `/usr/local/share/gnome-kiosk/window-config.ini` | force the Firefox window fullscreen | | `dim-probe.py` | run on any reachable host | measures what the kiosk actually renders | --- ## Add a client ```bash # 1. A vanilla Silverblue install. Enable Settings → Sharing → Remote Login. # Create an admin user (the first one you make) — that is your SSH account. # 2. Layer the kiosk packages. This is the only step that needs a reboot. ssh client 'sudo rpm-ostree install gnome-kiosk gnome-kiosk-script-session && sudo reboot' # 3. Wait ~60s, then install everything else. scp -r docs/kiosk-silverblue client:/tmp/ && ssh client \ 'cd /tmp/kiosk-silverblue && sudo sh install-kiosk.sh' # 4. Hand the console to the kiosk. ssh client 'sudo systemctl restart gdm' # 5. Prove it, without touching the machine. ssh client 'cd /tmp/kiosk-silverblue && sudo sh check-kiosk.sh' ``` `install-kiosk.sh` creates the `kiosk` user if it does not exist, so step 2 can be skipped when the packages are already layered. To point a client somewhere other than the production wall, pass the URL: ```bash sudo KIOSK_URL=https://mirror.reeselink.com/watch sh install-kiosk.sh ``` Only `kiosk.conf` differs between clients; every other file stays byte-identical so the fleet is auditable. --- ## The two facts that make this work **Silverblue's mutable seams.** `/usr` is read-only and belongs to the OSTree commit, but `/etc`, `/var` and `/usr/local` (which *is* `/var/usrlocal`) are writable and survive upgrades. That is why the payload goes to `/etc/firefox`, `/usr/local/share/gnome-kiosk` and `/var/home/kiosk` and nowhere else. Never `ostree admin unlock` to shove files into `/usr` — the next upgrade deletes them. **GNOME Kiosk's script session.** `gnome-kiosk-script-session` starts a locked down mutter session (no top bar, no activities, no dock) and runs `~/.local/bin/gnome-kiosk-script` as the kiosk application. Two consequences drive the design of that file: * It **re-executes itself forever**, so an unguarded launch opens a new window every few seconds. The script checks `pgrep -u $(id -u) -x firefox` first. * It **must never exit**. `org.gnome.Kiosk.Script.service` carries `OnSuccess=gnome-session-shutdown.target`, so the script returning logs the kiosk out and the screen falls back to the GDM greeter. Exiting is the failure mode, not crashing — which is exactly why a browser that dies is a feature: the loop comes back and starts it again. The loop also **re-sources `kiosk.conf` on every relaunch**, so changing the URL is: ```bash sudo sed -i 's|^MIRROR_KIOSK_URL=.*|MIRROR_KIOSK_URL="https://…/watch"|' \ /var/home/kiosk/.config/mirror/kiosk.conf sudo pkill -u kiosk firefox # loop notices within 5s and relaunches ``` --- ## Why each piece is where it is **Auto-login — `/etc/gdm/custom.conf`** ```ini [daemon] AutomaticLoginEnable=True AutomaticLogin=kiosk TimedLoginEnable=False ``` `TimedLoginEnable=False` matters: a timed login would hand the console to the greeter on its own schedule and you get a login form on your wall. **Default session — `/var/lib/AccountsService/users/kiosk`** ```ini [User] Session=gnome-kiosk-script-wayland ``` This is what GDM logs the kiosk user *into*. Without it auto-login drops the user into a normal GNOME session with a top bar. The installer restarts `accounts-daemon` afterwards, because the daemon caches these files and happily writes the stale value back over your edit. **gsettings — applied inside the session, not at install time** The screen must never blank or lock. This cannot be done from your SSH session: `gsettings` only commits when it can reach a D-Bus session bus, and over SSH it prints `Cannot autolaunch D-Bus without X11` and silently leaves the default. It also cannot live in `/etc/dconf/db/local.d`, because `/usr/bin/gnome-kiosk` sets `DCONF_PROFILE=gnomekiosk` and that profile is `user-db:user` plus the shipped compiled defaults — it never reads a system db. So the kiosk script applies them itself on first run each boot, stamped by a file in `$XDG_RUNTIME_DIR` (which is per-login, so it re-applies if someone changes a setting by hand): ```sh gsettings set org.gnome.desktop.session idle-delay 0 gsettings set org.gnome.desktop.screensaver lock-enabled false gsettings set org.gnome.desktop.interface enable-animations false ``` **Firefox window rules — `/usr/local/share/gnome-kiosk/window-config.ini`** gnome-kiosk reads the **first** `window-config.ini` it finds in `$XDG_CONFIG_HOME/gnome-kiosk`, then `/var/lib/flatpak/exports/share`, `/usr/local/share`, `/usr/share`. Because ours wins, the shipped file is not read at all — anything it defines is lost unless repeated. Ours therefore copies the shipped `[notifications]` section verbatim and adds: ```ini [mirror-browser] match-class=org.mozilla.* set-fullscreen=true ``` `firefox --kiosk` already goes fullscreen; this is the belt so a window that comes back from a resume still fills the output. **Firefox policy — `/etc/firefox/policies/policies.json`** No update prompts, no Firefox Account or Sync, no studies, no telemetry, no first-run/restore pages, no default bookmarks, no saved-password prompts. `/usr/lib64/firefox/distribution/` is the other documented location and it is inside the read-only `/usr`. > Verified on this box: the file installs, is world-readable with a sane SELinux > label, and Firefox runs happily with it. What is *not* verified headlessly is > that each policy is applied — Firefox's policy engine does not initialise in > the `--headless --screenshot`/`--dump-dom` modes, so a probe there proves > nothing either way. Open `about:policies` on a real kiosk display to confirm; > it lists every policy Firefox read and any it rejected. **Initial-setup guard — `~kiosk/.config/gnome-initial-setup-done`** Without it the GNOME "welcome" wizard claims the session on first login and the kiosk never starts. The journal confirms the guard working: `gnome-initial-setup-first-login.service … skipped, unmet condition check ConditionPathExists=!/var/home/kiosk/.config/gnome-initial-setup-done`. --- ## Verifying a kiosk you cannot see The console is not an interface. Every claim below is checkable over SSH. | Claim | Evidence | |---|---| | Auto-login works | `loginctl list-sessions` → a `seat0` session whose user is `kiosk` | | The right session | `loginctl show-session N -p Type` → `wayland`; `pgrep -u kiosk -a gnome-kiosk` | | The browser is up in kiosk mode | `pgrep -u kiosk -a firefox` → `--kiosk … ` | | It restarted after a crash | `~kiosk/.local/state/mirror-kiosk.log` shows `exited rc=143` then `launching:` | | The page loaded | the **server's** `GET /api/streams` shows the kiosk in `viewers` and its socket in `ss -tn` | | Media really flows | the sharer's own page renders each viewer's `RTCPeerConnection.connectionState`; `connected` = ICE+DTLS+SRTP up | | It is truly fullscreen | `dim-probe.py`: `viewport == window == screen == available` | | The URL is serving *mirror* | `check-kiosk.sh` greps the page for `Watch · mirror` | | It survives a power cut | `sudo systemctl reboot`, then re-run `check-kiosk.sh` | Measured on the reference client, which is attached to a 4K output: ``` viewport=3840x2160 window=3840x2160 screen=3840x2160 available=3840x2160 ``` Window equal to viewport means no browser chrome is left over; screen equal to available means no panel or bar took any of it. After a cold boot the wall re-joins on its own — reference client back up and subscribed to a live mirror in under 60 seconds: ``` {"mirrors": 1, "sharers": 1, "viewers": 1, "connections": 2, "watching": 1} Firefox · Linux | Primary Monitor | 2560 x 1600 | viewers 1 ``` ### One trap worth knowing `/watch` works over plain `http://<lan-ip>`, but **`/mirror` does not**: `getDisplayMedia()` needs a secure context, so the Share button is disabled there. Pointing a kiosk at a bare-LAN-IP mirror gives you a wall that watches fine and cannot share. Production is HTTPS at the edge, so both work — which is the main practical reason the kiosk URL should be the `https://` one and not a convenient IP. --- ## Troubleshooting | Symptom | Cause and fix | |---|---| | Greeter on screen, not the wall | `seat0` logged in as someone else — `sudo systemctl restart gdm`. GDM reads `custom.conf` at start only | | Kiosk logs in to a normal desktop | `Session=` empty in `/var/lib/AccountsService/users/kiosk`; re-run the installer (it restarts `accounts-daemon`) | | Wall shows a non-mirror page | the URL serves something else. `check-kiosk.sh` calls this out; a stale vhost/DNS looks like a working kiosk showing a login form | | Black screen, no browser at all | the URL is unreachable so the loop is waiting. `curl -fsSL <url>` from the client; then look in `~/.local/state/mirror-kiosk.log` | | A new window every few seconds | someone removed the `pgrep` guard from the kiosk script | | Blank on wake from idle | the gsettings did not apply (they must run inside the session) — `journalctl _UID=<kiosk uid> \| grep -i permission` | | `Permission denied` about keyrings/ICC | `~kiosk/.local` is not owned by kiosk. `sudo chown -R kiosk:kiosk /var/home/kiosk/.local` and re-run the installer | | Firefox shows a restore-session or first-run page | the policy file is missing or unreadable — `sudo cat /etc/firefox/policies/policies.json` | | Tile says `unreachable` | the sharer and the kiosk could not find each other over ICE. Both must be on a network where they can send UDP directly; mirror uses no STUN/TURN by design | | Wall shows the "not allowed" page | the kiosk's address is outside `MIRROR_ALLOW_CIDRS` — this is admission control working, see `SECURITY.md` | Debugging rule for this project applies: reproduce at the lowest layer that can fail. `curl` the URL from the client, check `/api/streams` on the server, and only then reach for the browser. --- ## Deliberately not done here These are real hardening steps for a kiosk in a public space; each is a decision, not an oversight. * **The `kiosk` user has no password.** Auto-login does not need one and a password does not stop anyone with a keyboard. If you want to be able to log in as `kiosk`, set one with `sudo passwd kiosk`. * **TTYs are live.** Ctrl-Alt-F3 still gets a console, which is your rescue path. `mask`-ing `getty@tty3..6` is the usual lockdown — do it only once the kiosk is known good, since it removes the way back in without SSH. * **No GRUB password**, so anyone with physical access can boot something else. * **No automatic OS updates.** `rpm-ostree` is transactional and the previous deployment is kept, so a bad upgrade is undone with `rpm-ostree rollback` (or chosen from the GRUB menu if it will not boot). Unattended upgrades of a box nobody looks at is its own risk — decide it deliberately, via `rpm-ostreed-automatic.timer`. * **SELinux is left enforcing, unmodified.** Nothing here needed a policy tweak; treat a new AVC as a bug in this runbook. * **No reboot watchdog.** If the wall is remote enough that a hang means driving to it, that is an ops decision (greenboot, or a smart PDU), not a client one. ## Naming on the reference network | Name | Address | What it is | |---|---|---| | `mirror.reeselink.com` | 10.1.1.2 | the mirror service (nginx → container), valid TLS cert | | `mirror-client.reeselink.com` | 10.2.1.183 | the reference kiosk client | | `mirror` | — | SSH alias for the kiosk client in `~/.ssh/config` | The client's address is **DHCP**. A kiosk wants a reservation: the address is what admits it to the service (`MIRROR_ALLOW_CIDRS`, nginx `allow`/`deny`), so a renewal into a different range can lock a wall out by itself.