#!/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, # nmcli translates its output and its terse field separator rules # are the only thing here that is parsed. C is the one locale in # which what this reads back is what nmcli is documented to # print, on a machine in any country. env={**os.environ, "LC_ALL": "C"}, ) if check and result.returncode != 0: say(result.stderr.strip() or "nmcli failed") raise SystemExit(1) return result def terse(line): """One line of `nmcli -t` output, split into its fields. nmcli escapes a colon inside a value as `\\:` and a backslash as `\\\\`, so splitting on ":" is wrong for any network whose name contains one. Everything here that compares a connection name to a field goes through this, because getting it wrong does not produce an error — it produces a migration that decides it failed and rolls a working network back. """ fields = [] current = [] escaped = False for character in line: if escaped: current.append(character) escaped = False elif character == "\\": escaped = True elif character == ":": fields.append("".join(current)) current = [] else: current.append(character) fields.append("".join(current)) return fields def connections(): """Every saved connection, as (name, uuid, type).""" result = nmcli("-t", "-f", "NAME,UUID,TYPE", "connection", "show", check=False) found = [] for line in (result.stdout or "").splitlines(): fields = terse(line) if len(fields) >= 3: found.append((fields[0], fields[1], fields[2])) return found def listing(): for name, _uuid, kind in connections(): if "wireless" in kind: print(f" {name}") return 0 def forget(name): nmcli("connection", "delete", name) say(f"removed {name}") return 0 def add(ssid, password, name=None, hidden=False): 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", # WHETHER THE ACCESS POINT BROADCASTS ITS NAME. # # Always passed, in both directions, rather than only when it is # true. NetworkManager's default is "no", and a default that # happens to be right is not the same as an answer: this is the # setting the prayer-room laptop's migration left at the default # and could therefore never associate. Written down either way so # that the connection file says what was meant. "802-11-wireless.hidden", "yes" if hidden else "no", ] if password: arguments += [ "wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password, ] nmcli(*arguments) if hidden: say(f"{name} is a hidden network; NetworkManager will probe for it " "by name") path = connection_file(name) if path and 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 connection_file(name): """Where NetworkManager actually put a connection, or None. Asked rather than constructed. NetworkManager escapes characters in the filename it derives from a connection name, so f"{name}.nmconnection" is a guess that happens to be right for a one-word SSID and wrong for "ROGC Classroom" — which is the network this command exists to migrate, and the file whose permissions the whole arrangement rests on. """ result = nmcli("-t", "-f", "NAME,FILENAME", "connection", "show", check=False) for line in (result.stdout or "").splitlines(): fields = terse(line) if len(fields) >= 2 and fields[0] == name and fields[-1]: return fields[-1] return None 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(): fields = terse(line) if len(fields) == 2 and fields[0] == name and fields[1] == device: return True return False def device_state(device): """NetworkManager's word for what it is doing with this interface.""" result = nmcli("-t", "-f", "DEVICE,STATE", "device", "status", check=False) for line in (result.stdout or "").splitlines(): fields = terse(line) if len(fields) >= 2 and fields[0] == device: return fields[1] return "unknown" def unmanaged_by_config(device): """Configuration files that name this device as unmanaged. Separate from the ifupdown plugin, and the reason the prayer-room laptop still needed `nmcli device set wlp2s0 managed yes` by hand after its stanza was gone. A keyfile saying `unmanaged-devices` is not undone by commenting out an interfaces stanza, and a hand-over that does not notice it hands the interface to nobody. """ naming = [] candidates = ["/etc/NetworkManager/NetworkManager.conf"] for directory in ("/etc/NetworkManager/conf.d", "/usr/lib/NetworkManager/conf.d", "/run/NetworkManager/conf.d"): try: candidates += sorted( os.path.join(directory, entry) for entry in os.listdir(directory) if entry.endswith(".conf") ) except OSError: continue for path in candidates: try: with open(path, "r", encoding="utf-8", errors="replace") as handle: text = handle.read() except OSError: continue for line in text.splitlines(): stripped = line.strip() if not stripped.startswith("unmanaged-devices"): continue if device in stripped or "except:" in stripped: naming.append(path) break return naming 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"] found = ifupdown.profile(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 found and found["hidden"]: say(" Its access point does not broadcast its name, which " "NetworkManager") say(" has to be told; --adopt carries that across.") for path in unmanaged_by_config(device): say(f" {path} also names this device unmanaged; --adopt " "reports it") say(" rather than editing it.") if found is None: say(" Its credentials are not in that file, so it has to be " "moved by hand.") elif found["key_mgmt"] is None: say(" Its security is not a pre-shared key, so it has to be " "moved by hand.") else: say(" To move it, while watching, over SSH:") say(" classroom-wifi --adopt --dry-run") say(" classroom-wifi --adopt") 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"] found = ifupdown.profile(stanza) if found is None: 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 if found["key_mgmt"] is None: say(f"{device} uses a security this command does not recreate " f"({stanza['options'].get('wpa-key-mgmt')})") say("refusing to guess; a network moved wrong is a network that " "cannot come back") return 1 ssid = found["ssid"] psk = found["psk"] # 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" f"{', hidden' if found['hidden'] else ''})") # A keyfile naming this device unmanaged survives the stanza going # away, so the hand-over would end with the interface belonging to # nobody. Said before anything is touched, and not edited: a file an # administrator wrote is not this command's to rewrite. for path in unmanaged_by_config(device): say(f"WARNING: {path} names {device} unmanaged") say(" NetworkManager will not keep it after a restart " "until that is changed") # 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(" 802-11-wireless.hidden: " f"{'yes' if found['hidden'] else 'no'}") say(f" 802-11-wireless-security.key-mgmt: {found['key_mgmt']}") say(f"would run `ifdown {device}` while its stanza is still in " f"{INTERFACES}") say(f"would then comment that stanza out of {INTERFACES}") say("would hand the interface over, and put it back if that failed") return 0 already = any(name == ssid for name, _uuid, _kind in connections()) if already: # Reused as it stands. An existing connection of this name may be # a teacher's own, or one a previous attempt left behind with the # wrong hidden setting — which is exactly the state the # prayer-room laptop was in — so it is corrected rather than # trusted. Every setting the migration decides is written again. say(f"NetworkManager already has a connection named {ssid!r}") say("correcting it to match what ifupdown holds") nmcli("connection", "modify", ssid, "802-11-wireless.hidden", "yes" if found["hidden"] else "no", "connection.autoconnect", "yes", "802-11-wireless-security.psk-flags", "0", check=False) else: if add(ssid, psk, hidden=found["hidden"]) != 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: # RELEASE THE INTERFACE BEFORE TAKING ITS STANZA AWAY. # # ifdown reads /etc/network/interfaces to find out how to bring # an interface down. Run after the stanza is commented out it # reports "Ignoring unknown interface" and does nothing at all — # so ifupdown's own wpa_supplicant and DHCP client were still # running, still holding the radio, when NetworkManager was asked # to associate with it. Two supplicants on one interface is a # fight NetworkManager loses, and it is the second half of why # the prayer-room laptop rolled back. # # The order is therefore: down first, while the file still says # how; rewrite the file second. The window in which the machine # is offline is the same either way. result = run("ifdown", device) if result is not None and result.returncode != 0: # Not fatal. A machine whose interface is already down is a # perfectly good starting point, and ifdown says so with a # non-zero status. say(f"ifdown {device}: " f"{(result.stderr or result.stdout or '').strip()}") say(f"released {device} from ifupdown") 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 in {INTERFACES}") # NetworkManager only re-reads which interfaces ifupdown claims # when its configuration is reloaded. nmcli("general", "reload", "conf", check=False) nmcli("device", "set", device, "managed", "yes", check=False) # On the device, by name. `connection up ` alone lets # NetworkManager choose an interface, and a machine with two # radios could bring the network up on the wrong one — which # `carrying` would then correctly report as a failure, and the # migration would roll back a network that was working. started = nmcli("connection", "up", ssid, "ifname", device, check=False) if started.returncode != 0: say(f"nmcli: {(started.stderr or '').strip()}") 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(f"NetworkManager reports {device} " f"{device_state(device)}") say("the panel will show the connection at the next login") return 0 time.sleep(3) say("NetworkManager did not get the network back " f"({device} is {device_state(device)})") 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 put_interfaces_back(): """Undo this command's edit to /etc/network/interfaces. Un-commenting the marked lines where they are, rather than copying the backup over the whole file. The backup is written once, on the first attempt, and never refreshed — so a whole-file restore silently reverts anything an administrator changed about the WIRED network in between. The marker names exactly the lines this command commented out, and nothing else in the file is touched. The backup is still the fallback, for a file that has been replaced since rather than edited. """ backup = f"{INTERFACES}.before-ekklesia-classroom" try: with open(INTERFACES, "r", encoding="utf-8") as handle: current = handle.read() except OSError as error: say(f"cannot read {INTERFACES}: {error}") current = None if current is not None: restored, count = ifupdown.uncomment(current) if count: with open(INTERFACES, "w", encoding="utf-8") as handle: handle.write(restored) say(f"put {count} line{'' if count == 1 else 's'} back in " f"{INTERFACES}, and changed nothing else in it") return True if os.path.exists(backup): shutil.copy2(backup, INTERFACES) say(f"no marked lines to restore; copied {backup} back instead") return True say(f"nothing of this command's is in {INTERFACES} to undo") return False 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. """ put_interfaces_back() nmcli("general", "reload", "conf", check=False) if ssid: nmcli("connection", "down", ssid, check=False) if device: nmcli("device", "set", device, "managed", "no", check=False) result = run("ifup", device) if result is not None and result.returncode != 0: say(f"ifup {device}: " f"{(result.stderr or result.stdout or '').strip()}") say(f"ifupdown has {device} again") for _ in range(10): if online(): say("the room is on the network") return True time.sleep(3) # THE ADAPTER IS NEVER LEFT BELONGING TO NOBODY. # # Above, the device has deliberately been handed back to ifupdown and # NetworkManager told to leave it alone. That is right while ifupdown # can actually bring it up. When ifup has failed too, the same two # lines are a wireless adapter that neither manager owns: no # connection, and a panel that cannot even scan for one. That is the # state the prayer-room laptop was found in after 0.2.5, and it is # the state that made `nmcli device set wlp2s0 managed yes` a manual # recovery step. # # So the last resort is to give it to NetworkManager rather than to # leave it with neither. A room able to pick its network from the # panel is recoverable by somebody standing in front of it; an # unmanaged radio is not. if device: say("WARNING: ifupdown did not bring the network back either") say(f" handing {device} to NetworkManager so the adapter " "is not left unmanaged") nmcli("device", "set", device, "managed", "yes", check=False) if ssid: nmcli("connection", "up", ssid, "ifname", device, check=False) for _ in range(5): if online(): say("the room is on the network, through NetworkManager") say(f"NOTE: {INTERFACES} still describes {device}, so " "ifupdown will") say(" claim it again at the next boot. Decide which " "one owns it.") return True time.sleep(3) say(f" {device} is {device_state(device)}") 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("--hidden", action="store_true", help="the access point does not broadcast its name") 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, hidden=arguments.hidden) if __name__ == "__main__": sys.exit(main())