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.
| Goal | What to run |
|---|---|
| Build the ISO from your working tree | docker build … first, every time you change distro-builder/ |
Reproduce the published ISO exactly as CI builds it from main | docker 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:
| Group | Packages |
|---|---|
| Kernel & live boot | linux-image-amd64, live-boot, live-config, live-config-systemd, live-tools, eject, overlayroot, systemd-sysv |
| Boot & partitioning | grub-efi-amd64-bin, grub-pc-bin, grub-common, grub2-common, shim-signed, grub-efi-amd64-signed, efibootmgr, parted, dosfstools, e2fsprogs, rsync, sudo, xz-utils |
| Virtualisation | hyperv-daemons |
| Firmware | intel-microcode, amd64-microcode, firmware-linux-free, firmware-misc-nonfree, firmware-realtek, firmware-iwlwifi |
| X11 & desktop | xserver-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 & fonts | chromium, chromium-sandbox, fonts-dejavu, fonts-liberation, fonts-noto-core, fonts-noto-color-emoji |
| Remote & network | x11vnc, 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-toolsis 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
x11vncthrough the agent to the console's relay, and the console serves the viewer. The simulator's noVNC is not Debian'snovncpackage (which depends on Node.js): its Dockerfile installs the release pinned indocker-test/novnc.pinwithdocker-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-compressiondoes not accept xz. scrotstays: Openbox already needsimlib2, which is what pulls in its large image loaders, so replacingscrotwould 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
| Hook | Does |
|---|---|
config/hooks/live/01-lockdown.hook.chroot | Creates 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.chroot | sysctl 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.
.gitattributespins every script, hook, and config in this tree toeol=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'sPath.write_textdoes, on Windows.
Bootloaders
| Firmware | Config | Notes |
|---|---|---|
| Legacy BIOS | config/bootloaders/isolinux/, syslinux/ | ALLOWOPTIONS 0 + NOESCAPE 1 in stdmenu.cfg means syslinux discards any kernel argument typed at the prompt — no password needed |
| UEFI | config/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)
| Pin | Unset | Wrong |
|---|---|---|
grub.pin | Builds with a loud warning; live menu stays editable | Build fails |
update-keys/*.gpg | Build fails — the image could never verify an update | Build 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.