From 179a3507f754cd4bb39d0ec10f38a0cbf16ee8d1 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Tue, 4 Aug 2026 20:28:13 +0000 Subject: [PATCH] fix(phase4): track Windows acceptance host provisioning and repair bootstrap The Windows acceptance host bootstrap existed only as a pre-built ISO on the hypervisor, so it could not be reviewed, reproduced, or corrected. Track it and fix two defects found while provisioning a fresh host. setup.ps1 installed the VirtIO network driver but never the VirtIO serial driver. The QEMU guest agent then starts and reports Running while the host side (qm agent ping) stays dead, leaving no guest-exec channel to provision through. setup.ps1 also skipped firewall setup whenever an OpenSSH-Server-In-TCP rule already existed. Add-WindowsCapability creates that rule scoped to the Private profile only, and a freshly bridged VM is categorized Public, so port 22 stayed unreachable even though sshd reported Running. The operator public key is no longer embedded. This repository is public, so publishing which key is authorized as Administrator on the acceptance host is needless disclosure; it is supplied at build time and staged onto the media. Also records two host constraints that are easy to get wrong: Proxmox always creates the TPM state volume as raw, and a raw volume on directory storage blocks snapshots for the whole VM, which the acceptance lane depends on for restoring its accepted clean baseline; and Windows 11 25H2 no longer auto-applies an answer file from secondary media. No release, tag, version bump, website deploy, or production change. Co-Authored-By: Claude --- docs/phase4-platform-acceptance.md | 15 ++++ .../windows-host/.gitignore | 2 + .../windows-host/Autounattend.xml | 50 +++++++++++++ .../windows-host/README.md | 75 +++++++++++++++++++ .../windows-host/build-unattend-iso.sh | 36 +++++++++ .../windows-host/setup.ps1 | 62 +++++++++++++++ 6 files changed, 240 insertions(+) create mode 100644 ops/platform-acceptance/windows-host/.gitignore create mode 100644 ops/platform-acceptance/windows-host/Autounattend.xml create mode 100644 ops/platform-acceptance/windows-host/README.md create mode 100755 ops/platform-acceptance/windows-host/build-unattend-iso.sh create mode 100644 ops/platform-acceptance/windows-host/setup.ps1 diff --git a/docs/phase4-platform-acceptance.md b/docs/phase4-platform-acceptance.md index 4ce2860..0799337 100644 --- a/docs/phase4-platform-acceptance.md +++ b/docs/phase4-platform-acceptance.md @@ -120,6 +120,21 @@ site-equivalent path without touching the public website. It creates an unrelated WSL control distribution and proves uninstall retains it. It never claims Windows artifact creation or signing. +### Retained host provisioning + +`ops/platform-acceptance/windows-host/` tracks the bootstrap media for this host: +the answer file, the first-boot driver/guest-agent/OpenSSH setup, and a +reproducible ISO build. Its README records two host constraints that are easy to +get wrong. Proxmox always creates the TPM state volume as raw, and a raw volume +on directory storage blocks snapshots for the entire VM even when every other +disk is qcow2 — so the acceptance host cannot honour "restore the accepted clean +snapshot before routing" unless that small volume sits on snapshot-capable +storage. Verify with `qm snapshot probe` before provisioning. Separately, +Windows 11 25H2 (build 26200) does not auto-apply an answer file from secondary +media and ignores `setup.exe /unattend:`; the README documents the offline +`dism /apply-image` path that still runs the specialize, oobeSystem and +auditUser passes. + ## Recovery and teardown First disable the applicable repository variable so new jobs cannot route. diff --git a/ops/platform-acceptance/windows-host/.gitignore b/ops/platform-acceptance/windows-host/.gitignore new file mode 100644 index 0000000..54db5f1 --- /dev/null +++ b/ops/platform-acceptance/windows-host/.gitignore @@ -0,0 +1,2 @@ +# The per-site operator public key is never committed to this public repository. +authorized_key.pub diff --git a/ops/platform-acceptance/windows-host/Autounattend.xml b/ops/platform-acceptance/windows-host/Autounattend.xml new file mode 100644 index 0000000..a5a6598 --- /dev/null +++ b/ops/platform-acceptance/windows-host/Autounattend.xml @@ -0,0 +1,50 @@ + + + + + en-US + en-USen-USen-USen-US + + + + E:\vioscsi\w11\amd64 + E:\NetKVM\w11\amd64 + + + + + 0true + + 1EFI260 + 2MSR16 + 3Primarytrue + + + 11FAT32 + 23NTFS + + + + /IMAGE/INDEX603 + true1Helm1Helm acceptance + falseOnError + + + + + ONEHELM-WINUTC1Helm1Helm acceptance + + + + + Audit + + + + + + 1Provision retained 1Helm acceptance hostpowershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$v=(Get-Volume -FileSystemLabel ONEHELM); & ($v.DriveLetter + ':\setup.ps1')"Never + + + + diff --git a/ops/platform-acceptance/windows-host/README.md b/ops/platform-acceptance/windows-host/README.md new file mode 100644 index 0000000..8a207d5 --- /dev/null +++ b/ops/platform-acceptance/windows-host/README.md @@ -0,0 +1,75 @@ +# Retained Windows acceptance-host provisioning + +These files build the bootstrap ISO for the dedicated Windows 11 Phase 4 +acceptance host described in `docs/phase4-platform-acceptance.md`. They were +previously carried only as a pre-built ISO on the hypervisor, so a rebuild could +not be reviewed or reproduced. They are tracked here for that reason. + +Nothing here publishes a release, creates a tag, deploys the website, or touches +production data or services. + +## What it does + +`Autounattend.xml` partitions the disk (GPT, EFI + MSR + NTFS), installs the Pro +image, sets the computer name and UTC timezone, then reseals into **audit mode** +and runs `setup.ps1` during the `auditUser` pass. + +`setup.ps1` installs the VirtIO network and serial drivers plus the QEMU guest +agent, enables OpenSSH Server pinned to the operator's public key (supplied at build +time, see below) with password authentication off, disables sleep and hibernate, and records +`C:\1HelmAcceptance\ready.json`. It is idempotent: an existing `ready.json` +makes it exit 0 immediately. + +## Build + +```sh +./build-unattend-iso.sh 1helm-windows-unattend.iso /path/to/operator_key.pub +``` + +The operator public key is **not** committed: this repository is public, and +publishing which key is authorized as Administrator on the acceptance host is +needless disclosure. Pass it at build time (or via +`HELM_ACCEPTANCE_AUTHORIZED_KEY`); the build stages it as `authorized_key.pub` +on the ISO and `setup.ps1` refuses to continue without it. `.gitignore` keeps a +local copy out of git. + +Two constraints are load-bearing: + +- `-iso-level 4` — at genisoimage's default level the ISO9660 namespace + truncates `Autounattend.xml` to `AUTOUNAT.XML`, which Windows Setup does not + recognize as an answer file. +- Volume label `ONEHELM` — the `auditUser` pass locates `setup.ps1` with + `Get-Volume -FileSystemLabel ONEHELM`. + +## Host VM shape + +The acceptance workflow restores an accepted clean snapshot before every job, so +the VM must actually be able to snapshot. Proxmox always creates the **TPM state +volume as raw**, and a raw volume on directory storage blocks snapshots for the +whole VM even when every other disk is qcow2. Place the disks as qcow2 and put +the small `tpmstate0` volume on snapshot-capable storage (thin-LVM), which keeps +TPM 2.0 and Secure Boot intact so no Windows 11 requirement bypass is needed. + +Verify before installing anything: + +```sh +qm snapshot probe && qm delsnapshot probe +``` + +## Windows 11 25H2 answer-file caveat + +On build 26200 (25H2) the new setup engine (`setuphost.exe`) does **not** +auto-apply `Autounattend.xml` from a secondary disc, and `setup.exe /unattend:` +is ignored. The answer file is readable from WinPE — it simply is not consumed, +so setup falls through to the interactive product-key page. + +Working alternative on affected media: from the WinPE shell (Shift+F10), +partition with `diskpart`, apply the image with +`dism /apply-image /imagefile::\sources\install.wim /index:6 /applydir:W:\`, +copy `Autounattend.xml` to `W:\Windows\Panther\unattend.xml`, then +`bcdboot W:\Windows /s S: /f UEFI`. First boot still runs `specialize`, +`oobeSystem` and `auditUser`, so the computer name, audit reseal and +`setup.ps1` all still apply. + +Note that WinPE on this media has no `curl`, no `taskkill`, and no configured +network, so stage anything you need on the bootstrap ISO itself. diff --git a/ops/platform-acceptance/windows-host/build-unattend-iso.sh b/ops/platform-acceptance/windows-host/build-unattend-iso.sh new file mode 100755 index 0000000..3921786 --- /dev/null +++ b/ops/platform-acceptance/windows-host/build-unattend-iso.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Build the retained Windows acceptance-host bootstrap ISO. +# +# Attach the result as a second CD-ROM alongside the Windows installation media +# on the dedicated Phase 4 acceptance VM. It carries the answer file and the +# first-boot bootstrap; it publishes nothing and contains no credentials beyond +# the operator's own SSH public key in setup.ps1. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +out="${1:-1helm-windows-unattend.iso}" +key="${2:-${HELM_ACCEPTANCE_AUTHORIZED_KEY:-}}" + +if [ -z "$key" ] || [ ! -f "$key" ]; then + echo "usage: build-unattend-iso.sh [out.iso] " >&2 + echo " or: HELM_ACCEPTANCE_AUTHORIZED_KEY=/path/to/key.pub build-unattend-iso.sh [out.iso]" >&2 + echo "The operator public key is deliberately not committed to this public repository." >&2 + exit 2 +fi + +stage="$(mktemp -d)" +trap 'rm -rf "$stage"' EXIT + +cp "$here/Autounattend.xml" "$here/setup.ps1" "$stage/" +printf 'Retained Windows acceptance bootstrap for 1Helm.\n' > "$stage/1helm-acceptance.txt" +install -m 0644 "$key" "$stage/authorized_key.pub" + +# -iso-level 4 keeps long file names. At genisoimage's default level the +# ISO9660 namespace truncates Autounattend.xml to AUTOUNAT.XML, which Windows +# Setup does not recognize as an answer file. +# +# The volume label MUST remain ONEHELM: the auditUser pass in Autounattend.xml +# locates setup.ps1 via Get-Volume -FileSystemLabel ONEHELM. +genisoimage -quiet -iso-level 4 -J -r -V ONEHELM -o "$out" "$stage" + +echo "wrote $out" diff --git a/ops/platform-acceptance/windows-host/setup.ps1 b/ops/platform-acceptance/windows-host/setup.ps1 new file mode 100644 index 0000000..6698eeb --- /dev/null +++ b/ops/platform-acceptance/windows-host/setup.ps1 @@ -0,0 +1,62 @@ +$ErrorActionPreference = "Stop" +$root = "C:\1HelmAcceptance" +New-Item -ItemType Directory -Force -Path $root | Out-Null +$log = Join-Path $root "bootstrap.log" +Start-Transcript -Path $log -Append +try { + if (Test-Path (Join-Path $root "ready.json")) { exit 0 } + + $virtio = Get-Volume | Where-Object { $_.DriveLetter -and (Test-Path ("{0}:\guest-agent\qemu-ga-x86_64.msi" -f $_.DriveLetter)) } | Select-Object -First 1 + if (-not $virtio) { throw "VirtIO 0.1.271 media was not found." } + $drive = "{0}:" -f $virtio.DriveLetter + & pnputil.exe /add-driver "$drive\NetKVM\w11\amd64\*.inf" /subdirs /install + if ($LASTEXITCODE -notin 0, 3010) { throw "NetKVM driver install failed: $LASTEXITCODE" } + # The QEMU guest agent reaches the host over a VirtIO serial port. Without + # this driver the QEMU-GA service still starts and reports Running, while the + # host side ("qm agent ping") stays dead and there is no guest-exec + # channel to provision the host with. + & pnputil.exe /add-driver "$drive\vioserial\w11\amd64\*.inf" /subdirs /install + if ($LASTEXITCODE -notin 0, 3010) { throw "VirtIO serial driver install failed: $LASTEXITCODE" } + & msiexec.exe /i "$drive\guest-agent\qemu-ga-x86_64.msi" /qn /norestart + if ($LASTEXITCODE -notin 0, 3010) { throw "QEMU guest agent install failed: $LASTEXITCODE" } + + $capability = Get-WindowsCapability -Online | Where-Object Name -Like "OpenSSH.Server*" | Select-Object -First 1 + if (-not $capability) { throw "Windows did not expose the OpenSSH Server capability." } + if ($capability.State -ne "Installed") { Add-WindowsCapability -Online -Name $capability.Name | Out-Null } + + New-Item -ItemType Directory -Force -Path "C:\ProgramData\ssh" | Out-Null + # The authorized key is supplied per site on the bootstrap media rather than + # committed to this public repository. build-unattend-iso.sh stages it. + $keySource = Join-Path $PSScriptRoot "authorized_key.pub" + if (-not (Test-Path $keySource)) { throw "authorized_key.pub is missing from the bootstrap media; see ops/platform-acceptance/windows-host/README.md" } + $authorizedKey = (Get-Content -Raw $keySource).Trim() + if ($authorizedKey -notmatch '^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp[0-9]+) ') { throw "authorized_key.pub is not an OpenSSH public key" } + Set-Content -Encoding ascii -Path "C:\ProgramData\ssh\administrators_authorized_keys" -Value $authorizedKey + & icacls.exe "C:\ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F" | Out-Null + $config = "C:\ProgramData\ssh\sshd_config" + if (Test-Path $config) { + $text = Get-Content -Raw $config + $text = [regex]::Replace($text, '(?m)^\s*#?\s*PasswordAuthentication\s+.*$', 'PasswordAuthentication no') + $text = [regex]::Replace($text, '(?m)^\s*#?\s*PubkeyAuthentication\s+.*$', 'PubkeyAuthentication yes') + Set-Content -Encoding ascii -Path $config -Value $text + } + Set-Service sshd -StartupType Automatic + Start-Service sshd + # Add-WindowsCapability already creates OpenSSH-Server-In-TCP, but scoped to + # the Private profile only. A freshly bridged VM is categorized Public, so a + # bare existence check short-circuits and leaves port 22 unreachable even + # though sshd reports Running. Ensure the rule exists AND covers every profile. + if (Get-NetFirewallRule -Name OpenSSH-Server-In-TCP -ErrorAction SilentlyContinue) { + Set-NetFirewallRule -Name OpenSSH-Server-In-TCP -Enabled True -Profile Any + } else { + New-NetFirewallRule -Name OpenSSH-Server-In-TCP -DisplayName "OpenSSH Server (sshd)" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 -Profile Any | Out-Null + } + Set-Service QEMU-GA -StartupType Automatic + Start-Service QEMU-GA + powercfg.exe /change standby-timeout-ac 0 | Out-Null + powercfg.exe /change hibernate-timeout-ac 0 | Out-Null + $record = [ordered]@{ ready = $true; computer = $env:COMPUTERNAME; build = [Environment]::OSVersion.Version.ToString(); at = (Get-Date).ToUniversalTime().ToString("o") } + Set-Content -Encoding utf8 -Path (Join-Path $root "ready.json") -Value ($record | ConvertTo-Json -Compress) +} finally { + Stop-Transcript +}