Auto-login succeeded and the wall kept streaming, but about ten seconds after the kiosk session came up GDM autospawned a greeter on the same GPU and a login screen covered it. gnome-kiosk ships X-GDM-SessionRegisters=false, so nothing ever registers and GDM concludes the display is still free — which is why every remote check (loginctl, the mirror roster) stayed green while the physical screen showed a password prompt. Override that session file with the flag set to true, keeping the file name and session id identical: inventing an id makes GDM quietly fall back to the plain GNOME session, top bar and all. Add a per-minute guard that reclaims the foreground VT, because this class of failure is invisible from the outside, and have the checker assert both the foreground VT and the greeter-session count. That count had a silent false green: journalctl has no --no-legend, so the check read an empty stream and passed unconditionally. Also keep a copy of the payload in /usr/local/share/mirror-kiosk. /tmp is cleared at every boot, which deletes the checker just after the one event you are most likely to want to re-run it for. Verified on the reference client: a cold boot now creates no greeter session at all, the kiosk compositor and Firefox come up undisturbed, and a console stolen with chvt is taken back within a minute. LLM-Generated: true
351 lines
17 KiB
Markdown
351 lines
17 KiB
Markdown
# 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 |
|
|
| `gnome-kiosk-script-wayland.desktop` | `/usr/local/share/wayland-sessions/` | **stops GDM covering the wall with a greeter** |
|
|
| `mirror-kiosk-console-guard` | `/usr/local/libexec/` | reclaims the console if the wall loses it |
|
|
| `mirror-kiosk-console-guard.{service,timer}` | `/etc/systemd/system/` | runs that check every minute |
|
|
| `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. Stage it in /var/tmp, not /tmp:
|
|
# /tmp is wiped at every boot, so a checker staged there is gone exactly when
|
|
# you want it. The installer keeps a permanent copy of the payload in
|
|
# /usr/local/share/mirror-kiosk.
|
|
scp -r docs/kiosk-silverblue client:/var/tmp/ && ssh client \
|
|
'cd /var/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 'sudo sh /usr/local/share/mirror-kiosk/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`.
|
|
|
|
---
|
|
|
|
## The greeter that eats the wall
|
|
|
|
The shipped `gnome-kiosk-script-wayland.desktop` declares:
|
|
|
|
```ini
|
|
X-GDM-SessionRegisters=false
|
|
```
|
|
|
|
That is true of the session itself — gnome-kiosk is a bare mutter kiosk, so
|
|
nothing ever registers with GDM. But GDM reads "never registered" as "this
|
|
display is still mine", and about ten seconds after a perfectly successful
|
|
auto-login it **autospawns a full greeter session on the same GPU**. A login
|
|
screen covers the wall.
|
|
|
|
The part that makes this maddening: the kiosk keeps running underneath. The
|
|
session stays `Active`, Firefox keeps streaming, the mirror roster keeps
|
|
counting the wall as a viewer, `loginctl` reports the kiosk on `seat0` — every
|
|
remote check says nothing is wrong while a password prompt is on the physical
|
|
screen. This is what "it didn't auto-login" actually looks like.
|
|
|
|
From a real boot, `journalctl -b`:
|
|
|
|
```
|
|
19:07:28 PAM:authentication grantors=pam_permit acct="kiosk" ... res=success
|
|
19:07:28 New session '1' of user 'kiosk' with class 'user' and type 'wayland'
|
|
19:07:30 Started org.gnome.Kiosk@wayland.service ← the wall is up
|
|
19:07:38 New session 'c1' of user 'gdm-greeter' with class 'greeter'
|
|
19:07:39 gnome-shell: Registering session with GDM ← the greeter eats it
|
|
19:08:46 Stopped org.gnome.Shell@gdm.service ← 68s of login screen
|
|
```
|
|
|
|
**The fix** is the local override of that one file, changing only
|
|
`X-GDM-SessionRegisters` to `true`, so GDM treats the kiosk session as the owner
|
|
of the display and never brings up a greeter. Two things about it are traps:
|
|
|
|
* **Keep the file name and session id identical to the shipped one.** Inventing
|
|
`mirror-kiosk.desktop` and pointing AccountsService at it does *not* work —
|
|
GDM silently falls back to the plain GNOME session, and you get a normal
|
|
desktop with a top bar instead of a kiosk. Verified the hard way.
|
|
* A normal GNOME session already sets `true`, which is why this bug only ever
|
|
shows up on the kiosk.
|
|
|
|
After the override, the same boot looks like this: `gdm-greeter` appears **zero**
|
|
times, the session registers, and the kiosk compositor and Firefox come up
|
|
undisturbed.
|
|
|
|
## And if the wall loses the console anyway
|
|
|
|
Because the failure above is invisible from the outside, the install also drops
|
|
in a guard: once a minute it checks that the kiosk's `seat0` session is still the
|
|
foreground VT, and if not it runs `loginctl activate` on it and writes one line
|
|
to the journal.
|
|
|
|
It is deliberately timid — it never restarts gdm and never logs anyone out, and
|
|
if there is no kiosk session at all it only reports that, because a display
|
|
manager is a bad thing to fight automatically. To stop it fighting you while you
|
|
debug the console by hand:
|
|
|
|
```bash
|
|
sudo systemctl stop mirror-kiosk-console-guard.timer
|
|
sudo systemctl start mirror-kiosk-console-guard.timer # when finished
|
|
sudo journalctl -u mirror-kiosk-console-guard.service # what it has done
|
|
```
|
|
|
|
Tested by stealing the console with `chvt 1`; the wall came back on its own:
|
|
|
|
```
|
|
mirror-kiosk-guard[4598]: kiosk session 1 brought back to the foreground
|
|
```
|
|
|
|
## 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` |
|
|
| The kiosk holds the screen | `/sys/class/tty/tty0/active` equals the kiosk session's TTY and the session is `Active` — the one check that catches a covered wall |
|
|
| No greeter is fighting it | `journalctl -b \| grep -c "New session.*gdm-greeter"` is `0`; any non-zero number is a login screen on your wall |
|
|
| 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 |
|
|
|---|---|
|
|
| **Login screen on the wall, but every remote check passes** | The session is not registered with GDM, so GDM autospawns a greeter over it. See [The greeter that eats the wall](#the-greeter-that-eats-the-wall); confirm with `ls /usr/local/share/wayland-sessions/` |
|
|
| Wall vanished, console on a blank VT | Someone or something switched VT — the guard reclaims it within a minute; `journalctl -u mirror-kiosk-console-guard.service` |
|
|
| Greeter on screen after boot and `seat0` is another user | `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.
|