diff --git a/docs/messaging.md b/docs/messaging.md new file mode 100644 index 0000000..f8643b1 --- /dev/null +++ b/docs/messaging.md @@ -0,0 +1,138 @@ +# Classroom messaging + +The rooms talk to the church over MQTT. `classroom-mqtt` is the link: +one small service per room that subscribes, validates what arrives, and +hands the work to the classroom commands that already exist. + +**Nothing in the room depends on it.** A broker that is down, a network +that is missing, a machine with no broker configured at all — each of +those is a service sitting quietly, not a classroom that fails. The +dock, the browser and the DVD player never ask whether it is connected, +and nothing waits for it at boot. + +## The room is the machine + +The room name is the hostname: `nursery`, `k2`, `older`. Lowercased, +domain stripped, and anything that is not a letter, digit, `-` or `_` +replaced, because it becomes a topic segment. `ROOM=` in device.conf +overrides it for a machine whose hostname cannot be changed. + +## Topics + +No product name appears in any topic. The broker is the church's and +these are just rooms. + +| Topic | Retained | Direction | Meaning | +| --- | --- | --- | --- | +| `classroom/notify/all` | no | to the rooms | Show this in every room | +| `classroom/notify/` | no | to the rooms | Show this in one room | +| `classroom/status/` | **yes** | from the room | Whether that room is up | + +Notifications are events, so they are never retained. Status is state, +so it is — published on connect, and set to `online: false` by the +broker's last will if the room drops off. + +### Notification payload + +```json +{ + "id": "unique-id", + "title": "Sermon Starting", + "message": "The sermon has started in the sanctuary.", + "urgency": "normal", + "expires_at": 1780000000 +} +``` + +Only `title` is required. `urgency` is `low`, `normal` or `critical` and +anything else becomes `normal`. `expires_at` is epoch seconds. `id` is +what deduplication works on; without one, a message is shown every time +it arrives. + +### Status payload + +```json +{"room": "nursery", "online": true, "version": "0.1.36", "since": 1780000000} +``` + +## What gets dropped + +Every one of these is ordinary traffic, logged and counted, never a +reason to fall over: + +- retained notifications (see below) +- payloads that are not JSON, or not a JSON object +- a missing, blank or non-text title +- a message or id that is not text +- an `expires_at` that is not a time, or is in the past +- an id already seen — the last 512 are remembered + +## Stale messages cannot come back + +This is the property that matters most, and it has two halves. + +**The subscription is clean.** The client connects with a clean session, +so a broker never hands over a backlog from while the room was off. +Yesterday's message is not news. + +**Retained notifications are refused.** If someone publishes a +notification with the retain flag, the broker will replay it to every +room that subscribes afterwards — at every reboot, forever. Those are +dropped on arrival. + +One honest limitation: MQTT only sets the retain flag on a message +*replayed to a new subscription*. A retained publish delivered live to +an already-connected room is indistinguishable from an ordinary one, and +is shown once. That is the correct outcome — it is a live message at +that moment — and the replay, which is the actual hazard, is blocked. +Publishers should still not retain notifications. + +## When the broker goes away + +The service stays up and paho retries with a backoff from 2 to 60 +seconds. The disconnection is logged once, not once per attempt, so a +broker down for a morning does not fill the journal. On reconnect the +subscriptions are re-established and the retained status is republished. + +Verified against a real broker: the daemon survives the broker being +killed, reconnects when it returns, and delivers the next message +normally. + +## Configuration + +In `/etc/ekklesia-classroom/device.conf`: + +``` +MQTT_ENABLED=false +MQTT_HOST= +MQTT_PORT=1883 +MQTT_USERNAME= +MQTT_PASSWORD= +``` + +With `MQTT_ENABLED=false`, or no host, the service logs one line and +exits. That is the shipped default: every existing classroom upgrades +into this release with messaging off until a broker exists. + +Because the broker password lives here, device.conf is now `0640 +root:classroom` rather than world readable. Everything that reads it — +the dock, the notification daemon, the DVD commands, the updater — runs +as root or as the classroom user. + +## Trying it + +``` +mosquitto_pub -h -t classroom/notify/nursery \ + -m '{"id":"t1","title":"Sermon Starting","message":"The sermon has started."}' + +mosquitto_sub -h -t 'classroom/status/#' -v +journalctl -u ekklesia-classroom-mqtt -f +``` + +## What this is for + +Parent Call and Sanctuary Live View are next, and both need a room to be +reachable. The transport is deliberately separate from what it carries: +`classroom_mqtt` holds room identity, the topic contract and the rules +for an acceptable message, with no broker library in it at all, so those +decisions can be tested without one running. diff --git a/docs/testing.md b/docs/testing.md index f01eec8..b86a058 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -129,6 +129,21 @@ session instead. It never starts mako. - [ ] Running it as an unprivileged admin account explains it needs sudo - [ ] `tests/test-classroom-notify-test.sh` passes (also run by the build) +## Messaging + +- [ ] With `MQTT_ENABLED=false`, `journalctl -u ekklesia-classroom-mqtt` + shows one line and the service is inactive — and the room is + completely normal +- [ ] With a broker configured, the service connects and + `mosquitto_sub -t 'classroom/status/#' -v` shows the room online +- [ ] A published notification appears on the classroom screen in the + classroom styling +- [ ] `urgency: critical` renders in the critical style +- [ ] Killing the broker leaves the room entirely usable +- [ ] The service reconnects on its own when the broker returns +- [ ] Rebooting with the broker **down** boots normally to the dock +- [ ] A retained notification left on the broker does not appear at boot + ## DVD With `DVD_ENABLED=false`: diff --git a/lib/classroom_mqtt.py b/lib/classroom_mqtt.py new file mode 100644 index 0000000..4c0de8b --- /dev/null +++ b/lib/classroom_mqtt.py @@ -0,0 +1,177 @@ +"""The classroom's side of the church message broker. + +Room identity, the topic contract, and the rules for what counts as an +acceptable message. Deliberately free of any broker library, so the +decisions can be tested without one running. + +Topics carry no product name. The broker belongs to the church and +these are just rooms: + + classroom/notify/all every room + classroom/notify/ one room + classroom/status/ retained; whether that room is up + +Notifications are events, never retained. Status is state, and is. +""" + +import collections +import json +import re +import socket +import time + +NOTIFY_ALL = "classroom/notify/all" +NOTIFY_ROOM = "classroom/notify/{room}" +STATUS_ROOM = "classroom/status/{room}" + +URGENCIES = ("low", "normal", "critical") +DEFAULT_URGENCY = "normal" + +DEFAULT_PORT = 1883 + +# Room names become topic segments, so they have to be plain. +UNSAFE = re.compile(r"[^a-z0-9_-]+") + + +def room_name(config=None, hostname=None): + """Which room this machine is. + + The hostname, because that is already how these machines are named — + nursery, k2, older. ROOM in device.conf overrides it for the odd + machine whose hostname cannot be changed. + """ + config = config or {} + + room = (config.get("ROOM") or "").strip() + + if not room: + room = socket.gethostname() if hostname is None else hostname + + room = (room or "").split(".")[0].strip().lower() + room = UNSAFE.sub("-", room).strip("-") + + return room or "unknown" + + +def notify_topics(room): + """Everything a room listens to for notifications.""" + return (NOTIFY_ALL, NOTIFY_ROOM.format(room=room)) + + +def status_topic(room): + return STATUS_ROOM.format(room=room) + + +def broker(config): + """Broker settings, or None when this classroom has no broker yet.""" + config = config or {} + + if (config.get("MQTT_ENABLED") or "").strip().lower() not in ( + "1", + "true", + "yes", + "on", + "enabled", + ): + return None + + host = (config.get("MQTT_HOST") or "").strip() + + if not host: + return None + + try: + port = int((config.get("MQTT_PORT") or DEFAULT_PORT)) + except (TypeError, ValueError): + port = DEFAULT_PORT + + return { + "host": host, + "port": port, + "username": (config.get("MQTT_USERNAME") or "").strip() or None, + "password": config.get("MQTT_PASSWORD") or None, + } + + +class Notifications: + """Decides which incoming payloads become notifications. + + Every rejection is ordinary traffic rather than an error: a + malformed, stale or repeated message is something to drop and count, + never something to fall over on. + """ + + def __init__(self, remember=512): + self.remember = remember + self.seen = collections.OrderedDict() + + def accept(self, payload, retained=False, now=None): + """Returns (notification, reason). notification is None if dropped.""" + now = time.time() if now is None else now + + # A retained notification is a stale one by definition: it would + # arrive again at every reconnect and every reboot. + if retained: + return None, "retained" + + if isinstance(payload, (bytes, bytearray)): + try: + payload = payload.decode("utf-8") + except UnicodeDecodeError: + return None, "not utf-8" + + try: + data = json.loads(payload) + except (TypeError, ValueError): + return None, "not json" + + if not isinstance(data, dict): + return None, "not an object" + + title = data.get("title") + + if not isinstance(title, str) or not title.strip(): + return None, "no title" + + message = data.get("message", "") + + if not isinstance(message, str): + return None, "message is not text" + + expires = data.get("expires_at") + + if expires is not None: + if isinstance(expires, bool) or not isinstance(expires, (int, float)): + return None, "expires_at is not a time" + + if expires <= now: + return None, "expired" + + identifier = data.get("id") + + if identifier is not None: + if not isinstance(identifier, str): + return None, "id is not text" + + if identifier in self.seen: + return None, "duplicate" + + self.seen[identifier] = now + + while len(self.seen) > self.remember: + self.seen.popitem(last=False) + + urgency = data.get("urgency", DEFAULT_URGENCY) + + if urgency not in URGENCIES: + urgency = DEFAULT_URGENCY + + return ( + { + "id": identifier, + "title": title.strip(), + "message": message.strip(), + "urgency": urgency, + }, + "ok", + ) diff --git a/packaging/debian/control.template b/packaging/debian/control.template index 34f75ef..af1aaf2 100644 --- a/packaging/debian/control.template +++ b/packaging/debian/control.template @@ -4,7 +4,7 @@ Section: misc Priority: optional Architecture: amd64 Maintainer: Ekklesia -Depends: sway, swaybg, waybar, mako-notifier, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, cups, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, adwaita-icon-theme, libnotify-bin, curl, gnupg, ca-certificates +Depends: sway, swaybg, waybar, mako-notifier, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, cups, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, adwaita-icon-theme, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt Recommends: vlc Description: Ekklesia managed classroom environment A minimal Wayland classroom environment for Ekklesia-managed church computers. diff --git a/packaging/debian/postinst b/packaging/debian/postinst index b6d478e..04badda 100755 --- a/packaging/debian/postinst +++ b/packaging/debian/postinst @@ -22,7 +22,9 @@ chmod 0755 /usr/local/bin/classroom-window chmod 0755 /usr/local/bin/classroom-dvd chmod 0755 /usr/local/bin/classroom-dvd-session chmod 0755 /usr/local/bin/classroom-dvd-css-setup +chmod 0755 /usr/local/bin/classroom-notify chmod 0755 /usr/local/bin/classroom-notify-test +chmod 0755 /usr/local/bin/classroom-mqtt chmod 0755 /usr/local/bin/classroom-notifications chmod 0755 /usr/local/bin/classroom-scale chmod 0755 /usr/local/bin/classroom-shutdown @@ -48,9 +50,15 @@ DVD_ENABLED=false # Size of the dock, clock and notifications, 0.8 to 1.5. # Larger rooms and larger TVs want a larger number. UI_SCALE=1.0 -CONFIG - chmod 0644 /etc/ekklesia-classroom/device.conf +# Church message broker. The room is this machine's hostname unless ROOM +# is set. Leave MQTT_ENABLED=false until a broker exists. +MQTT_ENABLED=false +MQTT_HOST= +MQTT_PORT=1883 +MQTT_USERNAME= +MQTT_PASSWORD= +CONFIG fi # Make the settings discoverable on classrooms installed before they @@ -73,6 +81,24 @@ UI_SCALE=1.0 CONFIG fi +if ! grep -q '^MQTT_ENABLED=' /etc/ekklesia-classroom/device.conf; then + cat >> /etc/ekklesia-classroom/device.conf <<'CONFIG' + +# Church message broker. The room is this machine's hostname unless ROOM +# is set. Leave MQTT_ENABLED=false until a broker exists. +MQTT_ENABLED=false +MQTT_HOST= +MQTT_PORT=1883 +MQTT_USERNAME= +MQTT_PASSWORD= +CONFIG +fi + +# The broker password lives here, so the file stops being world readable. +# Everything that reads it runs as root or as the classroom user. +chown root:classroom /etc/ekklesia-classroom/device.conf 2>/dev/null || true +chmod 0640 /etc/ekklesia-classroom/device.conf + systemctl daemon-reload || true systemctl enable ekklesia-classroom-update.timer || true @@ -94,6 +120,11 @@ systemctl enable ekklesia-classroom-provision.service || true # Gives Chrome a chance to close properly on reboot, so its profile is # not marked as crashed. Best effort: it must never hold a shutdown up. systemctl enable ekklesia-classroom-shutdown.service || true + +# The room's link to the church broker. Enabled always; it does nothing +# at all until a broker is configured in device.conf. +systemctl enable ekklesia-classroom-mqtt.service || true +systemctl restart ekklesia-classroom-mqtt.service || true systemctl start ekklesia-classroom-shutdown.service || true exit 0 diff --git a/scripts/classroom-mqtt b/scripts/classroom-mqtt new file mode 100755 index 0000000..0df7b69 --- /dev/null +++ b/scripts/classroom-mqtt @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 + +"""The classroom's link to the church broker. + +Runs as the classroom user so it can put messages on the room's screen +through classroom-notify, and holds no opinion about anything else: it +subscribes, validates, and hands work to the classroom commands that +already exist. + +Nothing here is allowed to matter to the room. A broker that is down, a +network that is missing, a machine with no broker configured at all — +each of those is a service that sits quietly, not a classroom that +fails. The dock, the browser and the DVD player never ask this whether +it is connected. +""" + +import json +import os +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_mqtt as transport # noqa: E402 +from classroom_config import read_device_config # noqa: E402 + +NOTIFY = "/usr/local/bin/classroom-notify" + +# Long enough that a broker down for the morning does not fill the +# journal, short enough that a blip is invisible. +RECONNECT_MIN_SECONDS = 2 +RECONNECT_MAX_SECONDS = 60 + +VERSION_FILE = "/usr/local/lib/ekklesia-classroom/VERSION" + + +def log(message): + print(f"[classroom-mqtt] {message}", flush=True) + + +def version(): + try: + with open(VERSION_FILE, encoding="utf-8") as handle: + return handle.read().strip() + except OSError: + return "unknown" + + +class Classroom: + def __init__(self, room, settings): + self.room = room + self.settings = settings + self.notifications = transport.Notifications() + self.connected = False + + # -- delivery --------------------------------------------------- + + def show(self, notification): + try: + subprocess.Popen( + [ + NOTIFY, + f"--urgency={notification['urgency']}", + "--", + notification["title"], + notification["message"], + ], + start_new_session=True, + ) + except OSError as error: + log(f"could not show a notification: {error}") + + # -- broker callbacks ------------------------------------------- + + def on_connect(self, client, userdata, flags, reason_code, properties=None): + if reason_code != 0: + log(f"broker refused the connection: {reason_code}") + return + + self.connected = True + + topics = transport.notify_topics(self.room) + + for topic in topics: + client.subscribe(topic, qos=1) + + log(f"connected as {self.room}, listening on {', '.join(topics)}") + + # Retained, because this is state rather than an event: whoever + # asks later should be told the room is up without waiting for + # it to say so again. + client.publish( + transport.status_topic(self.room), + json.dumps( + { + "room": self.room, + "online": True, + "version": version(), + "since": int(time.time()), + } + ), + qos=1, + retain=True, + ) + + def on_disconnect(self, client, userdata, flags, reason_code=None, properties=None): + # Only worth a line the first time: paho retries on its own, and + # a broker that is down for an hour should not write an hour of + # log lines. + if self.connected: + self.connected = False + log("lost the broker; retrying quietly until it returns") + + def on_message(self, client, userdata, message): + try: + self.dispatch(message) + except Exception as error: # noqa: BLE001 + # A bad message is never worth taking the service down for. + log(f"dropped a message on {message.topic}: {error}") + + def dispatch(self, message): + topic = message.topic + + if topic in transport.notify_topics(self.room): + notification, reason = self.notifications.accept( + message.payload, + retained=bool(message.retain), + ) + + if notification is None: + log(f"ignored a notification on {topic}: {reason}") + return + + self.show(notification) + return + + log(f"nothing listens to {topic} yet") + + +def main(): + config = read_device_config() + room = transport.room_name(config) + settings = transport.broker(config) + + if settings is None: + log("no broker configured for this classroom; nothing to do") + return 0 + + try: + import paho.mqtt.client as mqtt + except ImportError: + log("python3-paho-mqtt is not installed; nothing to do") + return 0 + + classroom = Classroom(room, settings) + + # A stable client id keeps one room from appearing twice, and a clean + # session means a broker never hands over a backlog of notifications + # from while the room was off. Yesterday's message is not news. + client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=f"classroom-{room}", + protocol=mqtt.MQTTv311, + clean_session=True, + ) + + if settings["username"]: + client.username_pw_set(settings["username"], settings["password"]) + + client.will_set( + transport.status_topic(room), + json.dumps({"room": room, "online": False}), + qos=1, + retain=True, + ) + + client.on_connect = classroom.on_connect + client.on_disconnect = classroom.on_disconnect + client.on_message = classroom.on_message + + client.reconnect_delay_set( + min_delay=RECONNECT_MIN_SECONDS, + max_delay=RECONNECT_MAX_SECONDS, + ) + + log(f"room {room}, broker {settings['host']}:{settings['port']}") + + # connect_async plus loop_forever means a broker that is not there at + # boot is simply a broker that is not there yet. + client.connect_async(settings["host"], settings["port"], keepalive=45) + + try: + client.loop_forever(retry_first_connection=True) + except KeyboardInterrupt: + pass + finally: + client.disconnect() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/classroom-notify b/scripts/classroom-notify new file mode 100755 index 0000000..029b2df --- /dev/null +++ b/scripts/classroom-notify @@ -0,0 +1,214 @@ +#!/bin/bash +set -euo pipefail + +# Puts a notification on the classroom screen, from anywhere. +# +# classroom-notify "Title" "Body text" +# classroom-notify --critical "Parent Call" "Room 3 needs a parent." +# classroom-notify --urgency=low "DVD" "Disc ejected." +# sudo classroom-notify --dry-run +# +# Used by classroom-notify-test to send a sample, and by classroom-mqtt +# to put messages from the church broker on the screen. +# +# The classroom desktop runs as the classroom user, and mako is attached +# to that user's D-Bus session. An administrator over SSH sits in a +# different session where nothing owns org.freedesktop.Notifications, so +# a plain notify-send there asks D-Bus to start a second mako, which has +# no display to open and times out with NoReply. Find the running desktop +# session and deliver into it instead. + +CLASSROOM_USER=classroom + +urgency=normal +dry_run=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --critical) + urgency=critical + shift + ;; + --low) + urgency=low + shift + ;; + --urgency=*) + urgency="${1#--urgency=}" + case "$urgency" in + low|normal|critical) ;; + *) urgency=normal ;; + esac + shift + ;; + --dry-run) + dry_run=true + shift + ;; + --) + shift + break + ;; + -*) + echo "classroom-notify: unknown option $1" >&2 + echo "usage: classroom-notify-test [--low|--critical] [--dry-run] [title] [body]" >&2 + exit 2 + ;; + *) + break + ;; + esac +done + +title="${1:-DVD Ready}" +body="${2:-A disc is ready to play.}" + +fail() { + echo "classroom-notify: $1" >&2 + exit 1 +} + +# Another process's environment, or nothing. Reading this needs ptrace +# access, which root has on the endpoint but not, for instance, inside a +# container with CAP_SYS_PTRACE dropped — so every caller has to cope +# with getting nothing back. +read_environ() { + { tr '\0' '\n' < "/proc/$1/environ"; } 2>/dev/null || true +} + +environ_value() { + printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1 +} + +# sway holds its own wayland-N.lock open, so the display name can be read +# back off the process instead of being guessed at. +display_from_lock() { + local link target + + for link in "/proc/$1/fd"/*; do + target="$(readlink "$link" 2>/dev/null || true)" + + case "$target" in + */wayland-*.lock) + target="${target%.lock}" + echo "${target##*/}" + return 0 + ;; + esac + done + + return 0 +} + +classroom_uid="$(id -u "$CLASSROOM_USER" 2>/dev/null || true)" + +[[ -n "$classroom_uid" ]] || + fail "there is no $CLASSROOM_USER user on this machine." + +current_uid="$(id -u)" + +runner=() +session="this session" +source_note="inherited from this session" + +if [[ "$current_uid" == "$classroom_uid" && + -n "${WAYLAND_DISPLAY:-}" && + -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then + # Already inside the classroom desktop session. + sway_pid="n/a" + runtime_dir="${XDG_RUNTIME_DIR:-}" + bus="$DBUS_SESSION_BUS_ADDRESS" + wayland_display="$WAYLAND_DISPLAY" +else + session="the $CLASSROOM_USER desktop session" + + sway_pid="$(pgrep -u "$CLASSROOM_USER" -x sway | head -n 1 || true)" + + [[ -n "$sway_pid" ]] || + fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER." + + # Checked before anything that needs to look inside the session, so + # an administrator gets told what to do rather than a permission + # error from somewhere further down. + if [[ "$current_uid" != "$classroom_uid" && "$current_uid" != "0" ]]; then + fail "run this as root (sudo classroom-notify) or as the $CLASSROOM_USER user." + fi + + environment_block="$(read_environ "$sway_pid")" + + if [[ -n "$environment_block" ]]; then + source_note="read from sway (pid $sway_pid)" + else + source_note="sway's environment was unreadable; using session defaults" + fi + + runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)" + runtime_dir="${runtime_dir:-/run/user/$classroom_uid}" + + [[ -d "$runtime_dir" ]] || + fail "the classroom session runtime directory $runtime_dir does not exist." + + bus="$(environ_value "$environment_block" DBUS_SESSION_BUS_ADDRESS)" + bus="${bus:-unix:path=$runtime_dir/bus}" + + case "$bus" in + unix:path=*) + bus_socket="${bus#unix:path=}" + bus_socket="${bus_socket%%,*}" + + [[ -S "$bus_socket" ]] || + fail "the classroom session bus socket $bus_socket does not exist. Is the desktop session fully up?" + ;; + esac + + wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)" + + if [[ -z "$wayland_display" ]]; then + wayland_display="$(display_from_lock "$sway_pid")" + fi + + environment=( + "XDG_RUNTIME_DIR=$runtime_dir" + "DBUS_SESSION_BUS_ADDRESS=$bus" + ) + + # notify-send only needs the bus. The display is passed through for + # the sake of anything else in the session, and its absence is not + # worth refusing to send over. + if [[ -n "$wayland_display" ]]; then + environment+=("WAYLAND_DISPLAY=$wayland_display") + fi + + if [[ "$current_uid" == "$classroom_uid" ]]; then + runner=(env "${environment[@]}") + else + runner=(runuser -u "$CLASSROOM_USER" -- env "${environment[@]}") + fi +fi + +# Deliver to the mako that is already running. Sending into a session +# without one would have D-Bus start a second mako, which is exactly the +# failure this script exists to avoid. +pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 || + fail "mako is not running in the classroom session, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a mako" + +if [[ "$dry_run" == true ]]; then + echo "Would notify $session:" + echo " sway pid: $sway_pid" + echo " session environment: $source_note" + echo " XDG_RUNTIME_DIR: $runtime_dir" + echo " DBUS_SESSION_BUS_ADDRESS: $bus" + echo " WAYLAND_DISPLAY: ${wayland_display:-(not resolved)}" + echo " urgency: $urgency" + echo " title: $title" + echo " body: $body" + exit 0 +fi + +"${runner[@]}" notify-send \ + --app-name="Ekklesia Classroom" \ + --urgency="$urgency" \ + -- \ + "$title" "$body" + +echo "Sent $urgency notification to $session: $title" diff --git a/scripts/classroom-notify-test b/scripts/classroom-notify-test index a6cf414..349cec7 100755 --- a/scripts/classroom-notify-test +++ b/scripts/classroom-notify-test @@ -7,198 +7,14 @@ set -euo pipefail # classroom-notify-test # classroom-notify-test "Title" "Body text" # classroom-notify-test --critical "Parent Call" "Room 3 needs a parent." -# sudo classroom-notify-test --dry-run # -# The classroom desktop runs as the classroom user, and mako is attached -# to that user's D-Bus session. An administrator over SSH sits in a -# different session where nothing owns org.freedesktop.Notifications, so -# a plain notify-send there asks D-Bus to start a second mako, which has -# no display to open and times out with NoReply. Find the running desktop -# session and deliver into it instead. +# All of the work — finding the classroom desktop session and delivering +# into it — is classroom-notify's. This only supplies the sample. -CLASSROOM_USER=classroom +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -urgency=normal -dry_run=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --critical) - urgency=critical - shift - ;; - --low) - urgency=low - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --) - shift - break - ;; - -*) - echo "classroom-notify-test: unknown option $1" >&2 - echo "usage: classroom-notify-test [--low|--critical] [--dry-run] [title] [body]" >&2 - exit 2 - ;; - *) - break - ;; - esac -done - -title="${1:-DVD Ready}" -body="${2:-A disc is ready to play.}" - -fail() { - echo "classroom-notify-test: $1" >&2 - exit 1 -} - -# Another process's environment, or nothing. Reading this needs ptrace -# access, which root has on the endpoint but not, for instance, inside a -# container with CAP_SYS_PTRACE dropped — so every caller has to cope -# with getting nothing back. -read_environ() { - { tr '\0' '\n' < "/proc/$1/environ"; } 2>/dev/null || true -} - -environ_value() { - printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1 -} - -# sway holds its own wayland-N.lock open, so the display name can be read -# back off the process instead of being guessed at. -display_from_lock() { - local link target - - for link in "/proc/$1/fd"/*; do - target="$(readlink "$link" 2>/dev/null || true)" - - case "$target" in - */wayland-*.lock) - target="${target%.lock}" - echo "${target##*/}" - return 0 - ;; - esac - done - - return 0 -} - -classroom_uid="$(id -u "$CLASSROOM_USER" 2>/dev/null || true)" - -[[ -n "$classroom_uid" ]] || - fail "there is no $CLASSROOM_USER user on this machine." - -current_uid="$(id -u)" - -runner=() -session="this session" -source_note="inherited from this session" - -if [[ "$current_uid" == "$classroom_uid" && - -n "${WAYLAND_DISPLAY:-}" && - -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then - # Already inside the classroom desktop session. - sway_pid="n/a" - runtime_dir="${XDG_RUNTIME_DIR:-}" - bus="$DBUS_SESSION_BUS_ADDRESS" - wayland_display="$WAYLAND_DISPLAY" -else - session="the $CLASSROOM_USER desktop session" - - sway_pid="$(pgrep -u "$CLASSROOM_USER" -x sway | head -n 1 || true)" - - [[ -n "$sway_pid" ]] || - fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER." - - # Checked before anything that needs to look inside the session, so - # an administrator gets told what to do rather than a permission - # error from somewhere further down. - if [[ "$current_uid" != "$classroom_uid" && "$current_uid" != "0" ]]; then - fail "run this as root (sudo classroom-notify-test) or as the $CLASSROOM_USER user." - fi - - environment_block="$(read_environ "$sway_pid")" - - if [[ -n "$environment_block" ]]; then - source_note="read from sway (pid $sway_pid)" - else - source_note="sway's environment was unreadable; using session defaults" - fi - - runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)" - runtime_dir="${runtime_dir:-/run/user/$classroom_uid}" - - [[ -d "$runtime_dir" ]] || - fail "the classroom session runtime directory $runtime_dir does not exist." - - bus="$(environ_value "$environment_block" DBUS_SESSION_BUS_ADDRESS)" - bus="${bus:-unix:path=$runtime_dir/bus}" - - case "$bus" in - unix:path=*) - bus_socket="${bus#unix:path=}" - bus_socket="${bus_socket%%,*}" - - [[ -S "$bus_socket" ]] || - fail "the classroom session bus socket $bus_socket does not exist. Is the desktop session fully up?" - ;; - esac - - wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)" - - if [[ -z "$wayland_display" ]]; then - wayland_display="$(display_from_lock "$sway_pid")" - fi - - environment=( - "XDG_RUNTIME_DIR=$runtime_dir" - "DBUS_SESSION_BUS_ADDRESS=$bus" - ) - - # notify-send only needs the bus. The display is passed through for - # the sake of anything else in the session, and its absence is not - # worth refusing to send over. - if [[ -n "$wayland_display" ]]; then - environment+=("WAYLAND_DISPLAY=$wayland_display") - fi - - if [[ "$current_uid" == "$classroom_uid" ]]; then - runner=(env "${environment[@]}") - else - runner=(runuser -u "$CLASSROOM_USER" -- env "${environment[@]}") - fi +if [[ -x "$here/classroom-notify" ]]; then + exec "$here/classroom-notify" "$@" fi -# Deliver to the mako that is already running. Sending into a session -# without one would have D-Bus start a second mako, which is exactly the -# failure this script exists to avoid. -pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 || - fail "mako is not running in the classroom session, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a mako" - -if [[ "$dry_run" == true ]]; then - echo "Would notify $session:" - echo " sway pid: $sway_pid" - echo " session environment: $source_note" - echo " XDG_RUNTIME_DIR: $runtime_dir" - echo " DBUS_SESSION_BUS_ADDRESS: $bus" - echo " WAYLAND_DISPLAY: ${wayland_display:-(not resolved)}" - echo " urgency: $urgency" - echo " title: $title" - echo " body: $body" - exit 0 -fi - -"${runner[@]}" notify-send \ - --app-name="Ekklesia Classroom" \ - --urgency="$urgency" \ - -- \ - "$title" "$body" - -echo "Sent $urgency notification to $session: $title" +exec /usr/local/bin/classroom-notify "$@" diff --git a/systemd/mqtt/ekklesia-classroom-mqtt.service b/systemd/mqtt/ekklesia-classroom-mqtt.service new file mode 100644 index 0000000..ff3b27d --- /dev/null +++ b/systemd/mqtt/ekklesia-classroom-mqtt.service @@ -0,0 +1,21 @@ +[Unit] +Description=Ekklesia Classroom messaging +Documentation=file:///usr/local/bin/classroom-mqtt +After=network-online.target +Wants=network-online.target +# Nothing in the classroom session requires this, and nothing waits for +# it. A broker outage must never reach the dock, the browser or boot. +StartLimitIntervalSec=0 + +[Service] +Type=simple +# The room's own user, so notifications reach the room's own session and +# the broker password is read by nothing with more privilege than that. +User=classroom +Group=classroom +ExecStart=/usr/local/bin/classroom-mqtt +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/tests/test-classroom-mqtt.sh b/tests/test-classroom-mqtt.sh new file mode 100755 index 0000000..50c643a --- /dev/null +++ b/tests/test-classroom-mqtt.sh @@ -0,0 +1,141 @@ +#!/bin/bash +set -uo pipefail + +# The rules for what the classroom accepts from the broker: room +# identity, the topic contract, and which payloads become notifications. +# No broker involved — these are decisions, not plumbing. +# +# tests/test-classroom-mqtt.sh + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PYTHONPATH="$ROOT/lib" python3 - <<'PY' +import json +import sys + +import classroom_mqtt as m + +passed = failed = 0 + + +def check(label, condition, detail=""): + global passed, failed + if condition: + print(f" ok {label}") + passed += 1 + else: + print(f" FAIL {label}") + if detail: + print(f" {detail}") + failed += 1 + + +print("1. the room is the machine") +check("hostname becomes the room", m.room_name({}, "nursery") == "nursery") +check("domain is dropped", m.room_name({}, "k2.church.local") == "k2") +check("case is normalised", m.room_name({}, "Older") == "older") +check("ROOM overrides the hostname", + m.room_name({"ROOM": "older"}, "box-17") == "older") +check("unsafe characters cannot reach a topic", + m.room_name({}, "Room 3!!") == "room-3") +check("a nameless machine still has a room", + m.room_name({}, "") == "unknown") + +print() +print("2. the topic contract") +topics = m.notify_topics("nursery") +check("listens to every room", "classroom/notify/all" in topics) +check("listens to its own room", "classroom/notify/nursery" in topics) +check("status is per room", m.status_topic("nursery") == "classroom/status/nursery") +check("no product name in any topic", + not any("ekklesia" in t for t in topics + (m.status_topic("nursery"),))) + +print() +print("3. a classroom without a broker stays quiet") +check("disabled means no broker", m.broker({"MQTT_HOST": "h"}) is None) +check("enabled with no host means no broker", + m.broker({"MQTT_ENABLED": "true"}) is None) +check("no config at all means no broker", m.broker({}) is None) +settings = m.broker({"MQTT_ENABLED": "yes", "MQTT_HOST": "10.40.0.2"}) +check("host is taken", settings and settings["host"] == "10.40.0.2") +check("port defaults to 1883", settings and settings["port"] == 1883) +settings = m.broker({"MQTT_ENABLED": "1", "MQTT_HOST": "h", "MQTT_PORT": "not a port"}) +check("a nonsense port falls back rather than crashing", + settings and settings["port"] == 1883) + +print() +print("4. which messages become notifications") + +now = 1_780_000_000 + + +def payload(**fields): + body = {"title": "Sermon Starting", "message": "The sermon has started."} + body.update(fields) + return json.dumps(body) + + +n = m.Notifications() +note, reason = n.accept(payload(id="a1"), now=now) +check("a good message is accepted", reason == "ok", reason) +check("title survives", note and note["title"] == "Sermon Starting") +check("message survives", note and note["message"] == "The sermon has started.") +check("urgency defaults to normal", note and note["urgency"] == "normal") + +note, reason = n.accept(payload(id="a2", urgency="critical"), now=now) +check("critical is honoured", note and note["urgency"] == "critical") +note, reason = n.accept(payload(id="a3", urgency="screaming"), now=now) +check("a nonsense urgency falls back to normal", + note and note["urgency"] == "normal") + +print() +print("5. what gets dropped, without anything falling over") + +n = m.Notifications() +for label, args, expected in [ + ("a retained notification is stale by definition", + {"payload": payload(id="r"), "retained": True}, "retained"), + ("not JSON", {"payload": "{{{"}, "not json"), + ("a JSON array is not a notification", {"payload": "[1,2,3]"}, "not an object"), + ("no title", {"payload": json.dumps({"message": "x"})}, "no title"), + ("a blank title", {"payload": json.dumps({"title": " "})}, "no title"), + ("a title that is not text", + {"payload": json.dumps({"title": 7})}, "no title"), + ("a message that is not text", + {"payload": json.dumps({"title": "t", "message": 7})}, "message is not text"), + ("an expiry that is not a time", + {"payload": payload(expires_at="soon")}, "expires_at is not a time"), + ("an id that is not text", {"payload": payload(id=7)}, "id is not text"), + ("invalid utf-8", {"payload": b"\xff\xfe"}, "not utf-8"), +]: + note, reason = n.accept(now=now, **args) + check(label, note is None and reason == expected, f"got {reason}") + +print() +print("6. expiry and duplicates") + +n = m.Notifications() +note, reason = n.accept(payload(id="old", expires_at=now - 1), now=now) +check("an expired message is ignored", note is None and reason == "expired") +note, reason = n.accept(payload(id="future", expires_at=now + 60), now=now) +check("a live message is accepted", reason == "ok") +note, reason = n.accept(payload(id="future"), now=now) +check("the same id is not shown twice", note is None and reason == "duplicate") +note, reason = n.accept(payload(id="different"), now=now) +check("a different id still gets through", reason == "ok") +note, reason = n.accept(payload(), now=now) +check("a message with no id is not deduplicated", reason == "ok") +note, reason = n.accept(payload(), now=now) +check("and still is not", reason == "ok") + +n = m.Notifications(remember=4) +for index in range(10): + n.accept(payload(id=f"m{index}"), now=now) +check("the id history stays bounded", len(n.seen) == 4, f"{len(n.seen)} kept") +note, reason = n.accept(payload(id="m0"), now=now) +check("an id forgotten long ago can come round again", reason == "ok") + +print() +print(f"passed: {passed} failed: {failed}") +sys.exit(1 if failed else 0) +PY diff --git a/tests/test-classroom-notify-test.sh b/tests/test-classroom-notify.sh similarity index 93% rename from tests/test-classroom-notify-test.sh rename to tests/test-classroom-notify.sh index a46123d..561141c 100755 --- a/tests/test-classroom-notify-test.sh +++ b/tests/test-classroom-notify.sh @@ -11,7 +11,7 @@ set -uo pipefail # tests/test-classroom-notify-test.sh ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SCRIPT="$ROOT/scripts/classroom-notify-test" +SCRIPT="$ROOT/scripts/classroom-notify" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"; kill "${FAKE_SWAY_PID:-}" 2>/dev/null' EXIT @@ -192,7 +192,7 @@ echo "5. an unprivileged administrator gets told what to do" FAKE_CURRENT_UID=9999 out="$(run)" -check "explains it needs root" "run this as root (sudo classroom-notify-test)" "$out" +check "explains it needs root" "run this as root (sudo classroom-notify)" "$out" check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")" FAKE_CURRENT_UID=4000 @@ -285,6 +285,22 @@ check "dry run names this session" "Would notify this session" "$out" unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR +# --------------------------------- 12. classroom-notify-test delegates + +echo "12. the sample sender delegates to classroom-notify" + +export WAYLAND_DISPLAY=wayland-4 +export DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" +export XDG_RUNTIME_DIR="$RUNTIME" + +: > "$NOTIFY_LOG" +out="$("$ROOT/scripts/classroom-notify-test" 2>&1)" +check "sample still reports being sent" "Sent normal notification" "$out" +check "sample title unchanged" "DVD Ready" "$(cat "$NOTIFY_LOG")" +check "sample body unchanged" "A disc is ready to play." "$(cat "$NOTIFY_LOG")" + +unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR + # ----------------------------------------------------------- summary echo diff --git a/tools/build-release.sh b/tools/build-release.sh index 14968c0..b1cebb0 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -113,10 +113,18 @@ install -Dm755 \ "$ROOT/scripts/classroom-dvd-css-setup" \ "$STAGE/usr/local/bin/classroom-dvd-css-setup" +install -Dm755 \ + "$ROOT/scripts/classroom-notify" \ + "$STAGE/usr/local/bin/classroom-notify" + install -Dm755 \ "$ROOT/scripts/classroom-notify-test" \ "$STAGE/usr/local/bin/classroom-notify-test" +install -Dm755 \ + "$ROOT/scripts/classroom-mqtt" \ + "$STAGE/usr/local/bin/classroom-mqtt" + install -Dm755 \ "$ROOT/scripts/classroom-notifications" \ "$STAGE/usr/local/bin/classroom-notifications" @@ -146,6 +154,10 @@ install -Dm644 \ "$ROOT/lib/classroom_icons.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_icons.py" +install -Dm644 \ + "$ROOT/lib/classroom_mqtt.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_mqtt.py" + install -Dm644 \ "$ROOT/lib/classroom_style.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py" @@ -215,6 +227,10 @@ install -Dm644 \ "$ROOT/systemd/shutdown/ekklesia-classroom-shutdown.service" \ "$STAGE/etc/systemd/system/ekklesia-classroom-shutdown.service" +install -Dm644 \ + "$ROOT/systemd/mqtt/ekklesia-classroom-mqtt.service" \ + "$STAGE/etc/systemd/system/ekklesia-classroom-mqtt.service" + install -Dm644 \ "$ROOT/config/icons/dvd.svg" \ "$STAGE/etc/ekklesia-classroom/icons/dvd.svg" @@ -241,6 +257,10 @@ done printf '%s\n' "$VERSION" > "$ROOT/VERSION" +install -Dm644 \ + "$ROOT/VERSION" \ + "$STAGE/usr/local/lib/ekklesia-classroom/VERSION" + dpkg-deb \ --root-owner-group \ --build "$STAGE" "$OUTPUT"