Setting it from post-oobe.cmd could never have worked, and the reason is worth writing down. Without delayOobeRun, OOBE runs inside the build VM -- whose NIC is qemu user networking, on a different subnet, with a different MAC. The address was being applied to an adapter that does not exist on the real host. Windows then meets the target's NIC as new hardware and defaults to DHCP. RDP came through the same script unharmed because its settings are registry-wide rather than per-adapter, which is why one worked and the other did not despite sitting a few lines apart. So the script is now registered as an onstart scheduled task running as SYSTEM. It was already idempotent, and per-boot also survives the adapter being replaced again later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g
393 lines
21 KiB
Nix
393 lines
21 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
|
|
'';
|
|
|
|
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 (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.optional (dataDisk != null) { source = initDataDiskScript; dest = "/vmix-init-data-disk.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
|
|
|