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]>
502 lines
16 KiB
Python
Executable File
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())
|