Files
Classroom_Desktop/scripts/classroom-wifi
T
ekklesiaandClaude Opus 5 a31fa75d55 fix: the bar keeps its order when an application opens
On the laptop the browser jumped behind the disc player the moment it was
opened. 0.2.4 caused that, and the cause is two lines of the stock applet:

    main.qml:153  hideActivatedLaunchers: tasks.iconsOnly || ...
    main.qml:155  launchInPlace: tasks.iconsOnly && sortingStrategy === 1

For an icons-only bar the first is always true — a launcher is hidden the
instant its application starts — and the second is what puts the running
window back where the launcher was. It only does that at sortingStrategy
1. 0.2.4 set it to 0 to stop teachers dragging the icons about, so the
launcher vanished and the window was appended after the others.

Reproduced in the lab with a real Chrome window before changing anything,
and the bar read off the screen: Wonder Ink, the player, then the
browser. With 1 it is Wonder Ink, the browser, the player, through
opening, a second window, another application opening beside it, and
every close.

The launcher identity was suspected and cleared: only one browser button
appears, because google-chrome.desktop already carries Chrome's own file
name and StartupWMClass for exactly this reason.

THE COST, AND IT IS REAL. Manual sorting is also the only mode in which
MouseHandler.qml accepts a reordering drop, so a teacher who knows to drag
can rearrange the bar again. There is no value that gives both and no
Kiosk key that separates them. classroom-desktop-reset already rebuilds
the bar from the package at every login, so a drag never outlives the
session. docs/testing.md states the trade rather than burying it, and the
configuration escape is written down there too as still open.

tools/lab/order-check is the test this needed and did not have. 0.2.4
passed a full set of configuration assertions while the bar did something
else, so this one opens real application windows and reads the panel
strip as pixels — there is no image library in the lab, so it captures
PPM and names each icon by colour. Six checks, all of them behaviour.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 19:34:15 -05:00

336 lines
11 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 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.
NetworkManager's own answer first, because that is the thing the room
was complaining about; a real fetch second, because "full" is a claim
and this is the moment to be sure of it.
"""
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 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.
"""
try:
with open(INTERFACES, "r", encoding="utf-8") as handle:
text = handle.read()
except OSError as error:
say(f"cannot read {INTERFACES}: {error}")
return 1
stanzas = ifupdown.wireless(ifupdown.parse(text), is_wireless)
if not stanzas:
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
if len(stanzas) > 1:
say(f"{len(stanzas)} wireless stanzas found; refusing to guess")
return 1
stanza = stanzas[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)")
if dry_run:
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}")
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)
subprocess.run(["ifdown", device], capture_output=True, check=False)
nmcli("device", "set", device, "managed", "yes", check=False)
nmcli("connection", "up", ssid, check=False)
deadline = time.monotonic() + ADOPT_TIMEOUT
while time.monotonic() < deadline:
if 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)
# It did not come back. Put the machine exactly as it was.
say("NetworkManager did not get the network back; rolling back")
shutil.copy2(backup, INTERFACES)
nmcli("general", "reload", "conf", check=False)
nmcli("device", "set", device, "managed", "no", check=False)
subprocess.run(["ifup", device], capture_output=True, check=False)
for _ in range(10):
if online():
say("ifupdown has the network back; nothing was lost")
return 1
time.sleep(3)
say("WARNING: the machine may be offline. ifupdown was restored; try "
f"`ifup {device}` or a reboot")
return 1
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")
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.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())