#!/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())