Files
vm-images/build-windows-base.sh
T
guofangandClaude Sonnet 5.5 91ae8cb147 build-windows-base.sh: show a window by default, fix macOS bash 3.2 and fork crashes
Replace --window with --headless (VNC); the window is the default. Fill the
QEMU config placeholders without nested quotes (bash 3.2 kept them literally),
and run QEMU as a background job instead of -daemonize when it shows a window
(Cocoa crashes in fork()).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 21:31:10 +08:00

359 lines
14 KiB
Bash
Executable File

#!/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 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 itself, like a cloud image: no VirtIO or
# 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 <<EOF
Usage: $(basename "$0") [--force] [--keep-work] [--compress] [--from-img]
[--headless] [OUTPUT.qcow2]
Install Windows 11 from the ISO, generalize it with sysprep and store the disk
as the read-only, generic base image that VM disks are cloned from.
--force Replace OUTPUT if it exists
--keep-work Keep the scratch state directory (also kept on failure)
--compress Compress the output (smaller, but clones read it slower)
--from-img Install from the disk image made by build-windows-install-disk.sh
(the ISO path ending in .img), which reads much faster than
the emulated CD-ROM
--headless Serve the guest screen over VNC on 127.0.0.1:5990 instead of
showing a QEMU window (the default; without a desktop
session on Linux, VNC is used as well)
-h, --help Show this help
Environment variables:
VM_IMAGE_ARCH amd64 or arm64 (default: the host's)
WIN11_VM_INSTALL_ISO Windows 11 ISO (default: the path that
"download-images.sh --print-path windows11 --iso" prints)
WIN11_VM_DISK_SIZE Size of the system disk (default: 64G)
WIN11_VM_UEFI_CODE OVMF read-only firmware (auto-detected if unset)
WIN11_VM_UEFI_VARS OVMF variable-store template (auto-detected if unset)
WIN11_VM_DISPLAY QEMU window display (default: cocoa on macOS,
gtk elsewhere)
BASE_BUILD_TMPDIR Parent of the scratch state directory, which needs about
25 GB (default: /var/tmp)
BASE_BUILD_TIMEOUT Seconds to wait for installation and sysprep (default: 5400)
EOF
}
die() {
printf 'Error: %s\n' "$1" >&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 >/dev/tty; } 2>/dev/null; then
printf 'Download the Windows 11 ISO now? [y/N] ' >/dev/tty
read -r answer </dev/tty || answer=
[[ "$answer" == [yY] || "$answer" == [yY][eE][sS] ]] || exit 1
"$downloader" windows11 || exit 1
fi
fi
output="${output:-$("$downloader" --print-path windows11)}"
if [[ -e "$output" && $force -eq 0 ]]; then
die "${output} exists (it may be the backing file of existing VM disks); use --force or another OUTPUT."
fi
install_iso="${WIN11_VM_INSTALL_ISO:-$("$downloader" --print-path windows11 --iso)}"
((!from_img)) || install_iso="${install_iso%.*}.img"
[[ -f "$install_iso" ]] || die "the Windows 11 installation medium was not found: ${install_iso} (see README.md)."
pick_firmware() {
local override="$1" candidate
shift
if [[ -n "$override" ]]; then printf '%s\n' "$override"; return; fi
for candidate in "$@"; do
[[ -f "$candidate" ]] && { printf '%s\n' "$candidate"; return; }
done
printf '%s\n' "$1"
}
if [[ "$arch" == arm64 ]]; then
uefi_code="$(pick_firmware "${WIN11_VM_UEFI_CODE:-}" \
/usr/share/AAVMF/AAVMF_CODE.fd /usr/share/edk2/aarch64/QEMU_EFI.fd \
/opt/homebrew/share/qemu/edk2-aarch64-code.fd /usr/local/share/qemu/edk2-aarch64-code.fd)"
uefi_vars_template="$(pick_firmware "${WIN11_VM_UEFI_VARS:-}" \
/usr/share/AAVMF/AAVMF_VARS.fd /usr/share/edk2/aarch64/vars-template-pflash.raw \
/opt/homebrew/share/qemu/edk2-arm-vars.fd /usr/local/share/qemu/edk2-arm-vars.fd)"
else
uefi_code="$(pick_firmware "${WIN11_VM_UEFI_CODE:-}" \
/usr/share/OVMF/OVMF_CODE_4M.fd /usr/share/edk2/ovmf/OVMF_CODE.fd \
/opt/homebrew/share/qemu/edk2-x86_64-code.fd /usr/local/share/qemu/edk2-x86_64-code.fd)"
uefi_vars_template="$(pick_firmware "${WIN11_VM_UEFI_VARS:-}" \
/usr/share/OVMF/OVMF_VARS_4M.fd /usr/share/edk2/ovmf/OVMF_VARS.fd \
/opt/homebrew/share/qemu/edk2-i386-vars.fd /usr/local/share/qemu/edk2-i386-vars.fd)"
fi
config_template="${win_dir}/qemu/config.ini"
platform_config="${win_dir}/qemu/${host_os}-${arch}.ini"
for file in "$uefi_code" "$uefi_vars_template" "$config_template" "$platform_config" \
"${win_dir}/seed/Autounattend.xml"; do
[[ -f "$file" ]] || die "required file was not found: ${file}"
done
total_steps=3
step_number=0
step() {
step_number=$((step_number + 1))
printf '[%d/%d] %s\n' "$step_number" "$total_steps" "$*"
}
note() {
printf ' %s\n' "$*" >&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"
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 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 &
qemu_job=$!
until [[ -s "$pid_file" ]]; do
kill -0 "$qemu_job" 2>/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"