Aller au contenu

The multi-seat manager

pupitre-manager (module pupitre.managerd) is the daemon that turns a room of Dell Wyse 1010 zero clients into numbered seats. Each client broadcasts a DCP packet on UDP 52330 every few seconds. The manager listens, reads the client MAC, looks it up in the seat table, and makes sure pupitre-seat@<N>.service is running for that seat. It never answers the client itself: the DCP assignment and keepalive are sent by the seat's own session process, which serves exactly one MAC.

The manager, the seat sessions and the interactive tools all bind UDP 52330 with SO_REUSEADDR, so they all receive every broadcast and coexist on the port.

Units

  • pupitre-nft.service (oneshot) installs the nftables table ip pupitre that drops the kernel's own RSTs toward the device ports. pupitre-manager.service wants it.
  • pupitre-manager.service runs /usr/bin/pupitre-manager as root (it calls systemctl start), restarts on failure, and owns /run/pupitre, /var/lib/pupitre and /etc/pupitre through RuntimeDirectory, StateDirectory and ConfigurationDirectory.
  • pupitre-seat@<N>.service (template, one instance per seat) reads /run/pupitre/seat-<N>.env and runs the seat launcher. It has no PartOf=: a crash of the manager that Restart= recovers, systemctl restart pupitre-manager and a package upgrade leave every seat running. systemctl stop pupitre-manager stops every seat, and every client returns to its waiting screen: the manager's ExecStop=/usr/lib/pupitre/pupitre-stop-seats sends SIGTERM to the manager and waits for it to end, since a manager still alive would start the stopping seats again, then stops pupitre-seat@*.service synchronously. To pause the manager without stopping the seats, use kill -STOP on its main PID. The gaps that remain are listed in the install guide (known limits).

Files

Path Written by Content
/etc/pupitre/pupitre.conf you INI, see config/pupitre.conf for the commented defaults
/var/lib/pupitre/seats.json manager, web console, you the seats table, see below
/var/lib/pupitre/seats.json.lock both daemons flock target guarding every read-modify-write of the table
/run/pupitre/seat-<N>.env manager PUPITRE_SEAT, PUPITRE_MAC, PUPITRE_CLIENT_IP, PUPITRE_SERVER_IP, PUPITRE_IFACE
/run/pupitre/stations.json manager live state of every station seen since start
/run/pupitre/manager.alive manager empty file touched once per loop iteration (heartbeat)
/run/pupitre/hold/<mac> web console "leave this MAC alone for a while", see below

All writes are atomic (temporary file then rename), so a reader never sees a partial file. stations.json is rewritten whenever a station appears, changes seat, IP or name, or goes online or offline, and after every table reload; a station is offline after 30 s without a broadcast. Nothing else happens on offline: the seat unit keeps running and the session process handles the device's absence.

stations.json carries server_ip, interface, enroll, seats_max (0 for no cap), updated, and one row per station: mac, ip, seat, name, serial, last_seen, online, label (from the table, "" if absent) and parked (true when the table lists the MAC with no seat).

The seats table

{
  "00:00:5e:00:53:01": {"seat": 3, "label": "Row 2, window side", "transport": "mct"},
  "00:00:5e:00:53:09": {"seat": null, "label": "Poste 9, fond de salle", "transport": "mct"}
}

Keys are lower-case MACs with colons. seat is a positive integer or null: a null seat is a parked station, known and deliberately seatless, never auto enrolled. label is free text (64 characters at most) shown by the web console and never sent to the device. Seat numbers must be unique. The old form {"mac": 3} is still read and is rewritten in the new form at the first save. A key that is not a MAC, or a value that is not an entry, is dropped with a warning.

transport is how that seat is served. mct is the only one that does anything today, and a missing or unreadable value becomes mct, so a table written by an older version loads as what it was: a table of MCT seats. The field is recorded and nothing dispatches on it yet; it exists so that the day a seat is served over a standard protocol, the table already says which seat that is. See docs/sieges.md. Its shape is checked (a short lower case name, at most 16 characters of a-z0-9-) but not checked against a list of known transports, which would have to grow before the code that reads it exists.

The file is read again within a second of any change (the manager compares inode and mtime once per packet and once per idle second): you can edit it while the manager runs. On reload, a seat whose MAC vanished or changed is stopped (systemctl stop --no-block) and its env file removed; an online station that gained a seat is started at once. Every read-modify-write by the manager (enrolment) or the console runs under seats.json.lock, so neither loses the other's edit.

What happens on an announce

  1. The packet is parsed; anything that is not a 1344-byte DCP broadcast is ignored.
  2. The MAC is looked up in the seat table. Unknown MAC: enrolment (below).
  3. seat-<N>.env is written if its content changed (first announce, new client IP).
  4. If the seat's next start is due, systemctl is-active --quiet pupitre-seat@<N>.service is checked and the unit is started when inactive. A unit in failed has hit its start limit (5 starts in 300 s): systemctl reset-failed runs first.

The interval between two starts of one seat is 30 s while things go well, and doubles each time the unit is found failed, up to a quarter of an hour; a start that finds the seat running puts it back to 30 s. This is not politeness: a wedged device answers nothing for about half an hour AND is kept in that state by every retry, so restarting a failing seat every 30 s is what maintains the fault. failed and not merely inactive is what grows the interval: a client that was switched off leaves an inactive unit and comes back at once. The seat launcher doubles its own cooldown between attempts the same way (25, 50, 100, 200 s at the default five attempts, PUPITRE_RETRY_MAX=300 capping it only beyond that), and the unit's RestartSec grows geometrically from 10 s to 900 s.

The three do not add up, and the manager's is the one that counts. A unit inside systemd's own restart holdoff is activating (auto-restart), which is neither active nor failed, so the manager starts it and that start pre-empts the holdoff. So RestartSteps only governs a seat the manager is not watching, and StartLimitBurst is out of reach for anything but a fast failure. When a seat is stopped or reassigned, _stop_seat drops its interval with its env: the backoff belongs to the device, not to the seat number.

A MAC with a hold file (/run/pupitre/hold/<mac>, written by the web console during a rename dialogue) skips steps 2 to 4: its station row is still updated. The file is JSON with reason, until (epoch seconds) and pid; a hold whose until is past, whose pid is gone or that does not parse is stale and is removed with a warning.

This is also the recovery path: a client that is power cycled announces again, and if its seat unit had died in the meantime it is started again. A unit that is already active is left alone.

Enrolment

With enroll = yes (the default), the first announce of an unknown MAC gives it the lowest free seat number between 1 and seats (15 by default), and the choice is saved in seats.json immediately. When every seat is taken the station is logged and left unassigned. With seats = 0 there is no cap: the lowest free number is taken, whatever it is. A parked station (in the table with "seat": null) is never enrolled.

With enroll = no, an unknown MAC is logged once per hour at INFO and ignored. Use this once the room is set up, so that a device plugged in by mistake does not take a seat.

Pinning a MAC to a seat

The web console does it in one click; by hand, edit /var/lib/pupitre/seats.json with the manager running: the change is picked up within a second. To swap two clients, swap their numbers: both units stop and restart with the right clients at their next broadcasts. To park a client, set its seat to null; to un-enrol it, delete its line: at its next announce it is either enrolled again (enroll = yes, it gets the lowest free number, possibly the same one) or ignored (enroll = no). The manager stops the old seat unit itself.

Running by hand

pupitre-manager --config /etc/pupitre/pupitre.conf --verbose
pupitre-manager --dry-run          # logs what it would do, writes nothing, never calls systemctl
pupitre-manager --seats-file /tmp/seats.json

Logs go to stderr, so journalctl -u pupitre-manager shows them under systemd.