Lab Kiosk OS & Edge SaaS

Client Agent

distro-builder/config/includes.chroot/opt/labkiosk/agent/agent.py — a single-file Python 3 daemon, standard library only, that is the workstation's entire relationship with the control plane.

It has three jobs: serve the local setup wizard, keep the control channel to the organization's hub open, and execute operator commands.


How it runs

The agent is not a systemd service. It needs the kiosk user's live X session for scrot and xdotool, so it is started from /etc/openbox/autostart inside a supervisor loop:

while true; do
  python3 /opt/labkiosk/agent/agent.py
  sleep 2
done

If it ever exits, it is back within about two seconds, and the restart is written to /tmp/lab-agent.log. That log lives in the RAM overlay and is gone at power-off.

Reading it on real hardware: open the setup wizard (the network icon in the kiosk top bar, or http://127.0.0.1:8888/setup) and expand Agent Log & Diagnostics. On an installed workstation it asks for the administrator password first. There is no other route — the machine has no terminal, no getty and no SSH, and file:// is blocked by the Chromium policy. docker exec works only for the simulator.

# In the simulator
docker exec labkiosk-client-01 tail -n 50 /tmp/lab-agent.log

Configuration

/etc/labkiosk/config.json, mode 0600, written at enrolment:

{
  "deviceToken": "…",
  "workerUrl": "https://oakridge.labkiosk.example.edu",
  "targetUrl": "https://oakridge.labkiosk.example.edu",
  "clientId": "PC-01",
  "clientNum": 1
}

Where that file actually lives matters. On live media it is in the RAM overlay and disappears at power-off — correct, because the workstation is meant to be installed rather than run from USB permanently. On an installed disk, /etc/labkiosk is a mount point for the LABKIOSK_DATA partition created by labkiosk-install — mounted by etc-labkiosk.mount, which labkiosk-data-generator pins to the partition UUID GRUB passes as labkiosk.data=, never by label — which is what makes a post-install enrolment persist. Without that partition, overlayroot="tmpfs" would discard the token on the next reboot.

Environment overrides, useful in the simulator:

VariablePurpose
WORKER_URLControl plane base URL
LABKIOSK_DOMAINBase platform domain shown in the wizard

The control channel

telemetry_loop() keeps one WebSocket open to the organization's OrgHub (ControlChannel, GET /api/devices/ws with the device token). The hub pushes the configuration on connect and on every admin change, pushes commands the moment they are dispatched, and asks for screen frames only while an operator has this screen on view. The agent sends its status when it changes, a frame every 3 s while asked, and {"type":"ping"} every 15 s — byte for byte, because the edge answers exactly that without waking the hub. → REST API Reference.

It needs Debian's python3-websocket. Without it, or when the server has no WebSocket route (an older Worker, or the Node development server), or after three failed connections in a row, the agent uses the older HTTP heartbeat for ten minutes and then tries again: every three seconds, post_telemetry() sends the same state with a thumbnail and receives the same updates back.

         +-----------------------------------------------+
         |  current_status()             url, lock, num   |
         |  capture_thumbnail_base64()   only if watched   |
         |  read_vnc_password()          /tmp/labkiosk/…   |
         +-----------------------------------------------+
                              |
                 WebSocket /api/devices/ws  (or POST /api/telemetry)
                     Authorization: Bearer …
                              |
                              v  apply_control_update()
         +-----------------------------------------------+
         |  commands[]      -> execute_command()          |
         |  whitelist[]     -> sync_chromium_policies()   |
         |  targetUrl       -> navigate_to()              |
         |  broadcastUrl    -> navigate_to(url, epoch)    |
         +-----------------------------------------------+

Failure handling. A failed connection or heartbeat backs off exponentially up to MAX_BACKOFF_SECONDS (60), so a lab that loses its uplink does not hammer the edge, and recovers promptly when the link returns. A connection the server closed on purpose (a deploy restarts every hub) is re-opened after a random pause of up to 10 s, so a whole fleet does not return at the same instant. A close with 4001 (removed) or 4003 (organization not active), like a 401/403, sends the screen to the re-enrolment form.

Thumbnails. Captured with scrot -t 20 -q 35 — there is no PIL/Pillow dependency; the agent is standard library plus scrot. A frame whose base64 payload exceeds MAX_THUMBNAIL_BYTES (256 KB) is dropped rather than sent, so an oversized capture never costs the organization's uplink or delays the loop. The heartbeat still lands; only that one frame is missing. Over the WebSocket, no screenshot is taken at all unless an operator is watching.

Remote Control. A {"type":"remote","session":…} message on the WebSocket makes start_remote_session() open GET /api/devices/remote (device token plus X-Labkiosk-Session) and pipe it to x11vnc on 127.0.0.1:5900, one session at a time. The HTTP heartbeat cannot carry that message, so an agent on the fallback cannot join. → Remote Control


Command execution

execute_command() handles exactly seven actions. Anything else is logged and ignored.

ActionImplementation
lockSets local lock state and message; the extension raises the curtain on its next poll
unlockClears lock state
navigateValidates with safe_navigable_url(), then drives the browser
reloadReloads the current page
rebootsystemctl reboot via logind
shutdownPowers off via logind
muteMutes output through alsa-utils

reboot and shutdown work because /etc/polkit-1/rules.d/50-labkiosk-power.rules grants the kiosk user exactly those two logind actions and nothing else. The agent runs as kiosk; without that rule the command would be accepted and then silently do nothing.

navigate never takes a URL on faith. safe_navigable_url() accepts only http: and https:, because the value ends up in window.location — a javascript: or file: URL arriving from a compromised control plane would otherwise execute in the page.


Network subsystem

The agent incorporates a full network management subsystem communicating with NetworkManager via nmcli and system sockets.

1. Interface & Carrier Detection (/api/network/interfaces)

get_interfaces() scans network hardware (nmcli -t -f DEVICE,TYPE,STATE dev status), categorising adapters as ethernet or wifi. For ethernet devices, it reads /sys/class/net/<dev>/carrier to provide real-time feedback on physical cable plug state (Connected vs. Unplugged).

2. Wi-Fi Scanning (/api/network/wifi/scan)

scan_wifi() triggers nmcli -t -f SSID,BSSID,SIGNAL,SECURITY,CHAN dev wifi list, deduplicating BSSIDs by SSID name and sorting candidates by signal percentage. Networks report encryption types (e.g. WPA2/WPA3-PSK vs. Open). Hidden networks are supported via manual SSID input.

3. Connection Configuration (/api/network/configure)

configure_network() orchestrates NetworkManager profiles:

  • Ethernet: Deletes stale profiles on the interface and creates Wired Connection (<dev>).
  • Wi-Fi: Configures Wi-Fi (<ssid>) with 802-11-wireless-security.key-mgmt wpa-psk and PSK passphrase.
  • IPv4:
    • auto: Standard DHCP client (ipv4.method auto, ipv4.ignore-auto-dns no).
    • custom_dns: DHCP addressing with custom nameserver override (ipv4.method auto, ipv4.ignore-auto-dns yes, ipv4.dns "<dns>").
    • manual: Static addressing (ipv4.method manual, ipv4.addresses "<ip>/<prefix>", ipv4.gateway "<gw>", ipv4.dns "<dns>").
  • IPv6: Configurable as auto (SLAAC/DHCPv6), custom_dns, manual, or disabled.
  • Proxy: Saves settings to /etc/labkiosk/proxy.json, applies proxy exports (http_proxy, https_proxy, no_proxy, loopback always exempt) to the agent's own environment, and regenerates /etc/chromium/policies/managed/policies.json with a ProxySettings dictionary (ProxyMode: "fixed_servers", ProxyServer: "<host>:<port>", ProxyBypassList as a comma-separated string). On installed systems the request needs the X-LabKiosk-Admin token from /api/admin/verify.

4. Connectivity Probing & Caching (/api/network/test)

test_connectivity() validates the connection:

  • DNS resolution via socket.getaddrinfo("cloudflare.com", 443).
  • Direct routing reachability via socket connection to 1.1.1.1:53 and 8.8.8.8:53 (2.5s timeout).
  • 5-Second TTL Cache: Because /api/status is polled once a second by the browser extension, test_connectivity(force=False) returns cached results to avoid socket exhaustion.

5. Administrator Verification (/api/admin/verify)

Post-installation network management is locked behind verify_admin_password(). The digest is read from labkiosk-password.cfg in /run/live/medium/boot/grub — on an installed disk that is boot/grub on LABKIOSK_ROOT, outside every system image, which live-boot mounts there read-only; on the ISO it holds the build-time password, if one was pinned. When the file exists, the agent parses the GRUB PBKDF2 line:

password_pbkdf2 <user> grub.pbkdf2.sha512.<rounds>.<salt_hex>.<hash_hex>

It computes hashlib.pbkdf2_hmac("sha512", password, salt, rounds) and checks equality in constant time.

6. Polkit Permissions

The agent runs as unprivileged user kiosk. NetworkManager commands succeed because /etc/polkit-1/rules.d/50-labkiosk-network.rules explicitly authorizes org.freedesktop.NetworkManager.* for user kiosk.


Chromium policy synchronisation

sync_chromium_policies(new_whitelist) merges the organization's effective allowlist into /etc/chromium/policies/managed/policies.json.

The static half of that policy is declared exactly once, in /usr/share/labkiosk/chromium-policy-base.json. Two consumers read it and neither may carry its own copy of those keys:

  • config/hooks/live/01-lockdown.hook.chroot, which generates the boot-time policy at build time;
  • sync_chromium_policies(), which regenerates it on every allowlist change.

When the same keys were declared twice, anything added to one and not the other silently vanished the moment a workstation enrolled. Only three keys are per-workstation and overlaid by the consumers: HomepageLocation, NewTabPageLocation, and URLAllowlist.

# CI check: the committed generated policy must match its base
python3 distro-builder/tools/generate-chromium-policy.py --check

Never hand-edit the generated etc/chromium/policies/managed/policies.json.

Chromium reads managed policy only at startup. So after writing a new policy, the agent sets pendingBrowserRestart and restarts the browser after the next sync. Without that, a freshly enrolled kiosk sits on a "This page is blocked" screen with a perfectly correct policy on disk.

→ Kiosk Hardening


The loopback API

A ThreadingHTTPServer bound strictly to **127.0.0.1:8888**. Threaded deliberately: a slow call such as the disk scan must not stall the once-a-second status poll that drives the lock curtain.

Every request must pass two checks, and either failing returns 403:

  • _is_expected_host() — the Host header is a loopback name.
  • _is_local_caller() — the Origin header, when present, is 127.0.0.1 or localhost, or the kiosk extension's own chrome-extension:// origin. Chromium sets that one on the service worker's POST to /api/admin/verify, which is how the top bar's administrator modal asks for a token. The id is pinned by the key in manifest.json.

That is what keeps a visited web page from reaching the installer.

EndpointMethodNotes
/setupGETServes the wizard. 403 once enrolled.
/api/statusGETLocal state for the wizard and the extension
/api/install/disksGET[] when not a live session
/api/install/statusGETProgress percentage
/api/installPOST400 when already installed on an internal drive
/api/rebootPOST
/api/setupPOST409 once already enrolled

Input is re-validated here rather than trusted from the caller, because /etc/sudoers.d/50-labkiosk-install lets the kiosk user run the installer directly — the agent is not the only possible caller, so it is not a trust boundary.

PatternAccepts
CLIENT_ID_PATTERN^[A-Z0-9][A-Z0-9_-]{0,62}$
TARGET_DISK_PATTERN^/dev/(sd[a-z]|vd[a-z]|nvme[0-9]+n[0-9]+|mmcblk[0-9]+)$
GRUB_PBKDF2_PATTERN^grub\.pbkdf2\.sha512\.[0-9]+\.[0-9A-Fa-f]+\.[0-9A-Fa-f]+$
HOSTNAME_PATTERNStandard DNS label syntax

→ REST API Reference


Session awareness

is_live_session() decides whether the machine booted from removable media or from an installed disk:

SignalMeaning
labkiosk.installed=1 on the kernel command line (or /etc/labkiosk-installed, on a disk installed before the image store)Installed drive, checked first
otherwise /run/live exists, or boot=live in /proc/cmdlineLive installer

Both of the latter are true on an installed disk as well, because it boots its system image through live-boot; only the installed boot menu passes labkiosk.installed=1.

This drives two behaviours:

  1. Backend lockout. /api/install/disks returns [] and POST /api/install returns 400 System is already installed on an internal drive, so a misdirected click cannot repartition the running system.
  2. Wizard shape. On live media the wizard shows both tabs — Connect & Enroll and Install to Hard Disk — with the badge LIVE INSTALLER & SETUP. On an installed disk it should hide the tabs and present the enrolment form alone with the badge INSTALLED WORKSTATION ENROLLMENT.

/api/status reports the computed value in isLive and isInstalled, and the wizard branches on it: an installed workstation shows neither the installer tab nor the network step.

It also reports **persistentStorage**. That is false when /etc/labkiosk is not the LABKIOSK_DATA partition — the machine can still be enrolled, and the enrolment lives in the RAM overlay and is gone at the next power-off. The wizard shows an amber warning before the form and again instead of the usual success, the enrolment reply carries persistent and warning, and the agent writes the same warning to its log at every start, because a workstation with no terminal has no other way to answer "why did it forget?".


Saved language and region

An installed workstation boots a system image under a RAM overlay, so the timezone, keyboard, time server and generated locale are the image's defaults again at every boot, and an update replaces the image altogether. The image ships no locales-all: apply_saved_localization() re-applies the choice saved in /etc/labkiosk/localization.json on LABKIOSK_DATA at every start, through labkiosk-localization, which regenerates the locale when it is missing. A failure is logged and never stops the agent.


Boot reports

On an installed disk, labkiosk-boot-slots check (root, run by labkiosk-boot-ok.service) records what this boot did with its system image in /run/labkiosk-update/status.json. boot_report_loop() reads it — refusing a file not owned by root or over 4 KiB — and posts the outcomes worth an administrator's attention to POST /api/devices/boot-report with the device token: installed, failed, rolled-back, fallback and error. Routine boots are not sent.

A report is retried every 30 s until the control plane answers it for good (200, or 400/404, which are not resent); the Worker drops a report it already holds. The latest outcome is kept on the workstation's row, and a failure, rollback, fallback or error is listed under Settings → Errors & Warnings in the admin console.


Enrolment

enroll() posts to POST /api/devices/enroll on the control plane and, on success, writes the config file, syncs the Chromium policy, and flags a browser restart. validate_worker_url() and probe_worker_url() check the target before anything is stored, so a typo in the subdomain fails loudly at the wizard instead of producing a workstation that silently never checks in.

The server must be https. Plain http is accepted only for a local test server: localhost, a loopback address, the container gateways (host.docker.internal, host.containers.internal, *.internal, *.local), or a private IPv4 literal in 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16 (the set the control plane treats as a dev host). That covers a test VM reaching pnpm dev on its host, e.g. http://172.31.64.1:8787 over the Hyper-V Default Switch. Public, link-local (169.254.x.x) and IPv6 addresses, and any hostname, still need https, because the device token travels in every heartbeat.

Re-enrolment is refused with 409 while a token is present. To move a workstation to another organization, decommission it from the admin console (POST /api/clients/remove): the next refused heartbeat sends the screen to the wizard's re-enrolment form (/setup#reenrol), where registering with the new organization's key needs the administrator password on an installed disk. On live media a reboot also forgets the old configuration with the RAM overlay.


Syntax check

PYTHONPYCACHEPREFIX=/tmp/labkiosk-pyc python3 -m py_compile \
  distro-builder/config/includes.chroot/opt/labkiosk/agent/agent.py

PYTHONPYCACHEPREFIX is not optional. Without it py_compile writes __pycache__ directories inside config/includes.chroot, and live-build copies whatever is on disk straight into the ISO — shipping bytecode built for the wrong interpreter into the image.

→ Browser Extension · Disk Installer · Workstation Simulator

This page is wiki/Client-Agent.md in the repository.