# 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, # Unified Write Filter: protect a volume by redirecting its writes to a # disk-backed overlay held on another one, e.g. # { protectedVolume = "C:"; swapfileVolume = "D:"; overlaySizeMB = 8192; } writeFilter ? 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; uwfProtected = if writeFilter != null then (writeFilter.protectedVolume or "C:") else "C:"; uwfSwapVolume = if writeFilter != null then (writeFilter.swapfileVolume or "D:") else "D:"; uwfOverlaySizeMB = if writeFilter != null then (writeFilter.overlaySizeMB or 8192) else 8192; # Runs from RunOnce on the target's first boot rather than during the build, # for two reasons: enabling the DISM feature needs a reboot before uwfmgr # exists at all, and the overlay swapfile has to be created on the real data # volume rather than on the build's throwaway copy of it. # # Order is forced by uwfmgr: create-swapfile is only accepted while the # filter is off and the overlay is already in disk mode. The default disk # overlay would otherwise sit at C:\uwfswap.sys, on the volume being # protected. Enabling the filter itself only takes effect after a restart. uwfConfigScript = pkgs.writeText "vmix-uwf-config.cmd" '' @echo off uwfmgr.exe overlay set-type disk uwfmgr.exe overlay set-size ${toString uwfOverlaySizeMB} uwfmgr.exe volume create-swapfile ${uwfSwapVolume} uwfmgr.exe volume protect ${uwfProtected} uwfmgr.exe filter enable del /q C:\vmix-uwf-config.cmd 2>nul shutdown /r /t 10 /c "vmix: activating the write filter" ''; 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) '' ${profilesDirectory} ''; dataDiskXml = lib.optionalString (dataDisk != null) '' 1 cmd /c C:\vmix-init-data-disk.cmd vmix: initialize the data disk ''; # 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}" ''} ${lib.optionalString (writeFilter != null) '' :: Install the feature now, but defer configuring it: uwfmgr does not exist :: until this has been through a reboot, and the swapfile belongs on the :: real data volume, so RunOnce picks it up on the target's first boot. dism /online /enable-feature /featurename:Client-UnifiedWriteFilter /all /norestart reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" /v "vmixUwf" /t REG_SZ /d "C:\vmix-uwf-config.cmd" /f ''} :: 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" '' true Automatic ${folderLocationsXml} ${dataDiskXml} ${locale} ${locale} ${locale} ${locale} true true true true Work true true 3 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"; } ++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.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