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
447 lines
24 KiB
Nix
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
|
|
|