diff --git a/README.md b/README.md index 2236434..73a5518 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ Linux is the primary target. On Linux the native picker is X11/Wayland's own (Chromium uses `xdg-desktop-portal` on Wayland/GNOME/KDE, and its built-in X11 picker on Xorg), so nothing has to be installed on the client side. +The target *client* setup for an always-on display is Fedora Silverblue running +GNOME Kiosk — see [`docs/kiosk-silverblue.md`](docs/kiosk-silverblue.md). + ## How it works Media never touches the server. Each mirror is a WebRTC peer connection @@ -105,6 +108,25 @@ from this side: server only relays JSON, so it dials nothing; `Internal=true` is what makes that a fact rather than an intention. +### Kiosk client + +A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk +session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at +`/watch`. It is set up and repaired entirely over SSH — no keyboard, no console, +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' +``` + +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 +otherwise read-only OS. + ## Development ```bash diff --git a/docs/kiosk-silverblue.md b/docs/kiosk-silverblue.md new file mode 100644 index 0000000..40bbb77 --- /dev/null +++ b/docs/kiosk-silverblue.md @@ -0,0 +1,270 @@ +# 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 | +| `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. +scp -r docs/kiosk-silverblue client:/tmp/ && ssh client \ + 'cd /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' +``` + +`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`. + +--- + +## 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 … ` | +| 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 `Watch · mirror` | +| 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 | +|---|---| +| Greeter on screen, not the wall | `seat0` logged in as someone else — `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. diff --git a/docs/kiosk-silverblue/check-kiosk.sh b/docs/kiosk-silverblue/check-kiosk.sh new file mode 100755 index 0000000..a866d06 --- /dev/null +++ b/docs/kiosk-silverblue/check-kiosk.sh @@ -0,0 +1,150 @@ +#!/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) : ;; + "") bad "kiosk URL is empty" ;; + *) warn "kiosk URL is not the /watch wall: $URL" ;; + 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" + +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) vt=$(loginctl show-session "$SEAT_SESSION" -p VTHint --value 2>/dev/null)" +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' '--- 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)" + else + warn "$URL answered HTTP $CODE but it is NOT the mirror watch page — 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' diff --git a/docs/kiosk-silverblue/dim-probe.py b/docs/kiosk-silverblue/dim-probe.py new file mode 100644 index 0000000..ea4a85b --- /dev/null +++ b/docs/kiosk-silverblue/dim-probe.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""dim-probe.py — measure what a headless kiosk actually renders. + +A kiosk has no keyboard, no monitor you can read and (on Silverblue) no +xdotool/grim to ask the compositor. So ask the browser instead: this serves one +page that reports its own viewport and screen size back as a request, which +lands in this server's log. + +Run it on a machine the kiosk can reach: + + python3 dim-probe.py # listens on 0.0.0.0:8099 + +then point the kiosk at it for a moment: + + sudo KIOSK_URL=http://<this-host>:8099/ sh install-kiosk.sh + sudo pkill -u kiosk firefox # the kiosk loop reloads and relaunches + +`innerWidth == screen.width` and `fullscreenElement == null` is the signature of +a real fullscreen window: the page occupies the whole output, so there is no +browser chrome and no panel left over to measure. (Firefox does not report +itself as a document.fullscreenElement when the window is fullscreen at +startup — that API is for in-page requestFullscreen.) + +Read-only, no dependencies, ctrl-C to stop. +""" + +from __future__ import annotations + +import http.server +import socketserver +import sys +import time + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8099 + +PAGE = b"""<!doctype html><html><head><meta charset="utf-8"><title>dim-probe + +
dim-probe
+ + +""" + +PNG1x1 = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" + "0000000d4944415478da6360000002000100fdff03fd0000000049454e44ae426082" +) + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path.startswith("/d?"): + query = self.path.split("?", 1)[1] + fields = dict(kv.split("=", 1) for kv in query.split("&") if "=" in kv) + print( + f"{time.strftime('%H:%M:%S')} " + f"{self.client_address[0]:<15} " + f"{fields.get('tag', '?'):<5} " + f"viewport={fields.get('inner', '?'):<11} " + f"window={fields.get('outer', '?'):<11} " + f"screen={fields.get('screen', '?'):<11} " + f"available={fields.get('avail', '?'):<11} " + f"fsElement={fields.get('fsElement', '?')}", + flush=True, + ) + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(PNG1x1))) + self.end_headers() + self.wfile.write(PNG1x1) + return + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(PAGE))) + self.end_headers() + self.wfile.write(PAGE) + + def log_message(self, *args: object) -> None: + """Silence the default access log; the /d beacon above is the signal.""" + + +if __name__ == "__main__": + socketserver.TCPServer.allow_reuse_address = True + with socketserver.ThreadingTCPServer(("0.0.0.0", PORT), Handler) as httpd: + print(f"dim-probe on http://0.0.0.0:{PORT}/ — waiting for the kiosk…", flush=True) + try: + httpd.serve_forever() + except KeyboardInterrupt: + sys.exit(0) diff --git a/docs/kiosk-silverblue/gnome-kiosk-script b/docs/kiosk-silverblue/gnome-kiosk-script new file mode 100755 index 0000000..c84f803 --- /dev/null +++ b/docs/kiosk-silverblue/gnome-kiosk-script @@ -0,0 +1,83 @@ +#!/bin/sh +# ~/.local/bin/gnome-kiosk-script — the mirror kiosk application. +# +# Installed by docs/kiosk-silverblue/install-kiosk.sh; see that runbook. +# +# `gnome-kiosk-script-session` runs this file as the kiosk user inside the +# kiosk session, and this script re-execs itself forever (the upstream example +# does the same). That has two consequences worth designing around: +# +# 1. Never launch unconditionally. The loop comes back every few seconds, so +# an unguarded launch opens a new window every few seconds. +# 2. Never exit. `org.gnome.Kiosk.Script.service` has +# `OnSuccess=gnome-session-shutdown.target`, so leaving this script logs +# the kiosk out and the screen goes back to the GDM greeter. +# +# So: poll, (re)launch only when firefox is gone, and keep the loop alive. + +# Source the per-client config FIRST, then derive everything from it, so the +# payload file can stay byte-identical on every client and the kiosk.conf under +# ~/.config/mirror is the only thing that differs. Deriving before the source +# is a real trap: the script silently runs on the built-in default URL forever. +CONF="${MIRROR_KIOSK_CONF:-$HOME/.config/mirror/kiosk.conf}" +if [ -r "$CONF" ]; then + # shellcheck source=/dev/null + . "$CONF" +fi + +URL="${MIRROR_KIOSK_URL:-https://mirror.reeselink.com/watch}" +PROFILE="${MIRROR_KIOSK_PROFILE:-$HOME/.mirror-kiosk}" +LOG="${MIRROR_KIOSK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/mirror-kiosk.log}" +POLL="${MIRROR_KIOSK_POLL:-5}" + +log() { + [ -n "$LOG" ] || return 0 + # Cap the log: this script runs forever on a machine nobody logs into. + if [ -f "$LOG" ] && [ "$(stat -c %s "$LOG" 2>/dev/null || echo 0)" -gt 1048576 ]; then + tail -c 65536 "$LOG" >"$LOG.tmp" 2>/dev/null && mv -f "$LOG.tmp" "$LOG" 2>/dev/null + fi + printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo -)" "$*" >>"$LOG" 2>/dev/null || : +} + +# Keep the screen on. This has to happen here, inside the session, and not at +# install time: gsettings only commits when it can reach a D-Bus session bus, +# so `sudo -u kiosk gsettings set ...` over SSH warns "Cannot autolaunch D-Bus +# without X11" and silently leaves the default. It also cannot go 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. +# +# $XDG_RUNTIME_DIR is per-login, so this runs once per kiosk start and still +# re-applies if someone changes the setting by hand. +GSETTINGS_STAMP="${XDG_RUNTIME_DIR:-/tmp}/mirror-kiosk-gsettings.done" +if [ ! -f "$GSETTINGS_STAMP" ]; then + gsettings set org.gnome.desktop.session idle-delay 0 >/dev/null 2>&1 + gsettings set org.gnome.desktop.screensaver lock-enabled false >/dev/null 2>&1 + gsettings set org.gnome.desktop.interface enable-animations false >/dev/null 2>&1 + log "applied idle/lock gsettings" + : >"$GSETTINGS_STAMP" 2>/dev/null || : +fi + +if pgrep -u "$(id -u)" -x firefox >/dev/null 2>&1; then + # Up already. Nothing to do but check again in a moment. + : +elif command -v curl >/dev/null 2>&1 && ! curl -fsSL -m 10 -o /dev/null "$URL" 2>/dev/null; then + # Don't park a "server not found" page on a wall display: keep polling and + # load the page the moment the mirror server answers again. This is what + # covers a reboot where the network has not come up yet. + log "target not reachable yet: $URL" +else + mkdir -p "$PROFILE" 2>/dev/null + log "launching: firefox --kiosk $URL" + # --kiosk : fullscreen, no chrome, no exit affordances + # --profile: our own dir, so the kiosk never collides with another profile + # lock and never inherits anyone's history + # `pgrep -x firefox` above can only be false here if a launch failed + # outright, so a foreground run cannot wedge the loop. + /usr/bin/firefox --kiosk --no-remote --profile "$PROFILE" "$URL" >>"$LOG" 2>&1 + rc=$? + log "firefox exited rc=$rc" +fi + +sleep "$POLL" +exec "$0" "$@" diff --git a/docs/kiosk-silverblue/install-kiosk.sh b/docs/kiosk-silverblue/install-kiosk.sh new file mode 100755 index 0000000..919fc7f --- /dev/null +++ b/docs/kiosk-silverblue/install-kiosk.sh @@ -0,0 +1,156 @@ +#!/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}" +KIOSK_URL="${KIOSK_URL:-https://mirror.reeselink.com/watch}" +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= 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/6 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/6 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/6 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" + +# `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. +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/6 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/6 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/6 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 || : + +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" diff --git a/docs/kiosk-silverblue/kiosk.conf b/docs/kiosk-silverblue/kiosk.conf new file mode 100644 index 0000000..c2004fc --- /dev/null +++ b/docs/kiosk-silverblue/kiosk.conf @@ -0,0 +1,24 @@ +# /var/home/kiosk/.config/mirror/kiosk.conf +# +# The single source of truth for what this kiosk displays. +# Sourced with `.` from ~/.local/bin/gnome-kiosk-script, so: plain sh syntax, +# no bashisms, no spaces around `=`, and no side effects in this file. +# +# After editing: `sudo systemctl restart gdm` (or log the kiosk user out) to +# re-read it. The browser is restarted by the kiosk script's own loop. + +# The wall of every live mirror. `/` is the Share/Watch chooser and needs a +# tap to get anywhere, which is wrong for a display nobody is standing at. +MIRROR_KIOSK_URL="https://mirror.reeselink.com/watch" + +# Optional overrides (uncomment to change): + +# Dedicated profile, kept out of ~/.mozilla so a wipe is one rm -rf. +#MIRROR_KIOSK_PROFILE="$HOME/.mirror-kiosk" + +# Where firefox's stderr goes; this is the first place to look when the kiosk +# shows the wrong thing. Kept under 1 MiB by the script itself. +#MIRROR_KIOSK_LOG="$HOME/.local/state/mirror-kiosk.log" + +# Seconds between "is the browser still up?" polls. +#MIRROR_KIOSK_POLL=5 diff --git a/docs/kiosk-silverblue/policies.json b/docs/kiosk-silverblue/policies.json new file mode 100644 index 0000000..b197f35 --- /dev/null +++ b/docs/kiosk-silverblue/policies.json @@ -0,0 +1,65 @@ +{ + "policies": { + "DisableAppUpdate": true, + "DisableFeedbackCommands": true, + "DisableFirefoxAccounts": true, + "DisableFirefoxStudies": true, + "DisableProfileRefresh": true, + "DisableSetDesktopBackground": true, + "DisableSync": true, + "ExtensionUpdate": false, + "NoDefaultBookmarks": true, + "OfferToSaveLogins": false, + "OverrideFirstRunPage": "", + "Preferences": { + "app.shield.optoutstudies.enabled": { + "Value": false, + "Status": "locked" + }, + "browser.aboutConfig.showWarning": { + "Value": false, + "Status": "default" + }, + "browser.migrate.interactions.bookmarks": { + "Value": false, + "Status": "default" + }, + "browser.migrate.interactions.passwords": { + "Value": false, + "Status": "default" + }, + "browser.sessionstore.resume_from_crash": { + "Value": false, + "Status": "default" + }, + "browser.startup.page": { + "Value": 1, + "Status": "locked" + }, + "datareporting.policy.dataSubmissionEnabled": { + "Value": false, + "Status": "locked" + }, + "datareporting.policy.dataSubmissionPolicyBypassNotification": { + "Value": true, + "Status": "default" + }, + "toolkit.telemetry.enabled": { + "Value": false, + "Status": "locked" + }, + "datareporting.healthreport.uploadEnabled": { + "Value": false, + "Status": "locked" + }, + "browser.urlbar.suggest.searches": { + "Value": false, + "Status": "locked" + }, + "media.autoplay.blocking_policy": { + "Value": 0, + "Status": "locked" + } + } + } +} diff --git a/docs/kiosk-silverblue/window-config.ini b/docs/kiosk-silverblue/window-config.ini new file mode 100644 index 0000000..d0b5866 --- /dev/null +++ b/docs/kiosk-silverblue/window-config.ini @@ -0,0 +1,34 @@ +# /usr/local/share/gnome-kiosk/window-config.ini — mirror kiosk window policy. +# +# gnome-kiosk searches $XDG_CONFIG_HOME/gnome-kiosk/ first, then $XDG_DATA_DIRS +# in order (/var/lib/flatpak/exports/share, /usr/local/share, /usr/share), and +# uses the FIRST file it finds. /usr/local/share is therefore the right place +# on Silverblue: it is mutable (it is /var/usrlocal) and it wins over the +# shipped /usr/share/gnome-kiosk/window-config.ini without touching /usr. +# +# Because that shipped file is then *not* read, anything it defines has to be +# repeated here or it is lost. The [notifications] section below is copied +# verbatim from gnome-kiosk-50.1's shipped file for that reason. + +# Firefox is the kiosk application: force it fullscreen even if it comes up +# windowed (e.g. after a resume, or if --kiosk is ever dropped). +[mirror-browser] +match-class=org.mozilla.* +set-fullscreen=true + +# Copied from the shipped window-config.ini — kiosk notifications stay a +# small overlay in the top-left instead of stealing the whole screen. +[notifications] +match-tag=gnome-kiosk-notification +set-x=0 +set-y=0 +set-fullscreen=false +set-above=true + +# Multi-monitor: pin the browser to one output. Find the output name with +# `wayland-info` inside the kiosk session (or `loginctl` / journal). +#[mirror-monitor] +#match-class=org.mozilla.* +#set-fullscreen=true +#set-on-monitor=eDP-1 +#lock-on-monitor=true