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:
parent
c40f4460e3
commit
702f723e6d
6 changed files with 173 additions and 15 deletions
|
|
@ -3,6 +3,7 @@ let
|
|||
windows = rec {
|
||||
drivers = import ./drivers { inherit pkgs system; };
|
||||
makeFilesISO = (import ./helpers/makeFilesISO.nix) { inherit pkgs; };
|
||||
makeConfigMedium = (import ./helpers/makeConfigMedium.nix) { inherit pkgs lib makeFilesISO; };
|
||||
customizeImage = (import ./helpers/customizeImage.nix) { inherit pkgs lib; };
|
||||
customizeImageFold = builtins.foldl' customizeImage;
|
||||
templates = (import ./templates) { inherit pkgs lib system drivers makeFilesISO; };
|
||||
|
|
@ -14,14 +15,20 @@ let
|
|||
win10 = (import ./win10) { 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:
|
||||
if val ? _vmixOsType then
|
||||
val // { generalize = args:
|
||||
val // {
|
||||
generalize = args:
|
||||
let
|
||||
templateArgs = builtins.removeAttrs args [ "vncDisplay" ];
|
||||
displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; };
|
||||
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
|
||||
lib.mapAttrs (_: addGeneralize) val
|
||||
|
|
|
|||
51
lib/images/windows/helpers/makeConfigMedium.nix
Normal file
51
lib/images/windows/helpers/makeConfigMedium.nix
Normal 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 ]; }
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ let
|
|||
''} \
|
||||
${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.config.file != null) "-drive file=${toString vmCfg.disks.config.file},media=cdrom,readonly=on"} \
|
||||
${concatMapStrings (diskCfg: ''
|
||||
-drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \
|
||||
'') (attrValues vmCfg.disks.add)} \
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
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 {
|
||||
default = {};
|
||||
type = types.attrsOf (types.submodule {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue