vmix.nix/lib/images/windows/templates/generalize.nix
Git Sagar 6d4b9155d6 windows/seal: leave nothing on C:, activate on the fresh SID, unmount the CD
Sealed VMs were left with setup scripts on C:\, the config CD mounted,
and Windows unactivated. Rework the first-boot flow so a settled VM
carries no vmix artifacts:

- Activation moves off the throwaway bootstrap's boot 1 into the boot-2
  finalize, which runs as the real account after its fresh SID/profile
  exist -- MAS on that SID is what actually sticks. MAS is kept until
  then, run once, and deleted.
- The static address is set once into the persistent store on the target
  NIC (sealed images already OOBE on the real NIC), so no per-boot task
  and no script survive on disk.
- Boot-2 finalize wipes every vmix-*, MAS_AIO.cmd, config and marker off
  C:\ after use, retires the bootstrap account/profile, and self-deletes.
- The config CD is unmounted for good -- drop its letter and disable the
  mount manager's auto-lettering (D: keeps its explicit assignment).

Non-configMedium generalize (the shared path) is unchanged.

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

812 lines
44 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,
# 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\<username>, 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 vmixFinalize -Value 'cmd /c C:\vmix-finalize.cmd' -Type String
'';
# Runs once as the real account (RunOnce, after the hand-over reboot), so the
# fresh machine SID and the real profile already exist. This is where activation
# belongs -- on that fresh SID, not the throwaway bootstrap's. It then finishes
# per-user setup, retires the bootstrap, unmounts the config CD for good, and
# wipes every vmix artifact off C:\ so the running machine carries no leftover
# setup files. Self-deletes last.
finalizeScript = pkgs.writeText "vmix-finalize.cmd" ''
@echo off
:: 1) Activate Windows on the real account's fresh SID (TSforge, offline).
cscript //nologo C:\Windows\System32\slmgr.vbs /ipk M7XTQ-FN8P6-TTKYV-9D4CC-J462D
cscript //nologo C:\Windows\System32\slmgr.vbs /rilc
net stop sppsvc /y 2>nul
net start sppsvc
ping -n 8 127.0.0.1 >nul
if exist C:\MAS_AIO.cmd ( echo. | call C:\MAS_AIO.cmd /Z-Windows )
if exist "C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE" (
if exist C:\MAS_AIO.cmd ( echo. | call C:\MAS_AIO.cmd /Ohook )
)
:: 2) Per-user desktop tint, now that the real account is logged in.
powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-apply-config.ps1 >nul 2>&1
:: 3) Retire the bootstrap account and its profile (idle now).
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
:: 4) Unmount the config CD and keep it unmounted -- drop its letter and stop
:: the mount manager auto-lettering it (D: keeps its explicit assignment).
powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Volume -Filter 'DriveType=5 AND DriveLetter IS NOT NULL' | ForEach-Object { $_.DriveLetter=$null; [void]($_ | Set-CimInstance) }"
> C:\Windows\Temp\vmix-am.txt echo automount disable
>> C:\Windows\Temp\vmix-am.txt echo automount scrub
diskpart /s C:\Windows\Temp\vmix-am.txt >nul 2>&1
del /q C:\Windows\Temp\vmix-am.txt 2>nul
:: 5) Wipe every vmix setup artifact from C:\.
del /q C:\MAS_AIO.cmd C:\vmix-config.ps1 C:\vmix-data-initialized 2>nul
del /q C:\vmix-load-config.cmd C:\vmix-apply-config.ps1 C:\vmix-create-user.ps1 2>nul
del /q C:\vmix-init-data-disk.cmd C:\vmix-data-profile.cmd C:\vmix-heal-profile.ps1 2>nul
del /q C:\vmix-static-ip.cmd C:\vmix-static-ip.ps1 2>nul
del /q C:\oobe-unattend.xml C:\post-oobe.cmd 2>nul
:: 6) Self-delete.
(goto) 2>nul & del "%~f0"
'';
# 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) ''
<!-- 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>'';
# 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) ''
<!-- 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 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"
${lib.optionalString (!configMedium) ''
:: 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
''}
:: configMedium: activation is deferred to the boot-2 finalize, so it runs
:: on the real account's fresh SID. MAS_AIO.cmd is kept for it until then.
${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
:: A VM reached over RDP must never suspend itself off the network. The
:: default Balanced plan sleeps after 15 min idle; switch to High
:: Performance and zero every idle timeout, and turn hibernate off.
powercfg /setactive SCHEME_MIN
powercfg /change standby-timeout-ac 0
powercfg /change standby-timeout-dc 0
powercfg /change hibernate-timeout-ac 0
powercfg /change hibernate-timeout-dc 0
powercfg /change monitor-timeout-ac 0
powercfg /hibernate off
''}
${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 configMedium ''
:: configMedium (sealed images) run OOBE on the real target NIC, so the
:: address is set here once and left in the persistent store -- it survives
:: reboots on its own, no per-boot task and no script left on disk. Runs on
:: this bootstrap boot so the real account is already reachable on boot 2.
powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-static-ip.ps1 > C:\Windows\Temp\vmix-static-ip.log 2>&1
''}
${lib.optionalString (dataDisk != null && !configMedium) ''
:: 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.
:: configMedium does not need this: the letter persists via MountedDevices
:: in the overlay after the first boot, so there is nothing to re-assert.
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\<username>.
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" ''
<?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 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 = finalizeScript; dest = "/vmix-finalize.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