VDI Image Build Process — Desktop Images for Frontier Workspace
Build reproducible, FIPS-enabled, STIG-hardened VDI desktop images with Packer. Complete Packer template, provisioner scripts, and customization guide.
This page is the definitive guide for building the OpenStack cloud images that Frontier Workspace uses to provision VDI desktops. It includes the complete Packer template, every provisioner script, the reasoning behind each design decision, and instructions for customizing images for your own environment.
The same immutable-infrastructure pattern used for Kubernetes node images applies here: every desktop VM boots from a Packer-built Glance image with the desktop environment, display server, and hardening controls pre-installed. No first-boot package installs, no SSH-and-configure, no snowflakes.
Why Rocky Linux?
The CUI enclave image is built on Rocky Linux 9, a 1:1 binary-compatible rebuild of Red Hat Enterprise Linux (RHEL). Rocky Linux is a community-supported, freely distributable alternative to RHEL that provides the same packages, the same kernel, the same FIPS-capable cryptographic modules, and the same DISA STIG security profiles — without requiring a Red Hat subscription.
Rocky Linux is not just the VDI image OS — it runs the entire Federal Frontier Platform infrastructure. The Kolla-Ansible containers that provide the OpenStack control plane (Nova, Neutron, Glance, Cinder, Keystone, Horizon) are all Rocky Linux-based. This means the same OS, the same package ecosystem, and the same security posture runs from the hypervisor layer through the control plane and into the VDI desktop. A single patching and compliance workflow covers the full stack.
This matters for federal VDI because:
- STIG profiles are identical. DISA publishes RHEL 9 STIGs. Rocky Linux 9 uses the same package versions, the same SELinux policies, and the same
scap-security-guidecontent. The OpenSCAP STIG profilexccdf_org.ssgproject.content_profile_stigapplies to both. - FIPS cryptographic modules are the same. Rocky Linux 9 ships the same OpenSSL, libgcrypt, NSS, and kernel crypto modules as RHEL 9. The FIPS 140-3 validation status depends on the customer’s procurement path (see FIPS validation below).
- No license cost per desktop. RHEL subscriptions are priced per system. In a VDI environment where workspaces are provisioned and destroyed dynamically, per-system licensing adds cost and complexity. Rocky Linux eliminates this.
- Drop-in replacement. If your organization requires RHEL, swap the base image and change the OpenSCAP datastream filename. Every script in this guide works on RHEL 9 without modification.
Rocky Linux to RHEL version mapping
Rocky Linux versions track RHEL releases exactly. Each Rocky minor release is built from the same source as the corresponding RHEL release.
| Rocky Linux | RHEL | Kernel | Release Date | End of Life |
|---|---|---|---|---|
| 9.0 | 9.0 | 5.14.0-70 | Jul 2022 | May 2032 |
| 9.1 | 9.1 | 5.14.0-162 | Nov 2022 | May 2032 |
| 9.2 | 9.2 | 5.14.0-284 | May 2023 | May 2032 |
| 9.3 | 9.3 | 5.14.0-362 | Nov 2023 | May 2032 |
| 9.4 | 9.4 | 5.14.0-427 | May 2024 | May 2032 |
| 9.5 | 9.5 | 5.14.0-503 | Nov 2024 | May 2032 |
| 9.6 | 9.6 | 5.14.0-573 | May 2025 | May 2032 |
| 9.7 | 9.7 | 5.14.0-611 | Nov 2025 | May 2032 |
The Frontier Workspace CUI image uses Rocky Linux 9.7 (kernel 5.14.0-611). You can use any Rocky 9.x release — the Packer template downloads the latest GenericCloud image automatically.
Other RHEL-compatible alternatives
If Rocky Linux does not meet your organization’s requirements, the same Packer template works with these RHEL-compatible distributions:
| Distribution | Maintainer | FIPS Support | STIG Profile | Notes |
|---|---|---|---|---|
| RHEL 9 | Red Hat | Full (NIST CMVP certified) | ssg-rhel9-ds.xml |
Requires subscription. Gold standard for federal. |
| Rocky Linux 9 | Rocky Enterprise Software Foundation | Same modules as RHEL | ssg-rl9-ds.xml |
Free, 1:1 binary compatible. Default for FFP. |
| AlmaLinux 9 | AlmaLinux OS Foundation | Same modules as RHEL | ssg-al9-ds.xml |
Free, 1:1 binary compatible. |
| Oracle Linux 9 | Oracle | UEK kernel differs | ssg-ol9-ds.xml |
Free. UEK kernel may diverge from RHEL. |
| CentOS Stream 9 | Red Hat / Fedora | Upstream of RHEL | ssg-cs9-ds.xml |
Rolling release. Not recommended for production CUI. |
To switch distributions, change the source_image variable in the Packer template and update the OpenSCAP datastream filename in 05-stig-hardening.sh.
Why Packer for VDI images?
The temptation with VDI is to start from a stock cloud image and use cloud-init to install the desktop environment, XRDP, and security controls at first boot. This fails for the same reasons it fails for Kubernetes nodes:
- Boot time. Installing GNOME or XFCE at first boot adds 10-20 minutes. Users waiting for a workspace after admin approval should wait 60 seconds, not 20 minutes.
- Drift. Cloud-init installs pull whatever package versions are available that day. Two workspaces provisioned a week apart may have different desktop configurations, different security patches, different XRDP versions.
- Airgap. IL5 and IL6 deployments have no path to external package repositories. Desktop packages must be in the image.
- STIG compliance. DISA STIG hardening must be applied consistently to every desktop instance. Running Ansible at first boot on every workspace is fragile and slow. Baking it into the image guarantees every instance is identically hardened.
The rule: if you find yourself SSHing into a VDI VM to install packages, STOP. Fix the Packer template instead.
Why QEMU builder, not OpenStack builder
The Packer template uses the QEMU builder rather than the OpenStack builder. This is a deliberate architecture decision:
| Concern | OpenStack builder | QEMU builder |
|---|---|---|
| Network path for packages | VM → Neutron router → NAT → internet | VM → host user-mode networking → internet |
| Overlay encapsulation | VXLAN adds 50 bytes, fragments large packets | None — direct host networking |
| Floating IP dependency | Requires available floating IP for SSH | No floating IPs needed |
| Build host requirement | Any host with OpenStack CLI | Host with KVM and QEMU installed |
| Glance upload | Automatic (Packer snapshots the volume) | Post-processor runs openstack image create |
In environments with nested virtualization (hypervisors running as VMs), the VXLAN overlay network between the Neutron router and the build VM can introduce significant packet loss on sustained TCP connections. Package downloads that work fine interactively may time out under the sustained load of a 300+ package dnf transaction. The QEMU builder avoids this entirely — the build VM’s network traffic flows through the host’s network stack via QEMU’s user-mode networking (-netdev user), which has direct access to the host’s upstream connection.
If your OpenStack environment has reliable VM-to-internet connectivity (dedicated hardware, no nested virt, proper MTU configuration), the OpenStack builder works fine. The QEMU builder is the safer default.
Image catalog
Frontier Workspace ships two golden images. Both are built with Packer and stored in OpenStack Glance.
| Image | OS | Desktop | Use Case | FIPS | STIG |
|---|---|---|---|---|---|
| ffp-vdi-desktop | Ubuntu 22.04 LTS | XFCE | General knowledge worker, demos | No | No |
| ffp-vdi-rocky9-cui | Rocky Linux 9 | GNOME Classic (X11) | CUI enclave, federal workstations | Yes | Yes |
The standard Ubuntu image is the default for all non-CUI workspaces. The Rocky Linux 9 CUI image is provisioned when an administrator enables the CUI Enclave toggle during workspace approval.
CUI enclave vs standard image
| Feature | Standard (Ubuntu/XFCE) | CUI Enclave (Rocky/GNOME Classic) |
|---|---|---|
| OS | Ubuntu 22.04 LTS | Rocky Linux 9 |
| Desktop | XFCE | GNOME Classic (X11) |
| FIPS mode | No | Yes (fips=1, FIPS crypto policy) |
| STIG hardening | No | Yes (DISA RHEL 9 STIG) |
| OpenSCAP scan | No | Yes (report saved as build artifact) |
| Clipboard (XRDP) | Enabled | Disabled |
| Drive redirection | Enabled | Disabled |
| Sound redirection | Enabled | Disabled |
| Login banner | None | CUI enclave notice |
| Guacamole clipboard | Enabled | Disabled (per-connection) |
| Guacamole file transfer | Enabled | Disabled (per-connection) |
| Session recording | No | Yes (guacd recordings volume) |
| Use case | Demos, non-CUI work | CUI processing, federal workstations |
Pipeline overview
Both images follow the same pipeline stages. The CUI enclave image includes additional hardening steps (stages 6-9).
| Stage | Script | Purpose |
|---|---|---|
| 1 | (inline) | Wait for cloud-init, configure build environment |
| 2 | 01-enable-fips.sh |
Enable FIPS 140-3 mode |
| 3 | (inline) | Reboot for FIPS kernel parameter |
| 4 | (inline) | Verify FIPS mode is active |
| 5 | 02-install-desktop.sh |
Install GNOME Classic, XRDP, OpenSCAP |
| 6 | 03-configure-xrdp.sh |
Harden XRDP channels, configure GNOME Classic session |
| 7 | 04-vdi-user-setup.sh |
Create VDI user, install persistent home mount |
| 8 | 05-stig-hardening.sh |
Apply DISA STIG controls, run OpenSCAP scan |
| 9 | 06-cui-banner.sh |
Configure CUI login banners |
| 10 | (file download) | Extract STIG compliance report |
| 11 | 99-cleanup.sh |
Generalize image for snapshot |
| 12 | (post-processor) | Upload QCOW2 to Glance |
Complete Packer template
Directory structure
packer/rocky9-cui/
├── rocky9-cui.pkr.hcl # Packer template
├── cidata/
│ ├── meta-data # Cloud-init instance metadata
│ └── user-data # Cloud-init user configuration
└── scripts/
├── 01-enable-fips.sh # FIPS 140-3 mode
├── 02-install-desktop.sh # GNOME Classic + XRDP + OpenSCAP
├── 03-configure-xrdp.sh # XRDP hardening
├── 04-vdi-user-setup.sh # VDI user + persistent home
├── 05-stig-hardening.sh # DISA STIG controls + OpenSCAP scan
├── 06-cui-banner.sh # CUI enclave banners
└── 99-cleanup.sh # Image generalization
Prerequisites
Before building, ensure the following are available on your build host:
- Packer 1.9+ with the QEMU plugin (
packer initinstalls it automatically) - QEMU/KVM installed and the build user in the
kvmgroup - Rocky Linux 9 GenericCloud QCOW2 downloaded from dl.rockylinux.org
- OpenStack CLI installed and configured (for the Glance upload post-processor)
- At least 30 GB free disk space for the build
# Install Packer (if not present)
curl -fsSL https://releases.hashicorp.com/packer/1.11.2/packer_1.11.2_linux_amd64.zip -o packer.zip
unzip packer.zip && sudo mv packer /usr/local/bin/
# Add your user to the kvm group
sudo usermod -aG kvm $(whoami)
# Download Rocky 9 GenericCloud image
curl -fsSL -o /tmp/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2 \
https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2
rocky9-cui.pkr.hcl
packer {
required_plugins {
qemu = {
version = ">= 1.0.0"
source = "github.com/hashicorp/qemu"
}
}
}
# =============================================================================
# Variables — customize these for your environment
# =============================================================================
variable "source_image" {
type = string
default = "/tmp/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2"
description = "Path to Rocky Linux 9 GenericCloud QCOW2 on the build host"
}
variable "output_directory" {
type = string
default = "/tmp/packer-output"
description = "Directory for the built QCOW2 image"
}
variable "disk_size" {
type = string
default = "20G"
description = "Root disk size for the image"
}
variable "memory" {
type = number
default = 4096
description = "VM memory in MB during build (4096 recommended for GNOME install)"
}
variable "cpus" {
type = number
default = 4
description = "Number of vCPUs during build"
}
# =============================================================================
# Source: QEMU — builds locally using host KVM
# =============================================================================
source "qemu" "rocky9-cui" {
iso_url = var.source_image
iso_checksum = "none"
disk_image = true
disk_size = var.disk_size
format = "qcow2"
output_directory = var.output_directory
accelerator = "kvm"
cpus = var.cpus
memory = var.memory
# User-mode networking — packages install through the host's network stack.
net_device = "virtio-net"
headless = true
# SSH access for provisioners
ssh_username = "rocky"
ssh_password = "rocky"
ssh_timeout = "20m"
ssh_handshake_attempts = 50
shutdown_command = "sudo shutdown -P now"
# Cloud-init NoCloud datasource — sets up the rocky user with password auth
cd_files = ["${path.root}/cidata/*"]
cd_label = "cidata"
qemuargs = [
["-cpu", "host"],
["-smp", "${var.cpus}"],
["-m", "${var.memory}"]
]
vm_name = "ffp-vdi-rocky9-cui.qcow2"
}
# =============================================================================
# Build — each provisioner runs in sequence
# =============================================================================
build {
sources = ["source.qemu.rocky9-cui"]
# 1. Wait for cloud-init to finish
provisioner "shell" {
inline = [
"echo 'Waiting for cloud-init to complete...'",
"cloud-init status --wait || true",
"echo 'Cloud-init complete.'"
]
}
# 2. Enable FIPS mode (must happen early — requires reboot)
provisioner "shell" {
script = "scripts/01-enable-fips.sh"
execute_command = "chmod +x ; sudo -E "
}
# 3. Reboot for FIPS kernel parameter to take effect
provisioner "shell" {
expect_disconnect = true
inline = ["sudo reboot"]
}
# 4. Verify FIPS mode is active after reboot
provisioner "shell" {
pause_before = "30s"
inline = [
"echo 'Verifying FIPS mode after reboot...'",
"FIPS=$(cat /proc/sys/crypto/fips_enabled)",
"echo \"FIPS enabled: $FIPS\"",
"if [ \"$FIPS\" != \"1\" ]; then echo 'ERROR: FIPS mode not active'; exit 1; fi",
"echo 'Crypto policy:' $(update-crypto-policies --show)"
]
}
# 5. Install GNOME Classic desktop + XRDP + OpenSCAP
provisioner "shell" {
script = "scripts/02-install-desktop.sh"
execute_command = "chmod +x ; sudo -E "
timeout = "60m"
}
# 6. Configure XRDP for GNOME Classic with CUI channel hardening
provisioner "shell" {
script = "scripts/03-configure-xrdp.sh"
execute_command = "chmod +x ; sudo -E "
}
# 7. Create vdi-user and persistent home mount service
provisioner "shell" {
script = "scripts/04-vdi-user-setup.sh"
execute_command = "chmod +x ; sudo -E "
}
# 8. DISA STIG hardening + OpenSCAP compliance scan
provisioner "shell" {
script = "scripts/05-stig-hardening.sh"
execute_command = "chmod +x ; sudo -E "
}
# 9. CUI banner configuration
provisioner "shell" {
script = "scripts/06-cui-banner.sh"
execute_command = "chmod +x ; sudo -E "
}
# 10. Download STIG report before cleanup deletes /tmp
provisioner "file" {
source = "/tmp/stig-report.html"
destination = "stig-report.html"
direction = "download"
}
# 11. Generalize image (must run last)
provisioner "shell" {
script = "scripts/99-cleanup.sh"
execute_command = "chmod +x ; sudo -E "
}
# 12. Upload finished QCOW2 to Glance
post-processor "shell-local" {
keep_input_artifact = true
inline = [
"echo 'Uploading image to Glance...'",
". /path/to/your/openrc.sh",
"openstack image create ffp-vdi-rocky9-cui \\",
" --disk-format qcow2 --container-format bare \\",
" --file ${var.output_directory}/ffp-vdi-rocky9-cui.qcow2 \\",
" --property os_distro=rocky \\",
" --property os_version=9 \\",
" --property architecture=x86_64 \\",
" --property desktop=gnome-classic \\",
" --property fips_enabled=true \\",
" --property stig_hardened=true \\",
" --property cui_enclave=true \\",
" --property built_by=packer",
"echo 'Image uploaded to Glance.'"
]
}
}
Key design decisions in this template:
disk_image = truetells Packer the source is an existing QCOW2, not an ISO. Packer copies it and boots from the copy.cd_files/cd_labelattach a cloud-init NoCloud ISO to the VM. This is how therockyuser gets password-based SSH access for the provisioners.expect_disconnect = trueon the reboot step tells Packer to expect the SSH connection to drop and wait for it to come back.pause_before = "30s"gives the VM time to boot after the FIPS reboot before Packer tries to reconnect.keep_input_artifact = trueon the post-processor preserves the built QCOW2 even if the Glance upload fails. Without this, a failed upload deletes the image you just spent 15 minutes building.
Cloud-init data
The cidata/ directory contains two files that cloud-init reads at first boot to configure SSH access for Packer.
cidata/meta-data:
instance-id: packer-rocky9-cui
local-hostname: ffp-vdi-rocky9-cui
cidata/user-data:
#cloud-config
users:
- name: rocky
plain_text_passwd: rocky
lock_passwd: false
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_pwauth: true
chpasswd:
expire: false
This creates the rocky user with password rocky and enables SSH password authentication. This user exists only during the build — the cleanup step removes its bash history, and production access is through the vdi-user account configured by cloud-init at workspace provisioning time.
Provisioner scripts
Each script runs as root (sudo -E) inside the build VM. They are designed to be idempotent and to fail loudly on errors (set -euo pipefail).
01-enable-fips.sh — FIPS 140-3 mode
#!/bin/bash
# Enable FIPS 140-3 mode on Rocky Linux 9
set -euo pipefail
echo "=== Enabling FIPS mode ==="
# Increase dnf timeout and retries
cat >> /etc/dnf/dnf.conf << 'DNFCONF'
timeout=120
retries=5
fastestmirror=1
max_parallel_downloads=5
DNFCONF
# Install crypto-policies-scripts (provides fips-mode-setup)
dnf install -y crypto-policies-scripts
# Enable FIPS mode (sets fips=1 on kernel command line and switches crypto policy)
fips-mode-setup --enable
echo "FIPS mode enabled. Will take effect after reboot."
echo "Crypto policy: $(update-crypto-policies --show)"
What this does:
- Installs
crypto-policies-scripts, which provides thefips-mode-setupcommand - Runs
fips-mode-setup --enable, which addsfips=1to the GRUB kernel command line and switches the system-wide crypto policy to FIPS - After the reboot (handled by the next Packer step), the kernel boots in FIPS mode and all cryptographic libraries (OpenSSL, libgcrypt, NSS, GnuTLS) restrict themselves to FIPS-approved algorithms
Why FIPS runs first: FIPS mode changes how TLS libraries negotiate connections. Some packages behave differently under FIPS (e.g., SHA-1 is disabled). By enabling FIPS before installing the desktop environment, you ensure every subsequent package installation and configuration happens under the same crypto policy that production workspaces will use.
02-install-desktop.sh — GNOME Classic and XRDP
#!/bin/bash
# Install GNOME Classic desktop environment and XRDP on Rocky Linux 9
# Uses X11 session — Wayland does not work with XRDP.
set -euo pipefail
echo "=== Installing GNOME Classic desktop ==="
# Retry wrapper for dnf — handles transient network issues
dnf_retry() {
for i in $(seq 1 3); do
if dnf "$@"; then return 0; fi
echo " dnf retry $i/3 — waiting 15s..."
sleep 15
done
dnf "$@" # final attempt, let it fail if still broken
}
# Install GNOME Classic desktop — targeted packages only.
# Do NOT use groupinstall "Server with GUI" — it pulls 795 packages (1.7GB)
# including Container Management, Printing Client, Hardware Monitoring, etc.
# that are unnecessary for a VDI image.
dnf_retry install -y \
gnome-classic-session \
gnome-session-xsession \
gnome-terminal \
gnome-system-monitor \
gnome-settings-daemon \
gnome-control-center \
nautilus \
gdm \
xorg-x11-server-Xorg \
xorg-x11-xinit \
mesa-dri-drivers \
dejavu-sans-fonts \
dejavu-serif-fonts \
dejavu-sans-mono-fonts \
liberation-fonts \
dbus-x11
# Use multi-user target — xrdp handles display sessions, GDM is not needed.
# Setting graphical.target starts GDM which conflicts with xrdp and creates
# a systemd ordering cycle ("Ordering cycle found, skipping xrdp daemon").
systemctl set-default multi-user.target
# Disable GDM — xrdp-sesman manages its own X sessions via startwm.sh.
# GDM fights with xrdp over display :0 and creates dependency cycles.
systemctl disable gdm 2>/dev/null || true
systemctl mask gdm
# Ensure Wayland is disabled in GDM config (belt-and-suspenders)
mkdir -p /etc/gdm
cat > /etc/gdm/custom.conf << 'GDMCONF'
[daemon]
WaylandEnable=false
AutomaticLoginEnable=false
[security]
[xdmcp]
[chooser]
[debug]
GDMCONF
echo "=== Installing XRDP ==="
# EPEL is required for xrdp on Rocky 9
dnf_retry install -y epel-release
dnf_retry install -y xrdp xorgxrdp
# Enable xrdp services
systemctl enable xrdp
systemctl enable xrdp-sesman
echo "=== Installing cloud-init and utilities ==="
dnf_retry install -y cloud-init cloud-utils-growpart
dnf_retry install -y \
iscsi-initiator-utils \
nfs-utils \
openssh-server \
firewalld \
dbus-x11 \
xterm \
firefox
echo "=== Installing OpenSCAP for STIG scanning ==="
dnf_retry install -y \
openscap-scanner \
scap-security-guide
# Enable SSH
systemctl enable sshd
echo "Desktop installation complete."
Critical design decisions:
-
multi-user.target, notgraphical.target. Settinggraphical.targetstarts GDM (the GNOME Display Manager), which competes with xrdp for the X display and creates a systemd ordering cycle. The symptom:[SKIP] Ordering cycle found, skipping xrdp daemonin the console log, and xrdp never starts. The fix: usemulti-user.targetand let xrdp-sesman manage its own X sessions independently. -
GDM is masked. Even with
multi-user.target, GDM can be accidentally started by other services that depend ondisplay-manager.service. Masking it prevents this entirely. -
Targeted packages, not
groupinstall "Server with GUI". The “Server with GUI” group installs 795 packages (1.7 GB) including Container Management, Printing Client, Hardware Monitoring Utilities, and other groups irrelevant to a VDI image. The targeted package list installs only what GNOME Classic and XRDP actually need (~400 packages, ~300 MB). -
Wayland is disabled. XRDP does not support Wayland. If any GNOME component attempts to start a Wayland session, the desktop will not render over RDP.
03-configure-xrdp.sh — XRDP session and channel hardening
#!/bin/bash
# Configure XRDP for GNOME Classic with CUI enclave channel hardening
set -euo pipefail
echo "=== Configuring XRDP for GNOME Classic ==="
# Set the default session to GNOME Classic on X11
cat > /etc/xrdp/startwm.sh << 'STARTWM'
#!/bin/sh
if [ -r /etc/default/locale ]; then
. /etc/default/locale
export LANG LANGUAGE
fi
export XDG_SESSION_TYPE=x11
exec dbus-launch --exit-with-session gnome-session --session=gnome-classic
STARTWM
chmod +x /etc/xrdp/startwm.sh
# Harden XRDP channels for CUI enclave
# Disable clipboard, drive redirection, and sound
cat >> /etc/xrdp/xrdp.ini << 'CHANNELS'
; CUI Enclave hardening — disable data exfiltration channels
[Channels]
allow_channels=true
cliprdr=false
rdpdr=false
rdpsnd=false
drdynvc=true
CHANNELS
# Fix key.pem permissions (prevents "Cannot read private key" error)
chmod 640 /etc/xrdp/key.pem 2>/dev/null || true
chown root:xrdp /etc/xrdp/key.pem 2>/dev/null || true
# Open firewall for XRDP (3389) and SSH (22)
if command -v firewall-offline-cmd &>/dev/null; then
firewall-offline-cmd --add-port=3389/tcp
firewall-offline-cmd --add-service=ssh
elif command -v firewall-cmd &>/dev/null; then
firewall-cmd --add-port=3389/tcp --permanent 2>/dev/null || true
firewall-cmd --add-service=ssh --permanent 2>/dev/null || true
fi
echo "XRDP configured for GNOME Classic with CUI hardening."
Channel hardening explained:
cliprdr=false— Disables clipboard sharing between the user’s local machine and the remote desktop. This prevents copy/paste of CUI data out of the enclave.rdpdr=false— Disables drive redirection. Users cannot map local drives into the remote session to copy files.rdpsnd=false— Disables sound redirection. Not a security control, but reduces bandwidth.drdynvc=true— Dynamic virtual channels remain enabled for display rendering.
For non-CUI images, set all three to true to enable clipboard, drive mapping, and sound.
04-vdi-user-setup.sh — VDI user and persistent home
#!/bin/bash
# Create vdi-user and install persistent home mount service
set -euo pipefail
echo "=== Creating vdi-user ==="
# Create vdi-user with no password (cloud-init sets it at boot)
useradd -m -s /bin/bash -G wheel vdi-user
echo "vdi-user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vdi-user
chmod 440 /etc/sudoers.d/vdi-user
# Seed .xsession for GNOME Classic
echo "gnome-session --session=gnome-classic" > /home/vdi-user/.xsession
chown vdi-user:vdi-user /home/vdi-user/.xsession
# Also seed in /etc/skel for any future users
echo "gnome-session --session=gnome-classic" > /etc/skel/.xsession
echo "=== Installing persistent home mount service ==="
# Script that mounts /dev/vdb at /home/vdi-user
cat > /usr/local/sbin/vdi-mount-persistent-home.sh << 'MOUNTSCRIPT'
#!/bin/bash
# Mount persistent Cinder volume at /home/vdi-user
# Called by vdi-persistent-home.service at boot
set -euo pipefail
DEVICE="/dev/vdb"
MOUNT_POINT="/home/vdi-user"
USER="vdi-user"
if [ ! -b "$DEVICE" ]; then
echo "No persistent volume at $DEVICE — using ephemeral home"
exit 0
fi
# Format on first boot (no filesystem detected)
if ! blkid -o value -s TYPE "$DEVICE" &>/dev/null; then
echo "First boot: formatting $DEVICE as ext4"
mkfs.ext4 -L vdi-home "$DEVICE"
fi
# fsck before mount
e2fsck -y "$DEVICE" || true
# Mount
mount "$DEVICE" "$MOUNT_POINT"
# Seed from /etc/skel if empty
if [ ! -f "$MOUNT_POINT/.bashrc" ]; then
cp -a /etc/skel/. "$MOUNT_POINT/"
fi
# Fix ownership
chown -R "$USER:$USER" "$MOUNT_POINT"
echo "Persistent home mounted at $MOUNT_POINT"
MOUNTSCRIPT
chmod +x /usr/local/sbin/vdi-mount-persistent-home.sh
# Systemd oneshot service
cat > /etc/systemd/system/vdi-persistent-home.service << 'SVCUNIT'
[Unit]
Description=Mount persistent Cinder volume at /home/vdi-user
After=local-fs.target cloud-init.target
# Do NOT add Before=xrdp.service or Before=display-manager.service here.
# That creates a systemd ordering cycle with graphical.target dependencies.
# The mount completes in seconds; xrdp sessions start minutes later when
# a user connects via Guacamole. No ordering dependency needed.
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/vdi-mount-persistent-home.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
SVCUNIT
systemctl enable vdi-persistent-home.service
echo "vdi-user and persistent home service configured."
Why no Before=xrdp.service: Adding Before=xrdp.service or Before=display-manager.service to the persistent home unit creates a systemd ordering cycle. The mount service depends on local-fs.target and cloud-init.target, and xrdp depends on multi-user.target. Adding cross-dependencies between these chains creates a cycle that systemd resolves by skipping xrdp entirely. The mount completes in under a second; xrdp sessions don’t start until a user connects via Guacamole minutes later. No ordering constraint is needed.
05-stig-hardening.sh — DISA STIG controls and OpenSCAP scan
#!/bin/bash
# DISA STIG hardening for Rocky Linux 9 / RHEL 9
# Applies key NIST 800-171 / STIG controls for CUI enclave VDI.
set -euo pipefail
echo "=== Applying STIG hardening ==="
# --- AC-11: Session lock after 15 minutes of inactivity ---
cat > /etc/dconf/db/local.d/00-screensaver << 'DCONF'
[org/gnome/desktop/session]
idle-delay=uint32 900
[org/gnome/desktop/screensaver]
lock-enabled=true
lock-delay=uint32 0
DCONF
# Lock the screensaver settings so users can't disable them
mkdir -p /etc/dconf/db/local.d/locks
cat > /etc/dconf/db/local.d/locks/screensaver << 'LOCKS'
/org/gnome/desktop/session/idle-delay
/org/gnome/desktop/screensaver/lock-enabled
/org/gnome/desktop/screensaver/lock-delay
LOCKS
dconf update
# --- AU-2/AU-3: Audit logging ---
dnf install -y audit
systemctl enable auditd
cat > /etc/audit/rules.d/cui-enclave.rules << 'AUDITRULES'
# Login/logout events
-w /var/log/lastlog -p wa -k logins
-w /var/run/faillock -p wa -k logins
# Privileged command execution
-a always,exit -F arch=b64 -S execve -C uid!=euid -F euid=0 -k privilege_escalation
# File deletion
-a always,exit -F arch=b64 -S unlink,unlinkat,rename,renameat -F auid>=1000 -F auid!=4294967295 -k delete
# Sudo usage
-w /etc/sudoers -p wa -k sudo_changes
-w /etc/sudoers.d -p wa -k sudo_changes
# SSH config changes
-w /etc/ssh/sshd_config -p wa -k sshd_config
AUDITRULES
# --- AC-7: Account lockout after 3 failed attempts ---
cat > /etc/security/faillock.conf << 'FAILLOCK'
deny = 3
unlock_time = 900
fail_interval = 900
audit
FAILLOCK
# --- IA-5: Password complexity ---
dnf install -y libpwquality
cat > /etc/security/pwquality.conf << 'PWQUALITY'
minlen = 15
dcredit = -1
ucredit = -1
lcredit = -1
ocredit = -1
maxrepeat = 3
maxclassrepeat = 4
PWQUALITY
# --- SC-8: SSH hardening ---
cat > /etc/ssh/sshd_config.d/99-stig.conf << 'SSHD'
PermitRootLogin no
MaxAuthTries 4
MaxSessions 4
ClientAliveInterval 600
ClientAliveCountMax 1
PermitEmptyPasswords no
X11Forwarding no
Banner /etc/issue.net
SSHD
# --- CM-6: Disable USB storage ---
echo "install usb-storage /bin/true" > /etc/modprobe.d/usb-storage.conf
echo "blacklist usb-storage" > /etc/modprobe.d/blacklist-usb-storage.conf
# --- SC-4: Disable core dumps ---
echo "* hard core 0" >> /etc/security/limits.d/99-stig.conf
echo "kernel.core_pattern=|/bin/false" >> /etc/sysctl.d/99-stig.conf
# --- Apply sysctl ---
sysctl --system
# --- OpenSCAP STIG scan (evidence for ATO package) ---
echo "=== Running OpenSCAP STIG evaluation ==="
STIG_PROFILE="xccdf_org.ssgproject.content_profile_stig"
STIG_DS="/usr/share/xml/scap/ssg/content/ssg-rl9-ds.xml"
if [ -f "$STIG_DS" ]; then
# Evaluate only — do NOT --remediate (our manual controls are intentional
# and oscap remediation can break xrdp/SSH access)
oscap xccdf eval \
--profile "$STIG_PROFILE" \
--report /tmp/stig-report.html \
--results /tmp/stig-results.xml \
"$STIG_DS" || true # oscap returns non-zero if any rule fails
echo "STIG report saved to /tmp/stig-report.html"
PASS=$(grep -c 'result>pass<' /tmp/stig-results.xml 2>/dev/null || echo "?")
FAIL=$(grep -c 'result>fail<' /tmp/stig-results.xml 2>/dev/null || echo "?")
echo "OpenSCAP STIG score: $PASS passed, $FAIL failed"
else
echo "WARNING: STIG datastream not found at $STIG_DS — skipping scan"
fi
echo "STIG hardening applied."
STIG controls reference:
| Control | NIST 800-171 | Implementation | DISA STIG ID |
|---|---|---|---|
| Session lock | AC-11 | GNOME screensaver locks after 15 min, dconf locked | RHEL-09-271040 |
| Audit logging | AU-2, AU-3 | auditd with rules for logins, privilege escalation, file deletion, sudo, SSH | RHEL-09-653010 |
| Account lockout | AC-7 | faillock: 3 failed attempts, 15-min lockout | RHEL-09-411075 |
| Password complexity | IA-5 | 15-char minimum, upper/lower/digit/special required | RHEL-09-611030 |
| SSH hardening | SC-8 | No root login, max 4 auth tries, ClientAliveCountMax=1, no X11 forwarding | RHEL-09-255070 |
| USB storage | CM-6 | usb-storage module blacklisted | RHEL-09-291010 |
| Core dumps | SC-4 | Hard limit 0, core_pattern to /bin/false | RHEL-09-214010 |
| Login banner | AC-8 | CUI notice on console, SSH, and GDM | RHEL-09-271035 |
Why ClientAliveCountMax 1, not 0: DISA STIG RHEL-09-255070 specifies ClientAliveCountMax must be set to 1. Setting it to 0 (as some hardening guides suggest) causes SSH to send zero keepalive probes and disconnect the session immediately when ClientAliveInterval expires, making the VM unmanageable via SSH.
Why evaluate-only, not --remediate: OpenSCAP’s automated remediation can disable XRDP, lock out SSH access, or change PAM configurations in ways that break VDI functionality. The manual controls above are specifically chosen to harden the system without breaking the remote desktop workflow. The OpenSCAP scan runs in evaluate-only mode to produce an HTML compliance report for the ATO package.
06-cui-banner.sh — Login banners
#!/bin/bash
# CUI enclave banner and final configuration
set -euo pipefail
echo "=== Configuring CUI enclave banner ==="
BANNER="CUI Enclave — Authorized Use Only — All sessions are monitored and recorded"
# /etc/motd — shown after login
echo "$BANNER" > /etc/motd
# /etc/issue — shown before login (local console)
echo "$BANNER" > /etc/issue
# /etc/issue.net — shown before SSH login
echo "$BANNER" > /etc/issue.net
# GNOME login banner (GDM)
mkdir -p /etc/dconf/db/gdm.d
cat > /etc/dconf/db/gdm.d/01-banner-message << 'DCONF'
[org/gnome/login-screen]
banner-message-enable=true
banner-message-text='CUI Enclave — Authorized Use Only — All sessions are monitored and recorded'
DCONF
dconf update
echo "CUI banner configured."
Customize the banner text by changing the BANNER variable. NIST 800-171 AC-8 requires a system use notification that includes: authorized use only, monitoring disclosure, and consent statement.
99-cleanup.sh — Image generalization
#!/bin/bash
# Clean up for image generalization — must run last
set -euo pipefail
echo "=== Cleaning up for image generalization ==="
# Remove SSH host keys (regenerated on first boot)
rm -f /etc/ssh/ssh_host_*
# Remove cloud-init state (re-runs on first boot)
cloud-init clean --logs
# Remove machine-id (regenerated on first boot)
truncate -s 0 /etc/machine-id
# Clear package cache
dnf clean all
rm -rf /var/cache/dnf/*
# Clear temporary files
rm -rf /tmp/* /var/tmp/*
# Clear bash history
unset HISTFILE
rm -f /root/.bash_history /home/rocky/.bash_history /home/vdi-user/.bash_history
history -c
# Clear logs
find /var/log -type f -exec truncate -s 0 {} \;
echo "Cleanup complete. Image ready for snapshot."
Building the image
Run the build
cd packer/rocky9-cui
# Initialize Packer plugins (first time only)
packer init .
# Build the image
packer build .
The build takes approximately 15 minutes. Packer output shows each provisioner step in real time.
To override variables:
packer build \
-var "source_image=/path/to/Rocky-9-GenericCloud.qcow2" \
-var "disk_size=30G" \
-var "memory=8192" \
.
Build artifacts
After a successful build:
| Artifact | Location | Purpose |
|---|---|---|
| QCOW2 image | /tmp/packer-output/ffp-vdi-rocky9-cui.qcow2 |
The golden image, uploaded to Glance |
| STIG report | ./stig-report.html |
OpenSCAP compliance report for ATO evidence |
Verify the image
Boot a test VM from the image and run the verification checklist:
# Create a test VM
openstack server create --image ffp-vdi-rocky9-cui \
--flavor m1.medium --network your-network \
--key-name your-keypair --boot-from-volume 20 \
cui-verify-test --wait
# SSH in and verify
ssh rocky@<floating-ip>
# FIPS mode
cat /proc/sys/crypto/fips_enabled # Should return: 1
update-crypto-policies --show # Should return: FIPS
# XRDP
systemctl is-active xrdp # Should return: active
systemctl is-active xrdp-sesman # Should return: active
ss -tlnp | grep 3389 # Should show LISTEN
# Systemd target (no GDM conflict)
systemctl get-default # Should return: multi-user.target
systemctl is-enabled gdm # Should return: masked
# XRDP channel hardening
grep cliprdr /etc/xrdp/xrdp.ini # Should show: cliprdr=false
# STIG controls
grep ClientAliveCountMax /etc/ssh/sshd_config.d/99-stig.conf # Should show: 1
cat /etc/security/faillock.conf # deny = 3
grep minlen /etc/security/pwquality.conf # minlen = 15
# CUI banner
cat /etc/motd # Should show CUI notice
# VDI user
id vdi-user # Should exist in wheel group
systemctl is-enabled vdi-persistent-home # Should return: enabled
# Clean up
openstack server delete cui-verify-test
Customization guide
Building a non-CUI standard image
To build a standard (non-CUI) image without FIPS or STIG hardening:
- Remove or skip
01-enable-fips.shand the reboot/verify steps - Replace GNOME Classic with XFCE in
02-install-desktop.sh:dnf install -y @xfce-desktop xfce4-terminal - Enable clipboard and drive redirection in
03-configure-xrdp.sh:cliprdr=true rdpdr=true rdpsnd=true - Update startwm.sh for XFCE:
exec startxfce4 - Remove
05-stig-hardening.shand06-cui-banner.shfrom the build - Remove the STIG report download step
Using a different base OS
The template works with any RHEL-family GenericCloud image:
- AlmaLinux 9: Change the source image and replace
ssg-rl9-ds.xmlwithssg-al9-ds.xmlin the STIG script - RHEL 9: Use the Red Hat GenericCloud image from your subscription. STIG datastream is
ssg-rhel9-ds.xml - CentOS Stream 9: Use
ssg-cs9-ds.xml
Adding custom packages
Add packages to 02-install-desktop.sh in the “additional packages” section:
dnf_retry install -y \
your-custom-package \
your-other-package
Changing STIG controls
Edit 05-stig-hardening.sh to add, remove, or modify controls. Each control is clearly labeled with its NIST 800-171 mapping. To add a new control:
- Identify the DISA STIG rule ID and NIST 800-171 control
- Add the configuration commands under a labeled comment
- Re-run the OpenSCAP scan to verify the new control passes
Session recording
Guacamole session recording is configured per-connection, not in the image. The deploy script sets these Guacamole connection parameters for CUI workspaces:
| Parameter | Value | Effect |
|---|---|---|
recording-path |
/var/lib/guacamole/recordings |
Directory on guacd pod for session files |
recording-name |
<workspace-name>-<timestamp> |
Unique filename per session |
create-recording-path |
true |
Auto-create directory if missing |
The guacd pod must have a PersistentVolumeClaim mounted at /var/lib/guacamole/recordings with sufficient storage (estimate ~60 GB/day at 100 concurrent users).
Image lifecycle
Images are versioned by name. To patch or update:
- Update the Packer scripts with new package versions or security fixes
- Run
packer buildto produce a new image - Upload to Glance with a new name or replace the existing one
- Update the deploy script’s
CUI_IMAGEvariable to reference the new image - Existing workspaces continue running on the old image
- New workspaces provision on the new image
- Users with persistent Cinder volumes can be migrated by destroying the old workspace and provisioning a new one — the persistent volume carries their home directory data across images
FIPS validation
FIPS mode is enabled at the kernel level (fips=1) and the system crypto policy is set to FIPS. To verify on a running workspace:
fips-mode-setup --check
# Expected: FIPS mode is enabled.
cat /proc/sys/crypto/fips_enabled
# Expected: 1
update-crypto-policies --show
# Expected: FIPS
The NIST CMVP validation certificate is the customer’s responsibility. Rocky Linux’s FIPS mode uses the same kernel crypto modules as RHEL 9 (BoringSSL, libgcrypt, OpenSSL). The validation path depends on your procurement relationship: Red Hat subscription (RHEL certification), CIQ Rocky Linux Pro (CIQ certification), TuxCare (TuxCare certification), or self-attestation with the OpenSCAP report as evidence.
Troubleshooting
XRDP: “Ordering cycle found, skipping xrdp daemon”
Cause: GDM (GNOME Display Manager) is enabled and conflicts with xrdp’s systemd dependencies.
Fix: Ensure multi-user.target is the default and GDM is masked:
systemctl set-default multi-user.target
systemctl mask gdm
SSH disconnects immediately after banner
Cause: ClientAliveCountMax is set to 0 instead of 1 in the STIG SSH configuration.
Fix: In /etc/ssh/sshd_config.d/99-stig.conf, set:
ClientAliveCountMax 1
GNOME Classic doesn’t load over XRDP
Cause: The XRDP session file must explicitly specify the GNOME Classic session type.
Fix: Ensure /etc/xrdp/startwm.sh contains:
export XDG_SESSION_TYPE=x11
exec dbus-launch --exit-with-session gnome-session --session=gnome-classic
Also ensure the user has a .xsession file:
echo "gnome-session --session=gnome-classic" > /home/vdi-user/.xsession
XRDP login fails — “login failed for display 0”
Cause: PAM authentication failed. The vdi-user password set by cloud-init does not match the password in the Guacamole connection configuration.
Fix: The deploy script generates a random password and passes it to both cloud-init (to set the vdi-user password) and the Guacamole connection. If the passwords don’t match, RDP login fails. Verify both sides have the same password:
# On the VDI VM — check if vdi-user has a password set
sudo passwd -S vdi-user
# Expected: vdi-user PS ... (Password set)
# If it shows LK (Locked), cloud-init did not set the password
Packer build fails with “failed to initialize KVM: Permission denied”
Cause: The build user is not in the kvm group.
Fix:
sudo usermod -aG kvm $(whoami)
# Log out and back in, or use: sg kvm -c "packer build ."
OpenSCAP scan shows many failures
The manual STIG hardening in 05-stig-hardening.sh applies the most critical controls for CUI enclave VDI. The full DISA STIG profile includes hundreds of rules, many of which are not applicable to a VDI desktop (e.g., UEFI Secure Boot, disk encryption at rest, AIDE file integrity). A score of 190 pass / 257 fail is expected with the manual hardening approach. To increase the score, add additional controls to the hardening script — but test each one to ensure it doesn’t break XRDP or SSH access.