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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a31fa75d55
commit
35e3bdcbbd
+211
-50
@@ -148,6 +148,22 @@ def add(ssid, password, name=None):
|
||||
return 0
|
||||
|
||||
|
||||
def run(*command):
|
||||
"""Run a command, tolerating one that is not installed.
|
||||
|
||||
Every call in the hand-over goes through this. An uncaught
|
||||
FileNotFoundError here once left a machine with its stanza already
|
||||
commented out and no rollback run, which is the exact state this
|
||||
whole command exists to avoid.
|
||||
"""
|
||||
try:
|
||||
return subprocess.run(list(command), capture_output=True, text=True,
|
||||
timeout=60, check=False)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
say(f"{command[0]}: {error}")
|
||||
return None
|
||||
|
||||
|
||||
def is_wireless(device):
|
||||
return os.path.isdir(f"/sys/class/net/{device}/wireless")
|
||||
|
||||
@@ -159,12 +175,7 @@ def connectivity():
|
||||
|
||||
|
||||
def online():
|
||||
"""Whether the machine can actually reach anything.
|
||||
|
||||
NetworkManager's own answer first, because that is the thing the room
|
||||
was complaining about; a real fetch second, because "full" is a claim
|
||||
and this is the moment to be sure of it.
|
||||
"""
|
||||
"""Whether the machine can actually reach anything."""
|
||||
if connectivity() in ("full", "limited"):
|
||||
return True
|
||||
|
||||
@@ -175,6 +186,106 @@ def online():
|
||||
).returncode == 0
|
||||
|
||||
|
||||
def carrying(name, device):
|
||||
"""Whether NetworkManager has this connection up on this device.
|
||||
|
||||
Asked as well as "is the machine online", and not instead of it. A
|
||||
machine with a second link would answer yes to connectivity while the
|
||||
interface being migrated sat dead, and this would then call a failed
|
||||
migration a success and leave the Wi-Fi behind.
|
||||
"""
|
||||
result = nmcli("-t", "-f", "NAME,DEVICE", "connection", "show",
|
||||
"--active", check=False)
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
parts = line.rsplit(":", 1)
|
||||
|
||||
if len(parts) == 2 and parts[0] == name and parts[1] == device:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def find_stanza():
|
||||
"""The one wireless stanza ifupdown holds, or None, plus the file."""
|
||||
try:
|
||||
with open(INTERFACES, "r", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
except OSError as error:
|
||||
say(f"cannot read {INTERFACES}: {error}")
|
||||
return None, None
|
||||
|
||||
found = ifupdown.wireless(ifupdown.parse(text), is_wireless)
|
||||
|
||||
if not found:
|
||||
return None, text
|
||||
|
||||
if len(found) > 1:
|
||||
say(f"{len(found)} wireless stanzas found; refusing to guess")
|
||||
return None, text
|
||||
|
||||
return found[0], text
|
||||
|
||||
|
||||
def check():
|
||||
"""Say who owns the Wi-Fi. Reads only; changes nothing, ever.
|
||||
|
||||
This is what the migration runs instead of migrating, because moving
|
||||
a machine's only link during an unattended upgrade is not a risk a
|
||||
room in a locked building should carry.
|
||||
"""
|
||||
stanza, text = find_stanza()
|
||||
|
||||
if text is None:
|
||||
return 0
|
||||
|
||||
if stanza is None:
|
||||
say("no wireless interface is under ifupdown's control here")
|
||||
return 0
|
||||
|
||||
device = stanza["name"]
|
||||
ssid, _ = ifupdown.credentials(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:
|
||||
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
|
||||
|
||||
|
||||
def rollback():
|
||||
"""Give the Wi-Fi back to ifupdown, exactly as it was.
|
||||
|
||||
The migration does this by itself if the network does not come back.
|
||||
This is the same path with a person deciding, for the case where the
|
||||
network came back and something else about it is wrong.
|
||||
"""
|
||||
backup = f"{INTERFACES}.before-ekklesia-classroom"
|
||||
|
||||
if not os.path.exists(backup):
|
||||
say(f"there is no {backup}; nothing was migrated from this file")
|
||||
return 1
|
||||
|
||||
with open(backup, "r", encoding="utf-8") as handle:
|
||||
original = handle.read()
|
||||
|
||||
found = ifupdown.wireless(ifupdown.parse(original), is_wireless)
|
||||
device = found[0]["name"] if found else None
|
||||
|
||||
ssid, _ = ifupdown.credentials(found[0]) if found else (None, None)
|
||||
|
||||
return 0 if restore(device, ssid) else 1
|
||||
|
||||
|
||||
def adopt(dry_run=False):
|
||||
"""Move an installer-configured Wi-Fi from ifupdown to NetworkManager.
|
||||
|
||||
@@ -184,26 +295,17 @@ def adopt(dry_run=False):
|
||||
handed over, and if the room is not back on the network within
|
||||
ADOPT_TIMEOUT the file is restored and ifupdown brought back.
|
||||
"""
|
||||
try:
|
||||
with open(INTERFACES, "r", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
except OSError as error:
|
||||
say(f"cannot read {INTERFACES}: {error}")
|
||||
stanza, text = find_stanza()
|
||||
|
||||
if text is None:
|
||||
return 1
|
||||
|
||||
stanzas = ifupdown.wireless(ifupdown.parse(text), is_wireless)
|
||||
|
||||
if not stanzas:
|
||||
if stanza is None:
|
||||
say("no wireless interface is configured in /etc/network/interfaces")
|
||||
say("nothing to adopt; this machine's Wi-Fi is already "
|
||||
"NetworkManager's or it has none")
|
||||
return 0
|
||||
|
||||
if len(stanzas) > 1:
|
||||
say(f"{len(stanzas)} wireless stanzas found; refusing to guess")
|
||||
return 1
|
||||
|
||||
stanza = stanzas[0]
|
||||
device = stanza["name"]
|
||||
ssid, psk = ifupdown.credentials(stanza)
|
||||
|
||||
@@ -217,7 +319,23 @@ def adopt(dry_run=False):
|
||||
say(f"{device}: ifupdown holds a wireless network "
|
||||
f"({'with' if psk else 'without'} a saved password)")
|
||||
|
||||
# Refuse to start a hand-over that cannot be undone. ifdown releases
|
||||
# the interface and ifup is the whole of the rollback; without them
|
||||
# this would be a one-way door.
|
||||
missing = [tool for tool in ("ifdown", "ifup")
|
||||
if shutil.which(tool) is None]
|
||||
|
||||
if missing and not dry_run:
|
||||
say(f"{' and '.join(missing)} not installed, so this could not be "
|
||||
"undone")
|
||||
say("leaving the network exactly as it is")
|
||||
return 1
|
||||
|
||||
if dry_run:
|
||||
if missing:
|
||||
say(f"NOTE: {' and '.join(missing)} are missing here, so a real "
|
||||
"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("would hand the interface over, and put it back if that failed")
|
||||
@@ -238,48 +356,81 @@ def adopt(dry_run=False):
|
||||
shutil.copy2(INTERFACES, backup)
|
||||
say(f"kept the original at {backup}")
|
||||
|
||||
with open(INTERFACES, "w", encoding="utf-8") as handle:
|
||||
handle.write(ifupdown.comment_out(text, stanza))
|
||||
# From here the machine is mid-migration, so everything is inside a
|
||||
# guard. Whatever goes wrong — a command that is not installed, a
|
||||
# timeout, an interrupt — the file goes back and ifupdown gets the
|
||||
# interface again. Nothing below may raise past this.
|
||||
try:
|
||||
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")
|
||||
|
||||
# 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.
|
||||
nmcli("general", "reload", "conf", check=False)
|
||||
|
||||
run("ifdown", device)
|
||||
nmcli("device", "set", device, "managed", "yes", check=False)
|
||||
nmcli("connection", "up", ssid, check=False)
|
||||
|
||||
deadline = time.monotonic() + ADOPT_TIMEOUT
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if carrying(ssid, device) and online():
|
||||
say(f"{device} is NetworkManager's now, and the room is "
|
||||
"online")
|
||||
say("the panel will show the connection at the next login")
|
||||
return 0
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
say("NetworkManager did not get the network back")
|
||||
except BaseException as error: # noqa: BLE001 - see the comment above
|
||||
say(f"the migration failed part way through: {error}")
|
||||
|
||||
say("rolling back")
|
||||
restore(device, ssid)
|
||||
|
||||
# The migration did not happen, whatever the rollback managed.
|
||||
return 1
|
||||
|
||||
|
||||
def restore(device, ssid=None):
|
||||
"""Put the interface back under ifupdown.
|
||||
|
||||
Returns True when the room is on the network again. The callers
|
||||
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}")
|
||||
|
||||
# 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.
|
||||
nmcli("general", "reload", "conf", check=False)
|
||||
|
||||
subprocess.run(["ifdown", device], capture_output=True, check=False)
|
||||
nmcli("device", "set", device, "managed", "yes", check=False)
|
||||
nmcli("connection", "up", ssid, check=False)
|
||||
if ssid:
|
||||
nmcli("connection", "down", ssid, check=False)
|
||||
|
||||
deadline = time.monotonic() + ADOPT_TIMEOUT
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if online():
|
||||
say(f"{device} is NetworkManager's now, and the room is online")
|
||||
say("the panel will show the connection at the next login")
|
||||
return 0
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
# It did not come back. Put the machine exactly as it was.
|
||||
say("NetworkManager did not get the network back; rolling back")
|
||||
shutil.copy2(backup, INTERFACES)
|
||||
nmcli("general", "reload", "conf", check=False)
|
||||
nmcli("device", "set", device, "managed", "no", check=False)
|
||||
subprocess.run(["ifup", device], capture_output=True, check=False)
|
||||
if device:
|
||||
nmcli("device", "set", device, "managed", "no", check=False)
|
||||
run("ifup", device)
|
||||
say(f"ifupdown has {device} again")
|
||||
|
||||
for _ in range(10):
|
||||
if online():
|
||||
say("ifupdown has the network back; nothing was lost")
|
||||
return 1
|
||||
say("the room is on the network")
|
||||
return True
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
say("WARNING: the machine may be offline. ifupdown was restored; try "
|
||||
f"`ifup {device}` or a reboot")
|
||||
return 1
|
||||
say("WARNING: the machine may be offline. The original "
|
||||
f"{INTERFACES} is restored; try `ifup {device}` or a reboot")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
@@ -298,6 +449,10 @@ def main():
|
||||
"ifupdown into NetworkManager")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="with --adopt, say what would change")
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="say who owns the Wi-Fi; changes nothing")
|
||||
parser.add_argument("--rollback", action="store_true",
|
||||
help="give the Wi-Fi back to ifupdown")
|
||||
arguments = parser.parse_args()
|
||||
|
||||
if arguments.list:
|
||||
@@ -307,6 +462,12 @@ def main():
|
||||
say("run this as root; it creates a connection the machine owns")
|
||||
return 1
|
||||
|
||||
if arguments.check:
|
||||
return check()
|
||||
|
||||
if arguments.rollback:
|
||||
return rollback()
|
||||
|
||||
if arguments.adopt:
|
||||
return adopt(dry_run=arguments.dry_run)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user