**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.
271 lines
13 KiB
Bash
Executable File
271 lines
13 KiB
Bash
Executable File
#!/bin/sh
|
|
# install-kiosk.sh — turn this Fedora Silverblue box into a mirror kiosk client.
|
|
#
|
|
# Run ON THE CLIENT, as root, from the directory holding the payload files:
|
|
#
|
|
# sudo sh install-kiosk.sh
|
|
# sudo KIOSK_URL=http://192.168.1.10:8055/watch sh install-kiosk.sh
|
|
#
|
|
# Idempotent: safe to re-run to repair or to change the URL. It deliberately
|
|
# does NOT restart gdm, because that kills whatever is on the console right
|
|
# now — it prints the command instead.
|
|
#
|
|
# What it configures, and why, is in ../kiosk-silverblue.md.
|
|
|
|
set -eu
|
|
|
|
KIOSK_USER="${KIOSK_USER:-kiosk}"
|
|
# The default is for a NEW client only. An existing client keeps its own URL
|
|
# unless the operator names one — see "the URL is the one per-client value" below.
|
|
# The default is the DISPLAY endpoint: /kiosk obeys the shared wall state, so
|
|
# a device with a keyboard (the /remote page) can arrange a box that has none.
|
|
# /watch is the operator's page — a wall a person stands at.
|
|
KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/kiosk"
|
|
KIOSK_URL="${KIOSK_URL:-}"
|
|
KIOSK_SESSION="gnome-kiosk-script-wayland"
|
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
NEED_REBOOT=0
|
|
|
|
say() { printf '%s\n' "$*"; }
|
|
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
|
need() { command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"; }
|
|
|
|
# Rewrite key=<value> inside [section] of an ini file, in place, preserving the
|
|
# file's own inode so its SELinux label and mode survive.
|
|
set_ini() {
|
|
file=$1 section=$2 key=$3 value=$4
|
|
awk -v sec="$section" -v key="$key" -v val="$value" '
|
|
BEGIN { insec = 0; done = 0; hassec = 0 }
|
|
/^[[:space:]]*\[/ {
|
|
if (insec && !done) { print key "=" val; done = 1 }
|
|
cur = $0
|
|
gsub(/[[:space:]]/, "", cur); sub(/^\[/, "", cur); sub(/\]$/, "", cur)
|
|
insec = (cur == sec)
|
|
if (insec) hassec = 1
|
|
print
|
|
next
|
|
}
|
|
insec && $0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
|
|
if (!done) { print key "=" val; done = 1 }
|
|
next
|
|
}
|
|
{ print }
|
|
END {
|
|
if (!done) {
|
|
if (!hassec) print "[" sec "]"
|
|
print key "=" val
|
|
}
|
|
}
|
|
' "$file" >"$file.tmp" && cat "$file.tmp" >"$file" && rm -f "$file.tmp"
|
|
}
|
|
|
|
[ "$(id -u)" -eq 0 ] || die "run me as root (sudo sh $0)"
|
|
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/9 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
|
|
# Layering is the only supported way to add RPMs on Silverblue; /usr stays
|
|
# read-only and the result is transactional.
|
|
rpm-ostree install gnome-kiosk gnome-kiosk-script-session
|
|
NEED_REBOOT=1
|
|
say "layered the kiosk packages — a reboot is required before they exist"
|
|
fi
|
|
|
|
say "== 2/9 kiosk user =="
|
|
if id -u "$KIOSK_USER" >/dev/null 2>&1; then
|
|
HOME_DIR=$(getent passwd "$KIOSK_USER" | cut -d: -f6)
|
|
else
|
|
useradd --home-dir "/var/home/$KIOSK_USER" --create-home --shell /bin/bash "$KIOSK_USER"
|
|
HOME_DIR="/var/home/$KIOSK_USER"
|
|
say "created user $KIOSK_USER (no password; auto-login does not need one)"
|
|
fi
|
|
[ -d "$HOME_DIR" ] || die "$KIOSK_USER has no home directory at $HOME_DIR"
|
|
KIOSK_GROUP=$(getent passwd "$KIOSK_USER" | cut -d: -f4)
|
|
say "user $KIOSK_USER uid=$(id -u "$KIOSK_USER") home=$HOME_DIR"
|
|
|
|
say "== 3/9 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
|
|
say " /etc/firefox/policies/policies.json"
|
|
|
|
# Window rules for gnome-kiosk. /usr/local is /var/usrlocal (writable) and is
|
|
# searched BEFORE /usr/share, so this overrides the shipped file without
|
|
# touching the read-only /usr.
|
|
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
|
|
# keyrings and ICC profiles. Create each level, then chown it explicitly.
|
|
for d in .local .local/bin .local/state .local/share .config .config/mirror; do
|
|
install -d -m 0755 "$HOME_DIR/$d"
|
|
chown "$KIOSK_USER:$KIOSK_GROUP" "$HOME_DIR/$d"
|
|
done
|
|
install -m 0755 -o "$KIOSK_USER" -g "$KIOSK_GROUP" \
|
|
"$HERE/gnome-kiosk-script" "$HOME_DIR/.local/bin/gnome-kiosk-script"
|
|
say " $HOME_DIR/.local/bin/gnome-kiosk-script"
|
|
|
|
# The URL is the one per-client value; keep it in its own file so everything
|
|
# else can stay byte-identical across the fleet.
|
|
#
|
|
# Re-running this installer is the documented way to REPAIR a client, and a repair
|
|
# must not move the wall somewhere else. So an existing kiosk.conf wins over the
|
|
# default; only an explicit KIOSK_URL= changes it.
|
|
KIOSK_CONF="$HOME_DIR/.config/mirror/kiosk.conf"
|
|
if [ -z "$KIOSK_URL" ] && [ -f "$KIOSK_CONF" ]; then
|
|
KIOSK_URL=$(sed -n 's/^[[:space:]]*MIRROR_KIOSK_URL="\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' \
|
|
"$KIOSK_CONF" | head -1)
|
|
if [ -n "$KIOSK_URL" ]; then
|
|
say " keeping this client's existing URL (pass KIOSK_URL=... to change it)"
|
|
fi
|
|
fi
|
|
[ -n "$KIOSK_URL" ] || KIOSK_URL="$KIOSK_URL_DEFAULT"
|
|
sed "s|^MIRROR_KIOSK_URL=.*|MIRROR_KIOSK_URL=\"$KIOSK_URL\"|" "$HERE/kiosk.conf" \
|
|
| install -m 0644 -o "$KIOSK_USER" -g "$KIOSK_GROUP" /dev/stdin \
|
|
"$HOME_DIR/.config/mirror/kiosk.conf"
|
|
say " $HOME_DIR/.config/mirror/kiosk.conf -> $KIOSK_URL"
|
|
|
|
# Without this the GNOME initial-setup wizard claims the screen on first login
|
|
# and the kiosk never starts.
|
|
install -m 0644 -o "$KIOSK_USER" -g "$KIOSK_GROUP" /dev/null \
|
|
"$HOME_DIR/.config/gnome-initial-setup-done"
|
|
|
|
say "== 4/9 default session =="
|
|
ACCT="/var/lib/AccountsService/users/$KIOSK_USER"
|
|
if [ -f "$ACCT" ]; then
|
|
set_ini "$ACCT" User Session "$KIOSK_SESSION"
|
|
else
|
|
mkdir -p /var/lib/AccountsService/users
|
|
printf '[User]\nSession=%s\nSystemAccount=false\n' "$KIOSK_SESSION" >"$ACCT"
|
|
fi
|
|
# accounts-daemon caches these files and would happily write the old value back.
|
|
systemctl restart accounts-daemon.service 2>/dev/null || :
|
|
say " $ACCT Session=$KIOSK_SESSION"
|
|
|
|
say "== 5/9 auto-login =="
|
|
if [ ! -d /etc/gdm ]; then
|
|
say " gdm is not installed — installing it"
|
|
rpm-ostree install gdm
|
|
NEED_REBOOT=1
|
|
fi
|
|
if [ -d /etc/gdm ]; then
|
|
[ -f /etc/gdm/custom.conf ] || : >/etc/gdm/custom.conf
|
|
set_ini /etc/gdm/custom.conf daemon AutomaticLoginEnable True
|
|
set_ini /etc/gdm/custom.conf daemon AutomaticLogin "$KIOSK_USER"
|
|
# A timed login would hand the console to the greeter on its own schedule.
|
|
set_ini /etc/gdm/custom.conf daemon TimedLoginEnable False
|
|
say " /etc/gdm/custom.conf: AutomaticLogin=$KIOSK_USER"
|
|
fi
|
|
|
|
say "== 6/9 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"
|
|
|
|
say "== 7/9 hang recovery =="
|
|
# An unattended wall that hangs must come back by itself. Two independent layers;
|
|
# reasoning and the incident behind them in ../kiosk-silverblue.md.
|
|
install -D -m 0644 "$HERE/90-mirror-wall-selfheal.conf" /etc/sysctl.d/90-mirror-wall-selfheal.conf
|
|
if sysctl --system >/dev/null 2>&1; then
|
|
say " soft lockup / hard lockup / oops now panic; panic reboots in $(cat /proc/sys/kernel/panic)s"
|
|
else
|
|
say " warning: sysctl --system failed — check: sudo sysctl kernel.softlockup_panic kernel.panic"
|
|
fi
|
|
|
|
# The layer that does not need the kernel to be cooperating: if PID 1 stops
|
|
# kicking /dev/watchdog, the chipset resets the box. Only configured when the
|
|
# hardware has one (VMs typically do not).
|
|
if [ -e /dev/watchdog ]; then
|
|
wd_identity=$(cat /sys/class/watchdog/watchdog0/identity 2>/dev/null || echo unknown)
|
|
wd_timeout=$(cat /sys/class/watchdog/watchdog0/timeout 2>/dev/null || echo 0)
|
|
case "$wd_timeout" in ''|*[!0-9]*) wd_timeout=0 ;; esac
|
|
install -D -m 0644 "$HERE/90-mirror-wall-watchdog.conf" /etc/systemd/system.conf.d/90-mirror-wall-watchdog.conf
|
|
# systemd kicks at a third of the value it is given. Asking for more than the
|
|
# device supports would kick less often than the device expires — a reboot
|
|
# loop of our own making — so clamp to what the driver came up with.
|
|
if [ "$wd_timeout" -gt 0 ] && [ "$wd_timeout" -ne 30 ]; then
|
|
sed -i "s|^RuntimeWatchdogSec=.*|RuntimeWatchdogSec=${wd_timeout}s|" \
|
|
/etc/systemd/system.conf.d/90-mirror-wall-watchdog.conf
|
|
say " clamped to the device's own ${wd_timeout}s timeout ($wd_identity)"
|
|
fi
|
|
# PID 1 re-reads system.conf on re-exec; nothing else picks it up short of a
|
|
# reboot, and re-exec is safe (sockets and running services survive it).
|
|
systemctl daemon-reexec
|
|
say " $wd_identity hardware watchdog armed (PID 1 must kick it or the box resets)"
|
|
else
|
|
say " no /dev/watchdog here — hardware layer skipped (lockup panics still installed)"
|
|
fi
|
|
|
|
say "== 8/9 firewall =="
|
|
# Fedora's Workstation zone opens 1025-65535/tcp and /udp. The UDP half is what
|
|
# WebRTC media needs (peer-to-peer, both directions, and mirror uses no STUN/TURN);
|
|
# the TCP half is pure exposure on a box whose only access control is its address.
|
|
# Dropping it is safe for a wall, which never serves: verified against a live
|
|
# share (media keeps flowing at ~460 kbit/s inbound with it gone).
|
|
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
|
zone=$(firewall-cmd --get-default-zone)
|
|
if firewall-cmd --zone="$zone" --list-ports | tr ' ' '\n' | grep -qx '1025-65535/tcp'; then
|
|
firewall-cmd --permanent --zone="$zone" --remove-port=1025-65535/tcp >/dev/null
|
|
firewall-cmd --reload >/dev/null
|
|
say " $zone: dropped 1025-65535/tcp (kept 1025-65535/udp for WebRTC media)"
|
|
else
|
|
say " $zone: 1025-65535/tcp already closed"
|
|
fi
|
|
if firewall-cmd --zone="$zone" --list-ports | tr ' ' '\n' | grep -qx '1025-65535/udp'; then
|
|
say " 1025-65535/udp still open — required, do not close it or mirrors go black"
|
|
else
|
|
say " warning: 1025-65535/udp is NOT open — peer-to-peer media will fail (tile: unreachable)"
|
|
fi
|
|
else
|
|
say " firewalld not running — nothing to tighten (verify your own firewall keeps UDP media)"
|
|
fi
|
|
|
|
# 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 "== 9/9 selinux labels =="
|
|
restorecon -RF "$HOME_DIR/.local" "$HOME_DIR/.config" 2>/dev/null || :
|
|
restorecon -RF /etc/firefox /usr/local/share/gnome-kiosk /usr/local/share/wayland-sessions \
|
|
/usr/local/libexec 2>/dev/null || :
|
|
restorecon -RF /etc/sysctl.d/90-mirror-wall-selfheal.conf \
|
|
/etc/systemd/system.conf.d/90-mirror-wall-watchdog.conf 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 (this path survives reboots):"
|
|
say " sudo sh /usr/local/share/mirror-kiosk/check-kiosk.sh"
|