# 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, # configMedium = true: this is a generic sealed base whose per-VM data # (hostname, static IP, timezone, desktop tint) is NOT baked. The target's # first boot reads it off a small removable config CD (see makeConfigMedium) # via baked finder/apply scripts. Implies the OOBE is deferred to the target, # so it is only meaningful together with delayOobeRun = true. Lets one sealed # store path be shared by every VM built from it. configMedium ? false, # Skip sysprep's SID reset (use /oobe without /generalize), so every rebuild # of the layers above the cached base install carries the same machine SID -- # and therefore the same account SID. A profile kept on a persistent disk then # matches on file ACLs, its NTUSER.DAT hive, and ProfileList across rebuilds, # with no ownership fixups. As a side effect MountedDevices survives too, so # the data disk keeps its drive letter without a boot-time reassign. # # Correct only for an image that is always this one machine; a fleet that # deploys the same image to many hosts wants the default generalization. keepMachineSid ? false, # Known-Folder redirection: keep the SID-bound profile on C: (so /generalize # can still randomize the SID per machine) but point the user's data folders # at the persistent data disk, so files -- not per-user registry settings -- # survive an OS rebuild. e.g. { base = "D:\\UserData"; folders = [ "Desktop" # "Documents" "Downloads" ]; }. Needs dataDisk to provide the volume, and is # mutually exclusive with profilesDirectory. folderRedirect ? null, }: 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); # Its own file rather than inline in post-oobe.cmd: the command is long, and # cmd's handling of quotes and pipes inside it is a needless hazard. # # Two things this has to get right. The adapter may not be up yet when # FirstLogonCommands runs, so it is waited for rather than assumed. And the # interface arrives DHCP-managed -- assigning an address without turning DHCP # off first does not stick, which is how a VM meant to be at a fixed address # ended up holding a lease instead. # PowerShell in its own file: it grew a wait loop and a retry, which are no # fun to keep correct inside a cmd one-liner. # # Two things it must survive. DHCP is turned off before the address is set, # so any failure to set it strands the box with no address at all -- which is # exactly what happened after an internal reboot, where a stale ARP entry for # the address from the previous instance tripped duplicate-address detection # and New-NetIPAddress threw. DadTransmits 0 turns that detection off so the # static always binds, and the assignment is retried rather than fatal. # Two shapes. Baked: the address is a build-time literal. configMedium: the # address is read from C:\vmix-config.ps1 (dot-sourced), which the finder # dropped there off the config CD -- so the same sealed script serves every # VM. The wait/retry logic is identical either way. staticIPAssign = if configMedium then { addr = "$VmixIpAddress"; prefix = "$VmixPrefixLength"; gw = "$VmixGateway"; dns = "$VmixDns"; } else { addr = "'${staticIP.address}'"; prefix = toString staticIP.prefixLength; gw = "'${staticIP.gateway}'"; dns = staticDnsList; }; staticIPScriptPs1 = pkgs.writeText "vmix-static-ip.ps1" '' ${lib.optionalString configMedium '' if (-not (Test-Path C:\vmix-config.ps1)) { Write-Output 'vmix: no config yet'; exit 0 } . C:\vmix-config.ps1 if (-not $VmixIpAddress) { Write-Output 'vmix: no static address in config'; exit 0 } ''} $a = $null for ($n = 0; $n -lt 30; $n++) { $a = Get-NetAdapter -Physical | Where-Object Status -eq 'Up' | Sort-Object ifIndex | Select-Object -First 1 if ($a) { break } Start-Sleep -Seconds 2 } if (-not $a) { Write-Output 'vmix: no adapter came up'; exit 1 } $i = $a.ifIndex Set-NetIPInterface -InterfaceIndex $i -Dhcp Disabled -DadTransmits 0 -ErrorAction SilentlyContinue Remove-NetIPAddress -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue Remove-NetRoute -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue $ok = $false for ($k = 0; $k -lt 5 -and -not $ok; $k++) { try { New-NetIPAddress -InterfaceIndex $i -IPAddress ${staticIPAssign.addr} -PrefixLength ${staticIPAssign.prefix} -DefaultGateway ${staticIPAssign.gw} -ErrorAction Stop | Out-Null $ok = $true } catch { Write-Output ('vmix: assign attempt ' + $k + ' failed: ' + $_.Exception.Message) Start-Sleep -Seconds 2 Remove-NetIPAddress -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue } } if (-not $ok) { Write-Output 'vmix: could not set static address'; exit 1 } Set-DnsClientServerAddress -InterfaceIndex $i -ServerAddresses ${staticIPAssign.dns} New-NetFirewallRule -DisplayName 'ICMPv4 Echo' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -Enabled True -ErrorAction SilentlyContinue | Out-Null Write-Output ('vmix: set ' + ${staticIPAssign.addr} + ' on ifIndex ' + $i) ''; # Finder: the config CD's drive letter is unknown, so scan for the marker file # and stage it on C: where the baked scripts expect it. Runs on the target's # first boot (post-oobe), before the per-boot static-IP task needs it. loadConfigScript = pkgs.writeText "vmix-load-config.cmd" '' @echo off for %%D in (E F G H I J K L M N O P Q R S T U V W X Y Z D) do ( if exist %%D:\vmix-config.ps1 ( copy /y %%D:\vmix-config.ps1 C:\vmix-config.ps1 >nul goto :done ) ) :done ''; # Applies the per-VM config that is not an answer-file field: timezone, the # desktop tint (per user, so run under the created account in post-oobe), and # the machine rename. Rename is pending until the post-oobe reboot. applyConfigScript = pkgs.writeText "vmix-apply-config.ps1" '' if (-not (Test-Path C:\vmix-config.ps1)) { exit 0 } . C:\vmix-config.ps1 if ($VmixTimeZone) { & tzutil /s "$VmixTimeZone" } if ($VmixBgColor) { $hex = ([string]$VmixBgColor).TrimStart('#') $r = [Convert]::ToInt32($hex.Substring(0,2),16) $g = [Convert]::ToInt32($hex.Substring(2,2),16) $b = [Convert]::ToInt32($hex.Substring(4,2),16) Set-ItemProperty 'HKCU:\Control Panel\Colors' -Name Background -Value "$r $g $b" Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name WallPaper -Value "" Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name WallpaperStyle -Value '0' } if ($VmixHostname -and $env:COMPUTERNAME -ne $VmixHostname) { Rename-Computer -NewName $VmixHostname -Force -ErrorAction SilentlyContinue } ''; # Creates the real per-VM account from the config and hands the machine over # to it. The sealed image bakes only a generic bootstrap account (${username}) # -- enough to carry OOBE to a logon so this can run -- and the real account # is made here, on the target, from the CD. Autologon is switched to it and a # one-shot cleanup is armed; the post-oobe reboot then lets the real account # log in and build its own SID-bound profile on D:\Users\, after # which the bootstrap is retired. So the account, like the SID, is per-VM and # nothing about it is shared or baked. Runs as the bootstrap user in post-oobe. createUserScript = pkgs.writeText "vmix-create-user.ps1" '' if (-not (Test-Path C:\vmix-config.ps1)) { exit 0 } . C:\vmix-config.ps1 if (-not $VmixUsername) { exit 0 } if ($VmixUsername -ieq '${username}') { exit 0 } & net user $VmixUsername $VmixPassword /add & net localgroup Administrators $VmixUsername /add $w = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' Set-ItemProperty $w -Name AutoAdminLogon -Value '1' Set-ItemProperty $w -Name DefaultUserName -Value $VmixUsername Set-ItemProperty $w -Name DefaultPassword -Value $VmixPassword Remove-ItemProperty $w -Name DefaultDomainName -ErrorAction SilentlyContinue # Fires at the real account's first logon (HKLM RunOnce = next user to log # on), i.e. after the reboot below, once the bootstrap is no longer in use. Set-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce' ` -Name vmixUserCleanup -Value 'cmd /c C:\vmix-user-cleanup.cmd' -Type String ''; # Runs once as the real account (RunOnce, after the hand-over reboot): retire # the bootstrap account and its profile, and apply the per-user desktop tint # (which the bootstrap ran against on the first boot, before this account # existed). Bootstrap is idle here, so its profile is safe to remove. userCleanupScript = pkgs.writeText "vmix-user-cleanup.cmd" '' @echo off powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_UserProfile | Where-Object { $_.LocalPath -like '*\${username}' } | Remove-CimInstance -ErrorAction SilentlyContinue" net user ${username} /delete >nul 2>&1 powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-apply-config.ps1 >nul 2>&1 del /q C:\vmix-user-cleanup.cmd 2>nul ''; # Thin launcher, so the scheduled task has a cmd to point at. staticIPScript = pkgs.writeText "vmix-static-ip.cmd" '' @echo off powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-static-ip.ps1 > C:\Windows\Temp\vmix-static-ip.log 2>&1 ''; 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" (if configMedium then '' @echo off :: Sealed-image variant. Two extra hazards over the baked path: :: :: 1. The per-VM config rides an optical drive, and on the target's first :: boot the raw data disk has no volume yet -- so Windows letters the CD :: as ${dataDriveLetter}:, exactly where the profile volume must go. The :: plain `if exist ${dataDriveLetter}:\` guard would then see the CD and :: skip, leaving ProfilesDirectory pointed at read-only media. So a first :: boot is tracked by a marker, not by the letter, and any occupant of :: ${dataDriveLetter}: is moved aside before the data disk claims it. :: 2. Left to Shell-Setup's FolderLocations, ProfilesDirectory can be :: evaluated before this disk exists (unordered within specialize) and :: silently fall back to C:. Setting it here, in the same step that just :: created the volume, removes that race. if exist C:\vmix-data-initialized goto :ensure :: First boot: park whatever holds ${dataDriveLetter}: (the config CD) on Y: :: so the data disk can take the letter. Harmless if the letter is free. > C:\Windows\Temp\vmix-cd.txt echo select volume ${dataDriveLetter} >> C:\Windows\Temp\vmix-cd.txt echo assign letter=Y noerr diskpart /s C:\Windows\Temp\vmix-cd.txt > nul 2>&1 :: Lay disk 1 (the host zvol) out from scratch and give it the letter. > C:\Windows\Temp\vmix-dd-init.txt echo select disk 1 >> C:\Windows\Temp\vmix-dd-init.txt echo clean >> C:\Windows\Temp\vmix-dd-init.txt echo convert gpt >> C:\Windows\Temp\vmix-dd-init.txt echo create partition primary >> C:\Windows\Temp\vmix-dd-init.txt echo format fs=ntfs quick label="${dataLabel}" >> C:\Windows\Temp\vmix-dd-init.txt echo assign letter=${dataDriveLetter} diskpart /s C:\Windows\Temp\vmix-dd-init.txt echo initialized > C:\vmix-data-initialized goto :ensure :ensure :: The letter normally persists via MountedDevices; re-assert if it is gone. if exist ${dataDriveLetter}:\ goto :profiledir > C:\Windows\Temp\vmix-dd-assign.txt echo select disk 1 >> C:\Windows\Temp\vmix-dd-assign.txt echo select partition 1 >> C:\Windows\Temp\vmix-dd-assign.txt echo assign letter=${dataDriveLetter} diskpart /s C:\Windows\Temp\vmix-dd-assign.txt > nul 2>&1 :profiledir ${lib.optionalString (profilesDirectory != null) '' :: Point new profiles at the data volume, now that it exists. REG_EXPAND_SZ :: to match Windows' own ProfilesDirectory type. if exist ${dataDriveLetter}:\ ( if not exist "${profilesDirectory}" mkdir "${profilesDirectory}" reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" /v ProfilesDirectory /t REG_EXPAND_SZ /d "${profilesDirectory}" /f > nul 2>&1 )''} del /q C:\Windows\Temp\vmix-cd.txt C:\Windows\Temp\vmix-dd-init.txt C:\Windows\Temp\vmix-dd-assign.txt 2>nul :done '' else '' @echo off :: diskpart rather than the Storage cmdlets. New-Partition and :: Format-Volume need services that are not up yet this early in :: specialize, so they fail where Initialize-Disk succeeds -- which left :: the disk carrying a GPT header and nothing else, and ProfilesDirectory :: pointing at a volume that never existed. if exist ${dataDriveLetter}:\ goto :done :: The volume may already be laid out and merely unlettered, in which case :: assigning is enough and cleaning would destroy the profile. > C:\Windows\Temp\vmix-dd-assign.txt echo select disk 1 >> C:\Windows\Temp\vmix-dd-assign.txt echo select partition 1 >> C:\Windows\Temp\vmix-dd-assign.txt echo assign letter=${dataDriveLetter} diskpart /s C:\Windows\Temp\vmix-dd-assign.txt > nul 2>&1 if exist ${dataDriveLetter}:\ goto :cleanup :: Nothing there to keep, so lay the disk out from scratch. > C:\Windows\Temp\vmix-dd-init.txt echo select disk 1 >> C:\Windows\Temp\vmix-dd-init.txt echo clean >> C:\Windows\Temp\vmix-dd-init.txt echo convert gpt >> C:\Windows\Temp\vmix-dd-init.txt echo create partition primary >> C:\Windows\Temp\vmix-dd-init.txt echo format fs=ntfs quick label="${dataLabel}" >> C:\Windows\Temp\vmix-dd-init.txt echo assign letter=${dataDriveLetter} diskpart /s C:\Windows\Temp\vmix-dd-init.txt :cleanup del /q C:\Windows\Temp\vmix-dd-assign.txt C:\Windows\Temp\vmix-dd-init.txt 2>nul :done ''); # PowerShell in its own file, so neither cmd quoting nor Nix's '' need to be # fought. If the account's real profile got backed up to a .bak key (the # temporary-profile fallback), put it back: drop the temp key, rename .bak to # the live SID, remove the temp directory, and drop a flag so the caller # knows to reboot. # Known-Folder GUIDs for the redirectable user folders. knownFolderGuids = { Desktop = "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}"; Documents = "{FDD39AD0-238F-46AF-ADB4-6C85480369C7}"; Downloads = "{374DE290-123F-4565-9164-39C4925E467B}"; Pictures = "{33E28130-4E1E-4676-835A-98395C3BC476}"; Music = "{4BD8D571-6D19-48D3-BE97-422220080E43}"; Videos = "{18989B1D-99B5-455B-841C-AB7C74E4DDFC}"; Favorites = "{1777F761-68AD-4D8A-87BD-30B759FA33DD}"; }; redirectFolders = if folderRedirect != null then (folderRedirect.folders or [ "Desktop" "Documents" "Downloads" "Pictures" "Music" "Videos" ]) else [ ]; redirectBase = if folderRedirect != null then (folderRedirect.base or "D:\\UserData") else "D:\\UserData"; # Per-user, run once per profile via Active Setup: point each known folder at # its directory under the data volume. SHSetKnownFolderPath updates both the # registration and the shell-folder registry; it does not move files, so a # freshly created profile's empty C: folder is simply repointed at the D: one, # which already holds this user's accumulated files after a rebuild. folderRedirectPs1 = pkgs.writeText "vmix-redirect-folders.ps1" (lib.optionalString (folderRedirect != null) '' $sig = @' [DllImport("shell32.dll", CharSet=CharSet.Unicode)] public static extern int SHSetKnownFolderPath(ref System.Guid rfid, uint dwFlags, System.IntPtr hToken, string pszPath); '@ $kf = Add-Type -MemberDefinition $sig -Name KFP -Namespace Vmix -PassThru $map = @{ ${lib.concatMapStringsSep "\n " (f: "'${f}' = '${knownFolderGuids.${f}}'") redirectFolders} } foreach ($name in $map.Keys) { $target = Join-Path '${redirectBase}' $name New-Item -ItemType Directory -Force -Path $target -ErrorAction SilentlyContinue | Out-Null $guid = [System.Guid]$map[$name] [void]$kf::SHSetKnownFolderPath([ref]$guid, 0, [System.IntPtr]::Zero, $target) } ''); # Active Setup fires StubPath once per user at first logon -- including the # fresh profile each generalized rebuild creates -- which is exactly when the # redirection needs re-applying. Backslashes doubled for .reg. activeSetupRegistry = lib.optionalString (folderRedirect != null) '' Windows Registry Editor Version 5.00 [HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Active Setup\Installed Components\{6f3b8c2a-1d4e-4f9a-b8c1-0a1b2c3d4e5f}] @="vmix folder redirection" "StubPath"="powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\\vmix-redirect-folders.ps1" "Version"="1" ''; healProfileScript = pkgs.writeText "vmix-heal-profile.ps1" (lib.optionalString (profilesDirectory != null) '' $pl = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' $bak = Get-ChildItem $pl -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -like '*.bak' -and (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).ProfileImagePath -eq '${profilesDirectory}\${username}' } | Select-Object -First 1 if ($bak) { $sid = $bak.PSChildName -replace '\.bak$' Remove-Item (Join-Path $pl $sid) -Recurse -Force -ErrorAction SilentlyContinue Rename-Item $bak.PSPath $sid -ErrorAction SilentlyContinue Remove-Item '${profilesDirectory}\TEMP' -Recurse -Force -ErrorAction SilentlyContinue New-Item -Path C:\Windows\Temp\vmix-profile-healed -ItemType File -Force | Out-Null } ''); # One onstart / SYSTEM script for whatever the data disk needs before logon: # assign its letter, and then either heal a relocated profile that went # temporary (profilesDirectory) or make the redirected data folders reachable # by whatever account this rebuild created (folderRedirect). Both cannot apply # at once -- a profile is either wholly on D: or only its data folders are. bootDataProfileScript = pkgs.writeText "vmix-data-profile.cmd" '' @echo off ${lib.optionalString (dataDisk != null) "call C:\\vmix-init-data-disk.cmd"} ${lib.optionalString (profilesDirectory != null) '' powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-heal-profile.ps1 > C:\Windows\Temp\vmix-data-profile.log 2>&1 if exist C:\Windows\Temp\vmix-profile-healed ( del /q C:\Windows\Temp\vmix-profile-healed shutdown /r /t 5 /c "vmix: repaired relocated profile, restarting" ) ''} ${lib.optionalString (folderRedirect != null) '' :: The redirected folders live under a per-user account whose SID changes on :: every generalized rebuild, so grant the well-known Users group -- which :: any account joins and which is SID-stable across machines -- inheritable :: full control, and let the per-user redirect (Active Setup) point the known :: folders here. Runs as SYSTEM, before any logon. if not exist "${redirectBase}" mkdir "${redirectBase}" ${lib.concatMapStringsSep "\n " (f: ''if not exist "${redirectBase}\${f}" mkdir "${redirectBase}\${f}"'') redirectFolders} icacls "${redirectBase}" /grant *S-1-5-32-545:(OI)(CI)F /t > C:\Windows\Temp\vmix-redirect-prep.log 2>&1 ''} ''; folderLocationsXml = lib.optionalString (profilesDirectory != null) '' ${profilesDirectory} ''; # ProfilesDirectory as an offline .reg merge, for the keepMachineSid path # where the specialize pass (and its FolderLocations) does not run. virt-win-reg # applies this before the Audit Mode boot, so it is in place when OOBE creates # the account. Backslashes are doubled for .reg syntax. profileListRegistry = lib.optionalString (keepMachineSid && profilesDirectory != null) '' Windows Registry Editor Version 5.00 [HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList] "ProfilesDirectory"="${builtins.replaceStrings [''\''] [''\\''] 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 configMedium '' :: Stage the per-VM config off the removable CD, then apply the parts that :: are not answer-file fields (timezone, desktop tint, machine rename). The :: static address is left to the per-boot task registered below. call C:\vmix-load-config.cmd powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-apply-config.ps1 > C:\Windows\Temp\vmix-apply-config.log 2>&1 ''} ${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 || configMedium) '' :: 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. :: Registered to run at every boot rather than applied here. OOBE runs in :: the build VM, whose NIC is qemu user networking on another subnet with :: another MAC -- so an address set now lands on an adapter that does not :: exist on the real host. Windows sees the target's NIC as new hardware :: and falls back to DHCP, which is exactly what happened. Per-boot also :: survives the adapter being replaced again later. schtasks /create /tn "vmix-static-ip" /tr "C:\vmix-static-ip.cmd" /sc onstart /ru SYSTEM /rl HIGHEST /f > nul 2>&1 ''} ${lib.optionalString (dataDisk != null) '' :: Ensures D: is assigned on every boot -- the image ships without a :: persisted letter for the data disk -- and heals a profile that went :: temporary before D: was ready. Onstart / SYSTEM, like the address task. schtasks /create /tn "vmix-data-profile" /tr "C:\vmix-data-profile.cmd" /sc onstart /ru SYSTEM /rl HIGHEST /f > nul 2>&1 ''} ${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 ''} ${lib.optionalString keepMachineSid '' :: /oobe without /generalize leaves the system set to re-run windeploy (OOBE) :: on every boot -- shutting down here in a FirstLogonCommand cuts OOBE off :: before it resets that state itself. Clear it, or the target boots into a :: Setup with no unattend left to consume and hangs on a black screen. reg add "HKLM\SYSTEM\Setup" /v SetupType /t REG_DWORD /d 0 /f reg delete "HKLM\SYSTEM\Setup" /v OOBEInProgress /f 2>nul reg delete "HKLM\SYSTEM\Setup" /v CmdLine /f 2>nul ''} ${lib.optionalString configMedium '' :: Runs last, as the generic bootstrap account: create the real per-VM :: account from the config, switch autologon to it and arm the cleanup. The :: reboot below then logs the real account in for the first time, building :: its SID-bound profile on D:\Users\. powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-create-user.ps1 > C:\Windows\Temp\vmix-create-user.log 2>&1 ''} :: 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 configMedium then "shutdown /r /t 5 /c \"vmix: applying per-VM config\"" else 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" '' ${lib.optionalString (profilesDirectory == null) " 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> ${folderLocationsXml} <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 configMedium then "seal" else if delayOobeRun then "generalize-delay-oobe" else "generalize"; inherit nicModel; # With keepMachineSid the specialize pass never runs (see the sysprep line), # so the profile relocation cannot ride the unattend there. It is written to # the registry offline instead, before the build's OOBE creates the profile, # so the account still lands on the data volume. Empty otherwise. windowsRegistry = profileListRegistry + activeSetupRegistry; # 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. # # Under delayOobeRun there is no build-VM OOBE to relocate into, and the real # data volume is the host's zvol attached at deploy time -- so building an # empty throwaway disk here would be pure waste. Gated off: the target's # specialize formats the real disk (dataDiskXml) and OOBE creates the profile # on it. This is what lets a sealed image ship without a `data` output. extraDisk = if (dataDisk != null && !delayOobeRun) 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.optionals (dataDisk != null) ( [ { source = initDataDiskScript; dest = "/vmix-init-data-disk.cmd"; } { source = bootDataProfileScript; dest = "/vmix-data-profile.cmd"; } ] ++ lib.optional (profilesDirectory != null) { source = healProfileScript; dest = "/vmix-heal-profile.ps1"; } ++ lib.optional (folderRedirect != null) { source = folderRedirectPs1; dest = "/vmix-redirect-folders.ps1"; } ) ++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; } ++ lib.optionals configMedium [ { source = loadConfigScript; dest = "/vmix-load-config.cmd"; } { source = applyConfigScript; dest = "/vmix-apply-config.ps1"; } { source = createUserScript; dest = "/vmix-create-user.ps1"; } { source = userCleanupScript; dest = "/vmix-user-cleanup.cmd"; } ] ++ lib.optionals (staticIP != null || configMedium) [ { source = staticIPScript; dest = "/vmix-static-ip.cmd"; } { source = staticIPScriptPs1; dest = "/vmix-static-ip.ps1"; } ]; # 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 ${lib.optionalString (dataDisk != null && !delayOobeRun) '' :: Lay the data disk out here, in Audit Mode, rather than leaving it to the :: specialize pass alone. Component order within a pass is not guaranteed, :: and FolderLocations is applied by Shell-Setup while the disk is prepared :: by Deployment -- so relocation can be evaluated before the volume it :: names exists, which silently leaves profiles on C:. Audit Mode is a :: fully booted OS with the disk already attached, so this always works. :: The specialize copy stays as a letter re-assertion after generalize :: clears MountedDevices. :: :: Only when there is a build disk to lay out. Under delayOobeRun (sealed :: images) the disk is the host's zvol, present only on the target, so this :: is left to the target's specialize pass alone. call C:\vmix-init-data-disk.cmd ''} C:\Windows\System32\Sysprep\sysprep.exe ${lib.optionalString (!keepMachineSid) "/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