The answer file now boots into audit mode after Setup instead of logging on as the built-in Administrator with a password: no account or credentials in the answer file, and no copy of one left in C:\Windows\Panther. The auditUser commands disable the built-in Administrator, turn hibernation off (no hiberfil.sys in the image), remove cached answer files and setup logs, and run sysprep /generalize. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
331 lines
13 KiB
Bash
Executable File
331 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.
|
|
# 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]
|
|
# [--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"
|