diff --git a/README.md b/README.md index 73a5518..0b3b660 100644 --- a/README.md +++ b/README.md @@ -116,15 +116,16 @@ session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at no monitor: ```bash -scp -r docs/kiosk-silverblue client:/tmp/ && ssh client \ - 'cd /tmp/kiosk-silverblue && sudo sh install-kiosk.sh && sudo systemctl restart gdm' -ssh client 'cd /tmp/kiosk-silverblue && sudo sh check-kiosk.sh' +scp -r docs/kiosk-silverblue client:/var/tmp/ && ssh client \ + 'cd /var/tmp/kiosk-silverblue && sudo sh install-kiosk.sh && sudo systemctl restart gdm' +ssh client 'sudo sh /usr/local/share/mirror-kiosk/check-kiosk.sh' ``` Because nobody is standing at the machine, every claim about it is verified from -the outside: `loginctl` for the session, the server's `/api/streams` for the -viewer, the sharer's own peer state for the media, and a probe page for the -rendered resolution. The runbook records why each file lives where it does on an +the outside: `loginctl` for the session, the foreground VT for "is the wall +actually on screen", the server's `/api/streams` for the viewer, the sharer's own +peer state for the media, and a probe page for the rendered resolution. The +runbook records why each file lives where it does on an otherwise read-only OS. ## Development diff --git a/docs/kiosk-silverblue.md b/docs/kiosk-silverblue.md index 40bbb77..d0814a4 100644 --- a/docs/kiosk-silverblue.md +++ b/docs/kiosk-silverblue.md @@ -16,6 +16,9 @@ or to change its URL. | `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 | --- @@ -29,15 +32,18 @@ or to change its URL. # 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' +# 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 'cd /tmp/kiosk-silverblue && sudo sh check-kiosk.sh' +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 @@ -170,6 +176,76 @@ 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. @@ -184,6 +260,8 @@ The console is not an interface. Every claim below is checkable over SSH. | 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` | +| 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: @@ -218,7 +296,9 @@ convenient IP. | 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 | +| **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` | diff --git a/docs/kiosk-silverblue/check-kiosk.sh b/docs/kiosk-silverblue/check-kiosk.sh index a866d06..55f16a2 100755 --- a/docs/kiosk-silverblue/check-kiosk.sh +++ b/docs/kiosk-silverblue/check-kiosk.sh @@ -75,6 +75,13 @@ if [ -n "$HOME_DIR" ]; then fi check_file /etc/firefox/policies/policies.json "DisableAppUpdate" "firefox policy" check_file /usr/local/share/gnome-kiosk/window-config.ini "org.mozilla" "kiosk window rules" +# Without this key the shipped gnome-kiosk session never registers with GDM and +# GDM autospawns a greeter over the wall ~8s after auto-login. The single most +# confusing failure this setup has: the kiosk keeps running underneath, so the +# mirror roster and loginctl both still look perfect while the screen shows a +# login prompt. +check_file /usr/local/share/wayland-sessions/gnome-kiosk-script-wayland.desktop \ + "X-GDM-SessionRegisters=true" "GDM session override" printf '\n%s\n' '--- session wiring ---' ACCT="/var/lib/AccountsService/users/$KIOSK_USER" @@ -103,7 +110,34 @@ else fi SEAT_SESSION=$(loginctl list-sessions --no-legend 2>/dev/null | awk '$4=="seat0"{print $1; exit}') if [ -n "$SEAT_SESSION" ]; then - info "seat0 session $SEAT_SESSION: class=$(loginctl show-session "$SEAT_SESSION" -p Class --value 2>/dev/null) type=$(loginctl show-session "$SEAT_SESSION" -p Type --value 2>/dev/null) vt=$(loginctl show-session "$SEAT_SESSION" -p VTHint --value 2>/dev/null)" + info "seat0 session $SEAT_SESSION: class=$(loginctl show-session "$SEAT_SESSION" -p Class --value 2>/dev/null) type=$(loginctl show-session "$SEAT_SESSION" -p Type --value 2>/dev/null)" +fi + +# Does the kiosk actually hold the screen? A session can be Active while another +# one owns the foreground VT, which is what a covered wall looks like from here. +KIOSK_SESSION=$(loginctl list-sessions --no-legend 2>/dev/null | + awk -v u="$KIOSK_USER" '$3==u && $4=="seat0"{print $1; exit}') +if [ -n "$KIOSK_SESSION" ]; then + K_ACTIVE=$(loginctl show-session "$KIOSK_SESSION" -p Active --value 2>/dev/null) + K_TTY=$(loginctl show-session "$KIOSK_SESSION" -p TTY --value 2>/dev/null) + K_FG=$(sudo cat /sys/class/tty/tty0/active 2>/dev/null) + if [ "$K_ACTIVE" = "yes" ] && [ -n "$K_TTY" ] && [ "$K_FG" = "$K_TTY" ]; then + ok "the kiosk holds the console (foreground VT $K_FG)" + else + bad "console is on ${K_FG:-?} but the kiosk is on ${K_TTY:-?} (Active=$K_ACTIVE) — the screen is showing something else" + info "fix: sudo loginctl activate $KIOSK_SESSION, then check the session override above" + fi +fi + +# Count the greeter *sessions* GDM created this boot, not every mention: a +# healthy kiosk creates none, and the autospawn bug creates exactly one soon +# after auto-login. (journalctl has no --no-legend; using it here once made this +# test read an empty stream and pass unconditionally — a silent false green.) +GREETERS=$(sudo journalctl -b --no-pager 2>/dev/null | + grep -c "New session .* of user 'gdm-greeter'") +if [ "${GREETERS:-0}" -gt 0 ]; then + warn "$GREETERS GDM greeter session(s) this boot: the wall may be covered by a login screen" + info "check X-GDM-SessionRegisters=true in /usr/local/share/wayland-sessions/" fi if [ -n "$HOME_DIR" ]; then diff --git a/docs/kiosk-silverblue/gnome-kiosk-script-wayland.desktop b/docs/kiosk-silverblue/gnome-kiosk-script-wayland.desktop new file mode 100644 index 0000000..d170c72 --- /dev/null +++ b/docs/kiosk-silverblue/gnome-kiosk-script-wayland.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=Kiosk Script Session (Wayland Display Server) +Comment=This session logs you into the session started by ~/.local/bin/gnome-kiosk-script +Exec=gnome-session --session gnome-kiosk-script +TryExec=gnome-session +Type=Application +DesktopNames=GNOME-Kiosk;GNOME; +X-GDM-SessionRegisters=true +X-GDM-CanRunHeadless=true diff --git a/docs/kiosk-silverblue/install-kiosk.sh b/docs/kiosk-silverblue/install-kiosk.sh index 919fc7f..484d509 100755 --- a/docs/kiosk-silverblue/install-kiosk.sh +++ b/docs/kiosk-silverblue/install-kiosk.sh @@ -58,7 +58,7 @@ for c in rpm getent restorecon useradd sed awk grep install; do need "$c"; done [ -f /etc/os-release ] && grep -q '^ID=fedora' /etc/os-release || say "warning: this does not look like Fedora; continuing anyway" -say "== 1/6 packages ==" +say "== 1/7 packages ==" if rpm -q gnome-kiosk gnome-kiosk-script-session >/dev/null 2>&1; then say "gnome-kiosk + gnome-kiosk-script-session already present" else @@ -69,7 +69,7 @@ else say "layered the kiosk packages — a reboot is required before they exist" fi -say "== 2/6 kiosk user ==" +say "== 2/7 kiosk user ==" if id -u "$KIOSK_USER" >/dev/null 2>&1; then HOME_DIR=$(getent passwd "$KIOSK_USER" | cut -d: -f6) else @@ -81,7 +81,7 @@ fi KIOSK_GROUP=$(getent passwd "$KIOSK_USER" | cut -d: -f4) say "user $KIOSK_USER uid=$(id -u "$KIOSK_USER") home=$HOME_DIR" -say "== 3/6 payload ==" +say "== 3/7 payload ==" # Firefox enterprise policy: no update prompts, no account/sync, no telemetry, # no first-run pages. /etc is mutable on Silverblue and survives upgrades. install -D -m 0644 "$HERE/policies.json" /etc/firefox/policies/policies.json @@ -93,6 +93,18 @@ say " /etc/firefox/policies/policies.json" install -D -m 0644 "$HERE/window-config.ini" /usr/local/share/gnome-kiosk/window-config.ini say " /usr/local/share/gnome-kiosk/window-config.ini" +# THE GREETER FIX. The shipped gnome-kiosk session sets X-GDM-SessionRegisters= +# false, so nothing ever registers with GDM and GDM concludes the display is +# still free: about eight seconds after auto-login it autospawns a full greeter +# session on the same GPU and a login screen covers the wall. The kiosk keeps +# running underneath, so loginctl and the mirror roster both still look healthy. +# Registering makes GDM treat the session as the display's owner. Keep the file +# name identical to the shipped one and change only that key — inventing a new +# session id makes GDM quietly fall back to the plain GNOME session. +install -D -m 0644 "$HERE/gnome-kiosk-script-wayland.desktop" \ + /usr/local/share/wayland-sessions/gnome-kiosk-script-wayland.desktop +say " /usr/local/share/wayland-sessions/gnome-kiosk-script-wayland.desktop (registers with GDM)" + # `install -d` creates missing parents but leaves them owned by whoever is # running the installer, which is how a kiosk ends up with a root-owned # ~/.local and then spends its life logging "Permission denied" about @@ -117,7 +129,7 @@ say " $HOME_DIR/.config/mirror/kiosk.conf -> $KIOSK_URL" install -m 0644 -o "$KIOSK_USER" -g "$KIOSK_GROUP" /dev/null \ "$HOME_DIR/.config/gnome-initial-setup-done" -say "== 4/6 default session ==" +say "== 4/7 default session ==" ACCT="/var/lib/AccountsService/users/$KIOSK_USER" if [ -f "$ACCT" ]; then set_ini "$ACCT" User Session "$KIOSK_SESSION" @@ -129,7 +141,7 @@ fi systemctl restart accounts-daemon.service 2>/dev/null || : say " $ACCT Session=$KIOSK_SESSION" -say "== 5/6 auto-login ==" +say "== 5/7 auto-login ==" if [ ! -d /etc/gdm ]; then say " gdm is not installed — installing it" rpm-ostree install gdm @@ -144,13 +156,36 @@ if [ -d /etc/gdm ]; then say " /etc/gdm/custom.conf: AutomaticLogin=$KIOSK_USER" fi -say "== 6/6 selinux labels ==" +say "== 6/7 console guard ==" +# Cheap insurance against the class of bug above: if the kiosk session exists but +# something else holds the foreground VT, bring the wall back. Stops short of +# ever restarting gdm. Pause it while debugging the console with: +# sudo systemctl stop mirror-kiosk-console-guard.timer +install -D -m 0755 "$HERE/mirror-kiosk-console-guard" /usr/local/libexec/mirror-kiosk-console-guard +install -D -m 0644 "$HERE/mirror-kiosk-console-guard.service" /etc/systemd/system/mirror-kiosk-console-guard.service +install -D -m 0644 "$HERE/mirror-kiosk-console-guard.timer" /etc/systemd/system/mirror-kiosk-console-guard.timer +systemctl daemon-reload +systemctl enable --now mirror-kiosk-console-guard.timer +say " mirror-kiosk-console-guard.timer enabled" + +# Keep a copy of the payload on the box. /tmp is cleared at every boot, so a +# checker staged there is gone precisely when you want to run it again — after a +# reboot, or from a rescue session hours later. +if [ "$HERE" != "/usr/local/share/mirror-kiosk" ]; then + install -d -m 0755 /usr/local/share/mirror-kiosk + cp -a "$HERE"/. /usr/local/share/mirror-kiosk/ + say " /usr/local/share/mirror-kiosk (survives reboot; run check-kiosk.sh from here)" +fi + +say "== 7/7 selinux labels ==" restorecon -RF "$HOME_DIR/.local" "$HOME_DIR/.config" 2>/dev/null || : -restorecon -RF /etc/firefox /usr/local/share/gnome-kiosk 2>/dev/null || : +restorecon -RF /etc/firefox /usr/local/share/gnome-kiosk /usr/local/share/wayland-sessions \ + /usr/local/libexec 2>/dev/null || : say "" say "Installed." [ "$NEED_REBOOT" -eq 1 ] && say "NEXT: sudo reboot (new RPMs are only in the new deployment)" say "Then, to start the kiosk now (this closes anything on the console):" say " sudo systemctl restart gdm" -say "Check it with: sh check-kiosk.sh" +say "Check it with (this path survives reboots):" +say " sudo sh /usr/local/share/mirror-kiosk/check-kiosk.sh" diff --git a/docs/kiosk-silverblue/mirror-kiosk-console-guard b/docs/kiosk-silverblue/mirror-kiosk-console-guard new file mode 100644 index 0000000..d0691d7 --- /dev/null +++ b/docs/kiosk-silverblue/mirror-kiosk-console-guard @@ -0,0 +1,54 @@ +#!/bin/sh +# mirror-kiosk-console-guard — make sure the kiosk owns the console. +# +# Run by mirror-kiosk-console-guard.timer once a minute as root. +# +# The failure this exists for is invisible remotely: the kiosk session keeps +# running (loginctl says active, the mirror roster counts the wall as a viewer) +# while some other session holds the foreground VT, so the screen shows a login +# prompt and every check says everything is fine. That is exactly how the +# greeter-autospawn bug presented itself. +# +# It deliberately does very little: if the kiosk HAS a seat0 session and that +# session is not the foreground one, activate it. It never restarts gdm and +# never logs anyone out, because a display manager is a bad thing to fight +# automatically. If there is no kiosk session at all it only says so — that is a +# problem for a human, and the message lands in the journal. +# +# Debugging the console by hand? Stop the fight: +# sudo systemctl stop mirror-kiosk-console-guard.timer + +set -eu + +KIOSK_USER="${KIOSK_USER:-kiosk}" +FG_VT=/sys/class/tty/tty0/active + +say() { logger -t mirror-kiosk-guard -- "$*"; } + +[ "$(id -u)" -eq 0 ] || { printf 'run me as root\n' >&2; exit 1; } + +# SESSION UID USER SEAT ... — the kiosk's own session on the physical seat. The +# greeter also sits on seat0 but under the gdm-greeter user, so the name filter +# is what keeps this from chasing the greeter. +session=$(loginctl list-sessions --no-legend 2>/dev/null | + awk -v u="$KIOSK_USER" '$3 == u && $4 == "seat0" { print $1; exit }') + +if [ -z "$session" ]; then + say "no $KIOSK_USER session on seat0: auto-login did not produce one. Not touching gdm." + exit 0 +fi + +active=$(loginctl show-session "$session" -p Active --value 2>/dev/null || echo unknown) +tty=$(loginctl show-session "$session" -p TTY --value 2>/dev/null || echo "") +foreground=$(cat "$FG_VT" 2>/dev/null || echo "") + +if [ "$active" = "yes" ] && [ -n "$tty" ] && [ "$foreground" = "$tty" ]; then + exit 0 # the wall already has the screen +fi + +say "console is on ${foreground:-unknown}, kiosk session $session is on ${tty:-unknown} (Active=$active) — activating it" +if loginctl activate "$session" 2>/dev/null; then + say "kiosk session $session brought back to the foreground" +else + say "could not activate kiosk session $session — look at journalctl -b -u gdm" +fi diff --git a/docs/kiosk-silverblue/mirror-kiosk-console-guard.service b/docs/kiosk-silverblue/mirror-kiosk-console-guard.service new file mode 100644 index 0000000..068c1e3 --- /dev/null +++ b/docs/kiosk-silverblue/mirror-kiosk-console-guard.service @@ -0,0 +1,8 @@ +[Unit] +Description=Keep the mirror kiosk on the console +# Nothing else may be mid-switch when we look at the console. +After=graphical.target + +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/mirror-kiosk-console-guard diff --git a/docs/kiosk-silverblue/mirror-kiosk-console-guard.timer b/docs/kiosk-silverblue/mirror-kiosk-console-guard.timer new file mode 100644 index 0000000..98b8dd1 --- /dev/null +++ b/docs/kiosk-silverblue/mirror-kiosk-console-guard.timer @@ -0,0 +1,13 @@ +[Unit] +Description=Check every minute that the mirror kiosk owns the console + +[Timer] +# Give the session time to come up first, then check forever. +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=15s +# If the box was suspended, run once immediately on resume. +WakeSystem=false + +[Install] +WantedBy=timers.target