Compare commits

..
21 Commits
Author SHA1 Message Date
GuofangandClaude Sonnet 5.5 384a4ade9f Add the rocky9 download target
Rocky Linux 9.8 GenericCloud Base for amd64 and arm64, with pinned SHA256
checksums, from dl.rockylinux.org and the NJU mirror.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 11:02:12 +08:00
GuofangandClaude Sonnet 5.5 6064bd650d Default the install prompts to yes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 23:36:15 +08:00
GuofangandClaude Sonnet 5.5 3935e2d5e2 Download Win32-OpenSSH through the wangguofang.net GitHub proxy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:28:34 +08:00
guofangandClaude Sonnet 5.5 00695cdddc Install the VirtIO drivers into the base image in audit mode
Add Install-VirtIO-Drivers.ps1, run from Autounattend.xml before sysprep, and
attach the virtio-win ISO (downloaded if missing) during the build.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:09:05 +08:00
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
guofangandClaude Sonnet 5.5 4eb26e3a96 Download with 16 aria2c connections by default
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 21:00:02 +08:00
guofangandClaude Sonnet 5.5 1476b4a3de Prompt step by step for the missing base image, ISO, npm deps and Chromium
Offer to build the base QCOW2, download the ISO, run npm ci and install
Chromium (from the npmmirror mirror by default). Let Playwright locate its
own Chromium so macOS works.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:53:50 +08:00
guofangandClaude Sonnet 5.5 b37d1e002c Report a missing base QCOW2 image instead of a missing ISO
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:26:07 +08:00
GuofangandClaude Sonnet 5.5 9c5abac9bf Generalize the base image from audit mode, with hibernation off
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>
2026-11-02 12:51:54 +08:00
GuofangandClaude Sonnet 5.5 cb01d2c120 Make build-windows-base.sh self-contained and produce a clean base image
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>
2026-11-02 11:19:26 +08:00
GuofangandClaude Sonnet 5.5 1dc40655df Add --window option to build-windows-base.sh
Show the guest in a QEMU window instead of the VNC display.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 10:05:55 +08:00
GuofangandClaude Sonnet 5.5 71c0c3d029 Add build-windows-base.sh to build the Windows 11 base image
Installs Windows unattended from the ISO in a scratch VM (under /var/tmp
by default), generalizes it with sysprep over SSH and stores the shut-down
disk as the read-only base image that run-windows11.py clones.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 16:12:23 +08:00
guofangandClaude Sonnet 5.5 30342b4e1b Make the staged ISO copy writable on macOS
macOS's openrsync ignores --chmod, so the staged directories kept the
read-only permissions of the UDF mount and wimlib-imagex split failed
with Permission denied. Run chmod -R u+rwX on the staging directory on
Darwin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:43:53 +08:00
GuofangandClaude Sonnet 5.5 592c7294c3 Add a windows11 target that downloads the Windows 11 ISO with headless Chromium
Move the Node.js script and its dependencies into nodejs/ and let
download-images.sh call it for the image architecture.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 16:45:36 +08:00
GuofangandClaude Sonnet 5.5 5e58722aca Add an openssh-win target to download the Win32-OpenSSH zip
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 14:28:13 +08:00
GuofangandClaude Sonnet 5.5 f5881cd5c8 Default the work directory to /var/tmp
/tmp is memory-backed on some systems, and the work directory holds about
10 GiB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:38:47 +08:00
GuofangandClaude Sonnet 5.5 e1148cce7b Drop the redundant mkdir of the staged sources directory
rsync already creates sources/ because the ISO has one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:28:44 +08:00
GuofangandClaude Sonnet 5.5 d9707fd57b Stage the Windows ISO files with rsync before copying them into the image
Copy the ISO without install.wim to a staging directory, split install.wim
into it, and fill the image with one recursive mcopy instead of a mmd and
mcopy per directory. rsync --chmod keeps the staged directories writable so
that cleanup can remove them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:26:24 +08:00
GuofangandClaude Sonnet 5.5 dd29fd92a7 Drop .install from the Windows install disk image name
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 09:28:54 +08:00
GuofangandClaude Sonnet 5.5 2aae652569 Ignore aria2 progress files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 14:29:06 +08:00
GuofangandClaude Sonnet 5.5 5468fdc033 Log each command in build-windows-install-disk.sh; build the image in place
Print the command line before every external command (hdiutil, udisksctl,
wimlib-imagex, sgdisk, mkfs.fat, mmd, mcopy and so on) so the log shows
which command is running. Write the image straight to the output path
instead of moving it from the work directory, and remove a partial output
if the run fails or is cancelled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 14:20:31 +08:00
14 changed files with 1139 additions and 62 deletions

No files matched your search

+5
View File
@@ -4,10 +4,15 @@
*.img
*.vhd
*.vhdx
*.zip
*.part
*.part.url
*.aria2
# Keep the intended directory layout in Git.
!base-qcow2/.gitkeep
!install-iso/.gitkeep
!tools-iso/.gitkeep
# Node dependencies of nodejs/get-windows-iso-url.js.
node_modules/
+62 -5
View File
@@ -17,6 +17,7 @@ Download or check selected targets:
```bash
./download-images.sh debian13
./download-images.sh almalinux10
./download-images.sh rocky9
./download-images.sh fedora44
./download-images.sh ubuntu2604
./download-images.sh virtio-win
@@ -30,6 +31,26 @@ arm64 hosts) and `.iso` in that order (`*Win*11*x64*.iso` or
version-sorted name is used. Check which file was picked with
`./download-images.sh --print-path windows11 --iso`.
To get a fresh temporary URL, `nodejs/get-windows-iso-url.js` drives Microsoft's
download page with headless Chromium (needs Node.js and an installed
Chromium, for example from Playwright):
```bash
cd nodejs
npm ci # once, installs playwright-core
node get-windows-iso-url.js # print the URL for the host architecture
node get-windows-iso-url.js --arch arm64 # choose the architecture explicitly
```
The script only prints the URL. `./download-images.sh windows11` downloads that
URL into `install-iso/` like the other images (aria2c, resumable) for the image
architecture (`VM_IMAGE_ARCH=arm64` for arm64), and skips it when a matching ISO
is already there. The `all` target does not include it.
The URL expires after about 24 hours and may be bound to the IP that requested
it, so download from the machine that ran the script. Run it with `--help` for
the language and path options.
## Query image paths
The path interface prints one absolute path without downloading anything:
@@ -37,6 +58,7 @@ The path interface prints one absolute path without downloading anything:
```bash
./download-images.sh --print-path debian13
./download-images.sh --print-path almalinux10
./download-images.sh --print-path rocky9
./download-images.sh --print-path fedora44
./download-images.sh --print-path ubuntu2604
./download-images.sh --print-path virtio-win
@@ -51,17 +73,23 @@ ISO (for example `Win11_25H2_English_x64_v2.iso` →
existing `*Win*11*x64*.qcow2` (`*Win*11*arm64*.qcow2` on arm64) in `base-qcow2/` is used instead.
Download URLs can be overridden with `DEBIAN_IMAGE_URL`, `ALMA_IMAGE_URL`,
`FEDORA_IMAGE_URL`, `UBUNTU_IMAGE_URL`, and `VIRTIO_WIN_ISO_URL`. Each may
`ROCKY_IMAGE_URL`, `FEDORA_IMAGE_URL`, `UBUNTU_IMAGE_URL`, `VIRTIO_WIN_ISO_URL`, and
`OPENSSH_WIN_ZIP_URL`. Each may
list several mirrors of the same file, separated by whitespace; the Fedora
image defaults to the TUNA, BFSU and NJU mirrors, and the Ubuntu image to the
image defaults to the TUNA, BFSU and NJU mirrors, the Rocky Linux image to the official
dl.rockylinux.org and NJU mirrors, and the Ubuntu image to the
TUNA, NJU and official cloud-images.ubuntu.com mirrors.
When [aria2](https://aria2.github.io/) is installed (`aria2c`), an image is
downloaded from all of its mirrors at once, with a single connection to each
mirror. Without it, `curl` (or `wget`) tries the mirrors in order. A partial
download is only resumed with the same downloader and mirror list; otherwise
it starts over. The Fedora and Ubuntu checksums can be overridden with
`FEDORA_IMAGE_SHA256` and `UBUNTU_IMAGE_SHA256` when using a custom image.
it starts over. The Rocky Linux, Fedora and Ubuntu checksums can be overridden with
`ROCKY_IMAGE_SHA256`, `FEDORA_IMAGE_SHA256` and `UBUNTU_IMAGE_SHA256` when using a custom image.
The `openssh-win` target downloads the Win32-OpenSSH zip matching the
architecture into `tools-iso/`: `OpenSSH-Win64.zip` on amd64 hosts and
`OpenSSH-ARM64.zip` on arm64 hosts.
## Windows installation disk image
@@ -79,8 +107,37 @@ sudo apt install wimtools dosfstools gdisk mtools p7zip-full
./build-windows-install-disk.sh [--force] install-iso/Windows11_..._arm64_....iso
```
The output defaults to the ISO path with `.install.img` in place of `.iso`. The
The output defaults to the ISO path with `.img` in place of `.iso`. The
image is built without root. The ISO has to be read as UDF (its ISO 9660 side only
holds a README): macOS uses `hdiutil`, Linux uses `mount -t udf` as root,
otherwise `udisksctl`, otherwise `7z` extracts it into a temporary directory
(which needs the free space of the whole ISO). The `*.img` file is ignored by Git.
## Windows base image
A VM disk can be created as a QCOW2 overlay on a Windows base image, so a new VM
does not run Windows Setup again. `build-windows-base.sh` makes that base image
(by default at the path `./download-images.sh --print-path windows11` prints): it
installs Windows unattended from the ISO in `install-iso/` (it does everything
itself: answer file in `windows11/seed/`, QEMU hardware in `windows11/qemu/`; this
directory has no dependency on any other repository). The answer file boots
into audit mode (no account and no credentials are needed), adds the VirtIO drivers
from the virtio-win ISO (downloaded if missing) to the driver store and runs
`sysprep /generalize` there as its last step; the script waits for the guest to power
off and stores the disk read-only. The image is clean and generic, like a cloud
image: nothing but Windows and the VirtIO drivers, with no account, no SSH, no settings,
no other hardware-specific drivers and no machine identity. Each VM is configured
on its first boot: Windows reads `Unattend.xml` from the root of an attached
CD-ROM during its specialize and OOBE passes (a VM launcher can mount such a
seed ISO when it creates a VM disk).
It needs `qemu`, `qemu-img`, an ISO tool (`xorriso`, `genisoimage` or `mkisofs`)
and `socat` or `nc`; there is no SSH to the guest.
```bash
./build-windows-base.sh [--force] [--compress] [--from-img] [--headless] [OUTPUT.qcow2]
```
It refuses to replace an existing base image without `--force`, because the
overlays of existing VMs still point at that file. Build to another `OUTPUT`
(for example with a version in its name) and point your VM launcher at it. The installation runs in a scratch directory
`/var/tmp/build-windows-base.*` (set `BASE_BUILD_TMPDIR` to use another parent; about 25 GB are needed).
+369
View File
@@ -0,0 +1,369 @@
#!/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 <<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_TOOLS_ISO virtio-win ISO (default: the path that
"download-images.sh --print-path virtio-win" prints; downloaded if missing)
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=
[[ -z "$answer" || "$answer" == [yY] || "$answer" == [yY][eE][sS] ]] || exit 1
"$downloader" windows11 || exit 1
fi
fi
# The VirtIO drivers for the image come from the virtio-win ISO.
virtio_iso="${WIN11_VM_TOOLS_ISO:-$("$downloader" --print-path virtio-win)}"
[[ -s "$virtio_iso" ]] || "$downloader" virtio-win || exit 1
[[ -s "$virtio_iso" ]] || die "the virtio-win ISO was not found: ${virtio_iso}"
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"
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 &
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"
+66 -44
View File
@@ -15,10 +15,10 @@
#
# Dependencies
# macOS (Homebrew) wimlib dosfstools gptfdisk mtools
# Debian wimtools dosfstools gdisk mtools udisks2
# Debian wimtools dosfstools gdisk mtools rsync udisks2
#
# Usage: build-windows-install-disk.sh [--force] ISO [OUTPUT.img]
# OUTPUT defaults to the ISO path with ".install.img" in place of ".iso".
# OUTPUT defaults to the ISO path with ".img" in place of ".iso".
set -euo pipefail
usage() {
@@ -39,7 +39,7 @@ if ((${#args[@]} < 1 || ${#args[@]} > 2)); then
exit 2
fi
iso="${args[0]}"
output="${args[1]:-${iso%.*}.install.img}"
output="${args[1]:-${iso%.*}.img}"
die() {
printf 'Error: %s\n' "$1" >&2
@@ -60,6 +60,18 @@ note() {
printf ' %s\n' "$*" >&2
}
# show logs a command line before it runs; run logs it and then runs it. Use show
# alone where the output of the command is captured.
show() {
local line
printf -v line '%q ' "$@"
note "\$ ${line% }"
}
run() {
show "$@"
"$@"
}
os="$(uname -s)"
case "$os" in
Darwin|Linux) ;;
@@ -76,7 +88,7 @@ for dir in ${brew_prefix:+"$brew_prefix/sbin"} /opt/homebrew/sbin /usr/local/sbi
[[ -d "$dir" ]] && PATH="$PATH:$dir"
done
tools=(wimlib-imagex mkfs.fat sgdisk mcopy mmd)
tools=(wimlib-imagex mkfs.fat sgdisk mcopy rsync)
[[ "$os" == Darwin ]] && tools+=(hdiutil)
missing=()
for tool in "${tools[@]}"; do
@@ -86,7 +98,7 @@ if ((${#missing[@]} > 0)); then
if [[ "$os" == Darwin ]]; then
hint="brew install wimlib dosfstools gptfdisk mtools"
else
hint="apt install wimtools dosfstools gdisk mtools"
hint="apt install wimtools dosfstools gdisk mtools rsync"
fi
die "missing tools: ${missing[*]} ($hint)"
fi
@@ -99,19 +111,27 @@ fi
# stat prints a file size with -f %z on macOS (BSD) and -c %s on Linux (GNU).
if [[ "$os" == Darwin ]]; then size_args=(-f %z); else size_args=(-c %s); fi
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/win11-install-disk.XXXXXX")"
# The work directory holds about 10 GiB, so it defaults to /var/tmp: /tmp is
# memory-backed on some systems (Debian 13, Fedora, Arch).
work_dir="$(mktemp -d "${TMPDIR:-/var/tmp}/win11-install-disk.XXXXXX")"
mount_dir="$work_dir/iso"
mkdir "$mount_dir"
note "work directory: $work_dir"
mount_method=""
loop_device=""
# Set while the output image is being written, so that a failed or cancelled
# run does not leave a half-built image behind.
partial_output=""
cleanup() {
[[ -z "$partial_output" ]] || rm -f "$partial_output"
case "$mount_method" in
hdiutil) hdiutil detach -quiet "$mount_dir" >/dev/null 2>&1 || true ;;
udisks)
udisksctl unmount -b "$loop_device" >/dev/null 2>&1 || true
udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true ;;
esac
# Staged directories may be read-only when the run was cancelled early.
chmod -R u+rwX "$work_dir" >/dev/null 2>&1 || true
rm -rf "$work_dir"
}
trap cleanup EXIT
@@ -127,6 +147,7 @@ mount_with_udisks() {
return 1
fi
local setup
show udisksctl loop-setup -r -f "$iso"
if ! setup="$(udisksctl loop-setup -r -f "$iso" 2>&1)"; then
note "udisksctl loop-setup failed: $setup"
return 1
@@ -142,8 +163,10 @@ mount_with_udisks() {
# A desktop session may have mounted the loop device already, which makes
# udisksctl mount fail with AlreadyMounted; use that mount point then.
local mounted point
show lsblk -no MOUNTPOINT "$loop_device"
point="$(lsblk -no MOUNTPOINT "$loop_device" 2>/dev/null | head -n 1)"
if [[ -z "$point" ]]; then
show udisksctl mount -b "$loop_device" -t udf -o ro
if mounted="$(udisksctl mount -b "$loop_device" -t udf -o ro 2>&1)"; then
# Some udisks versions end the message with a period, others do not.
point="$(sed -n 's/^Mounted .* at \(.*\)$/\1/p' <<<"$mounted")"
@@ -157,7 +180,7 @@ mount_with_udisks() {
fi
if [[ ! -d "$point" ]]; then
note "no usable mount point for $loop_device (parsed mount point: '${point}')"
udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true
run udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true
mount_method=""
return 1
fi
@@ -169,7 +192,7 @@ step "Reading the ISO $iso (it is UDF, so it has to be mounted)"
note "ISO size: $(stat -L "${size_args[@]}" "$iso") bytes"
if [[ "$os" == Darwin ]]; then
note "mounting with hdiutil at $mount_dir"
hdiutil attach -readonly -nobrowse -mountpoint "$mount_dir" "$iso" >/dev/null
run hdiutil attach -readonly -nobrowse -mountpoint "$mount_dir" "$iso" >/dev/null
mount_method=hdiutil
else
note "mounting with udisksctl"
@@ -183,20 +206,31 @@ install_wim="$mount_dir/sources/install.wim"
[[ -d "$mount_dir/efi/boot" ]] || die "efi/boot was not found in the ISO; is it a UEFI Windows ISO?"
note "install.wim: $(stat "${size_args[@]}" "$install_wim") bytes"
# The staging directory holds everything that goes into the image: the ISO files
# without install.wim, and the install.swm parts in sources/.
stage_dir="$work_dir/stage"
mkdir "$stage_dir"
step "Staging the ISO files without install.wim"
# The UDF mount is read-only, so its directories are too; --chmod keeps the copies
# writable, otherwise cleanup cannot remove them.
run rsync -rt --chmod=u+rwX --exclude=/sources/install.wim "$mount_dir/" "$stage_dir/"
if [[ "$os" == Darwin ]]; then
# macOS's openrsync ignores --chmod, so make the copies writable here (the split
# below writes into sources/).
run chmod -R u+rwX "$stage_dir"
fi
# Parts must stay below the 4 GiB FAT32 file limit; wimlib takes MiB.
step "Splitting install.wim into install.swm parts (FAT32 files must stay below 4 GiB)"
split_dir="$work_dir/split"
mkdir "$split_dir"
wimlib-imagex split "$install_wim" "$split_dir/install.swm" 3800 >/dev/null
for part in "$split_dir"/install*.swm; do
run wimlib-imagex split "$install_wim" "$stage_dir/sources/install.swm" 3800 >/dev/null
for part in "$stage_dir"/sources/install*.swm; do
note "$(basename "$part"): $(stat "${size_args[@]}" "$part") bytes"
done
# Size the image: everything but install.wim, the split parts, and 5% plus 64 MiB
# of room for FAT32 metadata and partition alignment.
# Size the image: the staged files, and 5% plus 64 MiB of room for FAT32
# metadata and partition alignment.
content_bytes=$(
{ find "$mount_dir" -type f ! -path "$install_wim" -exec stat "${size_args[@]}" {} +
find "$split_dir" -type f -exec stat "${size_args[@]}" {} +; } |
find "$stage_dir" -type f -exec stat "${size_args[@]}" {} + |
awk '{ total += $1 } END { printf "%d", total }'
)
mib=$((1024 * 1024))
@@ -204,42 +238,30 @@ image_mib=$(( (content_bytes + content_bytes / 20) / mib + 64 ))
step "Creating a $image_mib MiB image with a GPT and one FAT32 partition"
note "content to copy: $content_bytes bytes (without install.wim)"
image="$work_dir/disk.img"
truncate -s "${image_mib}m" "$image"
sgdisk -o -n 1:2048:0 -t 1:0700 -c 1:WINPE "$image" >/dev/null
image="$output"
run mkdir -p "$(dirname -- "$output")"
# --force replaces an existing image; truncate alone would keep its old content.
run rm -f "$image"
partial_output="$image"
run truncate -s "${image_mib}m" "$image"
run sgdisk -o -n 1:2048:0 -t 1:0700 -c 1:WINPE "$image" >/dev/null
show sgdisk -i 1 "$image"
sectors=$(sgdisk -i 1 "$image" | awk '/^Partition size:/ { print $3 }')
[[ -n "$sectors" ]] || die "could not read the partition size"
mkfs.fat -F 32 -n WINPE --offset 2048 "$image" $((sectors / 2)) >/dev/null
run mkfs.fat -F 32 -n WINPE --offset 2048 "$image" $((sectors / 2)) >/dev/null
# mtools addresses the partition by its byte offset inside the image.
mtools_image="$image@@$((2048 * 512))"
note "partition: $sectors sectors, formatted as FAT32 (label WINPE)"
export MTOOLS_SKIP_CHECK=1
copy_dir() { # copy_dir SOURCE_DIR TARGET_DIR_IN_IMAGE
local source="$1" target="$2" files=() entry
while IFS= read -r -d '' entry; do
files+=("$entry")
done < <(find "$source" -maxdepth 1 -type f ! -path "$install_wim" -print0)
if ((${#files[@]} > 0)); then
mcopy -Q -m -i "$mtools_image" "${files[@]}" "::$target/"
fi
}
step "Copying the installation files into the image"
copy_dir "$mount_dir" ""
dir_count=0
while IFS= read -r -d '' dir; do
relative="${dir#"$mount_dir"/}"
dir_count=$((dir_count + 1))
mmd -i "$mtools_image" "::$relative"
copy_dir "$dir" "$relative"
done < <(find "$mount_dir" -mindepth 1 -type d -print0 | sort -z)
note "copied $dir_count directories"
note "copying the install*.swm parts to sources/"
mcopy -Q -m -i "$mtools_image" "$split_dir"/install*.swm "::sources/"
entries=()
while IFS= read -r -d '' entry; do
entries+=("$entry")
done < <(find "$stage_dir" -mindepth 1 -maxdepth 1 -print0)
note "\$ mcopy -s -Q -m -i $mtools_image <${#entries[@]} entries from the staging directory> ::/"
mcopy -s -Q -m -i "$mtools_image" "${entries[@]}" "::/"
step "Moving the image to $output"
mkdir -p "$(dirname -- "$output")"
mv -f "$image" "$output"
partial_output=""
printf 'Built Windows 11 installation disk: %s\n' "$output"
+150 -13
View File
@@ -19,9 +19,12 @@ if [[ "$image_arch" == arm64 ]]; then
debian_image="${base_qcow2_dir}/debian-13-generic-arm64-20260810-2566.qcow2"
alma_image="${base_qcow2_dir}/AlmaLinux-10-GenericCloud-10.2-20260526.0.aarch64.qcow2"
fedora_image="${base_qcow2_dir}/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2"
rocky_image="${base_qcow2_dir}/Rocky-9-GenericCloud-Base-9.8-20260525.0.aarch64.qcow2"
ubuntu_image="${base_qcow2_dir}/ubuntu-26.04-server-cloudimg-arm64-20260927.img"
debian_url_default="https://cloud.debian.org/images/cloud/trixie/20260810-2566/debian-13-generic-arm64-20260810-2566.qcow2"
alma_url_default="https://repo.almalinux.org/almalinux/10/cloud/aarch64/images/AlmaLinux-10-GenericCloud-10.2-20260526.0.aarch64.qcow2"
rocky_url_default="https://dl.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud-Base-9.8-20260525.0.aarch64.qcow2
https://mirrors.nju.edu.cn/rocky/9/images/aarch64/Rocky-9-GenericCloud-Base-9.8-20260525.0.aarch64.qcow2"
fedora_url_default="https://mirrors.tuna.tsinghua.edu.cn/fedora/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2
https://mirrors.bfsu.edu.cn/fedora/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2
https://mirrors.nju.edu.cn/fedora/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2"
@@ -35,14 +38,18 @@ if [[ "$image_arch" == arm64 ]]; then
# ALMA_IMAGE_SHA256 / FEDORA_IMAGE_SHA256.
debian_sha512_default=""
alma_sha256_default=""
rocky_sha256_default="24692a444f1f0b8bb95375c38c8b43f8099a115347623691be2c330b40c8a1fe"
fedora_sha256_default=""
else
debian_image="${base_qcow2_dir}/debian-13-generic-amd64-20260810-2566.qcow2"
alma_image="${base_qcow2_dir}/AlmaLinux-10-GenericCloud-10.2-20260526.0.x86_64.qcow2"
fedora_image="${base_qcow2_dir}/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2"
rocky_image="${base_qcow2_dir}/Rocky-9-GenericCloud-Base-9.8-20260525.0.x86_64.qcow2"
ubuntu_image="${base_qcow2_dir}/ubuntu-26.04-server-cloudimg-amd64-20260927.img"
debian_url_default="https://cloud.debian.org/images/cloud/trixie/20260810-2566/debian-13-generic-amd64-20260810-2566.qcow2"
alma_url_default="https://repo.almalinux.org/almalinux/10/cloud/x86_64/images/AlmaLinux-10-GenericCloud-10.2-20260526.0.x86_64.qcow2"
rocky_url_default="https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base-9.8-20260525.0.x86_64.qcow2
https://mirrors.nju.edu.cn/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base-9.8-20260525.0.x86_64.qcow2"
fedora_url_default="https://mirrors.tuna.tsinghua.edu.cn/fedora/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2
https://mirrors.bfsu.edu.cn/fedora/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2
https://mirrors.nju.edu.cn/fedora/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2"
@@ -51,12 +58,22 @@ else
https://cloud-images.ubuntu.com/releases/26.04/release-20260927/ubuntu-26.04-server-cloudimg-amd64.img"
debian_sha512_default="f6978100d8031c266d55d7815ceea7fcdeacf28e1e5834fdb9c94ac96880a054a6e6f8681c2d3b0584e0057eaf3ef7353856b85212d04134744faa9b3bb1f24f"
alma_sha256_default="47f2218668dd4776be140dd92fa3bea700be1766e2c7d88bdfd6a4b50f477b4d"
rocky_sha256_default="92c206cc6f790c61583247eefe87890f8828420662c17cacf247cec78ab4eec8"
fedora_sha256_default="28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f"
ubuntu_sha256_default="8800651811af9a85465ad1d552add729947bb16488dddb4a9b5305a3d97332b2"
fi
virtio_image="${tools_iso_dir}/virtio-win.iso"
# Win32-OpenSSH names its zip after the architecture: Win64 for x64 and ARM64
# for arm64.
if [[ "$image_arch" == arm64 ]]; then
openssh_zip=OpenSSH-ARM64.zip
else
openssh_zip=OpenSSH-Win64.zip
fi
openssh_image="${tools_iso_dir}/${openssh_zip}"
# Windows 11 media is named after the architecture: x64 on amd64 hosts and
# Arm64 on arm64 hosts.
if [[ "$image_arch" == arm64 ]]; then
@@ -98,8 +115,10 @@ windows_qcow2_path() {
iso="$(basename -- "$iso")"
printf '%s/%s.qcow2\n' "$base_qcow2_dir" "${iso%.*}"
elif ! find_windows_image "$base_qcow2_dir" qcow2; then
printf 'Error: no Windows 11 %s ISO (*Win*11*%s*.iso) found in %s\n' \
"$windows_arch" "$windows_arch" "$install_iso_dir" >&2
printf 'Error: no Windows 11 %s base QCOW2 image (*Win*11*%s*.qcow2) found in %s\n' \
"$windows_arch" "$windows_arch" "$base_qcow2_dir" >&2
printf 'Copy a base image there, set WIN11_VM_BASE_IMAGE, or build one from an ISO in %s with build-windows-base.sh.\n' \
"$install_iso_dir" >&2
return 1
fi
}
@@ -109,28 +128,35 @@ windows_qcow2_path() {
# them in order.
debian_url="${DEBIAN_IMAGE_URL:-$debian_url_default}"
alma_url="${ALMA_IMAGE_URL:-$alma_url_default}"
rocky_url="${ROCKY_IMAGE_URL:-$rocky_url_default}"
fedora_url="${FEDORA_IMAGE_URL:-$fedora_url_default}"
ubuntu_url="${UBUNTU_IMAGE_URL:-$ubuntu_url_default}"
openssh_url="${OPENSSH_WIN_ZIP_URL:-https://wangguofang.net/github.com/PowerShell/Win32-OpenSSH/releases/download/10.0.0.0p2-Preview/${openssh_zip}}"
virtio_url="${VIRTIO_WIN_ISO_URL:-https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso}"
debian_sha512="${DEBIAN_IMAGE_SHA512:-$debian_sha512_default}"
alma_sha256="${ALMA_IMAGE_SHA256:-$alma_sha256_default}"
rocky_sha256="${ROCKY_IMAGE_SHA256:-$rocky_sha256_default}"
fedora_sha256="${FEDORA_IMAGE_SHA256:-$fedora_sha256_default}"
ubuntu_sha256="${UBUNTU_IMAGE_SHA256:-$ubuntu_sha256_default}"
force_download=false
usage() {
cat <<EOF
Usage: $(basename "$0") [--force] [all|debian13|almalinux10|fedora44|ubuntu2604|virtio-win] ...
Usage: $(basename "$0") [--force] [all|debian13|almalinux10|rocky9|fedora44|ubuntu2604|virtio-win|openssh-win|windows11] ...
$(basename "$0") --print-path TARGET [--iso]
Download one or more shared VM images. With no arguments, all targets are
processed. The Debian/AlmaLinux/Fedora/Ubuntu image architecture (arm64 or amd64)
processed. The Debian/AlmaLinux/Rocky Linux/Fedora/Ubuntu image architecture (arm64 or amd64)
is auto-detected from the host; override it with VM_IMAGE_ARCH.
When aria2c is installed and an image has several mirrors, the image is
downloaded from all of them at once, with one connection per mirror.
When aria2c is installed, an image is downloaded with 16 connections in total
(DOWNLOAD_CONNECTIONS, up to 16), spread across all its mirrors if it has several.
Otherwise curl or wget downloads from the mirrors in order.
The windows11 target is not part of "all". It downloads the Windows 11 ISO for
the image architecture with nodejs/get-windows-iso-url.js, which needs Node.js,
\`npm ci\` in nodejs/ and an installed Chromium (see README.md).
--force removes existing files and partial downloads for the selected targets,
then downloads them again from the beginning.
--print-path prints only the absolute image path without downloading it.
@@ -140,6 +166,36 @@ The Windows ISO is found automatically in install-iso/ by matching
EOF
}
# Ask a yes/no question on the terminal; fails when there is none or the answer
# is not yes (an empty answer means yes). stdout carries the printed path, so all dialogue goes to /dev/tty.
ask_yes_no() {
local answer
{ : </dev/tty >/dev/tty; } 2>/dev/null || return 1
printf '%s [Y/n] ' "$1" >/dev/tty
read -r answer </dev/tty || return 1
[[ -z "$answer" || "$answer" == [yY] || "$answer" == [yY][eE][sS] ]]
}
# Print the base QCOW2 path. When the image does not exist and a terminal is
# attached, offer to run build-windows-base.sh, which in turn offers to download
# the ISO. The builder sets VM_IMAGE_NO_PROMPT so its own lookups never ask.
windows_base_path() {
local qcow2 builder="${images_dir}/build-windows-base.sh"
if qcow2="$(windows_qcow2_path 2>/dev/null)" && [[ -f "$qcow2" ]]; then
printf '%s\n' "$qcow2"
return 0
fi
windows_qcow2_path >/dev/null || true
if [[ -z "${VM_IMAGE_NO_PROMPT:-}" && -x "$builder" ]] && ask_yes_no "Build the Windows 11 base image now with build-windows-base.sh?"; then
# stdout carries the path, so the build's output goes to the terminal.
"$builder" >/dev/tty 2>&1 </dev/tty || return 1
windows_qcow2_path
return
fi
# Name the expected path if the ISO fixes it; the caller reports it missing.
windows_qcow2_path 2>/dev/null
}
print_image_path() {
local target="$1"
local media="${2:-qcow2}"
@@ -147,14 +203,16 @@ print_image_path() {
case "$target" in
debian13) printf '%s\n' "$debian_image" ;;
almalinux10) printf '%s\n' "$alma_image" ;;
rocky9) printf '%s\n' "$rocky_image" ;;
fedora44) printf '%s\n' "$fedora_image" ;;
ubuntu2604) printf '%s\n' "$ubuntu_image" ;;
virtio-win) printf '%s\n' "$virtio_image" ;;
openssh-win) printf '%s\n' "$openssh_image" ;;
windows11)
if [[ "$media" == iso ]]; then
windows_iso_path
else
windows_qcow2_path
windows_base_path
fi
;;
*)
@@ -195,7 +253,7 @@ fi
for target in "${targets[@]}"; do
case "$target" in
all|debian13|almalinux10|fedora44|ubuntu2604|virtio-win) ;;
all|debian13|almalinux10|rocky9|fedora44|ubuntu2604|virtio-win|openssh-win|windows11) ;;
-h|--help) usage; exit 0 ;;
*) printf 'Error: unknown download target: %s\n' "$target" >&2; usage >&2; exit 2 ;;
esac
@@ -258,16 +316,17 @@ download_single_source() {
fi
}
# Download to $partial with aria2c, splitting the file across all URLs with a
# single connection to each server.
# Download to $partial with aria2c, splitting the file across all URLs with up
# to DOWNLOAD_CONNECTIONS (default 16) connections in total and to each server.
download_multi_source() {
local partial="$1"
shift
local connections="${DOWNLOAD_CONNECTIONS:-16}"
aria2c \
--continue=true \
--split="$#" \
--max-connection-per-server=1 \
--split="$connections" \
--max-connection-per-server="$connections" \
--file-allocation=none \
--auto-file-renaming=false \
--max-tries=5 \
@@ -286,6 +345,9 @@ download_file() {
local destination="$3"
local checksum_algorithm="${4:-}"
local expected_checksum="${5:-}"
# Signed URLs change on every request; this key identifies the source for
# resuming a partial download instead of the URLs when it is given.
local source_key="${6:-}"
local partial="${destination}.part"
# Records how the partial file was started, so that a partial download is
# only resumed with the same downloader and URLs.
@@ -329,7 +391,7 @@ download_file() {
# aria2c fills a partial file out of order, so it must not be resumed by
# curl or wget, and vice versa.
download_state="$(printf '%s\n' "$downloader" "${urls[@]}")"
download_state="$(printf '%s\n' "$downloader" "${source_key:-${urls[@]}}")"
if [[ -e "$partial" ]]; then
if [[ ! -f "$partial_url" ]] || [[ "$(<"$partial_url")" != "$download_state" ]]; then
printf 'Download source changed; restarting partial download.\n'
@@ -375,6 +437,61 @@ download_file() {
printf 'Downloaded %s:\n%s\n' "$label" "$destination"
}
# The Microsoft download link is temporary, so it is fetched with headless
# Chromium only when no matching ISO exists yet. "all" does not include it.
download_windows_iso() {
local iso
local url
local name
if [[ "$force_download" == true ]]; then
printf 'Forcing a fresh download of the Windows 11 %s ISO.\n' "$windows_arch"
while iso="$(find_windows_image "$install_iso_dir" iso)"; do
rm -f -- "$iso"
done
fi
if iso="$(find_windows_image "$install_iso_dir" iso)"; then
printf 'Windows 11 %s ISO already exists; skipping:\n%s\n' "$windows_arch" "$iso"
return
fi
if ! command -v node >/dev/null 2>&1; then
printf 'Error: node is required to get the Windows 11 ISO URL.\n' >&2
exit 1
fi
if [[ ! -d "${images_dir}/nodejs/node_modules/playwright-core" ]]; then
printf 'Error: the Node.js dependencies are not installed (npm ci has not been run in %s/nodejs).\n' \
"$images_dir" >&2
command -v npm >/dev/null 2>&1 || { printf 'Error: npm is required; install Node.js with npm.\n' >&2; exit 1; }
ask_yes_no "Run npm ci in ${images_dir}/nodejs now?" || exit 1
(cd "${images_dir}/nodejs" && npm ci) || exit 1
fi
local status=0
url="$(node "${images_dir}/nodejs/get-windows-iso-url.js" --arch "$windows_arch")" || status=$?
if ((status == 2)) && [[ -z "${CHROME_PATH:-}" ]]; then
# Exit status 2 means Chromium was not found (the script has said so).
ask_yes_no "Install Chromium with playwright-core now?" || exit 1
# Default to the npmmirror mirror (the official CDN is slow in China); set
# PLAYWRIGHT_DOWNLOAD_HOST to override it.
(cd "${images_dir}/nodejs" \
&& PLAYWRIGHT_DOWNLOAD_HOST="${PLAYWRIGHT_DOWNLOAD_HOST:-https://cdn.npmmirror.com/binaries/playwright}" \
npx playwright-core install chromium) || exit 1
url="$(node "${images_dir}/nodejs/get-windows-iso-url.js" --arch "$windows_arch")" || exit 1
elif ((status != 0)); then
exit "$status"
fi
[[ -n "$url" ]] || { printf 'Error: no Windows 11 ISO URL.\n' >&2; exit 1; }
name="${url%%\?*}"
name="${name##*/}"
name="$(printf '%b' "${name//%/\\x}")"
download_file \
"Windows 11 ${windows_arch} ISO" \
"$url" \
"${install_iso_dir}/${name}" \
'' \
'' \
"$name"
}
if wants_target debian13; then
download_file \
'Debian 13 cloud image' \
@@ -393,6 +510,15 @@ if wants_target almalinux10; then
"$alma_sha256"
fi
if wants_target rocky9; then
download_file \
'Rocky Linux 9 cloud image' \
"$rocky_url" \
"$rocky_image" \
sha256 \
"$rocky_sha256"
fi
if wants_target fedora44; then
download_file \
'Fedora 44 cloud image' \
@@ -417,3 +543,14 @@ if wants_target virtio-win; then
"$virtio_url" \
"$virtio_image"
fi
if wants_target openssh-win; then
download_file \
'Win32-OpenSSH zip' \
"$openssh_url" \
"$openssh_image"
fi
if [[ " ${targets[*]} " == *" windows11 "* ]]; then
download_windows_iso
fi
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env node
// Print a signed, temporary download URL for the Windows 11 ISO by driving
// Microsoft's download page with headless Chromium.
//
// Usage: node get-windows-iso-url.js [--arch x64|arm64] [--lang NAME]
// Environment:
// PLAYWRIGHT_CORE playwright-core directory (default: node_modules here)
// CHROME_PATH Chromium executable (default: the one Playwright installed)
// The URL expires after about 24 hours and may be bound to the IP that
// requested it. Only the URL goes to stdout; logs go to stderr.
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node get-windows-iso-url.js [options]
Print a signed, temporary download URL for the Windows 11 ISO (only the URL
goes to stdout; logs go to stderr).
Options:
--arch <x64|arm64> Architecture, default: the host's (x64 on amd64 hosts)
--lang <name> Language, default "English (United States)",
for example "Chinese Simplified"
-h, --help Show this help
Environment:
PLAYWRIGHT_CORE playwright-core directory (default: node_modules here)
CHROME_PATH Chromium executable (default: the one Playwright installed)
Examples:
node get-windows-iso-url.js --arch x64 --lang "Chinese Simplified"
wget -c "$(node get-windows-iso-url.js 2>/dev/null)"
The URL expires after about 24 hours and may be bound to the IP that
requested it.`);
process.exit(0);
}
const opt = (name, def) => {
const i = args.indexOf(name);
return i < 0 ? def : (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true);
};
const hostArch = { x64: 'x64', arm64: 'arm64' }[process.arch];
const arch = opt('--arch', hostArch);
const lang = opt('--lang', 'English (United States)');
const pages = { arm64: 'windows11arm64', x64: 'windows11' };
if (!pages[arch]) {
console.error(arch === undefined ? `unsupported host architecture ${process.arch}: pass --arch x64|arm64` : '--arch must be x64 or arm64');
process.exit(2);
}
const log = (...a) => console.error('[iso]', ...a);
function findPlaywright() {
if (process.env.PLAYWRIGHT_CORE) return process.env.PLAYWRIGHT_CORE;
try { return path.dirname(require.resolve('playwright-core/package.json')); } catch {}
console.error('playwright-core not found: run npm ci or set PLAYWRIGHT_CORE');
process.exit(2);
}
// CHROME_PATH wins; otherwise Playwright picks the Chromium it installed (in
// its own per-OS cache, PLAYWRIGHT_BROWSERS_PATH if set).
function launchOptions(chromium) {
if (process.env.CHROME_PATH) return { executablePath: process.env.CHROME_PATH, headless: true };
if (!fs.existsSync(chromium.executablePath())) {
console.error('Chromium not found: set CHROME_PATH or run npx playwright-core install chromium');
process.exit(2);
}
return { headless: true };
}
(async () => {
const { chromium } = require(findPlaywright());
const browser = await chromium.launch(launchOptions(chromium));
try {
const ctx = await browser.newContext({
locale: 'en-US',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
});
const page = await ctx.newPage();
let api = null;
page.on('response', async r => {
if (/GetProductDownloadLinks/i.test(r.url())) api = await r.json().catch(() => null);
});
log('opening', pages[arch]);
await page.goto(`https://www.microsoft.com/en-us/software-download/${pages[arch]}`, { waitUntil: 'networkidle', timeout: 60000 });
await page.click('button:has-text("Accept")', { timeout: 3000 }).catch(() => {});
// Pick the first real edition (currently the only multi-edition ISO).
const edition = await page.$$eval('#product-edition option', o => o.map(x => x.value).find(v => v && v !== 'null'));
if (!edition) throw new Error('no edition option found; the page layout may have changed');
await page.selectOption('#product-edition', edition);
await page.click('#submit-product-edition');
await page.waitForSelector('#product-languages option:not([value="null"])', { state: 'attached', timeout: 60000 });
const langs = await page.$$eval('#product-languages option', o => o.map(x => ({ value: x.value, text: x.text })));
const hit = langs.find(l => l.text === lang);
if (!hit) throw new Error(`no language "${lang}"; available: ${langs.map(l => l.text).filter(t => t !== 'Choose one').join(', ')}`);
await page.selectOption('#product-languages', hit.value);
await page.click('#submit-sku');
await page.waitForSelector('a[href*="prss.microsoft.com"]', { timeout: 60000 });
const url = await page.$eval('a[href*="prss.microsoft.com"]', a => a.href);
const opts = api && api.ProductDownloadOptions;
log(opts ? opts[0].Name : '(API response not captured)', '| expires:', api && api.DownloadExpirationDatetime);
console.log(url);
} finally {
await browser.close();
}
})().catch(e => { console.error('[iso] failed:', e.message); process.exit(1); });
+28
View File
@@ -0,0 +1,28 @@
{
"name": "win-iso",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "win-iso",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"playwright-core": "^1.64.1"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "win-iso",
"version": "1.0.0",
"description": "",
"main": "get-windows-iso-url.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"playwright-core": "^1.64.1"
}
}
+60
View File
@@ -0,0 +1,60 @@
# QEMU configuration for the Windows 11 virtual machine. The accelerators and machine type
# are in <os>-<arch>.ini (os is linux or darwin), which QEMU reads after
# this file.
#
# build-windows-base.sh fills in the file path placeholders below, writes the result
# to <state dir>/qemu.ini and passes it to QEMU with -readconfig. Options that
# have no -readconfig equivalent (-cpu, -display), the network and the installation
# media are still added by the script.
#
# Every device here has an inbox Windows driver: the base image made from this
# installation contains no VirtIO drivers.
[name]
guest = "windows11"
[memory]
size = "8G"
[smp-opts]
cpus = "8"
# UEFI firmware: read-only code and the per-VM variable store.
[drive "pflash0"]
if = "pflash"
format = "raw"
readonly = "on"
file = "${uefi_code}"
[drive "pflash1"]
if = "pflash"
format = "raw"
file = "${uefi_vars}"
# System disk image; the device that exposes it (and the NIC, which is
# attached to the user-mode network "net0" that the installer defines with
# -netdev) is in the architecture file.
[drive "system_disk"]
if = "none"
format = "qcow2"
file = "${disk_image}"
# Register the USB tablet after vdagent so QEMU selects it as the active pointer.
[device "usb_controller"]
driver = "qemu-xhci"
[device "tablet0"]
driver = "usb-tablet"
bus = "usb_controller.0"
# The QEMU monitor, on a socket in the state directory: build-windows-base.sh
# presses a key through it at the "Press any key to boot from CD or DVD" prompt.
[chardev "monitor"]
backend = "socket"
path = "${monitor_socket}"
server = "on"
wait = "off"
[mon "monitor0"]
chardev = "monitor"
mode = "readline"
+33
View File
@@ -0,0 +1,33 @@
# macOS host, arm64 part of the Windows 11 QEMU configuration; read after config.ini.
# Accelerators are tried in order: HVF on macOS, else emulation.
[accel]
accel = "hvf"
[accel]
accel = "tcg"
# build-windows-base.sh adds kernel-irqchip=off unless installing with
# --from-img (see there).
[machine]
type = "virt"
# The "virt" machine has no IDE. NVMe and USB mass storage (used by the
# installation media) both have inbox Windows drivers.
[device "system_disk-dev"]
driver = "nvme"
drive = "system_disk"
serial = "win11disk"
bootindex = "1"
# Windows has no inbox driver for virtio-net or virtio-gpu, so the network card
# is an Intel e1000e and the display is ramfb (a fixed-size framebuffer).
[device "nic0"]
driver = "e1000e"
netdev = "net0"
[device "gpu0"]
driver = "ramfb"
[device "kbd0"]
driver = "usb-kbd"
+23
View File
@@ -0,0 +1,23 @@
# Linux host, amd64 part of the Windows 11 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation.
[accel]
accel = "kvm"
[accel]
accel = "tcg"
[machine]
type = "q35"
# The system disk is attached through IDE so that Windows can boot before the
# VirtIO storage driver is installed; the installation media use ide.1 to ide.3.
[device "system_disk-dev"]
driver = "ide-hd"
drive = "system_disk"
bus = "ide.0"
bootindex = "1"
[device "nic0"]
driver = "e1000e"
netdev = "net0"
+31
View File
@@ -0,0 +1,31 @@
# Linux host, arm64 part of the Windows 11 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation.
[accel]
accel = "kvm"
[accel]
accel = "tcg"
[machine]
type = "virt"
# The "virt" machine has no IDE. NVMe and USB mass storage (used by the
# installation media) both have inbox Windows drivers.
[device "system_disk-dev"]
driver = "nvme"
drive = "system_disk"
serial = "win11disk"
bootindex = "1"
# Windows has no inbox driver for virtio-net or virtio-gpu, so the network card
# is an Intel e1000e and the display is ramfb (a fixed-size framebuffer).
[device "nic0"]
driver = "e1000e"
netdev = "net0"
[device "gpu0"]
driver = "ramfb"
[device "kbd0"]
driver = "usb-kbd"
+145
View File
@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<SetupUILanguage>
<UILanguage>en-US</UILanguage>
</SetupUILanguage>
<InputLocale>en-US</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<DynamicUpdate>
<Enable>false</Enable>
<WillShowUI>Never</WillShowUI>
</DynamicUpdate>
<DiskConfiguration>
<Disk wcm:action="add">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Type>EFI</Type>
<Size>100</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>2</Order>
<Type>MSR</Type>
<Size>16</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>3</Order>
<Type>Primary</Type>
<Extend>true</Extend>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Format>FAT32</Format>
<Label>System</Label>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>2</Order>
<PartitionID>3</PartitionID>
<Format>NTFS</Format>
<Label>Windows</Label>
<Letter>C</Letter>
</ModifyPartition>
</ModifyPartitions>
</Disk>
<WillShowUI>OnError</WillShowUI>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallFrom>
<MetaData wcm:action="add">
<Key>/IMAGE/INDEX</Key>
<Value>6</Value>
</MetaData>
</InstallFrom>
<InstallTo>
<DiskID>0</DiskID>
<PartitionID>3</PartitionID>
</InstallTo>
<WillShowUI>OnError</WillShowUI>
</OSImage>
</ImageInstall>
<UserData>
<AcceptEula>true</AcceptEula>
<ProductKey>
<Key>W269N-WFGWX-YVC9B-4J6C9-T83GX</Key>
<WillShowUI>Never</WillShowUI>
</ProductKey>
</UserData>
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<!-- After Setup, boot into audit mode instead of OOBE: Windows logs on as the
built-in Administrator (no password, no account is created) and runs the
auditUser commands below, the last of which generalizes the image and powers
off. So the answer file holds no credentials. -->
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<Reseal>
<Mode>Audit</Mode>
</Reseal>
</component>
</settings>
<settings pass="auditUser">
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>Disable the built-in Administrator again</Description>
<Path>net.exe user Administrator /active:no</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Description>Turn hibernation off, which deletes hiberfil.sys (3 GB) from the image</Description>
<Path>powercfg.exe /hibernate off</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Description>Remove the cached copies of this answer file (Windows would read
them before the Unattend.xml on a seed ISO) and the setup logs</Description>
<Path>cmd.exe /c "del /f /q C:\Windows\Panther\unattend*.xml C:\Windows\Panther\Unattend\unattend*.xml C:\Windows\Panther\*.log &amp; rd /s /q C:\Windows\Panther\UnattendGC &amp; exit /b 0"</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>4</Order>
<Description>Add the VirtIO drivers from the virtio-win ISO to the driver store</Description>
<Path>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "&amp; ('{0}:\Install-VirtIO-Drivers.ps1' -f (Get-Volume -FileSystemLabel ANSWER).DriveLetter); exit $LASTEXITCODE"</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>5</Order>
<Description>Generalize the image and power off</Description>
<Path>C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe /shutdown /quiet</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
</unattend>
+38
View File
@@ -0,0 +1,38 @@
# Runs in audit mode, before sysprep: adds the VirtIO drivers from the virtio-win
# ISO to the driver store. The devices are not present during the installation
# (it uses ramfb and e1000e), so pnputil only stages the packages; they survive
# /generalize, and Windows binds them on the first start with virtio devices.
$ErrorActionPreference = 'Stop'
$arch = if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') { 'ARM64' } else { 'amd64' }
$roots = (Get-PSDrive -PSProvider FileSystem).Root
$drivers = @(
@{ Folder = 'NetKVM'; Inf = 'netkvm.inf'; Required = $true },
@{ Folder = 'viogpudo'; Inf = 'viogpudo.inf'; Required = $true },
@{ Folder = 'vioserial'; Inf = 'vioser.inf' },
@{ Folder = 'Balloon'; Inf = 'balloon.inf' },
@{ Folder = 'viorng'; Inf = 'viorng.inf' },
@{ Folder = 'vioinput'; Inf = 'vioinput.inf' }
)
$installed = 0
foreach ($driver in $drivers) {
$inf = $roots |
ForEach-Object { Join-Path $_ "$($driver.Folder)\w11\$arch\$($driver.Inf)" } |
Where-Object { Test-Path -LiteralPath $_ } |
Select-Object -First 1
if (-not $inf) {
if ($driver.Required) {
throw "The $arch $($driver.Folder) driver was not found on the attached media."
}
continue
}
& pnputil.exe /add-driver $inf /install | Out-Host
# 3010 asks for a restart; 259 means that no device uses the driver.
if ($LASTEXITCODE -notin 0, 259, 3010) {
throw "pnputil failed to install $inf (exit code $LASTEXITCODE)."
}
$installed++
}
Write-Host "Added $installed VirtIO $arch driver packages."