diff --git a/README.md b/README.md index 8816440..27c87de 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,12 @@ passwordless administrator login. The installer includes the VirtIO drivers, SPI and QEMU Guest Agent. Connect with `ssh guofang@localhost -p 2222`. 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 Windows VM hardware (accelerator, 8G memory, 8 CPUs, system disk, devices) +is defined in `qemu/windows11.ini`, with the machine type in +`qemu/windows11-amd64.ini`; the launcher renders the former to +`.vm/windows11/qemu.ini` and loads both with `-readconfig`. Only the network +and the installation media 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`. @@ -183,7 +189,7 @@ To install from the Windows ISO instead, use `./run-windows11.py 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, resources, RDP port, or SSH port with the `WIN11_VM_` environment +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: diff --git a/qemu/windows11-amd64.ini b/qemu/windows11-amd64.ini new file mode 100644 index 0000000..688f818 --- /dev/null +++ b/qemu/windows11-amd64.ini @@ -0,0 +1,4 @@ +# amd64 part of the Windows 11 QEMU configuration; read after windows11.ini. + +[machine] + type = "q35" diff --git a/qemu/windows11.ini b/qemu/windows11.ini new file mode 100644 index 0000000..49f236a --- /dev/null +++ b/qemu/windows11.ini @@ -0,0 +1,104 @@ +# QEMU configuration for the Windows 11 virtual machine. The machine type is in +# windows11-amd64.ini, which QEMU reads after this file. +# +# run-windows11.py fills in the file path placeholders below, writes the result +# to /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. + +[name] + guest = "windows11" + +# Accelerators are tried in order: KVM on Linux, HVF on macOS, else emulation. +[accel] + accel = "kvm" + +[accel] + accel = "hvf" + +[accel] + accel = "tcg" + +[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. It 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. +[drive "system_disk"] + if = "none" + format = "qcow2" + file = "${disk_image}" + +[device "system_disk-dev"] + driver = "ide-hd" + drive = "system_disk" + bus = "ide.0" + bootindex = "1" + +# The NIC is attached to the user-mode network "net0", which the launcher +# defines with -netdev because a config file section cannot hold more than one +# hostfwd (Remote Desktop and SSH are both forwarded to the host). +[device "nic0"] + driver = "e1000e" + netdev = "net0" + +# Host/guest clipboard integration through the SPICE vdagent channel. +[device "virtio_serial"] + driver = "virtio-serial-pci" + +[chardev "vdagent"] + backend = "qemu-vdagent" + name = "vdagent" + clipboard = "on" + mouse = "off" + +[device "vdagent-port"] + driver = "virtserialport" + chardev = "vdagent" + name = "com.redhat.spice.0" + +# 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" + +# QEMU Guest Agent and the QEMU monitor, both on sockets in the state directory. +[chardev "qga"] + backend = "socket" + path = "${guest_agent_socket}" + server = "on" + wait = "off" + +[device "qga-port"] + driver = "virtserialport" + chardev = "qga" + name = "org.qemu.guest_agent.0" + +[chardev "monitor"] + backend = "socket" + path = "${monitor_socket}" + server = "on" + wait = "off" + +[mon "monitor0"] + chardev = "monitor" + mode = "readline" diff --git a/run-windows11.py b/run-windows11.py index 81cd14f..3ab4d44 100755 --- a/run-windows11.py +++ b/run-windows11.py @@ -4,6 +4,7 @@ import os import platform import shutil +import string import subprocess import sys from pathlib import Path @@ -50,10 +51,11 @@ def find_image_downloader(): state_dir = Path(env("WIN11_VM_STATE_DIR") or script_dir / ".vm" / "windows11") disk_size = env("WIN11_VM_DISK_SIZE") or "64G" -memory = env("WIN11_VM_MEMORY") or "8G" -cpus = env("WIN11_VM_CPUS") or "8" rdp_port = env("WIN11_VM_RDP_PORT") or "3389" ssh_port = env("WIN11_VM_SSH_PORT") or "2222" +qemu_config_template = Path( + env("WIN11_VM_QEMU_CONFIG") or script_dir / "qemu" / "windows11.ini") +qemu_arch_config = script_dir / "qemu" / "windows11-amd64.ini" def pick_firmware(override, *candidates): @@ -87,10 +89,15 @@ extra_args = [] def usage(stream=sys.stdout): stream.write(f"""\ -Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--install-from-iso] [--state-dir DIR] [-- QEMU_ARGUMENT ...] +Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--install-from-iso] [--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.ini, which is rendered to qemu.ini in the state directory and +loaded with QEMU's -readconfig, followed by qemu/windows11-amd64.ini. Later QEMU +arguments override them, e.g. -- -m 4G -smp 4. Use --install-from-iso to perform an automatic ISO installation instead. If it exists, start Windows normally without attaching installation media. @@ -101,14 +108,16 @@ Options: --install-from-iso Install from ISO when the system disk is missing --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 forwarded to guest RDP (default: 3389; + overrides WIN11_VM_RDP_PORT) + --ssh-port PORT Host port forwarded to guest SSH (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 WIN11_VM_TOOLS_ISO VirtIO Windows tools ISO (default: resolved by shared image helper) - WIN11_VM_MEMORY Guest memory (default: 8G) - WIN11_VM_CPUS Virtual CPU count (default: 8) WIN11_VM_RDP_PORT Host RDP port (default: 3389) WIN11_VM_SSH_PORT Host SSH port (default: 2222) WIN11_VM_STATE_DIR Runtime directory (default: .vm/windows11) @@ -117,6 +126,8 @@ Environment variables: 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.ini) WIN11_VM_DISPLAY QEMU display backend (default: cocoa on macOS, gtk elsewhere) """) @@ -136,6 +147,18 @@ while args: 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 in ("-h", "--help"): usage() sys.exit(0) @@ -170,6 +193,7 @@ 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" installing = False installing_from_iso = False @@ -249,23 +273,41 @@ if installing: display_backend = env("WIN11_VM_DISPLAY") or ( "cocoa" if platform.system() == "Darwin" else "gtk") +# 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_arch_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) + qemu_args = [ - "-name", "windows11", "-machine", "q35", "-m", memory, "-smp", cpus, + "-readconfig", str(qemu_config), + "-readconfig", str(qemu_arch_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", f"user,id=net0,hostfwd=tcp::{rdp_port}-:3389,hostfwd=tcp::{ssh_port}-:22", + "-nic", "none", "-display", display_backend, # "-display", "none", "-spice", "port=5900,addr=127.0.0.1,disable-ticketing=on,agent-mouse=off", "-device", "virtio-vga", - "-drive", f"if=pflash,format=raw,readonly=on,file={uefi_code}", - "-drive", f"if=pflash,format=raw,file={uefi_vars}", - "-drive", f"file={disk_image},if=none,id=system_disk,format=qcow2", - "-device", "ide-hd,drive=system_disk,bus=ide.0,bootindex=1", - "-nic", f"user,model=e1000e,hostfwd=tcp::{rdp_port}-:3389,hostfwd=tcp::{ssh_port}-:22", - "-device", "virtio-serial-pci,id=virtio_serial", - "-chardev", "qemu-vdagent,id=vdagent,name=vdagent,clipboard=on,mouse=off", - "-device", "virtserialport,chardev=vdagent,name=com.redhat.spice.0", - # Register the USB tablet after vdagent so QEMU selects it as the active pointer. - "-device", "qemu-xhci,id=usb_controller", "-device", "usb-tablet,bus=usb_controller.0", - "-chardev", f"socket,path={guest_agent_socket},server=on,wait=off,id=qga", - "-device", "virtserialport,chardev=qga,name=org.qemu.guest_agent.0", - "-monitor", f"unix:{monitor_socket},server=on,wait=off", ] if installing_from_iso: qemu_args += [ @@ -276,18 +318,12 @@ if installing_from_iso: "-drive", f"file={tools_iso},if=none,id=tools_media,media=cdrom,readonly=on", "-device", "ide-cd,drive=tools_media,bus=ide.3,bootindex=4", ] -if os.access("/dev/kvm", os.R_OK | os.W_OK): - qemu_args += ["-enable-kvm", "-cpu", "host"] - accelerator = "KVM" -else: - qemu_args += ["-accel", "tcg", "-cpu", "max"] - accelerator = "TCG" if installing_from_iso: - print(f"Starting Windows 11 automatic installation with {accelerator} acceleration.") + print("Starting Windows 11 automatic installation.") elif installing: - print(f"Starting Windows 11 from the QCOW2 base image with {accelerator} acceleration.") + print("Starting Windows 11 from the QCOW2 base image.") else: - print(f"Starting Windows 11 normally with {accelerator} acceleration.") + print("Starting Windows 11 normally.") print(f"Account: guofang / password; RDP: localhost:{rdp_port}; SSH: localhost:{ssh_port}", flush=True)