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..8a90be1 --- /dev/null +++ b/lib/images/windows/helpers/makeConfigMedium.nix @@ -0,0 +1,59 @@ +# 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 ? "", + # The per-VM account. The sealed image carries a generic bootstrap account + # (only there to carry OOBE); on first boot this real account is created from + # here, gets the SID-bound profile on D:\Users\, and the bootstrap + # is retired. Distinct per VM -- nothing about the account is shared/baked. + username ? "", + password ? "", + # { 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 (username != "") "$VmixUsername = '${username}'"} + ${lib.optionalString (username != "") "$VmixPassword = '${password}'"} + ${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..8bf1a10 100644 --- a/lib/images/windows/templates/default.nix +++ b/lib/images/windows/templates/default.nix @@ -39,6 +39,24 @@ 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:). + # + # The baked account is a generic bootstrap that only exists to carry OOBE to a + # logon -- the real, per-VM account (username/password) comes from the config + # medium, and the bootstrap is retired on the target. So nothing per-VM is + # baked. RDP and locale stay caller args. + seal = templateArgs: generalize ({ + delayOobeRun = true; + configMedium = true; + username = "vmixsetup"; + password = "vmixsetup"; + 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 52109d6..b883d89 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -42,6 +42,30 @@ 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 + # 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 @@ -93,12 +117,161 @@ in # 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\, 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 -Command "$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 -ErrorAction SilentlyContinue; Remove-NetIPAddress -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; Remove-NetRoute -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; New-NetIPAddress -InterfaceIndex $i -IPAddress '${staticIP.address}' -PrefixLength ${toString staticIP.prefixLength} -DefaultGateway '${staticIP.gateway}' -ErrorAction Stop | Out-Null; Set-DnsClientServerAddress -InterfaceIndex $i -ServerAddresses ${staticDnsList}; Write-Output ('vmix: set ' + '${staticIP.address}' + ' on ifIndex ' + $i)" > C:\Windows\Temp\vmix-static-ip.log 2>&1 - :: Answer pings. Windows blocks ICMP by default, which makes a box with a - :: fixed address look dead to everything that checks it the obvious way. - powershell -NoProfile -Command "New-NetFirewallRule -DisplayName 'ICMPv4 Echo' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -Enabled True | Out-Null" > nul 2>&1 + 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"; @@ -112,7 +285,59 @@ in # 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" '' + 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 @@ -141,6 +366,102 @@ in :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) '' @@ -150,6 +471,17 @@ in ${profilesDirectory} ''; + # 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) '' 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 @@ -202,6 +541,7 @@ in 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 @@ -220,6 +560,9 @@ in ) ) 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 @@ -233,9 +576,19 @@ in 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) '' + ${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 @@ -247,6 +600,23 @@ in 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 @@ -255,12 +625,31 @@ in 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\. + 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 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 ''; @@ -346,20 +735,46 @@ ${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 + # 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. - 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"; } { source = masScript; dest = "/MAS_AIO.cmd"; } - ] ++ lib.optional (dataDisk != null) { source = initDataDiskScript; dest = "/vmix-init-data-disk.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.optional (staticIP != null) { source = staticIPScript; dest = "/vmix-static-ip.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 = '' @@ -368,7 +783,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 @@ -377,9 +792,13 @@ 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 /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 ''; } diff --git a/nixos/vms/config.nix b/nixos/vms/config.nix index 4dc86a7..fa5c4cd 100644 --- a/nixos/vms/config.nix +++ b/nixos/vms/config.nix @@ -125,13 +125,34 @@ let if [ ! -f "$PERSIST_PATH" ]; then echo "Seeding persistent disk from store image..." mkdir -p "$(dirname "$PERSIST_PATH")" - cp --no-preserve=mode "${toString storeImage}" "$PERSIST_PATH" + ${if vmCfg.disks.os.persistMode == "backing" + then ''qemu-img create -f qcow2 -F qcow2 -b "${toString storeImage}" "$PERSIST_PATH"'' + else ''cp --no-preserve=mode "${toString storeImage}" "$PERSIST_PATH"''} chmod 600 "$PERSIST_PATH" fi ''; persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript; + # A GC root pinning the OS overlay's ACTUAL backing store path, so + # nix-collect-garbage cannot delete the store image the disk reads through. + gcrootLink = "/nix/var/nix/gcroots/vmix-${vmCfg.name}-osbacking"; + gcrootScript = pkgs.writeShellScript "${vmCfg.name}-gcroot-vmix" '' + # Read the live overlay's backing (not the config's current image, which + # drifts to a new store path after a rebuild while the overlay keeps + # backing the old one). Pinning the top of the chain transitively keeps + # the whole chain -- qcow2 backing_file paths are registered nix refs. + BACK="" + if [ -f "${vmCfg.disks.os.persistPath}" ]; then + BACK=$(qemu-img info "${vmCfg.disks.os.persistPath}" 2>/dev/null | awk '/^backing file:/ {print $3; exit}') + fi + [ -z "$BACK" ] && BACK="${toString storeImage}" + if [ -n "$BACK" ]; then + mkdir -p /nix/var/nix/gcroots + ln -sfn "$BACK" "${gcrootLink}" + fi + ''; + # QEMU expects single-letter boot codes (e.g. c,d,n), while vmix uses readable names. bootOrderQemu = let @@ -212,14 +233,18 @@ let -machine type=${vmCfg.pc.type}${machineIrqchipArg} \ ${optionalString vmCfg.bios.efi "-bios ${pkgs.OVMF.fd}/FV/OVMF.fd"} \ ${optionalString vmCfg.bios.tpm "-chardev socket,id=chrtpm,path=/tmp/mytpm-sock -tpmdev emulator,id=tpm0,chardev=chrtpm -device tpm-tis,tpmdev=tpm0"} \ - ${# Windows: localtime RTC, USB tablet for mouse, disable S3/S4 sleep + ${# Windows: localtime RTC, USB tablet for mouse, disable S3/S4 sleep. + # The q35 power/LPC bridge is ICH9-LPC -- ICH9-LMB was a typo qemu + # rejects ("invalid class name"), so the sleep states stayed offered + # and an idle guest could suspend itself right off the network. optionalString isWindows '' -rtc base=localtime,clock=host \ -device qemu-xhci -device usb-tablet \ - -global ICH9-LMB.disable_s3=1 -global ICH9-LMB.disable_s4=1 \ + -global ICH9-LPC.disable_s3=1 -global ICH9-LPC.disable_s4=1 \ ''} \ ${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)} \ @@ -254,7 +279,8 @@ let "vm.vmix@${vmCfg.name}" = rec { bindsTo = [ "net.vmix@${spaceName}.target" ] ++ lib.optional (allMacvtaps != []) "macvtaps.vm.vmix@${vmCfg.name}.service"; unitConfig.JoinsNamespaceOf = "ns.net.vmix@${spaceName}.service"; - after = bindsTo; + after = bindsTo ++ lib.optional (hasOsDisk && vmCfg.disks.os.persist) "vm.vmix-gcroot@${vmCfg.name}.service"; + wants = lib.optional (hasOsDisk && vmCfg.disks.os.persist) "vm.vmix-gcroot@${vmCfg.name}.service"; path = with pkgs; [ iproute2 qemu gawk coreutils ]; serviceConfig = { ExecStartPre = persistExecStartPre ++ [ createTapsforLansScript ]; @@ -284,6 +310,18 @@ let ExecStop = deleteMacvTapsScript; }; }; + } + // lib.optionalAttrs (cfg.enable && hasOsDisk && vmCfg.disks.os.persist) { + "vm.vmix-gcroot@${vmCfg.name}" = { + before = [ "vm.vmix@${vmCfg.name}.service" ]; + wantedBy = [ "multi-user.target" ]; + path = with pkgs; [ qemu coreutils ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = gcrootScript; + }; + }; }; vmServices = concatMapAttrs (spaceName: namespaceCfg: (concatMapAttrs (mkServices4aVMInNamespace spaceName) namespaceCfg.vms)) vmixCfg.namespaces; diff --git a/nixos/vms/submoduleOptions.nix b/nixos/vms/submoduleOptions.nix index c7cbd1a..24e9f8f 100644 --- a/nixos/vms/submoduleOptions.nix +++ b/nixos/vms/submoduleOptions.nix @@ -173,11 +173,32 @@ with lib; default = ""; description = "Mutable path for the persistent OS disk (e.g. /storage/vms/myvm/os.qcow2). Required when persist = true."; }; + disks.os.persistMode = mkOption { + type = types.enum [ "copy" "backing" ]; + default = "copy"; + description = '' + How the persistent OS disk is seeded from the store image (persist = true). + copy: a full cp of the store image; the mutable disk holds everything. + backing: a thin qcow2 overlay backing onto the shared store image, so many + VMs share one base and each holds only its own deltas. The store image (and + its backing chain) must then survive GC -- vmix pins it via a per-VM gcroot. + ''; + }; disks.iso.file = mkOption { type = types.nullOr (types.either types.path types.str); 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 {