vmix.nix/lib/images/windows/templates/generalize.nix
Git Sagar d94c576df2 generalize: assign the data-disk letter every boot, and heal a temp profile
The relocated profile went temporary on the target and stayed that way. The
cause was not the profile: the image ships with ProfilesDirectory set to
D:\Users but with no drive letter for the data disk. generalize strips
MountedDevices, and the specialize pass that re-asserts the letter runs only in
the build VM, never on the target -- so the zvol boots letterless, the first
autologon cannot find D:\Users\sagar (event 1511), and Windows falls back to a
temporary profile, renames the real ProfileList key to .bak, and the fault
sticks on every later logon.

Confirmed by reading the shipped image offline: ProfileList has the SID at
D:\Users\TEMP with a .bak sibling at D:\Users\sagar, MountedDevices carries no
\DosDevices\D:, and the sagar hive on the zvol is intact -- so nothing was
wrong but the letter.

An onstart SYSTEM task now runs the existing (idempotent) data-disk init, whose
diskpart assign writes MountedDevices and so makes D: persistent for every
later boot. Only the first boot is exposed to the race; if it left a .bak, a
small PowerShell heal puts the key back, drops the temp profile, and reboots
once -- after which D: is persistent and the real profile loads. Same onstart /
SYSTEM mechanism the static address already uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g
2026-09-10 17:43:11 -03:00

447 lines
24 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,
# 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);
# 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.
staticIPScript = pkgs.writeText "vmix-static-ip.cmd" ''
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -Command "$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 -ErrorAction SilentlyContinue; Remove-NetIPAddress -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; Remove-NetRoute -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; New-NetIPAddress -InterfaceIndex $i -IPAddress '${staticIP.address}' -PrefixLength ${toString staticIP.prefixLength} -DefaultGateway '${staticIP.gateway}' -ErrorAction Stop | Out-Null; Set-DnsClientServerAddress -InterfaceIndex $i -ServerAddresses ${staticDnsList}; Write-Output ('vmix: set ' + '${staticIP.address}' + ' on ifIndex ' + $i)" > C:\Windows\Temp\vmix-static-ip.log 2>&1
:: Answer pings. Windows blocks ICMP by default, which makes a box with a
:: fixed address look dead to everything that checks it the obvious way.
powershell -NoProfile -Command "New-NetFirewallRule -DisplayName 'ICMPv4 Echo' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -Enabled True | Out-Null" > nul 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" ''
@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.
healProfileScript = pkgs.writeText "vmix-heal-profile.ps1" ''
$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
}
'';
# Ensures D: exists on every boot and heals a profile that went temporary.
#
# generalize strips MountedDevices, so the shipped image carries no drive
# letter for the data disk, and the specialize pass that would re-assert it
# runs only in the build, not on the target. The zvol therefore boots
# letterless, the first autologon cannot find its relocated profile at
# ${profilesDirectory}\${username} (event 1511) and falls back to a temporary
# one, backing the real key up as .bak and making the fault stick.
#
# diskpart assign writes MountedDevices, so once this has run once D: is
# persistent for every later boot. Only the first boot is exposed, and if it
# left a .bak the heal puts it back and reboots -- the next boot, D: now
# persistent and ProfileList clean, logs straight into the real profile.
bootDataProfileScript = pkgs.writeText "vmix-data-profile.cmd" ''
@echo off
call C:\vmix-init-data-disk.cmd
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"
)
'';
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.
:: 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
''}
:: 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">
<!-- CopyProfile bakes the Audit Mode customizations into the Default
profile, but it is dropped when profiles are being relocated: the
two interfere, and sysprep picking a profile to copy while the
profile root is moving underneath it is the likelier reason a
correctly formatted data volume came back holding nothing. Having a
profile that persists matters more than the customizations do.
FolderLocations appears in both passes on purpose. Which one
actually honours it is not something the documentation is crisp
about, and naming it twice costs nothing. -->
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
${lib.optionalString (profilesDirectory == null) " <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>
${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 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.optionals (dataDisk != null) [
{ source = initDataDiskScript; dest = "/vmix-init-data-disk.cmd"; }
{ source = healProfileScript; dest = "/vmix-heal-profile.ps1"; }
{ source = bootDataProfileScript; dest = "/vmix-data-profile.cmd"; }
]
++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; }
++ lib.optional (staticIP != null) { source = staticIPScript; dest = "/vmix-static-ip.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
${lib.optionalString (dataDisk != null) ''
:: 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.
call C:\vmix-init-data-disk.cmd
''}
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