Files
mirror/docs/kiosk-silverblue.md
T
ducoterra c5da9461a2 docs: the Silverblue kiosk client, installed and checked over SSH
A wall display is now a target client: Fedora Silverblue running GNOME
Kiosk, auto-logged in as kiosk, showing /watch fullscreen in Firefox.
docs/kiosk-silverblue/ carries the idempotent installer, a read-only
checker and a probe that measures what the browser actually renders, so
a second client is a copy-paste and nothing about it needs a keyboard.

The runbook records what the upstream tutorial leaves out: gsettings
only commits from inside the session (gnome-kiosk sets DCONF_PROFILE,
so a system dconf db is never read at all), window-config.ini wins by
being found first and therefore silently loses the shipped notification
rules, AccountsService caches Session= until its daemon restarts, and
/watch survives a bare LAN IP while /mirror cannot.

Verified on the reference client: cold boot back to a subscribed wall in
under 60s, viewport == window == screen at 3840x2160, and the sharer's
own peer connection to it reports connected.

LLM-Generated: true
2026-11-01 19:11:53 -05:00

12 KiB

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

# 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:

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:

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

[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

[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):

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:

[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 … <url>
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 <title>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.