diff --git a/CLAUDE.md b/CLAUDE.md index ccd0b53..30933b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,13 @@ instead of working around it. build; the supplicant config carries both options. - erofs module availability on the target RHEL kernel (fallback: journal-less ext4 in `extract-artifacts.sh`). +- NTP via DHCP option 42 → chrony (no public-pool egress): confirm on a booted + host `chronyc sources` lists the DHCP-handed lab server (not `*.pool.ntp.org`). + Assumes (a) NM exports the lease's NTP servers to the dispatcher as + `$DHCP4_NTP_SERVERS`, and (b) RHEL 9 `/etc/chrony.conf` honours + `sourcedir /run/chrony-dhcp` (the provisioner appends it if absent). If sources + are empty, the dispatcher didn't fire or the env var name differs — check + `journalctl -u NetworkManager` and `ls /run/chrony-dhcp/`. - `fleet` Plymouth theme: confirm (a) the script plugin package actually installed (`/usr/lib64/plymouth/script.so` — else the build fell back to `spinner`, logged as a WARNING), (b) the scripted theme + a font got bundled diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl index a3f445b..65727d4 100644 --- a/diskless.pkr.hcl +++ b/diskless.pkr.hcl @@ -262,6 +262,16 @@ build { # secure mode, reserving the physical NIC for wpa_supplicant. "chmod 0755 /usr/lib/dracut/modules.d/95dot1x-macsec/*.sh", + # ---- NTP from DHCP (option 42) -> chrony, no public-pool egress -------- + # NM dispatcher writes DHCP-provided NTP servers into chrony's sourcedir + # (replaces the removed dhclient.d/chrony.sh bridge). Strip the baked public + # pool so the host syncs ONLY from the lab NTP server handed via DHCP — the + # workstation never reaches *.pool.ntp.org. + "install -D -m0755 /tmp/etc-stage/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp /etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp", + "restorecon /etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp", + "sed -i -E 's/^[[:space:]]*(pool|server)[[:space:]]/#&/' /etc/chrony.conf", + "grep -q '^sourcedir /run/chrony-dhcp' /etc/chrony.conf || echo 'sourcedir /run/chrony-dhcp' >> /etc/chrony.conf", + # Inject the fleet NIC drivers into the dracut config "sed -i 's/@NIC_DRIVERS@/${var.nic_drivers}/' /etc/dracut.conf.d/90-diskless.conf", @@ -341,15 +351,18 @@ build { "install -D -m0644 /tmp/kiosk/weston.ini /etc/xdg/weston/weston.ini", # Chromium managed policy: stops the browser phoning home to Google # (sign-in, variations, optimization-guide, component/auto update, metrics, - # safe browsing). EPEL chromium reads /etc/chromium/policies/managed/*.json. + # safe browsing). The managed-policy dir differs across chromium builds + # (/etc/chromium vs /etc/chromium-browser), and a policy in the wrong dir is + # silently ignored — so install to BOTH. Verify at runtime via chrome://policy. # Pairs with the hardening flags in kiosk-session. "install -D -m0644 /tmp/kiosk/chromium-kiosk-policy.json /etc/chromium/policies/managed/10-kiosk-lockdown.json", + "install -D -m0644 /tmp/kiosk/chromium-kiosk-policy.json /etc/chromium-browser/policies/managed/10-kiosk-lockdown.json", # URL from the var (weston renderer is set via --use-pixman in the unit). "install -d -m0755 /etc/kiosk", "printf 'KIOSK_URL=%s\\n' \"$KIOSK_URL\" > /etc/kiosk/kiosk.env", "chmod 0644 /etc/kiosk/kiosk.env", # Label the new files so they survive into the enforcing erofs. - "restorecon -R /usr/local/bin/kiosk-session /etc/systemd/system/kiosk.service /etc/xdg/weston /etc/kiosk /etc/chromium", + "restorecon -R /usr/local/bin/kiosk-session /etc/systemd/system/kiosk.service /etc/xdg/weston /etc/kiosk /etc/chromium /etc/chromium-browser", # Boot graphical and start the kiosk (replaces the tty1 getty). "systemctl enable kiosk.service", "systemctl set-default graphical.target", diff --git a/docs/TODO.md b/docs/TODO.md index 5aa948d..8bf0148 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -155,15 +155,32 @@ Path to getting here (don't re-discover): avc`; the kiosk user is unconfined so likely none — but do NOT set permissive if they appear; add a policy module). Admin SSH (the key user) still works for debug even if the kiosk fails (it lands at graphical.target either way). -- **Phone-home lockdown (done 2026-06-17):** Chromium was dialing Google +- **Phone-home lockdown (done 2026-06-17/18):** Chromium was dialing Google (accounts/variations/optimization-guide/component+auto update/safe browsing — - seen in dnsmasq logs). Stopped via a managed policy - (`files/kiosk/chromium-kiosk-policy.json` → `/etc/chromium/policies/managed/`) - plus hardening flags in `kiosk-session` (`--disable-background-networking`, - `--disable-component-update`, `--disable-features=OptimizationHints,...`, etc.). - Verify with the dnsmasq query log after a rebuild — only `boot.fleet.lab` - (kiosk page) + chrony NTP should remain. NTP is the OS (chrony), not Chromium; - optionally point it at the serving host to fully contain egress. + seen in dnsmasq logs). Two layers: + - **Flags** in `kiosk-session` (`--disable-background-networking`, + `--disable-component-update`, `--disable-features=OptimizationHints,...`) — + confirmed killing variations/optimization-guide/update queries. + - **Managed policy** `files/kiosk/chromium-kiosk-policy.json` for the + policy-governed bits (sign-in `accounts.google.com`, sync). FIX 2026-06-18: + the managed-policy dir differs by build, so it's installed to BOTH + `/etc/chromium/policies/managed/` AND `/etc/chromium-browser/policies/managed/` + (a policy in the wrong dir is silently ignored — the reason + `accounts.google.com`/`android.clients.google.com` persisted). Verify via + `chrome://policy` (Status: OK per entry). + - **Serving-side sinkhole** (defense-in-depth, `serving/docker-compose.yml` + dnsmasq `--address=/google.com|googleapis.com|gstatic.com/0.0.0.0`): the + kiosk is local-only, so block egress at the resolver. Stops the *leak*; the + query still logs (answered 0.0.0.0, not forwarded) — eliminating the query + is the browser policy's job. Production: do this at the boot-LAN firewall. + - **NTP egress closed (done 2026-06-18):** the `*.pool.ntp.org` lookups were + the OS (chrony), not Chromium. Now: a `chrony` NTP server in the serving + stack (`serving/chrony/`), dnsmasq hands it via **DHCP option 42**, and the + image's NM dispatcher (`files/etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp`) + feeds it into chrony's `sourcedir /run/chrony-dhcp`; the baked public pool is + stripped from `/etc/chrony.conf`. Result: the workstation syncs time only + from the lab server — no external NTP egress. BENCH-VERIFY: on a booted host + `chronyc sources` shows the lab server (the DHCP one), not a public pool. - **Image size:** Chromium+Mesa add hundreds of MB to the *uncompressed* erofs — feeds the "shrink the rootfs" concern (and #7); reconsider whether a lighter browser/render path is viable. diff --git a/files/etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp b/files/etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp new file mode 100644 index 0000000..5941eb6 --- /dev/null +++ b/files/etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp @@ -0,0 +1,36 @@ +#!/bin/bash +# NetworkManager dispatcher: feed DHCP option 42 (NTP servers) into chrony. +# +# Replaces the old dhclient.d/chrony.sh bridge — we use NetworkManager's internal +# DHCP now (the network-manager dracut port), and NM exports the lease's NTP +# servers to dispatcher scripts as $DHCP4_NTP_SERVERS. chrony.conf carries +# `sourcedir /run/chrony-dhcp`, so writing .sources there and reloading +# makes chronyd use the DHCP-provided server. The image's baked public pool is +# stripped (see the provisioner), so the host syncs ONLY from the lab NTP server +# — no time egress from the workstation. +# +# Installed 0755 root:root at /etc/NetworkManager/dispatcher.d/. Lenient on +# purpose: a hiccup here must never break networking. + +interface="$1" +action="$2" + +case "$action" in + up|dhcp4-change|reapply) ;; + *) exit 0 ;; +esac + +dir=/run/chrony-dhcp +file="$dir/${interface}.sources" +mkdir -p "$dir" + +if [ -n "${DHCP4_NTP_SERVERS:-}" ]; then + : > "$file" + for s in $DHCP4_NTP_SERVERS; do + printf 'server %s iburst\n' "$s" >> "$file" + done + # Pick up the new sources without restarting chronyd. + chronyc reload sources >/dev/null 2>&1 || true +fi + +exit 0 diff --git a/files/kiosk/chromium-kiosk-policy.json b/files/kiosk/chromium-kiosk-policy.json index 1255fda..4d03e03 100644 --- a/files/kiosk/chromium-kiosk-policy.json +++ b/files/kiosk/chromium-kiosk-policy.json @@ -6,6 +6,7 @@ "DeviceMetricsReportingEnabled": false, "BrowserSignin": 0, + "SigninAllowed": false, "SyncDisabled": true, "SafeBrowsingProtectionLevel": 0, diff --git a/scripts/preview-plymouth.sh b/scripts/preview-plymouth.sh new file mode 100755 index 0000000..543b682 --- /dev/null +++ b/scripts/preview-plymouth.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# preview-plymouth.sh — fast local preview of the 'fleet' boot splash WITHOUT +# building/booting the full diskless image. The splash is just plymouthd + the +# theme + status tokens, so it runs on any Linux box with plymouth installed +# (root), driven by the same `plymouth update --status=` tokens the dracut hooks +# emit (dot1x-macsec-up.sh / nvmf-connect.sh). +# +# sudo bash scripts/preview-plymouth.sh [secure|test] +# +# Seeing it: +# - Windowed: needs the X11 renderer (Debian/Ubuntu: `apt install plymouth-x11`, +# run inside an X session). Fedora/RHEL often don't package it. +# - No X11 renderer: switch to a spare VT (Ctrl+Alt+F3), run there, watch, +# Ctrl+Alt+F1 back — plymouthd grabs DRM on that VT. +# - Headless/CI screenshot: Xvfb + x11 renderer + a grab tool. +# +# Iterate: edit files/plymouth/themes/fleet/fleet.script, re-run this. Seconds, +# not a 30-min image build. (On an already-booted diskless host the root overlay +# is writable, so you can scp the theme to /usr/share/plymouth/themes/fleet and +# run this there for a real distro/font/plugin stack.) +set -euo pipefail + +[ "$(id -u)" -eq 0 ] || { echo "run as root (plymouthd needs it)" >&2; exit 1; } + +mode=${1:-secure} +theme_src=${THEME_SRC:-files/plymouth/themes/fleet} +dest=/usr/share/plymouth/themes/fleet + +command -v plymouthd >/dev/null 2>&1 || { + echo "ERROR: plymouthd not found — install plymouth, plymouth-scripts," >&2 + echo " plymouth-plugin-label, and the script plugin first." >&2 + exit 1 +} +[ -f "$theme_src/fleet.script" ] || { + echo "ERROR: $theme_src/fleet.script not found (run from the repo root, or set THEME_SRC)." >&2 + exit 1 +} + +echo "==> installing theme: $theme_src -> $dest" +mkdir -p "$dest" +cp -a "$theme_src"/. "$dest"/ +plymouth-set-default-theme fleet 2>/dev/null || \ + echo "WARNING: plymouth-set-default-theme failed (plymouth-scripts missing?) — continuing" >&2 + +if [ ! -e /usr/lib64/plymouth/script.so ] && [ ! -e /usr/lib/plymouth/script.so ]; then + echo "WARNING: plymouth script plugin (script.so) not found — the theme will not render." >&2 +fi + +echo "==> starting plymouthd (debug log: /tmp/plymouth-preview.log)" +plymouth quit 2>/dev/null || true +plymouthd --no-daemon --debug --debug-file=/tmp/plymouth-preview.log & +ply_pid=$! +trap 'plymouth quit 2>/dev/null || true; kill "$ply_pid" 2>/dev/null || true' EXIT + +sleep 1 +plymouth show-splash +sleep 1 + +emit() { + echo " --status=$1" + plymouth update --status="$1" || true + sleep "${2:-1.2}" +} + +echo "==> driving the '$mode' handshake sequence" +if [ "$mode" = test ]; then + emit mode-test 0.6 + emit net-active; emit net-done + emit nvme-active; emit nvme-done 1.5 +else + emit tpm-active; emit tpm-done + emit dot1x-active 1.6; emit dot1x-done + emit macsec-active; emit macsec-done + emit net-active 1.6; emit net-done + emit nvme-active 1.6; emit nvme-done 2 +fi + +echo "==> sequence complete; press Enter to quit the splash" +read -r _ || true diff --git a/serving/README.md b/serving/README.md index 1cca9eb..fe299fe 100644 --- a/serving/README.md +++ b/serving/README.md @@ -61,6 +61,9 @@ 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 +# NTP: lab time server answers (diskless hosts get it via DHCP option 42) +chronyc -h ${SERVER_IP} tracking 2>/dev/null || ntpdate -q ${SERVER_IP} + # NVMe/TCP: target is listening + discoverable ss -lntp | grep 4420 sudo nvme discover -t tcp -a ${SERVER_IP} -s 4420 # lists the subsystem diff --git a/serving/chrony/Dockerfile b/serving/chrony/Dockerfile new file mode 100644 index 0000000..edbe610 --- /dev/null +++ b/serving/chrony/Dockerfile @@ -0,0 +1,9 @@ +# Minimal chrony NTP server for the lab boot LAN. Diskless workstations get this +# host as their time source via DHCP option 42 (dnsmasq), so they never reach the +# public NTP pool (no workstation egress). Config is bind-mounted from +# chrony.conf in docker-compose.yml. Build our own so the version is pinned. +FROM alpine:3.20 +RUN apk add --no-cache chrony +# -d: run in the foreground (don't daemonise) so the container stays up and logs +# to stdout for `docker compose logs`. +ENTRYPOINT ["chronyd", "-d", "-f", "/etc/chrony/chrony.conf"] diff --git a/serving/chrony/chrony.conf b/serving/chrony/chrony.conf new file mode 100644 index 0000000..33f0d7a --- /dev/null +++ b/serving/chrony/chrony.conf @@ -0,0 +1,20 @@ +# Lab NTP server (serving stack). Syncs from upstream and serves the boot LAN so +# diskless workstations get time INTERNALLY (handed out as DHCP option 42 by +# dnsmasq) instead of egressing to the public pool. Only this host reaches the +# internet for time; the workstations never do. + +# Upstream time source (this serving host has internet; the workstations don't). +pool pool.ntp.org iburst + +# Serve the boot LAN. This is an isolated lab/boot network, so allow all; scope +# to your subnet (e.g. `allow 192.168.50.0/24`) for anything less trusted. +allow all + +# Serve our own clock at a high stratum if upstream is unreachable, so a diskless +# boot never blocks waiting for time. +local stratum 10 + +# Step the clock on large initial offsets (containers can start far off), then +# slew. driftfile is best-effort (container fs is ephemeral). +makestep 1.0 3 +driftfile /var/lib/chrony/drift diff --git a/serving/docker-compose.yml b/serving/docker-compose.yml index 793648c..03c4805 100644 --- a/serving/docker-compose.yml +++ b/serving/docker-compose.yml @@ -63,11 +63,24 @@ services: # resolve the per-environment DNS name to this serving host. - --address=/${NVME_HOST}/${SERVER_IP} - --address=/${BOOT_HOST}/${SERVER_IP} + # Kiosk is LOCAL-ONLY (loads boot.fleet.lab), so sinkhole Chromium's + # call-home to 0.0.0.0 — telemetry can't leave the lab even if the browser + # tries. Defense-in-depth behind the in-image Chromium policy/flags; in + # production this belongs in the boot-LAN firewall's egress rules. NOTE: the + # client still *emits* the query (you'll see it in this log answered locally, + # not "forwarded"); stopping the query itself is the browser policy's job. + - --address=/google.com/0.0.0.0 + - --address=/googleapis.com/0.0.0.0 + - --address=/gstatic.com/0.0.0.0 # ---- DHCP ---- - --dhcp-authoritative - --dhcp-range=${DHCP_RANGE_START},${DHCP_RANGE_END},${DHCP_LEASE} - --dhcp-option=option:router,${GATEWAY_IP} - --dhcp-option=option:dns-server,${SERVER_IP} + # NTP server (option 42): point diskless hosts at the lab 'ntp' service + # below so they sync time internally and never reach the public NTP pool. + # The image's NM dispatcher (10-chrony-dhcp-ntp) feeds this into chrony. + - --dhcp-option=option:ntp-server,${SERVER_IP} # ---- UEFI HTTP Boot ---- # Firmware sends vendor-class "HTTPClient"; we must echo it back (opt 60) # and hand over the UKI URL (opt 67). IP in the URL avoids a DNS round-trip @@ -96,6 +109,20 @@ services: # 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. + # --- NTP server (serves time to the boot LAN; handed out via DHCP opt 42) --- + ntp: + build: ./chrony + network_mode: host # serve NTP on udp/123 across the boot LAN + cap_add: + - SYS_TIME # discipline the (host-shared) clock it serves + restart: unless-stopped + volumes: + - ./chrony/chrony.conf:/etc/chrony/chrony.conf:ro + # NOTE: with host networking + SYS_TIME this chronyd disciplines the Docker + # HOST clock (the clock isn't namespaced) — intended: the serving host is the + # lab time source. Drop SYS_TIME (and add `chronyd -x`) if the host already + # runs its own NTP and you only want this to relay. + # --- NVMe/TCP target (serves rootfs.erofs, read-only) ---------------------- nvmeof: # Prebuilt by CI (.gitea/workflows/spdk-image.yaml) and pulled here — no