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]>
262 lines
9.0 KiB
Python
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
|