Lab Kiosk OS & Edge SaaS

Architecture Overview

Lab Kiosk is two independently deployable systems joined by one authenticated HTTPS contract.

  • The control plane is a single Cloudflare Worker plus a D1 database. It is multi-tenant: one deployment serves every organization.
  • The client is a Debian 12 image that boots into a locked Chromium session and runs a small Python daemon.

Nothing else is required. There is no per-organization server, no on-premise appliance, and no VPN.


System map

+---------------------------------------------------------------------------------------+
|                               CLOUDFLARE EDGE SAAS LAYER                              |
|                                                                                       |
|   [ Public Visitors ]          [ Platform Owner ]           [ Organization Operators ]       |
|            |                           |                             |                |
|            v                           v                             v                |
|   labkiosk.example.edu      labkiosk.example.edu/super   greenwood.labkiosk.example.edu|
|    (Landing page & ISO)      (Super Admin console)         (Operator Lab Dashboard)    |
|            |                           |                             |                |
|            +---------------------------+-----------------------------+                |
|                                        |                                              |
|                                        v                                              |
|                     [ Cloudflare Worker router: src/index.ts ]                        |
|                        |-- auth.ts    Web Crypto PBKDF2, sessions, nonces             |
|                        |-- guard.ts   Tenant resolution, authorization, CSRF origin   |
|                        |-- escape.ts  HTML / attribute / JSON escaping, safe URLs     |
|                        |-- db.ts      D1 queries, SCHEMA_SQL, tenant seeding          |
|                        |-- ui*.ts     Server-rendered consoles, nonce CSP             |
|                        |-- org_hub.ts One Durable Object per organization             |
|                        |-- boot_report.ts Boot outcomes -> Errors & Warnings          |
|                        |-- bug_reports.ts Opt-in redacted GitHub bug reports          |
|                        `-- scheduled() Hourly housekeeping cron                       |
|                                        |                                              |
|                                        v                                              |
|                          [ Cloudflare D1 (SQLite at the edge) ]                       |
|     + Queue (audit), R2 (audit archive), Analytics Engine, Workflow, Workers AI       |
+---------------------------------------------------------------------------------------+
                                         ^
                                         | WebSocket: status up, config and commands down
+---------------------------------------------------------------------------------------+
|                      CLIENT WORKSTATION LAYER (Intel thin clients)                    |
|                                                                                       |
|   Debian 12, Linux 6.1, live-boot squashfs + tmpfs overlay (every write lands in RAM) |
|   Xorg + Openbox, empty keybinding table, VT switching disabled, TTYs masked          |
|                                                                                       |
|   [ Chromium --kiosk ] <----- MV3 extension (--load-extension, unpacked)              |
|         |                       |-- content.js     Nav bar + lock curtain (Shadow DOM)|
|         |                       `-- background.js  Service worker -> agent (loopback) |
|         |-- Top-level native navigation (no iframes, full hardware acceleration)      |
|         `-- Managed enterprise policy (URLBlocklist deny-all + dynamic URLAllowlist)  |
|                                                                                       |
|   [ Python 3 agent: /opt/labkiosk/agent/agent.py ]                                    |
|         |-- Loopback API on 127.0.0.1:8888 (setup wizard, install, status)            |
|         |-- Control channel to OrgHub; JPEG frames only while an operator watches     |
|         `-- Chromium policy synchronisation and command execution                     |
|                                                                                       |
|   [ Remote control gateway ]                                                          |
|         `-- x11vnc 127.0.0.1:5900 <- agent -> console's RemoteRelay (outbound WSS)    |
+---------------------------------------------------------------------------------------+

The three planes

1. Control plane — stateless compute, durable D1

src/index.ts is a flat router: one if (path === ... && method === ...) block per endpoint, in a single fetch() handler. There is no framework. Each block resolves its tenant, applies a guard, and returns JSON or nonce-stamped HTML.

The critical architectural rule is that worker isolates are per-colocation and short-lived, so nothing that two requests must agree on may live in module memory:

StateWhere it livesWhy
Active broadcast URL and epochtenants.broadcast_url / broadcast_epoch (organization-wide) and client_devices.broadcast_url / broadcast_epoch (selected workstations) in D1; the newer winsWorkstations hitting different colos must see the same page, and a broadcast to some screens must survive their next heartbeat.
Device VNC passwordclient_devices.vnc_password in D1The operator's browser and the workstation's heartbeat land in different isolates.
Domain allowlisttenant_whitelist rows in D1Previously a module global shared across every tenant, and lost on isolate recycle.
Who is online, the command queue, screen framesThe organization's OrgHub Durable ObjectOne object per organization sees every workstation and console of it; frames are relayed, never stored.
An open Remote Control sessionA RemoteRelay Durable Object, one per workstationPairs the viewer's WebSocket with the agent's and forwards VNC bytes; nothing is stored.
Workstation registry (last known state, groups)client_devices in D1Written by the hub on connect, disconnect, a change, or every 5 minutes -- never per heartbeat.

2. Transport — one channel, pushed both ways

A workstation holds one WebSocket to its organization's OrgHub:

GET /api/devices/ws   (Upgrade: websocket)
Authorization: Bearer <device token>

   up  ->  status {clientNum, activeUrl, isLocked, vncPassword?}                on change
           frame {thumbnail}                                                  only while watched
           {"type":"ping"}                                                    every 15 s
 down  <-  config {whitelist, mode, targetUrl, broadcastUrl, broadcastEpoch}  on connect and change
           commands [...]                                                     at once
           frames {on, intervalSeconds}                                       when a console watches
           remote {session}                                                   when an operator opens Remote Control

The ping is answered at the edge without waking the hub, so an idle workstation costs nothing. Agents without the WebSocket client, and any agent whose server lacks the route, use the older POST /api/telemetry every three seconds, which carries the same fields in one request and reply.

The device token, never the request body, decides which workstation and which tenant the request belongs to. A payload claiming a different clientId is ignored.

That one channel delivers everything: fleet liveness, screen frames, operator commands, the Chromium allowlist, and the authoritative page URL. It is opened by the workstation, so there is still no inbound connection to the organization's network.

→ REST API Reference for the full catalogue.

3. Client — immutable by construction

The client's defining property is that it does not keep anything. Live media and installed disks alike boot a read-only squashfs image through live-boot (boot=live), which layers a RAM (tmpfs) overlay on top. Browser profiles, caches, logs, downloads, and user artefacts all land in that overlay and are gone at power-off.

The one deliberate exception exists because enrolment has to survive a reboot: labkiosk-install creates a 512 MiB LABKIOSK_DATA partition and mounts it at /etc/labkiosk, which is where the device token lives. Without it, an installed workstation would forget its enrolment on the next boot.

→ Kiosk Hardening for the lockdown layers.


Tenant resolution

The organization a request belongs to is derived from the **Host header**, authoritatively, in resolveTenant() (src/guard.ts). Nothing else is trusted by default.

greenwood.labkiosk.example.edu  ->  subdomain "greenwood"
kiosk.greenwood.example             ->  approved custom domain lookup
labkiosk.example.edu            ->  platform apex: landing page, no tenant

?tenant=<slug> and the X-Tenant header are honoured only when one of these holds:

  • the request arrived on a development host (localhost, 127.0.0.1, host.docker.internal, …), or
  • the caller holds an active super_admin session, or
  • the caller's session already owns that tenant, or
  • the route is explicitly public (/, /home, /privacy, /terms, /terms/bug-reports, /api/status, /api/portal-sites, /api/devices/enroll, /api/telemetry, and GET /api/i18n and /i18n/<tag>.json, the interface catalogs a workstation reads before it is enrolled).

X-Forwarded-Host is never read. A set of reserved slugs — www, super, api, admin, portal, status, mail, app, kiosk, labkiosk, root — can be neither registered nor resolved as an organization.


Request lifecycle

Every request passes the same gauntlet before it reaches a handler:

  1. **bootstrap(env)** — refuses to serve if a D1 binding exists but SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD are unset, or if migrations have not been applied (assertSchemaCurrent()). A deployed worker never creates tables at runtime.
  2. **resolveTenant()** — establishes the organization from Host, per the rules above.
  3. **rejectCrossSiteMutation()** — for cookie-authenticated POST/DELETE under /api/, requires a browser Origin matching this host, the platform domain, or a dev host. Bearer-authenticated device routes are exempt because they carry no ambient credential.
  4. A guard — requireTenantAdmin(), requireSuperAdmin(), or requireDevice(). A route with no guard is treated as a security defect, and the test suite asserts coverage.
  5. The handler, whose every interpolation into HTML goes through escapeHtml() / escapeAttr() / escapeJson(), and whose every navigable URL goes through safeHttpUrl().
  6. **buildHtmlHeaders(nonce, …)** for HTML responses — nonce CSP, HSTS, X-Frame-Options: DENY, frame-ancestors 'none', Permissions-Policy, Cross-Origin-Opener-Policy: same-origin.

→ Security Model for the reasoning behind each layer.


Two operating modes

An organization chooses one, and it is delivered to workstations in the telemetry response as mode:

ModeBehaviour
portalWorkstations land on the User Portal: a grid of approved application cards, curated by the operator.
single_urlWorkstations are locked to one destination — an LMS, an assessment platform, a library catalogue — with no launcher at all.

Either mode can be temporarily overridden by a broadcast: the operator pushes a URL to the whole lab at once, and it persists on the tenant row until reset. A broadcast is not its own command type; it is navigate plus a monotonic broadcastEpoch that lets a workstation tell a new broadcast from a replayed one.


Repository layout

labkiosk/
├── cloudflare-control/      Worker, D1 migrations, tests
├── distro-builder/          live-build ISO pipeline, client source, installer
├── docker-test/             Workstation simulator entrypoint + docs
├── docs/                    In-repo deployment, remote control, API specs
├── .agents/skills/          AI agent procedures (loaded on demand)
├── wiki/                    This wiki's source
├── AGENTS.md                Master engineering codex
└── Dockerfile               The simulator image

→ Control Plane Internals · Client Agent · Development Workflow

This page is wiki/Architecture-Overview.md in the repository.