Files
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

164 lines
6.3 KiB
PowerShell

param(
[switch]$EnableAutomaticLogon,
[switch]$DisableAutoUpdate,
[switch]$CopyInstallers,
[switch]$InstallOpenSSH,
[switch]$NoPause
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Test-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
if (-not (Test-Administrator)) {
if (-not $PSCommandPath) {
throw 'Run this script from a PowerShell window opened as Administrator.'
}
$arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"")
foreach ($name in $PSBoundParameters.Keys) {
$arguments += "-$name"
}
$elevated = Start-Process `
-FilePath 'powershell.exe' `
-ArgumentList $arguments `
-Verb RunAs `
-Wait `
-PassThru
exit $elevated.ExitCode
}
function Enable-AutomaticLogon {
$winlogonPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
Set-ItemProperty -Path $winlogonPath -Name 'AutoAdminLogon' -Value '1' -Type String
Set-ItemProperty -Path $winlogonPath -Name 'DefaultUserName' -Value 'guofang' -Type String
Set-ItemProperty -Path $winlogonPath -Name 'DefaultPassword' -Value 'wang' -Type String
Set-ItemProperty -Path $winlogonPath -Name 'DefaultDomainName' -Value $env:COMPUTERNAME -Type String
Remove-ItemProperty -Path $winlogonPath -Name 'AutoLogonCount' -ErrorAction SilentlyContinue
}
function Disable-AutomaticUpdate {
$windowsUpdatePath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
$autoUpdatePath = Join-Path $windowsUpdatePath 'AU'
$storePath = 'HKLM:\SOFTWARE\Policies\Microsoft\WindowsStore'
$currentVersion = Get-ItemProperty `
-Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
$releaseVersion = $currentVersion.DisplayVersion
New-Item -Path $autoUpdatePath -Force | Out-Null
New-Item -Path $storePath -Force | Out-Null
# Never check automatically; manual checks and installs keep working.
Set-ItemProperty -Path $autoUpdatePath -Name 'NoAutoUpdate' -Value 1 -Type DWord
Set-ItemProperty -Path $autoUpdatePath -Name 'NoAutoRebootWithLoggedOnUsers' -Value 1 -Type DWord
Set-ItemProperty -Path $windowsUpdatePath -Name 'ExcludeWUDriversInQualityUpdate' -Value 1 -Type DWord
# Stay on the installed feature release instead of upgrading automatically.
if ($releaseVersion) {
Set-ItemProperty -Path $windowsUpdatePath -Name 'TargetReleaseVersion' -Value 1 -Type DWord
Set-ItemProperty -Path $windowsUpdatePath -Name 'TargetReleaseVersionInfo' -Value $releaseVersion -Type String
}
# 2 = never download Microsoft Store app updates automatically.
Set-ItemProperty -Path $storePath -Name 'AutoDownload' -Value 2 -Type DWord
# Windows may re-enable this task, so the policies above are the real switch.
Disable-ScheduledTask `
-TaskPath '\Microsoft\Windows\UpdateOrchestrator\' `
-TaskName 'Schedule Scan' `
-ErrorAction SilentlyContinue | Out-Null
}
function Copy-InstallerScripts {
# Numbered scripts (NN-*.bat / NN-*.ps1: installers and tools) go to Desktop\Tools.
$scripts = @(Get-ChildItem -Path $PSScriptRoot -File |
Where-Object { $_.Name -match '^\d\d-.*\.(bat|ps1)$' })
if ($scripts.Count -eq 0) {
throw "No installer batch files were found next to $PSCommandPath."
}
$target = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Tools'
New-Item -ItemType Directory -Path $target -Force | Out-Null
Copy-Item -Path $scripts.FullName -Destination $target -Force
}
# Installs the OpenSSH Server from the Win32-OpenSSH zip on the answer media,
# so no Windows Update download is needed.
function Install-OpenSSHServer {
$zip = Get-ChildItem -Path $PSScriptRoot -Filter 'OpenSSH-*.zip' -File |
Select-Object -First 1
if (-not $zip) {
throw "No OpenSSH-*.zip was found next to $PSCommandPath."
}
$installDir = Join-Path $env:ProgramFiles 'OpenSSH'
if (-not (Test-Path -LiteralPath (Join-Path $installDir 'install-sshd.ps1'))) {
$temp = Join-Path $env:TEMP 'openssh'
Remove-Item $temp -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -LiteralPath $zip.FullName -DestinationPath $temp
Remove-Item $installDir -Recurse -Force -ErrorAction SilentlyContinue
Move-Item (Get-ChildItem $temp -Directory | Select-Object -First 1).FullName $installDir
Remove-Item $temp -Recurse -Force
}
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $installDir 'install-sshd.ps1') | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "install-sshd.ps1 failed (exit code $LASTEXITCODE)."
}
Set-Service -Name sshd -StartupType Automatic
Start-Service -Name sshd
if (-not (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' `
-Direction Inbound -Action Allow -Protocol TCP -LocalPort 22 -Profile Private, Public | Out-Null
}
# build-seed.sh puts the host's public key on the media; without it, only
# password login works.
$key = Join-Path $PSScriptRoot 'authorized_keys'
if (-not (Test-Path -LiteralPath $key)) {
Write-Warning "No authorized_keys was found next to $PSCommandPath; skipping the administrator key."
return
}
$keyFile = Join-Path $env:ProgramData 'ssh\administrators_authorized_keys'
Copy-Item -LiteralPath $key -Destination $keyFile -Force
& icacls.exe $keyFile /inheritance:r /grant '*S-1-5-32-544:F' /grant '*S-1-5-18:F' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed (exit code $LASTEXITCODE)."
}
}
$exitCode = 0
try {
if ($EnableAutomaticLogon) {
Enable-AutomaticLogon
}
if ($DisableAutoUpdate) {
Disable-AutomaticUpdate
}
if ($CopyInstallers) {
Copy-InstallerScripts
}
if ($InstallOpenSSH) {
Install-OpenSSHServer
}
Write-Host 'Done.' -ForegroundColor Green
}
catch {
Write-Host "Failed: $($_.Exception.Message)" -ForegroundColor Red
$exitCode = 1
}
if (-not $NoPause) {
Read-Host 'Press Enter to exit' | Out-Null
}
exit $exitCode