diff --git a/SETUP.md b/SETUP.md index e88289a..c15f656 100644 --- a/SETUP.md +++ b/SETUP.md @@ -80,8 +80,19 @@ sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso # With WinPE images bundled (single USB, larger drive needed) sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04.iso /path/to/winpe-images + +# Same layout into a disk-image file, no root (dd it to a stick later, or +# boot it in a VM: see tests/vm/README.md). SIZE is e.g. 16G or "auto". +./scripts/build-usb.sh --image pxe-usb.img auto /path/to/ubuntu-24.04.iso ``` +Options: `--payload-root DIR` takes offline-packages/, pip-wheels/, +boot-files/, boot-tools/, enrollment/, bios-staging/ and drivers-staging/ +from another checkout; `--skip-enrollment`, `--skip-bios`, +`--skip-drivers`, `--skip-boot-tools` leave those out; `--yes` skips the +prompt. The ISO volume label and El Torito load sizes are read from the +ISO, so any 24.04.x point release works. + This creates a bootable USB with: - **Partition 1:** Ubuntu Server installer - **Partition 2:** CIDATA (autoinstall config, offline .debs, pip wheels, Ansible playbook, webapp, boot tools) diff --git a/scripts/build-proxmox-iso.sh b/scripts/build-proxmox-iso.sh index 263afd4..c925540 100755 --- a/scripts/build-proxmox-iso.sh +++ b/scripts/build-proxmox-iso.sh @@ -225,9 +225,12 @@ echo " Copied playbook/" # Flask webapp if [ -d "$WEBAPP_DIR" ]; then - mkdir -p "$PXE_DATA/webapp" - cp "$WEBAPP_DIR/app.py" "$WEBAPP_DIR/requirements.txt" "$PXE_DATA/webapp/" - cp -r "$WEBAPP_DIR/templates" "$WEBAPP_DIR/static" "$PXE_DATA/webapp/" + # Whole tree: app.py imports config, services/ and views/, and the + # Blancco evidence timer runs blancco_evidence_monthly.py. + rm -rf "$PXE_DATA/webapp" + cp -r "$WEBAPP_DIR" "$PXE_DATA/webapp" + find "$PXE_DATA/webapp" -name __pycache__ -type d -prune -exec rm -rf {} + + find "$PXE_DATA/webapp" -name '*.py[co]' -delete echo " Copied webapp/" fi diff --git a/scripts/build-usb.sh b/scripts/build-usb.sh index 4a1b0e9..7835783 100755 --- a/scripts/build-usb.sh +++ b/scripts/build-usb.sh @@ -1,233 +1,511 @@ #!/bin/bash # -# build-usb.sh — Build a bootable PXE-server installer USB +# build-usb.sh - Build a bootable PXE-server installer USB (or disk image) # -# Creates a two-partition USB: -# Partition 1: Ubuntu Server 24.04 installer (ISO contents) -# Partition 2: CIDATA volume (autoinstall config, .debs, playbook) +# Creates a two-partition layout: +# Partition 1: Ubuntu Server 24.04 installer (ISO contents, autoinstall patched) +# Partition N: CIDATA volume (autoinstall config, .debs, playbook, webapp...) # # The target machine boots from this USB, Ubuntu auto-installs with # cloud-init (user-data/meta-data from CIDATA), installs offline .debs, # and on first boot runs the Ansible playbook to configure PXE services. # # Usage: -# sudo ./build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso +# sudo ./build-usb.sh [options] /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso [winpe-dir] +# ./build-usb.sh [options] --image out.img SIZE /path/to/ubuntu.iso [winpe-dir] # -# WARNING: This will ERASE the target USB device. +# Image mode writes the same layout into a regular FILE and needs no root: +# the CIDATA filesystem is built with mkfs.vfat on an image file and filled +# with mtools (mcopy), then placed after the ISO with sfdisk on the file. +# SIZE is a byte count with an optional K/M/G suffix (1024-based), or +# "auto" to size the image to fit the payload. +# +# Options: +# --image FILE SIZE build a disk image file instead of writing a device +# --payload-root DIR take the gitignored payload dirs (offline-packages/, +# pip-wheels/, boot-files/, boot-tools/, enrollment/, +# bios-staging/, drivers-staging/) from DIR instead of +# the repo root (e.g. a main checkout next to a worktree) +# --skip-enrollment do not copy enrollment/ +# --skip-bios do not copy bios-staging/ +# --skip-drivers do not copy drivers-staging/ +# --skip-boot-tools do not copy boot-tools/ +# --yes do not ask for confirmation (device mode still asks +# YES for a non-removable device) +# +# WARNING: device mode ERASES the target USB device. set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" AUTOINSTALL_DIR="$REPO_ROOT/autoinstall" PLAYBOOK_DIR="$REPO_ROOT/playbook" -OFFLINE_PKG_DIR="$REPO_ROOT/offline-packages" +WEBAPP_DIR="$REPO_ROOT/webapp" -# --- Validate arguments --- -if [ $# -lt 2 ]; then - echo "Usage: sudo $0 /dev/sdX /path/to/ubuntu-24.04.iso [/path/to/winpe-images]" +usage() { + echo "Usage: sudo $0 [options] /dev/sdX /path/to/ubuntu-24.04.iso [/path/to/winpe-images]" + echo " $0 [options] --image out.img SIZE|auto /path/to/ubuntu-24.04.iso [/path/to/winpe-images]" echo "" - echo " The optional third argument is the path to WinPE deployment content" + echo " Options: --payload-root DIR, --skip-enrollment, --skip-bios," + echo " --skip-drivers, --skip-boot-tools, --yes" + echo "" + echo " The optional last argument is the path to WinPE deployment content" echo " (e.g., the mounted Media Creator LITE USB). If provided, the images" echo " will be bundled onto the CIDATA partition for automatic import." echo "" echo "Available removable devices:" - lsblk -d -o NAME,SIZE,TRAN,RM | grep -E '^\S+\s+\S+\s+(usb)\s+1' - exit 1 + lsblk -d -o NAME,SIZE,TRAN,RM 2>/dev/null | grep -E '^\S+\s+\S+\s+(usb)\s+1' || true +} + +# --- Parse arguments --- +IMAGE_MODE=0 +IMAGE_FILE="" +IMAGE_SIZE_ARG="" +PAYLOAD_ROOT="$REPO_ROOT" +SKIP_ENROLLMENT=0 +SKIP_BIOS=0 +SKIP_DRIVERS=0 +SKIP_BOOT_TOOLS=0 +ASSUME_YES=0 +POSITIONAL=() +while [ $# -gt 0 ]; do + case "$1" in + --image) + [ $# -ge 3 ] || { usage; exit 1; } + IMAGE_MODE=1; IMAGE_FILE="$2"; IMAGE_SIZE_ARG="$3"; shift 3 ;; + --payload-root) + [ $# -ge 2 ] || { usage; exit 1; } + PAYLOAD_ROOT="$(cd "$2" && pwd)"; shift 2 ;; + --skip-enrollment) SKIP_ENROLLMENT=1; shift ;; + --skip-bios) SKIP_BIOS=1; shift ;; + --skip-drivers) SKIP_DRIVERS=1; shift ;; + --skip-boot-tools) SKIP_BOOT_TOOLS=1; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; POSITIONAL+=("$@"); break ;; + -*) echo "ERROR: unknown option $1"; usage; exit 1 ;; + *) POSITIONAL+=("$1"); shift ;; + esac +done + +if [ "$IMAGE_MODE" -eq 1 ]; then + if [ ${#POSITIONAL[@]} -lt 1 ]; then usage; exit 1; fi + USB_DEV="" + ISO_PATH="${POSITIONAL[0]}" + WINPE_SOURCE="${POSITIONAL[1]:-}" +else + if [ ${#POSITIONAL[@]} -lt 2 ]; then usage; exit 1; fi + USB_DEV="${POSITIONAL[0]}" + ISO_PATH="${POSITIONAL[1]}" + WINPE_SOURCE="${POSITIONAL[2]:-}" fi -USB_DEV="$1" -ISO_PATH="$2" -WINPE_SOURCE="${3:-}" +OFFLINE_PKG_DIR="$PAYLOAD_ROOT/offline-packages" +PIP_WHEELS_DIR="$PAYLOAD_ROOT/pip-wheels" +BOOT_FILES_DIR="$PAYLOAD_ROOT/boot-files" +BOOT_TOOLS_DIR="$PAYLOAD_ROOT/boot-tools" +ENROLLMENT_DIR="$PAYLOAD_ROOT/enrollment" +BIOS_DIR="$PAYLOAD_ROOT/bios-staging" +DRIVERS_DIR="$PAYLOAD_ROOT/drivers-staging" +[ "$SKIP_ENROLLMENT" -eq 1 ] && ENROLLMENT_DIR="" +[ "$SKIP_BIOS" -eq 1 ] && BIOS_DIR="" +[ "$SKIP_DRIVERS" -eq 1 ] && DRIVERS_DIR="" +[ "$SKIP_BOOT_TOOLS" -eq 1 ] && BOOT_TOOLS_DIR="" -# Safety checks -if [ "$(id -u)" -ne 0 ]; then - echo "ERROR: Must run as root (sudo)." - exit 1 -fi - -if [ ! -b "$USB_DEV" ]; then - echo "ERROR: $USB_DEV is not a block device." - exit 1 -fi +# FAT32 has a 4GB max file size; files larger than this are split into +# chunks that the playbook reassembles with `cat`. +FAT32_MAX=$((3500 * 1024 * 1024)) # 3500 MiB chunks, safely under 4GiB +SECTOR_SIZE=512 +# --- Safety checks --- if [ ! -f "$ISO_PATH" ]; then echo "ERROR: ISO not found at $ISO_PATH" exit 1 fi +ISO_PATH="$(cd "$(dirname "$ISO_PATH")" && pwd)/$(basename "$ISO_PATH")" -# Verify it's a removable device (safety against wiping system disks) -REMOVABLE=$(lsblk -nd -o RM "$USB_DEV" 2>/dev/null || echo "0") -if [ "$REMOVABLE" != "1" ]; then - echo "WARNING: $USB_DEV does not appear to be a removable device." - read -rp "Are you SURE you want to erase $USB_DEV? (type YES): " CONFIRM - if [ "$CONFIRM" != "YES" ]; then +need_tools=(xorriso 7z sfdisk mkfs.vfat) +if [ "$IMAGE_MODE" -eq 1 ]; then + need_tools+=(mcopy mmd mdir truncate) +else + need_tools+=(partprobe) +fi +for t in "${need_tools[@]}"; do + if ! command -v "$t" >/dev/null 2>&1; then + echo "ERROR: required tool '$t' not found in PATH" + exit 1 + fi +done + +if [ "$IMAGE_MODE" -eq 1 ]; then + if [ -e "$IMAGE_FILE" ] && [ ! -f "$IMAGE_FILE" ]; then + echo "ERROR: $IMAGE_FILE exists and is not a regular file (image mode never writes devices)." + exit 1 + fi + IMAGE_FILE="$(cd "$(dirname "$IMAGE_FILE")" && pwd)/$(basename "$IMAGE_FILE")" +else + if [ "$(id -u)" -ne 0 ]; then + echo "ERROR: Must run as root (sudo). (Use --image to build a file without root.)" + exit 1 + fi + if [ ! -b "$USB_DEV" ]; then + echo "ERROR: $USB_DEV is not a block device." + exit 1 + fi + # Verify it's a removable device (safety against wiping system disks) + REMOVABLE=$(lsblk -nd -o RM "$USB_DEV" 2>/dev/null | tr -d '[:space:]' || echo "0") + if [ "$REMOVABLE" != "1" ]; then + echo "WARNING: $USB_DEV does not appear to be a removable device." + read -rp "Are you SURE you want to erase $USB_DEV? (type YES): " CONFIRM + if [ "$CONFIRM" != "YES" ]; then + echo "Aborted." + exit 1 + fi + fi +fi + +# Verify required source files exist +for f in "$AUTOINSTALL_DIR/user-data" "$AUTOINSTALL_DIR/meta-data" "$PLAYBOOK_DIR/pxe_server_setup.yml"; do + if [ ! -f "$f" ]; then + echo "ERROR: $f not found" + exit 1 + fi +done + +echo "============================================" +echo "PXE Server USB Builder" +echo "============================================" +if [ "$IMAGE_MODE" -eq 1 ]; then + echo "Image file : $IMAGE_FILE (size: $IMAGE_SIZE_ARG)" +else + echo "USB Device : $USB_DEV" +fi +echo "ISO : $ISO_PATH" +echo "Source Dir : $REPO_ROOT" +echo "Payloads : $PAYLOAD_ROOT" +echo "" +if [ "$ASSUME_YES" -ne 1 ]; then + if [ "$IMAGE_MODE" -eq 1 ]; then + echo "This will overwrite $IMAGE_FILE." + else + echo "This will ERASE all data on $USB_DEV." + fi + read -rp "Continue? (y/N): " PROCEED + if [[ ! "$PROCEED" =~ ^[Yy]$ ]]; then echo "Aborted." exit 1 fi fi -# Verify required source files exist -if [ ! -f "$AUTOINSTALL_DIR/user-data" ]; then - echo "ERROR: user-data not found at $AUTOINSTALL_DIR/user-data" - exit 1 -fi - -if [ ! -f "$AUTOINSTALL_DIR/meta-data" ]; then - echo "ERROR: meta-data not found at $AUTOINSTALL_DIR/meta-data" - exit 1 -fi - -if [ ! -f "$PLAYBOOK_DIR/pxe_server_setup.yml" ]; then - echo "ERROR: pxe_server_setup.yml not found at $PLAYBOOK_DIR/" - exit 1 -fi - -echo "============================================" -echo "PXE Server USB Builder" -echo "============================================" -echo "USB Device : $USB_DEV" -echo "ISO : $ISO_PATH" -echo "Source Dir : $REPO_ROOT" -echo "" -echo "This will ERASE all data on $USB_DEV." -read -rp "Continue? (y/N): " PROCEED -if [[ ! "$PROCEED" =~ ^[Yy]$ ]]; then - echo "Aborted." - exit 1 +# Work dir: next to the output image in image mode (multi-GB, keep it off +# a tmpfs /tmp), else the default temp dir. +if [ "$IMAGE_MODE" -eq 1 ]; then + WORK=$(mktemp -d "$(dirname "$IMAGE_FILE")/.build-usb.XXXXXX") +else + WORK=$(mktemp -d) fi +MOUNT_POINT="" +cleanup() { + if [ -n "$MOUNT_POINT" ] && mountpoint -q "$MOUNT_POINT" 2>/dev/null; then + umount "$MOUNT_POINT" || true + fi + [ -n "$MOUNT_POINT" ] && rmdir "$MOUNT_POINT" 2>/dev/null || true + rm -rf "$WORK" +} +trap cleanup EXIT # --- Unmount any existing partitions --- echo "" -echo "[1/6] Unmounting existing partitions on $USB_DEV..." -for part in "${USB_DEV}"*; do - umount "$part" 2>/dev/null || true -done +if [ "$IMAGE_MODE" -eq 0 ]; then + echo "[1/6] Unmounting existing partitions on $USB_DEV..." + for part in "${USB_DEV}"*; do + umount "$part" 2>/dev/null || true + done +else + echo "[1/6] Image mode: nothing to unmount" +fi # --- Rebuild ISO with 'autoinstall' kernel parameter --- echo "[2/6] Rebuilding ISO with autoinstall kernel parameter..." -ISO_WORK=$(mktemp -d) -7z -y x "$ISO_PATH" -o"$ISO_WORK/iso" >/dev/null 2>&1 -mv "$ISO_WORK/iso/[BOOT]" "$ISO_WORK/BOOT" -chmod -R u+w "$ISO_WORK/iso" +# Volume label and El Torito load sizes come from the source ISO, so a +# point release (24.04.3, 24.04.5, ...) does not need a script edit. +ELTORITO_REPORT=$(xorriso -indev "$ISO_PATH" -report_el_torito as_mkisofs 2>/dev/null) +ISO_VOLID=$(printf '%s\n' "$ELTORITO_REPORT" | sed -n "s/^-V '\(.*\)'\$/\1/p" | head -1) +# Load sizes in order: first follows -b (BIOS), last follows -e (EFI). +mapfile -t LOAD_SIZES < <(printf '%s\n' "$ELTORITO_REPORT" | sed -n 's/^-boot-load-size \([0-9][0-9]*\)$/\1/p') +if [ -z "$ISO_VOLID" ] || [ ${#LOAD_SIZES[@]} -lt 2 ]; then + echo "ERROR: could not read volume id / El Torito load sizes from $ISO_PATH" + echo " (xorriso -indev ISO -report_el_torito as_mkisofs)" + exit 1 +fi +BIOS_LOAD_SIZE="${LOAD_SIZES[0]}" +EFI_LOAD_SIZE="${LOAD_SIZES[${#LOAD_SIZES[@]}-1]}" +echo " Volume id: $ISO_VOLID (boot-load-size BIOS=$BIOS_LOAD_SIZE EFI=$EFI_LOAD_SIZE)" + +7z -y x "$ISO_PATH" -o"$WORK/iso" >/dev/null 2>&1 +mv "$WORK/iso/[BOOT]" "$WORK/BOOT" +chmod -R u+w "$WORK/iso" # Patch grub.cfg: add 'autoinstall' to kernel cmdline, reduce timeout -sed -i 's|linux\t/casper/vmlinuz ---|linux\t/casper/vmlinuz autoinstall ---|' "$ISO_WORK/iso/boot/grub/grub.cfg" -sed -i 's/^set timeout=30/set timeout=5/' "$ISO_WORK/iso/boot/grub/grub.cfg" +GRUB_CFG="$WORK/iso/boot/grub/grub.cfg" +sed -i 's|linux\t/casper/vmlinuz ---|linux\t/casper/vmlinuz autoinstall ---|' "$GRUB_CFG" +sed -i 's/^set timeout=30/set timeout=5/' "$GRUB_CFG" +if ! grep -q 'vmlinuz.*autoinstall' "$GRUB_CFG"; then + echo "ERROR: grub.cfg patch failed: no 'autoinstall' on a kernel line in boot/grub/grub.cfg" + echo " The ISO's grub.cfg format changed; update the sed in this script." + exit 1 +fi -PATCHED_ISO="$ISO_WORK/patched.iso" -cd "$ISO_WORK/iso" -xorriso -as mkisofs -r \ - -V 'Ubuntu-Server 24.04.3 LTS amd64' \ - -o "$PATCHED_ISO" \ - --grub2-mbr ../BOOT/1-Boot-NoEmul.img \ - --protective-msdos-label \ - -partition_cyl_align off \ - -partition_offset 16 \ - --mbr-force-bootable \ - -append_partition 2 28732ac11ff8d211ba4b00a0c93ec93b ../BOOT/2-Boot-NoEmul.img \ - -appended_part_as_gpt \ - -iso_mbr_part_type a2a0d0ebe5b9334487c068b6b72699c7 \ - -c '/boot.catalog' \ - -b '/boot/grub/i386-pc/eltorito.img' \ - -no-emul-boot \ - -boot-load-size 4 \ - -boot-info-table \ - --grub2-boot-info \ - -eltorito-alt-boot \ - -e '--interval:appended_partition_2:::' \ - -no-emul-boot \ - -boot-load-size 10160 \ - . 2>/dev/null -cd "$REPO_ROOT" +PATCHED_ISO="$WORK/patched.iso" +( + cd "$WORK/iso" + xorriso -as mkisofs -r \ + -V "$ISO_VOLID" \ + -o "$PATCHED_ISO" \ + --grub2-mbr ../BOOT/1-Boot-NoEmul.img \ + --protective-msdos-label \ + -partition_cyl_align off \ + -partition_offset 16 \ + --mbr-force-bootable \ + -append_partition 2 28732ac11ff8d211ba4b00a0c93ec93b ../BOOT/2-Boot-NoEmul.img \ + -appended_part_as_gpt \ + -iso_mbr_part_type a2a0d0ebe5b9334487c068b6b72699c7 \ + -c '/boot.catalog' \ + -b '/boot/grub/i386-pc/eltorito.img' \ + -no-emul-boot \ + -boot-load-size "$BIOS_LOAD_SIZE" \ + -boot-info-table \ + --grub2-boot-info \ + -eltorito-alt-boot \ + -e '--interval:appended_partition_2:::' \ + -no-emul-boot \ + -boot-load-size "$EFI_LOAD_SIZE" \ + . 2>"$WORK/xorriso.log" +) || { echo "ERROR: xorriso failed:"; tail -20 "$WORK/xorriso.log"; exit 1; } +rm -rf "$WORK/iso" echo " ISO rebuilt with 'autoinstall' kernel param and 5s GRUB timeout" -echo " Writing patched ISO to $USB_DEV..." -dd if="$PATCHED_ISO" of="$USB_DEV" bs=4M status=progress oflag=sync -sync ISO_SIZE=$(stat -c%s "$PATCHED_ISO") -rm -rf "$ISO_WORK" - -# --- Find the end of the ISO to create CIDATA partition --- -echo "[3/6] Creating CIDATA partition after ISO data..." - -# Get ISO size in bytes and calculate the start sector for the new partition -SECTOR_SIZE=512 # Start the CIDATA partition 1MB after the ISO ends (alignment) START_SECTOR=$(( (ISO_SIZE / SECTOR_SIZE) + 2048 )) -# Use sfdisk to append a new partition +# --- Payload size (image mode sizing) --- +src_bytes() { + # Apparent size in bytes of the given paths (missing ones count 0) + local total=0 p n + for p in "$@"; do + [ -n "$p" ] && [ -e "$p" ] || continue + n=$(du -sbL "$p" 2>/dev/null | cut -f1) + total=$((total + ${n:-0})) + done + echo "$total" +} + +parse_size() { + local s="$1" num unit + num="${s%[KkMmGgTt]}" + unit="${s#"$num"}" + [[ "$num" =~ ^[0-9]+$ ]] || { echo "ERROR: bad size '$s'" >&2; return 1; } + case "$unit" in + "") echo "$num" ;; + K|k) echo $((num * 1024)) ;; + M|m) echo $((num * 1024 * 1024)) ;; + G|g) echo $((num * 1024 * 1024 * 1024)) ;; + T|t) echo $((num * 1024 * 1024 * 1024 * 1024)) ;; + esac +} + +if [ "$IMAGE_MODE" -eq 1 ]; then + PAYLOAD_BYTES=$(src_bytes "$AUTOINSTALL_DIR/user-data" "$AUTOINSTALL_DIR/meta-data" \ + "$OFFLINE_PKG_DIR" "$PLAYBOOK_DIR" "$WEBAPP_DIR" "$PIP_WHEELS_DIR" \ + "$BOOT_FILES_DIR" "$BOOT_TOOLS_DIR" "$ENROLLMENT_DIR" "$BIOS_DIR" \ + "$DRIVERS_DIR" "$WINPE_SOURCE") + if [ "$IMAGE_SIZE_ARG" = "auto" ]; then + # payload + 10% + 256 MiB FAT/cluster slack, rounded up to 1 MiB + CIDATA_BYTES=$(( PAYLOAD_BYTES + PAYLOAD_BYTES / 10 + 256 * 1024 * 1024 )) + IMAGE_BYTES=$(( START_SECTOR * SECTOR_SIZE + CIDATA_BYTES + 2 * 1024 * 1024 )) + else + IMAGE_BYTES=$(parse_size "$IMAGE_SIZE_ARG") + fi + IMAGE_BYTES=$(( (IMAGE_BYTES + 1048575) / 1048576 * 1048576 )) + MIN_BYTES=$(( START_SECTOR * SECTOR_SIZE + PAYLOAD_BYTES + 64 * 1024 * 1024 )) + if [ "$IMAGE_BYTES" -lt "$MIN_BYTES" ]; then + echo "ERROR: image size $((IMAGE_BYTES / 1048576)) MiB too small; need at least $((MIN_BYTES / 1048576)) MiB" + echo " (ISO $((ISO_SIZE / 1048576)) MiB + payload $((PAYLOAD_BYTES / 1048576)) MiB). Use SIZE=auto." + exit 1 + fi + echo " Payload: $((PAYLOAD_BYTES / 1048576)) MiB, image: $((IMAGE_BYTES / 1048576)) MiB" +fi + +# --- Write the ISO --- +if [ "$IMAGE_MODE" -eq 1 ]; then + echo " Writing patched ISO to $IMAGE_FILE..." + rm -f "$IMAGE_FILE" + truncate -s "$IMAGE_BYTES" "$IMAGE_FILE" + dd if="$PATCHED_ISO" of="$IMAGE_FILE" bs=4M conv=notrunc,sparse status=none + TARGET="$IMAGE_FILE" +else + echo " Writing patched ISO to $USB_DEV..." + dd if="$PATCHED_ISO" of="$USB_DEV" bs=4M status=progress oflag=sync + sync + TARGET="$USB_DEV" +fi +rm -f "$PATCHED_ISO" + +# --- Create the CIDATA partition after the ISO data --- +echo "[3/6] Creating CIDATA partition after ISO data..." echo " ISO size: $((ISO_SIZE / 1024 / 1024)) MB" echo " CIDATA partition starts at sector $START_SECTOR" -# Add a new partition using sfdisk --append -echo "${START_SECTOR},+,L" | sfdisk --append "$USB_DEV" --no-reread 2>/dev/null || true -partprobe "$USB_DEV" -sleep 2 - -# Determine the new partition name (could be sdX3, sdX4, etc.) -CIDATA_PART="" -for part in "${USB_DEV}"[0-9]*; do - # Find the partition that starts at or after our start sector - PART_START=$(sfdisk -d "$USB_DEV" 2>/dev/null | grep "$part" | grep -o 'start=[[:space:]]*[0-9]*' | grep -o '[0-9]*') - if [ -n "$PART_START" ] && [ "$PART_START" -ge "$START_SECTOR" ]; then - CIDATA_PART="$part" - break - fi -done - -# Fallback: use the last partition -if [ -z "$CIDATA_PART" ]; then - CIDATA_PART=$(lsblk -ln -o NAME "$USB_DEV" | tail -1) - CIDATA_PART="/dev/$CIDATA_PART" +# A failed append must stop the build: the old fallback ("last partition") +# then formatted the ISO's own EFI partition. +if ! echo "${START_SECTOR},+,L" | sfdisk --append "$TARGET" --no-reread >"$WORK/sfdisk.log" 2>&1; then + echo "ERROR: sfdisk --append failed on $TARGET:" + sed 's/^/ /' "$WORK/sfdisk.log" + exit 1 fi -echo " CIDATA partition: $CIDATA_PART" +# Find the partition that starts exactly at our start sector. +# sfdisk -d lines look like: "/dev/sdb3 : start= 6324224, size= ..., type=..." +read_new_part() { + sfdisk -d "$TARGET" 2>/dev/null | awk -v want="$START_SECTOR" ' + / : start=/ { + node = $1 + line = $0 + sub(/.*start=[ \t]*/, "", line); start = line; sub(/[^0-9].*/, "", start) + line = $0 + sub(/.*size=[ \t]*/, "", line); size = line; sub(/[^0-9].*/, "", size) + if (start + 0 == want + 0) { print node, start, size; exit } + }' +} +NEW_PART=$(read_new_part) +if [ -z "$NEW_PART" ]; then + echo "ERROR: no partition starting at sector $START_SECTOR after sfdisk --append. Table:" + sfdisk -d "$TARGET" 2>&1 | sed 's/^/ /' + exit 1 +fi +read -r PART_NODE PART_START PART_SECTORS <<<"$NEW_PART" + +if [ "$IMAGE_MODE" -eq 0 ]; then + partprobe "$USB_DEV" + sleep 2 + CIDATA_PART="$PART_NODE" + if [ ! -b "$CIDATA_PART" ]; then + echo "ERROR: $CIDATA_PART (from sfdisk) is not a block device after partprobe" + exit 1 + fi + # Refuse to format anything that starts before the computed start sector + # (the ISO9660 data and its EFI partition live there). + SYS_START=$(cat "/sys/class/block/$(basename "$CIDATA_PART")/start" 2>/dev/null || echo 0) + if [ "$SYS_START" -lt "$START_SECTOR" ]; then + echo "ERROR: refusing to format $CIDATA_PART: it starts at sector $SYS_START, before $START_SECTOR" + exit 1 + fi + echo " CIDATA partition: $CIDATA_PART" +else + if [ "$PART_START" -lt "$START_SECTOR" ]; then + echo "ERROR: refusing to format partition at sector $PART_START (< $START_SECTOR)" + exit 1 + fi + echo " CIDATA partition: $PART_NODE (sectors $PART_START + $PART_SECTORS)" +fi # --- Format CIDATA partition --- -echo "[4/6] Formatting $CIDATA_PART as FAT32 (label: CIDATA)..." -mkfs.vfat -F 32 -n CIDATA "$CIDATA_PART" +if [ "$IMAGE_MODE" -eq 0 ]; then + echo "[4/6] Formatting $CIDATA_PART as FAT32 (label: CIDATA)..." + mkfs.vfat -F 32 -n CIDATA "$CIDATA_PART" + MOUNT_POINT=$(mktemp -d) + mount "$CIDATA_PART" "$MOUNT_POINT" +else + echo "[4/6] Building FAT32 CIDATA filesystem image ($((PART_SECTORS / 2048)) MiB)..." + CIDATA_IMG="$WORK/cidata.img" + # mkfs.vfat -C takes the size in 1 KiB blocks + mkfs.vfat -F 32 -n CIDATA -C "$CIDATA_IMG" $((PART_SECTORS / 2)) >/dev/null + export MTOOLS_SKIP_CHECK=1 +fi -# --- Mount and copy files --- +# --- Target-neutral copy helpers --- +# Paths are relative to the CIDATA root ("" = root). +t_mkdir() { + local rel="$1" acc="" part + if [ "$IMAGE_MODE" -eq 0 ]; then + mkdir -p "$MOUNT_POINT/$rel" + return + fi + local IFS=/ + for part in $rel; do + [ -n "$part" ] || continue + acc="$acc/$part" + mmd -i "$CIDATA_IMG" "::$acc" 2>/dev/null || true + done +} +# t_cp DEST_REL_DIR SRC... : copy files/dirs (recursively) into a CIDATA dir +t_cp() { + local dest="$1"; shift + if [ "$IMAGE_MODE" -eq 0 ]; then + cp -r "$@" "$MOUNT_POINT/$dest/" + else + mcopy -i "$CIDATA_IMG" -s -Q -D o "$@" "::/${dest:+$dest/}" + fi +} +# t_cp_split SRC DEST_REL_DIR : copy one file, splitting it if over FAT32_MAX +SPLIT_COUNT=0 +t_cp_split() { + local f="$1" dest="$2" bn + bn="$(basename "$f")" + if [ "$(stat -L -c%s "$f")" -gt "$FAT32_MAX" ]; then + echo " Splitting $bn (>$((FAT32_MAX / 1024 / 1024))M) into chunks..." + if [ "$IMAGE_MODE" -eq 0 ]; then + split -b "$FAT32_MAX" -d -a 2 "$f" "$MOUNT_POINT/$dest/${bn}.part." + else + mkdir -p "$WORK/split" + split -b "$FAT32_MAX" -d -a 2 "$f" "$WORK/split/${bn}.part." + t_cp "$dest" "$WORK/split/"* + rm -rf "$WORK/split" + fi + SPLIT_COUNT=$((SPLIT_COUNT + 1)) + else + t_cp "$dest" "$f" + fi +} + +# --- Copy files --- echo "[5/6] Copying autoinstall config, packages, and playbook to CIDATA..." -MOUNT_POINT=$(mktemp -d) -mount "$CIDATA_PART" "$MOUNT_POINT" # Copy cloud-init files -cp "$AUTOINSTALL_DIR/user-data" "$MOUNT_POINT/" -cp "$AUTOINSTALL_DIR/meta-data" "$MOUNT_POINT/" +t_cp "" "$AUTOINSTALL_DIR/user-data" "$AUTOINSTALL_DIR/meta-data" # Copy offline .deb packages into packages/ subdirectory -mkdir -p "$MOUNT_POINT/packages" +t_mkdir packages DEB_COUNT=0 - -if [ -d "$OFFLINE_PKG_DIR" ]; then - for deb in "$OFFLINE_PKG_DIR"/*.deb; do - if [ -f "$deb" ]; then - cp "$deb" "$MOUNT_POINT/packages/" - DEB_COUNT=$((DEB_COUNT + 1)) - fi - done +if [ -d "$OFFLINE_PKG_DIR" ] && compgen -G "$OFFLINE_PKG_DIR/*.deb" >/dev/null; then + t_cp packages "$OFFLINE_PKG_DIR"/*.deb + DEB_COUNT=$(find "$OFFLINE_PKG_DIR" -maxdepth 1 -name '*.deb' | wc -l) +else + echo " WARNING: no .deb packages in $OFFLINE_PKG_DIR (run download-packages.sh)" fi echo " Copied $DEB_COUNT .deb packages to packages/" # Copy playbook directory -cp -r "$PLAYBOOK_DIR" "$MOUNT_POINT/playbook" +t_cp "" "$PLAYBOOK_DIR" echo " Copied playbook/" -# Copy webapp -WEBAPP_DIR="$REPO_ROOT/webapp" +# Copy the whole webapp tree (app.py imports config, services/, views/; +# the Blancco evidence timer runs blancco_evidence_monthly.py), minus caches. if [ -d "$WEBAPP_DIR" ]; then - mkdir -p "$MOUNT_POINT/webapp" - cp -r "$WEBAPP_DIR/app.py" "$WEBAPP_DIR/requirements.txt" "$MOUNT_POINT/webapp/" - cp -r "$WEBAPP_DIR/templates" "$WEBAPP_DIR/static" "$MOUNT_POINT/webapp/" + mkdir -p "$WORK/stage" + cp -r "$WEBAPP_DIR" "$WORK/stage/webapp" + find "$WORK/stage/webapp" -name __pycache__ -type d -prune -exec rm -rf {} + + find "$WORK/stage/webapp" -name '*.py[co]' -delete + t_cp "" "$WORK/stage/webapp" + rm -rf "$WORK/stage" echo " Copied webapp/" fi # Copy pip wheels for offline Flask install -PIP_WHEELS_DIR="$REPO_ROOT/pip-wheels" if [ ! -d "$PIP_WHEELS_DIR" ]; then - echo " pip-wheels/ not found — downloading now..." + echo " pip-wheels/ not found - downloading now..." mkdir -p "$PIP_WHEELS_DIR" if pip3 download -d "$PIP_WHEELS_DIR" flask lxml 2>/dev/null; then echo " Downloaded pip wheels successfully." @@ -239,100 +517,92 @@ if [ ! -d "$PIP_WHEELS_DIR" ]; then fi fi if [ -d "$PIP_WHEELS_DIR" ]; then - cp -r "$PIP_WHEELS_DIR" "$MOUNT_POINT/pip-wheels" + t_cp "" "$PIP_WHEELS_DIR" WHEEL_COUNT=$(find "$PIP_WHEELS_DIR" -name '*.whl' | wc -l) echo " Copied pip-wheels/ ($WHEEL_COUNT wheels)" fi -# Copy WinPE boot files (wimboot, boot.wim, BCD, ipxe.efi, etc.) -BOOT_FILES_DIR="$REPO_ROOT/boot-files" +# Copy WinPE boot files (wimboot, boot.wim, BCD, ipxe.efi, etc.) to the root if [ -d "$BOOT_FILES_DIR" ]; then BOOT_FILE_COUNT=0 for bf in "$BOOT_FILES_DIR"/*; do if [ -f "$bf" ]; then - cp "$bf" "$MOUNT_POINT/" + t_cp_split "$bf" "" BOOT_FILE_COUNT=$((BOOT_FILE_COUNT + 1)) fi done - BOOT_FILES_SIZE=$(du -sh "$BOOT_FILES_DIR" | cut -f1) - echo " Copied $BOOT_FILE_COUNT boot files ($BOOT_FILES_SIZE) — wimboot, boot.wim, ipxe.efi, etc." + BOOT_FILES_SIZE=$(du -shL "$BOOT_FILES_DIR" | cut -f1) + echo " Copied $BOOT_FILE_COUNT boot files ($BOOT_FILES_SIZE) - wimboot, boot.wim, ipxe.efi, etc." else echo " WARNING: No boot-files/ found (copy WinPE boot files from Media Creator)" fi # Copy boot tools (Clonezilla, Blancco, Memtest) if prepared -BOOT_TOOLS_DIR="$REPO_ROOT/boot-tools" -if [ -d "$BOOT_TOOLS_DIR" ]; then - cp -r "$BOOT_TOOLS_DIR" "$MOUNT_POINT/boot-tools" - TOOLS_SIZE=$(du -sh "$MOUNT_POINT/boot-tools" | cut -f1) +if [ -n "$BOOT_TOOLS_DIR" ] && [ -d "$BOOT_TOOLS_DIR" ]; then + t_cp "" "$BOOT_TOOLS_DIR" + TOOLS_SIZE=$(du -shL "$BOOT_TOOLS_DIR" | cut -f1) echo " Copied boot-tools/ ($TOOLS_SIZE)" +elif [ -z "$BOOT_TOOLS_DIR" ]; then + echo " Skipped boot-tools/ (--skip-boot-tools)" else echo " No boot-tools/ found (run prepare-boot-tools.sh first)" fi -# Copy enrollment directory (PPKGs, run-enrollment.ps1) if present -# FAT32 has a 4GB max file size; files larger than that are split into chunks -# that the playbook reassembles with `cat`. -ENROLLMENT_DIR="$REPO_ROOT/enrollment" -FAT32_MAX=$((3500 * 1024 * 1024)) # 3500 MiB chunks, safely under 4GiB FAT32 limit -if [ -d "$ENROLLMENT_DIR" ]; then - mkdir -p "$MOUNT_POINT/enrollment" +# Copy enrollment directory (PPKGs, run-enrollment.ps1) if present. +# Files over the FAT32 limit are split; the playbook reassembles them. +if [ -n "$ENROLLMENT_DIR" ] && [ -d "$ENROLLMENT_DIR" ]; then + t_mkdir enrollment SPLIT_COUNT=0 for f in "$ENROLLMENT_DIR"/*; do [ -e "$f" ] || continue - bn="$(basename "$f")" - if [ -f "$f" ] && [ "$(stat -c%s "$f")" -gt "$FAT32_MAX" ]; then - echo " Splitting $bn (>$((FAT32_MAX / 1024 / 1024))M) into chunks..." - split -b "$FAT32_MAX" -d -a 2 "$f" "$MOUNT_POINT/enrollment/${bn}.part." - SPLIT_COUNT=$((SPLIT_COUNT + 1)) + if [ -f "$f" ]; then + t_cp_split "$f" enrollment else - cp -r "$f" "$MOUNT_POINT/enrollment/" + t_cp enrollment "$f" fi done PPKG_COUNT=$(find "$ENROLLMENT_DIR" -maxdepth 1 -name '*.ppkg' 2>/dev/null | wc -l) - ENROLL_SIZE=$(du -sh "$MOUNT_POINT/enrollment" | cut -f1) + ENROLL_SIZE=$(du -shL "$ENROLLMENT_DIR" | cut -f1) echo " Copied enrollment/ ($ENROLL_SIZE, $PPKG_COUNT PPKGs, $SPLIT_COUNT split)" +elif [ -z "$ENROLLMENT_DIR" ]; then + echo " Skipped enrollment/ (--skip-enrollment)" else echo " No enrollment/ directory found (PPKGs can be uploaded via webapp later)" fi # Copy BIOS update binaries if staged -BIOS_DIR="$REPO_ROOT/bios-staging" -if [ -d "$BIOS_DIR" ] && [ "$(ls -A "$BIOS_DIR" 2>/dev/null)" ]; then +if [ -n "$BIOS_DIR" ] && [ -d "$BIOS_DIR" ] && [ "$(ls -A "$BIOS_DIR" 2>/dev/null)" ]; then echo " Copying BIOS update binaries from bios-staging/..." - mkdir -p "$MOUNT_POINT/bios" - cp -r "$BIOS_DIR"/* "$MOUNT_POINT/bios/" 2>/dev/null || true - BIOS_COUNT=$(find "$MOUNT_POINT/bios" -name '*.exe' 2>/dev/null | wc -l) - BIOS_SIZE=$(du -sh "$MOUNT_POINT/bios" | cut -f1) + t_mkdir bios + t_cp bios "$BIOS_DIR"/* || true + BIOS_COUNT=$(find "$BIOS_DIR" -name '*.exe' 2>/dev/null | wc -l) + BIOS_SIZE=$(du -shL "$BIOS_DIR" | cut -f1) echo " Copied bios/ ($BIOS_SIZE, $BIOS_COUNT files)" +elif [ -z "$BIOS_DIR" ]; then + echo " Skipped bios-staging/ (--skip-bios)" else echo " No bios-staging/ found (BIOS updates can be pushed via download-drivers.py later)" fi -# Copy Dell driver packs if staged -# Files larger than the FAT32 4GB limit are split into chunks; the playbook -# reassembles them on the server. -DRIVERS_DIR="$REPO_ROOT/drivers-staging" -if [ -d "$DRIVERS_DIR" ] && [ "$(ls -A "$DRIVERS_DIR" 2>/dev/null)" ]; then +# Copy Dell driver packs if staged (split over the FAT32 limit) +if [ -n "$DRIVERS_DIR" ] && [ -d "$DRIVERS_DIR" ] && [ "$(ls -A "$DRIVERS_DIR" 2>/dev/null)" ]; then echo " Copying Dell driver packs from drivers-staging/..." - mkdir -p "$MOUNT_POINT/drivers" - DRV_SPLIT=0 - # Mirror directory tree first (fast) - (cd "$DRIVERS_DIR" && find . -type d -exec mkdir -p "$MOUNT_POINT/drivers/{}" \;) - # Copy files <4GB directly, split files >=4GB into chunks + t_mkdir drivers + SPLIT_COUNT=0 + # Mirror directory tree first + while IFS= read -r d; do + t_mkdir "drivers/${d#./}" + done < <(cd "$DRIVERS_DIR" && find . -mindepth 1 -type d) while IFS= read -r f; do - rel="${f#$DRIVERS_DIR/}" - dest="$MOUNT_POINT/drivers/$rel" - if [ "$(stat -c%s "$f")" -gt "$FAT32_MAX" ]; then - echo " Splitting $rel..." - split -b "$FAT32_MAX" -d -a 2 "$f" "${dest}.part." - DRV_SPLIT=$((DRV_SPLIT + 1)) - else - cp "$f" "$dest" - fi + rel="${f#"$DRIVERS_DIR"/}" + reldir="$(dirname "$rel")" + [ "$reldir" = "." ] && reldir="" || reldir="/$reldir" + t_cp_split "$f" "drivers$reldir" done < <(find "$DRIVERS_DIR" -type f) - DRIVERS_SIZE=$(du -sh "$MOUNT_POINT/drivers" | cut -f1) - echo " Copied drivers/ ($DRIVERS_SIZE, $DRV_SPLIT split)" + DRIVERS_SIZE=$(du -shL "$DRIVERS_DIR" | cut -f1) + echo " Copied drivers/ ($DRIVERS_SIZE, $SPLIT_COUNT split)" +elif [ -z "$DRIVERS_DIR" ]; then + echo " Skipped drivers-staging/ (--skip-drivers)" else echo " No drivers-staging/ found (drivers can be downloaded later)" fi @@ -340,9 +610,9 @@ fi # Optionally copy WinPE deployment images if [ -n "$WINPE_SOURCE" ] && [ -d "$WINPE_SOURCE" ]; then echo " Copying WinPE deployment content from $WINPE_SOURCE..." - mkdir -p "$MOUNT_POINT/images" - cp -r "$WINPE_SOURCE"/* "$MOUNT_POINT/images/" 2>/dev/null || true - IMG_SIZE=$(du -sh "$MOUNT_POINT/images" | cut -f1) + t_mkdir images + t_cp images "$WINPE_SOURCE"/* || true + IMG_SIZE=$(du -shL "$WINPE_SOURCE" | cut -f1) echo " Copied WinPE images ($IMG_SIZE)" elif [ -n "$WINPE_SOURCE" ]; then echo " WARNING: WinPE source path not found: $WINPE_SOURCE (skipping)" @@ -351,23 +621,44 @@ fi # List what's on CIDATA echo "" echo " CIDATA contents:" -ls -lh "$MOUNT_POINT/" | sed 's/^/ /' +if [ "$IMAGE_MODE" -eq 0 ]; then + # shellcheck disable=SC2012 # human-readable listing only + ls -lh "$MOUNT_POINT/" | sed 's/^/ /' +else + mdir -i "$CIDATA_IMG" ::/ | sed 's/^/ /' +fi -# --- Cleanup --- +# --- Finish --- echo "" -echo "[6/6] Syncing and unmounting..." -sync -umount "$MOUNT_POINT" -rmdir "$MOUNT_POINT" +if [ "$IMAGE_MODE" -eq 0 ]; then + echo "[6/6] Syncing and unmounting..." + sync + umount "$MOUNT_POINT" + rmdir "$MOUNT_POINT" + MOUNT_POINT="" +else + echo "[6/6] Writing CIDATA filesystem into $IMAGE_FILE at sector $PART_START..." + dd if="$CIDATA_IMG" of="$IMAGE_FILE" bs=1M conv=notrunc,sparse \ + oflag=seek_bytes seek=$((PART_START * SECTOR_SIZE)) status=none + rm -f "$CIDATA_IMG" + echo " Partition table:" + sfdisk -l "$IMAGE_FILE" 2>/dev/null | sed 's/^/ /' +fi echo "" echo "============================================" echo "USB build complete!" echo "============================================" echo "" -echo "Next steps:" -echo " 1. Insert USB into target machine" -echo " 2. Boot from USB (F12 / boot menu)" -echo " 3. Ubuntu will auto-install and configure the PXE server" -echo " 4. After reboot, move the NIC to the isolated PXE network" +if [ "$IMAGE_MODE" -eq 1 ]; then + echo "Image: $IMAGE_FILE" + echo " Write to a stick with: sudo dd if=$IMAGE_FILE of=/dev/sdX bs=4M conv=fsync status=progress" + echo " or attach it to a VM as a USB disk (see tests/vm/README.md)." +else + echo "Next steps:" + echo " 1. Insert USB into target machine" + echo " 2. Boot from USB (F12 / boot menu)" + echo " 3. Ubuntu will auto-install and configure the PXE server" + echo " 4. After reboot, move the NIC to the isolated PXE network" +fi echo "" diff --git a/tests/vm/README.md b/tests/vm/README.md new file mode 100644 index 0000000..e1bed7c --- /dev/null +++ b/tests/vm/README.md @@ -0,0 +1,60 @@ +# pxetest VM harness + +Proves a new PXE server can be built from this repo, in two libvirt VMs on +a laptop, without root and without touching a real network. + +## Topology + +| Object | What | +|-------------------|----------------------------------------------------------------------| +| `pxetest-imaging` | libvirt network, bridge `virbr-pxetest`. No ``, no ``: the host has no address on it and libvirt runs no DHCP there. Only VM taps are on the bridge. | +| `default` | libvirt NAT, 192.168.122.0/24. Stands in for the GE/corp network. | +| `pxetest-server` | 2 vCPU, 4 GiB, q35 + OVMF Secure Boot (MS keys), 60G thin qcow2 (`vda`, boot 1), the USB image as a USB disk (`sda`, boot 2). | +| `pxetest-client` | 2 vCPU, 2 GiB, OVMF Secure Boot (MS keys), 1G blank disk, network boot first (UEFI IPv4 PXE, virtio-net, OVMF driver, no option ROM). | + +NIC MACs (fixed): + +| VM | NIC | MAC | Network | Playbook var | +|--------|---------|---------------------|-------------------|-------------------| +| server | GE | `52:54:00:9e:10:01` | `default` NAT | `pxe_ge_mac` / corp_nic_mac | +| server | imaging | `52:54:00:9e:10:02` | `pxetest-imaging` | `pxe_imaging_mac` / pxe_nic_mac | +| client | - | `52:54:00:9e:10:03` | `pxetest-imaging` | - | + +The disk boots before the USB on purpose: a blank disk falls through to +the installer, and after the install the server boots its disk instead of +re-running autoinstall. CIDATA stays attached for the first-boot playbook. + +Never attach a pxetest VM to a physical NIC, a host bridge with physical +ports, or macvtap: the server's dnsmasq serves DHCP/PXE as 172.16.9.1. +`create.sh`, `start.sh` and `status.sh` check the isolation and refuse to +continue if `pxetest-imaging` ever gains a ``, an ``, a host +IPv4 address or a non-tap bridge port. + +## Run it + + export PXETEST_CACHE=~/.cache/pxetest # ISO + image (about 10 GB) + export PXETEST_PAYLOAD_ROOT=/home/camp/projects/pxe # checkout holding offline-packages/, boot-files/, ... + + tests/vm/fetch-iso.sh # Ubuntu 24.04.5 live-server ISO, SHA256SUMS-verified + tests/vm/build-image.sh # build-usb.sh --image, upload as volume pxetest-usb.img + tests/vm/create.sh # network, volumes, both VMs (prints the MAC pins) + tests/vm/start.sh server # autoinstall, reboot, first-boot playbook + tests/vm/status.sh --shot /tmp/pxetest-shots # states, leases, screenshots + tests/vm/start.sh client # once the server playbook is done + tests/vm/stop.sh # ACPI shutdown both, keep them defined + tests/vm/destroy.sh # remove every pxetest-* object + +`build-image.sh` skips enrollment/, bios-staging/, drivers-staging/ and +boot-tools/ by default; `--with-enrollment` and `--with-boot-tools` add +them back, `--size 20G` fixes the image size (default `auto`). If the host +has no `sfdisk` or `mtools`, the debs are extracted into +`$PXETEST_CACHE/tools` (no install, no root). + +Watch the server with `virt-viewer -c qemu:///system pxetest-server` (VNC on +127.0.0.1) or screenshots. On the installed system, first-boot output goes +to `/var/log/first-boot.log`. Once the GE NIC takes a DHCP lease on the NAT +network, `status.sh` shows its address and `ssh pxe@
` works from +the host. + +Memory: server 4 GiB + client 2 GiB. Keep other VMs (win11) off while +both run, and stop them when done. diff --git a/tests/vm/build-image.sh b/tests/vm/build-image.sh new file mode 100755 index 0000000..10bc5a8 --- /dev/null +++ b/tests/vm/build-image.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Build the installer USB as a disk image FILE with build-usb.sh image mode, +# then upload it into the libvirt 'default' pool as volume pxetest-usb.img. +# +# Usage: tests/vm/build-image.sh [--with-enrollment] [--with-boot-tools] [--size SIZE] +# Default skips enrollment/, bios-staging/, drivers-staging/ and boot-tools/ +# to keep the image small; SIZE defaults to "auto". +# Env: PXETEST_PAYLOAD_ROOT (where offline-packages/, boot-files/... live) +source "$(dirname "$0")/common.sh" + +SIZE=auto +SKIPS=(--skip-enrollment --skip-bios --skip-drivers --skip-boot-tools) +while [ $# -gt 0 ]; do + case "$1" in + --with-enrollment) SKIPS=("${SKIPS[@]/--skip-enrollment}"); shift ;; + --with-boot-tools) SKIPS=("${SKIPS[@]/--skip-boot-tools}"); shift ;; + --size) SIZE="$2"; shift 2 ;; + *) die "unknown option $1" ;; + esac +done +ARGS=() +for s in "${SKIPS[@]}"; do [ -n "$s" ] && ARGS+=("$s"); done + +[ -f "$UBUNTU_ISO" ] || die "no ISO at $UBUNTU_ISO; run tests/vm/fetch-iso.sh" +ensure_image_tools + +# Report what the payload root has and lacks (do not invent content). +log "payload root: $PXETEST_PAYLOAD_ROOT" +for d in offline-packages pip-wheels boot-files boot-tools enrollment bios-staging drivers-staging; do + if [ -d "$PXETEST_PAYLOAD_ROOT/$d" ]; then + printf ' %-17s %s\n' "$d" "$(du -shL "$PXETEST_PAYLOAD_ROOT/$d" 2>/dev/null | cut -f1)" + else + printf ' %-17s MISSING\n' "$d" + fi +done +for f in ipxe-shimx64.efi ipxeboot.tar.gz wimboot boot.wim BCD boot.sdi bootx64.efi undionly.kpxe; do + [ -f "$PXETEST_PAYLOAD_ROOT/boot-files/$f" ] || log " boot-files/$f MISSING" +done + +if [ -e "$USB_IMAGE" ] && [ ! -f "$USB_IMAGE" ]; then + die "$USB_IMAGE is not a regular file" +fi + +"$REPO_ROOT/scripts/build-usb.sh" --yes --payload-root "$PXETEST_PAYLOAD_ROOT" \ + "${ARGS[@]}" --image "$USB_IMAGE" "$SIZE" "$UBUNTU_ISO" + +BYTES=$(stat -c%s "$USB_IMAGE") +if dom_exists "$SERVER_VM" && [ "$(dom_state "$SERVER_VM")" != "shut off" ]; then + die "$SERVER_VM is running; stop it before replacing its USB volume" +fi +if vol_exists "$USB_VOL"; then + virsh vol-delete --pool "$POOL" "$USB_VOL" >/dev/null +fi +log "uploading $USB_IMAGE ($((BYTES / 1048576)) MiB) to pool $POOL as $USB_VOL" +virsh vol-create-as "$POOL" "$USB_VOL" "$BYTES" --format raw >/dev/null +virsh vol-upload --pool "$POOL" --sparse "$USB_VOL" "$USB_IMAGE" +log "done" diff --git a/tests/vm/common.sh b/tests/vm/common.sh new file mode 100755 index 0000000..8e2e8ed --- /dev/null +++ b/tests/vm/common.sh @@ -0,0 +1,113 @@ +# shellcheck shell=bash disable=SC2034 +# Shared settings for the pxetest VM harness. Sourced, not run. +# +# Everything this harness creates is named pxetest-*: the two domains, the +# isolated network, and the volumes in the libvirt 'default' pool. Nothing +# else is touched. + +set -euo pipefail + +VM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$VM_DIR/../.." && pwd)" + +export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}" + +# Download / build cache (ISO, USB image, extracted helper tools). +PXETEST_CACHE="${PXETEST_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/pxetest}" + +# Where the gitignored payload dirs (offline-packages/, pip-wheels/, +# boot-files/, boot-tools/, enrollment/, ...) live. Defaults to this repo; +# point it at the main checkout when running from a worktree. +PXETEST_PAYLOAD_ROOT="${PXETEST_PAYLOAD_ROOT:-$REPO_ROOT}" + +# Ubuntu ISO +UBUNTU_SERIES="${UBUNTU_SERIES:-24.04}" +UBUNTU_ISO_NAME="${UBUNTU_ISO_NAME:-ubuntu-24.04.5-live-server-amd64.iso}" +UBUNTU_RELEASE_URL="${UBUNTU_RELEASE_URL:-https://releases.ubuntu.com/$UBUNTU_SERIES}" +UBUNTU_ISO="$PXETEST_CACHE/$UBUNTU_ISO_NAME" + +USB_IMAGE="$PXETEST_CACHE/pxetest-usb.img" + +# libvirt objects +POOL="default" +SERVER_VM="pxetest-server" +CLIENT_VM="pxetest-client" +IMAGING_NET="pxetest-imaging" +IMAGING_BRIDGE="virbr-pxetest" +GE_NET="default" # libvirt NAT, 192.168.122.0/24 = "GE/corp" +SERVER_DISK_VOL="pxetest-server.qcow2" +SERVER_DISK_SIZE="${SERVER_DISK_SIZE:-60G}" +USB_VOL="pxetest-usb.img" +CLIENT_DISK_VOL="pxetest-client.qcow2" + +SERVER_MEM_MIB="${SERVER_MEM_MIB:-4096}" +CLIENT_MEM_MIB="${CLIENT_MEM_MIB:-2048}" + +# Fixed MACs so the playbook's NIC pins can be written once for this site. +SERVER_GE_MAC="52:54:00:9e:10:01" # on 'default' NAT (GE/corp side) +SERVER_IMAGING_MAC="52:54:00:9e:10:02" # on pxetest-imaging (imaging side) +CLIENT_MAC="52:54:00:9e:10:03" # on pxetest-imaging only + +log() { printf '[pxetest] %s\n' "$*"; } +die() { printf '[pxetest] ERROR: %s\n' "$*" >&2; exit 1; } + +dom_exists() { virsh dominfo "$1" >/dev/null 2>&1; } +dom_state() { virsh domstate "$1" 2>/dev/null | head -1; } +net_exists() { virsh net-info "$1" >/dev/null 2>&1; } +# No "| grep -q" under pipefail: grep exits early, the writer gets SIGPIPE +# and the pipeline reads as failed even on a match. +net_active() { [ "$(virsh net-info "$1" 2>/dev/null | awk '/^Active:/{print $2}')" = "yes" ]; } +vol_exists() { virsh vol-info --pool "$POOL" "$1" >/dev/null 2>&1; } + +# sfdisk and mtools are needed for image mode. If the host lacks them, +# extract the Ubuntu debs into the cache (no root, no install). +ensure_image_tools() { + local troot="$PXETEST_CACHE/tools/root" + if [ -d "$troot" ]; then + export PATH="$troot/usr/sbin:$troot/usr/bin:$PATH" + export LD_LIBRARY_PATH="$troot/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + fi + if command -v sfdisk >/dev/null 2>&1 && command -v mcopy >/dev/null 2>&1 \ + && sfdisk --version >/dev/null 2>&1; then + return 0 + fi + log "sfdisk/mtools missing: extracting fdisk, libfdisk1, mtools debs into $troot" + mkdir -p "$PXETEST_CACHE/tools/debs" "$troot" + (cd "$PXETEST_CACHE/tools/debs" && apt-get download fdisk libfdisk1 mtools) \ + || die "apt-get download fdisk libfdisk1 mtools failed; install fdisk + mtools" + local d + for d in "$PXETEST_CACHE/tools/debs"/*.deb; do + dpkg-deb -x "$d" "$troot" + done + export PATH="$troot/usr/sbin:$troot/usr/bin:$PATH" + export LD_LIBRARY_PATH="$troot/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + if ! sfdisk --version >/dev/null || ! command -v mcopy >/dev/null; then + die "extracted sfdisk/mcopy do not run" + fi +} + +# The imaging network must be fully isolated: no , no +# (so the host has no address on it and libvirt runs no DHCP there). +assert_imaging_isolated() { + local xml + xml=$(virsh net-dumpxml "$IMAGING_NET") + if grep -q ' or element: NOT isolated. Run destroy.sh and create.sh." + fi + local v4 + v4=$(ip -4 -o addr show dev "$IMAGING_BRIDGE" 2>/dev/null || true) + if [ -n "$v4" ]; then + die "host has an IPv4 address on $IMAGING_BRIDGE: NOT isolated" + fi + # The bridge must hold only libvirt tap devices (vnetN), never a + # physical port. + local port + for port in /sys/class/net/"$IMAGING_BRIDGE"/brif/*; do + [ -e "$port" ] || continue + port=$(basename "$port") + case "$port" in + vnet*) ;; + *) die "$IMAGING_BRIDGE has non-tap port $port: NOT isolated" ;; + esac + done +} diff --git a/tests/vm/create.sh b/tests/vm/create.sh new file mode 100755 index 0000000..991a069 --- /dev/null +++ b/tests/vm/create.sh @@ -0,0 +1,172 @@ +#!/bin/bash +# Define the pxetest network, volumes and VMs. Idempotent: existing +# pxetest-* objects are kept (run destroy.sh first to rebuild them). +# +# pxetest-imaging isolated libvirt network: no , no , so the +# host has no address on it and libvirt runs no DHCP. +# Bridge virbr-pxetest holds only the VMs' tap devices. +# pxetest-server 2 vCPU, 4 GiB, OVMF (Secure Boot, MS keys), 60G thin +# qcow2 + the USB image as a USB disk. +# NIC1 52:54:00:9e:10:01 on 'default' NAT ("GE/corp") +# NIC2 52:54:00:9e:10:02 on pxetest-imaging ("imaging") +# pxetest-client 2 vCPU, 2 GiB, OVMF Secure Boot with MS keys, 1G blank +# disk, one NIC 52:54:00:9e:10:03 on pxetest-imaging, +# network boot first (UEFI IPv4 PXE). +# +# Usage: tests/vm/create.sh +source "$(dirname "$0")/common.sh" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +# --- isolated imaging network --- +if ! net_exists "$IMAGING_NET"; then + cat >"$TMP/net.xml" < + $IMAGING_NET + + +XML + virsh net-define "$TMP/net.xml" >/dev/null + log "defined network $IMAGING_NET (isolated, no forward, no ip)" +fi +virsh net-autostart --disable "$IMAGING_NET" >/dev/null 2>&1 || true +if ! net_active "$IMAGING_NET"; then + virsh net-start "$IMAGING_NET" >/dev/null +fi +assert_imaging_isolated +net_exists "$GE_NET" || die "libvirt network '$GE_NET' (NAT) does not exist" +net_active "$GE_NET" || virsh net-start "$GE_NET" >/dev/null + +# --- volumes --- +if ! vol_exists "$SERVER_DISK_VOL"; then + virsh vol-create-as "$POOL" "$SERVER_DISK_VOL" "$SERVER_DISK_SIZE" --format qcow2 >/dev/null + log "created $SERVER_DISK_VOL ($SERVER_DISK_SIZE thin qcow2)" +fi +if ! vol_exists "$CLIENT_DISK_VOL"; then + virsh vol-create-as "$POOL" "$CLIENT_DISK_VOL" 1G --format qcow2 >/dev/null + log "created $CLIENT_DISK_VOL (1G blank)" +fi +vol_exists "$USB_VOL" || log "WARNING: no $USB_VOL volume yet; run build-image.sh before start.sh" + +# Disks are type='file' with the pool's path: with type='volume' sources +# qemu got "Permission denied" opening them (AppArmor profile). +POOL_DIR=$(virsh pool-dumpxml "$POOL" | sed -n 's|.*\(.*\).*|\1|p' | head -1) +[ -n "$POOL_DIR" ] || die "cannot read the path of pool $POOL" + +# Secure Boot with Microsoft keys enrolled: libvirt picks the edk2 +# descriptor (40-edk2-x86_64-secure-enrolled.json) and copies its +# OVMF_VARS_4M.ms.fd template into a per-VM nvram. +efi_os() { + cat < + hvm + + + + + + +XML +} + +# --- server VM --- +# Boot order: the installed disk first, the USB second. A blank disk falls +# through to the USB; after the install the disk boots, so autoinstall does +# not loop. CIDATA stays attached for the first-boot playbook. +if ! dom_exists "$SERVER_VM"; then + cat >"$TMP/server.xml" < + $SERVER_VM + $SERVER_MEM_MIB + 2 +$(efi_os) + + + restart + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /dev/urandom + + +XML + virsh define "$TMP/server.xml" >/dev/null + log "defined $SERVER_VM" +fi + +# --- client VM --- +if ! dom_exists "$CLIENT_VM"; then + cat >"$TMP/client.xml" < + $CLIENT_VM + $CLIENT_MEM_MIB + 2 +$(efi_os) + + + + + + + + + + + + + + + + + + + + + + + +XML + virsh define "$TMP/client.xml" >/dev/null + log "defined $CLIENT_VM" +fi + +# Never attach a pxetest VM anywhere but default NAT / pxetest-imaging. +for vm in "$SERVER_VM" "$CLIENT_VM"; do + while read -r _ type src _ _; do + case "$type:$src" in + network:"$GE_NET") [ "$vm" = "$SERVER_VM" ] || die "$vm is on $GE_NET" ;; + network:"$IMAGING_NET") ;; + *) die "$vm has an interface on $type:$src (only $GE_NET / $IMAGING_NET allowed)" ;; + esac + done < <(virsh domiflist "$vm" | tail -n +3 | grep -v '^$') +done +log "ready. Playbook NIC pins for this site:" +log " pxe_imaging_mac: \"$SERVER_IMAGING_MAC\" (pxe_nic_mac)" +log " pxe_ge_mac: \"$SERVER_GE_MAC\" (corp_nic_mac)" diff --git a/tests/vm/destroy.sh b/tests/vm/destroy.sh new file mode 100755 index 0000000..aa7550e --- /dev/null +++ b/tests/vm/destroy.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Remove every pxetest-* object: both domains (and their nvram), the +# volumes, and the isolated network. Idempotent. Does not touch the +# cached ISO / USB image file (delete $PXETEST_CACHE by hand). +# Usage: tests/vm/destroy.sh +source "$(dirname "$0")/common.sh" + +for vm in "$CLIENT_VM" "$SERVER_VM"; do + dom_exists "$vm" || continue + [ "$(dom_state "$vm")" = "shut off" ] || virsh destroy "$vm" >/dev/null + virsh undefine "$vm" --nvram >/dev/null + log "undefined $vm" +done +for v in "$SERVER_DISK_VOL" "$USB_VOL" "$CLIENT_DISK_VOL"; do + vol_exists "$v" || continue + virsh vol-delete --pool "$POOL" "$v" >/dev/null + log "deleted volume $v" +done +if net_exists "$IMAGING_NET"; then + virsh net-destroy "$IMAGING_NET" >/dev/null 2>&1 || true + virsh net-undefine "$IMAGING_NET" >/dev/null + log "removed network $IMAGING_NET" +fi diff --git a/tests/vm/fetch-iso.sh b/tests/vm/fetch-iso.sh new file mode 100755 index 0000000..d6621ff --- /dev/null +++ b/tests/vm/fetch-iso.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Download the Ubuntu live-server ISO into the cache and verify it against +# the release's SHA256SUMS. Idempotent: a verified ISO is not re-fetched. +# +# Usage: tests/vm/fetch-iso.sh +# Env: PXETEST_CACHE, UBUNTU_ISO_NAME (default ubuntu-24.04.5-live-server-amd64.iso) +source "$(dirname "$0")/common.sh" + +mkdir -p "$PXETEST_CACHE" +cd "$PXETEST_CACHE" + +log "fetching $UBUNTU_RELEASE_URL/SHA256SUMS" +curl -fsSL -m 60 -o SHA256SUMS.new "$UBUNTU_RELEASE_URL/SHA256SUMS" \ + || die "could not download SHA256SUMS (proxy? offline?)" +mv SHA256SUMS.new SHA256SUMS + +if ! grep -q "[ *]$UBUNTU_ISO_NAME\$" SHA256SUMS; then + log "available live-server ISOs:" + grep live-server SHA256SUMS | sed 's/^/ /' + die "$UBUNTU_ISO_NAME is not in SHA256SUMS; set UBUNTU_ISO_NAME" +fi + +verify() { grep "[ *]$UBUNTU_ISO_NAME\$" SHA256SUMS | sha256sum -c --status; } + +if [ -f "$UBUNTU_ISO_NAME" ] && verify; then + log "ISO already present and verified: $UBUNTU_ISO" + exit 0 +fi + +log "downloading $UBUNTU_RELEASE_URL/$UBUNTU_ISO_NAME (resumable)" +curl -fL -C - --retry 3 -o "$UBUNTU_ISO_NAME" "$UBUNTU_RELEASE_URL/$UBUNTU_ISO_NAME" \ + || die "ISO download failed (proxy? offline?)" +if ! verify; then + rm -f "$UBUNTU_ISO_NAME" + die "SHA256 mismatch for $UBUNTU_ISO_NAME (deleted; run again)" +fi +log "ISO verified: $UBUNTU_ISO" diff --git a/tests/vm/start.sh b/tests/vm/start.sh new file mode 100755 index 0000000..d63c2ef --- /dev/null +++ b/tests/vm/start.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Start pxetest VMs. Idempotent (a running VM is left alone). +# Usage: tests/vm/start.sh [server|client|all] (default: server) +# Start the client only once the server's playbook has finished; both +# together use 6 GiB. +source "$(dirname "$0")/common.sh" + +which="${1:-server}" +case "$which" in + server) vms=("$SERVER_VM") ;; + client) vms=("$CLIENT_VM") ;; + all) vms=("$SERVER_VM" "$CLIENT_VM") ;; + *) die "usage: $0 [server|client|all]" ;; +esac + +net_exists "$IMAGING_NET" || die "run create.sh first" +net_active "$IMAGING_NET" || virsh net-start "$IMAGING_NET" >/dev/null +assert_imaging_isolated + +for vm in "${vms[@]}"; do + dom_exists "$vm" || die "$vm not defined; run create.sh" + if [ "$vm" = "$SERVER_VM" ]; then + vol_exists "$USB_VOL" || die "no $USB_VOL; run build-image.sh" + fi + if [ "$(dom_state "$vm")" = "running" ]; then + log "$vm already running" + else + virsh start "$vm" >/dev/null + log "started $vm (VNC: $(virsh vncdisplay "$vm" 2>/dev/null | head -1))" + fi +done +assert_imaging_isolated diff --git a/tests/vm/status.sh b/tests/vm/status.sh new file mode 100755 index 0000000..6bf8925 --- /dev/null +++ b/tests/vm/status.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Show the state of every pxetest-* object, the isolation check, the NIC +# MACs, the server's GE-side DHCP lease, and optionally a screenshot. +# Usage: tests/vm/status.sh [--shot DIR] +source "$(dirname "$0")/common.sh" + +SHOT_DIR="" +[ "${1:-}" = "--shot" ] && SHOT_DIR="${2:?--shot needs a dir}" + +if ! net_exists "$IMAGING_NET"; then n=absent; elif net_active "$IMAGING_NET"; then n=active; else n=inactive; fi +echo "network $IMAGING_NET: $n" +if net_active "$IMAGING_NET"; then + (assert_imaging_isolated && echo " isolation: OK (no forward, no ip, no host IPv4, only vnet ports)") +fi +for v in "$SERVER_DISK_VOL" "$USB_VOL" "$CLIENT_DISK_VOL"; do + if vol_exists "$v"; then + echo "volume $v: $(virsh vol-info --pool "$POOL" "$v" | awk '/Capacity|Allocation/{printf "%s %s %s ", $1, $2, $3}')" + else + echo "volume $v: absent" + fi +done +for vm in "$SERVER_VM" "$CLIENT_VM"; do + if dom_exists "$vm"; then + echo "domain $vm: $(dom_state "$vm")" + virsh domiflist "$vm" | tail -n +3 | grep -v '^$' | sed 's/^/ /' + if [ "$(dom_state "$vm")" = "running" ]; then + echo " VNC $(virsh vncdisplay "$vm" 2>/dev/null | head -1)" + if [ -n "$SHOT_DIR" ]; then + mkdir -p "$SHOT_DIR" + f="$SHOT_DIR/$vm-$(date +%H%M%S).ppm" + virsh screenshot "$vm" "$f" >/dev/null 2>&1 && echo " screenshot $f" + fi + fi + else + echo "domain $vm: absent" + fi +done +echo "GE-side DHCP leases for $SERVER_GE_MAC (on $GE_NET):" +virsh net-dhcp-leases "$GE_NET" --mac "$SERVER_GE_MAC" 2>/dev/null | tail -n +3 | grep -v '^$' | sed 's/^/ /' || true diff --git a/tests/vm/stop.sh b/tests/vm/stop.sh new file mode 100755 index 0000000..9017e4f --- /dev/null +++ b/tests/vm/stop.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Shut pxetest VMs down (ACPI, then force after a timeout). Keeps them +# defined. Idempotent. +# Usage: tests/vm/stop.sh [server|client|all] (default: all) +source "$(dirname "$0")/common.sh" + +which="${1:-all}" +case "$which" in + server) vms=("$SERVER_VM") ;; + client) vms=("$CLIENT_VM") ;; + all) vms=("$CLIENT_VM" "$SERVER_VM") ;; + *) die "usage: $0 [server|client|all]" ;; +esac +for vm in "${vms[@]}"; do + dom_exists "$vm" || continue + [ "$(dom_state "$vm")" = "shut off" ] && { log "$vm already off"; continue; } + virsh shutdown "$vm" >/dev/null 2>&1 || true + for _ in $(seq 1 60); do + [ "$(dom_state "$vm")" = "shut off" ] && break + sleep 1 + done + if [ "$(dom_state "$vm")" != "shut off" ]; then + log "$vm ignored ACPI shutdown; forcing off" + virsh destroy "$vm" >/dev/null + fi + log "$vm off" +done