fix: a hidden Wi-Fi survives the hand-over, and a failed one leaves an owner

The prayer-room laptop's 0.2.5 migration built a NetworkManager
connection, could not bring it up, rolled back, and left the wireless
adapter belonging to nobody. Four separate defects, each of which would
have been enough on its own.

THE HIDDEN FLAG WAS THROWN AWAY. The stanza said wpa-scan-ssid 1 and the
parser read it — into options, where the only accessor ignored it. The
profile came out with 802-11-wireless.hidden: no, and NetworkManager
will not associate with an SSID it has not seen in a scan. A hidden one
is never in a scan. credentials() returned a pair; profile() now returns
the whole network, and add() writes the flag in BOTH directions rather
than only when true, because a default that happens to be right is not
the same as an answer.

IFDOWN RAN AFTER THE STANZA WAS COMMENTED OUT. ifupdown reads
/etc/network/interfaces to find out how to bring an interface down; with
the stanza gone it says "Ignoring unknown interface" and does nothing.
So ifupdown's own wpa_supplicant and DHCP client were still holding the
radio when NetworkManager was asked to associate with it, which is a
fight NetworkManager loses. Down first, while the file still says how;
rewrite the file second.

THE ROLLBACK LEFT THE ADAPTER UNMANAGED. restore() ended with
`device set <dev> managed no`, which is right while ifupdown can bring
the interface up and is a radio nobody owns when ifup has failed too.
That is the state the laptop was found in, and why `nmcli device set
wlp2s0 managed yes` was a manual recovery step. It now escalates: if
ifupdown cannot get the network back either, the interface goes to
NetworkManager rather than to neither, and it says so loudly.

A '#' IN A PASSWORD WAS SILENTLY TRUNCATED. The parser cut every line at
its first '#'. interfaces(5) is explicit that only whole-line comments
exist, so a pre-shared key containing one became a prefix of itself —
and the only symptom is an association failing exactly the way a wrong
password fails.

Two smaller ones went with them. The rollback un-comments its own marked
lines in place rather than copying the backup over the whole file, so a
wired change made between the migration and the rollback is no longer
silently reverted. And nmcli terse output is split on its own escaping
rules under LC_ALL=C, so a network whose name contains a colon cannot
make a successful migration look like a failed one.

--adopt now also refuses a network it cannot recreate before taking the
interface away from ifupdown to find out, and reports an
unmanaged-devices line in NetworkManager's configuration, which survives
the stanza going away.

Verified against a real NetworkManager 1.52.1: the keyfile carries
hidden=true, the SSID keeps its space, and a psk containing '#' is
intact, at 0600 root:root with no connection.permissions. The new test
drives the whole hand-over against stubbed tools and asserts the
ordering, the rollback and the managed state — including that a machine
already migrated, which is the laptop, is not migrated again.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-22 20:20:53 -05:00
co-authored by Claude Opus 5
parent fe08363534
commit 30d2d99230
4 changed files with 927 additions and 43 deletions
+411
View File
@@ -0,0 +1,411 @@
#!/bin/bash
# The Wi-Fi hand-over, driven end to end against stubbed tools.
#
# tests/test-classroom-network-adopt.sh covers the parsing. This covers
# the part that actually failed on the prayer-room laptop during the
# 0.2.5 setup: the ORDER of the hand-over, and what is carried across
# with the network.
#
# The laptop's own configuration was
#
# allow-hotplug wlp2s0
# iface wlp2s0 inet dhcp
# wpa-ssid "ROGC Classroom"
# wpa-psk <secret>
# wpa-scan-ssid 1
#
# and the migration produced a NetworkManager connection with
# 802-11-wireless.hidden: no, which can never associate with an access
# point that does not broadcast its name. It also ran `ifdown` after the
# stanza had been commented out, where ifdown has nothing left to read
# and does nothing, so ifupdown's own supplicant was still holding the
# radio. Then the rollback left the adapter unmanaged.
#
# nmcli, ifup, ifdown and curl are stubs that record what they were
# asked and can be told to fail, so the sequence is asserted rather than
# described. No real interface, no real NetworkManager, no laptop.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python3 - "$ROOT" <<'PY'
import importlib.machinery
import importlib.util
import os
import shutil
import sys
import tempfile
from pathlib import Path
root = Path(sys.argv[1])
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
def load():
"""classroom-wifi as a module. It has no .py, so name the loader."""
path = root / "scripts" / "classroom-wifi"
loader = importlib.machinery.SourceFileLoader("classroom_wifi", str(path))
spec = importlib.util.spec_from_loader("classroom_wifi", loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
# The laptop's file, with the hidden network and an SSID with a space in
# it. Also a wired stanza, which nothing here may touch.
LAPTOP = """# This file describes the network interfaces available on your system
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
auto enp3s0
iface enp3s0 inet static
\taddress 10.40.0.90/24
\tgateway 10.40.0.1
allow-hotplug wlp2s0
iface wlp2s0 inet dhcp
\twpa-ssid "ROGC Classroom"
\twpa-psk s#cret-with-a-hash
\twpa-scan-ssid 1
"""
BROADCAST = """allow-hotplug wlp2s0
iface wlp2s0 inet dhcp
\twpa-ssid Sanctuary
\twpa-psk ordinary-secret
"""
ENTERPRISE = """allow-hotplug wlp2s0
iface wlp2s0 inet dhcp
\twpa-ssid Campus
\twpa-key-mgmt WPA-EAP
"""
STUB = r"""#!/bin/bash
# Records the call, and the state of the interfaces file at the moment of
# it, so the test can assert ordering rather than trust it.
printf '%s' "$(basename "$0")" >> "$LOG"
for argument in "$@"; do printf ' %s' "$argument" >> "$LOG"; done
printf '\n' >> "$LOG"
if [[ -f "$INTERFACES" ]]; then
cp "$INTERFACES" "$SNAPSHOTS/$(basename "$0").$$.interfaces"
fi
case "$(basename "$0")" in
nmcli)
case "$*" in
*"-f NAME,UUID,TYPE connection show"*) cat "$FAKE_CONNECTIONS" ;;
*"-f NAME,FILENAME connection show"*) cat "$FAKE_FILENAMES" ;;
*"connection show --active"*) cat "$FAKE_ACTIVE" ;;
*"device status"*) cat "$FAKE_DEVICES" ;;
*"networking connectivity"*) cat "$FAKE_CONNECTIVITY" ;;
*"connection up"*) exit "${UP_STATUS:-0}" ;;
esac
exit 0
;;
ifdown) exit "${IFDOWN_STATUS:-0}" ;;
ifup) exit "${IFUP_STATUS:-0}" ;;
curl) exit "${CURL_STATUS:-0}" ;;
esac
exit 0
"""
class Rig:
"""A machine mid-migration, with every external tool stubbed."""
def __init__(self, interfaces_text, **environment):
self.directory = Path(tempfile.mkdtemp(prefix="classroom-wifi-"))
self.bin = self.directory / "bin"
self.snapshots = self.directory / "snapshots"
self.bin.mkdir()
self.snapshots.mkdir()
self.interfaces = self.directory / "interfaces"
self.interfaces.write_text(interfaces_text)
self.log = self.directory / "calls.log"
self.log.write_text("")
for tool in ("nmcli", "ifdown", "ifup", "curl"):
target = self.bin / tool
target.write_text(STUB)
target.chmod(0o755)
# What the stubbed nmcli answers with. Written as files so a test
# can change an answer partway through if it needs to.
self.answers = {
"FAKE_CONNECTIONS": "",
"FAKE_FILENAMES": "",
"FAKE_ACTIVE": "ROGC Classroom:wlp2s0\n",
"FAKE_DEVICES": "wlp2s0:connected\n",
"FAKE_CONNECTIVITY": "full\n",
}
self.environment = {
"LOG": str(self.log),
"INTERFACES": str(self.interfaces),
"SNAPSHOTS": str(self.snapshots),
}
for key, value in self.answers.items():
path = self.directory / key
path.write_text(value)
self.environment[key] = str(path)
self.environment.update({k: str(v) for k, v in environment.items()})
def answer(self, key, value):
Path(self.environment[key]).write_text(value)
def run(self, action, **kwargs):
saved_path = os.environ.get("PATH", "")
saved = {k: os.environ.get(k) for k in self.environment}
os.environ["PATH"] = f"{self.bin}:{saved_path}"
os.environ.update(self.environment)
module = load()
module.INTERFACES = str(self.interfaces)
# The poll loops are real time; nothing here needs to wait.
module.time.sleep = lambda _seconds: None
module.ADOPT_TIMEOUT = 0.2
try:
return getattr(module, action)(**kwargs), module
finally:
os.environ["PATH"] = saved_path
for key, value in saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
@property
def calls(self):
return self.log.read_text().splitlines()
def snapshot_at(self, tool):
"""The interfaces file as it was when `tool` ran."""
for path in sorted(self.snapshots.glob(f"{tool}.*.interfaces")):
return path.read_text()
return None
def clean(self):
shutil.rmtree(self.directory, ignore_errors=True)
print("1. the hidden network survives the hand-over")
# The 0.2.5 regression itself. wpa-scan-ssid 1 was sitting in the parsed
# options and the only accessor threw it away.
rig = Rig(LAPTOP)
status, module = rig.run("adopt")
added = [c for c in rig.calls if c.startswith("nmcli connection add")]
check("the migration reports success", status == 0, str(status))
check("a connection is created", len(added) == 1, str(rig.calls))
check("and it is told the access point does not broadcast its name",
added and "802-11-wireless.hidden yes" in added[0],
added[0] if added else "no connection was added")
check("the SSID keeps its space",
added and "ssid ROGC Classroom" in added[0], added[0] if added else "")
check("it is still a system connection, with no permissions",
added and "connection.permissions" not in added[0])
check("the secret is stored in the connection file",
added and "802-11-wireless-security.psk-flags 0" in added[0])
check("the password is not truncated at the # inside it",
added and "wifi-sec.psk s#cret-with-a-hash" in added[0],
added[0] if added else "")
rig.clean()
print("2. the interface is released BEFORE its stanza is taken away")
# ifdown reads /etc/network/interfaces to know how to bring an interface
# down. Run after the stanza is commented out it reports "Ignoring
# unknown interface" and does nothing, so ifupdown's wpa_supplicant kept
# the radio and NetworkManager could not have it.
rig = Rig(LAPTOP)
rig.run("adopt")
order = [c.split()[0] for c in rig.calls]
at_ifdown = rig.snapshot_at("ifdown")
check("ifdown is called at all", "ifdown" in order, str(order))
check("the stanza is still live in the file when ifdown runs",
at_ifdown is not None
and "\niface wlp2s0 inet dhcp\n" in at_ifdown,
"ifdown ran against a file whose stanza was already commented out")
check("and the file is commented out afterwards",
"# ekklesia-classroom: iface wlp2s0" in rig.interfaces.read_text())
check("NetworkManager is only told to manage it after the file changed",
order.index("ifdown") < len(order) - 1)
managed = [c for c in rig.calls if "device set" in c]
check("the device is explicitly handed to NetworkManager",
any("managed yes" in c for c in managed), str(managed))
up = [c for c in rig.calls if "connection up" in c]
check("the connection is brought up on that interface by name",
up and "ifname wlp2s0" in up[0], str(up))
rig.clean()
print("3. an ordinary broadcast network is not marked hidden")
# The flag has to be an answer in both directions, not something only
# ever switched on.
rig = Rig(BROADCAST)
rig.run("adopt")
added = [c for c in rig.calls if c.startswith("nmcli connection add")]
check("hidden is written as no", added and "802-11-wireless.hidden no" in added[0],
added[0] if added else "")
rig.clean()
print("4. a failed hand-over puts the file back, and only the file")
# The backup is written once and never refreshed, so a whole-file restore
# would silently revert a wired change made in between. The rollback
# un-comments its own marker instead.
rig = Rig(LAPTOP, UP_STATUS="1", FAKE_CONNECTIVITY="none\n", CURL_STATUS="1")
status, _ = rig.run("adopt")
after = rig.interfaces.read_text()
check("the migration reports failure", status == 1, str(status))
check("the wireless stanza is live again",
"\niface wlp2s0 inet dhcp\n" in after, after)
check("the allow-hotplug line came back too",
"\nallow-hotplug wlp2s0\n" in after)
check("nothing of the marker is left behind",
"ekklesia-classroom:" not in after, after)
check("the file is byte for byte what it was", after == LAPTOP,
"restored file differs from the original")
check("the wired stanza was never touched",
"\taddress 10.40.0.90/24\n" in after)
rig.clean()
print("5. a rollback preserves an unrelated change made since")
# The case a whole-file copy gets wrong: the wired address is corrected
# after the migration, and then the migration fails.
rig = Rig(LAPTOP, UP_STATUS="1", FAKE_CONNECTIVITY="none\n", CURL_STATUS="1")
def edit_between(module):
original = module.put_interfaces_back
def wrapped():
text = rig.interfaces.read_text()
rig.interfaces.write_text(
text.replace("10.40.0.90/24", "10.40.0.91/24"))
return original()
module.put_interfaces_back = wrapped
saved_run = Rig.run
def run_with_edit(self, action, **kwargs):
saved_path = os.environ.get("PATH", "")
os.environ["PATH"] = f"{self.bin}:{saved_path}"
os.environ.update(self.environment)
module = load()
module.INTERFACES = str(self.interfaces)
module.time.sleep = lambda _seconds: None
module.ADOPT_TIMEOUT = 0.2
edit_between(module)
try:
return getattr(module, action)(**kwargs), module
finally:
os.environ["PATH"] = saved_path
run_with_edit(rig, "adopt")
after = rig.interfaces.read_text()
check("the corrected wired address survived the rollback",
"10.40.0.91/24" in after, after)
check("and the wireless stanza is live again",
"\niface wlp2s0 inet dhcp\n" in after)
rig.clean()
print("6. a failed hand-over never leaves the adapter unmanaged")
# The state the prayer-room laptop was found in: ifupdown had been told
# to take the interface back, ifup failed too, and NetworkManager had
# been told to leave it alone. Nobody owned the radio, and recovering it
# needed `nmcli device set wlp2s0 managed yes` typed by hand.
rig = Rig(LAPTOP, UP_STATUS="1", IFUP_STATUS="1",
FAKE_CONNECTIVITY="none\n", CURL_STATUS="1")
status, _ = rig.run("adopt")
# Only what the rollback did. The hand-over itself sets managed yes on
# the way in, which is not the question here.
rollback_calls = rig.calls[
max(index for index, call in enumerate(rig.calls)
if call.startswith("ifup ")):]
sets = [c for c in rollback_calls if "device set" in c]
check("ifupdown is offered the interface first",
any("managed no" in c for c in rig.calls), str(rig.calls))
check("and when ifup fails too it is given to NetworkManager",
sets and "managed yes" in sets[-1],
"the last word on the adapter was 'managed no', which owns nothing")
check("so the adapter is never left belonging to nobody",
"managed yes" in rig.calls[-1] or any("managed yes" in c
for c in sets),
str(rollback_calls))
check("and the migration still reports failure", status == 1, str(status))
rig.clean()
print("7. a network this cannot recreate is refused before anything moves")
# WPA-Enterprise has no pre-shared key to carry across. Guessing would
# take the interface away from ifupdown to find that out.
rig = Rig(ENTERPRISE)
status, _ = rig.run("adopt")
check("it refuses", status == 1, str(status))
check("nothing was added", not any("connection add" in c for c in rig.calls),
str(rig.calls))
check("the file is untouched", rig.interfaces.read_text() == ENTERPRISE)
check("ifdown was never run", not any(c.startswith("ifdown")
for c in rig.calls), str(rig.calls))
rig.clean()
print("8. a machine already migrated is not migrated again")
# The prayer-room laptop, after tomorrow. Its stanza is gone, so there is
# nothing to adopt and --adopt must be a no-op rather than an error.
MIGRATED = LAPTOP.replace("allow-hotplug wlp2s0",
"# ekklesia-classroom: allow-hotplug wlp2s0")
MIGRATED = "\n".join(
("# ekklesia-classroom: " + line)
if line.startswith("iface wlp2s0") or line.startswith("\twpa-")
else line
for line in MIGRATED.splitlines()
) + "\n"
rig = Rig(MIGRATED)
status, _ = rig.run("adopt")
check("it reports nothing to do, and succeeds", status == 0, str(status))
check("no connection was created",
not any("connection add" in c for c in rig.calls), str(rig.calls))
check("nothing was brought down", not any(c.startswith("ifdown")
for c in rig.calls))
check("the file is untouched", rig.interfaces.read_text() == MIGRATED)
rig.clean()
print("9. --check changes nothing at all")
rig = Rig(LAPTOP)
status, _ = rig.run("check")
check("it succeeds", status == 0)
check("the file is untouched", rig.interfaces.read_text() == LAPTOP)
check("it runs no command that could change anything",
not any(c.startswith(("ifup", "ifdown")) for c in rig.calls),
str(rig.calls))
check("and no nmcli that writes",
not any("connection add" in c or "connection modify" in c
or "device set" in c for c in rig.calls), str(rig.calls))
rig.clean()
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY