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