Compare commits

..
3 Commits
Author SHA1 Message Date
GuofangandClaude Sonnet 5.5 48c4477ec4 Update vm-images: audit-mode base image
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 12:51:54 +08:00
GuofangandClaude Sonnet 5.5 a920ba0ca5 Windows launcher only creates a differential disk; seed via Unattend.xml
run-windows11.py no longer installs Windows or touches ISO/img files: it
creates a QCOW2 overlay on the base image (made by vm-images/build-windows-base.sh)
and, when the disk is created, mounts seed.iso. Windows reads the Unattend.xml
in its root during the specialize and OOBE passes (host name, account, auto
logon, Remote Desktop) and Configure-Windows.ps1 then installs OpenSSH with the
host's key, turns off Windows Update and copies the installer scripts.

The VM hardware uses devices with inbox Windows drivers (IDE/NVMe, e1000e,
ramfb on arm64), since the base image has no VirtIO drivers. Autounattend.xml
moves to vm-images; the vm-images pointer is updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 11:19:48 +08:00
GuofangandClaude Sonnet 5.5 fe7250870e Attach the cloud-init seed ISO only when the VM disk is created
cloud-init reads the seed on the first boot only, so the Linux launchers now
mount it just for the run that creates the overlay disk. The seed drive moves
from the config files to the launcher arguments.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 11:19:48 +08:00
18 changed files with 469 additions and 754 deletions

No files matched your search

+38 -33
View File
@@ -48,8 +48,7 @@ corresponding `*_STATE_DIR` environment variable:
With `--download-only`, a launcher only downloads the media it would fetch
automatically and then exits without creating a VM disk or starting QEMU: the
base image for Debian, AlmaLinux, Fedora and Ubuntu, and the VirtIO tools ISO
for Windows 11 (whose installation ISO and base QCOW2 are supplied manually):
base image for Debian, AlmaLinux, Fedora and Ubuntu:
```bash
./run-fedora44.py --download-only
@@ -165,43 +164,49 @@ from the shared Windows 11 QCOW2 base image:
./run-windows11.py
```
It creates a persistent 64G disk at `vm/windows11/disk.qcow2`, automatically
partitions it, completes OOBE, creates the local administrator `guofang` with
the initial password `wang`, and enables Remote Desktop on
`0.0.0.0:3389` (all host interfaces; connect to `localhost:3389` on the host itself). During the first login it also silently installs the VirtIO
drivers (the guest tools installer on amd64, the Arm64 driver packages from the
VirtIO ISO with `pnputil` on arm64), without which the guest has no network on
arm64. The installer includes the VirtIO drivers, SPICE vdagent, and QEMU Guest
Agent. The launcher exposes the vdagent channel for host/guest clipboard
integration and the QEMU Guest Agent at `vm/windows11/qemu-guest-agent.sock`.
Change the password after first login. The OpenSSH Server is installed during
the first logon from the Win32-OpenSSH zip for the host architecture, which the
seed ISO carries (downloaded by `vm-images/download-images.sh openssh-win`), so
Windows Update is not needed. Connect with `ssh guofang@localhost -p 2222`; the
seed ISO also carries the host's SSH public key (`$SSH_PUBLIC_KEY_FILE`, else the
first of `~/.ssh/id_ed25519.pub`, `id_ecdsa.pub`, `id_rsa.pub`) for passwordless
administrator login.
It creates a persistent 64G disk at `vm/windows11/disk.qcow2` as a QCOW2
overlay on the base image and nothing else: the launcher only creates the
differential disk and starts QEMU. The base image is generic and holds nothing
but Windows (see `vm-images/README.md`): no account, no SSH, no VirtIO or other
hardware-specific drivers, no machine identity. Like a cloud image, each VM is
configured on its first boot, from the seed ISO.
Whenever the disk does not exist yet, the launcher also mounts the seed ISO
`vm/windows11/seed.iso` (beside the disk; built from `seediso/windows11/` when
missing or outdated, with the OpenSSH zip and the host's SSH public key,
`$SSH_PUBLIC_KEY_FILE` or the first of `~/.ssh/id_ed25519.pub`, `id_ecdsa.pub`,
`id_rsa.pub`). Windows reads the `Unattend.xml` in the root of that CD-ROM
during its specialize and OOBE passes: host name `WIN11`, time zone, Remote
Desktop on `0.0.0.0:3389` (all host interfaces; connect to `localhost:3389` on
the host itself), the local administrator `guofang` (initial password `wang`;
change it after first login) and automatic logon. At the first logon it runs
`Configure-Windows.ps1` from the seed ISO, which installs the OpenSSH Server
from the zip with the SSH key (connect with `ssh guofang@localhost -p 2222`),
turns off Windows Update and copies the numbered installer scripts
(`seediso/windows11/NN-*.bat` and `.ps1`) to the desktop folder `Tools`. Later
starts do not mount the seed ISO.
Because the image has no VirtIO drivers, the VM only uses devices that Windows
has inbox drivers for: an IDE disk and e1000e NIC on amd64, an NVMe disk, e1000e
NIC and ramfb display on arm64 (a fixed-size framebuffer there). The SPICE
vdagent and QEMU Guest Agent come with the VirtIO guest tools, so host/guest
clipboard integration and the guest agent are not available unless you install
those tools in the VM yourself.
The Windows VM hardware (8G memory, 8 CPUs, system disk, devices)
is defined in `qemu/windows11/config.ini`, with the accelerators and machine type in
`qemu/windows11/<os>-<arch>.ini`; the launcher renders the former to
`vm/windows11/qemu.ini` and loads both with `-readconfig`. Only the network
(see Port forwards) and the installation media are still set by the launcher. QEMU arguments after
(see Port forwards) and the seed ISO are still set by the launcher. QEMU arguments after
`--` override the config files, for example `./run-windows11.py -- -m 4G -smp 4`.
The generated answer-file ISO is stored beside the disk as
`vm/windows11/seed.iso`.
To install from the Windows ISO instead, use `./run-windows11.py
--install-from-iso`. This choice only applies while creating a missing system
disk; subsequent starts always boot the existing system disk normally. During
ISO installation, the launcher resolves its tools ISO through the shared image
helper and downloads it when missing. Override the base QCOW2, source ISO,
tools ISO, RDP port, or SSH port with the `WIN11_VM_` environment
variables shown by `./run-windows11.py --help`. For example, when the VirtIO ISO
is elsewhere:
```bash
WIN11_VM_TOOLS_ISO=/custom/path/virtio-win.iso ./run-windows11.py --install-from-iso
```
The launcher has no installation options and stops with a hint if the base image
is missing. Make the base image once with `vm-images/build-windows-base.sh`,
which is self-contained in `vm-images/` (its own answer file, QEMU config and
installation logic, written in bash): it installs Windows unattended from the
ISO into a scratch disk, runs `sysprep` as its last step and stores the disk as
the read-only base image. Override the base QCOW2, seed ISO, RDP port, or SSH
port with the `WIN11_VM_` environment variables shown by
`./run-windows11.py --help`.
## Port forwards
-11
View File
@@ -39,17 +39,6 @@
driver = "virtio-blk-pci"
drive = "disk0"
# cloud-init seed ISO.
[drive "seed0"]
if = "none"
format = "raw"
readonly = "on"
file = "${seed_image}"
[device "seed0-dev"]
driver = "virtio-blk-pci"
drive = "seed0"
# The NIC is attached to the user-mode network "net0", which the launcher
# defines with -netdev from qemu/debian13/forwards.conf because a config file
# section cannot hold more than one hostfwd.
-11
View File
@@ -39,17 +39,6 @@
driver = "virtio-blk-pci"
drive = "disk0"
# cloud-init seed ISO.
[drive "seed0"]
if = "none"
format = "raw"
readonly = "on"
file = "${seed_image}"
[device "seed0-dev"]
driver = "virtio-blk-pci"
drive = "seed0"
# The NIC is attached to the user-mode network "net0", which the launcher
# defines with -netdev from qemu/fedora44/forwards.conf because a config file
# section cannot hold more than one hostfwd.
-11
View File
@@ -39,17 +39,6 @@
driver = "virtio-blk-pci"
drive = "disk0"
# cloud-init seed ISO.
[drive "seed0"]
if = "none"
format = "raw"
readonly = "on"
file = "${seed_image}"
[device "seed0-dev"]
driver = "virtio-blk-pci"
drive = "seed0"
# The NIC is attached to the user-mode network "net0", which the launcher
# defines with -netdev from qemu/ubuntu2604/forwards.conf because a config file
# section cannot hold more than one hostfwd.
+2 -2
View File
@@ -4,8 +4,8 @@
#
# run-windows11.py 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) and the installation media
# attached by --install-from-iso are still added by the launcher.
# have no -readconfig equivalent (-cpu, -display) and the network are still added
# by the launcher.
[name]
guest = "windows11"
+7 -15
View File
@@ -7,35 +7,27 @@
[accel]
accel = "tcg"
# run-windows11.py adds kernel-irqchip=off unless installing with
# --install-from-img (see there).
# run-windows11.py adds kernel-irqchip=off (see there).
[machine]
type = "virt"
# The "virt" machine has no IDE. NVMe and USB mass storage (used by the
# installation media of --install-from-iso) both have inbox Windows drivers.
# 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; the guest tools installed by
# Configure-Windows.ps1 provide it.
# Windows has no inbox driver for virtio-net or virtio-gpu, and the base image
# contains no VirtIO drivers, so the network card is an Intel e1000e and the
# display is ramfb (a fixed-size framebuffer).
[device "nic0"]
driver = "virtio-net-pci"
driver = "e1000e"
netdev = "net0"
# Unlike q35, "virt" has no default GPU or keyboard. virtio-gpu is the only
# display, driven by viogpudo (installed by Configure-Windows.ps1). ramfb is not
# added because Windows would then see a second adapter with a fixed-mode
# monitor. xres/yres set the preferred mode in the EDID (QEMU defaults to
# 1280x800); viogpudo accepted 1920x1200, 2048x1280 and 2304x1440 but not
# 2560x1440 or 2560x1600.
[device "gpu0"]
driver = "virtio-gpu-pci"
xres = "2304"
yres = "1440"
driver = "ramfb"
[device "kbd0"]
driver = "usb-kbd"
+1 -2
View File
@@ -11,8 +11,7 @@
type = "q35"
# The system disk is attached through IDE so that Windows can boot before the
# VirtIO storage driver is installed; the installation media of
# --install-from-iso use ide.1 to ide.3.
# VirtIO storage driver is installed.
[device "system_disk-dev"]
driver = "ide-hd"
drive = "system_disk"
+6 -12
View File
@@ -11,28 +11,22 @@
type = "virt"
# The "virt" machine has no IDE. NVMe and USB mass storage (used by the
# installation media of --install-from-iso) both have inbox Windows drivers.
# 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; the guest tools installed by
# Configure-Windows.ps1 provide it.
# Windows has no inbox driver for virtio-net or virtio-gpu, and the base image
# contains no VirtIO drivers, so the network card is an Intel e1000e and the
# display is ramfb (a fixed-size framebuffer).
[device "nic0"]
driver = "virtio-net-pci"
driver = "e1000e"
netdev = "net0"
# Unlike q35, "virt" has no default GPU or keyboard. virtio-gpu is the only
# display, driven by viogpudo (installed by Configure-Windows.ps1). ramfb is not
# added because Windows would then see a second adapter with a fixed-mode
# monitor. xres/yres set the preferred mode in the EDID (QEMU defaults to
# 1280x800); viogpudo accepted 1920x1200 and 2048x1280 but not 2560x1600.
[device "gpu0"]
driver = "virtio-gpu-pci"
xres = "2048"
yres = "1280"
driver = "ramfb"
[device "kbd0"]
driver = "usb-kbd"
+10 -4
View File
@@ -208,7 +208,8 @@ for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}")
if not seed_image.is_file():
creating = not disk_image.is_file()
if creating and not seed_image.is_file():
seed_builder = script_dir / "seediso" / "almalinux10" / "build-seed.sh"
if not is_executable(seed_builder):
die(f"seed image is missing and builder is unavailable: {seed_builder}")
@@ -240,7 +241,7 @@ if pid_file.is_file():
die(f"the AlmaLinux virtual machine is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True)
if not disk_image.is_file():
if creating:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size])
print(f"Created writable disk overlay: {disk_image}")
@@ -261,12 +262,17 @@ qemu_args = [
"-drive", f"if=pflash,format=raw,file={uefi_vars}",
"-drive", f"file={disk_image},if=none,id=disk0,format=qcow2",
"-device", "virtio-blk-pci,drive=disk0",
"-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
"-device", "virtio-blk-pci,drive=seed0",
"-netdev", vmforwards.netdev_argument(forwards),
"-device", "virtio-net-pci,netdev=net0",
]
if creating:
# cloud-init reads the seed ISO on the first boot only (the users, keys,
# network and packages it sets up stay on the disk), so it is attached only
# when the disk is created.
qemu_args += ["-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
"-device", "virtio-blk-pci,drive=seed0"]
if machine_type == "virt":
# Unlike q35, the "virt" machine has no default GPU or USB input devices.
qemu_args += [
+10 -3
View File
@@ -208,7 +208,8 @@ for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}")
if not seed_image.is_file():
creating = not disk_image.is_file()
if creating and not seed_image.is_file():
seed_builder = script_dir / "seediso" / "debian13" / "build-seed.sh"
if not is_executable(seed_builder):
die(f"seed image is missing and builder is unavailable: {seed_builder}")
@@ -240,7 +241,7 @@ if pid_file.is_file():
die(f"the virtual machine is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True)
if not disk_image.is_file():
if creating:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size])
print(f"Created writable disk overlay: {disk_image}")
@@ -254,7 +255,6 @@ config_values = {
"uefi_code": uefi_code_flash,
"uefi_vars": uefi_vars,
"disk_image": disk_image,
"seed_image": seed_image,
}
for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values.
@@ -285,6 +285,13 @@ qemu_args = [
"-display", display_backend,
]
if creating:
# cloud-init reads the seed ISO on the first boot only (the users, keys,
# network and packages it sets up stay on the disk), so it is attached only
# when the disk is created.
qemu_args += ["-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
"-device", "virtio-blk-pci,drive=seed0"]
print(f"Starting {host_arch} VM from {qemu_config_template} "
f"and {qemu_platform_config}.", flush=True)
vmforwards.print_forwards(forwards)
+10 -3
View File
@@ -210,7 +210,8 @@ for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}")
if not seed_image.is_file():
creating = not disk_image.is_file()
if creating and not seed_image.is_file():
seed_builder = script_dir / "seediso" / "fedora44" / "build-seed.sh"
if not is_executable(seed_builder):
die(f"seed image is missing and builder is unavailable: {seed_builder}")
@@ -242,7 +243,7 @@ if pid_file.is_file():
die(f"the Fedora virtual machine is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True)
if not disk_image.is_file():
if creating:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size])
print(f"Created writable disk overlay: {disk_image}")
@@ -256,7 +257,6 @@ config_values = {
"uefi_code": uefi_code_flash,
"uefi_vars": uefi_vars,
"disk_image": disk_image,
"seed_image": seed_image,
}
for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values.
@@ -287,6 +287,13 @@ qemu_args = [
"-display", display_backend,
]
if creating:
# cloud-init reads the seed ISO on the first boot only (the users, keys,
# network and packages it sets up stay on the disk), so it is attached only
# when the disk is created.
qemu_args += ["-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
"-device", "virtio-blk-pci,drive=seed0"]
print(f"Starting Fedora 44 ({host_arch}) from {qemu_config_template} "
f"and {qemu_platform_config}.", flush=True)
vmforwards.print_forwards(forwards)
+10 -3
View File
@@ -210,7 +210,8 @@ for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}")
if not seed_image.is_file():
creating = not disk_image.is_file()
if creating and not seed_image.is_file():
seed_builder = script_dir / "seediso" / "ubuntu2604" / "build-seed.sh"
if not is_executable(seed_builder):
die(f"seed image is missing and builder is unavailable: {seed_builder}")
@@ -242,7 +243,7 @@ if pid_file.is_file():
die(f"the Ubuntu virtual machine is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True)
if not disk_image.is_file():
if creating:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size])
print(f"Created writable disk overlay: {disk_image}")
@@ -256,7 +257,6 @@ config_values = {
"uefi_code": uefi_code_flash,
"uefi_vars": uefi_vars,
"disk_image": disk_image,
"seed_image": seed_image,
}
for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values.
@@ -287,6 +287,13 @@ qemu_args = [
"-display", display_backend,
]
if creating:
# cloud-init reads the seed ISO on the first boot only (the users, keys,
# network and packages it sets up stay on the disk), so it is attached only
# when the disk is created.
qemu_args += ["-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
"-device", "virtio-blk-pci,drive=seed0"]
print(f"Starting Ubuntu 26.04 ({host_arch}) from {qemu_config_template} "
f"and {qemu_platform_config}.", flush=True)
vmforwards.print_forwards(forwards)
+297 -400
View File
@@ -51,16 +51,6 @@ def find_image_downloader():
return image_downloader
state_dir = Path(env("WIN11_VM_STATE_DIR") or script_dir / "vm" / "windows11")
disk_size = env("WIN11_VM_DISK_SIZE") or "64G"
rdp_port = env("WIN11_VM_RDP_PORT")
ssh_port = env("WIN11_VM_SSH_PORT")
forwards_file = Path(
env("WIN11_VM_FORWARDS") or script_dir / "qemu" / "windows11" / "forwards.conf")
qemu_config_template = Path(
env("WIN11_VM_QEMU_CONFIG") or script_dir / "qemu" / "windows11" / "config.ini")
def normalize_arch(arch):
if arch in ("arm64", "aarch64"):
return "arm64"
@@ -69,142 +59,6 @@ def normalize_arch(arch):
die(f"unsupported architecture: {arch}")
daemonize = False
install_from_iso = False
install_from_img = False
download_only = False
seed_only = False
extra_args = []
arch_option = None
vnc_listen = None
def usage(stream=sys.stdout):
stream.write(f"""\
Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--seed-only] [--vnc[=ADDR:N]] [--install-from-iso | --install-from-img]
[--arch ARCH] [--state-dir DIR] [--rdp-port PORT] [--ssh-port PORT] [-- QEMU_ARGUMENT ...]
If the system disk (DIR/disk.qcow2, see --state-dir) is missing, create it from
the Windows 11 QCOW2 base image.
The VM hardware (accelerator, memory, CPUs, drives, devices) is defined in
qemu/windows11/config.ini, which is rendered to qemu.ini in the state directory and
loaded with QEMU's -readconfig, followed by qemu/windows11/<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64, chosen from the host system and
architecture, --arch or VM_IMAGE_ARCH). Later QEMU arguments override them, e.g. -- -m 4G -smp 4.
Use --install-from-iso to perform an automatic ISO installation instead, or
--install-from-img to install from a disk image built from the ISO.
If it exists, start Windows normally without attaching installation media.
Options:
--daemon Run QEMU in the background
--download-only Download the VirtIO tools ISO and the Windows 11 ISO
(for --arch) if they are missing, then exit; needs
Node.js and Chromium for the latter, see vm-images/README.md
(the base QCOW2 is supplied manually)
--seed-only Build the seed ISO (answer file and setup scripts) and exit,
without starting the VM or fetching the Windows and VirtIO ISOs
--install-from-iso Install from ISO when the system disk is missing
--install-from-img Like --install-from-iso, but install from the disk image
made by vm-images/build-windows-install-disk.sh, which
is the ISO path ending in .img instead of .iso.
It is attached as an NVMe disk, much faster than the
emulated USB CD-ROM, and is never modified
--vnc[=ADDR:N] Serve the screen over VNC instead of opening a window, on
ADDR (default: 127.0.0.1) at TCP port 5900+N (default N: 0).
Overrides WIN11_VM_DISPLAY
--arch ARCH Guest architecture: arm64 or amd64 (default: the host's;
overrides VM_IMAGE_ARCH). A guest that differs from the
host runs under slow TCG emulation
--state-dir DIR Directory for the VM disk, seed ISO and runtime files
(default: vm/windows11; overrides WIN11_VM_STATE_DIR)
--rdp-port PORT Host port of the RDP rule in the port forward file
(default: 3389; overrides WIN11_VM_RDP_PORT)
--ssh-port PORT Host port of the SSH rule in the port forward file
(default: 2222; overrides WIN11_VM_SSH_PORT)
-h, --help Show this help
Environment variables:
WIN11_VM_BASE_IMAGE Windows 11 base QCOW2 image (default: beside shared images)
WIN11_VM_INSTALL_ISO Windows 11 installation ISO used with --install-from-iso
(--install-from-img uses the same path ending in .img)
WIN11_VM_TOOLS_ISO VirtIO Windows tools ISO (default: resolved by shared image helper)
WIN11_VM_RDP_PORT Host RDP port (default: from the port forward file, 3389)
WIN11_VM_SSH_PORT Host SSH port (default: from the port forward file, 2222)
WIN11_VM_FORWARDS Port forward file (default: qemu/windows11/forwards.conf)
WIN11_VM_STATE_DIR Runtime directory (default: vm/windows11)
WIN11_VM_DISK_IMAGE System disk path
WIN11_VM_DISK_SIZE New system disk size (default: 64G)
WIN11_VM_SEED_ISO Answer-file ISO path (default: alongside the VM disk)
WIN11_VM_UEFI_CODE OVMF read-only firmware path (auto-detected if unset)
WIN11_VM_UEFI_VARS OVMF variable-store template path (auto-detected if unset)
WIN11_VM_QEMU_CONFIG QEMU config template passed to -readconfig
(default: qemu/windows11/config.ini)
WIN11_VM_DISPLAY QEMU display backend (default: cocoa on macOS,
gtk elsewhere)
""")
args = sys.argv[1:]
while args:
arg = args.pop(0)
if arg == "--daemon":
daemonize = True
elif arg == "--download-only":
download_only = True
elif arg == "--seed-only":
seed_only = True
elif arg == "--vnc" or arg.startswith("--vnc="):
vnc_listen = arg.partition("=")[2] or "127.0.0.1:0"
if ":" not in vnc_listen:
vnc_listen += ":0"
elif arg == "--install-from-iso":
install_from_iso = True
elif arg == "--install-from-img":
install_from_img = True
elif arg == "--state-dir":
if not args:
die(f"{arg} requires a directory argument.", 2)
state_dir = Path(args.pop(0))
elif arg.startswith("--state-dir="):
state_dir = Path(arg.split("=", 1)[1])
elif arg in ("--rdp-port", "--ssh-port") or arg.startswith(("--rdp-port=", "--ssh-port=")):
name, sep, value = arg.partition("=")
if not sep:
if not args:
die(f"{name} requires a port argument.", 2)
value = args.pop(0)
if not (value.isascii() and value.isdigit() and 1 <= int(value) <= 65535):
die(f"{name} must be a port number from 1 to 65535: {value}", 2)
if name == "--rdp-port":
rdp_port = value
else:
ssh_port = value
elif arg == "--arch":
if not args:
die(f"{arg} requires an architecture argument.", 2)
arch_option = args.pop(0)
elif arg.startswith("--arch="):
arch_option = arg.split("=", 1)[1]
elif arg in ("-h", "--help"):
usage()
sys.exit(0)
elif arg == "--":
extra_args.extend(args)
break
else:
print(f"Error: unknown option: {arg}", file=sys.stderr)
usage(sys.stderr)
sys.exit(2)
host_arch = normalize_arch(arch_option or env("VM_IMAGE_ARCH") or platform.machine())
# The shared image helper and build-seed.sh pick the architecture from here.
os.environ["VM_IMAGE_ARCH"] = host_arch
qemu_binary = "qemu-system-aarch64" if host_arch == "arm64" else "qemu-system-x86_64"
host_os = platform.system().lower()
if host_os == "darwin" and host_arch == "amd64":
die("amd64 guests are not supported on macOS hosts; use an arm64 image")
qemu_platform_config = script_dir / "qemu" / "windows11" / f"{host_os}-{host_arch}.ini"
def pick_firmware(override, *candidates):
if override:
return Path(override)
@@ -214,80 +68,296 @@ def pick_firmware(override, *candidates):
return Path(candidates[0])
if host_arch == "arm64":
uefi_code = pick_firmware(
class Options:
"""Command-line options."""
def __init__(self):
self.daemonize = False
self.vnc_listen = None
self.state_dir = Path(env("WIN11_VM_STATE_DIR") or script_dir / "vm" / "windows11")
self.rdp_port = env("WIN11_VM_RDP_PORT")
self.ssh_port = env("WIN11_VM_SSH_PORT")
self.arch_option = None
self.extra_args = []
def usage(stream=sys.stdout):
stream.write(f"""\
Usage: {Path(sys.argv[0]).name} [--daemon] [--vnc[=ADDR:N]]
[--arch ARCH] [--state-dir DIR] [--rdp-port PORT] [--ssh-port PORT] [-- QEMU_ARGUMENT ...]
If the system disk (DIR/disk.qcow2, see --state-dir) is missing, create it as a
QCOW2 overlay on the Windows 11 base image, which starts in seconds and runs the
specialize and OOBE passes of its answer file on the first boot, and mount the
seed ISO (DIR/seed.iso, built from seediso/windows11 if missing or outdated):
Windows reads its Unattend.xml from the root of that CD-ROM during the specialize
and OOBE passes: host name, account, auto logon, Remote Desktop, then at the
first logon Configure-Windows.ps1 (OpenSSH and the SSH key, settings, installer
scripts on the desktop). If the disk exists, start Windows normally, without the
seed ISO.
Make the base image once with vm-images/build-windows-base.sh; it installs
Windows from the ISO and generalizes the result. The base image contains only
Windows (no account, no SSH, no VirtIO or other hardware-specific drivers), so
the devices in qemu/windows11 are ones Windows has inbox drivers for.
The VM hardware (accelerator, memory, CPUs, drives, devices) is defined in
qemu/windows11/config.ini, which is rendered to qemu.ini in the state directory and
loaded with QEMU's -readconfig, followed by qemu/windows11/<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64, chosen from the host system and
architecture, --arch or VM_IMAGE_ARCH). Later QEMU arguments override them, e.g. -- -m 4G -smp 4.
Options:
--daemon Run QEMU in the background
--vnc[=ADDR:N] Serve the screen over VNC instead of opening a window, on
ADDR (default: 127.0.0.1) at TCP port 5900+N (default N: 0).
Overrides WIN11_VM_DISPLAY
--arch ARCH Guest architecture: arm64 or amd64 (default: the host's;
overrides VM_IMAGE_ARCH). A guest that differs from the
host runs under slow TCG emulation
--state-dir DIR Directory for the VM disk and runtime files
(default: vm/windows11; overrides WIN11_VM_STATE_DIR)
--rdp-port PORT Host port of the RDP rule in the port forward file
(default: 3389; overrides WIN11_VM_RDP_PORT)
--ssh-port PORT Host port of the SSH rule in the port forward file
(default: 2222; overrides WIN11_VM_SSH_PORT)
-h, --help Show this help
Environment variables:
WIN11_VM_BASE_IMAGE Windows 11 base QCOW2 image (default: beside shared images)
WIN11_VM_RDP_PORT Host RDP port (default: from the port forward file, 3389)
WIN11_VM_SSH_PORT Host SSH port (default: from the port forward file, 2222)
WIN11_VM_FORWARDS Port forward file (default: qemu/windows11/forwards.conf)
WIN11_VM_STATE_DIR Runtime directory (default: vm/windows11)
WIN11_VM_DISK_IMAGE System disk path
WIN11_VM_DISK_SIZE New system disk size (default: 64G)
WIN11_VM_SEED_ISO Seed ISO path (default: alongside the VM disk)
WIN11_VM_UEFI_CODE OVMF read-only firmware path (auto-detected if unset)
WIN11_VM_UEFI_VARS OVMF variable-store template path (auto-detected if unset)
WIN11_VM_QEMU_CONFIG QEMU config template passed to -readconfig
(default: qemu/windows11/config.ini)
WIN11_VM_DISPLAY QEMU display backend (default: cocoa on macOS,
gtk elsewhere)
""")
def parse_args(args):
"""Parse the command line."""
options = Options()
args = list(args)
while args:
arg = args.pop(0)
if arg == "--daemon":
options.daemonize = True
elif arg == "--vnc" or arg.startswith("--vnc="):
options.vnc_listen = arg.partition("=")[2] or "127.0.0.1:0"
if ":" not in options.vnc_listen:
options.vnc_listen += ":0"
elif arg == "--state-dir":
if not args:
die(f"{arg} requires a directory argument.", 2)
options.state_dir = Path(args.pop(0))
elif arg.startswith("--state-dir="):
options.state_dir = Path(arg.split("=", 1)[1])
elif arg in ("--rdp-port", "--ssh-port") or arg.startswith(("--rdp-port=", "--ssh-port=")):
name, sep, value = arg.partition("=")
if not sep:
if not args:
die(f"{name} requires a port argument.", 2)
value = args.pop(0)
if not (value.isascii() and value.isdigit() and 1 <= int(value) <= 65535):
die(f"{name} must be a port number from 1 to 65535: {value}", 2)
if name == "--rdp-port":
options.rdp_port = value
else:
options.ssh_port = value
elif arg == "--arch":
if not args:
die(f"{arg} requires an architecture argument.", 2)
options.arch_option = args.pop(0)
elif arg.startswith("--arch="):
options.arch_option = arg.split("=", 1)[1]
elif arg in ("-h", "--help"):
usage()
sys.exit(0)
elif arg == "--":
options.extra_args.extend(args)
break
else:
print(f"Error: unknown option: {arg}", file=sys.stderr)
usage(sys.stderr)
sys.exit(2)
return options
class Platform:
"""Guest architecture, host system and the UEFI firmware that fit them."""
def __init__(self, arch_option):
self.arch = normalize_arch(arch_option or env("VM_IMAGE_ARCH") or platform.machine())
# The shared image helper and build-seed.sh pick the architecture from here.
os.environ["VM_IMAGE_ARCH"] = self.arch
self.qemu_binary = "qemu-system-aarch64" if self.arch == "arm64" else "qemu-system-x86_64"
self.os = platform.system().lower()
if self.os == "darwin" and self.arch == "amd64":
die("amd64 guests are not supported on macOS hosts; use an arm64 image")
self.config_template = Path(
env("WIN11_VM_QEMU_CONFIG") or script_dir / "qemu" / "windows11" / "config.ini")
self.platform_config = script_dir / "qemu" / "windows11" / f"{self.os}-{self.arch}.ini"
if self.arch == "arm64":
self.uefi_code = pick_firmware(
env("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(
self.uefi_vars_template = pick_firmware(
env("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(
else:
self.uefi_code = pick_firmware(
env("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(
self.uefi_vars_template = pick_firmware(
env("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",
)
if install_from_img:
install_from_iso = True
if download_only:
# The VirtIO tools ISO and the Windows installation ISO are the media the
# launcher downloads itself; the base QCOW2 is supplied manually.
image_downloader = None
if env("WIN11_VM_TOOLS_ISO"):
tools_iso = Path(env("WIN11_VM_TOOLS_ISO"))
if not tools_iso.is_file():
die(f"VirtIO Windows tools ISO was not found: {tools_iso}")
class Runtime:
"""Paths of the files a VM keeps in its state directory."""
def __init__(self, state_dir):
self.state_dir = state_dir
self.disk_image = Path(env("WIN11_VM_DISK_IMAGE") or state_dir / "disk.qcow2")
self.uefi_vars = state_dir / "OVMF_VARS_4M.fd"
self.pid_file = state_dir / "qemu.pid"
self.log_file = state_dir / "console.log"
self.monitor_socket = state_dir / "qemu-monitor.sock"
self.guest_agent_socket = state_dir / "qemu-guest-agent.sock"
self.qemu_config = state_dir / "qemu.ini"
def check_tools_and_firmware(plat, need_qemu_img=True):
if shutil.which(plat.qemu_binary) is None:
die(f"{plat.qemu_binary} is not installed.")
if need_qemu_img and shutil.which("qemu-img") is None:
die("qemu-img is not installed.")
for firmware_file in (plat.uefi_code, plat.uefi_vars_template):
if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}")
def prepare_runtime(plat, rt):
"""Create the state directory and clear the leftovers of an earlier run."""
rt.state_dir.mkdir(parents=True, exist_ok=True)
if rt.pid_file.is_file():
old_pid = rt.pid_file.read_text().strip()
if old_pid.isdigit():
try:
os.kill(int(old_pid), 0)
except OSError:
pass
else:
image_downloader = find_image_downloader()
tools_iso = Path(capture([str(image_downloader), "--print-path", "virtio-win"]))
if not tools_iso.is_file():
run([str(image_downloader), "virtio-win"])
if not tools_iso.is_file():
die(f"downloader did not create: {tools_iso}")
print(f"VirtIO Windows tools ISO is ready: {tools_iso}")
if env("WIN11_VM_INSTALL_ISO"):
install_iso = Path(env("WIN11_VM_INSTALL_ISO"))
if not install_iso.is_file():
die(f"Windows 11 installation ISO was not found: {install_iso}")
die(f"Windows 11 VM is already running (PID {old_pid}).")
rt.pid_file.unlink(missing_ok=True)
rt.monitor_socket.unlink(missing_ok=True)
rt.guest_agent_socket.unlink(missing_ok=True)
if not rt.uefi_vars.is_file():
shutil.copyfile(plat.uefi_vars_template, rt.uefi_vars)
def display_backend(vnc_listen):
"""cocoa shows the guest at its own resolution. Append ,zoom-to-fit=on (via
WIN11_VM_DISPLAY) to stretch a low-resolution screen to the window."""
backend = (f"vnc={vnc_listen}" if vnc_listen else env("WIN11_VM_DISPLAY") or (
"cocoa" if platform.system() == "Darwin" else "gtk"))
if vnc_listen:
vnc_host, _, vnc_display = vnc_listen.rpartition(":")
if not vnc_display.isdigit():
die(f"--vnc display number must be an integer: {vnc_listen}", 2)
print(f"VNC: connect to {vnc_host or '127.0.0.1'}:{5900 + int(vnc_display)}")
return backend
def common_qemu_args(plat, rt, options):
"""Render the config template and return the QEMU arguments shared by both
launchers, plus the port forwards (for the start-up message).
Most of the VM definition lives in the QEMU config template; the file
paths are filled in and the rendered file is handed to QEMU with -readconfig.
"""
config_values = {
"uefi_code": plat.uefi_code,
"uefi_vars": rt.uefi_vars,
"disk_image": rt.disk_image,
"guest_agent_socket": rt.guest_agent_socket,
"monitor_socket": rt.monitor_socket,
}
for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values.
if any(char in str(value) for char in '"\n'):
die(f"{key} must not contain double quotes or newlines: {value}")
for config_file in (plat.config_template, plat.platform_config):
if not config_file.is_file():
die(f"QEMU config file was not found: {config_file}")
try:
config_text = string.Template(plat.config_template.read_text()).substitute(
{key: str(value) for key, value in config_values.items()})
except (KeyError, ValueError) as error:
die(f"invalid placeholder in {plat.config_template}: {error}")
rt.qemu_config.write_text(config_text)
# Each rule of the port forward file is one hostfwd entry of the network.
forwards_file = Path(
env("WIN11_VM_FORWARDS") or script_dir / "qemu" / "windows11" / "forwards.conf")
forwards = vmforwards.usable_forwards(vmforwards.load_forwards(
forwards_file,
{label: port for label, port in (("RDP", options.rdp_port), ("SSH", options.ssh_port))
if port}))
qemu_args = [
"-readconfig", str(rt.qemu_config),
"-readconfig", str(plat.platform_config),
# "max" is the host CPU under KVM/HVF and the fullest model under TCG.
"-cpu", "max",
# A config file section cannot hold two hostfwd entries, so the network
# that the NIC in the config file uses is defined here. -nic none stops
# QEMU from adding its default NIC as well.
"-netdev", vmforwards.netdev_argument(forwards),
"-nic", "none",
"-display", display_backend(options.vnc_listen),
]
return qemu_args, forwards
def start_qemu(plat, rt, qemu_args, extra_args, forwards, daemonize, banner):
print(banner)
print("Account: guofang / password; " + "; ".join(
f"{forward.label}: 0.0.0.0:{forward.host_port}" for forward in forwards),
flush=True)
if daemonize:
rt.log_file.write_text("")
qemu_args += ["-serial", f"file:{rt.log_file}", "-daemonize", "-pidfile", str(rt.pid_file)]
run([plat.qemu_binary, *qemu_args, *extra_args])
print(f"VM started (PID {rt.pid_file.read_text().strip()}); console log: {rt.log_file}")
else:
image_downloader = image_downloader or find_image_downloader()
run([str(image_downloader), "windows11"])
install_iso = Path(capture([str(image_downloader), "--print-path", "windows11", "--iso"]))
print(f"Windows 11 installation ISO is ready: {install_iso}")
sys.exit(0)
disk_image = Path(env("WIN11_VM_DISK_IMAGE") or state_dir / "disk.qcow2")
seed_iso = Path(env("WIN11_VM_SEED_ISO") or disk_image.parent / "seed.iso")
uefi_vars = state_dir / "OVMF_VARS_4M.fd"
pid_file = state_dir / "qemu.pid"
log_file = state_dir / "console.log"
monitor_socket = state_dir / "qemu-monitor.sock"
guest_agent_socket = state_dir / "qemu-guest-agent.sock"
qemu_config = state_dir / "qemu.ini"
os.execvp(plat.qemu_binary, [plat.qemu_binary, *qemu_args, *extra_args])
def build_seed_iso(image_downloader, force=False):
"""Build the seed ISO, unless it is newer than all of its sources and not forced."""
def build_seed_iso(seed_iso):
"""Build the seed ISO, unless it is newer than all of its sources."""
seed_dir = script_dir / "seediso" / "windows11"
seed_sources = [seed_dir / "Autounattend.xml", seed_dir / "Configure-Windows.ps1",
seed_sources = [seed_dir / "Unattend.xml", seed_dir / "Configure-Windows.ps1",
*sorted(seed_dir.glob("[0-9][0-9]-*.bat")),
*sorted(seed_dir.glob("[0-9][0-9]-*.ps1"))]
@@ -300,222 +370,49 @@ def build_seed_iso(image_downloader, force=False):
# The seed carries the OpenSSH zip for this architecture; build-seed.sh
# downloads it when missing.
seed_sources.append(Path(capture(
[str(image_downloader), "--print-path", "openssh-win"])))
openssh_zip = Path(capture(
[str(find_image_downloader()), "--print-path", "openssh-win"]))
seed_sources.append(openssh_zip)
def newer_than_seed(source):
# Mirrors bash `-nt`: true if source exists and is newer than seed_iso.
return source.exists() and source.stat().st_mtime > seed_iso.stat().st_mtime
if force or not seed_iso.is_file() or not seed_sources[-1].is_file() \
or any(newer_than_seed(s) for s in seed_sources):
run([str(script_dir / "seediso" / "windows11" / "build-seed.sh"), str(seed_iso), host_arch])
if not seed_iso.is_file() or not openssh_zip.is_file() or any(
source.exists() and source.stat().st_mtime > seed_iso.stat().st_mtime
for source in seed_sources):
run([str(seed_dir / "build-seed.sh"), str(seed_iso), plat.arch])
if seed_only:
build_seed_iso(find_image_downloader(), force=True)
print(f"Seed ISO is ready: {seed_iso}")
sys.exit(0)
options = parse_args(sys.argv[1:])
plat = Platform(options.arch_option)
rt = Runtime(options.state_dir)
disk_size = env("WIN11_VM_DISK_SIZE") or "64G"
installing = False
installing_from_iso = False
if not disk_image.is_file():
installing = True
installing_from_iso = install_from_iso
image_downloader = find_image_downloader()
if install_from_iso:
install_iso = Path(
env("WIN11_VM_INSTALL_ISO")
or capture([str(image_downloader), "--print-path", "windows11", "--iso"]))
if install_from_img:
install_iso = install_iso.with_suffix(".img")
uses_shared_tools_iso = False
if env("WIN11_VM_TOOLS_ISO"):
tools_iso = Path(env("WIN11_VM_TOOLS_ISO"))
else:
tools_iso = Path(capture([str(image_downloader), "--print-path", "virtio-win"]))
uses_shared_tools_iso = True
else:
creating = not rt.disk_image.is_file()
if creating:
base_image = Path(env("WIN11_VM_BASE_IMAGE")
or capture([str(image_downloader), "--print-path", "windows11"]))
or capture([str(find_image_downloader()), "--print-path", "windows11"]))
if not base_image.is_file():
die(f"Windows 11 base QCOW2 image was not found: {base_image}\n"
"Make it once with vm-images/build-windows-base.sh")
if shutil.which(qemu_binary) is None:
die(f"{qemu_binary} is not installed.")
if installing:
if shutil.which("qemu-img") is None:
die("qemu-img is not installed.")
if installing_from_iso:
if not install_iso.is_file():
if install_from_img:
die(f"Windows 11 installation disk image was not found: {install_iso}\n"
"Build it with vm-images/build-windows-install-disk.sh ISO")
die(f"Windows 11 ISO was not found: {install_iso}")
elif not base_image.is_file():
die(f"Windows 11 base QCOW2 image was not found: {base_image}")
for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}")
state_dir.mkdir(parents=True, exist_ok=True)
if installing_from_iso:
if not tools_iso.is_file():
if uses_shared_tools_iso:
run([str(image_downloader), "virtio-win"])
else:
die(f"VirtIO Windows tools ISO was not found: {tools_iso}")
if not tools_iso.is_file():
die(f"downloader did not create: {tools_iso}")
build_seed_iso(image_downloader)
if pid_file.is_file():
old_pid = pid_file.read_text().strip()
if old_pid.isdigit():
try:
os.kill(int(old_pid), 0)
except OSError:
pass
else:
die(f"Windows 11 VM is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True)
monitor_socket.unlink(missing_ok=True)
guest_agent_socket.unlink(missing_ok=True)
if not uefi_vars.is_file():
shutil.copyfile(uefi_vars_template, uefi_vars)
if installing:
if installing_from_iso:
run(["qemu-img", "create", "-q", "-f", "qcow2", str(disk_image), disk_size])
else:
check_tools_and_firmware(plat, need_qemu_img=creating)
prepare_runtime(plat, rt)
if creating:
seed_iso = Path(env("WIN11_VM_SEED_ISO") or rt.disk_image.parent / "seed.iso")
build_seed_iso(seed_iso)
if creating:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size])
print(f"Created Windows 11 system disk: {disk_image}")
"-b", str(base_image), str(rt.disk_image), disk_size])
print(f"Created Windows 11 system disk: {rt.disk_image}")
# cocoa shows the guest at its own resolution. Append ,zoom-to-fit=on (via
# WIN11_VM_DISPLAY) to stretch a low-resolution screen to the window.
display_backend = (f"vnc={vnc_listen}" if vnc_listen else env("WIN11_VM_DISPLAY") or (
"cocoa" if platform.system() == "Darwin" else "gtk"))
if vnc_listen:
vnc_host, _, vnc_display = vnc_listen.rpartition(":")
if not vnc_display.isdigit():
die(f"--vnc display number must be an integer: {vnc_listen}", 2)
print(f"VNC: connect to {vnc_host or '127.0.0.1'}:{5900 + int(vnc_display)}")
# Most of the VM definition lives in the QEMU config template; fill in the
# file paths and hand the rendered file to QEMU with -readconfig.
config_values = {
"uefi_code": uefi_code,
"uefi_vars": uefi_vars,
"disk_image": disk_image,
"guest_agent_socket": guest_agent_socket,
"monitor_socket": monitor_socket,
}
for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values.
if any(char in str(value) for char in '"\n'):
die(f"{key} must not contain double quotes or newlines: {value}")
for config_file in (qemu_config_template, qemu_platform_config):
if not config_file.is_file():
die(f"QEMU config file was not found: {config_file}")
try:
qemu_config_text = string.Template(qemu_config_template.read_text()).substitute(
{key: str(value) for key, value in config_values.items()})
except (KeyError, ValueError) as error:
die(f"invalid placeholder in {qemu_config_template}: {error}")
qemu_config.write_text(qemu_config_text)
# Each rule of the port forward file is one hostfwd entry of the network.
forwards = vmforwards.usable_forwards(vmforwards.load_forwards(
forwards_file, {label: port for label, port in (("RDP", rdp_port), ("SSH", ssh_port)) if port}))
# Neither Windows Setup nor the base image has the viogpudo driver yet, and
# without it the virtio-gpu in the config file shows "display output is not
# active". ramfb shows the screen until Configure-Windows.ps1 installs it. It
# must be created before the virtio-gpu: the display window shows the first GPU.
qemu_args = ["-device", "ramfb"] if installing and host_arch == "arm64" else []
qemu_args += [
"-readconfig", str(qemu_config),
"-readconfig", str(qemu_platform_config),
# "max" is the host CPU under KVM/HVF and the fullest model under TCG.
"-cpu", "max",
# A config file section cannot hold two hostfwd entries, so the network
# that the NIC in the config file uses is defined here. -nic none stops
# QEMU from adding its default NIC as well.
"-netdev", vmforwards.netdev_argument(forwards),
"-nic", "none",
"-display", display_backend,
# "-display", "none", "-spice", "port=5900,addr=127.0.0.1,disable-ticketing=on,agent-mouse=off", "-device", "virtio-vga",
]
if host_os == "darwin" and host_arch == "arm64" and not install_from_img:
# 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; upstream
# QEMU only gets the fix from the UTM fork. The NVMe install disk of
# --install-from-img does not use usb-storage, and hangs with the emulated
# controller.
qemu_args, forwards = common_qemu_args(plat, rt, options)
if plat.os == "darwin" and plat.arch == "arm64":
# The HVF in-kernel GICv3 stalls Windows on its USB and NVMe devices
# (utmapp/UTM#7970), so use the emulated interrupt controller.
qemu_args += ["-machine", "kernel-irqchip=off"]
if installing_from_iso:
# q35 has IDE for the installation media; "virt" does not, so use USB.
for media_id, media_file, boot_index, ide_bus in (
("install_media", install_iso, 2, "ide.1"),
("answer_media", seed_iso, 3, "ide.2"),
("tools_media", tools_iso, 4, "ide.3")):
device = (f"ide-cd,drive={media_id},bus={ide_bus},bootindex={boot_index}"
if host_arch == "amd64"
else f"usb-storage,drive={media_id},bootindex={boot_index}")
if media_id == "install_media" and install_from_img:
# 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", f"file={media_file},if=none,id={media_id},format=raw,snapshot=on",
"-device", f"nvme,drive={media_id},serial=installer,bootindex={boot_index}",
]
continue
qemu_args += [
"-drive", f"file={media_file},if=none,id={media_id},media=cdrom,readonly=on",
"-device", device,
]
if installing_from_iso:
print("Starting Windows 11 automatic installation.")
elif installing:
print("Starting Windows 11 from the QCOW2 base image.")
else:
print("Starting Windows 11 normally.")
print("Account: guofang / password; " + "; ".join(
f"{forward.label}: 0.0.0.0:{forward.host_port}" for forward in forwards),
flush=True)
if installing_from_iso and not install_from_img:
# 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 detached helper taps the space bar through the
# QEMU monitor for the first 30 s. It exits by itself; later key presses
# are harmless because Setup is unattended.
key_script = (
"import socket, sys, time\n"
"path = sys.argv[1]\n"
"end = time.time() + 30\n"
"while time.time() < end:\n"
" try:\n"
" s = socket.socket(socket.AF_UNIX)\n"
" s.connect(path)\n"
" break\n"
" except OSError:\n"
" time.sleep(0.5)\n"
"else:\n"
" sys.exit(0)\n"
"while time.time() < end:\n"
" s.sendall(b'sendkey spc\\n')\n"
" time.sleep(1)\n")
subprocess.Popen([sys.executable, "-c", key_script, str(monitor_socket)],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
if daemonize:
log_file.write_text("")
qemu_args += ["-serial", f"file:{log_file}", "-daemonize", "-pidfile", str(pid_file)]
run([qemu_binary, *qemu_args, *extra_args])
print(f"VM started (PID {pid_file.read_text().strip()}); console log: {log_file}")
else:
os.execvp(qemu_binary, [qemu_binary, *qemu_args, *extra_args])
if creating:
# q35 has IDE (ide.0 is the system disk); "virt" does not, so use USB.
qemu_args += ["-drive", f"file={seed_iso},if=none,id=seed_media,media=cdrom,readonly=on",
"-device", "ide-cd,drive=seed_media,bus=ide.1" if plat.arch == "amd64"
else "usb-storage,drive=seed_media"]
start_qemu(plat, rt, qemu_args, options.extra_args, forwards, options.daemonize,
"Starting Windows 11 from the QCOW2 base image." if creating
else "Starting Windows 11 normally.")
-171
View File
@@ -1,171 +0,0 @@
<?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>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>WIN11</ComputerName>
<TimeZone>China Standard Time</TimeZone>
</component>
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager"
processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral"
versionScope="nonSxS">
<fDenyTSConnections>false</fDenyTSConnections>
</component>
<component name="Networking-MPSSVC-Svc" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<FirewallGroups>
<FirewallGroup xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
wcm:action="add" wcm:keyValue="RemoteDesktop">
<Active>true</Active>
<Group>Remote Desktop</Group>
<Profile>all</Profile>
</FirewallGroup>
</FirewallGroups>
</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"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>guofang</Name>
<Group>Administrators</Group>
<DisplayName>Guofang</DisplayName>
<Password>
<Value>wang</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Enabled>true</Enabled>
<LogonCount>1</LogonCount>
<Username>guofang</Username>
<Password>
<Value>wang</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>Configure Windows from the answer-media script</Description>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "&amp; ('{0}:\Configure-Windows.ps1' -f (Get-Volume -FileSystemLabel CIDATA).DriveLetter) -InstallVirtIO -EnableAutomaticLogon -DisableAutoUpdate -CopyInstallers -InstallOpenSSH -NoPause"</CommandLine>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
-70
View File
@@ -1,5 +1,4 @@
param(
[switch]$InstallVirtIO,
[switch]$EnableAutomaticLogon,
[switch]$DisableAutoUpdate,
[switch]$CopyInstallers,
@@ -35,72 +34,6 @@ if (-not (Test-Administrator)) {
exit $elevated.ExitCode
}
# The guest tools installer is an x86 program and the VirtIO ISO has no Arm64
# package for it, so on Arm64 Windows the Arm64 drivers are installed from their
# folders on the ISO instead. NetKVM is the network driver; without it the guest
# has no network.
function Install-VirtIOArm64Drivers {
$roots = (Get-PSDrive -PSProvider FileSystem).Root
$drivers = @(
@{ Folder = 'NetKVM'; Inf = 'netkvm.inf' },
@{ Folder = 'vioserial'; Inf = 'vioser.inf' },
@{ Folder = 'Balloon'; Inf = 'balloon.inf' },
@{ Folder = 'viorng'; Inf = 'viorng.inf' },
@{ Folder = 'vioinput'; Inf = 'vioinput.inf' },
@{ Folder = 'viogpudo'; Inf = 'viogpudo.inf' }
)
$installed = 0
foreach ($driver in $drivers) {
$inf = $roots |
ForEach-Object { Join-Path $_ "$($driver.Folder)\w11\ARM64\$($driver.Inf)" } |
Where-Object { Test-Path -LiteralPath $_ } |
Select-Object -First 1
if (-not $inf) {
if ($driver.Folder -eq 'NetKVM') {
throw 'The Arm64 NetKVM 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 "Installed $installed VirtIO Arm64 driver packages."
}
function Install-VirtIOGuestTools {
if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') {
Install-VirtIOArm64Drivers
return
}
$installer = Get-ChildItem `
-Path (Get-PSDrive -PSProvider FileSystem).Root `
-Filter 'virtio-win-guest-tools.exe' `
-File `
-ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $installer) {
throw 'virtio-win-guest-tools.exe was not found on the attached media.'
}
$process = Start-Process `
-FilePath $installer.FullName `
-ArgumentList '/quiet', '/norestart' `
-Wait `
-PassThru
if ($process.ExitCode -notin 0, 3010) {
throw "VirtIO guest tools installer exited with code $($process.ExitCode)."
}
}
function Enable-AutomaticLogon {
$winlogonPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
@@ -205,9 +138,6 @@ function Install-OpenSSHServer {
$exitCode = 0
try {
if ($InstallVirtIO) {
Install-VirtIOGuestTools
}
if ($EnableAutomaticLogon) {
Enable-AutomaticLogon
}
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>WIN11</ComputerName>
<TimeZone>China Standard Time</TimeZone>
</component>
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager"
processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral"
versionScope="nonSxS">
<fDenyTSConnections>false</fDenyTSConnections>
</component>
<component name="Networking-MPSSVC-Svc" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<FirewallGroups>
<FirewallGroup xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
wcm:action="add" wcm:keyValue="RemoteDesktop">
<Active>true</Active>
<Group>Remote Desktop</Group>
<Profile>all</Profile>
</FirewallGroup>
</FirewallGroups>
</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"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>guofang</Name>
<Group>Administrators</Group>
<DisplayName>Guofang</DisplayName>
<Password>
<Value>wang</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Enabled>true</Enabled>
<LogonCount>1</LogonCount>
<Username>guofang</Username>
<Password>
<Value>wang</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>Configure Windows from the answer-media script</Description>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "&amp; ('{0}:\Configure-Windows.ps1' -f (Get-Volume -FileSystemLabel CIDATA).DriveLetter) -EnableAutomaticLogon -DisableAutoUpdate -CopyInstallers -InstallOpenSSH -NoPause"</CommandLine>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
+2 -2
View File
@@ -32,10 +32,10 @@ fi
work_dir="$(mktemp -d)"
trap 'rm -rf "$work_dir"' EXIT
sed "s/processorArchitecture=\"amd64\"/processorArchitecture=\"${answer_arch}\"/" \
"$script_dir/Autounattend.xml" >"$work_dir/Autounattend.xml"
"$script_dir/Unattend.xml" >"$work_dir/Unattend.xml"
seed_files=(
"$work_dir/Autounattend.xml"
"$work_dir/Unattend.xml"
"$script_dir/Configure-Windows.ps1"
"$openssh_zip"
)