The release validator caught it: in a container there is no /etc/network/interfaces at all, and a missing file was being treated as a read failure, so --check printed nothing and said nothing was wrong rather than saying there was nothing to do. A machine with no ifupdown configuration is the ordinary case for a fresh install, and the answer is simply that this command has no work. Co-Authored-By: Claude Opus 5 <[email protected]>
191 lines
7.4 KiB
Bash
Executable File
191 lines
7.4 KiB
Bash
Executable File
#!/bin/bash
|
|
# Moving an installer-configured Wi-Fi out of ifupdown's hands.
|
|
#
|
|
# The Bible study laptop had working internet and an applet that said
|
|
# there was no connection, because Debian's installer had written the
|
|
# Wi-Fi into /etc/network/interfaces and NetworkManager marks an
|
|
# interface named there UNMANAGED. This is the parsing half of the fix:
|
|
# find that stanza, get the network out of it, and comment out exactly
|
|
# it and nothing else.
|
|
set -uo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
root = Path(sys.argv[1])
|
|
sys.path.insert(0, str(root / "lib"))
|
|
|
|
from classroom_network import comment_out, credentials, parse, wireless
|
|
|
|
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
|
|
|
|
|
|
# The shape Debian's installer writes when it is given a wireless network
|
|
# during installation. Measured against the laptop: /etc/network/interfaces
|
|
# is 0600 root:root, wpa-ssid and wpa-psk are inline, and the
|
|
# wpa_supplicant it starts has no -c config file, which is what tells you
|
|
# ifupdown fed it these values rather than a wpa_supplicant.conf.
|
|
LAPTOP = """# This file describes the network interfaces available on your system
|
|
source /etc/network/interfaces.d/*
|
|
|
|
auto lo
|
|
iface lo inet loopback
|
|
|
|
allow-hotplug wlp2s0
|
|
iface wlp2s0 inet dhcp
|
|
\twpa-ssid Sanctuary
|
|
\twpa-psk a-real-secret
|
|
"""
|
|
|
|
# A classroom tower: static Ethernet an administrator put under
|
|
# ifupdown's control on purpose. Nothing here may touch it.
|
|
TOWER = """source /etc/network/interfaces.d/*
|
|
|
|
auto lo
|
|
iface lo inet loopback
|
|
|
|
auto enp1s0
|
|
iface enp1s0 inet static
|
|
\taddress 10.40.0.55/24
|
|
\tgateway 10.40.0.1
|
|
"""
|
|
|
|
|
|
def wireless_names(*names):
|
|
return lambda device: device in names
|
|
|
|
|
|
print("1. the laptop's own file")
|
|
stanzas = parse(LAPTOP)
|
|
check("every iface stanza is found",
|
|
[s["name"] for s in stanzas] == ["lo", "wlp2s0"],
|
|
str([s["name"] for s in stanzas]))
|
|
found = wireless(stanzas, wireless_names("wlp2s0"))
|
|
check("the wireless one is picked out", len(found) == 1)
|
|
check("and it is not the loopback", found and found[0]["name"] == "wlp2s0")
|
|
|
|
ssid, psk = credentials(found[0])
|
|
check("the network name is read", ssid == "Sanctuary", repr(ssid))
|
|
check("and the password", psk == "a-real-secret", repr(psk))
|
|
|
|
print("2. a tower is left completely alone")
|
|
tower = parse(TOWER)
|
|
check("its Ethernet is not mistaken for wireless",
|
|
wireless(tower, wireless_names("wlp2s0")) == [])
|
|
check("even though it has a static stanza ifupdown owns",
|
|
[s["name"] for s in tower] == ["lo", "enp1s0"])
|
|
# managed=false stays because of this machine, so nothing may change here.
|
|
check("commenting out is never reached for it",
|
|
not wireless(tower, lambda d: False))
|
|
|
|
print("3. only the wireless stanza is commented out")
|
|
after = comment_out(LAPTOP, found[0])
|
|
check("the iface line is commented",
|
|
"# ekklesia-classroom: iface wlp2s0" in after, after)
|
|
check("its options are commented with it",
|
|
"# ekklesia-classroom: \twpa-ssid" in after)
|
|
check("the allow-hotplug above it goes too, or ifupdown asks for an "
|
|
"interface with no stanza",
|
|
"# ekklesia-classroom: allow-hotplug wlp2s0" in after)
|
|
check("the loopback is untouched",
|
|
"\niface lo inet loopback\n" in after)
|
|
check("and so is the source line",
|
|
"\nsource /etc/network/interfaces.d/*\n" in after)
|
|
check("the password is still in the file, not deleted",
|
|
"a-real-secret" in after,
|
|
"the original must remain recoverable until rollback is impossible")
|
|
|
|
print("4. doing it twice changes nothing the second time")
|
|
twice = comment_out(after, parse(after)[0] if parse(after) else found[0])
|
|
check("already-commented lines are not commented again",
|
|
"# ekklesia-classroom: # ekklesia-classroom:" not in twice)
|
|
|
|
print("5. a stanza this must refuse to move")
|
|
# Pointing at a wpa_supplicant.conf means the credentials are not in this
|
|
# file, so there is nothing here to recreate and guessing would be wrong.
|
|
CONF = """allow-hotplug wlan0
|
|
iface wlan0 inet dhcp
|
|
\twpa-conf /etc/wpa_supplicant/wpa_supplicant.conf
|
|
"""
|
|
stanza = wireless(parse(CONF), wireless_names("wlan0"))[0]
|
|
check("a wpa-conf stanza yields no SSID, so the caller stops",
|
|
credentials(stanza) == (None, None), str(credentials(stanza)))
|
|
|
|
print("6. a renamed or absent device is still recognised")
|
|
# The machine is asked whether a device is wireless, but a wpa-ssid is
|
|
# evidence on its own — a device that has been renamed between kernels
|
|
# would otherwise be invisible.
|
|
check("a wpa-ssid alone is enough",
|
|
len(wireless(parse(LAPTOP), lambda d: False)) == 1)
|
|
|
|
print("7. the command itself")
|
|
wifi = (root / "scripts/classroom-wifi").read_text()
|
|
check("--adopt exists and has a dry run", "--adopt" in wifi and "--dry-run" in wifi)
|
|
check("--check reads without changing anything", "--check" in wifi)
|
|
check("--rollback is a command a person can run, not only an automatic path",
|
|
"--rollback" in wifi)
|
|
check("it keeps the original file before changing it",
|
|
"before-ekklesia-classroom" in wifi)
|
|
check("it verifies the room is really online, not just that NM says so",
|
|
"curl" in wifi and "connectivity" in wifi)
|
|
# A machine with a second link would answer "online" while the interface
|
|
# being migrated sat dead, and a migration that failed would be called a
|
|
# success.
|
|
check("and that the interface itself is carrying the connection",
|
|
"def carrying(" in wifi)
|
|
check("it never prints the password",
|
|
"psk}" not in wifi and "{psk" not in wifi)
|
|
check("the connection it creates is still a system one",
|
|
'"connection.permissions"' not in wifi)
|
|
|
|
print("8. it cannot leave a machine half migrated")
|
|
# Found by testing the rollback rather than by reading the code: ifdown
|
|
# was missing in the lab, subprocess raised, and the exception escaped
|
|
# AFTER the stanza had been commented out — no rollback, no network. Both
|
|
# of these are the fix.
|
|
check("every hand-over command tolerates not being installed",
|
|
"def run(" in wifi and "OSError" in wifi)
|
|
check("and the whole hand-over is guarded, so any failure restores it",
|
|
"except BaseException" in wifi and "restore(device, ssid)" in wifi)
|
|
check("it refuses to start when it could not undo itself",
|
|
"shutil.which" in wifi
|
|
and "leaving the network exactly as it is" in wifi)
|
|
|
|
print("9. a machine with no ifupdown configuration at all")
|
|
# A container, and any machine installed without ifupdown. Found by the
|
|
# release validator: --check said nothing at all, because a missing file
|
|
# was being treated as a read error.
|
|
check("a missing interfaces file is not an error, it is just nothing to do",
|
|
"not os.path.exists(INTERFACES)" in wifi
|
|
and 'return None, ""' in wifi)
|
|
|
|
print("10. the upgrade does not do any of this on its own")
|
|
# Moving a machine's only link during an unattended upgrade is fine until
|
|
# the once it is not, and the machine it would strand is a laptop in a
|
|
# locked room.
|
|
migrate = (root / "scripts/classroom-migrate").read_text()
|
|
code = "\n".join(line for line in migrate.splitlines()
|
|
if not line.lstrip().startswith("#"))
|
|
check("the migration never runs --adopt", "--adopt" not in code)
|
|
check("it only looks, and says what it found", "classroom-wifi --check" in code)
|
|
|
|
print()
|
|
print(f"{passed} passed, {failed} failed")
|
|
raise SystemExit(1 if failed else 0)
|
|
PY
|