aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README133
-rwxr-xr-xwin11-qemu-install232
2 files changed, 365 insertions, 0 deletions
diff --git a/README b/README
new file mode 100644
index 0000000..85a6b9d
--- /dev/null
+++ b/README
@@ -0,0 +1,133 @@
+win11-qemu-install
+==================
+Mostly-unattended install of Windows 11 onto a disk image under QEMU/KVM.
+The script generates an Autounattend.xml, hands it to Windows Setup on a
+virtual floppy, and runs QEMU against the install ISO and a target disk
+created beforehand with qemu-img. The answer file presets the en-US locale,
+bypasses the TPM 2.0, Secure Boot, and minimum RAM checks via LabConfig,
+creates one local administrator, and auto-logs in once. The pages it leaves
+alone (EULA, edition, target disk) are clicked once over VNC.
+
+Whatever disk is picked on Setup's disk page is wiped.
+
+
+Requirements
+------------
+ - qemu-system-x86_64 with KVM and seccomp sandbox support
+ - a POSIX shell, sed, id, and mktemp
+ - a Windows 11 install ISO and a disk image made with qemu-img
+ - a VNC client such as remote-viewer for the Setup pages
+ - optionally the VirtIO driver ISO
+
+Run as an unprivileged user with access to /dev/kvm. The script refuses to
+run as root.
+
+
+Usage
+-----
+Create a disk, then run the install:
+
+ qemu-img create -f qcow2 win11.qcow2 64G
+ ./win11-qemu-install win11.iso win11.qcow2
+
+QEMU runs in the foreground. Connect a VNC client and click through the
+pages the answer file leaves alone:
+
+ remote-viewer vnc://127.0.0.1:5900
+
+A third argument names the VirtIO driver ISO. The install disk is then
+attached over VirtIO, which is faster:
+
+ ./win11-qemu-install win11.iso win11.qcow2 virtio-win.iso
+
+Setup shows an empty disk list until viostor is loaded by hand:
+
+ Load driver -> virtio CD -> viostor -> w11 -> amd64
+
+Without it the disk is attached over SATA/AHCI and Setup sees it with no
+extra drivers. The same driver ISO covers network and balloon after the
+install.
+
+
+Configuration
+-------------
+Environment variables, all optional.
+
+ - RAM_MB guest memory in MB during install (default 8192)
+ - SMP guest CPU count during install (default 8)
+ - IMAGE_FORMAT disk image format: qcow2 or raw (default qcow2)
+ - LOCAL_USER administrator account name (default user)
+ - LOCAL_PASS administrator account password (default 123456)
+
+Example:
+
+ RAM_MB=16384 SMP=4 LOCAL_USER=admin LOCAL_PASS=hunter2 \
+ ./win11-qemu-install win11.iso win11.qcow2
+
+
+Networking
+----------
+The installer runs with -nic none. With no way to reach Microsoft, OOBE
+settles for a local account instead of forcing an online one, and restarting
+inside Setup changes nothing. Networking arrives when the installed image is
+booted afterwards, with a NIC model Windows has an in-box driver for:
+
+ qemu-system-x86_64 -enable-kvm -machine q35 -cpu host -m 8192 \
+ -drive file=win11.qcow2,if=ide \
+ -nic user,model=e1000e \
+ -vnc 127.0.0.1:0 -usb -device usb-tablet
+
+Use if=virtio for the drive if the install ran in VirtIO mode. virtio-net
+stays dead until NetKVM is installed from the driver ISO; e1000e works out
+of the box.
+
+
+Quirks
+------
+The windowsPE pass is minimal on purpose. Automating the EULA, edition, and
+disk pages with UserData, DiskConfiguration, and DriverPaths makes the
+Windows 11 24H2 setup engine abort right after boot ("Windows installation
+encountered an unexpected error", extend code 0x40031), so those pages are
+left to the VNC session.
+
+The answer file is written to a per-run mktemp directory (mode 0700, under
+TMPDIR), reaches the guest as fat:floppy:, and is removed by an EXIT trap.
+SIGKILL and host failure cannot run the trap; remove stale
+win11-qemu-install.* directories from TMPDIR after either.
+
+QEMU runs as a background command so HUP, INT, and TERM stop it and then
+clean up, exiting 129, 130, or 143. In the foreground the trap would not run
+until QEMU had already exited.
+
+LOCAL_USER and LOCAL_PASS are XML-escaped, so values containing & < > " '
+are safe.
+
+
+Debugging
+---------
+Watch the install over VNC. While QEMU runs the generated answer file can be
+read at win11-qemu-install.*/Autounattend.xml under TMPDIR.
+
+ - "must not contain a comma": QEMU separates drive options with commas;
+ use a path without one.
+ - Black VNC screen: Setup is still booting.
+ - Empty disk list in Setup: load the VirtIO driver as shown above, or
+ rerun without the VirtIO ISO for the SATA/AHCI path.
+ - "installation encountered an unexpected error" right after boot: the
+ answer file automates more of windowsPE than the setup engine accepts.
+ - KVM errors on start: check that /dev/kvm exists and is accessible.
+
+
+Security
+--------
+The default password is 123456 and the account is a local administrator, so
+this is for throwaway lab VMs. Set LOCAL_PASS for anything kept. VNC has no
+authentication and any local host user can reach the guest console; it is
+bound to 127.0.0.1, and the QEMU monitor, serial port, and guest network are
+not exposed.
+
+The answer file holds the password in plain text in the 0700 temp dir. QEMU
+refuses to run as root and runs under its seccomp sandbox with privilege
+elevation, process spawning, obsolete system calls, and resource-control
+changes denied, but it still has the invoking user's file access. Use a
+dedicated unprivileged account when installing untrusted media.
diff --git a/win11-qemu-install b/win11-qemu-install
new file mode 100755
index 0000000..8426f09
--- /dev/null
+++ b/win11-qemu-install
@@ -0,0 +1,232 @@
+#!/bin/sh
+set -eu
+
+# win11-qemu-install WINDOWS_ISO VM_DISK [VIRTIO_WIN_ISO]
+#
+# Mostly-unattended install of Windows 11 onto VM_DISK, a disk image created
+# beforehand with qemu-img. The answer file presets the language, bypasses the
+# TPM, Secure Boot, and RAM checks, and automates OOBE. Setup's remaining pages
+# (EULA, edition, target disk) are clicked once over VNC on 127.0.0.1:5900.
+
+[ "$#" -eq 2 ] || [ "$#" -eq 3 ] || { echo "usage: $0 WINDOWS_ISO VM_DISK [VIRTIO_WIN_ISO]" >&2; exit 64; }
+
+[ "$(id -u)" -ne 0 ] || { echo "error: refusing to run QEMU as root" >&2; exit 1; }
+
+WIN_ISO=$1
+VM_DISK=$2
+VIRTIO_ISO=${3:-}
+[ -r "$WIN_ISO" ] || { echo "error: WINDOWS_ISO not readable: $WIN_ISO" >&2; exit 1; }
+[ -r "$VM_DISK" ] && [ -w "$VM_DISK" ] || { echo "error: VM_DISK must be readable and writable: $VM_DISK" >&2; exit 1; }
+[ -z "$VIRTIO_ISO" ] || [ -r "$VIRTIO_ISO" ] || { echo "error: VIRTIO_WIN_ISO not readable: $VIRTIO_ISO" >&2; exit 1; }
+
+RAM_MB=${RAM_MB:-8192}
+SMP=${SMP:-8}
+IMAGE_FORMAT=${IMAGE_FORMAT:-qcow2}
+LOCAL_USER=${LOCAL_USER:-user}
+LOCAL_PASS=${LOCAL_PASS:-123456}
+
+validate_positive_integer() {
+ case $2 in
+ ''|*[!0-9]*|0) echo "error: $1 must be a positive integer: $2" >&2; exit 1 ;;
+ esac
+}
+
+reject_comma() {
+ case $2 in
+ *,*) echo "error: $1 must not contain a comma: $2" >&2; exit 1 ;;
+ esac
+}
+
+validate_positive_integer RAM_MB "$RAM_MB"
+validate_positive_integer SMP "$SMP"
+case $IMAGE_FORMAT in
+ qcow2|raw) ;;
+ *) echo "error: IMAGE_FORMAT must be qcow2 or raw: $IMAGE_FORMAT" >&2; exit 1 ;;
+esac
+reject_comma WINDOWS_ISO "$WIN_ISO"
+reject_comma VM_DISK "$VM_DISK"
+reject_comma VIRTIO_WIN_ISO "$VIRTIO_ISO"
+reject_comma TMPDIR "${TMPDIR:-/tmp}"
+
+command -v qemu-system-x86_64 >/dev/null || { echo "error: need qemu-system-x86_64" >&2; exit 1; }
+
+xml_escape() {
+ sed \
+ -e 's/&/\&amp;/g' \
+ -e 's/</\&lt;/g' \
+ -e 's/>/\&gt;/g' \
+ -e 's/"/\&quot;/g' \
+ -e "s/'/\&apos;/g"
+}
+
+LOCAL_USER_XML=$(printf '%s' "$LOCAL_USER" | xml_escape)
+LOCAL_PASS_XML=$(printf '%s' "$LOCAL_PASS" | xml_escape)
+
+# The answer file carries LOCAL_PASS in plain text; mktemp gives a 0700 dir.
+WORK=$(mktemp -d "${TMPDIR:-/tmp}/win11-qemu-install.XXXXXX")
+QEMU_PID=
+trap 'rm -rf "$WORK"' EXIT
+
+# QEMU runs in the background so a trapped signal is handled while it is
+# still alive; in the foreground the trap only runs once QEMU has exited.
+# QEMU shuts down on TERM, so that is what gets sent on whatever arrived.
+stop() {
+ trap - HUP INT TERM
+ if [ -n "$QEMU_PID" ]; then
+ kill "$QEMU_PID" 2>/dev/null || :
+ wait "$QEMU_PID" 2>/dev/null || :
+ fi
+ exit "$1"
+}
+trap 'stop 129' HUP
+trap 'stop 130' INT
+trap 'stop 143' TERM
+
+# The windowsPE pass stays minimal on purpose: LabConfig bypasses and
+# language only. Automating the EULA, edition, and disk pages with UserData,
+# DiskConfiguration, and DriverPaths makes the Windows 11 24H2 setup engine
+# abort right after boot (extend code 0x40031), so those pages are clicked
+# once over VNC instead.
+cat <<EOF >"$WORK/Autounattend.xml"
+<?xml version="1.0" encoding="utf-8"?>
+<unattend xmlns="urn:schemas-microsoft-com:unattend"
+ xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
+ <settings pass="windowsPE">
+ <component name="Microsoft-Windows-International-Core-WinPE"
+ processorArchitecture="amd64"
+ publicKeyToken="31bf3856ad364e35"
+ language="neutral"
+ versionScope="nonSxS">
+ <SetupUILanguage>
+ <UILanguage>en-US</UILanguage>
+ </SetupUILanguage>
+ <InputLocale>0409:00000409</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">
+ <RunSynchronous>
+ <RunSynchronousCommand wcm:action="add">
+ <Order>1</Order>
+ <Description>Create LabConfig</Description>
+ <Path>cmd /c reg add HKLM\\SYSTEM\\Setup\\LabConfig /f</Path>
+ </RunSynchronousCommand>
+ <RunSynchronousCommand wcm:action="add">
+ <Order>2</Order>
+ <Description>Bypass TPM</Description>
+ <Path>cmd /c reg add HKLM\\SYSTEM\\Setup\\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
+ </RunSynchronousCommand>
+ <RunSynchronousCommand wcm:action="add">
+ <Order>3</Order>
+ <Description>Bypass Secure Boot</Description>
+ <Path>cmd /c reg add HKLM\\SYSTEM\\Setup\\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
+ </RunSynchronousCommand>
+ <RunSynchronousCommand wcm:action="add">
+ <Order>4</Order>
+ <Description>Bypass RAM check</Description>
+ <Path>cmd /c reg add HKLM\\SYSTEM\\Setup\\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
+ </RunSynchronousCommand>
+ </RunSynchronous>
+ </component>
+ </settings>
+
+ <settings pass="oobeSystem">
+ <component name="Microsoft-Windows-International-Core"
+ processorArchitecture="amd64"
+ publicKeyToken="31bf3856ad364e35"
+ language="neutral"
+ versionScope="nonSxS">
+ <InputLocale>en-US</InputLocale>
+ <SystemLocale>en-US</SystemLocale>
+ <UILanguage>en-US</UILanguage>
+ <UserLocale>en-US</UserLocale>
+ </component>
+
+ <component name="Microsoft-Windows-Shell-Setup"
+ processorArchitecture="amd64"
+ publicKeyToken="31bf3856ad364e35"
+ language="neutral"
+ versionScope="nonSxS">
+ <OOBE>
+ <HideOnlineAccountScreens>true</HideOnlineAccountScreens>
+ <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
+ <HideEULAPage>true</HideEULAPage>
+ <ProtectYourPC>3</ProtectYourPC>
+ </OOBE>
+
+ <UserAccounts>
+ <LocalAccounts>
+ <LocalAccount wcm:action="add">
+ <Name>${LOCAL_USER_XML}</Name>
+ <DisplayName>${LOCAL_USER_XML}</DisplayName>
+ <Group>Administrators</Group>
+ <Password>
+ <Value>${LOCAL_PASS_XML}</Value>
+ <PlainText>true</PlainText>
+ </Password>
+ </LocalAccount>
+ </LocalAccounts>
+ </UserAccounts>
+
+ <AutoLogon>
+ <Enabled>true</Enabled>
+ <Username>${LOCAL_USER_XML}</Username>
+ <LogonCount>1</LogonCount>
+ <Password>
+ <Value>${LOCAL_PASS_XML}</Value>
+ <PlainText>true</PlainText>
+ </Password>
+ </AutoLogon>
+ </component>
+ </settings>
+</unattend>
+EOF
+
+echo 'use: remote-viewer vnc://127.0.0.1:5900'
+
+# WinPE scans floppy drives for Autounattend.xml on its own; a vvfat floppy
+# holding the single file is the simplest way to hand it over. usb-tablet
+# gives the VNC pointer absolute positioning. No NIC: with no way to reach
+# Microsoft, OOBE settles for a local account instead of forcing an online
+# one.
+set -- qemu-system-x86_64 \
+ -enable-kvm \
+ -machine q35 \
+ -cpu host \
+ -smp "$SMP" \
+ -m "$RAM_MB" \
+ -boot order=d \
+ -sandbox on,obsolete=deny,elevateprivileges=deny,spawn=deny,resourcecontrol=deny \
+ -drive file="$WIN_ISO",format=raw,media=cdrom,if=ide,index=1 \
+ -drive if=floppy,file="fat:floppy:$WORK",format=raw,readonly=on \
+ -vnc 127.0.0.1:0 \
+ -monitor none \
+ -serial none \
+ -parallel none \
+ -usb \
+ -device usb-tablet \
+ -nic none
+
+if [ -n "$VIRTIO_ISO" ]; then
+ echo "virtio mode: enabled; when Setup's disk list is empty:"
+ echo " Load driver -> virtio CD -> viostor -> w11 -> amd64"
+ set -- "$@" \
+ -drive file="$VM_DISK",format="$IMAGE_FORMAT",if=virtio \
+ -drive file="$VIRTIO_ISO",format=raw,media=cdrom,if=ide,index=2
+else
+ echo "virtio mode: disabled; disk attached over SATA/AHCI, no extra drivers"
+ set -- "$@" \
+ -device ich9-ahci,id=sata \
+ -drive file="$VM_DISK",format="$IMAGE_FORMAT",if=none,id=systemdisk \
+ -device ide-hd,drive=systemdisk,bus=sata.0
+fi
+
+"$@" &
+QEMU_PID=$!
+wait "$QEMU_PID"