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 ` 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