Files
Classroom_Desktop/tests/test-classroom-network-adopt.sh
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

183 lines
7.0 KiB
Bash
Executable File

#!/bin/bash
# Moving an installer-configured Wi-Fi out of ifupdown's hands.
#
# The Bible study laptop had working internet and an applet that said
# there was no connection, because Debian's installer had written the
# Wi-Fi into /etc/network/interfaces and NetworkManager marks an
# interface named there UNMANAGED. This is the parsing half of the fix:
# find that stanza, get the network out of it, and comment out exactly
# it and nothing else.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import sys
from pathlib import Path
root = Path(sys.argv[1])
sys.path.insert(0, str(root / "lib"))
from classroom_network import comment_out, credentials, parse, wireless
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
# The shape Debian's installer writes when it is given a wireless network
# during installation. Measured against the laptop: /etc/network/interfaces
# is 0600 root:root, wpa-ssid and wpa-psk are inline, and the
# wpa_supplicant it starts has no -c config file, which is what tells you
# ifupdown fed it these values rather than a wpa_supplicant.conf.
LAPTOP = """# This file describes the network interfaces available on your system
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
allow-hotplug wlp2s0
iface wlp2s0 inet dhcp
\twpa-ssid Sanctuary
\twpa-psk a-real-secret
"""
# A classroom tower: static Ethernet an administrator put under
# ifupdown's control on purpose. Nothing here may touch it.
TOWER = """source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
auto enp1s0
iface enp1s0 inet static
\taddress 10.40.0.55/24
\tgateway 10.40.0.1
"""
def wireless_names(*names):
return lambda device: device in names
print("1. the laptop's own file")
stanzas = parse(LAPTOP)
check("every iface stanza is found",
[s["name"] for s in stanzas] == ["lo", "wlp2s0"],
str([s["name"] for s in stanzas]))
found = wireless(stanzas, wireless_names("wlp2s0"))
check("the wireless one is picked out", len(found) == 1)
check("and it is not the loopback", found and found[0]["name"] == "wlp2s0")
ssid, psk = credentials(found[0])
check("the network name is read", ssid == "Sanctuary", repr(ssid))
check("and the password", psk == "a-real-secret", repr(psk))
print("2. a tower is left completely alone")
tower = parse(TOWER)
check("its Ethernet is not mistaken for wireless",
wireless(tower, wireless_names("wlp2s0")) == [])
check("even though it has a static stanza ifupdown owns",
[s["name"] for s in tower] == ["lo", "enp1s0"])
# managed=false stays because of this machine, so nothing may change here.
check("commenting out is never reached for it",
not wireless(tower, lambda d: False))
print("3. only the wireless stanza is commented out")
after = comment_out(LAPTOP, found[0])
check("the iface line is commented",
"# ekklesia-classroom: iface wlp2s0" in after, after)
check("its options are commented with it",
"# ekklesia-classroom: \twpa-ssid" in after)
check("the allow-hotplug above it goes too, or ifupdown asks for an "
"interface with no stanza",
"# ekklesia-classroom: allow-hotplug wlp2s0" in after)
check("the loopback is untouched",
"\niface lo inet loopback\n" in after)
check("and so is the source line",
"\nsource /etc/network/interfaces.d/*\n" in after)
check("the password is still in the file, not deleted",
"a-real-secret" in after,
"the original must remain recoverable until rollback is impossible")
print("4. doing it twice changes nothing the second time")
twice = comment_out(after, parse(after)[0] if parse(after) else found[0])
check("already-commented lines are not commented again",
"# ekklesia-classroom: # ekklesia-classroom:" not in twice)
print("5. a stanza this must refuse to move")
# Pointing at a wpa_supplicant.conf means the credentials are not in this
# file, so there is nothing here to recreate and guessing would be wrong.
CONF = """allow-hotplug wlan0
iface wlan0 inet dhcp
\twpa-conf /etc/wpa_supplicant/wpa_supplicant.conf
"""
stanza = wireless(parse(CONF), wireless_names("wlan0"))[0]
check("a wpa-conf stanza yields no SSID, so the caller stops",
credentials(stanza) == (None, None), str(credentials(stanza)))
print("6. a renamed or absent device is still recognised")
# The machine is asked whether a device is wireless, but a wpa-ssid is
# evidence on its own — a device that has been renamed between kernels
# would otherwise be invisible.
check("a wpa-ssid alone is enough",
len(wireless(parse(LAPTOP), lambda d: False)) == 1)
print("7. the command itself")
wifi = (root / "scripts/classroom-wifi").read_text()
check("--adopt exists and has a dry run", "--adopt" in wifi and "--dry-run" in wifi)
check("--check reads without changing anything", "--check" in wifi)
check("--rollback is a command a person can run, not only an automatic path",
"--rollback" in wifi)
check("it keeps the original file before changing it",
"before-ekklesia-classroom" in wifi)
check("it verifies the room is really online, not just that NM says so",
"curl" in wifi and "connectivity" in wifi)
# A machine with a second link would answer "online" while the interface
# being migrated sat dead, and a migration that failed would be called a
# success.
check("and that the interface itself is carrying the connection",
"def carrying(" in wifi)
check("it never prints the password",
"psk}" not in wifi and "{psk" not in wifi)
check("the connection it creates is still a system one",
'"connection.permissions"' not in wifi)
print("8. it cannot leave a machine half migrated")
# Found by testing the rollback rather than by reading the code: ifdown
# was missing in the lab, subprocess raised, and the exception escaped
# AFTER the stanza had been commented out — no rollback, no network. Both
# of these are the fix.
check("every hand-over command tolerates not being installed",
"def run(" in wifi and "OSError" in wifi)
check("and the whole hand-over is guarded, so any failure restores it",
"except BaseException" in wifi and "restore(device, ssid)" in wifi)
check("it refuses to start when it could not undo itself",
"shutil.which" in wifi
and "leaving the network exactly as it is" in wifi)
print("9. the upgrade does not do any of this on its own")
# Moving a machine's only link during an unattended upgrade is fine until
# the once it is not, and the machine it would strand is a laptop in a
# locked room.
migrate = (root / "scripts/classroom-migrate").read_text()
code = "\n".join(line for line in migrate.splitlines()
if not line.lstrip().startswith("#"))
check("the migration never runs --adopt", "--adopt" not in code)
check("it only looks, and says what it found", "classroom-wifi --check" in code)
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY