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
+345 -39
View File
@@ -77,6 +77,11 @@ def nmcli(*args, check=True):
result = subprocess.run(
["nmcli"] + list(args), capture_output=True, text=True, timeout=60,
check=False,
# nmcli translates its output and its terse field separator rules
# are the only thing here that is parsed. C is the one locale in
# which what this reads back is what nmcli is documented to
# print, on a machine in any country.
env={**os.environ, "LC_ALL": "C"},
)
if check and result.returncode != 0:
@@ -86,14 +91,57 @@ def nmcli(*args, check=True):
return result
def terse(line):
"""One line of `nmcli -t` output, split into its fields.
nmcli escapes a colon inside a value as `\\:` and a backslash as
`\\\\`, so splitting on ":" is wrong for any network whose name
contains one. Everything here that compares a connection name to a
field goes through this, because getting it wrong does not produce an
error — it produces a migration that decides it failed and rolls a
working network back.
"""
fields = []
current = []
escaped = False
for character in line:
if escaped:
current.append(character)
escaped = False
elif character == "\\":
escaped = True
elif character == ":":
fields.append("".join(current))
current = []
else:
current.append(character)
fields.append("".join(current))
return fields
def connections():
"""Every saved connection, as (name, uuid, type)."""
result = nmcli("-t", "-f", "NAME,UUID,TYPE", "connection", "show",
check=False)
found = []
for line in (result.stdout or "").splitlines():
fields = terse(line)
if len(fields) >= 3:
found.append((fields[0], fields[1], fields[2]))
return found
def listing():
result = nmcli("-t", "-f", "NAME,TYPE,UUID", "connection", "show")
for line in result.stdout.splitlines():
parts = line.split(":")
if len(parts) >= 2 and "wireless" in parts[1]:
print(f" {parts[0]}")
for name, _uuid, kind in connections():
if "wireless" in kind:
print(f" {name}")
return 0
@@ -105,7 +153,7 @@ def forget(name):
return 0
def add(ssid, password, name=None):
def add(ssid, password, name=None, hidden=False):
name = name or ssid
# Deliberately no connection.permissions. That single omission is the
@@ -121,6 +169,15 @@ def add(ssid, password, name=None):
# Stored in the connection file, which is root-only, rather than
# in a login keyring that belongs to whoever is signed in.
"802-11-wireless-security.psk-flags", "0",
# WHETHER THE ACCESS POINT BROADCASTS ITS NAME.
#
# Always passed, in both directions, rather than only when it is
# true. NetworkManager's default is "no", and a default that
# happens to be right is not the same as an answer: this is the
# setting the prayer-room laptop's migration left at the default
# and could therefore never associate. Written down either way so
# that the connection file says what was meant.
"802-11-wireless.hidden", "yes" if hidden else "no",
]
if password:
@@ -131,9 +188,13 @@ def add(ssid, password, name=None):
nmcli(*arguments)
path = os.path.join(CONNECTIONS, f"{name}.nmconnection")
if hidden:
say(f"{name} is a hidden network; NetworkManager will probe for it "
"by name")
if os.path.exists(path):
path = connection_file(name)
if path and os.path.exists(path):
# NetworkManager already writes 0600 root:root. Said out loud and
# enforced anyway, because this is the file the whole arrangement
# rests on.
@@ -148,6 +209,28 @@ def add(ssid, password, name=None):
return 0
def connection_file(name):
"""Where NetworkManager actually put a connection, or None.
Asked rather than constructed. NetworkManager escapes characters in
the filename it derives from a connection name, so
f"{name}.nmconnection" is a guess that happens to be right for a
one-word SSID and wrong for "ROGC Classroom" — which is the network
this command exists to migrate, and the file whose permissions the
whole arrangement rests on.
"""
result = nmcli("-t", "-f", "NAME,FILENAME", "connection", "show",
check=False)
for line in (result.stdout or "").splitlines():
fields = terse(line)
if len(fields) >= 2 and fields[0] == name and fields[-1]:
return fields[-1]
return None
def run(*command):
"""Run a command, tolerating one that is not installed.
@@ -198,14 +281,71 @@ def carrying(name, device):
"--active", check=False)
for line in (result.stdout or "").splitlines():
parts = line.rsplit(":", 1)
fields = terse(line)
if len(parts) == 2 and parts[0] == name and parts[1] == device:
if len(fields) == 2 and fields[0] == name and fields[1] == device:
return True
return False
def device_state(device):
"""NetworkManager's word for what it is doing with this interface."""
result = nmcli("-t", "-f", "DEVICE,STATE", "device", "status", check=False)
for line in (result.stdout or "").splitlines():
fields = terse(line)
if len(fields) >= 2 and fields[0] == device:
return fields[1]
return "unknown"
def unmanaged_by_config(device):
"""Configuration files that name this device as unmanaged.
Separate from the ifupdown plugin, and the reason the prayer-room
laptop still needed `nmcli device set wlp2s0 managed yes` by hand
after its stanza was gone. A keyfile saying `unmanaged-devices` is
not undone by commenting out an interfaces stanza, and a hand-over
that does not notice it hands the interface to nobody.
"""
naming = []
candidates = ["/etc/NetworkManager/NetworkManager.conf"]
for directory in ("/etc/NetworkManager/conf.d",
"/usr/lib/NetworkManager/conf.d",
"/run/NetworkManager/conf.d"):
try:
candidates += sorted(
os.path.join(directory, entry)
for entry in os.listdir(directory)
if entry.endswith(".conf")
)
except OSError:
continue
for path in candidates:
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
text = handle.read()
except OSError:
continue
for line in text.splitlines():
stripped = line.strip()
if not stripped.startswith("unmanaged-devices"):
continue
if device in stripped or "except:" in stripped:
naming.append(path)
break
return naming
def find_stanza():
"""The one wireless stanza ifupdown holds, or None, plus the file."""
if not os.path.exists(INTERFACES):
@@ -249,20 +389,33 @@ def check():
return 0
device = stanza["name"]
ssid, _ = ifupdown.credentials(stanza)
found = ifupdown.profile(stanza)
say(f"NOTE: {device} is configured in {INTERFACES}, so ifupdown owns "
"it and")
say(" NetworkManager will report it unmanaged — the room's panel")
say(" will say there is no connection and will not find networks.")
if ssid:
if found and found["hidden"]:
say(" Its access point does not broadcast its name, which "
"NetworkManager")
say(" has to be told; --adopt carries that across.")
for path in unmanaged_by_config(device):
say(f" {path} also names this device unmanaged; --adopt "
"reports it")
say(" rather than editing it.")
if found is None:
say(" Its credentials are not in that file, so it has to be "
"moved by hand.")
elif found["key_mgmt"] is None:
say(" Its security is not a pre-shared key, so it has to be "
"moved by hand.")
else:
say(" To move it, while watching, over SSH:")
say(" classroom-wifi --adopt --dry-run")
say(" classroom-wifi --adopt")
else:
say(" Its credentials are not in that file, so it has to be "
"moved by hand.")
return 0
@@ -312,17 +465,37 @@ def adopt(dry_run=False):
return 0
device = stanza["name"]
ssid, psk = ifupdown.credentials(stanza)
found = ifupdown.profile(stanza)
if not ssid:
if found is None:
say(f"{device} is configured through a wpa_supplicant.conf rather "
"than named in this file")
say("that one is not ours to move; adopt it by hand")
return 1
if found["key_mgmt"] is None:
say(f"{device} uses a security this command does not recreate "
f"({stanza['options'].get('wpa-key-mgmt')})")
say("refusing to guess; a network moved wrong is a network that "
"cannot come back")
return 1
ssid = found["ssid"]
psk = found["psk"]
# Never printed. The room's password is not diagnostic output.
say(f"{device}: ifupdown holds a wireless network "
f"({'with' if psk else 'without'} a saved password)")
f"({'with' if psk else 'without'} a saved password"
f"{', hidden' if found['hidden'] else ''})")
# A keyfile naming this device unmanaged survives the stanza going
# away, so the hand-over would end with the interface belonging to
# nobody. Said before anything is touched, and not edited: a file an
# administrator wrote is not this command's to rewrite.
for path in unmanaged_by_config(device):
say(f"WARNING: {path} names {device} unmanaged")
say(" NetworkManager will not keep it after a restart "
"until that is changed")
# Refuse to start a hand-over that cannot be undone. ifdown releases
# the interface and ifup is the whole of the rollback; without them
@@ -342,17 +515,32 @@ def adopt(dry_run=False):
"run would refuse")
say(f"would create a NetworkManager system connection for {ssid!r}")
say(f"would comment the {device} stanza out of {INTERFACES}")
say(" 802-11-wireless.hidden: "
f"{'yes' if found['hidden'] else 'no'}")
say(f" 802-11-wireless-security.key-mgmt: {found['key_mgmt']}")
say(f"would run `ifdown {device}` while its stanza is still in "
f"{INTERFACES}")
say(f"would then comment that stanza out of {INTERFACES}")
say("would hand the interface over, and put it back if that failed")
return 0
existing = nmcli("-t", "-f", "NAME", "connection", "show", check=False)
already = ssid in (existing.stdout or "").splitlines()
already = any(name == ssid for name, _uuid, _kind in connections())
if already:
# Reused as it stands. An existing connection of this name may be
# a teacher's own, or one a previous attempt left behind with the
# wrong hidden setting — which is exactly the state the
# prayer-room laptop was in — so it is corrected rather than
# trusted. Every setting the migration decides is written again.
say(f"NetworkManager already has a connection named {ssid!r}")
say("correcting it to match what ifupdown holds")
nmcli("connection", "modify", ssid,
"802-11-wireless.hidden", "yes" if found["hidden"] else "no",
"connection.autoconnect", "yes",
"802-11-wireless-security.psk-flags", "0",
check=False)
else:
if add(ssid, psk) != 0:
if add(ssid, psk, hidden=found["hidden"]) != 0:
return 1
backup = f"{INTERFACES}.before-ekklesia-classroom"
@@ -366,20 +554,51 @@ def adopt(dry_run=False):
# timeout, an interrupt — the file goes back and ifupdown gets the
# interface again. Nothing below may raise past this.
try:
# RELEASE THE INTERFACE BEFORE TAKING ITS STANZA AWAY.
#
# ifdown reads /etc/network/interfaces to find out how to bring
# an interface down. Run after the stanza is commented out it
# reports "Ignoring unknown interface" and does nothing at all —
# so ifupdown's own wpa_supplicant and DHCP client were still
# running, still holding the radio, when NetworkManager was asked
# to associate with it. Two supplicants on one interface is a
# fight NetworkManager loses, and it is the second half of why
# the prayer-room laptop rolled back.
#
# The order is therefore: down first, while the file still says
# how; rewrite the file second. The window in which the machine
# is offline is the same either way.
result = run("ifdown", device)
if result is not None and result.returncode != 0:
# Not fatal. A machine whose interface is already down is a
# perfectly good starting point, and ifdown says so with a
# non-zero status.
say(f"ifdown {device}: "
f"{(result.stderr or result.stdout or '').strip()}")
say(f"released {device} from ifupdown")
with open(INTERFACES, "w", encoding="utf-8") as handle:
handle.write(ifupdown.comment_out(text, stanza))
say(f"took {device} out of ifupdown's hands")
say(f"took {device} out of ifupdown's hands in {INTERFACES}")
# NetworkManager only re-reads which interfaces ifupdown claims
# when its configuration is reloaded. The link is still
# ifupdown's at this point, and NetworkManager does not touch a
# device it considers unmanaged, so this cannot drop it.
# when its configuration is reloaded.
nmcli("general", "reload", "conf", check=False)
run("ifdown", device)
nmcli("device", "set", device, "managed", "yes", check=False)
nmcli("connection", "up", ssid, check=False)
# On the device, by name. `connection up <name>` alone lets
# NetworkManager choose an interface, and a machine with two
# radios could bring the network up on the wrong one — which
# `carrying` would then correctly report as a failure, and the
# migration would roll back a network that was working.
started = nmcli("connection", "up", ssid, "ifname", device,
check=False)
if started.returncode != 0:
say(f"nmcli: {(started.stderr or '').strip()}")
deadline = time.monotonic() + ADOPT_TIMEOUT
@@ -387,12 +606,15 @@ def adopt(dry_run=False):
if carrying(ssid, device) and online():
say(f"{device} is NetworkManager's now, and the room is "
"online")
say(f"NetworkManager reports {device} "
f"{device_state(device)}")
say("the panel will show the connection at the next login")
return 0
time.sleep(3)
say("NetworkManager did not get the network back")
say("NetworkManager did not get the network back "
f"({device} is {device_state(device)})")
except BaseException as error: # noqa: BLE001 - see the comment above
say(f"the migration failed part way through: {error}")
@@ -403,6 +625,48 @@ def adopt(dry_run=False):
return 1
def put_interfaces_back():
"""Undo this command's edit to /etc/network/interfaces.
Un-commenting the marked lines where they are, rather than copying
the backup over the whole file. The backup is written once, on the
first attempt, and never refreshed — so a whole-file restore silently
reverts anything an administrator changed about the WIRED network in
between. The marker names exactly the lines this command commented
out, and nothing else in the file is touched.
The backup is still the fallback, for a file that has been replaced
since rather than edited.
"""
backup = f"{INTERFACES}.before-ekklesia-classroom"
try:
with open(INTERFACES, "r", encoding="utf-8") as handle:
current = handle.read()
except OSError as error:
say(f"cannot read {INTERFACES}: {error}")
current = None
if current is not None:
restored, count = ifupdown.uncomment(current)
if count:
with open(INTERFACES, "w", encoding="utf-8") as handle:
handle.write(restored)
say(f"put {count} line{'' if count == 1 else 's'} back in "
f"{INTERFACES}, and changed nothing else in it")
return True
if os.path.exists(backup):
shutil.copy2(backup, INTERFACES)
say(f"no marked lines to restore; copied {backup} back instead")
return True
say(f"nothing of this command's is in {INTERFACES} to undo")
return False
def restore(device, ssid=None):
"""Put the interface back under ifupdown.
@@ -410,11 +674,7 @@ def restore(device, ssid=None):
decide what that means for an exit code: for a failed migration it is
still a failure, and for `--rollback` it is a success.
"""
backup = f"{INTERFACES}.before-ekklesia-classroom"
if os.path.exists(backup):
shutil.copy2(backup, INTERFACES)
say(f"restored {INTERFACES} from {backup}")
put_interfaces_back()
nmcli("general", "reload", "conf", check=False)
@@ -423,7 +683,12 @@ def restore(device, ssid=None):
if device:
nmcli("device", "set", device, "managed", "no", check=False)
run("ifup", device)
result = run("ifup", device)
if result is not None and result.returncode != 0:
say(f"ifup {device}: "
f"{(result.stderr or result.stdout or '').strip()}")
say(f"ifupdown has {device} again")
for _ in range(10):
@@ -433,6 +698,44 @@ def restore(device, ssid=None):
time.sleep(3)
# THE ADAPTER IS NEVER LEFT BELONGING TO NOBODY.
#
# Above, the device has deliberately been handed back to ifupdown and
# NetworkManager told to leave it alone. That is right while ifupdown
# can actually bring it up. When ifup has failed too, the same two
# lines are a wireless adapter that neither manager owns: no
# connection, and a panel that cannot even scan for one. That is the
# state the prayer-room laptop was found in after 0.2.5, and it is
# the state that made `nmcli device set wlp2s0 managed yes` a manual
# recovery step.
#
# So the last resort is to give it to NetworkManager rather than to
# leave it with neither. A room able to pick its network from the
# panel is recoverable by somebody standing in front of it; an
# unmanaged radio is not.
if device:
say("WARNING: ifupdown did not bring the network back either")
say(f" handing {device} to NetworkManager so the adapter "
"is not left unmanaged")
nmcli("device", "set", device, "managed", "yes", check=False)
if ssid:
nmcli("connection", "up", ssid, "ifname", device, check=False)
for _ in range(5):
if online():
say("the room is on the network, through NetworkManager")
say(f"NOTE: {INTERFACES} still describes {device}, so "
"ifupdown will")
say(" claim it again at the next boot. Decide which "
"one owns it.")
return True
time.sleep(3)
say(f" {device} is {device_state(device)}")
say("WARNING: the machine may be offline. The original "
f"{INTERFACES} is restored; try `ifup {device}` or a reboot")
return False
@@ -449,6 +752,8 @@ def main():
help="remove a saved connection")
parser.add_argument("--open", action="store_true",
help="the network has no password")
parser.add_argument("--hidden", action="store_true",
help="the access point does not broadcast its name")
parser.add_argument("--adopt", action="store_true",
help="move an installer-configured Wi-Fi from "
"ifupdown into NetworkManager")
@@ -494,7 +799,8 @@ def main():
say("no password given")
return 1
return add(arguments.ssid, password, arguments.name)
return add(arguments.ssid, password, arguments.name,
hidden=arguments.hidden)
if __name__ == "__main__":