vmix.nix/lib/images/windows/templates/generalize.nix
Git Sagar c213fc4db9 windows/pci: vIOMMU for nested passthrough, and a data disk built with the image
Three things, all in service of putting Proxmox in a VM that can still hand a
GPU to its own guests, and of a Windows VM whose profile survives its OS disk.

pci.viommu.enable emits `-device intel-iommu,intremap=on,caching-mode=on` and
forces kernel-irqchip=split, which interrupt remapping requires. Without an
IOMMU of its own a guest cannot bind a passed-through device to vfio-pci, so
it can never forward one on. The device leads the command line because QEMU
realizes devices in order and intel-iommu must precede what it translates.

pci.vgaPassthrough (default true, so nothing changes for existing VMs) makes
x-vga=on optional. It was forced on the first passthrough device, which is
wrong for a card the guest only forwards onward: it claims the VGA path the
emulated console adapter needs.

customizeImage gains extraDisk, a blank disk attached for the Audit Mode boot
and emitted as the derivation's `data` output. generalize uses it for dataDisk
and profilesDirectory, so the disk is partitioned and the profile relocated
under OOBE in the build VM. That is what removes the need for delayOobeRun --
previously the volume ProfilesDirectory names could not exist until the image
reached real hardware. A second output rather than a directory keeps ${image}
meaning the OS qcow2 for every existing consumer.

generalize also picks up staticIP and profilesDirectory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g
2026-09-09 22:27:44 -03:00

287 lines
15 KiB
Nix

# Generalize image via sysprep + OOBE in two phases.
# Phase 1 (sysprep): runs sysprep /generalize /oobe /shutdown in Audit Mode
# Phase 2 (oobe): boots through OOBE, creates user, activates Windows, shuts down
# Between phases, NTUSER.DAT can be modified offline.
# Usage: (templates.generalize { username = "User"; password = ""; })
{ pkgs, lib, makeFilesISO, ... }:
let
masScript = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/massgravel/Microsoft-Activation-Scripts/166814e52d10204aaa5c3c7db03a3dae9d866509/MAS/All-In-One-Version-KL/MAS_AIO.cmd";
hash = "sha256-2UsavLok0mxfvhFKFbU6VYaE10oazP95u7JAe+cQKok=";
};
in
{
username ? "User",
password ? "",
autoLogon ? true,
hostname ? "WIN-VM",
locale ? "en-US",
timezone ? "UTC",
# Desktop background solid color as hex string (e.g. "8e8cd8")
bgColor ? null,
# Enable Remote Desktop for the created user (re-applied after sysprep)
enableRDP ? false,
# NIC model for the build VM (e.g. "e1000" for images without VirtIO drivers)
nicModel ? null,
# Static IPv4 for the guest's single NIC, applied from inside Windows:
# { address = "10.10.10.26"; prefixLength = 24; gateway = "10.10.10.1";
# dns = [ "10.10.10.1" ]; }
staticIP ? null,
# Relocate user profiles, e.g. "D:\\Users". Needs the volume to exist by the
# time specialize runs, which is what dataDisk arranges.
profilesDirectory ? null,
# Partition the non-OS disk and relocate user profiles onto it, e.g.
# { driveLetter = "D"; label = "data"; size = "100G"; }. The disk is attached
# during the build (see extraDisk in the returned set), so this is done and
# verified before the image ever reaches a host.
dataDisk ? null,
# delayOobeRun = true: sysprep only, OOBE + activation on real hardware
# delayOobeRun = false: sysprep + OOBE + activation in build VM
delayOobeRun ? false,
}: let
# Convert "8e8cd8" hex to "142 140 216" decimal RGB for Windows registry
hexToRgbStr = hex: let
hexChars = lib.stringToCharacters hex;
hexToDec = h: let
c = lib.toLower h;
m = { "0"=0; "1"=1; "2"=2; "3"=3; "4"=4; "5"=5; "6"=6; "7"=7; "8"=8; "9"=9; "a"=10; "b"=11; "c"=12; "d"=13; "e"=14; "f"=15; };
in m.${c};
r = hexToDec (builtins.elemAt hexChars 0) * 16 + hexToDec (builtins.elemAt hexChars 1);
g = hexToDec (builtins.elemAt hexChars 2) * 16 + hexToDec (builtins.elemAt hexChars 3);
b = hexToDec (builtins.elemAt hexChars 4) * 16 + hexToDec (builtins.elemAt hexChars 5);
in "${toString r} ${toString g} ${toString b}";
stripHash = s: lib.removePrefix "#" s;
bgRgb = if bgColor != null then hexToRgbStr (stripHash bgColor) else null;
staticDnsList = lib.optionalString (staticIP != null)
(lib.concatMapStringsSep "," (s: "'${s}'") staticIP.dns);
dataDriveLetter = if dataDisk != null then (dataDisk.driveLetter or "D") else "D";
dataLabel = if dataDisk != null then (dataDisk.label or "data") else "data";
# ProfilesDirectory is only honoured when the volume it names already exists,
# and a freshly created zvol arrives RAW. Initializing the disk here, in the
# same specialize pass, brings it up before oobeSystem creates any profile.
#
# Idempotent, because specialize runs again on every sysprep: a RAW disk gets
# a GPT label, one full-size NTFS partition and the drive letter, while a disk
# that already holds data keeps it and only has its letter re-asserted. The
# OS disk is added to QEMU first and so is always disk 0.
initDataDiskScript = pkgs.writeText "vmix-init-data-disk.cmd" ''
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $d = Get-Disk | Where-Object Number -ne 0 | Sort-Object Number | Select-Object -First 1; if (-not $d) { exit 0 }; if ($d.PartitionStyle -eq 'RAW') { Initialize-Disk -Number $d.Number -PartitionStyle GPT -Confirm:$false; $p = New-Partition -DiskNumber $d.Number -UseMaximumSize -DriveLetter ${dataDriveLetter}; Format-Volume -Partition $p -FileSystem NTFS -NewFileSystemLabel '${dataLabel}' -Confirm:$false | Out-Null } else { $p = Get-Partition -DiskNumber $d.Number | Sort-Object Size -Descending | Select-Object -First 1; if ($p -and $p.DriveLetter -ne '${dataDriveLetter}') { Set-Partition -InputObject $p -NewDriveLetter ${dataDriveLetter} } }"
'';
folderLocationsXml = lib.optionalString (profilesDirectory != null) ''
<!-- Profiles live on the data disk, so the OS disk stays disposable
and rebuilding it does not take the profile along -->
<FolderLocations>
<ProfilesDirectory>${profilesDirectory}</ProfilesDirectory>
</FolderLocations>'';
dataDiskXml = lib.optionalString (dataDisk != null) ''
<!-- Runs during specialize, before the first profile is created -->
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>cmd /c C:\vmix-init-data-disk.cmd</Path>
<Description>vmix: initialize the data disk</Description>
</RunSynchronousCommand>
</RunSynchronous>
</component>'';
# Post-OOBE script: runs as the created user via FirstLogonCommands.
postOobeScript = pkgs.writeText "post-oobe.cmd" ''
@echo off
${lib.optionalString (!autoLogon) ''
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoAdminLogon /f 2>nul
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultUserName /f 2>nul
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultPassword /f 2>nul
''}
${lib.optionalString (bgColor != null) ''
:: Set solid background color
reg add "HKCU\Control Panel\Desktop" /v WallPaper /t REG_SZ /d "" /f
reg add "HKCU\Control Panel\Colors" /v Background /t REG_SZ /d "${bgRgb}" /f
reg add "HKCU\Control Panel\Desktop" /v WallpaperStyle /t REG_SZ /d "0" /f
''}
${lib.optionalString (password != "") ''
:: Set user password (OOBE creates with blank password for reliable AutoLogon)
net user "${username}" "${password}"
''}
:: Set AutoLogon via registry (OOBE unattend AutoLogon is unreliable)
${lib.optionalString autoLogon ''
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoAdminLogon /t REG_SZ /d "1" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultUserName /t REG_SZ /d "${username}" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultPassword /t REG_SZ /d "${password}" /f
''}
:: Kill sysprep if it was triggered via CopyProfile'd startup entries
taskkill /f /im sysprep.exe 2>nul
:: Clean any leftover RunOnce/Run entries from audit phase
reg delete "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" /v "vmixAudit" /f 2>nul
reg delete "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v "vmixAudit" /f 2>nul
:: Remove Edge AppxPackage for current user (runs in user context during OOBE)
:: The app is already removed on one of the templates but a ghost appx entry remains that can only be deleted at the user level
powershell -Command "Get-AppxPackage *MicrosoftEdge* | Remove-AppxPackage -ErrorAction SilentlyContinue"
powershell -Command "Get-AppxPackage *MicrosoftEdgeDevToolsClient* | Remove-AppxPackage -ErrorAction SilentlyContinue"
:: Re-install product key and licenses to restore activation IDs after sysprep
cscript //nologo C:\Windows\System32\slmgr.vbs /ipk M7XTQ-FN8P6-TTKYV-9D4CC-J462D
cscript //nologo C:\Windows\System32\slmgr.vbs /rilc
:: Restart SPP service and wait for it to settle
net stop sppsvc /y 2>nul
net start sppsvc
ping -n 10 127.0.0.1 >nul
:: Activate Windows using TSforge
if exist C:\MAS_AIO.cmd (
echo. | call C:\MAS_AIO.cmd /Z-Windows
)
:: Activate Office using Ohook method (if Office is installed)
if exist "C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE" (
if exist C:\MAS_AIO.cmd (
echo. | call C:\MAS_AIO.cmd /Ohook
)
)
del /q C:\MAS_AIO.cmd 2>nul
${lib.optionalString enableRDP ''
:: Enable RDP
powershell -Command "Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections -Value 0"
powershell -Command "Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name UserAuthentication -Value 1"
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Lsa" /v LimitBlankPasswordUse /t REG_DWORD /d 0 /f
:: Create firewall rules for all profiles (New-NetFirewallRule is more reliable than Enable-NetFirewallRule)
powershell -Command "New-NetFirewallRule -DisplayName 'RDP (TCP)' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3389 -RemoteAddress Any -Profile Any -Enabled True | Out-Null"
powershell -Command "New-NetFirewallRule -DisplayName 'RDP (UDP)' -Direction Inbound -Action Allow -Protocol UDP -LocalPort 3389 -RemoteAddress Any -Profile Any -Enabled True | Out-Null"
:: Set all RDP services to auto-start
reg add "HKLM\SYSTEM\CurrentControlSet\Services\SessionEnv" /v Start /t REG_DWORD /d 2 /f
reg add "HKLM\SYSTEM\CurrentControlSet\Services\UmRdpService" /v Start /t REG_DWORD /d 2 /f
reg add "HKLM\SYSTEM\CurrentControlSet\Services\TermService" /v Start /t REG_DWORD /d 2 /f
''}
${lib.optionalString (staticIP != null) ''
:: This VM's only NIC sits on a macvtap bridged to the host's LAN, so its
:: address is a LAN address that nothing hands out -- the guest asserts it.
:: Clearing first makes the command idempotent across re-runs.
powershell -NoProfile -Command "$a = Get-NetAdapter -Physical | Sort-Object ifIndex | Select-Object -First 1; Remove-NetIPAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; Remove-NetRoute -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; New-NetIPAddress -InterfaceIndex $a.ifIndex -IPAddress '${staticIP.address}' -PrefixLength ${toString staticIP.prefixLength} -DefaultGateway '${staticIP.gateway}' | Out-Null; Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ${staticDnsList}"
''}
:: Clean up
del /q C:\oobe-unattend.xml 2>nul
del /q C:\vmix-audit-script.cmd 2>nul
del /q C:\vmix-audit-wrapper.cmd 2>nul
${if delayOobeRun then "" else "shutdown /s /t 5 /c \"vmix generalize complete\""}
del /q C:\post-oobe.cmd 2>nul
'';
oobeXml = pkgs.writeText "oobe-unattend.xml" ''
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<!-- Copy Administrator profile to default (preserves Audit Mode customizations) -->
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<CopyProfile>true</CopyProfile>
<Themes>
<WindowColor>Automatic</WindowColor>
</Themes>
${folderLocationsXml}
</component>
${dataDiskXml}
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<InputLocale>${locale}</InputLocale>
<SystemLocale>${locale}</SystemLocale>
<UILanguage>${locale}</UILanguage>
<UserLocale>${locale}</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Password>
<Value></Value>
<PlainText>true</PlainText>
</Password>
<Group>Administrators</Group>
<Name>${username}</Name>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Password>
<Value></Value>
<PlainText>true</PlainText>
</Password>
<Enabled>true</Enabled>
<LogonCount>999</LogonCount>
<Username>${username}</Username>
</AutoLogon>
<ComputerName>${hostname}</ComputerName>
<TimeZone>${timezone}</TimeZone>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>C:\post-oobe.cmd</CommandLine>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
'';
in {
name = if delayOobeRun then "generalize-delay-oobe" else "generalize";
inherit nicModel;
# The blank disk is attached for the Audit Mode boot itself, so the disk-init
# command and the profile relocation both happen under OOBE in the build VM.
# That is what makes delayOobeRun unnecessary: nothing is left to do on real
# hardware. The written disk comes back as this derivation's `data` output.
extraDisk = if dataDisk != null then { size = dataDisk.size or "100G"; } else null;
uploads = [
{ source = oobeXml; dest = "/oobe-unattend.xml"; }
{ source = postOobeScript; dest = "/post-oobe.cmd"; }
{ source = masScript; dest = "/MAS_AIO.cmd"; }
] ++ lib.optional (dataDisk != null) { source = initDataDiskScript; dest = "/vmix-init-data-disk.cmd"; };
# delayOobeRun: sysprep + shutdown — OOBE runs on real hardware
# generalize: sysprep + reboot into OOBE in the same QEMU session
auditScript = ''
@echo off
:: Remove cached Autounattend from initial install (contains Audit Mode reseal)
del /q C:\Windows\Panther\unattend.xml 2>nul
del /q C:\Windows\Panther\Unattend\unattend.xml 2>nul
del /q C:\Windows\System32\Sysprep\Panther\unattend.xml 2>nul
C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe ${if delayOobeRun then "/shutdown" else "/reboot"} /quiet /unattend:C:\oobe-unattend.xml
'';
}
# :: Enable RDP (sysprep resets offline registry changes)
# reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server" /v fDenyTSConnections /t REG_DWORD /d 0 /f
# reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" /v UserAuthentication /t REG_DWORD /d 0 /f
# netsh advfirewall firewall add rule name="RDP" dir=in protocol=tcp localport=3389 action=allow
# :: Start and enable the RDP service
# sc config TermService start= auto
# net start TermService