#!/usr/bin/env bash # Build a clean, generic Windows 11 base QCOW2 image. # # A VM disk can then be created as a QCOW2 overlay on this image, so a new VM # starts in seconds instead of running Windows Setup again. The base image is # made once, and this script needs nothing outside this directory: # 1. an answer ISO is built from windows11/seed/Autounattend.xml; # 2. QEMU installs Windows unattended from the ISO into a scratch directory. # The answer file then boots into audit mode, where it adds the VirtIO # drivers from the virtio-win ISO to the driver store and runs sysprep # /generalize, which removes the machine identity and shuts the guest down; # 3. the shut-down disk is converted into the output file and made read-only. # The image holds nothing but Windows and the VirtIO drivers, like a cloud image: # no other hardware-specific drivers (the installation hardware, windows11/qemu, # only uses devices with inbox Windows drivers), no machine identity, no user # account (the answer file holds no credentials either: audit mode needs no # account; the built-in Administrator is disabled), no SSH, no settings. Every # VM is configured on its first boot: Windows looks for an Unattend.xml in the # root of an attached CD-ROM during its specialize and OOBE passes, and so # takes the host name, account, auto logon, SSH and so on from a seed ISO. # # Needs qemu (qemu-system-x86_64 or qemu-system-aarch64), qemu-img, an ISO tool # (xorriso, genisoimage or mkisofs), socat or nc (to press a key at the boot # prompt of the ISO), the UEFI firmware for QEMU and the Windows ISO in # install-iso/ (see README.md). # # Usage: build-windows-base.sh [--force] [--keep-work] [--compress] [--from-img] # [--headless] [OUTPUT.qcow2] # OUTPUT defaults to the path that "download-images.sh --print-path windows11" # prints. set -euo pipefail images_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" downloader="${images_dir}/download-images.sh" # Path lookups below must not offer to run this script again. export VM_IMAGE_NO_PROMPT=1 win_dir="${images_dir}/windows11" usage() { cat <&2 exit 1 } force=0 keep_work=0 compress=0 from_img=0 window=1 output="" while (($#)); do case "$1" in -h|--help) usage; exit 0 ;; --force) force=1 ;; --keep-work) keep_work=1 ;; --compress) compress=1 ;; --from-img) from_img=1 ;; --headless) window=0 ;; -*) usage >&2; exit 2 ;; *) [[ -z "$output" ]] || { usage >&2; exit 2; }; output="$1" ;; esac shift done case "${VM_IMAGE_ARCH:-$(uname -m)}" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=amd64 ;; *) die "unsupported architecture: ${VM_IMAGE_ARCH:-$(uname -m)}" ;; esac export VM_IMAGE_ARCH="$arch" host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" [[ "$host_os" != darwin || "$arch" == arm64 ]] \ || die "amd64 guests are not supported on macOS hosts; use an arm64 image" if [[ "$arch" == arm64 ]]; then qemu_binary=qemu-system-aarch64; else qemu_binary=qemu-system-x86_64; fi for tool in "$qemu_binary" qemu-img; do command -v "$tool" >/dev/null || die "${tool} is not installed." done [[ -x "$downloader" ]] || die "download-images.sh was not found: ${downloader}" iso_tool="" for tool in xorriso genisoimage mkisofs; do command -v "$tool" >/dev/null && { iso_tool="$tool"; break; } done [[ -n "$iso_tool" ]] || die "install xorriso, genisoimage, or mkisofs." if ((!from_img)); then command -v socat >/dev/null || command -v nc >/dev/null \ || die "install socat or nc (it presses a key at the boot prompt of the ISO)." fi # Without the ISO, offer to download it (needs a terminal). if [[ -z "${WIN11_VM_INSTALL_ISO:-}" ]] && ! "$downloader" --print-path windows11 --iso >/dev/null 2>&1; then "$downloader" --print-path windows11 --iso >&2 || true if { : /dev/tty; } 2>/dev/null; then printf 'Download the Windows 11 ISO now? [y/N] ' >/dev/tty read -r answer &2 } mkdir -p "${BASE_BUILD_TMPDIR:-/var/tmp}" work="$(mktemp -d "${BASE_BUILD_TMPDIR:-/var/tmp}/build-windows-base.XXXXXX")" pid_file="${work}/qemu.pid" monitor_socket="${work}/qemu-monitor.sock" succeeded=0 vm_running() { [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null } cleanup() { if vm_running; then kill "$(cat "$pid_file")" 2>/dev/null || true fi if ((succeeded && !keep_work)); then rm -rf "$work" else note "Scratch directory kept: ${work}" fi } trap cleanup EXIT # Poll "$@" every 10 s until it succeeds; give up after $1 seconds. wait_for() { local limit="$1" shift local deadline=$((SECONDS + limit)) until "$@"; do ((SECONDS < deadline)) || return 1 sleep 10 done } # --- 1. The answer media ----------------------------------------------------- step "Building the answer ISO" # The answer file's components name the processor architecture, so render a # copy for the target architecture. seed_dir="${work}/seed" mkdir -p "$seed_dir" sed "s/processorArchitecture=\"amd64\"/processorArchitecture=\"${arch}\"/" \ "${win_dir}/seed/Autounattend.xml" >"${seed_dir}/Autounattend.xml" cp -- "${win_dir}/seed/Install-VirtIO-Drivers.ps1" "${seed_dir}/" seed_iso="${work}/seed.iso" case "$iso_tool" in xorriso) xorriso -as mkisofs -quiet -o "$seed_iso" -V ANSWER -J -r "$seed_dir" ;; *) "$iso_tool" -quiet -o "$seed_iso" -V ANSWER -J -r "$seed_dir" ;; esac # --- 2. The installation ----------------------------------------------------- step "Installing and generalizing Windows 11 (unattended; takes a while)" disk_image="${work}/disk.qcow2" uefi_vars="${work}/OVMF_VARS.fd" qemu_config="${work}/qemu.ini" cp -- "$uefi_vars_template" "$uefi_vars" qemu-img create -q -f qcow2 "$disk_image" "${WIN11_VM_DISK_SIZE:-64G}" # Fill in the file path placeholders of the config template. QEMU's config file # parser has no escape syntax for quoted values. config_text="$(<"$config_template")" for placeholder in uefi_code uefi_vars disk_image monitor_socket; do value="${!placeholder}" [[ "$value" != *\"* ]] || die "${placeholder} must not contain double quotes: ${value}" # No outer double quotes: bash 3.2 (macOS) keeps inner quotes literally. config_text=${config_text//"\${${placeholder}}"/"$value"} done printf '%s\n' "$config_text" >"$qemu_config" if ((window)) && [[ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" && "$host_os" != darwin ]]; then window=0 fi if ((window)); then if [[ -n "${WIN11_VM_DISPLAY:-}" ]]; then display="$WIN11_VM_DISPLAY" elif [[ "$host_os" == darwin ]]; then display=cocoa else display=gtk; fi else display="vnc=127.0.0.1:90" fi qemu_args=( -readconfig "$qemu_config" -readconfig "$platform_config" # "max" is the host CPU under KVM/HVF and the fullest model under TCG. -cpu max # The network that the NIC in the config file uses is defined here; -nic # none stops QEMU from adding a default NIC. Nothing is forwarded. -netdev user,id=net0 -nic none -display "$display" -serial "file:${work}/console.log" -pidfile "$pid_file" ) # The Cocoa/GTK window needs the original process (macOS crashes in fork()), so # only the VNC display is daemonized; the window runs as a background job. ((window)) || qemu_args+=(-daemonize) if [[ "$host_os" == darwin && "$arch" == arm64 ]] && ((!from_img)); then # With the HVF in-kernel GICv3, QEMU's usb-storage races on the status packet # and Windows Setup stalls 10 s after every burst of reads (utmapp/UTM#7970), # so use the emulated interrupt controller. The NVMe install disk of # --from-img does not use usb-storage, and hangs with the emulated controller. qemu_args+=(-machine kernel-irqchip=off) fi # q35 has IDE for the installation media; "virt" does not, so use USB. attach_media() { local id="$1" file="$2" boot_index="$3" ide_bus="$4" qemu_args+=(-drive "file=${file},if=none,id=${id},media=cdrom,readonly=on") if [[ "$arch" == amd64 ]]; then qemu_args+=(-device "ide-cd,drive=${id},bus=${ide_bus},bootindex=${boot_index}") else qemu_args+=(-device "usb-storage,drive=${id},bootindex=${boot_index}") fi } if ((from_img)); then # snapshot=on: NVMe cannot report write protection, and Windows writes to the # volume it mounts, so a read-only drive makes it fail. The writes go to a # temporary overlay and the image stays unchanged. qemu_args+=(-drive "file=${install_iso},if=none,id=install_media,format=raw,snapshot=on" -device "nvme,drive=install_media,serial=installer,bootindex=2") else attach_media install_media "$install_iso" 2 ide.1 fi attach_media answer_media "$seed_iso" 3 ide.2 # The VirtIO drivers are installed into the image from this ISO in audit mode. attach_media virtio_media "$virtio_iso" 4 ide.3 # The Windows ISO boots through bootmgr, which waits for a key ("Press any key # to boot from CD or DVD...") and gives up after a few seconds. Nobody is there # to press it, so a background loop taps the "z" key through the QEMU monitor # for the first 30 s. The key has to produce a character (UEFI ignores a lone # Shift) and Windows Setup starts within that time, so it must not be one that # Setup acts on: Space and Enter press its focused Cancel button, and Y, N and # C are accelerators of its buttons. press_key() { if command -v socat >/dev/null; then printf 'sendkey z\n' | socat -t1 - "UNIX-CONNECT:${monitor_socket}" else printf 'sendkey z\n' | nc -U -w1 "$monitor_socket" fi } if ((window)); then "$qemu_binary" "${qemu_args[@]}" /dev/null || die "QEMU exited before it started the guest." sleep 0.2 done else "$qemu_binary" "${qemu_args[@]}" fi if ((!from_img)); then ( deadline=$((SECONDS + 30)) while ((SECONDS < deadline)); do [[ -S "$monitor_socket" ]] && press_key >/dev/null 2>&1 || true sleep 1 done ) & fi # --- 3. Wait for the guest to power off ------------------------------------- if ((window)); then note "Screen: QEMU window; console log: ${work}/console.log" else note "Screen: VNC 127.0.0.1:5990; console log: ${work}/console.log" fi guest_off() { ! vm_running; } if ! wait_for "${BASE_BUILD_TIMEOUT:-5400}" guest_off; then die "the guest did not power off in time; look at its screen. If sysprep failed, its log is C:\\Windows\\System32\\Sysprep\\Panther\\setuperr.log in the guest." fi step "Converting the disk to ${output}" mkdir -p "$(dirname "$output")" convert_options=(-f qcow2 -O qcow2) ((compress)) && convert_options+=(-c) rm -f "${output}.part" qemu-img convert -p "${convert_options[@]}" "${work}/disk.qcow2" "${output}.part" [[ ! -e "$output" ]] || chmod u+w "$output" mv -f "${output}.part" "$output" chmod a-w "$output" succeeded=1 printf 'Windows 11 base image is ready: %s\n' "$output"