windows: seal mode -- generic OOBE-deferred base, per-VM data on a config CD

A sealed image bakes no per-VM data. generalize.nix gains a gated
configMedium flag (false path byte-identical, so the shared macOS
generalize path is untouched): the baked answer file stays generic and
the target's first boot reads hostname/static-IP/timezone/tint off a
small removable CD via a finder (vmix-load-config.cmd) + applier
(vmix-apply-config.ps1), with the static-IP script dot-sourcing the same
config. templates.seal is a thin preset (delayOobeRun + configMedium +
D:\Users profiles + a data disk); images gain a .seal leaf next to
.generalize; makeConfigMedium renders the per-VM ISO.

So one sealed store path is shared by every VM, each mints its own SID
on first boot and builds the whole profile on D:, and only a cheap ISO
is per-VM. Also folds in the delayOobeRun reconcile: extraDisk (and the
audit-mode data-disk init) are gated off under deferral, so a sealed
image ships with no throwaway `data` output -- the target's specialize
formats the host zvol instead.

vms: disks.config.file attaches the config medium as a second CD-ROM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g
This commit is contained in:
Git Sagar 2026-09-16 13:01:12 -03:00
parent c40f4460e3
commit 702f723e6d
6 changed files with 173 additions and 15 deletions

View file

@ -39,6 +39,18 @@ in rec {
# Generalize (sysprep + OOBE). Pass seal=true for hardware deployment.
generalize = import ./generalize.nix args;
# Seal: a generic OOBE-deferred base whose per-VM data is not baked but
# delivered at deploy time on a config medium (helpers/makeConfigMedium.nix).
# One sealed store path is shared by every VM; each VM's first boot mints its
# own SID and builds the whole profile on the relocated data volume (D:).
# Forces only the structural bits -- account, RDP and locale stay caller args.
seal = templateArgs: generalize ({
delayOobeRun = true;
configMedium = true;
profilesDirectory = "D:\\Users";
dataDisk = { driveLetter = "D"; label = "data"; };
} // templateArgs);
# Offline registry templates
reg = import ./registry args;

View file

@ -42,6 +42,13 @@ in
# 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
@ -119,7 +126,19 @@ in
# 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
@ -134,7 +153,7 @@ in
$ok = $false
for ($k = 0; $k -lt 5 -and -not $ok; $k++) {
try {
New-NetIPAddress -InterfaceIndex $i -IPAddress '${staticIP.address}' -PrefixLength ${toString staticIP.prefixLength} -DefaultGateway '${staticIP.gateway}' -ErrorAction Stop | Out-Null
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)
@ -143,9 +162,44 @@ in
}
}
if (-not $ok) { Write-Output 'vmix: could not set static address'; exit 1 }
Set-DnsClientServerAddress -InterfaceIndex $i -ServerAddresses ${staticDnsList}
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 ${staticIP.address} on ifIndex ' + $i)
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
}
'';
# Thin launcher, so the scheduled task has a cmd to point at.
@ -326,6 +380,13 @@ in
# 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
@ -395,7 +456,7 @@ in
reg add "HKLM\SYSTEM\CurrentControlSet\Services\TermService" /v Start /t REG_DWORD /d 2 /f
''}
${lib.optionalString (staticIP != null) ''
${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
@ -437,7 +498,9 @@ in
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\""}
${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
'';
@ -523,7 +586,7 @@ ${folderLocationsXml}
</unattend>
'';
in {
name = if delayOobeRun then "generalize-delay-oobe" else "generalize";
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
@ -534,7 +597,13 @@ in {
# 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;
#
# 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"; }
@ -547,7 +616,11 @@ in {
++ lib.optional (folderRedirect != null) { source = folderRedirectPs1; dest = "/vmix-redirect-folders.ps1"; }
)
++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; }
++ lib.optionals (staticIP != null) [
++ lib.optionals configMedium [
{ source = loadConfigScript; dest = "/vmix-load-config.cmd"; }
{ source = applyConfigScript; dest = "/vmix-apply-config.ps1"; }
]
++ lib.optionals (staticIP != null || configMedium) [
{ source = staticIPScript; dest = "/vmix-static-ip.cmd"; }
{ source = staticIPScriptPs1; dest = "/vmix-static-ip.ps1"; }
];
@ -559,7 +632,7 @@ in {
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) ''
${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
@ -568,6 +641,10 @@ in {
:: 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