# ============================================================================= # Diskless fleet image build # # Builds a RHEL9-family root filesystem + fleet-generic initramfs for # PXE/HTTP-booted diskless hosts authenticating with 802.1X EAP-TLS # (TPM-held key, cert in TPM NV) and MACsec link encryption. # Root filesystem: erofs + dm-verity over a shared read-only NVMe/TCP # namespace, with a tmpfs overlay for runtime state. # # Outputs: # ${var.artifact_dir}/${var.image_version}/ # vmlinuz, initramfs.img - intermediate (consumed by ukify) # fleet-.efi - signed UKI (kernel+initramfs+cmdline+roothash) # rootfs.erofs - erofs + appended verity tree -> nvmet RO namespace # verity.json, cmdline.txt, SHA256SUMS # OCI image ${var.oci_image}:${var.image_version} (FROM scratch) # /artifacts/{fleet-.efi, rootfs.erofs, verity.json, SHA256SUMS} # = the atomic fleet release unit; deploy as initContainer / image volume # feeding the SPDK + HTTPS serving containers, pinned by digest. # # Usage: # packer init . # packer build -var iso_url=... -var iso_checksum=... . # ============================================================================= packer { required_version = ">= 1.10.0" required_plugins { qemu = { source = "github.com/hashicorp/qemu" version = ">= 1.1.0" } } } # ------------------------------------------------------------------ variables variable "iso_url" { type = string description = "RHEL 9 / Rocky 9 / Alma 9 install ISO URL or local path. The minimal ISO is sufficient: ks.cfg installs only core+sshd from it and the provisioner pulls the diskless tooling from the online repos. Defaults to the fast internal mirror (version-pinned to match iso_checksum)." default = "http://iso-server.bitasylum.org/isos/Rocky-9.8-x86_64-minimal.iso" } variable "iso_checksum" { type = string # Pinned sha256 of Rocky-9.8-x86_64-minimal.iso on the internal mirror. # When moving to a new point release, bump iso_url AND this hash together: # curl -fsSL http://iso-server.bitasylum.org/isos/Rocky--x86_64-minimal.iso | sha256sum # (or `sha256sum` the file on iso-server). Set "none" to skip verification. description = "ISO checksum as sha256: (or file:, or none)." default = "sha256:d338032cd1cdd41c67139f2f71b4c832c8e4a21943106519db9c7137df7a63d4" } variable "image_version" { type = string description = "Version stamp for the published artifact directory" default = "0.1.0" } variable "artifact_dir" { type = string description = "Where extracted boot artifacts and rootfs tar are written" default = "artifacts" } variable "nic_drivers" { type = string description = "Kernel modules for the fleet's real NICs (build VM only has virtio!). e1000e covers Intel I21x onboard NICs (e.g. EliteBook 820 G4 = I219-LM) for laptop/desktop bench hosts; igb/igc/ixgbe/i40e/ice/mlx5_core cover server NICs. Firmware uses its own driver to PXE/HTTP boot, but the initramfs needs the matching module here to bring the link up in-OS for 802.1X/DHCP/NVMe." default = "e1000e igb igc ixgbe i40e ice mlx5_core" } variable "build_password" { type = string description = "Throwaway root password used only during the Packer build (must match http/ks.cfg)" default = "packer-build-only" sensitive = true } variable "oci_image" { type = string description = "OCI image ref for the release-artifact image (FROM scratch, holds UKI + rootfs.erofs)" default = "registry.example.org/fleet-boot" } variable "oci_push" { type = bool description = "Push the release-artifact image to the registry after building" default = false } variable "disable_dot1x" { type = bool description = "TEST ONLY: bake rd.dot1x=0 into the UKI cmdline so the host boots with 802.1X + MACsec disabled (open-port NVMe-oF boot test). Leave false for any real/secure build." default = false } variable "nvmet_traddr" { type = string description = "NVMe/TCP discovery address baked into the UKI cmdline (rd.nvmf.discover). Use a per-environment DNS name resolved at boot so one UKI works everywhere; the lab serving stack's dnsmasq resolves this name to the serving host. Immutable once the UKI is signed." default = "nvme.fleet.lab" } variable "enrol_url" { type = string description = "Enrolment broker base URL baked into the UKI cmdline (rd.fleet.enrol_url) for the inline empty-NV first-boot enrolment (docs/tpm-enrolment-design.md). A fresh host reaches this pre-auth to get its TPM identity signed; hosts that already hold a cert never use it. Per-environment DNS name resolved at boot (the serving nginx reverse-proxies /enrol/ to the broker). Immutable once the UKI is signed." default = "http://boot.fleet.lab/enrol" } variable "ssh_authorized_key" { type = string description = "Single SSH public key authorized for the sudo-enabled admin user. Fleet-generic (same on every host — not per-host state). Baked into the immutable rootfs, so rotating it needs a rebuild. EMPTY = no admin user is created and root is left UNLOCKED (lab convenience, avoids a no-way-in image)." default = "" } variable "admin_user" { type = string description = "Username of the sudo-enabled, key-only admin account baked into the image (wheel group, NOPASSWD sudo since the account has no password). Created only when ssh_authorized_key is set." default = "fleetadmin" } variable "enable_kiosk" { type = bool description = "Build-time feature flag for the graphical kiosk (weston + Chromium + mesa). false (default, current) skips the kiosk provisioner entirely — much faster builds and a far smaller image — while iterating on the boot/network stack. Set true to bake the kiosk back in. (kiosk_url=\"\" also skips it; this flag lets you disable without losing the configured URL.)" default = true } variable "kiosk_url" { type = string 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 = "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 "enable_stig" { type = bool description = "true (default): apply DISA STIG hardening (OpenSCAP + SSG) with a diskless/UKI/kiosk tailoring (files/stig/diskless-tailoring.xml), bake STIG kernel params into the UKI cmdline, and emit a deviations report. false: skip hardening entirely — escape hatch if a build regresses the boot/secure-path bench. FIPS is OFF and audit offload is LOCAL-only regardless (see docs/stig-deviations.md)." default = true } variable "debug_shell" { type = bool description = "DEBUG/LAB ONLY. true: enable systemd's debug-shell.service — a passwordless root /bin/sh on tty2 (Ctrl+Alt+F2) that bypasses login/PAM entirely. (Placed on tty2, with the login getty there masked, because constrained consoles often expose only tty1-6; tty1 stays the kiosk.) Use to get into a hardened image when login is broken (locked root + key-only accounts). This is a deliberate security hole; NEVER ship it. Default false." default = true } variable "audit_host" { type = string description = "Remote rsyslog/audit collector the image forwards auditd (via audisp-syslog) + the journal (rsyslog imjournal) to. Host-generic DNS name resolved per-environment by the serving dnsmasq to the collector (serving/audit-collector); matches serving/.env AUDIT_HOST. The diskless root is a RAM-only overlay, so local logs die on reboot — this ships them off-box. Empty = local-only (no remote offload)." default = "audit.fleet.lab" } variable "sb_key" { type = string description = "Secure Boot signing key (PEM) for the UKI. Empty = unsigned lab build. Production: CI/HSM-held; never enters the build VM." default = "" sensitive = true } variable "sb_cert" { type = string description = "Secure Boot signing certificate (PEM) matching sb_key" default = "" } # --------------------------------------------------------------------- source source "qemu" "diskless" { iso_url = var.iso_url iso_checksum = var.iso_checksum output_directory = "output-qemu" vm_name = "diskless-build.qcow2" disk_size = "12G" disk_interface = "virtio" net_device = "virtio-net-pci" format = "qcow2" accelerator = "kvm" memory = 8192 cpus = 4 headless = true vnc_bind_address = "0.0.0.0" qemuargs = [["-cpu", "host"], [ "-smp", "cpus=4,cores=4,sockets=1" ], [ "-serial", "stdio" ]] http_directory = "http" # BIOS/isolinux boot. For an OVMF/UEFI build VM, replace with the grub # variant: ["c", "linuxefi /images/pxeboot/vmlinuz inst.text ", # "inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg", # "initrdefi /images/pxeboot/initrd.img", "boot"] boot_wait = "5s" boot_command = [ "", " inst.text inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg", "", ] ssh_username = "root" ssh_password = var.build_password ssh_timeout = "60m" shutdown_command = "shutdown -P now" } # ---------------------------------------------------------------------- build build { sources = ["source.qemu.diskless"] # --- 1. Stage the dracut module, configs, and CA placeholder ------------- # Pre-create the staging dir. Packer's directory-contents upload (a file # provisioner whose source ends in "/") requires the destination to already # exist; otherwise the uploaded tree lands outside /tmp/etc-stage and the # install steps below can't find it. provisioner "shell" { inline = ["mkdir -p /tmp/etc-stage"] } provisioner "file" { source = "files/dracut/95dot1x-macsec" destination = "/usr/lib/dracut/modules.d/" } provisioner "file" { source = "files/etc/" destination = "/tmp/etc-stage" } # Kiosk unit + session wrapper (installed by the kiosk provisioner below). # Dir-without-trailing-slash upload -> /tmp/kiosk/ (mirrors the dracut module # upload form, which stages reliably). provisioner "file" { source = "files/kiosk" destination = "/tmp/" } # Workstation host-info API (Go). Source staged to /tmp/hostapi; compiled + # installed in the 2b-hostapi step. The kiosk SPA reads it at localhost:9099. provisioner "file" { source = "files/hostapi" destination = "/tmp/" } # fleet-enrol + fleet-hostapi static binaries, built OUT OF THE VM on the CI # runner (the "Build host Go binaries" step) so the Rocky build image never # carries the Go toolchain / vendored go-tpm. Staged here; installed to # /usr/sbin (enrol, before the initramfs build) and /usr/local/bin (hostapi, 2b). provisioner "file" { source = "files/enrol/bin/fleet-enrol" destination = "/tmp/fleet-enrol" } provisioner "file" { source = "files/hostapi/bin/fleet-hostapi" destination = "/tmp/fleet-hostapi" } # Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to # /tmp/plymouth, installed + set default in the package step before dracut runs. provisioner "file" { source = "files/plymouth" destination = "/tmp/" } # Secure Boot enrollment helper + PUBLIC PK/KEK/db .auth files. Staged to # /tmp/secureboot; installed in the package step. The helper (fleet-sb-enroll) # enrolls the keys into firmware via efivarfs over SSH (EL9 has no efitools). # keys/ may be empty (the *.auth are gitignored / CI-materialized); the helper # then reports "no baked keys" until they're supplied. See docs/secure-boot-keys.md. provisioner "file" { source = "files/secureboot" destination = "/tmp/" } # --- 2. Packages, configs, diskless adaptations -------------------------- provisioner "shell" { inline = [ "set -euxo pipefail", # No dhcp-client: the initramfs now uses the 'network-manager' dracut # module (90-diskless.conf), not 'network-legacy'/dhclient. NM renews the # DHCP lease in the running system after switch-root, fixing the lease- # expiry outage (legacy's one-shot lease was never renewed -> address # dropped -> NVMe/TCP root died). NetworkManager is already pulled in by # @^minimal-environment, so nothing extra is needed for it here. # zstd: dracut compresses the initramfs with compress="zstd" # (90-diskless.conf); the minimal ISO lacks the zstd binary, so without it # dracut warns and silently falls back to gzip (a larger initramfs). # tpm2-tools: tpm2_nvread reads the client cert from NV on the secure path. # (First-boot ENROLMENT no longer needs tpm2-tools / an openssl tpm2 provider # / curl in the image — that's the out-of-VM fleet-enrol Go binary, which # talks to the TPM directly via go-tpm. So this list stays at the baseline.) "dnf -y install wpa_supplicant tpm2-tools tpm2-tss nvme-cli cryptsetup dracut-network NetworkManager zstd openssl", # Locale trim: @^minimal-environment pulls glibc-all-langpacks — ~228 MB of # locale data for EVERY language (measured 2026-07-03, 3rd-largest package in # the whole image). This fleet is English-only (we also strip # /usr/share/locale in 2d). Swap it for the English langpack + the C/POSIX # minimal set → reclaims ~220 MB of the read-only erofs. Install the small # replacements first, then drop the monolith (nothing Requires it; strict so # a surprise dep surfaces in CI rather than silently bloating the image). "dnf -y install glibc-minimal-langpack glibc-langpack-en", "dnf -y remove glibc-all-langpacks", # EFI / Secure Boot management tools (efivar, mokutil, efibootmgr — all base # repos). Used over SSH on a workstation: `mokutil --sb-state/--pk/--kek/--db` # reads Secure Boot state + the enrolled PK/KEK/db; efibootmgr manages boot # entries. They act on efivarfs (/sys/firmware/efi/efivars), NOT the # read-only root, so they work at runtime; RPM-installed => fapolicyd-trusted. # NOTE: efitools (efi-updatevar/efi-readvar/KeyTool) is NOT packaged for EL9 # (confirmed 2026-06-20), so OS-side *enrollment* of custom PK/KEK/db isn't # available on the image — enroll via the firmware setup UI (load-from-file) # instead. See docs/secure-boot-keys.md; the auto-enroll path (TODO #10) # can't rely on KeyTool here and would need it built/sourced separately. # e2fsprogs gives `chattr`, which fleet-sb-enroll uses to clear the immutable # flag on existing EFI vars before overwriting them via efivarfs. "dnf -y install efivar mokutil efibootmgr e2fsprogs", # Secure Boot enrollment helper + PUBLIC PK/KEK/db .auth files. The helper # (fleet-sb-enroll) enrolls the keys into firmware via efivarfs over SSH on a # Setup-Mode workstation (EL9-native, no efitools). Non-RPM exec => add to # fapolicyd trust in 2c-STIG-postfix (like kiosk-session). keys/ may have no # .auth (gitignored / CI-materialized) — then the helper reports no baked keys. "install -D -m0755 /tmp/secureboot/fleet-sb-enroll /usr/local/bin/fleet-sb-enroll", "install -d -m0755 /usr/share/fleet-secureboot", "for f in /tmp/secureboot/keys/*.auth; do [ -e \"$f\" ] && install -m0644 \"$f\" /usr/share/fleet-secureboot/ || echo 'NOTE: no Secure Boot .auth baked (supply via files/secureboot/keys/ or CI secrets)'; done", "restorecon -R /usr/local/bin/fleet-sb-enroll /usr/share/fleet-secureboot 2>/dev/null || true", "rm -rf /tmp/secureboot", # Plymouth boot splash. The custom scripted 'fleet' theme renders the # security-handshake checklist driven by the dracut hooks # (`plymouth update --status=...`). plymouth-plugin-label gives Image.Text # its font rendering; dejavu fonts back it. The script-plugin package name # has varied across releases, so install it best-effort and FALL BACK to the # stock 'spinner' theme if script.so is absent — never break the build over # the theme. Set the default theme BEFORE the dracut build below so the # (--no-hostonly) initramfs bundles the theme + its plugin + a font; no -R # (we rebuild the initramfs explicitly). Whether the splash shows at boot is # gated by the cmdline (boot_splash var), not by installing this. "dnf -y install plymouth plymouth-scripts plymouth-plugin-label dejavu-sans-fonts", "dnf -y install plymouth-plugin-script 2>/dev/null || dnf -y install plymouth-theme-script 2>/dev/null || true", "cp -a /tmp/plymouth/themes/fleet /usr/share/plymouth/themes/fleet", "if [ -e /usr/lib64/plymouth/script.so ]; then plymouth-set-default-theme fleet; echo '==> plymouth theme: fleet (scripted security splash)'; else dnf -y install plymouth-theme-spinner && plymouth-set-default-theme spinner; echo 'WARNING: plymouth script plugin (script.so) missing — using spinner; fleet security splash unavailable' >&2; fi", "rm -rf /tmp/plymouth", # 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/wpa_supplicant-dot1x.conf /etc/dot1x/wpa_supplicant-dot1x.conf", "install -D -m0644 /tmp/etc-stage/dracut.conf.d/90-diskless.conf /etc/dracut.conf.d/90-diskless.conf", # NB: the old static /etc/NetworkManager/conf.d/99-dot1x-unmanaged.conf is # GONE. It can't be mode-aware (it wrongly unmanaged en* in test mode too, # blocking renewal). The 95dot1x-macsec cmdline hook nm-unmanaged.sh now # writes /run/NetworkManager/conf.d/10-dot1x-unmanaged.conf at boot, only in # 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", # ---- Diskless adaptations ------------------------------------------ # Per-host machine-id is generated at boot into the overlay "truncate -s 0 /etc/machine-id", # No per-host hostname in the image (image stays fleet-generic): empty # /etc/hostname so the static hostname is unset and NetworkManager sets the # transient hostname from the DHCP lease — DHCP option 12 (the dnsmasq # fleet-namer assigns a creative name per MAC, server-side) or a reverse-DNS # PTR lookup. hostname-mode=default makes that explicit so a stricter global # config can't switch it to 'none' and leave hosts unnamed. "truncate -s 0 /etc/hostname", "install -D -m0644 /tmp/etc-stage/NetworkManager/conf.d/20-hostname.conf /etc/NetworkManager/conf.d/20-hostname.conf", "restorecon /etc/NetworkManager/conf.d/20-hostname.conf", # Subscription-manager has no role on a booted, hostname-neutral, immutable # appliance — entitlements only matter at BUILD time (dnf). Left running, # rhsmcertd wakes periodically and tries to write Python __pycache__ into a # bin_t dir on the read-only/overlay root: SELinux denies it (enforcing), so # it's a benign but RECURRING AVC that floods the SIEM (fleet_audit rule # 100120) and can never succeed (tmpfs overlay is wiped each boot). Mask the # daemon + timer to kill the noise and trim attack surface. (Don't waive the # AVC in Wazuh — that would also hide future rhsmcertd_t denials.) "systemctl mask rhsmcertd.service 2>/dev/null || true", "systemctl disable rhsmcertd.service 2>/dev/null || true", # Read-only erofs/verity root; runtime state on a tmpfs overlay provided # by systemd.volatile=overlay (set in the cmdline by extract-artifacts.sh). # RHEL/Rocky dracut has no 'overlayfs'/'rootovl' module. "printf '%s\\n' '# diskless: root is provided by netroot; no local mounts' > /etc/fstab", # No local bootloader regeneration wanted on kernel updates inside CI "command -v grub2-mkconfig >/dev/null && echo 'GRUB updates not used on diskless hosts' || true", # Install the out-of-VM-built enrolment binary BEFORE the initramfs build so # the dracut module (95dot1x-macsec) can inst it into the initramfs. Non-RPM # exec => fapolicyd-trusted in 2c-STIG-postfix (like kiosk-session/hostapi). "install -D -m0755 /tmp/fleet-enrol /usr/sbin/fleet-enrol", "restorecon /usr/sbin/fleet-enrol 2>/dev/null || true", # ---- Firmware slimming (BEFORE dracut so the initramfs shrinks too) --- # linux-firmware ships ~678 MB of blobs for hardware this fixed fleet will # never have. A live fleet audit (`modinfo -F firmware` over loaded modules + # sysfs/`lspci` driver bindings on the real 820-class hosts AND the VMs, # 2026-07-03) proved the ONLY loadable firmware any host requests is Intel # i915 (GPU); every NIC is firmware-free (e1000e's ethtool "firmware-version" # is on-chip NVM, not a /usr/lib/firmware file; virtio_net loads nothing), so # NO NIC needs firmware. This runs PRE-dracut deliberately: --no-hostonly + # plymouth's `drm` dep instmods a broad DRM set (i915/amdgpu/radeon/…) and # dracut bundles each module's firmware into the initramfs (amdgpu fw is the # classic UKI bloater), so trimming FIRST shrinks BOTH the rootfs erofs AND # the UKI's initramfs. Keep i915 (all Intel gens) + intel/ (ice DDP for the # ice/mlx5 server-NIC build variant + small Intel blobs) + mellanox + # intel-ucode; strip the rest (~600 MB). Safe for the initramfs: the fleet # splash GPU (i915) fw is kept and the build-VM splash uses virtio-gpu/bochs # (no fw) — dracut just warns "firmware not found" for the removed drm modules # and continues. Risk asymmetry: missing NIC fw would brick the fleet (none # needed), missing GPU fw only degrades a display. BENCH-VERIFY the kiosk # renders on the 820. Strip-in-place (rm, not rpm -e): linux-firmware can't be # dropped as a package without also losing i915. See docs/TODO #18. "if [ -d /usr/lib/firmware ]; then find /usr/lib/firmware -mindepth 1 -maxdepth 1 ! -name i915 ! -name intel ! -name mellanox ! -name intel-ucode -exec rm -rf {} + ; fi", # Wi-Fi firmware: wired-only fleet (no iwlwifi). Remove the packages cleanly # so the rpmdb stays honest (the find above dropped their iwlwifi-*.ucode). "dnf remove -y 'iwl*-firmware' 2>/dev/null || true", "echo \"==> firmware after strip: $(du -sh /usr/lib/firmware 2>/dev/null | cut -f1) (was ~678 MB)\"", # ---- Build the fleet-generic initramfs ------------------------------ # %%{ escapes the rpm query tags so Packer's HCL template engine treats # them as literals (a bare %{...} is parsed as a template directive). "KVER=$(rpm -q --qf '%%{VERSION}-%%{RELEASE}.%%{ARCH}\\n' kernel | sort -V | tail -1)", "dracut -f --no-hostonly --kver \"$KVER\" /boot/initramfs-generic.img", # Capture the listing to a file and grep THAT — never `lsinitrd | grep -q`. # grep -q closes the pipe on first match, lsinitrd dies of SIGPIPE, and # under pipefail the pipeline reports failure even when the file is # present (a false negative that wrongly failed the build for many runs). "lsinitrd /boot/initramfs-generic.img > /tmp/lsinitrd.txt", # Initramfs footprint (this .img is embedded in the signed UKI and pulled # over PXE/HTTP on every boot). Report the built size + any firmware that # still made it in — post-strip this should be i915 only (pulled by # plymouth's drm dep); if amdgpu/radeon/etc. appear, the strip didn't run # before dracut. See docs/TODO #18. "echo \"==> initramfs size: $(du -h /boot/initramfs-generic.img | cut -f1)\"", "echo '==> firmware bundled in initramfs (expect i915 only):'; grep -i '/firmware/' /tmp/lsinitrd.txt || echo ' (none)'", "grep -E 'macsec|wpa_supplicant|tpm2_nvread|nvme.tcp|erofs|veritysetup' /tmp/lsinitrd.txt", # Assert NetworkManager is in the initramfs — the network-manager dracut # module is what renews the DHCP lease post-switch-root. A missing/renamed # network module would silently regress to no-renewal, so fail loudly. "grep -q 'NetworkManager' /tmp/lsinitrd.txt || { echo 'ERROR: NetworkManager missing from initramfs (network-manager dracut module not installed)' >&2; exit 1; }", # Assert the secure-mode NM unmanaged hook + test-mode NVMe-oF connect unit # made it in (systemd-driven initramfs → the connect is a systemd unit, not # 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 'nvmf-connect.service' /tmp/lsinitrd.txt || { echo 'ERROR: nvmf-connect.service missing from initramfs' >&2; exit 1; }", # Assert the inline empty-NV enrolment path is present: the gate hook + the # out-of-VM fleet-enrol binary (Go + go-tpm; it does key-gen/CSR/NV directly, # so no tpm2-tools/openssl-provider/curl are needed in the initramfs). A fresh # host silently unable to enrol is a bricked host. "grep -q 'tpm-enrol.sh' /tmp/lsinitrd.txt || { echo 'ERROR: tpm-enrol.sh hook missing from initramfs' >&2; exit 1; }", "grep -q 'fleet-enrol' /tmp/lsinitrd.txt || { echo 'ERROR: fleet-enrol binary missing from initramfs (out-of-VM build not staged?)' >&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 --------------------------------------------------- "dnf clean all", "rm -rf /tmp/etc-stage /var/log/anaconda /root/anaconda-ks.cfg", "truncate -s 0 /var/log/messages /var/log/secure 2>/dev/null || true", ] } # --- 2b. Kiosk mode: Wayland (weston kiosk-shell) + fullscreen Chromium -- # Post-pivot graphical kiosk (no initramfs impact). Baked into the immutable # root, fleet-generic. Empty kiosk_url skips it. EPEL is enabled for weston + # chromium — a supply-chain surface (accepted for the lab; see TODO #4). # weston kiosk-shell is used because cage isn't packaged for EL9; weston 8.0 # (EPEL 9) ships kiosk-shell.so and is far lighter than GDM+gnome-kiosk. # NOTE: pulls in Chromium + Mesa + weston (~hundreds of MB) — grows the # uncompressed erofs namespace noticeably. Graphical bring-up needs a display # to verify; likely tweak points: chromium ozone/wayland, --disable-gpu vs a # real GPU, /dev/dri present in the VM, and SELinux AVCs for the GUI stack. provisioner "shell" { environment_vars = [ "ENABLE_KIOSK=${var.enable_kiosk}", "KIOSK_URL=${var.kiosk_url}", ] inline = [ "set -e", "if [ \"$ENABLE_KIOSK\" != \"true\" ]; then echo 'enable_kiosk=false -> skipping kiosk install (smaller/faster build).'; exit 0; fi", "if [ -z \"$KIOSK_URL\" ]; then echo 'kiosk_url empty -> skipping kiosk install.'; exit 0; fi", "echo '==> Enabling EPEL + installing kiosk stack (weston, chromium, mesa)'", "dnf install -y epel-release", # tsflags=nodocs: the kickstart's --excludedocs only covers the anaconda # install, not these provisioner transactions; without it chromium/mesa lay # down man/doc into the read-only erofs. (mesa-dri-drivers + llvm-libs are # the heaviest items here and are likely unnecessary given weston # --use-pixman + chromium --disable-gpu — candidate to drop once the kiosk # is display-verified; see TODO #4.) # # install_weak_deps=False: a locked-down single-URL kiosk needs none of the # Recommends: this transaction otherwise drags in — flatpak + its ostree/ # bubblewrap sandbox runtime, tracker-miners (a filesystem indexer, both # dead weight AND a threat surface), the pipewire/pulseaudio audio stack, # and libproxy-webkitgtk4 → webkit2gtk3-jsc (a whole SECOND browser engine # pulled into a Chromium kiosk just for libproxy's PAC runner). Measured: # the default (weak-deps=on) transaction was 229 pkgs / 1.0 GB. The floor # that survives is chromium + chromium-common + llvm-libs (mesa sw-GL) + # gtk3/adwaita + libicu — all HARD deps. If a genuine runtime dep turns out # to be a weak dep (e.g. a font, or libva for VA-API on the GPU path), add # it to the explicit list below rather than re-enabling weak deps wholesale. "dnf install -y --setopt=tsflags=nodocs --setopt=install_weak_deps=False weston chromium mesa-dri-drivers mesa-libEGL mesa-libgbm dejavu-sans-fonts", # Footprint audit (surfaces in the CI build log): the installed set, largest # first, + a total count. Turns 'seems unnecessary' into data for the next # trim round (e.g. dropping gstreamer/media codecs if the SPA plays no HTML5 # media, or mesa-dri-drivers if the GPU path is abandoned). %%{...} is escaped # for Packer's HCL2 template engine (a bare %%{ is parsed as a directive). "echo '==> kiosk package footprint (installed size, largest 40 first):'", "rpm -qa --qf '%%{SIZE}\\t%%{NAME}-%%{VERSION}-%%{RELEASE}\\n' | sort -rn | head -40", "echo \"==> total packages installed after kiosk: $(rpm -qa | wc -l)\"", # Unprivileged, passwordless kiosk user. Groups: 'video' (KMS /dev/dri/card0 # for weston) + 'input' (libinput) as a seat fallback; 'render' # (/dev/dri/renderD128) so Chromium's GPU process can do hardware raster on # real hardware (kiosk-session enables GPU when a real KMS driver is present; # render nodes aren't logind-seat-ACL'd on EL9, so group membership is needed). "id kiosk >/dev/null 2>&1 || useradd -m -G video,input kiosk", "getent group render >/dev/null 2>&1 && usermod -aG render kiosk || true", # Install the staged unit, weston config, and session wrapper. "install -D -m0755 /tmp/kiosk/kiosk-session /usr/local/bin/kiosk-session", "install -D -m0644 /tmp/kiosk/kiosk.service /etc/systemd/system/kiosk.service", # Dedicated PAM stack for the autologin session (PAMName=kiosk). Decouples # the passwordless kiosk user from the STIG-hardened login/system-auth # account phase (faillock + aging) that would otherwise deny the session. "install -D -m0644 /tmp/kiosk/kiosk.pam /etc/pam.d/kiosk", "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). 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/pam.d/kiosk /etc/xdg/weston /etc/kiosk /etc/chromium /etc/chromium-browser", # Enable the kiosk on multi-user.target (its [Install] WantedBy) so it # starts on a normal boot and takes tty1 from the getty (Conflicts=). No # set-default graphical.target: the kiosk needs no display-manager target, # and relying on graphical.target left it dead at boot. "systemctl enable kiosk.service", "rm -rf /tmp/kiosk", "echo \"==> kiosk enabled: $KIOSK_URL\"", ] } # --- 2b-hostapi. Workstation host-info API (Go, loopback, read-only) ------ # Installs the OUT-OF-VM-built /usr/local/bin/fleet-hostapi (the "Build host Go # binaries" CI step; staged to /tmp/fleet-hostapi) and enables it as a systemd # service the kiosk SPA reads at http://localhost:9099/v1/host (the HOST-API.md # contract). Build-time, image-GENERIC facts (image version + weston/chromium # versions) are written to /etc/fleet-hostinfo.json; everything host-specific # (kernel, hostname, IP, MAC, board, uptime) is read at RUNTIME, so NO per-host # state lands in the image (invariant #1). No Go toolchain in the VM anymore — # the reproducible static build happens on the CI runner (like fleet-enrol). provisioner "shell" { environment_vars = [ "IMAGE_VERSION=${var.image_version}", ] inline = [ "set -euxo pipefail", "install -D -m0755 /tmp/fleet-hostapi /usr/local/bin/fleet-hostapi", "install -D -m0644 /tmp/hostapi/fleet-hostapi.service /etc/systemd/system/fleet-hostapi.service", # Build-time facts file (image-generic; same for every host of a release). # rpm '%%{...}' is doubled so Packer's HCL2 doesn't parse %{ as a directive. "WESTON_VER=$(rpm -q --qf '%%{VERSION}' weston 2>/dev/null || echo '')", "CHROMIUM_VER=$(rpm -q --qf '%%{VERSION}' chromium 2>/dev/null || rpm -q --qf '%%{VERSION}' chromium-browser 2>/dev/null || echo '')", "printf '{\"image\":\"%s\",\"weston\":\"%s\",\"chromium\":\"%s\"}\\n' \"$IMAGE_VERSION\" \"$WESTON_VER\" \"$CHROMIUM_VER\" > /etc/fleet-hostinfo.json", "chmod 0644 /etc/fleet-hostinfo.json", "restorecon -R /usr/local/bin/fleet-hostapi /etc/systemd/system/fleet-hostapi.service /etc/fleet-hostinfo.json 2>/dev/null || true", "systemctl enable fleet-hostapi.service", "rm -rf /tmp/hostapi /tmp/fleet-hostapi", "echo '==> fleet-hostapi enabled (localhost:9099/v1/host)'", ] } # --- 2c. Admin access: sudo-enabled key user + locked root -------------- # Fleet-generic (same user + key on every host => NOT per-host state). The # key comes from var.ssh_authorized_key (CI secret). If no key is supplied we # skip the whole thing AND leave root unlocked, so a lab build can't end up # with no way in. Runs LAST: locking root is safe because Packer drives # shutdown over the already-authenticated SSH session (no re-auth as root). provisioner "shell" { environment_vars = [ "SSH_AUTHORIZED_KEY=${var.ssh_authorized_key}", "ADMIN_USER=${var.admin_user}", ] inline = [ "set -e", "if [ -z \"$SSH_AUTHORIZED_KEY\" ]; then echo 'WARNING: ssh_authorized_key empty -> not creating admin user, leaving root UNLOCKED (lab build).'; exit 0; fi", "id \"$ADMIN_USER\" >/dev/null 2>&1 || useradd -m -G wheel \"$ADMIN_USER\"", "install -d -m0700 -o \"$ADMIN_USER\" -g \"$ADMIN_USER\" \"/home/$ADMIN_USER/.ssh\"", "printf '%s\\n' \"$SSH_AUTHORIZED_KEY\" > \"/home/$ADMIN_USER/.ssh/authorized_keys\"", "chmod 0600 \"/home/$ADMIN_USER/.ssh/authorized_keys\"", "chown \"$ADMIN_USER:$ADMIN_USER\" \"/home/$ADMIN_USER/.ssh/authorized_keys\"", "restorecon -R \"/home/$ADMIN_USER/.ssh\"", # Key-only account (useradd leaves no password), so sudo must not prompt # for one. NOPASSWD is a STIG finding (tracked in TODO #2) — acceptable for # a key-only admin; reconcile when hardening lands. "printf '%s ALL=(ALL) NOPASSWD:ALL\\n' \"$ADMIN_USER\" > \"/etc/sudoers.d/$ADMIN_USER\"", "chmod 0440 \"/etc/sudoers.d/$ADMIN_USER\"", "visudo -cf \"/etc/sudoers.d/$ADMIN_USER\"", "restorecon \"/etc/sudoers.d/$ADMIN_USER\"", # Image sshd: no root login, key-only. A drop-in (read via Include before # the main config) overrides the ks's PermitRootLogin yes. Takes effect on # the next boot only (sshd isn't restarted), so the live build session is # unaffected. # Banner: show the fleet (starship) login notice pre-auth. The banner file # itself is written by the '2e. Fleet login banner' step below (after STIG, # so it overrides the DoD banner). Set here too so it works with enable_stig=false. "printf 'PermitRootLogin no\\nPasswordAuthentication no\\nKbdInteractiveAuthentication no\\nBanner /etc/issue.net\\n' > /etc/ssh/sshd_config.d/50-fleet-hardening.conf", "chmod 0644 /etc/ssh/sshd_config.d/50-fleet-hardening.conf", "restorecon /etc/ssh/sshd_config.d/50-fleet-hardening.conf", # Guarantee sshd is enabled (it's our ONLY way in once root is locked and # the admin user is key-only). Don't rely on the distro preset. Validate # the full sshd config so a bad drop-in fails the BUILD, not the boot. "systemctl enable sshd.service", "sshd -t", # Lock root in the image. Done last; admin access is via the key user. "passwd -l root", "echo \"root locked; admin user $ADMIN_USER created (wheel + NOPASSWD sudo, key-only).\"", ] } # --- 2c-debug. Break-glass root shell (DEBUG/LAB ONLY) ------------------- # debug_shell=true enables systemd's debug-shell.service: a passwordless root # /bin/sh on tty9 (Ctrl+Alt+F9) that does NOT go through login/PAM — so you can # get into a hardened image even when root is locked, the accounts are key-only, # and login/sshd are broken. This is a deliberate security hole for diagnosing # boot/login regressions; never ship it. The symlink into sysinit.target.wants # guarantees it starts early regardless of the unit's [Install] section. provisioner "shell" { environment_vars = ["DEBUG_SHELL=${var.debug_shell}"] inline = [ "set -e", "if [ \"$DEBUG_SHELL\" != \"true\" ]; then echo 'debug_shell=false -> no debug shell baked.'; exit 0; fi", "echo '############################################################'", "echo '## WARNING: debug_shell=true — PASSWORDLESS ROOT on tty2. ##'", "echo '## DEBUG/LAB IMAGE ONLY. Do NOT deploy to production. ##'", "echo '############################################################'", "systemctl enable debug-shell.service 2>/dev/null || true", "mkdir -p /etc/systemd/system/sysinit.target.wants", "ln -sf /usr/lib/systemd/system/debug-shell.service /etc/systemd/system/sysinit.target.wants/debug-shell.service", # Default debug-shell is tty9 (outside the getty range) — but constrained # consoles (noVNC/SPICE) often only expose tty1-6. Move it to tty2, which # IS reachable, and mask the login getty there so they don't fight over the # VT. Result: Ctrl+Alt+F2 = passwordless root shell. (tty1 stays the kiosk.) "mkdir -p /etc/systemd/system/debug-shell.service.d", "printf '[Service]\\nTTYPath=/dev/tty2\\n' > /etc/systemd/system/debug-shell.service.d/tty.conf", "systemctl mask getty@tty2.service autovt@tty2.service 2>/dev/null || true", ] } # --- 2c-STIG. DISA STIG hardening (OpenSCAP + SSG), diskless/UKI/kiosk tailored # Runs after admin (so it hardens the full system incl. the admin/sshd config) # and BEFORE hygiene 2d (so 2d can strip the scanner tooling + reports). The # tailoring (files/stig/diskless-tailoring.xml) unselects rules that are N/A to # this architecture or dangerous to auto-remediate (FIPS off, firewall off, # AIDE, partitions, smartcard, GRUB) — each justified in docs/stig-deviations.md. # FIPS=off, audit offload=local per decision. Gated by enable_stig. provisioner "file" { source = "files/stig" destination = "/tmp/" } provisioner "shell" { environment_vars = [ "ENABLE_STIG=${var.enable_stig}", "ADMIN_USER=${var.admin_user}", ] inline = [ "set -e", "if [ \"$ENABLE_STIG\" != \"true\" ]; then echo 'enable_stig=false -> skipping STIG hardening.'; mkdir -p /root; echo 'STIG hardening disabled for this build (enable_stig=false).' > /root/stig-report.html; : > /root/stig-arf.xml; exit 0; fi", "echo '==> Installing OpenSCAP + SCAP Security Guide'", "dnf install -y openscap-scanner scap-security-guide", # Pick the datastream that MATCHES THIS DISTRO. The RHEL9 datastream's CPE # dictionary has no Rocky entry, so running it on Rocky makes EVERY rule # 'notapplicable' -> the scan evaluates nothing and the report is falsely # clean. SSG ships a Rocky-specific datastream (ssg-rl9-ds.xml); map by # /etc/os-release ID (al9=AlmaLinux, cs9=CentOS Stream, ol9=Oracle). ". /etc/os-release", "case \"$ID\" in rocky) SSG=rl9;; almalinux) SSG=al9;; centos) SSG=cs9;; ol|oracle) SSG=ol9;; rhel|redhat) SSG=rhel9;; *) SSG=rhel9;; esac", "DS=/usr/share/xml/scap/ssg/content/ssg-$SSG-ds.xml", "[ -f \"$DS\" ] || DS=/usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml", # last-resort fallback "[ -f \"$DS\" ] || { echo \"ERROR: no SSG datastream found for $ID ($DS)\" >&2; exit 1; }", "echo \"==> Using SSG datastream $DS for $ID $VERSION_ID\"", "install -D -m0644 /tmp/stig/diskless-tailoring.xml /etc/stig/diskless-tailoring.xml", "echo \"==> Remediating against tailored STIG profile using $DS (this changes a lot)\"", # --remediate returns nonzero when rules remain failed (expected) — tolerate. "oscap xccdf eval --remediate --profile xccdf_org.ssgproject.content_profile_stig_diskless --tailoring-file /etc/stig/diskless-tailoring.xml --results-arf /root/stig-arf.xml --report /root/stig-report.html \"$DS\" || true", # --- Supplemental remediations (report triage 2026-06-19, scan app-9f7d8d1). # Applied BETWEEN --remediate and the clean eval so the report of record # reflects them. See docs/stig-deviations.md #12. # (a) postfix: remove the unused local MTA. The appliance sends no mail and # ships audit off-box via rsyslog (2f), so an MTA is dead surface. This # makes postfix_prevent_unrestricted_relay N/A; package_postfix_installed # is waived in the tailoring to match. "echo '==> supplemental: removing unused MTA (postfix)'", "dnf remove -y postfix >/dev/null 2>&1 || true", # Removing postfix leaves auditd's STIG `space_left_action = email` with no # MTA (it warns at boot and can't send). Repoint the 75%-full alert to # SYSLOG so it rides the remote audit offload (2f) instead. Only # space_left_action changes; admin_space_left_action stays `single` (STIG). # The auditd_data_retention_space_left_action rule (wants email) is waived in # the tailoring to match — see docs/stig-deviations.md #12. "[ -f /etc/audit/auditd.conf ] && sed -i 's/^space_left_action[[:space:]]*=.*/space_left_action = SYSLOG/' /etc/audit/auditd.conf || true", # (b) USBGuard: usbguard is already installed + enabled, but its policy was # generated on the build VM (no real USB input). Add the LinuxAudit audit # backend (configure_usbguard_auditbackend) AND a hardware-independent # HID/hub allowlist so a physical console keyboard/mouse keep working # under the default block target (a plain keyboard = HID only, a keyboard # with built-in hub = HID+hub, so all three allow forms are needed). # BENCH-VERIFY physical console input on real hardware. "echo '==> supplemental: usbguard LinuxAudit backend + HID/hub allowlist'", "if rpm -q usbguard >/dev/null 2>&1; then", " [ -f /etc/usbguard/usbguard-daemon.conf ] || touch /etc/usbguard/usbguard-daemon.conf", " sed -i '/^[ \\t]*AuditBackend=/Id' /etc/usbguard/usbguard-daemon.conf", " printf 'AuditBackend=LinuxAudit\\n' >> /etc/usbguard/usbguard-daemon.conf", " touch /etc/usbguard/rules.conf", " for rule in 'allow with-interface match-all { 03:*:* }' 'allow with-interface match-all { 09:00:* }' 'allow with-interface match-all { 03:*:* 09:00:* }'; do", " grep -qF \"$rule\" /etc/usbguard/rules.conf || printf '%s\\n' \"$rule\" >> /etc/usbguard/rules.conf", " done", " restorecon /etc/usbguard/usbguard-daemon.conf /etc/usbguard/rules.conf 2>/dev/null || true", "else", " echo 'WARNING: usbguard not installed; skipping (configure_usbguard_auditbackend stays a finding).'", "fi", # (c) selinux_context_elevation_for_sudo: STIG wants sudo to ELEVATE the # SELinux context. SSG ships no auto-fix. Decision (2026-06-20): # REMEDIATE WITH CONFINEMENT. Two parts: # 1. Rewrite the admin sudoers rule to add the role/type transition # (KEEP NOPASSWD — the admin is key-only; #8). This is what the # OVAL check looks for, so it makes the rule pass. # 2. Map the admin to the confined **staff_u** SELinux user so the # staff_r->sysadm_r sudo transition is actually authorized at # runtime (unconfined_u cannot reach sysadm_r). staff_u (NOT # sysadm_u) so SSH login isn't gated by the ssh_sysadm_login bool. # ⚠ BENCH-VERIFY: admin SSH login succeeds AND `sudo` works AND every # privileged op the admin needs runs under the sysadm_t transition. If # this locks the admin out, enable_stig=false is the escape hatch and # this rule reverts to a justified deviation. ⚠ REPRODUCIBILITY: confirm # the verity hash is stable across two builds — semanage rebuilds the # SELinux policy store and may write nondeterministic content; if it # drifts, normalize in 2d. "if [ -n \"$ADMIN_USER\" ] && id \"$ADMIN_USER\" >/dev/null 2>&1; then", " echo \"==> supplemental: sudo SELinux elevation — confining $ADMIN_USER to staff_u + sudoers role/type\"", " command -v semanage >/dev/null 2>&1 || dnf install -y policycoreutils-python-utils", " printf '%s ALL=(ALL) TYPE=sysadm_t ROLE=sysadm_r NOPASSWD:ALL\\n' \"$ADMIN_USER\" > \"/etc/sudoers.d/$ADMIN_USER\"", " chmod 0440 \"/etc/sudoers.d/$ADMIN_USER\"", " visudo -cf \"/etc/sudoers.d/$ADMIN_USER\"", " restorecon \"/etc/sudoers.d/$ADMIN_USER\"", " if semanage login -l 2>/dev/null | grep -qE \"^$ADMIN_USER[[:space:]]\"; then semanage login -m -s staff_u \"$ADMIN_USER\"; else semanage login -a -s staff_u \"$ADMIN_USER\"; fi", " restorecon -R \"/home/$ADMIN_USER\" 2>/dev/null || true", " echo \"==> $ADMIN_USER mapped to staff_u; sudo elevates to sysadm_r/sysadm_t\"", "fi", # Clean post-remediation eval = the report/ARF of record (no --remediate). "oscap xccdf eval --profile xccdf_org.ssgproject.content_profile_stig_diskless --tailoring-file /etc/stig/diskless-tailoring.xml --results-arf /root/stig-arf.xml --report /root/stig-report.html \"$DS\" || true", # GUARD: a scan with 0 evaluated rules (everything notapplicable/notselected) # means the datastream/CPE didn't match this host — NO hardening happened and # the report would be falsely 'clean'. Fail the build rather than ship an # unhardened image masquerading as STIG-compliant. (enable_stig=false is the # intentional way to skip hardening.) "evaluated=$(grep -oE '(pass|fixed|fail)' /root/stig-arf.xml | wc -l)", "[ \"$evaluated\" -gt 0 ] || { echo 'ERROR: STIG scan evaluated 0 rules (all notapplicable/notselected) — SSG datastream/CPE mismatch for this distro. Hardening did NOT run; aborting.' >&2; exit 1; }", "echo \"==> STIG scan evaluated $evaluated rules (pass/fixed/fail)\"", # SELinux: allow chronyd to reply to the chronyc our NM dispatcher runs for # DHCP option-42 NTP (10-chrony-dhcp-ntp -> `chronyc reload sources`). The # dispatcher's confined domain (NetworkManager_dispatcher_custom_t) labels # chronyc's reply socket, and the shipped policy denies chronyd_t sendto to # it -> AVC flood + failed reload under enforcing. Ship a minimal allow # module (docs/stig-deviations.md). NB: rebuilds the policy store — include # in the reproducibility check alongside semanage (verity hash stability). "echo '==> supplemental: SELinux module chrony_dhcp_dispatcher (chronyd reply to NM-dispatcher chronyc)'", "command -v checkmodule >/dev/null 2>&1 || dnf install -y checkpolicy", "checkmodule -M -m -o /tmp/chrony_dhcp_dispatcher.mod /tmp/stig/chrony-dhcp-dispatcher.te", "semodule_package -o /tmp/chrony_dhcp_dispatcher.pp -m /tmp/chrony_dhcp_dispatcher.mod", "semodule -i /tmp/chrony_dhcp_dispatcher.pp", "rm -f /tmp/chrony_dhcp_dispatcher.mod /tmp/chrony_dhcp_dispatcher.pp", "semodule -l | grep -q '^chrony_dhcp_dispatcher' && echo '==> SELinux module chrony_dhcp_dispatcher installed' || { echo 'ERROR: chrony_dhcp_dispatcher module not installed' >&2; exit 1; }", "rm -rf /tmp/stig", "echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'", ] } # Pull the scan out to the host artifact dir for CI (before 2d removes it from # the VM). Packer creates the local destination dir on download. Two single- # source downloads (the file provisioner's download direction takes one source). provisioner "file" { direction = "download" source = "/root/stig-report.html" destination = "${var.artifact_dir}/${var.image_version}/stig-report.html" } provisioner "file" { direction = "download" source = "/root/stig-arf.xml" destination = "${var.artifact_dir}/${var.image_version}/stig-arf.xml" } # --- 2c-STIG-postfix. Undo the STIG settings that lock out this architecture. # Runs AFTER remediation so STIG can't re-break it. Two fixes: # (a) APPLIANCE ACCOUNTS: STIG enables pam_faillock (account phase) + password # aging/inactivity. The kiosk + fleet-admin accounts are PASSWORDLESS / # key-only, so the hardened account phase rejects them (kiosk session never # opens; sshd account phase denies). Exempt just those two accounts. # (b) fapolicyd: STIG's app-allowlisting blocks non-RPM executables, killing # the kiosk (ExecStart=/usr/local/bin/kiosk-session). Keep fapolicyd (it's # the exec-guard for the writable overlay that dm-verity can't cover) and # trust the custom kiosk binary instead of disabling it. # Both justified in docs/stig-deviations.md (#10). provisioner "shell" { environment_vars = [ "ENABLE_STIG=${var.enable_stig}", "ADMIN_USER=${var.admin_user}", ] inline = [ "set -e", "if [ \"$ENABLE_STIG\" != \"true\" ]; then echo '2c-STIG-postfix: enable_stig=false -> nothing to undo.'; exit 0; fi", # Exempt each appliance account from password aging/inactivity/expiry and # clear any faillock tally (these accounts have NO password to age). "for u in \"$ADMIN_USER\" kiosk; do", " { [ -n \"$u\" ] && id \"$u\" >/dev/null 2>&1; } || continue", " echo \"==> exempting key-only account '$u' from STIG password aging + faillock\"", " chage -M -1 -m 0 -I -1 -E -1 \"$u\"", " chage -d \"$(date +%Y-%m-%d)\" \"$u\"", " command -v faillock >/dev/null 2>&1 && faillock --user \"$u\" --reset || true", " { [ -d \"/home/$u/.ssh\" ] && restorecon -R \"/home/$u/.ssh\"; } || true", "done", # sshd: bypass the hardened ACCOUNT stack (faillock/aging) for the key-only # admin only, inserted as the FIRST account rule so a valid key can't be # denied by pam_acct_mgmt. Other users keep the full STIG account policy. "if [ -n \"$ADMIN_USER\" ] && id \"$ADMIN_USER\" >/dev/null 2>&1; then", " grep -q \"pam_succeed_if.so user = $ADMIN_USER\" /etc/pam.d/sshd || sed -i \"0,/^account/ s//account sufficient pam_succeed_if.so user = $ADMIN_USER quiet\\n&/\" /etc/pam.d/sshd", " restorecon /etc/pam.d/sshd", " echo \"==> /etc/pam.d/sshd: account-stack bypass ensured for $ADMIN_USER\"", "fi", # fapolicyd (STIG app-allowlisting) blocks execution of any non-RPM file. # The kiosk's ExecStart=/usr/local/bin/kiosk-session is exactly that, so # fapolicyd kills the kiosk. KEEP fapolicyd (it's the real exec-guard for the # writable RAM overlay, which dm-verity does NOT cover) and instead TRUST the # custom kiosk files: add them to fapolicyd's trust DB and rebuild it. weston # /chromium are RPM-installed so already trusted. (See docs/stig-deviations.md.) "if command -v fapolicyd-cli >/dev/null 2>&1; then", " for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done", " fapolicyd-cli --update 2>/dev/null || true", " echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi) + rebuilt trust db'", "fi", ] } # --- 2e. Fleet login banner (starship-themed) ---------------------------- # Replaces the DoD/USG warning banner STIG writes (banner_etc_issue*) with the # fleet (ISV AURELIAN) notice. Runs AFTER the STIG step so it overrides what # remediation wrote; the sshd 'Banner /etc/issue.net' directive is set in 2c. # Keeps a (themed) monitored/authorized-only notice — that warning is the # banner's actual security purpose; only the wording changes. This makes the # STIG banner-content rules fail by design (documented: stig-deviations.md #11). provisioner "file" { source = "files/banner" destination = "/tmp/" } provisioner "shell" { inline = [ "set -eux", # /etc/issue = console getty (pre-login); issue.net = SSH pre-auth banner; # motd = post-login. Same fleet notice in all three. "install -D -m0644 /tmp/banner/fleet-issue /etc/issue", "install -D -m0644 /tmp/banner/fleet-issue /etc/issue.net", "install -D -m0644 /tmp/banner/fleet-issue /etc/motd", "restorecon /etc/issue /etc/issue.net /etc/motd", "rm -rf /tmp/banner", ] } # --- 2f. Remote log / audit offload (sender) ----------------------------- # Forward auditd (via the audisp-syslog plugin, on LOCAL6) AND the journal # (rsyslog's stock imjournal) to the remote collector (serving/audit-collector). # The diskless root is a RAM-only overlay so local logs vanish on reboot; this # ships them off-box (and gives remote visibility into boot/login failures). # Host-generic: targets ${audit_host} (DNS, resolved per environment). Empty # audit_host => local-only. Implements the remote-offload half of TODO #2 / # docs/stig-deviations.md #6. Disk-assisted queue so a network blip won't drop # records (action.resumeRetryCount=-1 retries forever). provisioner "shell" { environment_vars = ["AUDIT_HOST=${var.audit_host}"] inline = [ "set -eux", "if [ -z \"$AUDIT_HOST\" ]; then echo 'audit_host empty -> local-only logging, no remote offload.'; exit 0; fi", "dnf install -y rsyslog", # auditd -> syslog on LOCAL6 (the collector routes LOCAL6 to a per-host # audit.log; see serving/audit-collector/rsyslog.conf). "if [ -f /etc/audit/plugins.d/syslog.conf ]; then sed -i -e 's/^active.*/active = yes/' -e 's/^args.*/args = LOG_INFO LOG_LOCAL6/' /etc/audit/plugins.d/syslog.conf; fi", # rsyslog -> collector over TCP, disk-assisted queue. "cat > /etc/rsyslog.d/60-fleet-remote.conf </dev/null || true", ] } # --- 2d. Final image hygiene: strip caches/logs after the LAST dnf step -- # MUST be the last provisioner. The `dnf clean all` in step 2 runs BEFORE the # kiosk (2b) and admin (2c) provisioners, and 2b enables EPEL + installs # weston/chromium/mesa — repopulating /var/cache/dnf (EPEL metadata alone is # ~100MB+). This tree becomes the read-only, UNCOMPRESSED erofs served over # NVMe/TCP, so every cache byte is dead weight read on every boot of every # host. Clear the repo *cache* dirs only — never /var/lib/rpm or # /var/lib/dnf/history (the rpmdb keeps the image introspectable/patchable). provisioner "shell" { inline = [ "set -eux", # Drop the STIG scanner tooling from the shipped image (the reports were # already downloaded to the host) — slims the erofs + cuts attack surface. # Keep auditd (it's part of the hardened runtime). Best-effort. "dnf remove -y openscap-scanner scap-security-guide openscap-utils 2>/dev/null || true", # firewalld: the kickstart EXCLUDES it (-firewalld, firewall --disabled) — # this fleet uses network-layer security (802.1X/MACsec + switch); the host # firewall is a documented OFF item (docs/stig-deviations.md #4). But a # provisioner dnf pulled it back as a WEAK dep (Recommends) and, once # installed, it starts by default. Its Python then tries to rewrite # site-package .pyc bytecode — which the reproducible erofs `-T 0` mtime # reset makes look stale — so firewalld_t gets write/unlink AVCs on lib_t # that flood the SIEM (seen 2026-07-03). Remove it (weak dep, nothing # hard-needs it) and mask as a backstop against future weak-dep reinstalls. "dnf remove -y firewalld 2>/dev/null || true", "systemctl mask firewalld.service 2>/dev/null || true", # nss-mdns: the mDNS/.local NSS resolver, and the ONLY reverse-dep of avahi # (confirmed 2026-07-03: `rpm -e --test avahi` → "avahi is needed by nss-mdns", # and avahi is absent from `ldd libavformat` so it's unrelated to Chromium's # media stack — unlike samba/librabbitmq/librist/libsrt/libzmq, which ARE # libavformat DT_NEEDED and must stay for future HTML5 media). This fleet # resolves via DHCP/DNS (boot.fleet.lab), never .local, so mDNS is dead weight # AND a network-listening service unwanted on a locked-down kiosk. Removing # nss-mdns lets dnf's clean_requirements_on_remove reap avahi/avahi-glib/ # avahi-libs as orphans. (install_weak_deps=False may already drop nss-mdns if # it's a Recommends; this is the belt-and-braces for the hard-dep case.) "dnf remove -y nss-mdns 2>/dev/null || true", # (No Go toolchain to remove — fleet-hostapi + fleet-enrol are built OUT OF # the VM on the CI runner and file-provisioned in as static binaries.) # The build-time STIG reports must not ship inside the read-only rootfs. "rm -f /root/stig-report.html /root/stig-arf.xml", "dnf clean all", "rm -rf /var/cache/dnf/* /var/cache/PackageKit /var/cache/ldconfig/*", "rm -rf /var/lib/dnf/repos/* /var/log/dnf.* /var/log/hawkey.log", "rm -rf /var/tmp/* /tmp/*", "truncate -s 0 /var/log/messages /var/log/secure 2>/dev/null || true", # Docs/man/info: the kickstart used --excludedocs but the provisioner dnf # installs (kiosk + admin) did not, so strip what they laid down. "rm -rf /usr/share/doc/* /usr/share/man/* /usr/share/info/*", # Chromium ships ~50 locale .pak files; a single-locale kiosk needs one. "if [ -d /usr/lib64/chromium-browser/locales ]; then find /usr/lib64/chromium-browser/locales -type f -name '*.pak' ! -name 'en-US.pak' -delete; fi", # App translation catalogs (.mo) for locales this fleet never uses. "[ -d /usr/share/locale ] && find /usr/share/locale -mindepth 1 -maxdepth 1 -type d ! -name 'en*' ! -name 'C*' -exec rm -rf {} + || true", # (linux-firmware slimming moved to step 2, BEFORE the dracut build, so the # UKI initramfs shrinks too — see TODO #18. Nothing re-adds firmware after # that point, so the rootfs erofs is already trimmed by the time we get here.) ] } # --- 3. Extract kernel, initramfs, rootfs from the built disk ------------ # --- 4. Assemble + sign the UKI; build the OCI release-artifact image ---- post-processor "shell-local" { inline = [ # extract-artifacts MUST run as root so tar/mkfs.erofs preserve the build # tree's real ownership (root:ssh_keys, tty/crontab setgid groups, user # 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 # the runner has passwordless sudo (cf. the `sudo apt-get` install step). "sudo bash -c \"IMAGE_VERSION='${var.image_version}' NVMET_TRADDR='${var.nvmet_traddr}' ENROL_URL='${var.enrol_url}' BOOT_SPLASH='${var.boot_splash}' STIG='${var.enable_stig}' 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 # already baked correctly) so build-oci.sh can write the UKI and push with # the build user's `buildah login`. "sudo chown -R \"$(id -u):$(id -g)\" ${var.artifact_dir}", "bash scripts/build-oci.sh ${var.artifact_dir}/${var.image_version} ${var.oci_image} ${var.image_version} '${var.sb_key}' '${var.sb_cert}' ${var.oci_push}", ] } }