Lab Kiosk OS & Edge SaaS

Building the ISO

The client OS is produced by Debian live-build, driven either from a container (any host) or natively on a Debian/Ubuntu machine.

Output: distro-builder/out/labkiosk-debian12-amd64.iso and its .sha256.


Method 1 — Docker (cross-platform)

Run both commands from the repository root:

# 1. Build the builder image. The Dockerfile COPYs the source into the image.
docker build -t ghcr.io/akbhoi/labkiosk-iso-builder distro-builder

# 2. Run live-build in a privileged container, mounting only the output directory.
docker run --privileged --rm \
  -v "$PWD/distro-builder/out:/build/out" \
  ghcr.io/akbhoi/labkiosk-iso-builder

The engine must be rootful

live-build runs debootstrap, which creates device nodes with mknod. A rootless user namespace forbids that even under --privileged, and the build dies partway through the chroot stage.

Docker Desktop is rootful by default. If your docker command is served by a podman machine, switch it once:

podman machine stop && podman machine set --rootful && podman machine start

Verify before starting a long build:

docker run --rm --privileged debian:bookworm-slim sh -c 'mknod /tmp/n b 7 99 && echo ok'

Rootful and rootless keep separate image stores, so images pulled before the switch will not be listed afterwards. Reverse with podman machine set --rootful=false.

Step 1 is not optional when you have changed anything

The builder image contains the source — its Dockerfile does COPY . /build/ rather than bind-mounting, because Windows 9P/drvfs mounts enforce nodev/noexec and would break mknod. Only out/ is bind-mounted.

GoalWhat to run
Build the ISO from your working treedocker build … first, every time you change distro-builder/
Reproduce the published ISO exactly as CI builds it from maindocker pull ghcr.io/akbhoi/labkiosk-iso-builder:latest, then run it

Running a stale or pulled image silently builds an ISO from the source baked into it, and the result looks exactly like your change had no effect.

The .dockerignore matters

Without it the build context carries chroot/, cache/, and every previously built ISO — over a gigabyte — and, worse, a stale lb config-generated config/binary whose LB_BOOTAPPEND_LIVE still contained the quiet loglevel=3 that caused the black-screen boot deadlock.


Method 2 — Native Debian / Ubuntu / WSL2

On Debian 12 (Bookworm) or Ubuntu 22.04+:

sudo apt-get update && sudo apt-get install -y live-build debootstrap

cd distro-builder
sudo bash build-iso.sh

What goes into the image

Package manifest

config/package-lists/kiosk.list.chroot is deliberately minimal:

GroupPackages
Kernel & live bootlinux-image-amd64, live-boot, live-config, live-config-systemd, live-tools, eject, overlayroot, systemd-sysv
Boot & partitioninggrub-efi-amd64-bin, grub-pc-bin, grub-common, grub2-common, shim-signed, grub-efi-amd64-signed, efibootmgr, parted, dosfstools, e2fsprogs, rsync, sudo, xz-utils
Virtualisationhyperv-daemons
Firmwareintel-microcode, amd64-microcode, firmware-linux-free, firmware-misc-nonfree, firmware-realtek, firmware-iwlwifi
X11 & desktopxserver-xorg-core, xserver-xorg-legacy, xserver-xorg-video-{all,fbdev,vesa,intel,qxl}, xserver-xorg-input-all, xinit, nodm, openbox, xdotool, scrot, unclutter, alsa-utils
Browser & fontschromium, chromium-sandbox, fonts-dejavu, fonts-liberation, fonts-noto-core, fonts-noto-color-emoji
Remote & networkx11vnc, network-manager, wpasupplicant, wireless-regdb, rfkill, systemd-timesyncd, locales, iproute2, libnss-systemd, curl, python3, python3-websocket, ca-certificates

Keeping the image small

auto/config builds with --apt-recommends false and --firmware-chroot false, so only what the list names is installed. Before this, live-build's defaults added every package in non-free-firmware (~875 MB installed: server NICs, GPU-compute, Raspberry Pi) and every Recommends (printer tools, Samba, Avahi, ModemManager, Perl web modules), which took the ISO past 1.1 GB. Consequences:

  • A package the kiosk needs that some other package only recommends must be listed by name (that is why systemd-timesyncd, the signed shim/GRUB and the extra Xorg drivers are there).
  • live-tools is in that list for a concrete reason: it prints "Please remove the live-medium ... press ENTER" when a live session reboots. Without it, a workstation that has just been installed reboots straight back into the installer.
  • New Wi-Fi or GPU hardware needs its firmware-* package added explicitly (e.g. firmware-amd-graphics, firmware-atheros, firmware-brcm80211).
  • The image has no noVNC, websockify or cloudflared: Remote Control goes from loopback x11vnc through the agent to the console's relay, and the console serves the viewer. The simulator's noVNC is not Debian's novnc package (which depends on Node.js): its Dockerfile installs the release pinned in docker-test/novnc.pin with docker-test/install-novnc.sh, verifying its SHA-256.
  • The initramfs is xz-compressed via etc/initramfs-tools/conf.d/labkiosk-compress; live-build 20230502's --initramfs-compression does not accept xz.
  • scrot stays: Openbox already needs imlib2, which is what pulls in its large image loaders, so replacing scrot would add packages rather than remove them.

scrot supplies thumbnails, xdotool drives the browser, alsa-utils implements the mute command, and chromium-sandbox is present because the image keeps Chromium's sandbox enabled. The simulator does too; it passes --no-sandbox only when started as root.

Build hooks

HookDoes
config/hooks/live/01-lockdown.hook.chrootCreates the kiosk user, configures nodm autologin and its PAM stack, masks every getty, writes the Xorg lockdown snippet, the polkit power rule, the sudoers rule, the units that mount LABKIOSK_DATA on an installed disk, enables labkiosk-boot-ok.service, pre-seeds live-config's sudo and policykit components away, ships an empty /etc/machine-id, and generates the Chromium policy from its base
config/hooks/live/02-security.hook.chrootsysctl hardening, disables core dumps, sets GRUB timeout and consoleblank=0, disables recovery mode, applies --unrestricted and any pinned boot password

Rootfs overlay

config/includes.chroot/ is injected verbatim into the image: the agent, the extension, the wizard, the installer, labkiosk-boot-slots and labkiosk-boot-ok.service, the installed disk's boot menu (usr/share/labkiosk/boot/grub.cfg), the image's version (usr/share/labkiosk/version), the labkiosk-data-generator systemd generator, overlayroot.conf, and the Openbox config.

Line endings. .gitattributes pins every script, hook, and config in this tree to eol=lf, because the repository builds a Linux image. A CRLF hook dies with $'\r': command not found. Never write these files with a tool that translates newlines — Python's Path.write_text does, on Windows.


Bootloaders

FirmwareConfigNotes
Legacy BIOSconfig/bootloaders/isolinux/, syslinux/ALLOWOPTIONS 0 + NOESCAPE 1 in stdmenu.cfg means syslinux discards any kernel argument typed at the prompt — no password needed
UEFIconfig/bootloaders/grub-pc/Password applied from LABKIOSK_GRUB_PBKDF2 or grub.pin when present

The menu offers a default entry, a Load into RAM (toram) entry, an Install to Hard Disk entry, and a failsafe entry.


Build pins

config/includes.chroot/usr/share/labkiosk/grub.pin          PBKDF2 boot-menu hash
config/includes.chroot/usr/share/labkiosk/update-keys/      release-signing public keys (current + next)
PinUnsetWrong
grub.pinBuilds with a loud warning; live menu stays editableBuild fails
update-keys/*.gpgBuild fails — the image could never verify an updateBuild fails (not a binary public key)

Never invent a value to make a build go green. → Kiosk Hardening


Pre-flight checks

Run these before packaging — CI runs them too:

PYTHONPYCACHEPREFIX=/tmp/labkiosk-pyc python3 -m py_compile \
  distro-builder/config/includes.chroot/opt/labkiosk/agent/agent.py \
  distro-builder/config/includes.chroot/usr/local/bin/labkiosk-install \
  distro-builder/config/includes.chroot/usr/local/sbin/labkiosk-localization \
  distro-builder/config/includes.chroot/usr/local/sbin/labkiosk-boot-slots \
  distro-builder/config/includes.chroot/usr/local/sbin/labkiosk-update

node --check distro-builder/config/includes.chroot/opt/labkiosk/extension/content.js
node --check distro-builder/config/includes.chroot/opt/labkiosk/extension/background.js

python3 distro-builder/tools/generate-chromium-policy.py --check

shellcheck -S warning \
  distro-builder/config/includes.chroot/etc/openbox/autostart \
  distro-builder/docker-build.sh \
  docker-test/entrypoint.sh

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.


CI

.github/workflows/build-iso.yml builds the ISO on version tags (v*) and on manual dispatch, frees disk space on the runner first, warns when a release build has no GRUB password pinned, verifies the checksum, and uploads the artifact. It then boot-tests the ISO before any release: distro-builder/tests/vm/boot-test.sh installs it onto a virtual disk with the real installer and boots that disk in QEMU (UEFI, KVM) to prove the one-try boot, the promotion of a new image and both kinds of rollback. It then downloads a release signed with a throwaway key onto that disk with the image's own labkiosk-update: a download killed halfway is never booted and resumes, a tampered manifest and a signed downgrade are refused, and the finished download installs and is promoted. A failing scenario uploads VM screenshots, and no GitHub Release is created. On a tag, the release is then signed and uploaded to R2 as an over-the-air update (distro-builder README). → Testing Guide

.github/workflows/ci.yml runs on every push: both Docker images build (no push), the worker is typechecked and tested, and the client checks above all run.


Flashing to USB

Minimum 2 GB.

  • Windows: Rufus, in DD Image mode when prompted.
  • macOS / Linux: balenaEtcher, or:
sudo dd if=distro-builder/out/labkiosk-debian12-amd64.iso of=/dev/sdX \
  bs=4M status=progress conv=fsync

Verify /dev/sdX carefully. dd will not ask twice.

→ Installation Guide · Disk Installer · Kiosk Hardening

This page is wiki/Building-the-ISO.md in the repository.