Files
diskless-workstation/diskless.pkr.hcl
T
Audiobuzz f59eb7942a trim: run firmware strip pre-dracut so the UKI initramfs shrinks too
Follow-up to the linux-firmware strip (prev commit put it in the 2d hygiene
block, which runs AFTER the dracut build — so it only shrank the rootfs erofs,
not the initramfs). Move the strip to step 2, before the dracut build: with
--no-hostonly, plymouth's drm dep instmods a broad DRM set and dracut bundles
each module's firmware into the initramfs (amdgpu being the classic UKI
bloater). Trimming first means dracut never sees it, so both the rootfs erofs
AND the UKI initramfs shrink. Keep-list is unchanged (i915 + intel/ + mellanox
+ intel-ucode), so the fleet splash GPU (i915) and ice DDP firmware the
initramfs actually needs are retained; dracut just warns 'firmware not found'
for the removed drm modules and continues.

Add a post-build diagnostic (initramfs .img size + bundled firmware, expected
i915 only) so the initrd win is measured in the build log, not estimated.
2026-07-03 05:36:51 +00:00

984 lines
62 KiB
HCL

# =============================================================================
# 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-<version>.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-<version>.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-<ver>-x86_64-minimal.iso | sha256sum
# (or `sha256sum` the file on iso-server). Set "none" to skip verification.
description = "ISO checksum as sha256:<hex> (or file:<CHECKSUM-url>, 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<wait>", "linuxefi /images/pxeboot/vmlinuz inst.text ",
# "inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg<enter>",
# "initrdefi /images/pxeboot/initrd.img<enter>", "boot<enter>"]
boot_wait = "5s"
boot_command = [
"<up><wait><tab>",
" inst.text inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg",
"<enter><wait>",
]
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 '<html><body>STIG hardening disabled for this build (enable_stig=false).</body></html>' > /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 '<result>(pass|fixed|fail)</result>' /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 <<EOF",
"*.* action(type=\"omfwd\" target=\"$AUDIT_HOST\" port=\"514\" protocol=\"tcp\" queue.type=\"LinkedList\" queue.fileName=\"fleetfwdq\" queue.maxDiskSpace=\"256m\" queue.saveOnShutdown=\"on\" action.resumeRetryCount=\"-1\")",
"EOF",
"chmod 0644 /etc/rsyslog.d/60-fleet-remote.conf",
"rsyslogd -N1",
"systemctl enable rsyslog.service",
"restorecon /etc/rsyslog.d/60-fleet-remote.conf /etc/audit/plugins.d/syslog.conf 2>/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",
# (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}",
]
}
}