The script no longer needs the parent repository or SSH: it builds an answer ISO from windows11/seed/Autounattend.xml, installs Windows from the ISO in QEMU (hardware in windows11/qemu uses only devices with inbox Windows drivers), and the answer file generalizes the image with sysprep at the end. The base holds only Windows: no account (the built-in Administrator is disabled), no SSH, no settings, no VirtIO drivers, no machine identity and no cached answer files. VMs are configured on first boot from an Unattend.xml on an attached CD-ROM. Add --window and --from-img; press a key at the ISO boot prompt with the z key, which neither UEFI nor Windows Setup ignores or acts on. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
330 lines
13 KiB
Bash
Executable File
330 lines
13 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.
|
|
# After the first logon, the answer file 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 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]
|
|
# [--window] [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"
|
|
win_dir="${images_dir}/windows11"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $(basename "$0") [--force] [--keep-work] [--compress] [--from-img]
|
|
[--window] [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
|
|
--window Show the guest in a QEMU window (needs a desktop session)
|
|
instead of the VNC display on 127.0.0.1:5990
|
|
-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 display with --window (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=0
|
|
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 ;;
|
|
--window) window=1 ;;
|
|
-*) 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
|
|
|
|
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}"
|
|
config_text="${config_text//"\${${placeholder}}"/"$value"}"
|
|
done
|
|
printf '%s\n' "$config_text" >"$qemu_config"
|
|
|
|
if ((window)); then
|
|
[[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" || "$host_os" == darwin ]] \
|
|
|| die "--window needs a desktop session (DISPLAY or WAYLAND_DISPLAY is not set)."
|
|
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" -daemonize -pidfile "$pid_file"
|
|
)
|
|
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
|
|
}
|
|
|
|
"$qemu_binary" "${qemu_args[@]}"
|
|
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"
|