Files
Classroom_Desktop/lib/classroom_network.py
T
ekklesiaandClaude Opus 5 30d2d99230 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]>
2026-09-22 20:20:53 -05:00

262 lines
9.0 KiB
Python

"""Reading what ifupdown was told about this machine's network.
Only the parsing, so it can be tested against the file a real machine
actually has rather than against a running laptop.
WHY THIS EXISTS.
The Bible study laptop had working internet and a Plasma network applet
that said there was no connection. Both were true. Debian's installer had
been given the Wi-Fi during installation, so it wrote the network into
/etc/network/interfaces:
auto wlp2s0
iface wlp2s0 inet dhcp
wpa-ssid Something
wpa-psk a-secret
ifupdown brings that up with its own wpa_supplicant and dhcpcd, which is
why the internet works. NetworkManager ships with plugins=ifupdown and
[ifupdown] managed=false, so an interface named in that file is marked
UNMANAGED — which is why nmcli showed `wlp2s0 wifi unmanaged`, why the
applet had nothing to report, and why scanning found nothing: an
unmanaged device is not a radio NetworkManager will scan with.
managed=false is deliberate and stays. It is what keeps a tower's static
Ethernet under ifupdown's control where an administrator put it. What has
to change is narrower: a WIRELESS interface should belong to
NetworkManager, so the room can see it, scan with it, and so its password
is stored the way this package stores passwords.
So this finds the wireless stanza and everything needed to recreate it as
a NetworkManager system connection.
"""
import re
# ifupdown's own spelling. Values may be quoted, and a psk may be a
# 64-character hex key rather than a passphrase.
SETTINGS = ("wpa-ssid", "wpa-psk", "wpa-key-mgmt", "wpa-conf", "wpa-proto",
"wpa-scan-ssid")
# Whether the access point broadcasts its name.
#
# wpa-scan-ssid 1 is ifupdown's spelling of wpa_supplicant's scan_ssid=1:
# probe for this network by name, because it is not in a passive scan.
# NetworkManager spells the same thing 802-11-wireless.hidden, and a
# migration that drops it produces a connection that is correct in every
# visible respect and can never associate — NetworkManager will not
# connect to an SSID it has not seen in a scan, and a hidden one is never
# in a scan. That is what happened to the prayer-room laptop on 0.2.5.
HIDDEN = "wpa-scan-ssid"
_STANZA = re.compile(r"^\s*(iface|auto|allow-hotplug|source|source-directory)\s+(.*)$")
TRUTHY = {"1", "yes", "true", "on"}
def _unquote(value):
value = (value or "").strip()
for quote in ('"', "'"):
if len(value) >= 2 and value.startswith(quote) and value.endswith(quote):
return value[1:-1]
return value
def parse(text):
"""Every `iface` stanza in an interfaces file, with its options.
Returns a list of dicts: name, family, method, options, and the line
range the stanza occupies so a caller can comment exactly it out and
nothing else.
"""
lines = (text or "").splitlines()
stanzas = []
current = None
for number, raw in enumerate(lines):
# A comment is a WHOLE line, and only a whole line.
#
# interfaces(5): "Lines starting with '#' are ignored. Note that
# end-of-line comments are NOT supported." This used to cut every
# line at its first '#', which is a silent corruption of exactly
# one value: a pre-shared key containing a '#' was truncated, the
# migrated connection got a password that was a prefix of the
# real one, and the only symptom is an association that fails the
# way a wrong password fails.
if raw.lstrip().startswith("#"):
continue
line = raw.rstrip()
match = _STANZA.match(line)
if match:
keyword, rest = match.group(1), match.group(2).split()
if keyword == "iface":
current = {
"name": rest[0] if rest else "",
"family": rest[1] if len(rest) > 1 else "",
"method": rest[2] if len(rest) > 2 else "",
"options": {},
"first": number,
"last": number,
}
stanzas.append(current)
else:
# auto/allow-hotplug end the previous stanza's body.
current = None
continue
if current is None or not line.strip():
continue
parts = line.strip().split(None, 1)
if not parts:
continue
current["options"][parts[0].lower()] = _unquote(
parts[1] if len(parts) > 1 else "")
current["last"] = number
return stanzas
def wireless(stanzas, is_wireless):
"""The stanzas that describe a wireless interface.
`is_wireless` answers for a device name, so the caller decides how to
ask the machine — /sys/class/net/<name>/wireless in practice. A
wpa-ssid is accepted as evidence on its own, for a machine whose
device has since been renamed or removed.
"""
found = []
for stanza in stanzas:
name = stanza.get("name") or ""
if not name or name == "lo":
continue
if is_wireless(name) or "wpa-ssid" in stanza["options"]:
found.append(stanza)
return found
def credentials(stanza):
"""SSID and pre-shared key from a stanza, or (None, None).
A stanza that points at a wpa_supplicant.conf instead of naming the
network inline returns no SSID: that one is not ours to move, and the
caller must leave it alone rather than guess.
"""
found = profile(stanza)
if found is None:
return None, None
return found["ssid"], found["psk"]
def profile(stanza):
"""Everything needed to recreate this stanza in NetworkManager.
A dict, or None when the stanza is not ours to move. Returning the
whole network rather than a pair is the fix for the 0.2.5 failure:
`wpa-scan-ssid` was sitting in the parsed options the whole time and
the only accessor threw it away, so the hand-over could not have
preserved it however carefully it was written.
`key_mgmt` is "wpa-psk", "none", or None meaning "this file says
something this command does not understand". The caller must refuse
that last one rather than guess: a WPA-Enterprise network recreated
as a PSK network is a migration that cannot work and has already
taken the interface away from ifupdown to find out.
"""
options = stanza.get("options") or {}
if "wpa-conf" in options and "wpa-ssid" not in options:
return None
ssid = options.get("wpa-ssid") or None
if not ssid:
return None
psk = options.get("wpa-psk") or None
declared = (options.get("wpa-key-mgmt") or "").strip().upper()
if declared in ("", "WPA-PSK", "WPA-PSK-SHA256", "SAE", "WPA-PSK SAE"):
key_mgmt = "wpa-psk" if psk else "none"
elif declared == "NONE":
key_mgmt = "none"
else:
# WPA-EAP, IEEE8021X and anything else this has never seen.
key_mgmt = None
return {
"ssid": ssid,
"psk": psk,
# A network that has to be probed for by name. See HIDDEN.
"hidden": (options.get(HIDDEN) or "").strip().lower() in TRUTHY,
"key_mgmt": key_mgmt,
}
def comment_out(text, stanza, marker="# ekklesia-classroom: "):
"""Comment out exactly one stanza, leaving every other byte alone."""
lines = (text or "").splitlines(keepends=True)
for number in range(stanza["first"], stanza["last"] + 1):
if number < len(lines) and not lines[number].lstrip().startswith("#"):
lines[number] = marker + lines[number]
# The `auto`/`allow-hotplug` line above it would otherwise ask
# ifupdown to bring up an interface it no longer has a stanza for.
for number in range(stanza["first"] - 1, -1, -1):
stripped = lines[number].strip() if number < len(lines) else ""
if not stripped or stripped.startswith("#"):
continue
if re.match(r"^(auto|allow-hotplug)\s", stripped) and \
stanza["name"] in stripped.split():
lines[number] = marker + lines[number]
break
return "".join(lines)
def uncomment(text, marker="# ekklesia-classroom: "):
"""Undo comment_out, in the file as it stands now.
Returns (text, count). The rollback uses this rather than copying the
backup over the top, because the backup is kept once and never
refreshed: an administrator who corrected a static Ethernet address
between the migration and the rollback would have had that correction
silently reverted by a whole-file restore. This touches only the
lines this package marked, so everything else in the file — wired
stanzas, source lines, an administrator's own comments — is exactly
as the machine left it.
"""
lines = (text or "").splitlines(keepends=True)
restored = 0
for number, line in enumerate(lines):
stripped = line.lstrip()
if not stripped.startswith(marker):
continue
indent = line[:len(line) - len(stripped)]
lines[number] = indent + stripped[len(marker):]
restored += 1
return "".join(lines), restored