**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)**
**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass.
**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓.
**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added.
**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure.
**Next pending phase** — none; `todo/` held only phase 09.
234 lines
10 KiB
Bash
Executable File
234 lines
10 KiB
Bash
Executable File
#!/bin/sh
|
|
# check-kiosk.sh — read-only report on a mirror kiosk client. Run ON THE CLIENT.
|
|
#
|
|
# sudo sh check-kiosk.sh
|
|
#
|
|
# PASS/FAIL describe this machine; WARN means "configured fine, but the thing
|
|
# you can see on the screen will still be wrong". Exits non-zero on FAIL.
|
|
#
|
|
# Nothing here needs the console: every check is readable over SSH, which is
|
|
# the whole point of a kiosk you never plug a keyboard into.
|
|
|
|
KIOSK_USER="${KIOSK_USER:-kiosk}"
|
|
FAILS=0
|
|
|
|
ok() { printf 'PASS %s\n' "$*"; }
|
|
bad() { printf 'FAIL %s\n' "$*"; FAILS=$((FAILS + 1)); }
|
|
warn() { printf 'WARN %s\n' "$*"; }
|
|
info() { printf 'info %s\n' "$*"; }
|
|
|
|
SUDO=""
|
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
|
|
|
printf '%s\n' '--- machine ---'
|
|
if [ -f /etc/os-release ]; then
|
|
info "$( . /etc/os-release && printf '%s %s' "$NAME" "$VERSION_ID" ) on $(uname -m)"
|
|
fi
|
|
if command -v rpm-ostree >/dev/null 2>&1; then
|
|
info "booted deployment: $(rpm-ostree status 2>/dev/null | sed -n 's/^ *● *//p' | head -1)"
|
|
info "layered: $(rpm-ostree status 2>/dev/null | sed -n 's/^ *LayeredPackages: //p' | head -1)"
|
|
fi
|
|
|
|
printf '\n%s\n' '--- packages ---'
|
|
for p in gnome-kiosk gnome-kiosk-script-session firefox; do
|
|
if rpm -q "$p" >/dev/null 2>&1; then ok "$p installed"; else bad "$p not installed (rpm-ostree install $p)"; fi
|
|
done
|
|
|
|
printf '\n%s\n' '--- user ---'
|
|
if id -u "$KIOSK_USER" >/dev/null 2>&1; then
|
|
HOME_DIR=$(getent passwd "$KIOSK_USER" | cut -d: -f6)
|
|
ok "user $KIOSK_USER exists, home $HOME_DIR"
|
|
else
|
|
bad "user $KIOSK_USER does not exist"
|
|
HOME_DIR=""
|
|
fi
|
|
|
|
printf '\n%s\n' '--- payload ---'
|
|
check_file() {
|
|
path=$1 needle=$2 label=$3
|
|
if [ -f "$path" ]; then
|
|
if [ -n "$needle" ] && ! $SUDO grep -q "$needle" "$path" 2>/dev/null; then
|
|
bad "$label: $path exists but has no '$needle'"
|
|
else
|
|
ok "$label: $path"
|
|
fi
|
|
else
|
|
bad "$label: $path is missing"
|
|
fi
|
|
}
|
|
URL=""
|
|
if [ -n "$HOME_DIR" ]; then
|
|
check_file "$HOME_DIR/.local/bin/gnome-kiosk-script" "firefox --kiosk" "kiosk script"
|
|
if [ -f "$HOME_DIR/.config/mirror/kiosk.conf" ]; then
|
|
URL=$($SUDO sed -n 's/^MIRROR_KIOSK_URL="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' \
|
|
"$HOME_DIR/.config/mirror/kiosk.conf" 2>/dev/null | head -1)
|
|
ok "kiosk URL: ${URL:-<empty>}"
|
|
case "$URL" in
|
|
*/watch|*/kiosk) : ;;
|
|
"") bad "kiosk URL is empty" ;;
|
|
*) warn "kiosk URL is neither wall (/watch or /kiosk): $URL — check DNS and the nginx vhost" ;;
|
|
esac
|
|
else
|
|
bad "kiosk config $HOME_DIR/.config/mirror/kiosk.conf is missing"
|
|
fi
|
|
check_file "$HOME_DIR/.config/gnome-initial-setup-done" "" "initial-setup guard"
|
|
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"
|
|
if [ -f "$ACCT" ] && $SUDO grep -q '^Session=gnome-kiosk-script-wayland' "$ACCT"; then
|
|
ok "AccountsService default session is the kiosk"
|
|
else
|
|
bad "AccountsService session for $KIOSK_USER is not gnome-kiosk-script-wayland"
|
|
[ -f "$ACCT" ] && info "$($SUDO cat "$ACCT" 2>/dev/null | tr '\n' ' ')"
|
|
fi
|
|
if [ -f /etc/gdm/custom.conf ] &&
|
|
$SUDO grep -qi "^AutomaticLogin=$KIOSK_USER\$" /etc/gdm/custom.conf &&
|
|
$SUDO grep -qi "^AutomaticLoginEnable=True" /etc/gdm/custom.conf; then
|
|
ok "gdm auto-login targets $KIOSK_USER"
|
|
else
|
|
bad "gdm auto-login is not enabled for $KIOSK_USER"
|
|
fi
|
|
|
|
printf '\n%s\n' '--- running state ---'
|
|
SEAT_USER=$(loginctl list-sessions --no-legend 2>/dev/null | awk '$4=="seat0"{print $3; exit}')
|
|
if [ -z "$SEAT_USER" ]; then
|
|
warn "nobody is logged in on seat0 (sudo systemctl restart gdm)"
|
|
elif [ "$SEAT_USER" = "$KIOSK_USER" ]; then
|
|
ok "seat0 is logged in as $KIOSK_USER"
|
|
else
|
|
bad "seat0 is logged in as '$SEAT_USER', not $KIOSK_USER (sudo systemctl restart gdm)"
|
|
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)"
|
|
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
|
|
KUID=$(id -u "$KIOSK_USER")
|
|
FF=$(pgrep -u "$KUID" -a firefox 2>/dev/null | head -1)
|
|
if [ -z "$FF" ]; then
|
|
warn "no firefox running as $KIOSK_USER yet"
|
|
else
|
|
case "$FF" in
|
|
*--kiosk*) ok "$FF" ;;
|
|
*) warn "firefox is running without --kiosk: $FF" ;;
|
|
esac
|
|
fi
|
|
if [ -f "$HOME_DIR/.local/state/mirror-kiosk.log" ]; then
|
|
info "last kiosk log lines:"
|
|
$SUDO tail -5 "$HOME_DIR/.local/state/mirror-kiosk.log" 2>/dev/null | sed 's/^/ /'
|
|
fi
|
|
fi
|
|
|
|
printf '\n%s\n' '--- hang recovery ---'
|
|
# An unattended wall has to end a hang with a reboot, not with a frozen frame for
|
|
# a week. Two independent layers; ../kiosk-silverblue.md ("When the wall hangs").
|
|
sc() { sysctl -n "$1" 2>/dev/null || echo missing; }
|
|
for k in kernel.softlockup_panic kernel.hardlockup_panic kernel.panic_on_oops; do
|
|
v=$(sc "$k")
|
|
case "$v" in
|
|
1) ok "$k = 1" ;;
|
|
missing) warn "$k does not exist in this kernel build"
|
|
warn " (kernel.hung_task_panic is absent from Fedora's kernel; the watchdog covers it)" ;;
|
|
*) bad "$k = $v, expected 1 — fix /etc/sysctl.d/90-mirror-wall-selfheal.conf, then sudo sysctl --system" ;;
|
|
esac
|
|
done
|
|
P=$(sc kernel.panic)
|
|
case "$P" in
|
|
''|0|missing) bad "kernel.panic = ${P:-unset}: after a panic the box sits there forever" ;;
|
|
*) ok "kernel.panic = $P (reboots $P s after a panic)" ;;
|
|
esac
|
|
if [ -e /dev/watchdog ]; then
|
|
WD=$(systemctl show -p RuntimeWatchdogUSec --value 2>/dev/null || echo 0)
|
|
WID=$(cat /sys/class/watchdog/watchdog0/identity 2>/dev/null || echo '?')
|
|
case "$WD" in
|
|
0|''|infinity) bad "PID 1 is not kicking the $WID watchdog (RuntimeWatchdogUSec=$WD) — a wedged kernel here never recovers" ;;
|
|
*) ok "hardware watchdog armed: $WID at $WD (no kick from PID 1 for $WD = chipset reset)" ;;
|
|
esac
|
|
else
|
|
info "no /dev/watchdog on this box — the lockup panics are the only recovery layer"
|
|
fi
|
|
|
|
printf '\n%s\n' '--- firewall ---'
|
|
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
|
ZONE=$(firewall-cmd --get-default-zone)
|
|
PORTS=$(firewall-cmd --zone="$ZONE" --list-ports | tr ' ' '\n')
|
|
if printf '%s\n' "$PORTS" | grep -qx '1025-65535/tcp'; then
|
|
bad "$ZONE opens 1025-65535/tcp — on a box whose only access control is its address, that is every high port"
|
|
else
|
|
ok "$ZONE: 1025-65535/tcp closed"
|
|
fi
|
|
if printf '%s\n' "$PORTS" | grep -qx '1025-65535/udp'; then
|
|
ok "$ZONE: 1025-65535/udp open (WebRTC media needs it — do not close it)"
|
|
else
|
|
bad "$ZONE: 1025-65535/udp closed — peer-to-peer media cannot flow, every tile reads 'unreachable'"
|
|
fi
|
|
else
|
|
info "firewalld is not running — nothing listening, nothing to tighten"
|
|
fi
|
|
|
|
printf '\n%s\n' '--- what the screen will show ---'
|
|
if [ -n "$URL" ]; then
|
|
BODY=$(curl -fsSL -m 8 "$URL" 2>/dev/null)
|
|
if [ -z "$BODY" ]; then
|
|
warn "$URL is not reachable from this machine right now"
|
|
else
|
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 8 "$URL" 2>/dev/null)
|
|
if printf '%s' "$BODY" | grep -q "Watch · mirror"; then
|
|
ok "$URL serves the mirror watch page (HTTP $CODE)"
|
|
elif printf '%s' "$BODY" | grep -q "Wall · mirror"; then
|
|
ok "$URL serves the mirror kiosk page (HTTP $CODE)"
|
|
else
|
|
warn "$URL answered HTTP $CODE but it is NOT a mirror wall — the kiosk"
|
|
warn "will happily display whatever this is. Check DNS and the nginx vhost."
|
|
info "title: $(printf '%s' "$BODY" | sed -n 's/.*<title>\(.*\)<\/title>.*/\1/p' | head -1)"
|
|
fi
|
|
fi
|
|
else
|
|
info "no URL configured, nothing to fetch"
|
|
fi
|
|
|
|
printf '\n'
|
|
if [ "$FAILS" -gt 0 ]; then
|
|
printf '%s check(s) FAILED.\n' "$FAILS"
|
|
exit 1
|
|
fi
|
|
printf 'All checks passed.\n'
|