#!/bin/bash # Classroom v2 test lab driver. # # The development host is a VM with no /dev/kvm, so the desktop under test # runs inside a systemd container instead of a virtual machine. Nothing this # script does touches the host outside Docker. # # The session is nested: an outer headless sway exists only to give the # desktop under test a display that grim can capture and that sway's IPC can # aim a real pointer at. The outer compositor is lab equipment, not part of # the classroom design. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" NAME="${LAB_NAME:-classroom-v2-lab}" IMAGE="${LAB_IMAGE:-classroom-v2-lab:plasma}" DOCKERFILE="${LAB_DOCKERFILE:-tools/lab/Dockerfile.plasma}" SHOTS="${LAB_SHOTS:-$ROOT/docs/screenshots}" OUTER=wayland-1 session_env() { docker exec -u classroom \ -e XDG_RUNTIME_DIR=/run/user/1000 \ -e HOME=/home/classroom \ -e LANG=C.UTF-8 \ -e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ "$@" } # Talk to the outer lab compositor: grim, swaymsg. outer() { local sock sock=$(docker exec "$NAME" sh -c 'ls /run/user/1000/sway-ipc.* 2>/dev/null | head -1') session_env -e WAYLAND_DISPLAY="$OUTER" -e SWAYSOCK="$sock" "$NAME" "$@" } # NOTE ON XDG_CONFIG_DIRS. # # This harness used to hand the session # XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg, and every result it # produced was therefore a result about a machine somebody had already # configured by hand. A real session is started by SDDM through PAM, which # does not read /etc/environment.d, and the classroom's settings were not # in the cascade at all: the first real upgrade came up as stock Debian # Plasma with a lock screen. The variable is deliberately not set here any # more. The package has to put it there, the way it has to on a machine. # Talk to the Plasma session under test. inner() { session_env -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE \ -e QT_QPA_PLATFORM=wayland "$NAME" "$@" } case "${1:-}" in build) shift exec docker build -f "$ROOT/$DOCKERFILE" -t "$IMAGE" "$@" "$ROOT" ;; up) docker rm -f "$NAME" >/dev/null 2>&1 || true docker run -d --name "$NAME" \ --privileged --cgroupns=private \ --tmpfs /run --tmpfs /run/lock \ --shm-size=1g \ -v "$ROOT:/srv/classroom:ro" \ "$IMAGE" >/dev/null for _ in $(seq 1 90); do state=$(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true) [[ "$state" == running || "$state" == degraded ]] && break sleep 1 done for _ in $(seq 1 30); do docker exec "$NAME" test -S /run/user/1000/bus && break sleep 1 done # startplasma looks for `qdbus`; Debian ships it as qdbus6. docker exec "$NAME" ln -sf /usr/bin/qdbus6 /usr/local/bin/qdbus # The church's own networks, refused from inside the container. # # This is not theoretical. The moment the package began defaulting # MQTT_HOST to the church broker, the lab connected to it: a test # machine appearing on the real broker under a container id. Nothing # was left behind — the broker held no retained status afterwards, # which was checked — but the lab had no business being there at all, # and the Parent Call coordinator is on the same networks. # # Container-local: this netns belongs to the container alone and # nothing on the host or the church network is changed by it. Debian's # own mirrors are untouched, so installs still work. # A blackhole route rather than a firewall rule: the lab image has no # iptables, and the rig's guard had been quietly doing nothing because # of it. Routes need no netfilter modules and cannot silently fail. docker exec "$NAME" sh -c ' for net in 10.30.0.0/16 10.40.0.0/16; do ip route replace blackhole "$net" || exit 1 done' || echo " WARNING: could not fence the church networks" # And checked with bash, not sh. The probe uses /dev/tcp, which is a # bash feature: under dash it fails whatever the network is doing, so # the check passed on a lab that could reach the broker perfectly # well. Measured. for target in 10.40.0.2/1883 10.30.0.2/1025; do if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2 exit 1 fi done echo " church networks fenced out of the lab" echo "lab $NAME is up ($(docker exec "$NAME" systemctl is-system-running 2>/dev/null))" ;; down) docker rm -f "$NAME" >/dev/null 2>&1 || true echo "lab $NAME is down" ;; reboot) # As close to a room being switched off and on as a container gets: # the same filesystem, a new PID 1, a new user manager, and every # generator and drop-in read again from scratch. The migration work # needs this, because half of what an upgrade changes is only read at # boot. docker restart "$NAME" >/dev/null for _ in $(seq 1 90); do state=$(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true) [[ "$state" == running || "$state" == degraded ]] && break sleep 1 done for _ in $(seq 1 30); do docker exec "$NAME" test -S /run/user/1000/bus && break sleep 1 done docker exec "$NAME" ln -sf /usr/bin/qdbus6 /usr/local/bin/qdbus # The fence again. Routes do not survive a container restart, and the # lab reached the church broker through exactly that gap: four rooms # named after container ids on the real broker, from sessions that # had been fenced when they started and were not after a reboot. docker exec "$NAME" sh -c ' for net in 10.30.0.0/16 10.40.0.0/16; do ip route replace blackhole "$net" || exit 1 done' || echo " WARNING: could not fence the church networks" for target in 10.40.0.2/1883 10.30.0.2/1025; do if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2 exit 1 fi done # SDDM really does autologin here: it authenticates the classroom # through PAM and launches the Plasma session, and then has no display # hardware to draw it on. That is worth having — it is the real login # path, not a reading of a configuration file — so the attempt is left # to happen and its evidence stays in the journal. It is then stopped, # because the harness runs its own session on the nested compositor # and two sessions cannot both own the keyboard. for _ in $(seq 1 30); do docker exec "$NAME" journalctl -u sddm --no-pager 2>/dev/null \ | grep -q "Session started" && break sleep 1 done docker exec "$NAME" systemctl stop sddm >/dev/null 2>&1 || true docker exec "$NAME" pkill -f sddm-helper >/dev/null 2>&1 || true # -f, not -x. "startplasma-wayland" is longer than the fifteen # characters a process name has, so -x silently matches nothing and # SDDM's session was left winding down underneath the harness's own: # it tore down graphical-session.target sixteen seconds later and took # the panel with it, which cost an afternoon to understand. docker exec "$NAME" pkill -f startplasma-wayland >/dev/null 2>&1 || true docker exec "$NAME" pkill -f plasma-dbus-run-session >/dev/null 2>&1 || true for _ in $(seq 1 20); do state=$(docker exec -u classroom -e XDG_RUNTIME_DIR=/run/user/1000 "$NAME" \ systemctl --user is-active graphical-session.target 2>/dev/null || true) [[ "$state" != active ]] && break sleep 1 done sleep 2 echo "lab $NAME rebooted ($(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true))" ;; outer-up) # A headless compositor has no input devices, and a wl_seat with no # pointer capability never gives the session under test a pointer to # click with — sway's cursor commands then report success and move # nothing. So the lab runs wlroots' libinput backend as well, over the # devices the privileged container inherits, with seatd standing in for # the login session it does not have. Clicks in this experiment are # real pointer events arriving through libinput. docker exec "$NAME" sh -c 'pgrep -x seatd >/dev/null || \ (SEATD_VTBOUND=0 setsid seatd -g input > /tmp/seatd.log 2>&1 &)' docker exec "$NAME" usermod -aG input classroom 2>/dev/null || true sleep 2 session_env \ -e WLR_BACKENDS=headless,libinput -e LIBSEAT_BACKEND=seatd \ -e WLR_RENDERER=pixman -e LIBGL_ALWAYS_SOFTWARE=1 \ -d "$NAME" sh -c 'exec sway -c /srv/classroom/tools/lab/outer-sway.conf \ > /tmp/outer-sway.log 2>&1' for _ in $(seq 1 30); do docker exec "$NAME" test -S "/run/user/1000/$OUTER" && break sleep 1 done echo "outer compositor on $OUTER" ;; plasma-up) session_env \ -e WAYLAND_DISPLAY="$OUTER" -e XDG_SESSION_TYPE=wayland \ -e XDG_CURRENT_DESKTOP=KDE -e LIBGL_ALWAYS_SOFTWARE=1 \ -e QT_LOGGING_RULES="kwin_scripting.debug=true" \ -d "$NAME" sh -c 'exec startplasma-wayland > /tmp/plasma.log 2>&1' for _ in $(seq 1 60); do docker exec "$NAME" pgrep -x plasmashell >/dev/null 2>&1 && break sleep 1 done sleep 5 echo "plasma session up" ;; plasma-down) # A logout, not a killing. plasmashell and kwin are both services of # the user manager, and killing them only makes it start them again — # the compositor came straight back holding wayland-0 and the next # session could not start at all. The workspace target has to go down # first, and this is what a real logout does too. session_env "$NAME" systemctl --user stop plasma-workspace.target \ >/dev/null 2>&1 || true docker exec "$NAME" pkill -u classroom -f startplasma-wayland || true session_env "$NAME" systemctl --user stop graphical-session.target \ >/dev/null 2>&1 || true for _ in $(seq 1 20); do docker exec "$NAME" pgrep -x kwin_wayland >/dev/null 2>&1 || break sleep 1 done docker exec "$NAME" pkill -u classroom -x kwin_wayland >/dev/null 2>&1 || true docker exec "$NAME" pkill -u classroom -x plasmashell >/dev/null 2>&1 || true sleep 2 echo "plasma session down" ;; session) "$0" outer-up "$0" plasma-up ;; shot) # shot NAME [GEOMETRY] — geometry as grim wants it, e.g. "0,980 1920x100" name="${2:?usage: lab shot NAME}" if [[ -n "${3:-}" ]]; then outer grim -g "$3" /tmp/lab-shot.png >/dev/null else outer grim /tmp/lab-shot.png >/dev/null fi mkdir -p "$SHOTS" docker exec "$NAME" cat /tmp/lab-shot.png > "$SHOTS/$name.png" echo "$SHOTS/$name.png" ;; click) # Absolute, through sway's own seat. This only works because the seat # has a pointer capability at all, which is what the uinput device in # `outer-up` is for. outer swaymsg seat - cursor set "$2" "$3" >/dev/null sleep 0.4 outer swaymsg seat - cursor press button1 >/dev/null sleep 0.15 outer swaymsg seat - cursor release button1 >/dev/null sleep 0.5 ;; rclick) outer swaymsg seat - cursor set "$2" "$3" >/dev/null sleep 0.4 outer swaymsg seat - cursor press button3 >/dev/null sleep 0.15 outer swaymsg seat - cursor release button3 >/dev/null sleep 0.5 ;; move) outer swaymsg seat - cursor set "$2" "$3" >/dev/null ;; drag) outer swaymsg seat - cursor set "$2" "$3" >/dev/null sleep 0.3 outer swaymsg seat - cursor press button1 >/dev/null sleep 0.3 outer swaymsg seat - cursor set "$4" "$5" >/dev/null sleep 0.3 outer swaymsg seat - cursor release button1 >/dev/null sleep 0.4 ;; key) shift outer wtype "$@" ;; sh) shift exec docker exec -it "$NAME" bash "$@" ;; run) shift; session_env "$NAME" "$@" ;; irun) shift; session_env -it "$NAME" "$@" ;; inner) shift; inner "$@" ;; inner-d) shift; session_env -d -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE \ -e QT_QPA_PLATFORM=wayland "$NAME" "$@" ;; inner-it) shift; session_env -it -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE "$NAME" "$@" ;; outer) shift; outer "$@" ;; *) cat <