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

@ -3,6 +3,7 @@ let
windows = rec { windows = rec {
drivers = import ./drivers { inherit pkgs system; }; drivers = import ./drivers { inherit pkgs system; };
makeFilesISO = (import ./helpers/makeFilesISO.nix) { inherit pkgs; }; makeFilesISO = (import ./helpers/makeFilesISO.nix) { inherit pkgs; };
makeConfigMedium = (import ./helpers/makeConfigMedium.nix) { inherit pkgs lib makeFilesISO; };
customizeImage = (import ./helpers/customizeImage.nix) { inherit pkgs lib; }; customizeImage = (import ./helpers/customizeImage.nix) { inherit pkgs lib; };
customizeImageFold = builtins.foldl' customizeImage; customizeImageFold = builtins.foldl' customizeImage;
templates = (import ./templates) { inherit pkgs lib system drivers makeFilesISO; }; templates = (import ./templates) { inherit pkgs lib system drivers makeFilesISO; };
@ -14,14 +15,20 @@ let
win10 = (import ./win10) { inherit pkgs lib system windows; }; win10 = (import ./win10) { inherit pkgs lib system windows; };
win11 = (import ./win11) { inherit pkgs lib system windows; }; win11 = (import ./win11) { inherit pkgs lib system windows; };
# Recursively add .generalize to every derivation leaf in the image tree # Recursively add .generalize and .seal to every derivation leaf in the tree
addGeneralize = val: addGeneralize = val:
if val ? _vmixOsType then if val ? _vmixOsType then
val // { generalize = args: val // {
generalize = args:
let let
templateArgs = builtins.removeAttrs args [ "vncDisplay" ]; templateArgs = builtins.removeAttrs args [ "vncDisplay" ];
displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; }; displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; };
in windows.customizeImage val (windows.templates.generalize templateArgs // displayArgs); in windows.customizeImage val (windows.templates.generalize templateArgs // displayArgs);
seal = args:
let
templateArgs = builtins.removeAttrs args [ "vncDisplay" ];
displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; };
in windows.customizeImage val (windows.templates.seal templateArgs // displayArgs);
} }
else if builtins.isAttrs val then else if builtins.isAttrs val then
lib.mapAttrs (_: addGeneralize) val lib.mapAttrs (_: addGeneralize) val

View file

@ -0,0 +1,51 @@
# Per-VM config medium for a sealed Windows image (see templates.seal).
#
# A sealed image carries no per-VM data. The values that differ between VMs --
# hostname, the static address the guest asserts, timezone, desktop tint -- are
# written here as a PowerShell data file and packed into a tiny ISO. config.nix
# attaches it as a read-only CD-ROM; the image's baked first-boot scripts
# (vmix-load-config.cmd finds it, then dot-source it) apply the values. So one
# sealed store path is shared by every VM, and only this cheap ISO is per-VM.
#
# Usage:
# makeConfigMedium {
# name = "win-config";
# hostname = "panda-win";
# staticIP = { address = "10.10.10.26"; prefixLength = 24;
# gateway = "10.10.10.1"; dns = [ "10.10.10.1" ]; };
# timezone = "E. South America Standard Time";
# bgColor = "#856558";
# }
{ pkgs, lib, makeFilesISO, ... }:
{
name ? "vmix-config",
hostname ? "",
# { address; prefixLength; gateway; dns = [ ... ]; }
staticIP ? null,
timezone ? null,
# Solid desktop background as a hex string, e.g. "#856558". Converted to the
# registry's decimal "R G B" on the target, in vmix-apply-config.ps1.
bgColor ? null,
}:
let
dnsList = lib.optionalString (staticIP != null)
(lib.concatMapStringsSep "," (s: "'${s}'") staticIP.dns);
# Consumed by dot-sourcing (. C:\vmix-config.ps1), so it only assigns
# variables. Anything not set here is simply absent, and the baked scripts
# guard on that ($VmixIpAddress being null skips the static-IP assignment).
configPs1 = pkgs.writeText "vmix-config.ps1" ''
# vmix per-VM config -- generated, read by the sealed image's baked scripts.
$VmixHostname = '${hostname}'
${lib.optionalString (staticIP != null) ''
$VmixIpAddress = '${staticIP.address}'
$VmixPrefixLength = ${toString staticIP.prefixLength}
$VmixGateway = '${staticIP.gateway}'
$VmixDns = @(${dnsList})''}
${lib.optionalString (timezone != null) "$VmixTimeZone = '${timezone}'"}
${lib.optionalString (bgColor != null) "$VmixBgColor = '${bgColor}'"}
'';
in
# makeFilesISO strips the store-hash prefix, so this lands at the ISO root as
# exactly vmix-config.ps1 -- which is what vmix-load-config.cmd scans for.
makeFilesISO { inherit name; files = [ configPs1 ]; }

View file

@ -39,6 +39,18 @@ in rec {
# Generalize (sysprep + OOBE). Pass seal=true for hardware deployment. # Generalize (sysprep + OOBE). Pass seal=true for hardware deployment.
generalize = import ./generalize.nix args; 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 # Offline registry templates
reg = import ./registry args; reg = import ./registry args;

View file

@ -42,6 +42,13 @@ in
# delayOobeRun = true: sysprep only, OOBE + activation on real hardware # delayOobeRun = true: sysprep only, OOBE + activation on real hardware
# delayOobeRun = false: sysprep + OOBE + activation in build VM # delayOobeRun = false: sysprep + OOBE + activation in build VM
delayOobeRun ? false, 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 # 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 -- # 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 # 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 # the address from the previous instance tripped duplicate-address detection
# and New-NetIPAddress threw. DadTransmits 0 turns that detection off so the # and New-NetIPAddress threw. DadTransmits 0 turns that detection off so the
# static always binds, and the assignment is retried rather than fatal. # 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" '' 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 $a = $null
for ($n = 0; $n -lt 30; $n++) { for ($n = 0; $n -lt 30; $n++) {
$a = Get-NetAdapter -Physical | Where-Object Status -eq 'Up' | Sort-Object ifIndex | Select-Object -First 1 $a = Get-NetAdapter -Physical | Where-Object Status -eq 'Up' | Sort-Object ifIndex | Select-Object -First 1
@ -134,7 +153,7 @@ in
$ok = $false $ok = $false
for ($k = 0; $k -lt 5 -and -not $ok; $k++) { for ($k = 0; $k -lt 5 -and -not $ok; $k++) {
try { 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 $ok = $true
} catch { } catch {
Write-Output ('vmix: assign attempt ' + $k + ' failed: ' + $_.Exception.Message) 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 } 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 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. # 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. # Post-OOBE script: runs as the created user via FirstLogonCommands.
postOobeScript = pkgs.writeText "post-oobe.cmd" '' postOobeScript = pkgs.writeText "post-oobe.cmd" ''
@echo off @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) '' ${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 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 DefaultUserName /f 2>nul
@ -395,7 +456,7 @@ in
reg add "HKLM\SYSTEM\CurrentControlSet\Services\TermService" /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) '' ${lib.optionalString (staticIP != null || configMedium) ''
:: This VM's only NIC sits on a macvtap bridged to the host's LAN, so its :: 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. :: 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 :: 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-script.cmd 2>nul
del /q C:\vmix-audit-wrapper.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 del /q C:\post-oobe.cmd 2>nul
''; '';
@ -523,7 +586,7 @@ ${folderLocationsXml}
</unattend> </unattend>
''; '';
in { 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; inherit nicModel;
# With keepMachineSid the specialize pass never runs (see the sysprep line), # With keepMachineSid the specialize pass never runs (see the sysprep line),
# so the profile relocation cannot ride the unattend there. It is written to # 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. # 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 # 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. # 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 = [ uploads = [
{ source = oobeXml; dest = "/oobe-unattend.xml"; } { source = oobeXml; dest = "/oobe-unattend.xml"; }
{ source = postOobeScript; dest = "/post-oobe.cmd"; } { source = postOobeScript; dest = "/post-oobe.cmd"; }
@ -547,7 +616,11 @@ in {
++ lib.optional (folderRedirect != null) { source = folderRedirectPs1; dest = "/vmix-redirect-folders.ps1"; } ++ lib.optional (folderRedirect != null) { source = folderRedirectPs1; dest = "/vmix-redirect-folders.ps1"; }
) )
++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; } ++ 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 = staticIPScript; dest = "/vmix-static-ip.cmd"; }
{ source = staticIPScriptPs1; dest = "/vmix-static-ip.ps1"; } { 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.xml 2>nul
del /q C:\Windows\Panther\Unattend\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 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 :: 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, :: specialize pass alone. Component order within a pass is not guaranteed,
:: and FolderLocations is applied by Shell-Setup while the disk is prepared :: 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. :: fully booted OS with the disk already attached, so this always works.
:: The specialize copy stays as a letter re-assertion after generalize :: The specialize copy stays as a letter re-assertion after generalize
:: clears MountedDevices. :: 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 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 C:\Windows\System32\Sysprep\sysprep.exe ${lib.optionalString (!keepMachineSid) "/generalize "}/oobe ${if delayOobeRun then "/shutdown" else "/reboot"} /quiet /unattend:C:\oobe-unattend.xml

View file

@ -241,6 +241,7 @@ let
''} \ ''} \
${optionalString hasOsDisk "-drive file=${osDiskPath},format=qcow2,if=virtio${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"}"} \ ${optionalString hasOsDisk "-drive file=${osDiskPath},format=qcow2,if=virtio${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"}"} \
${optionalString (vmCfg.disks.iso.file != null) "-drive file=${toString vmCfg.disks.iso.file},media=cdrom,readonly=on"} \ ${optionalString (vmCfg.disks.iso.file != null) "-drive file=${toString vmCfg.disks.iso.file},media=cdrom,readonly=on"} \
${optionalString (vmCfg.disks.config.file != null) "-drive file=${toString vmCfg.disks.config.file},media=cdrom,readonly=on"} \
${concatMapStrings (diskCfg: '' ${concatMapStrings (diskCfg: ''
-drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \ -drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \
'') (attrValues vmCfg.disks.add)} \ '') (attrValues vmCfg.disks.add)} \

View file

@ -189,6 +189,16 @@ with lib;
description = "Path to the ISO file. Can be a Nix store path or a string path to a local file."; description = "Path to the ISO file. Can be a Nix store path or a string path to a local file.";
default = null; default = null;
}; };
disks.config.file = mkOption {
type = types.nullOr (types.either types.path types.str);
default = null;
description = ''
A small read-only config medium attached as a second CD-ROM. For Windows
sealed images (templates.seal) this is the per-VM ISO from
vmixLib.windows.makeConfigMedium, carrying hostname/static-IP/etc. that
the image's baked first-boot scripts consume. Null to attach nothing.
'';
};
disks.add = mkOption { disks.add = mkOption {
default = {}; default = {};
type = types.attrsOf (types.submodule { type = types.attrsOf (types.submodule {