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
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 firefoxfirst. - It must never exit.
org.gnome.Kiosk.Script.servicecarriesOnSuccess=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-dommodes, so a probe there proves nothing either way. Openabout:policieson 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
kioskuser 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 askiosk, set one withsudo passwd kiosk. - TTYs are live. Ctrl-Alt-F3 still gets a console, which is your rescue
path.
mask-inggetty@tty3..6is 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-ostreeis transactional and the previous deployment is kept, so a bad upgrade is undone withrpm-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, viarpm-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.