Files
Classroom_Desktop/scripts/classroom-migrate
T
ekklesiaandClaude Opus 5 35e3bdcbbd fix: give a Wi-Fi the installer set up to NetworkManager, by hand
The Bible study laptop had working internet and a panel that said there
was no connection. Both were true, and one thing caused both.

Measured on the machine:

    nmcli device status          wlp2s0  wifi  unmanaged
    nmcli networking connectivity  none        while curl fetched fine
    nmcli device wifi list       nothing, with the radio enabled
    /run/network/ifstate         wlp2s0=wlp2s0
    a second wpa_supplicant, -i wlp2s0, with no -c config file
    dhcpcd holding the lease
    /etc/network/interfaces      0600 root:root

Debian's installer had been given the Wi-Fi, so it wrote it into
/etc/network/interfaces and ifupdown brings it up. NetworkManager ships
with plugins=ifupdown and [ifupdown] managed=false, so an interface named
in that file is UNMANAGED — which is why the applet had nothing to report
and why scanning found nothing. An unmanaged device is not a radio
NetworkManager will scan with. rfkill was ruled out separately: the radio
is enabled.

managed=false stays; it is what keeps a tower's static Ethernet where an
administrator put it. Only a wireless interface is moved.

classroom-wifi grows --check, --adopt (with --dry-run) and --rollback.
--adopt builds a NetworkManager SYSTEM connection from the SSID and key
already in that root-only file, so the password never leaves the machine
and never reaches a human or a log; it lands in a 0600 root file with
psk-flags=0, the same shape 0.2.4 established, and the polkit rules
protecting it are untouched.

Deliberately NOT run by the upgrade. It moves a machine's only link
between managers and the machine it could strand is a laptop in a locked
room, so classroom-migrate only looks and prints what it found. An
administrator runs it over SSH while watching.

Two things this learned from testing the rollback rather than from
reading the code, and both are why the rollback is worth testing:

  * ifdown was missing in the lab, subprocess raised FileNotFoundError,
    and the exception escaped AFTER the stanza was commented out. No
    rollback ran. Every hand-over command now goes through a runner that
    tolerates a missing binary, and the whole hand-over is inside a guard
    that restores the file on any failure at all.
  * it now refuses to start when ifdown or ifup are absent, because
    without them the hand-over would be a one-way door.

Success also requires the interface itself to be carrying the connection,
not merely that the machine is online — a machine with a second link
would otherwise report a failed migration as a success.

Verified in the lab against real NetworkManager with a real ifupdown
file: --check and --dry-run leave it byte-identical, a failed --adopt
rolls back byte-identically, and --rollback restores a half-migrated
machine byte-identically and exits 0. The one thing no lab can do is the
hand-over succeeding, which needs the laptop's radio.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 21:11:19 -05:00

363 lines
14 KiB
Bash
Executable File

#!/bin/bash
set -uo pipefail
# Turn a machine that was something else into this classroom.
#
# A room does not arrive new. It arrives as whatever it was before: the
# sway classroom, the Hyprland experiment, a machine a technician tried
# something on. The package can describe the desktop it wants perfectly
# and still lose to one leftover symlink, which is what happened: a real
# room upgraded to the Plasma appliance and came up with the old Waybar
# across the top, mako still catching notifications, and Plasma's own
# stock panel underneath.
#
# So the installation converges the machine instead of assuming it.
# Everything here is something a classroom package created, or state this
# package can identify as belonging to a classroom shell it replaces.
# Nothing removes a file Debian owns: the old bar and the old notification
# daemon stay installed and simply stop being started.
#
# Idempotent, and safe on a machine that has none of this. Run by hand at
# any time:
#
# sudo classroom-migrate
CLASSROOM_USER=classroom
DEVICE_CONFIG=/etc/ekklesia-classroom/device.conf
# The classroom's own Python, wherever this is being run from: the package
# path first, and the checkout next to this script when there is no
# package — which is how the tests reach it.
LIB_DIR=/usr/local/lib/ekklesia-classroom
if [[ ! -r "$LIB_DIR/classroom_config.py" ]]; then
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd || true)"
fi
# The shells this appliance replaces. There is exactly one desktop stack
# now — SDDM, KWin, plasmashell — and every one of these is a component of
# a stack that is gone.
LEGACY_UNITS=(waybar.service mako.service swayidle.service swaybg.service)
LEGACY_PROCESSES=(waybar mako classroom-dock swaybg swayidle)
LEGACY_EXEC_PATTERN='waybar|mako|classroom-dock|classroom-notifications|sway|Hyprland|start-hyprland|classroom-session'
say() {
echo "[classroom-migrate] $1"
}
if [[ "$(id -u)" -ne 0 ]]; then
say "must be run as root"
exit 1
fi
home=$(getent passwd "$CLASSROOM_USER" | cut -d: -f6)
uid=$(id -u "$CLASSROOM_USER" 2>/dev/null || true)
# ---------------------------------------------------------------- units
# Stop what is running now. The upgrade happens underneath a live session,
# so the old bar is on the screen while this runs.
if [[ -n "$uid" && -d "/run/user/$uid" ]]; then
for unit in "${LEGACY_UNITS[@]}"; do
runuser -u "$CLASSROOM_USER" -- env "XDG_RUNTIME_DIR=/run/user/$uid" \
systemctl --user stop "$unit" >/dev/null 2>&1 || true
done
fi
for process in "${LEGACY_PROCESSES[@]}"; do
if pkill -u "$CLASSROOM_USER" -x "$process" 2>/dev/null; then
say "stopped $process"
fi
done
# Remove the activation, wherever it was made. `systemctl --global enable`
# writes into /etc/systemd/user; a session that set XDG_CONFIG_DIRS gets
# /etc/xdg/systemd/user as well; and a unit enabled by hand as the
# classroom user lands in the home directory. The real room had it in the
# second of those, which is why guessing one location would not have been
# enough.
wants_roots=(/etc/systemd/user /etc/xdg/systemd/user /run/systemd/user)
[[ -n "$home" ]] && wants_roots+=("$home/.config/systemd/user")
for root in "${wants_roots[@]}"; do
[[ -d "$root" ]] || continue
for unit in "${LEGACY_UNITS[@]}"; do
while IFS= read -r link; do
rm -f "$link"
say "removed activation ${link#/}"
done < <(find "$root" -name "$unit" -path '*.wants/*' 2>/dev/null)
done
done
# The unit files themselves, where somebody wrote them by hand. Neither
# waybar nor mako ships a systemd unit in trixie, so a file by one of
# those names belongs to the shell this package replaces — but the rule is
# checked rather than assumed: anything a Debian package owns is left
# exactly where it is.
for root in /etc/systemd/user /etc/xdg/systemd/user "${home:+$home/.config/systemd/user}"; do
[[ -n "$root" && -d "$root" ]] || continue
for unit in "${LEGACY_UNITS[@]}"; do
[[ -f "$root/$unit" ]] || continue
if dpkg -S "$root/$unit" >/dev/null 2>&1; then
say "left $root/$unit alone: a Debian package owns it"
continue
fi
rm -f "$root/$unit"
say "removed the hand-written $root/$unit"
done
done
# And then say no once more, at the system level, so that a unit file
# reappearing from anywhere cannot start it. A mask is a symlink to
# /dev/null in /etc/systemd/user: visible, reversible, and it removes
# nothing.
systemctl --global mask "${LEGACY_UNITS[@]}" >/dev/null 2>&1 || true
# And the other way the old notification daemon starts: D-Bus activation.
# The package shadows mako's service file so that activation fails, but a
# session bus that is already running read its service directories at
# startup and has not seen it. Measured during crash recovery on a
# migrated machine: the panel was killed, a notification was sent while
# nothing owned the name, and mako took it.
if [[ -n "$uid" && -S "/run/user/$uid/bus" ]]; then
runuser -u "$CLASSROOM_USER" -- env \
"XDG_RUNTIME_DIR=/run/user/$uid" \
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus" \
dbus-send --session --dest=org.freedesktop.DBus --type=method_call \
/org/freedesktop/DBus org.freedesktop.DBus.ReloadConfig \
>/dev/null 2>&1 || true
fi
# ------------------------------------------------------------- autostart
# XDG autostart entries that start a component of the old shell. Matched on
# what they run, not on their filename, and only in the classroom's own
# directory.
if [[ -n "$home" && -d "$home/.config/autostart" ]]; then
for entry in "$home/.config/autostart"/*.desktop; do
[[ -e "$entry" ]] || continue
if grep -qE "^Exec=.*($LEGACY_EXEC_PATTERN)" "$entry" 2>/dev/null; then
rm -f "$entry"
say "removed autostart entry $(basename "$entry")"
fi
done
fi
# ------------------------------------------------------------ tty1 login
# The sway classroom started its session from the login shell of an
# autologin getty. SDDM owns the session now, and two things owning tty1
# is how a machine ends up with no usable screen at all.
if [[ -n "$home" && -f "$home/.bash_profile" ]]; then
if grep -qE "exec .*($LEGACY_EXEC_PATTERN)" "$home/.bash_profile" 2>/dev/null; then
rm -f "$home/.bash_profile"
say "removed the login shell that used to start the old session"
fi
fi
rm -f /etc/systemd/system/[email protected]/autologin.conf
rmdir --ignore-fail-on-non-empty \
/etc/systemd/system/[email protected] 2>/dev/null || true
# --------------------------------------------------------- device.conf
# The room's own settings file outlives every version of this package, so
# it is edited rather than replaced: the broker password is in it.
#
# COMPOSITOR chose between sway and Hyprland. There is nothing left for it
# to choose, and a stale value is worse than none — it is an instruction
# this package cannot carry out.
#
# CHANNEL decides which manifest the updater fetches. A machine left on the
# hyprland channel would be handed the Hyprland experiment again by the
# next release published there, undoing the upgrade it just took.
#
# The broker is the same for every classroom, so a room that was set up
# before there was one — blank address, switched off — is given the
# address rather than left with Parent Call saying there is no church
# network. A room already pointed somewhere else is left pointed there:
# that is a decision somebody made. The username and the password are
# never read, never written and never printed.
if [[ -f "$DEVICE_CONFIG" ]]; then
python3 - "$DEVICE_CONFIG" <<'PY'
import re
import sys
OBSOLETE_KEYS = ("COMPOSITOR", "HYPR_APPEARANCE")
# Every channel that serves a desktop this package replaces.
#
# This list was the other way round while the Plasma desktop was an
# experiment: rooms were moved off stable onto a channel of its own so
# that a sway release could not take them back. The Plasma classroom is
# the stable one now, so the experiment's channel is the obsolete one and
# rooms come home. hyprland never carried anything anybody runs.
#
# canary is deliberately not here. It is the next development release's
# channel, and a room an administrator put there is a room somebody meant
# to put there — as is one on a channel named by hand.
OBSOLETE_CHANNELS = {"classroom-v2", "hyprland"}
CHANNEL = "stable"
# The church broker. One address for every room.
BROKER_HOST = "10.40.0.2"
BROKER_PORT = "1883"
TRUTHY = {"1", "true", "yes", "on", "enabled"}
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as handle:
lines = handle.readlines()
out = []
changed = []
seen = set()
def value_of(line):
return line.split("=", 1)[1].strip()
for line in lines:
key = line.split("=", 1)[0].strip() if "=" in line else ""
if key in OBSOLETE_KEYS:
changed.append(f"dropped {key}")
continue
if key:
seen.add(key)
if key == "CHANNEL":
value = value_of(line)
if value.lower() in OBSOLETE_CHANNELS:
changed.append(f"moved off the {value} channel onto {CHANNEL}")
line = f"CHANNEL={CHANNEL}\n"
elif key == "MQTT_ENABLED":
if value_of(line).lower() not in TRUTHY:
changed.append("switched the broker on")
line = "MQTT_ENABLED=true\n"
elif key == "MQTT_HOST":
if not value_of(line):
changed.append(f"filled in the broker address {BROKER_HOST}")
line = f"MQTT_HOST={BROKER_HOST}\n"
elif key == "MQTT_PORT":
if not value_of(line):
changed.append(f"filled in the broker port {BROKER_PORT}")
line = f"MQTT_PORT={BROKER_PORT}\n"
out.append(line)
# A room old enough to predate a setting has no line to correct.
for key, value in (
("MQTT_ENABLED", "true"),
("MQTT_HOST", BROKER_HOST),
("MQTT_PORT", BROKER_PORT),
):
if key not in seen:
changed.append(f"added {key}")
out.append(f"{key}={value}\n")
if changed:
with open(path, "w", encoding="utf-8") as handle:
handle.writelines(out)
for note in changed:
print(f"[classroom-migrate] device.conf: {note}")
PY
fi
# --------------------------------------------- and then the shared defaults
# Everything above is this package's own business: settings to remove, a
# channel to move, a broker to point at. What it deliberately does not do
# is know which settings the classroom has gained since a room was
# installed. That list lives in classroom_config.py, in one place, and
# ensure_device_config appends exactly the ones a file is missing and
# nothing else — so a room upgraded from any age ends up with all of them,
# whether it arrived here through the package's postinst or through
# somebody running this command by hand. Repeating the list here would
# mean two places to forget.
#
# It runs last on purpose. The migration above rewrites lines; this only
# ever appends, so appending afterwards cannot be undone by it.
#
# Outside the guard the migration is inside, because this also writes a
# device.conf for a machine that has none.
if [[ -n "$LIB_DIR" && -r "$LIB_DIR/classroom_config.py" ]]; then
if ! PYTHONPATH="$LIB_DIR" python3 - "$DEVICE_CONFIG" <<'SHARED'
import sys
from classroom_config import ensure_device_config
ensure_device_config(sys.argv[1])
SHARED
then
say "WARNING: could not add this release's settings to device.conf"
fi
else
say "WARNING: classroom_config.py is missing, so device.conf may be short"
say " of settings this release added"
fi
# ------------------------------------------- a Wi-Fi the installer set up
# The Bible study laptop came up with working internet and a Plasma applet
# that insisted there was no connection. Both were true. Debian's
# installer had been given the Wi-Fi, so it wrote the network into
# /etc/network/interfaces; ifupdown brings that up with its own
# wpa_supplicant and dhcpcd, and NetworkManager — which ships here with
# plugins=ifupdown and [ifupdown] managed=false — marks an interface named
# in that file UNMANAGED. Measured on the laptop:
#
# wlp2s0 wifi unmanaged
# nmcli networking connectivity -> none while curl fetched fine
#
# An unmanaged device is also not a radio NetworkManager will scan with,
# which is the same reason the room could not find any networks.
#
# managed=false stays: it is what keeps a tower's static Ethernet under
# ifupdown's control where an administrator put it. Only a WIRELESS
# interface is moved, and classroom-wifi refuses to guess — it does
# nothing at all on a machine whose Wi-Fi is already NetworkManager's, or
# that has none, which is every existing classroom.
#
# DELIBERATELY NOT DONE HERE.
#
# Moving a machine's only network link from one manager to another during
# an unattended package upgrade is the kind of thing that is fine until
# the once it is not, and the machine it would strand is a laptop in a
# room with no keyboard on it. So this only LOOKS, and says what it found.
# An administrator runs the migration when they are watching:
#
# classroom-wifi --adopt --dry-run
# classroom-wifi --adopt
# classroom-wifi --rollback if anything about it is wrong
#
# Reading is safe and changes nothing.
if [[ -x /usr/local/bin/classroom-wifi ]]; then
/usr/local/bin/classroom-wifi --check 2>&1 | sed "s/^/ /" || true
fi
# ------------------------------------------------------- the old session
# Plasma reads the classroom's settings out of a directory that has to be
# in XDG_CONFIG_DIRS, and the environment.d file that used to put it there
# never reached a session started by SDDM. The session script that does is
# installed by the package; this only checks it arrived, because a room
# without it is a room with no lockdown and no panel of ours.
if [[ ! -r /etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh ]]; then
say "WARNING: the session environment script is missing;"
say " the desktop will come up as stock Plasma"
fi
say "done"