Files
Classroom_Desktop/scripts/classroom-wifi
T
ekklesiaandClaude Opus 5 fe08363534 fix: a machine with no ifupdown configuration is not an error
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]>
2026-09-21 21:23:30 -05:00

502 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."""
if not os.path.exists(INTERFACES):
# A machine with no ifupdown configuration at all. Nothing is
# wrong with that and nothing here has anything to do.
return None, ""
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())