Files
Classroom_Desktop/scripts/classroom-wifi
T
ekklesiaandClaude Opus 5 35e3bdcbbd 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]>
2026-09-21 21:11:19 -05:00

497 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Put the church's Wi-Fi on this machine, so the room can use it and
nobody in the room can read it.
Run by an administrator, as root, over SSH or from the maintenance
terminal:
classroom-wifi "Church Wi-Fi" # prompts for the password
classroom-wifi --list
classroom-wifi --forget "Old Network"
classroom-wifi --adopt --dry-run # what would be migrated
classroom-wifi --adopt # migrate it, with rollback
ADOPTING A WI-FI THE INSTALLER SET UP.
A machine whose Wi-Fi was given to the Debian installer has it in
/etc/network/interfaces, where ifupdown owns it. The internet works and
NetworkManager reports the device UNMANAGED, so the room's applet says
there is no connection and cannot scan. --adopt moves that network into a
NetworkManager system connection and takes the stanza out of ifupdown's
hands, which is the whole fix for both symptoms.
The password never leaves the machine and is never printed: this runs as
root, reads it out of a root-only file and writes it into another one.
WHY THIS EXISTS RATHER THAN LETTING A TEACHER ADD ONE.
A wireless network added from the desktop is created as a USER connection:
NetworkManager writes connection.permissions naming whoever added it.
Debian then allows that user to read its secrets with no authentication at
all — org.freedesktop.NetworkManager.settings.modify.own is "yes" — so a
password typed in by one teacher is readable by the next person to sit
down, through the applet's own "Show Network's QR Code".
This creates a SYSTEM connection instead: no connection.permissions, so it
belongs to the machine rather than to a person, its secrets are stored in
a root-only file, and reading them needs an administrator. Every account
on the machine can still USE it, and NetworkManager reconnects to it at
boot on its own without anybody logging in.
That is the whole reason this is an administrator's command. The classroom
account is refused the NetworkManager actions that return secrets — see
org.ekklesia.classroom.network.rules — and that refusal would be pointless
if the room could create a connection it was allowed to read.
"""
import argparse
import getpass
import os
import shutil
import subprocess
import sys
import time
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
import classroom_network as ifupdown # noqa: E402
CONNECTIONS = "/etc/NetworkManager/system-connections"
INTERFACES = "/etc/network/interfaces"
# How long NetworkManager gets to bring the network back up after the
# hand-over before this decides it has failed and puts ifupdown back.
ADOPT_TIMEOUT = 45
def say(message):
print(f"classroom-wifi: {message}")
def nmcli(*args, check=True):
result = subprocess.run(
["nmcli"] + list(args), capture_output=True, text=True, timeout=60,
check=False,
)
if check and result.returncode != 0:
say(result.stderr.strip() or "nmcli failed")
raise SystemExit(1)
return result
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]}")
return 0
def forget(name):
nmcli("connection", "delete", name)
say(f"removed {name}")
return 0
def add(ssid, password, name=None):
name = name or ssid
# Deliberately no connection.permissions. That single omission is the
# difference between a connection the machine owns and one a teacher
# owns and may therefore read.
arguments = [
"connection", "add",
"type", "wifi",
"con-name", name,
"ifname", "*",
"ssid", ssid,
"connection.autoconnect", "yes",
# 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",
]
if password:
arguments += [
"wifi-sec.key-mgmt", "wpa-psk",
"wifi-sec.psk", password,
]
nmcli(*arguments)
path = os.path.join(CONNECTIONS, f"{name}.nmconnection")
if 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.
os.chmod(path, 0o600)
os.chown(path, 0, 0)
mode = oct(os.stat(path).st_mode & 0o777)
say(f"{path} is {mode} root:root")
say(f"added {name} as a system connection")
say("the room can use it; the classroom account cannot read it")
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")
def connectivity():
result = nmcli("networking", "connectivity", check=False)
return (result.stdout or "").strip()
def online():
"""Whether the machine can actually reach anything."""
if connectivity() in ("full", "limited"):
return True
return subprocess.run(
["curl", "-fsS", "--max-time", "8", "-o", "/dev/null",
"https://updates.riverofglorymn.org/stable/manifest.json"],
capture_output=True, check=False,
).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.
Ordered so that the link is given up as late as possible and put back
the moment anything goes wrong. The connection is created first and
sits unused; the file is changed next; only then is the interface
handed over, and if the room is not back on the network within
ADOPT_TIMEOUT the file is restored and ifupdown brought back.
"""
stanza, text = find_stanza()
if text is None:
return 1
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
device = stanza["name"]
ssid, psk = ifupdown.credentials(stanza)
if not ssid:
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
# 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)")
# 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")
return 0
existing = nmcli("-t", "-f", "NAME", "connection", "show", check=False)
already = ssid in (existing.stdout or "").splitlines()
if already:
say(f"NetworkManager already has a connection named {ssid!r}")
else:
if add(ssid, psk) != 0:
return 1
backup = f"{INTERFACES}.before-ekklesia-classroom"
if not os.path.exists(backup):
shutil.copy2(INTERFACES, backup)
say(f"kept the original at {backup}")
# 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")
# 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}")
nmcli("general", "reload", "conf", check=False)
if ssid:
nmcli("connection", "down", ssid, 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("the room is on the network")
return True
time.sleep(3)
say("WARNING: the machine may be offline. The original "
f"{INTERFACES} is restored; try `ifup {device}` or a reboot")
return False
def main():
parser = argparse.ArgumentParser(
description="Provision the church Wi-Fi as a system connection.")
parser.add_argument("ssid", nargs="?", help="the network name")
parser.add_argument("--name", help="connection name, if not the SSID")
parser.add_argument("--list", action="store_true",
help="list the wireless connections on this machine")
parser.add_argument("--forget", metavar="NAME",
help="remove a saved connection")
parser.add_argument("--open", action="store_true",
help="the network has no password")
parser.add_argument("--adopt", action="store_true",
help="move an installer-configured Wi-Fi from "
"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:
return listing()
if os.geteuid() != 0:
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)
if arguments.forget:
return forget(arguments.forget)
if not arguments.ssid:
parser.print_help()
return 2
password = ""
if not arguments.open:
# Never echoed, and never read from the command line, where it
# would sit in a shell history and in ps.
password = getpass.getpass("Wi-Fi password (not shown): ")
if not password:
say("no password given")
return 1
return add(arguments.ssid, password, arguments.name)
if __name__ == "__main__":
sys.exit(main())