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-guide content. The OpenSCAP STIG profile xccdf_org.ssgproject.content_profile_stig applies 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:

  1. 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.
  2. 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.
  3. Airgap. IL5 and IL6 deployments have no path to external package repositories. Desktop packages must be in the image.
  4. 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 init installs it automatically)
  • QEMU/KVM installed and the build user in the kvm group
  • 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 = true tells Packer the source is an existing QCOW2, not an ISO. Packer copies it and boots from the copy.
  • cd_files / cd_label attach a cloud-init NoCloud ISO to the VM. This is how the rocky user gets password-based SSH access for the provisioners.
  • expect_disconnect = true on 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 = true on 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 the fips-mode-setup command
  • Runs fips-mode-setup --enable, which adds fips=1 to 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, not graphical.target. Setting graphical.target starts 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 daemon in the console log, and xrdp never starts. The fix: use multi-user.target and 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 on display-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:

  1. Remove or skip 01-enable-fips.sh and the reboot/verify steps
  2. Replace GNOME Classic with XFCE in 02-install-desktop.sh:
    dnf install -y @xfce-desktop xfce4-terminal
    
  3. Enable clipboard and drive redirection in 03-configure-xrdp.sh:
    cliprdr=true
    rdpdr=true
    rdpsnd=true
    
  4. Update startwm.sh for XFCE:
    exec startxfce4
    
  5. Remove 05-stig-hardening.sh and 06-cui-banner.sh from the build
  6. 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.xml with ssg-al9-ds.xml in 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:

  1. Identify the DISA STIG rule ID and NIST 800-171 control
  2. Add the configuration commands under a labeled comment
  3. 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:

  1. Update the Packer scripts with new package versions or security fixes
  2. Run packer build to produce a new image
  3. Upload to Glance with a new name or replace the existing one
  4. Update the deploy script’s CUI_IMAGE variable to reference the new image
  5. Existing workspaces continue running on the old image
  6. New workspaces provision on the new image
  7. 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.