docs: the Silverblue kiosk client, installed and checked over SSH

A wall display is now a target client: Fedora Silverblue running GNOME
Kiosk, auto-logged in as kiosk, showing /watch fullscreen in Firefox.
docs/kiosk-silverblue/ carries the idempotent installer, a read-only
checker and a probe that measures what the browser actually renders, so
a second client is a copy-paste and nothing about it needs a keyboard.

The runbook records what the upstream tutorial leaves out: gsettings
only commits from inside the session (gnome-kiosk sets DCONF_PROFILE,
so a system dconf db is never read at all), window-config.ini wins by
being found first and therefore silently loses the shipped notification
rules, AccountsService caches Session= until its daemon restarts, and
/watch survives a bare LAN IP while /mirror cannot.

Verified on the reference client: cold boot back to a subscribed wall in
under 60s, viewport == window == screen at 3840x2160, and the sharer's
own peer connection to it reports connected.

LLM-Generated: true
This commit is contained in:
2026-11-01 19:11:53 -05:00
parent 269b64bace
commit c5da9461a2
9 changed files with 914 additions and 0 deletions
+270
View File
@@ -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 … <url>` |
| 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 `<title>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.
+150
View File
@@ -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'
+110
View File
@@ -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</title></head>
<body style="margin:0;background:#0a0f14;color:#4fd1c5;font:700 8vmin system-ui">
<div id="t">dim-probe</div>
<script>
function report(tag) {
const q = new URLSearchParams({
tag: tag,
inner: window.innerWidth + "x" + window.innerHeight,
outer: window.outerWidth + "x" + window.outerHeight,
screen: screen.width + "x" + screen.height,
avail: screen.availWidth + "x" + screen.availHeight,
dpr: window.devicePixelRatio,
fsElement: document.fullscreenElement ? document.fullscreenElement.tagName : "null",
ua: encodeURIComponent(navigator.userAgent.slice(0, 40)),
});
new Image().src = "/d?" + q.toString();
}
report("load");
// Two more samples: if the window is still settling, the numbers move.
setTimeout(() => report("t2s"), 2000);
setTimeout(() => report("t6s"), 6000);
setInterval(() => report("poll"), 10000);
</script>
</body></html>
"""
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)
+83
View File
@@ -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" "$@"
+156
View File
@@ -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=<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/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"
+24
View File
@@ -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
+65
View File
@@ -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"
}
}
}
}
+34
View File
@@ -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