1235 lines
81 KiB
HCL
1235 lines
81 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 "enable_agent" {
|
|
type = bool
|
|
description = "Build-time feature flag for the on-host fleet-agent (telemetry client that dials the fleet-manager over TPM-mTLS and streams health; docs/fleet-management-design.md). true (default) installs /usr/local/bin/fleet-agent + fleet-agent.service; false skips it (the auth stack still boots — the agent is a management add-on). Config is fleet-generic in files/agent/agent.env; set the manager DNS name there per environment."
|
|
default = true
|
|
}
|
|
|
|
variable "enable_ssh_hostcert" {
|
|
type = bool
|
|
description = "Build-time feature flag for the SSH host-certificate client (fleet-ssh-hostcert: at boot, gets this host's per-boot SSH host key signed by the fleet SSH CA via the enrol-broker host plane over TPM-identity mTLS, so admins trust every host via one `@cert-authority` line instead of per-boot TOFU; closes F-02/P-17, docs/ssh-host-certificate-design.md). true (default) installs /usr/local/bin/fleet-ssh-hostcert + ssh-hostcert.service/.timer; false skips it (sshd still boots with its per-boot key). Config is fleet-generic in files/ssh-hostcert/ssh-hostcert.env; set the broker DNS name there per environment."
|
|
default = true
|
|
}
|
|
|
|
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 = "MUST be true (enforced). Applies DISA STIG hardening (OpenSCAP + SSG) with a diskless/UKI/kiosk tailoring (files/stig/diskless-tailoring.xml), bakes STIG kernel params into the UKI cmdline, and emits a deviations report. FIPS is OFF and audit offload is LOCAL-only regardless (see docs/stig-deviations.md). The former enable_stig=false escape hatch is removed for production hardening (POA&M P-03) — a build with enable_stig=false now fails validation. To debug a hardening regression, revert this validation on a throwaway branch, never on a release build."
|
|
default = true
|
|
validation {
|
|
condition = var.enable_stig
|
|
error_message = "STIG hardening is mandatory: enable_stig must be true. Production builds are hardening-mandatory (POA&M P-03); there is no unhardened build path, so do not set enable_stig=false on a release build."
|
|
}
|
|
}
|
|
|
|
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 = ""
|
|
}
|
|
|
|
variable "fleet_ca" {
|
|
type = string
|
|
description = "Path on the BUILD HOST to the fleet CA PEM baked to /etc/dot1x/radius-ca.pem — the trust anchor wpa_supplicant uses for RADIUS EAP-TLS AND the fleet-agent uses to verify the fleet-manager. Defaults to the COMMITTED PLACEHOLDER (files/etc/dot1x/radius-ca.pem), which is NOT a usable CA (auth/telemetry can't succeed with it). Override per-environment (CI secret -> temp file) with your real step-ca root (or a root+intermediate bundle for robustness). Never commit real internal CA material (CLAUDE.md)."
|
|
default = "files/etc/dot1x/radius-ca.pem"
|
|
}
|
|
|
|
# --------------------------------------------------------------------- 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"
|
|
}
|
|
|
|
# Fleet CA baked to /etc/dot1x/radius-ca.pem (RADIUS trust anchor + fleet-agent
|
|
# server-verify CA). var.fleet_ca defaults to the committed placeholder; CI
|
|
# overrides it with the real per-environment step-ca root (a build-host path).
|
|
# Installed over the placeholder in the etc-install step below.
|
|
provisioner "file" {
|
|
source = var.fleet_ca
|
|
destination = "/tmp/fleet-ca.pem"
|
|
}
|
|
|
|
# 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"
|
|
}
|
|
|
|
# On-host fleet-agent: the systemd unit AND the baked fleet-generic config
|
|
# (agent.env) are staged from here to /tmp/agent (installed in the 2b-agent step,
|
|
# which runs AFTER /tmp/etc-stage is cleaned — so config must ride this upload,
|
|
# not files/etc). The static binary (built OUT OF THE VM like enrol/hostapi) is
|
|
# staged separately below.
|
|
provisioner "file" {
|
|
source = "files/agent"
|
|
destination = "/tmp/"
|
|
}
|
|
provisioner "file" {
|
|
source = "files/agent/bin/fleet-agent"
|
|
destination = "/tmp/fleet-agent"
|
|
}
|
|
|
|
# SSH host-certificate client: the systemd unit + timer AND the baked
|
|
# fleet-generic config (ssh-hostcert.env) ride this module upload to
|
|
# /tmp/ssh-hostcert (installed in the 2b-ssh-hostcert step, after /tmp/etc-stage
|
|
# is cleaned — same reason as the agent). Static binary staged separately below.
|
|
provisioner "file" {
|
|
source = "files/ssh-hostcert"
|
|
destination = "/tmp/"
|
|
}
|
|
provisioner "file" {
|
|
source = "files/ssh-hostcert/bin/fleet-ssh-hostcert"
|
|
destination = "/tmp/fleet-ssh-hostcert"
|
|
}
|
|
|
|
# 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
|
|
# radius-ca.pem comes from var.fleet_ca (placeholder by default; real fleet CA
|
|
# per-environment via CI) — NOT the etc-stage copy, so one build input controls
|
|
# the trust anchor for both RADIUS EAP-TLS and the fleet-agent.
|
|
"install -D -m0644 /tmp/fleet-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",
|
|
# Enable chrony's tracking log so the fleet-agent can report clock health
|
|
# (source/stratum/offset) WITHOUT touching chronyd's command interface. That
|
|
# interface is unusable by the hardened agent: the unix socket is
|
|
# owner-write-only (0755) and the agent is DynamicUser + ProtectSystem=strict
|
|
# (/run read-only), and STIG closes the UDP command port (cmdport 0). Instead
|
|
# chronyd appends source/stratum/offset to /var/log/chrony/tracking.log, which
|
|
# the agent simply READS (world-readable; no port, no socket, no SELinux domain
|
|
# transition). Write volume is tiny (~one ~130B line per clock update, backing
|
|
# off to ~maxpoll when stable) and capped by the chrony RPM's logrotate; on the
|
|
# RAM overlay this is negligible. Keeps cmdport 0 intact — no STIG deviation.
|
|
"grep -qE '^log ([^#]*[[:space:]])?tracking' /etc/chrony.conf || echo 'log tracking' >> /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",
|
|
# Same class of noise, different trigger: dnf-makecache.timer fires ~10 min
|
|
# after boot and imports the whole dnf Python stack (dnf/libdnf/librepo/rhsm).
|
|
# Because the reproducible erofs `-T 0` reset makes every source .py look
|
|
# newer than its baked .pyc, that import REWRITES bytecode into the tmpfs
|
|
# overlay (measured 2026-07-03: ~MBs of dnf/libdnf/rhsm __pycache__) and
|
|
# throws lib_t write/unlink AVCs. An immutable diskless host never refreshes
|
|
# dnf caches (no persistent store, no runtime installs), so mask the timer to
|
|
# remove the trigger. Belt-and-braces with the compileall unchecked-hash step
|
|
# in 2d (which stops the rewrite at the source). See docs/TODO.md #16.
|
|
"systemctl mask dnf-makecache.timer 2>/dev/null || true",
|
|
"systemctl disable dnf-makecache.timer 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 — the fleet's only
|
|
# firmware-loading GPU) + intel/ice (the ice server-NIC DDP, the SOLE intel/
|
|
# blob any host needs — ish/sof/ibt are sensor-hub/audio/bluetooth, none
|
|
# loaded at boot) + intel-ucode; strip the rest (~600 MB). NOT mellanox/ —
|
|
# those are mlxsw_spectrum SWITCH-ASIC blobs, not NIC firmware (mlx5 NIC fw is
|
|
# on-card, never request_firmware'd from /usr/lib/firmware), so they're dead
|
|
# weight even for an mlx5 build. 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 intel-ucode -exec rm -rf {} + ; fi",
|
|
# Narrow intel/ to just ice (NIC DDP); drop ish (sensor hub), sof (audio DSP),
|
|
# ibt (bluetooth) etc. — none are loaded on this fleet (audit 2026-07-03).
|
|
"[ -d /usr/lib/firmware/intel ] && find /usr/lib/firmware/intel -mindepth 1 -maxdepth 1 ! -name ice -exec rm -rf {} + || true",
|
|
# 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)'",
|
|
# Largest files IN the initramfs (in-cpio size, before the outer zstd) — after
|
|
# the firmware strip the .img is module-bound (the --no-hostonly DRM/net/crypto
|
|
# set), so this surfaces the next trim targets (e.g. GPU driver kmods). See #18.
|
|
# Sort into a FILE then head the file — NOT `sort | head`: head closes the pipe
|
|
# after 25 lines, sort dies of SIGPIPE (exit 141), and step 2's
|
|
# `set -euxo pipefail` turns that into a build failure (same trap as the
|
|
# lsinitrd `| grep -q` note above). (The kiosk step's rpm-manifest `| head` is
|
|
# safe only because that step runs plain `set -e`, no pipefail.)
|
|
"echo '==> initramfs largest files:'; awk '/^-/{print $5, $NF}' /tmp/lsinitrd.txt | sort -rn > /tmp/initrd-sizes.txt; head -25 /tmp/initrd-sizes.txt; rm -f /tmp/initrd-sizes.txt",
|
|
"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)'",
|
|
]
|
|
}
|
|
|
|
# --- 2b-agent. On-host fleet-agent (Go, TPM-mTLS telemetry client) --------
|
|
# Installs the OUT-OF-VM-built /usr/local/bin/fleet-agent (the "Build host Go
|
|
# binaries" CI step; staged to /tmp/fleet-agent) + its systemd unit + baked,
|
|
# fleet-generic config, and enables it. The agent dials the fleet-manager over
|
|
# mTLS using the host's reused TPM identity (signing key at 0x81000100 + cert in
|
|
# NV 0x01800100) and streams health; NOTHING host-specific is baked (invariant
|
|
# #1) — the manager DNS name in agent.env is fleet-generic. Gated on
|
|
# enable_agent so a lean/auth-only build can skip it. The binary is
|
|
# fapolicyd-trusted in 2c-STIG-postfix (non-RPM exec, like hostapi/kiosk-session).
|
|
provisioner "shell" {
|
|
environment_vars = [
|
|
"ENABLE_AGENT=${var.enable_agent}",
|
|
]
|
|
inline = [
|
|
"set -euxo pipefail",
|
|
"if [ \"$ENABLE_AGENT\" != \"true\" ]; then echo 'enable_agent=false -> skipping fleet-agent install.'; exit 0; fi",
|
|
"install -D -m0755 /tmp/fleet-agent /usr/local/bin/fleet-agent",
|
|
"install -D -m0644 /tmp/agent/fleet-agent.service /etc/systemd/system/fleet-agent.service",
|
|
"install -D -m0644 /tmp/agent/agent.env /etc/fleet-agent/agent.env",
|
|
"restorecon -R /usr/local/bin/fleet-agent /etc/systemd/system/fleet-agent.service /etc/fleet-agent 2>/dev/null || true",
|
|
"systemctl enable fleet-agent.service",
|
|
"rm -rf /tmp/agent /tmp/fleet-agent",
|
|
"echo '==> fleet-agent enabled (TPM-mTLS telemetry to the fleet-manager)'",
|
|
]
|
|
}
|
|
|
|
# --- 2b-ssh-hostcert. SSH host-certificate client (Go, TPM-authenticated) --
|
|
# Installs the OUT-OF-VM-built /usr/local/bin/fleet-ssh-hostcert (staged to
|
|
# /tmp/fleet-ssh-hostcert) + its systemd unit + renewal timer + baked,
|
|
# fleet-generic config. At boot it gets this host's PER-BOOT SSH host key signed
|
|
# by the fleet SSH CA via the enrol-broker host plane (TPM-identity mTLS), so
|
|
# admins trust every host via one `@cert-authority` line instead of TOFU on a key
|
|
# that changes each boot (closes F-02 / POA&M P-17; docs/ssh-host-certificate-design.md).
|
|
# NOTHING host-specific is baked (invariant #1) — the broker DNS name in
|
|
# ssh-hostcert.env is fleet-generic; the host identity comes from the TPM at
|
|
# runtime (same 0x81000100 key / 0x01800100 cert as 802.1X + the agent). We
|
|
# enable the TIMER (not the service) so it fires at boot + renews. Gated on
|
|
# enable_ssh_hostcert. The binary is fapolicyd-trusted in 2c-STIG-postfix
|
|
# (non-RPM exec, like hostapi/agent/kiosk-session).
|
|
provisioner "shell" {
|
|
environment_vars = [
|
|
"ENABLE_SSH_HOSTCERT=${var.enable_ssh_hostcert}",
|
|
]
|
|
inline = [
|
|
"set -euxo pipefail",
|
|
"if [ \"$ENABLE_SSH_HOSTCERT\" != \"true\" ]; then echo 'enable_ssh_hostcert=false -> skipping fleet-ssh-hostcert install.'; exit 0; fi",
|
|
"install -D -m0755 /tmp/fleet-ssh-hostcert /usr/local/bin/fleet-ssh-hostcert",
|
|
"install -D -m0644 /tmp/ssh-hostcert/ssh-hostcert.service /etc/systemd/system/ssh-hostcert.service",
|
|
"install -D -m0644 /tmp/ssh-hostcert/ssh-hostcert.timer /etc/systemd/system/ssh-hostcert.timer",
|
|
"install -D -m0644 /tmp/ssh-hostcert/ssh-hostcert.env /etc/ssh-hostcert/ssh-hostcert.env",
|
|
"restorecon -R /usr/local/bin/fleet-ssh-hostcert /etc/systemd/system/ssh-hostcert.service /etc/systemd/system/ssh-hostcert.timer /etc/ssh-hostcert 2>/dev/null || true",
|
|
"systemctl enable ssh-hostcert.timer",
|
|
"rm -rf /tmp/ssh-hostcert /tmp/fleet-ssh-hostcert",
|
|
"echo '==> fleet-ssh-hostcert enabled (SSH host cert from the fleet SSH CA; timer at boot + renew)'",
|
|
]
|
|
}
|
|
|
|
# --- 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 as a pre-STIG baseline.
|
|
"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 REMOVED (POA&M P-02 / R-02) -------------------------------
|
|
# The break-glass systemd debug-shell.service (passwordless root on a tty,
|
|
# bypassing login/PAM) is no longer part of any build. It was an undocumented
|
|
# privileged access path flagged High-risk in the A&A (irap-poam.md P-02,
|
|
# ssp.md R-02). There is intentionally NO debug_shell variable and NO provisioner
|
|
# that enables debug-shell.service. To diagnose a boot/login regression, use a
|
|
# throwaway branch/serial console — never reintroduce a shipped root shell.
|
|
|
|
# --- 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",
|
|
# enable_stig is enforced true by variable validation (POA&M P-03). This is
|
|
# a fail-closed backstop: if STIG were ever somehow disabled we abort the
|
|
# build rather than silently ship an unhardened image masquerading as one.
|
|
"if [ \"$ENABLE_STIG\" != \"true\" ]; then echo 'FATAL: ENABLE_STIG != true — production builds are hardening-mandatory (no unhardened path).' >&2; exit 1; 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, revert this rule to a justified deviation in
|
|
# the tailoring (NOT by disabling STIG — that path is gone). ⚠ 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. (There is no skip-hardening
|
|
# path — enable_stig is enforced true, POA&M P-03.)
|
|
"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; }",
|
|
# SELinux: allow fapolicyd to statfs (getattr) tmpfs. fapolicyd is the exec
|
|
# guard for the WRITABLE overlay (dm-verity covers only the RO erofs lower);
|
|
# it statfs's mounts to decide which to watch (tmpfs is in its default
|
|
# watch_fs). This host's root is systemd.volatile=overlay, so statfs("/")
|
|
# lands on the tmpfs upper (audit: dev=tmpfs ino=1 name="/") and the shipped
|
|
# policy denies it -> fapolicyd can't fully guard the tmpfs mounts (/, /tmp,
|
|
# /dev/shm) — a real exec-guard gap. Ship a minimal allow (docs/stig-deviations.md).
|
|
# checkpolicy already ensured above. Same policy-store-rebuild reproducibility
|
|
# caveat as chrony.
|
|
"echo '==> supplemental: SELinux module fapolicyd_tmpfs (fapolicyd statfs the volatile-overlay tmpfs)'",
|
|
"checkmodule -M -m -o /tmp/fapolicyd_tmpfs.mod /tmp/stig/fapolicyd-tmpfs.te",
|
|
"semodule_package -o /tmp/fapolicyd_tmpfs.pp -m /tmp/fapolicyd_tmpfs.mod",
|
|
"semodule -i /tmp/fapolicyd_tmpfs.pp",
|
|
"rm -f /tmp/fapolicyd_tmpfs.mod /tmp/fapolicyd_tmpfs.pp",
|
|
"semodule -l | grep -q '^fapolicyd_tmpfs' && echo '==> SELinux module fapolicyd_tmpfs installed' || { echo 'ERROR: fapolicyd_tmpfs module not installed' >&2; exit 1; }",
|
|
# SELinux: let the fleet-agent READ chrony's tracking log. The agent
|
|
# (unconfined_service_t) reports clock health by reading
|
|
# /var/log/chrony/tracking.log (type chronyd_var_log_t), which the shipped
|
|
# policy confines to chronyd_t despite the 0644 mode -> denied under enforcing
|
|
# (seen on comet-beacon-54). A minimal read-only allow (no write, no domain
|
|
# transition). Installed unconditionally like the modules above (inert without
|
|
# the agent). checkpolicy ensured above. Same policy-store-rebuild
|
|
# reproducibility caveat as chrony/fapolicyd. docs/stig-deviations.md #16.
|
|
"echo '==> supplemental: SELinux module fleet_agent_chronylog (fleet-agent reads chrony tracking.log)'",
|
|
"checkmodule -M -m -o /tmp/fleet_agent_chronylog.mod /tmp/stig/fleet-agent-chronylog.te",
|
|
"semodule_package -o /tmp/fleet_agent_chronylog.pp -m /tmp/fleet_agent_chronylog.mod",
|
|
"semodule -i /tmp/fleet_agent_chronylog.pp",
|
|
"rm -f /tmp/fleet_agent_chronylog.mod /tmp/fleet_agent_chronylog.pp",
|
|
"semodule -l | grep -q '^fleet_agent_chronylog' && echo '==> SELinux module fleet_agent_chronylog installed' || { echo 'ERROR: fleet_agent_chronylog 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",
|
|
# enable_stig is enforced true (POA&M P-03); fail closed if it ever isn't.
|
|
"if [ \"$ENABLE_STIG\" != \"true\" ]; then echo 'FATAL: 2c-STIG-postfix reached with ENABLE_STIG != true.' >&2; exit 1; 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",
|
|
# RAM: fapolicyd's LMDB trust DB is rebuilt at BOOT onto the tmpfs overlay,
|
|
# so its map size (db_max_size, in MB) is a fixed RAM RESERVATION, not a
|
|
# ceiling it grows into. The EL9 default (100) was measured consuming a real
|
|
# 100 MiB of overlay RAM on a booted host (2026-07-03) — ~80% of the entire
|
|
# overlay. The minimal+kiosk trust set is a fraction of that (fapolicyd
|
|
# upstream shipped db_max_size=50 for years as the default that covers a
|
|
# FULL workstation rpmdb, which is larger than this image). Halve it to
|
|
# reclaim ~50 MiB. MUST run before the --update below so the rebuilt db uses
|
|
# the smaller map. BENCH-VERIFY the exec-guard still works end-to-end (boot +
|
|
# kiosk render + admin SSH + sudo): undersize this and fapolicyd hits
|
|
# MDB_MAP_FULL, allowlisting breaks, and login/kiosk exec is bricked (keep
|
|
# fapolicyd working — it's the writable-overlay exec guard dm-verity can't
|
|
# provide). Can drop lower after measuring `fapolicyd-cli --dump | wc -l` on
|
|
# the bench; see docs/TODO.md.
|
|
" if [ -f /etc/fapolicyd/fapolicyd.conf ]; then sed -ri 's/^[[:space:]]*#?[[:space:]]*db_max_size[[:space:]]*=.*/db_max_size = 50/' /etc/fapolicyd/fapolicyd.conf; grep -q '^db_max_size' /etc/fapolicyd/fapolicyd.conf || printf 'db_max_size = 50\\n' >> /etc/fapolicyd/fapolicyd.conf; echo '==> fapolicyd: db_max_size set to 50 MiB (was EL9 default ~100)'; fi",
|
|
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/local/bin/fleet-agent /usr/local/bin/fleet-ssh-hostcert /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, fleet-agent, fleet-ssh-hostcert) + rebuilt trust db'",
|
|
# watch_fs: the diskless root is OVERLAYFS (systemd.volatile=overlay), but the
|
|
# shipped watch_fs lacks 'overlay', so fapolicyd marks NO overlay mount and
|
|
# therefore intercepts NO exec on / — the exec guard for the writable overlay
|
|
# (the whole reason we keep fapolicyd; dm-verity can't cover the tmpfs upper)
|
|
# is effectively OFF at runtime. VERIFIED on the bench 2026-07-03: a novel
|
|
# non-RPM binary in /tmp (overlayfs) ran freely under `permissive=0`. Add
|
|
# 'overlay' so fapolicyd actually watches the root; trust is path-based
|
|
# (integrity=none) so overlay paths resolve against the rpmdb trust as normal.
|
|
# The #15 tmpfs getattr allow supports this (fapolicyd statfs's the overlay's
|
|
# tmpfs upper). Idempotent. BENCH-VERIFY HARD — this turns app-allowlisting ON
|
|
# for the WHOLE root at runtime for the first time: boot + kiosk render + admin
|
|
# SSH + sudo + TPM enrolment must ALL still work (nothing legit is untrusted),
|
|
# a novel /tmp binary must now be BLOCKED (EPERM), and watch for new AVCs.
|
|
# If it regresses the boot/secure path, waive the offending rule in the
|
|
# tailoring (a justified deviation) — not by disabling STIG (that path is gone).
|
|
" if [ -f /etc/fapolicyd/fapolicyd.conf ]; then",
|
|
" grep -Eq '^watch_fs.*overlay' /etc/fapolicyd/fapolicyd.conf || sed -ri '/^watch_fs *=/ s/$/,overlay/' /etc/fapolicyd/fapolicyd.conf",
|
|
" grep -Eq '^watch_fs.*overlay' /etc/fapolicyd/fapolicyd.conf || { echo 'ERROR: failed to add overlay to fapolicyd watch_fs' >&2; exit 1; }",
|
|
" echo '==> fapolicyd: watch_fs now includes overlay (guards the volatile-overlay root)'",
|
|
" fi",
|
|
"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",
|
|
# nss-mdns / avahi: DO NOT REMOVE — the EPEL `chromium` package hard-Requires
|
|
# nss-mdns (Chromium resolves `.local` mDNS hostnames for WebRTC ICE
|
|
# candidates, to avoid leaking local IPs in peer connections). `dnf remove
|
|
# nss-mdns` therefore takes CHROMIUM with it as a dependent package, then
|
|
# reaps its whole orphaned GUI stack (avahi, at-spi2, atk, adwaita,
|
|
# chromium-common, ...) — a ~300 MB drop that ships a browserless, bricked
|
|
# kiosk (flashing cursor; kiosk-session dies at `${BROWSER:?}`). This bit us
|
|
# 2026-07-03: the earlier commit checked what nss-mdns NEEDS (avahi) but not
|
|
# what NEEDS nss-mdns (chromium). The FINAL GUARD below now asserts the kiosk
|
|
# stack survived so this can never ship silently again.
|
|
#
|
|
# We still don't want the avahi mDNS DAEMON listening on :5353 on a
|
|
# locked-down kiosk (that was the real attack-surface concern). nss-mdns is
|
|
# only an NSS module (libnss_mdns.so) — it needs no daemon; if avahi-daemon
|
|
# isn't running, `.local` lookups just fall through. So keep the PACKAGES
|
|
# (satisfy chromium's Requires) but mask the listener: no socket, no daemon,
|
|
# dep satisfied, browser intact. ~1.5 MB kept — negligible vs a working kiosk.
|
|
"systemctl mask avahi-daemon.service avahi-daemon.socket 2>/dev/null || true",
|
|
"systemctl disable avahi-daemon.service avahi-daemon.socket 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.)
|
|
#
|
|
# .pyc invalidation vs reproducible mtimes: the erofs is built `-T 0`, which
|
|
# zeroes every file's mtime. Python's DEFAULT .pyc invalidation is
|
|
# timestamp-based (the .pyc header stores the source's mtime), so at runtime
|
|
# every source .py looks newer than its baked .pyc and Python recompiles into
|
|
# the tmpfs overlay — wasting RAM and, under enforcing SELinux, throwing
|
|
# lib_t write/unlink AVCs (seen 2026-07-03: dnf/libdnf/rhsm __pycache__
|
|
# rewrites 10 min after boot). Recompile ALL bytecode with hash-based,
|
|
# UNCHECKED invalidation: the .pyc then carries a source hash flagged
|
|
# 'do not check', so Python loads it WITHOUT stat-ing the source, and never
|
|
# rewrites regardless of the `-T 0` mtimes. Bonus: hash .pyc embed no mtime,
|
|
# so they're more reproducible than timestamp .pyc (reinforces the -T 0
|
|
# invariant). MUST run LAST (after every dnf install/remove) so nothing lays
|
|
# down a fresh timestamp .pyc afterward; -f forces existing .pyc to this mode.
|
|
# Version-agnostic loop so an EL point-release python bump doesn't silently
|
|
# skip it. See docs/TODO.md #16.
|
|
"for d in /usr/lib/python3.* /usr/lib64/python3.*; do [ -d \"$d\" ] && python3 -m compileall -f -q --invalidation-mode unchecked-hash \"$d\" 2>/dev/null || true; done",
|
|
#
|
|
# ---- FINAL GUARD: assert the kiosk stack survived the whole pipeline ----
|
|
# 2026-07-03 incident: chromium installed cleanly in the 2b kiosk step (the
|
|
# footprint audit showed chromium-149 + chromium-common, 523 pkgs), but by
|
|
# the final image it was GONE (472 pkgs) — `dnf remove -y nss-mdns` in 2d
|
|
# removed chromium AS A DEPENDENT PACKAGE (EPEL chromium hard-Requires
|
|
# nss-mdns for WebRTC mDNS ICE candidates), then reaped its orphaned GUI
|
|
# stack. Result: a ~500 MB image whose kiosk died at the `${BROWSER:?}` guard
|
|
# in kiosk-session — a flashing-cursor boot with no weston, shipped green.
|
|
# The nss-mdns removal is fixed above (kept + avahi listener masked), but a
|
|
# missing browser must fail the BUILD, never brick the fleet: assert every
|
|
# kiosk-critical package is still installed at the very end of the pipeline
|
|
# (after remediation AND all 2d removes). This catches ANY future silent drop
|
|
# — a new hard-Requires cascade, an oscap package_*_removed rule, autoremove.
|
|
# Run AFTER the .pyc recompile (read-only rpm -q, lays down no bytecode, so it
|
|
# doesn't violate the "recompile last" rule). enable_kiosk=false legitimately
|
|
# ships no browser, so gate on it.
|
|
"if [ \"${var.enable_kiosk}\" = \"true\" ] && [ -n \"${var.kiosk_url}\" ]; then for p in chromium chromium-common weston mesa-dri-drivers mesa-libEGL mesa-libgbm; do rpm -q \"$p\" >/dev/null 2>&1 || { echo \"ERROR: kiosk-critical package '$p' missing from the FINAL image — a later step removed it after the 2b kiosk install (e.g. a dnf remove whose dependency cascade took chromium, cf. the 2026-07-03 nss-mdns incident). Refusing to ship a bricked kiosk.\" >&2; exit 1; }; done; echo '==> guard: kiosk stack intact (chromium/weston/mesa present after remediation + hygiene)'; fi",
|
|
]
|
|
}
|
|
|
|
# --- 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}",
|
|
]
|
|
}
|
|
}
|