Adding Plymouth

This commit is contained in:
Audiobuzz
2026-06-17 14:28:27 +00:00
parent 55aa517976
commit 3ef34745c9
11 changed files with 188 additions and 18 deletions
+11 -2
View File
@@ -49,8 +49,16 @@ env:
# per-environment DNS name (e.g. nvme.fleet.lab) resolved at boot so one UKI # per-environment DNS name (e.g. nvme.fleet.lab) resolved at boot so one UKI
# works across environments — the serving stack's dnsmasq resolves it. # works across environments — the serving stack's dnsmasq resolves it.
NVMET_TRADDR: "192.168.50.10" NVMET_TRADDR: "192.168.50.10"
# URL the fullscreen Chromium kiosk loads at boot. Empty disables the kiosk. # URL the fullscreen Chromium kiosk loads at boot. Defaults to the test page
KIOSK_URL: "https://www.reddit.com" # served by the boot HTTP server (serving/http/kiosk) at /kiosk/; boot.fleet.lab
# resolves to the serving host via the stack's dnsmasq. Empty disables the kiosk.
KIOSK_URL: "http://boot.fleet.lab/kiosk/"
# Plymouth boot splash. "false" => verbose console boot (rhgb quiet NOT baked;
# plymouth.enable=0 instead) — kept OFF in CI while the network-manager port is
# being bench-verified, so the boot chain stays debuggable. Flip to "true" to
# ship the graphical splash once boot is trusted. (Plymouth is in the image
# either way; this only flips the cmdline.)
BOOT_SPLASH: "false"
# =========================================================================== # ===========================================================================
# TEMPORARY: 802.1X + MACsec are DISABLED for this build (rd.dot1x=0) so the # TEMPORARY: 802.1X + MACsec are DISABLED for this build (rd.dot1x=0) so the
# UKI / rootfs / NVMe-oF boot path can be validated on an OPEN switch port. # UKI / rootfs / NVMe-oF boot path can be validated on an OPEN switch port.
@@ -178,4 +186,5 @@ jobs:
-var nvmet_traddr="${{ env.NVMET_TRADDR }}" \ -var nvmet_traddr="${{ env.NVMET_TRADDR }}" \
-var ssh_authorized_key="${{ secrets.SSH_AUTHORIZED_KEY }}" \ -var ssh_authorized_key="${{ secrets.SSH_AUTHORIZED_KEY }}" \
-var kiosk_url="${{ env.KIOSK_URL }}" \ -var kiosk_url="${{ env.KIOSK_URL }}" \
-var boot_splash=${{ env.BOOT_SPLASH }} \
. .
+12 -3
View File
@@ -161,9 +161,10 @@ instead of working around it.
`network-manager` module hands its initramfs connections to the real-root NM `network-manager` module hands its initramfs connections to the real-root NM
(state under `/run/NetworkManager`), which renews the lease. No more (state under `/run/NetworkManager`), which renews the lease. No more
`dhcp-client`/`dhclient` (deprecated). The hook drives macsec0 DHCP via `nmcli` `dhcp-client`/`dhclient` (deprecated). The hook drives macsec0 DHCP via `nmcli`
(not `ifup`). See invariant on the managed/unmanaged split. BENCH-VERIFY: lease (not `ifup`). See invariant on the managed/unmanaged split. Test path
actually renews past expiry (test path); macsec0 managed + `en*` unmanaged with VERIFIED on the bench 2026-06-17 (lease renews past expiry; host stays up).
MKA surviving switch-root (secure path). STILL BENCH-VERIFY when `disable_dot1x=false`: macsec0 managed + `en*`
unmanaged with MKA surviving switch-root (secure path).
- Writable root overlay is `systemd.volatile=overlay`, **not** `rootovl`: - Writable root overlay is `systemd.volatile=overlay`, **not** `rootovl`:
RHEL/Rocky dracut ships no `overlayfs`/`rootovl` module. Needs RHEL/Rocky dracut ships no `overlayfs`/`rootovl` module. Needs
`systemd-volatile-root.service` in the initramfs (bundled by recent dracut; `systemd-volatile-root.service` in the initramfs (bundled by recent dracut;
@@ -194,6 +195,14 @@ These bit us bringing the build up on Rocky 9.8; they are easy to regress.
stack stays in the image, dormant. The matching lab DHCP/HTTP/NVMe-oF serving stack stays in the image, dormant. The matching lab DHCP/HTTP/NVMe-oF serving
stack lives in `serving/` (docker compose). Re-enable security by dropping the stack lives in `serving/` (docker compose). Re-enable security by dropping the
var / setting `DISABLE_DOT1X: "false"` in the CI workflow. var / setting `DISABLE_DOT1X: "false"` in the CI workflow.
- **Boot splash toggle**: `boot_splash=true` (default) bakes `rhgb quiet` for a
Plymouth graphical splash (theme `spinner`, set in the provisioner); `false`
bakes `plymouth.enable=0` for a verbose console boot — keep it OFF while
debugging the boot chain. Plymouth is always in the image + initramfs (the
`plymouth` dracut module, which pulls `drm`); only the cmdline changes. CI
currently sets `BOOT_SPLASH: "false"` during the network-manager bench-verify.
Press ESC at boot to see details behind a splash. ks.cfg no longer excludes
`plymouth`.
## Testing changes ## Testing changes
+19 -3
View File
@@ -120,8 +120,14 @@ variable "enable_kiosk" {
variable "kiosk_url" { variable "kiosk_url" {
type = string type = string
description = "URL the fullscreen Chromium kiosk loads at boot (cage Wayland session on tty1, baked into the immutable root). Empty disables the kiosk install entirely. A runtime config-agent could override it later via /etc/kiosk/kiosk.env." description = "URL the fullscreen Chromium kiosk loads at boot (weston session on tty1, baked into the immutable root). Defaults to the self-contained test page served by the lab boot HTTP server at /kiosk/ (boot.fleet.lab resolves to the serving host via the stack's dnsmasq) — validates the boot end-to-end without external connectivity. Empty disables the kiosk install entirely. A runtime config-agent could override it later via /etc/kiosk/kiosk.env."
default = "https://www.reddit.com" default = "http://boot.fleet.lab/kiosk/"
}
variable "boot_splash" {
type = bool
description = "true (default): bake 'rhgb quiet' into the UKI cmdline so Plymouth shows a graphical boot splash (covers the network-auth + NVMe/TCP + verity phase). false: bake 'plymouth.enable=0' for a fully verbose console boot — use while debugging the boot chain. Plymouth itself is always in the image; this only flips the cmdline. (The cmdline is immutable once signed, so this is a build-time choice.)"
default = true
} }
variable "sb_key" { variable "sb_key" {
@@ -222,6 +228,14 @@ build {
# dracut warns and silently falls back to gzip (a larger initramfs). # dracut warns and silently falls back to gzip (a larger initramfs).
"dnf -y install wpa_supplicant tpm2-tools tpm2-tss nvme-cli cryptsetup dracut-network NetworkManager zstd openssl", "dnf -y install wpa_supplicant tpm2-tools tpm2-tss nvme-cli cryptsetup dracut-network NetworkManager zstd openssl",
# Plymouth boot splash. plymouth-scripts provides plymouth-set-default-theme;
# spinner is the clean modern theme. Set the default theme BEFORE the dracut
# build below so the (-no-hostonly) initramfs bundles it; no -R here because
# we rebuild the initramfs explicitly. Whether the splash shows at boot is
# decided by the cmdline (boot_splash var), not by installing this.
"dnf -y install plymouth plymouth-scripts plymouth-theme-spinner",
"plymouth-set-default-theme spinner",
# Stage etc files into place # Stage etc files into place
"install -D -m0644 /tmp/etc-stage/dot1x/radius-ca.pem /etc/dot1x/radius-ca.pem", "install -D -m0644 /tmp/etc-stage/dot1x/radius-ca.pem /etc/dot1x/radius-ca.pem",
"install -D -m0644 /tmp/etc-stage/dot1x/wpa_supplicant-dot1x.conf /etc/dot1x/wpa_supplicant-dot1x.conf", "install -D -m0644 /tmp/etc-stage/dot1x/wpa_supplicant-dot1x.conf /etc/dot1x/wpa_supplicant-dot1x.conf",
@@ -266,6 +280,8 @@ build {
# a dracut hook). # a dracut hook).
"grep -q 'nm-unmanaged.sh' /tmp/lsinitrd.txt || { echo 'ERROR: nm-unmanaged.sh hook missing from initramfs' >&2; exit 1; }", "grep -q 'nm-unmanaged.sh' /tmp/lsinitrd.txt || { echo 'ERROR: nm-unmanaged.sh hook missing from initramfs' >&2; exit 1; }",
"grep -q 'nvmf-connect.service' /tmp/lsinitrd.txt || { echo 'ERROR: nvmf-connect.service missing from initramfs' >&2; exit 1; }", "grep -q 'nvmf-connect.service' /tmp/lsinitrd.txt || { echo 'ERROR: nvmf-connect.service missing from initramfs' >&2; exit 1; }",
# Plymouth must be in the initramfs to splash during the early boot phase.
"grep -q 'plymouthd' /tmp/lsinitrd.txt || { echo 'ERROR: plymouth missing from initramfs (plymouth dracut module not installed)' >&2; exit 1; }",
# ---- Build hygiene --------------------------------------------------- # ---- Build hygiene ---------------------------------------------------
"dnf clean all", "dnf clean all",
@@ -402,7 +418,7 @@ build {
# home dirs) — we dropped mkfs.erofs --all-root, which used to mask this by # home dirs) — we dropped mkfs.erofs --all-root, which used to mask this by
# flattening everything to root:root. sudo is a no-op if already root, and # flattening everything to root:root. sudo is a no-op if already root, and
# the runner has passwordless sudo (cf. the `sudo apt-get` install step). # the runner has passwordless sudo (cf. the `sudo apt-get` install step).
"sudo bash -c \"NVMET_TRADDR='${var.nvmet_traddr}' bash scripts/extract-artifacts.sh output-qemu/diskless-build.qcow2 ${var.artifact_dir}/${var.image_version} ${var.disable_dot1x}\"", "sudo bash -c \"NVMET_TRADDR='${var.nvmet_traddr}' BOOT_SPLASH='${var.boot_splash}' bash scripts/extract-artifacts.sh output-qemu/diskless-build.qcow2 ${var.artifact_dir}/${var.image_version} ${var.disable_dot1x}\"",
# Hand the OUTPUT FILES back to the build user (their in-erofs ownership is # Hand the OUTPUT FILES back to the build user (their in-erofs ownership is
# already baked correctly) so build-oci.sh can write the UKI and push with # already baked correctly) so build-oci.sh can write the UKI and push with
# the build user's `buildah login`. # the build user's `buildah login`.
+18 -9
View File
@@ -5,13 +5,18 @@ Persistent backlog, kept in sync with the working session task tracker. Status:
--- ---
## [~] 1. Port the dracut module off `network-legacy` → `network-manager` ## [x] 1. Port the dracut module off `network-legacy` → `network-manager`
**IMPLEMENTED 2026-06-17 — awaiting boot-bench verification.** Fixes the **DONE — test path VERIFIED ON THE BENCH 2026-06-17** (host stays up past the
confirmed lease-expiry outage: network-legacy's one-shot DHCP lease was never DHCP lease time; no more lease-expiry outage). Fixed the confirmed outage:
renewed post-switch-root, so the address dropped at lease expiry → NVMe/TCP root network-legacy's one-shot DHCP lease was never renewed post-switch-root, so the
died ("keep alive timeout" / "no route to host"). NM hands its initramfs address dropped at lease expiry → NVMe/TCP root died ("keep alive timeout" /
connections to the real-root NM (`/run/NetworkManager` state), which renews. "no route to host"). NM hands its initramfs connections to the real-root NM
(`/run/NetworkManager` state), which renews.
REMAINING: the **secure path** (macsec0 DHCP via `nmcli`, physical NIC unmanaged
via `nm-unmanaged.sh`) is only exercised once `disable_dot1x=false` — verify on
the swtpm + hostapd + FreeRADIUS bench when re-enabling security (see #2/cleanup).
Done: Done:
- `90-diskless.conf` `add_dracutmodules`: `network-legacy` → `network-manager`. - `90-diskless.conf` `add_dracutmodules`: `network-legacy` → `network-manager`.
@@ -125,8 +130,11 @@ Path to getting here (don't re-discover):
smaller image. **Re-enable with `-var enable_kiosk=true` when returning to smaller image. **Re-enable with `-var enable_kiosk=true` when returning to
kiosk work** — CI does not pass the var, so CI currently builds lean (no kiosk work** — CI does not pass the var, so CI currently builds lean (no
kiosk) too; add `-var enable_kiosk=true` to the Gitea workflow to ship it. kiosk) too; add `-var enable_kiosk=true` to the Gitea workflow to ship it.
- `kiosk_url` Packer var (default `https://www.reddit.com`; empty disables the - `kiosk_url` Packer var (default `http://boot.fleet.lab/kiosk/` — the
whole install). Wired in CI as `KIOSK_URL` env. self-contained test page served by the boot HTTP server at
`serving/http/kiosk/`, via the `/kiosk/` nginx location; validates a boot
without external connectivity. Empty disables the whole install). Wired in CI
as `KIOSK_URL` env.
- Provisioner enables EPEL, installs `weston chromium mesa-dri-drivers - Provisioner enables EPEL, installs `weston chromium mesa-dri-drivers
mesa-libEGL mesa-libgbm dejavu-sans-fonts`, creates a passwordless mesa-libEGL mesa-libgbm dejavu-sans-fonts`, creates a passwordless
unprivileged `kiosk` user (video/input groups). unprivileged `kiosk` user (video/input groups).
@@ -156,7 +164,8 @@ Path to getting here (don't re-discover):
Make the diskless workstation boot straight into a locked-down kiosk: a Make the diskless workstation boot straight into a locked-down kiosk: a
fullscreen Chromium showing a configured site (initial target: Reddit). All fullscreen Chromium showing a configured site (default: the boot HTTP server's
`/kiosk/` test page). All
baked at **build time** into the root tree (immutable erofs/verity root; runtime baked at **build time** into the root tree (immutable erofs/verity root; runtime
is the tmpfs overlay), fleet-generic (no per-host state). is the tmpfs overlay), fleet-generic (no per-host state).
+8
View File
@@ -15,6 +15,14 @@ hostonly="no"
# lease was never renewed post-switch-root -> the address was dropped at lease # lease was never renewed post-switch-root -> the address was dropped at lease
# expiry -> NVMe/TCP root died ("keep alive timeout" / "no route to host"). # expiry -> NVMe/TCP root died ("keep alive timeout" / "no route to host").
add_dracutmodules+=" dot1x-macsec network-manager nvmf systemd-veritysetup " add_dracutmodules+=" dot1x-macsec network-manager nvmf systemd-veritysetup "
# plymouth: graphical boot splash, shown from early initramfs through the long
# network-auth + NVMe/TCP + verity phase until the kiosk/login takes over. The
# plymouth dracut module depends on 'drm', which in --no-hostonly mode pulls a
# broad set of DRM/KMS drivers (incl. the VM's bochs/virtio-gpu); if a specific
# fleet GPU's splash doesn't render, add its drm kmod to add_drivers like the
# NICs. Whether the splash actually displays is gated by the cmdline (rhgb quiet
# vs plymouth.enable=0), set from the boot_splash var in extract-artifacts.sh.
add_dracutmodules+=" plymouth "
filesystems+=" erofs overlay " filesystems+=" erofs overlay "
add_drivers+=" macsec nvme-tcp nvme-fabrics dm-verity @NIC_DRIVERS@ " add_drivers+=" macsec nvme-tcp nvme-fabrics dm-verity @NIC_DRIVERS@ "
install_items+=" /etc/dot1x/radius-ca.pem /etc/dot1x/wpa_supplicant-dot1x.conf " install_items+=" /etc/dot1x/radius-ca.pem /etc/dot1x/wpa_supplicant-dot1x.conf "
-1
View File
@@ -32,7 +32,6 @@ reboot
kernel kernel
openssh-server openssh-server
chrony chrony
-plymouth
-firewalld -firewalld
%end %end
+15
View File
@@ -23,6 +23,10 @@ DISABLE_DOT1X=${3:-false}
# environments. Defaults to the literal placeholder so build-oci.sh's warning # environments. Defaults to the literal placeholder so build-oci.sh's warning
# fires if it was never set. # fires if it was never set.
NVMET_TRADDR=${NVMET_TRADDR:-NVMET_TRADDR} NVMET_TRADDR=${NVMET_TRADDR:-NVMET_TRADDR}
# Plymouth boot splash toggle (from the boot_splash Packer var). "true" bakes
# "rhgb quiet" so Plymouth shows the graphical splash; anything else bakes
# "plymouth.enable=0" for a fully verbose console boot (boot-chain debugging).
BOOT_SPLASH=${BOOT_SPLASH:-true}
# Stable namespace UUIDs. Devices are referenced by-id # Stable namespace UUIDs. Devices are referenced by-id
# (/dev/disk/by-id/nvme-uuid.<uuid>) rather than /dev/nvmeNnM because NVMe # (/dev/disk/by-id/nvme-uuid.<uuid>) rather than /dev/nvmeNnM because NVMe
# controller/namespace indices are NOT stable (a transient discovery controller # controller/namespace indices are NOT stable (a transient discovery controller
@@ -142,6 +146,16 @@ jq -n --arg hash "$ROOT_HASH" --arg erofs_uuid "$FIXED_UUID" \
# The device node assumes the root namespace is the host's first/only # The device node assumes the root namespace is the host's first/only
# NVMe/TCP namespace; adjust if not. NVMET_TRADDR: set per environment # NVMe/TCP namespace; adjust if not. NVMET_TRADDR: set per environment
# *before* the UKI build, since the cmdline is immutable once signed. # *before* the UKI build, since the cmdline is immutable once signed.
# Plymouth: rhgb (Red Hat graphical boot) + quiet for a clean splash, or
# plymouth.enable=0 for a verbose debug boot. Press ESC at boot to see details
# behind the splash; or override at runtime via the SMBIOS Type 11
# io.systemd.stub.kernel-cmdline-extra OEM string (adds args without a rebuild).
if [ "$BOOT_SPLASH" = "true" ]; then
SPLASH_FRAGMENT="rhgb quiet "
else
SPLASH_FRAGMENT="plymouth.enable=0 "
fi
DOT1X_FRAGMENT="" DOT1X_FRAGMENT=""
if [ "$DISABLE_DOT1X" = "true" ]; then if [ "$DISABLE_DOT1X" = "true" ]; then
echo "==> WARNING: building TEST cmdline with rd.dot1x=0 (802.1X + MACsec DISABLED)" >&2 echo "==> WARNING: building TEST cmdline with rd.dot1x=0 (802.1X + MACsec DISABLED)" >&2
@@ -154,6 +168,7 @@ if [ "$DISABLE_DOT1X" = "true" ]; then
DOT1X_FRAGMENT="rd.dot1x=0 ip=dhcp " DOT1X_FRAGMENT="rd.dot1x=0 ip=dhcp "
fi fi
printf '%s' \ printf '%s' \
"$SPLASH_FRAGMENT" \
"$DOT1X_FRAGMENT" \ "$DOT1X_FRAGMENT" \
"rd.neednet=1 systemd.volatile=overlay " \ "rd.neednet=1 systemd.volatile=overlay " \
"rd.dot1x.nvindex=0x01800001 rd.dot1x.timeout=60 " \ "rd.dot1x.nvindex=0x01800001 rd.dot1x.timeout=60 " \
+3
View File
@@ -58,6 +58,9 @@ docker compose ps
# HTTP: UKI is served # HTTP: UKI is served
curl -sI http://${SERVER_IP}/fleet-${IMAGE_VERSION}.efi | head -1 # 200 OK curl -sI http://${SERVER_IP}/fleet-${IMAGE_VERSION}.efi | head -1 # 200 OK
# HTTP: kiosk test page is served (what the diskless kiosk loads by default)
curl -sI http://${SERVER_IP}/kiosk/ | head -1 # 200 OK
# NVMe/TCP: target is listening + discoverable # NVMe/TCP: target is listening + discoverable
ss -lntp | grep 4420 ss -lntp | grep 4420
sudo nvme discover -t tcp -a ${SERVER_IP} -s 4420 # lists the subsystem sudo nvme discover -t tcp -a ${SERVER_IP} -s 4420 # lists the subsystem
+4
View File
@@ -89,6 +89,10 @@ services:
subpath: artifacts # mount only /artifacts from the image subpath: artifacts # mount only /artifacts from the image
# Fallback (Docker < 28 / offline): bind the local Packer output dir: # Fallback (Docker < 28 / offline): bind the local Packer output dir:
# - ${ARTIFACT_SRC}:/usr/share/nginx/html:ro # - ${ARTIFACT_SRC}:/usr/share/nginx/html:ro
# Serve the kiosk test page at /kiosk/ alongside the artifacts. Config
# adds the location; the page is lab content (NOT in the artifact image).
- ./http/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./http/kiosk:/usr/share/nginx/kiosk:ro
# For HTTPS boot (production), terminate TLS here with the boot-infra CA's # For HTTPS boot (production), terminate TLS here with the boot-infra CA's
# cert and switch the dhcp-boot URL above to https://. Left HTTP for the lab. # cert and switch the dhcp-boot URL above to https://. Left HTTP for the lab.
+26
View File
@@ -0,0 +1,26 @@
# Lab boot HTTP server (nginx). Replaces the stock default.conf so one server on
# :80 serves BOTH:
# / -> the OCI release artifacts (UKI fleet-<ver>.efi, etc.), mounted
# read-only from the image volume at /usr/share/nginx/html
# /kiosk/ -> the diskless kiosk test page (lab content bind-mounted at
# /usr/share/nginx/kiosk, deliberately NOT part of the artifact
# image, which stays FROM-scratch).
# A separate kiosk container was considered, but every service here uses host
# networking, so a second nginx would fight for :80; a single location is cleaner.
server {
listen 80 default_server;
server_name _;
# UKI + release artifacts (firmware fetches /fleet-<ver>.efi from here).
location / {
root /usr/share/nginx/html;
autoindex on;
}
# Kiosk test page. The diskless kiosk (kiosk_url) points at
# http://boot.fleet.lab/kiosk/ — resolved to this host by the stack's dnsmasq.
location /kiosk/ {
alias /usr/share/nginx/kiosk/;
index index.html;
}
}
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<!--
Kiosk test page — served by the lab boot HTTP server (nginx) at /kiosk/, NOT
baked into the OCI artifact image (that stays FROM-scratch: UKI + rootfs only).
The diskless kiosk (weston + Chromium) loads this instead of an internet site,
so a boot is validated end-to-end without external connectivity: DNS resolves
boot.fleet.lab via dnsmasq, HTTP fetches from the serving host, and Chromium
renders it. Fully self-contained (no external fonts/CDN) so it works offline.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fleet Kiosk — Boot OK</title>
<style>
:root { --fg: #00ffd0; --dim: #4a8c84; --bg: #040a0d; --accent: #ff2e88; }
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: radial-gradient(circle at 50% 30%, #0a1620 0%, var(--bg) 70%);
color: var(--fg);
font-family: "DejaVu Sans Mono", ui-monospace, monospace;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; gap: 1.2rem; padding: 2rem;
overflow: hidden;
}
h1 { font-size: clamp(2rem, 7vw, 5rem); margin: 0; letter-spacing: .15em;
text-shadow: 0 0 18px var(--fg); }
.ok { color: var(--accent); text-shadow: 0 0 18px var(--accent); }
.sub { color: var(--dim); font-size: clamp(.9rem, 2.2vw, 1.3rem);
letter-spacing: .1em; }
.grid { display: grid; grid-template-columns: auto auto; gap: .35rem 1.2rem;
margin-top: 1rem; font-size: clamp(.8rem, 1.8vw, 1.1rem);
text-align: left; }
.grid .k { color: var(--dim); }
.clock { font-size: clamp(1.4rem, 4vw, 2.4rem); text-shadow: 0 0 12px var(--fg); }
.bar { width: min(60vw, 520px); height: 3px; background: var(--dim); opacity:.4;
border-radius: 2px; }
footer { position: fixed; bottom: 1rem; color: var(--dim); font-size: .8rem;
letter-spacing: .1em; }
</style>
</head>
<body>
<h1><span class="ok">▣</span> FLEET KIOSK ONLINE</h1>
<div class="sub">diskless boot validated &mdash; weston + chromium rendering local HTTP</div>
<div class="bar"></div>
<div class="clock" id="clock">--:--:--</div>
<div class="grid">
<span class="k">served from</span><span id="origin">&hellip;</span>
<span class="k">page url</span><span id="href">&hellip;</span>
<span class="k">user agent</span><span id="ua">&hellip;</span>
<span class="k">screen</span><span id="screen">&hellip;</span>
</div>
<footer>boot HTTP server &middot; replace this page or point kiosk_url elsewhere</footer>
<script>
// Live clock proves JS executes; the origin/href confirm where it was fetched.
function pad(n){ return String(n).padStart(2,'0'); }
function tick(){
var d = new Date();
document.getElementById('clock').textContent =
pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds());
}
setInterval(tick, 1000); tick();
document.getElementById('origin').textContent = location.host || '(file)';
document.getElementById('href').textContent = location.href;
document.getElementById('ua').textContent = navigator.userAgent;
document.getElementById('screen').textContent =
window.innerWidth + '×' + window.innerHeight;
</script>
</body>
</html>