diff --git a/lib/images/windows/default.nix b/lib/images/windows/default.nix index b01dcdd..d66c043 100644 --- a/lib/images/windows/default.nix +++ b/lib/images/windows/default.nix @@ -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: - let - templateArgs = builtins.removeAttrs args [ "vncDisplay" ]; - displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; }; - in windows.customizeImage val (windows.templates.generalize templateArgs // displayArgs); + 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 diff --git a/lib/images/windows/helpers/makeConfigMedium.nix b/lib/images/windows/helpers/makeConfigMedium.nix new file mode 100644 index 0000000..e3c10cd --- /dev/null +++ b/lib/images/windows/helpers/makeConfigMedium.nix @@ -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 ]; } diff --git a/lib/images/windows/templates/default.nix b/lib/images/windows/templates/default.nix index 67b06e5..a5a60fa 100644 --- a/lib/images/windows/templates/default.nix +++ b/lib/images/windows/templates/default.nix @@ -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; diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 8c1c10b..a8c3a42 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -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} ''; 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 diff --git a/nixos/vms/config.nix b/nixos/vms/config.nix index 528671f..91ab137 100644 --- a/nixos/vms/config.nix +++ b/nixos/vms/config.nix @@ -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)} \ diff --git a/nixos/vms/submoduleOptions.nix b/nixos/vms/submoduleOptions.nix index 45a55c4..24e9f8f 100644 --- a/nixos/vms/submoduleOptions.nix +++ b/nixos/vms/submoduleOptions.nix @@ -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 {