From 3b454b749a198271d3641433637c253ce1342bc9 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Tue, 9 Jun 2026 10:06:41 +0530 Subject: [PATCH 01/27] switch from HWID to TSforge activation - Switch MAS from /HWID to /Z-Windows (TSforge ZeroCID) which is hardware-independent and survives VM migration - Re-install product key and restart SPP service before TSforge to restore licensing state after sysprep - Add nicModel option to customizeImage and generalize for images without VirtIO drivers - Update MAS activation script to latest version Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/images/windows/helpers/customizeImage.nix | 13 +++++++++++-- lib/images/windows/templates/generalize.nix | 13 ++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/images/windows/helpers/customizeImage.nix b/lib/images/windows/helpers/customizeImage.nix index 79dc155..19758b9 100644 --- a/lib/images/windows/helpers/customizeImage.nix +++ b/lib/images/windows/helpers/customizeImage.nix @@ -25,6 +25,10 @@ smp ? 4, memSize ? 4096, nicModel ? null, + # Flatten COW chain into a standalone qcow2 (removes backing file dependency) + compact ? false, + # QEMU timeout in seconds (default 30 min, increase for Windows Update) + qemuTimeout ? 1800, }: let originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name); @@ -107,11 +111,11 @@ ${cdromArgs} \ -nic user,model=${if nicModel != null then nicModel else if isAHCI then "e1000" else "virtio-net-pci"}" - timeout 1800 qemu-system-x86_64 $VMIX_DISPLAY $QEMU_ARGS || \ + timeout ${toString qemuTimeout} qemu-system-x86_64 $VMIX_DISPLAY $QEMU_ARGS || \ if [[ "$VMIX_DISPLAY" == "-display sdl" ]]; then echo "=== vmix: SDL failed, retrying headless ===" cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd && chmod +w vars.fd - timeout 1800 qemu-system-x86_64 -nographic $QEMU_ARGS + timeout ${toString qemuTimeout} qemu-system-x86_64 -nographic $QEMU_ARGS else exit 1 fi @@ -125,6 +129,11 @@ [ -n "${diskSize}" ] && qemu-img resize ${resultImg} ${diskSize} ${virtWinRegMerge} ${auditBootCommands} + ${lib.optionalString compact '' + echo "=== vmix: compacting image ===" + qemu-img convert -O qcow2 ${resultImg} compact.qcow2 + mv compact.qcow2 ${resultImg} + ''} mv ${resultImg} $out ''; builtImage = pkgs.runCommand customImageName ({ diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 0caa5b9..6ff6c0a 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -81,14 +81,21 @@ in powershell -Command "Get-AppxPackage *MicrosoftEdgeDevToolsClient* | Remove-AppxPackage -ErrorAction SilentlyContinue" - :: Activate Windows using HWID method + :: Re-install product key to restore licensing after sysprep + cscript //nologo C:\Windows\System32\slmgr.vbs /ipk M7XTQ-FN8P6-TTKYV-9D4CC-J462D + cscript //nologo C:\Windows\System32\slmgr.vbs /rilc + :: Restart SPP service and wait for it to settle + net stop sppsvc /y 2>nul + net start sppsvc + ping -n 10 127.0.0.1 >nul + :: Activate Windows using TSforge if exist C:\MAS_AIO.cmd ( - echo. | call C:\MAS_AIO.cmd /HWID + echo. | call C:\MAS_AIO.cmd /Z-Windows ) :: Activate Office using Ohook method (if Office is installed) if exist "C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE" ( if exist C:\MAS_AIO.cmd ( - echo. | call C:\MAS_AIO.cmd /Ohook + echo. | call C:\MAS_AIO.cmd /Ohook >> C:\vmix-activation.log 2>&1 ) ) del /q C:\MAS_AIO.cmd 2>nul From f33b8e7ce3f5f76b3ccf433704836d359e688839 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Tue, 9 Jun 2026 10:31:08 +0530 Subject: [PATCH 02/27] WIP: add Windows Update template with online COM API updates Add essentials.windowsUpdate template that boots Audit Mode, uses the Windows Update COM API to search/download/install all available updates (cumulative, .NET, Defender), handles multi-round reboots with Audit Mode preservation, and compacts the image afterward. Known issues being worked: - Audit Mode preservation after update reboot needs verification - Install takes ~60-90 min with 4GB RAM on slow machines See wip/win10-update.session.md for full context and TODOs. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/images/windows/templates/default.nix | 1 + .../templates/essentials/windows-update.nix | 114 ++++++++++++++++++ lib/images/windows/win10/images.nix | 9 +- lib/images/windows/win11/images.nix | 9 +- wip/win10-update.session.md | 95 +++++++++++++++ 5 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 lib/images/windows/templates/essentials/windows-update.nix create mode 100644 wip/win10-update.session.md diff --git a/lib/images/windows/templates/default.nix b/lib/images/windows/templates/default.nix index 67b06e5..78a282e 100644 --- a/lib/images/windows/templates/default.nix +++ b/lib/images/windows/templates/default.nix @@ -20,6 +20,7 @@ in rec { bestPerformance = import ./essentials/best-performance.nix args; clearFileAssociations = import ./essentials/clear-file-associations.nix args; virtioDrivers = import ./essentials/virtio-drivers.nix args; + windowsUpdate = import ./essentials/windows-update.nix args; }; # Applications diff --git a/lib/images/windows/templates/essentials/windows-update.nix b/lib/images/windows/templates/essentials/windows-update.nix new file mode 100644 index 0000000..5b3e188 --- /dev/null +++ b/lib/images/windows/templates/essentials/windows-update.nix @@ -0,0 +1,114 @@ +# Apply all available Windows Updates via the Windows Update COM API in Audit Mode. +# Handles reboots automatically — re-registers via RunOnce and continues updating. +# Compacts the image afterward to flatten the COW chain. +# +# Usage: +# essentials.windowsUpdate {} +# essentials.windowsUpdate { maxRounds = 5; } +{ pkgs, lib, ... }: +{ maxRounds ? 3 }: +{ + name = "windows-update"; + compact = true; + memSize = 4096; + qemuTimeout = 7200; + auditScript = '' + @echo off + setlocal + + :: Track update round via a counter file + set "ROUND_FILE=C:\vmix-update-round.txt" + set "MAX_ROUNDS=${toString maxRounds}" + + if exist "%ROUND_FILE%" ( + set /p ROUND=<"%ROUND_FILE%" + ) else ( + set "ROUND=1" + ) + + echo === vmix: Windows Update round %ROUND% of %MAX_ROUNDS% === + + :: Ensure Windows Update service is running + net start wuauserv 2>nul + sc config wuauserv start= auto + + :: Remove any update-blocking policies (LTSC may have these) + reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" /f 2>nul + reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /f 2>nul + + :: Give the service time to initialize on first round + if "%ROUND%"=="1" ( + echo Waiting for Windows Update service to initialize... + timeout /t 30 /nobreak >nul + ) + + :: Run Windows Update via PowerShell COM API + powershell -ExecutionPolicy Bypass -Command ^ + "try {" ^ + " $session = New-Object -ComObject Microsoft.Update.Session;" ^ + " $searcher = $session.CreateUpdateSearcher();" ^ + " Write-Host 'Searching for updates...';" ^ + " $result = $searcher.Search('IsInstalled=0');" ^ + " $count = $result.Updates.Count;" ^ + " Write-Host \"Found $count updates\";" ^ + " if ($count -eq 0) { exit 0 };" ^ + " foreach ($u in $result.Updates) { Write-Host \" - $($u.Title)\" };" ^ + " $updatesToInstall = New-Object -ComObject Microsoft.Update.UpdateColl;" ^ + " foreach ($u in $result.Updates) {" ^ + " if ($u.EulaAccepted -eq $false) { $u.AcceptEula() };" ^ + " $updatesToInstall.Add($u) | Out-Null" ^ + " };" ^ + " $downloader = $session.CreateUpdateDownloader();" ^ + " $downloader.Updates = $updatesToInstall;" ^ + " Write-Host 'Downloading...';" ^ + " $downloader.Download() | Out-Null;" ^ + " $installer = $session.CreateUpdateInstaller();" ^ + " $installer.Updates = $updatesToInstall;" ^ + " Write-Host 'Installing...';" ^ + " $installResult = $installer.Install();" ^ + " Write-Host \"Result: $($installResult.ResultCode)\";" ^ + " for ($i = 0; $i -lt $updatesToInstall.Count; $i++) {" ^ + " $hr = $installResult.GetUpdateResult($i).HResult;" ^ + " Write-Host \" $($updatesToInstall.Item($i).Title): code=$hr\"" ^ + " };" ^ + " if ($installResult.RebootRequired) { exit 3010 } else { exit 0 }" ^ + "} catch {" ^ + " Write-Host \"ERROR: $_\";" ^ + " exit 1" ^ + "}" + + set "WU_EXIT=%ERRORLEVEL%" + echo Windows Update exit code: %WU_EXIT% + + :: Cleanup component store + echo Cleaning up component store... + dism /Online /Cleanup-Image /StartComponentCleanup /ResetBase /Quiet 2>nul + + :: Check if we need to reboot and continue + set /a "NEXT_ROUND=%ROUND%+1" + + if "%WU_EXIT%"=="3010" ( + if %ROUND% LSS %MAX_ROUNDS% ( + echo Reboot required, scheduling round %NEXT_ROUND%... + echo %NEXT_ROUND% > "%ROUND_FILE%" + :: Copy script to a path the wrapper won't delete + copy /y "%~f0" "C:\vmix-update-continue.cmd" >nul + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" /v vmixUpdate /t REG_SZ /d "cmd /c C:\vmix-update-continue.cmd" /f + :: Preserve Audit Mode across reboot (updates can reset it) + reg add "HKLM\SYSTEM\Setup\Status\AuditBoot" /v AuditBoot /t REG_DWORD /d 1 /f + reg add "HKLM\SYSTEM\Setup" /v AuditInProgress /t REG_DWORD /d 1 /f + :: Immediate reboot (preempts wrapper shutdown) + shutdown /r /f /t 0 + exit /b + ) else ( + echo Max update rounds reached. + ) + ) + + :: Done — clean up and shutdown + del /q "%ROUND_FILE%" 2>nul + del /q "C:\vmix-update-continue.cmd" 2>nul + echo === vmix: Windows Update complete === + shutdown /s /f /t 10 /c "vmix: windows-update complete" + ''; +} diff --git a/lib/images/windows/win10/images.nix b/lib/images/windows/win10/images.nix index 67ec23d..1aabf77 100644 --- a/lib/images/windows/win10/images.nix +++ b/lib/images/windows/win10/images.nix @@ -9,7 +9,9 @@ rec { upstreamISO = upstreamISOs.win10-ltsc-2021; productKey = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D"; }; - basic = customizeImageFold upstream (with templates; [ + # Apply all available Windows Updates (cumulative, .NET, Defender) + updated = customizeImage upstream (templates.essentials.windowsUpdate {}); + basic = customizeImageFold updated (with templates; [ essentials.virtioTools essentials.removeIE essentials.removeWMP @@ -38,8 +40,9 @@ rec { productKey = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D"; useAHCI = true; }; + laptopUpdated = customizeImage laptopUpstream (templates.essentials.windowsUpdate {}); - laptopSlim = customizeImageFold laptopUpstream templates.bundles.laptopSlim; + laptopSlim = customizeImageFold laptopUpdated templates.bundles.laptopSlim; - laptop = customizeImageFold laptopUpstream templates.bundles.laptop; + laptop = customizeImageFold laptopUpdated templates.bundles.laptop; } diff --git a/lib/images/windows/win11/images.nix b/lib/images/windows/win11/images.nix index 54eb076..730638e 100644 --- a/lib/images/windows/win11/images.nix +++ b/lib/images/windows/win11/images.nix @@ -12,7 +12,8 @@ rec { windowsVersionForVirtioDrivers = "w11"; }; - basic = customizeImageFold upstream (with templates; [ + updated = customizeImage upstream (templates.essentials.windowsUpdate {}); + basic = customizeImageFold updated (with templates; [ essentials.virtioTools essentials.removeIE essentials.removeWMP @@ -45,9 +46,11 @@ rec { windowsVersionForVirtioDrivers = "w11"; }; - laptopSlim = customizeImageFold laptopUpstream + laptopUpdated = customizeImage laptopUpstream (templates.essentials.windowsUpdate {}); + + laptopSlim = customizeImageFold laptopUpdated (templates.bundles.laptopSlim ++ [ templates.reg.disableUCPD ]); - laptop = customizeImageFold laptopUpstream + laptop = customizeImageFold laptopUpdated (templates.bundles.laptop ++ [ templates.reg.disableUCPD ]); } diff --git a/wip/win10-update.session.md b/wip/win10-update.session.md new file mode 100644 index 0000000..379f4f3 --- /dev/null +++ b/wip/win10-update.session.md @@ -0,0 +1,95 @@ +# Windows Update Template - Development Session + +## Goal +Create a `customizeImage` template that applies Windows Updates to an existing image via the Windows Update COM API in Audit Mode, then compacts the qcow2. + +## What was built + +### New files +- `lib/images/windows/templates/essentials/windows-update.nix` — template that boots into Audit Mode, runs Windows Update via COM API, handles reboots automatically, compacts image + +### Modified files +- `lib/images/windows/helpers/customizeImage.nix` — added `compact`, `qemuTimeout` parameters +- `lib/images/windows/templates/default.nix` — wired `windowsUpdate` into `essentials` +- `lib/images/windows/win10/images.nix` — added `updated` step between `upstream` and `basic` +- `lib/images/windows/win11/images.nix` — same + +### New customizeImage parameters +- `compact ? false` — flattens COW chain via `qemu-img convert` into standalone qcow2 (no backing file) +- `qemuTimeout ? 1800` — configurable QEMU timeout in seconds (was hardcoded 30 min) + +### How the template works +1. Boots image in Audit Mode with QEMU user networking +2. Starts `wuauserv` service, removes update-blocking policies +3. Uses PowerShell COM API (`Microsoft.Update.Session`) to search, download, install updates +4. Accepts EULAs, logs per-update results +5. If reboot required: copies script, preserves Audit Mode registry keys, re-registers via RunOnce, reboots +6. After reboot: continues with next round (up to `maxRounds`, default 3) +7. When done: runs `dism /Cleanup-Image /StartComponentCleanup /ResetBase`, shuts down +8. Host-side: `qemu-img convert` flattens COW chain (when `compact=true`) + +### Usage +```nix +# Online mode (default) - triggers Windows Update service +essentials.windowsUpdate {} +essentials.windowsUpdate { maxRounds = 5; } + +# In image pipeline +updated = customizeImage upstream (templates.essentials.windowsUpdate {}); +basic = customizeImageFold updated [ ... ]; +``` + +## Test results + +### What works +- Windows Update service starts successfully in Audit Mode +- COM API finds all available updates (cumulative, .NET, Defender, MSRT) +- Downloads and installs 6 updates including: + - 2026-05 Cumulative Update KB5087544 + - .NET Framework updates (KB5010472, KB5011048, KB5088859) + - Windows Malicious Software Removal Tool (KB890830) + - Microsoft Defender definitions (KB2267602) +- `compact` flag works — produces standalone qcow2 without backing file +- Image grows from 5.02 GB to ~8.33 GB with updates + +### Issues found and fixed during session + +1. **First build (no service start)**: COM API found 0 updates because `wuauserv` wasn't running + - Fix: added `net start wuauserv` + `sc config wuauserv start= auto` + remove blocking policies + 30s wait + +2. **Wrapper deletes script before reboot**: After round 1, the wrapper's `del /q C:\vmix-audit-script.cmd` runs before reboot completes, so RunOnce points to deleted file + - Fix: script copies itself to `C:\vmix-update-continue.cmd` before rebooting; registers that path in RunOnce + +3. **Audit Mode lost after reboot**: Cumulative updates can reset the Audit Mode flag, causing Windows to enter OOBE instead of Audit Mode after reboot + - Fix: explicitly set `AuditBoot=1` and `AuditInProgress=1` registry keys before rebooting + +4. **No shutdown after round 2**: When script runs from RunOnce (not wrapper), no shutdown command executes + - Fix: script always calls `shutdown /s /f /t 10` when done, regardless of invocation method + +### Remaining TODO (for next session on faster machine) +- [ ] Verify the Audit Mode preservation fix (AuditBoot + AuditInProgress registry keys) actually works — last build was still in round 1 install when session ended +- [ ] The cumulative update install takes ~60-90 min with 4GB RAM — consider using 8GB for faster builds +- [ ] Test round 2 → round 3 flow (does it find additional updates after cumulative?) +- [ ] Test with `compact = true` to verify final image is standalone +- [ ] Test full pipeline: `upstream → updated → basic → generalize` +- [ ] Test win11 images too +- [ ] Consider: should `windowsUpdate` go before or after `virtioTools` in the pipeline? Currently it's before (applied to raw upstream), but having virtio drivers might help with disk I/O performance during update install +- [ ] The template is impure (downloads from Microsoft during build) — document this clearly +- [ ] Consider adding a `maxRounds = 1` fast mode that skips the reboot cycle (just installs whatever doesn't need reboot) + +## Build command for testing +```bash +nix build --print-build-logs --impure --option sandbox relaxed --expr ' + let + vmixLib = (builtins.getFlake "path:/storage/gitrepos/vmix.nix").lib.x86_64-linux; + upstream = vmixLib.windows.images.win10.ltsc.upstream; + withVirtio = vmixLib.windows.customizeImage upstream vmixLib.windows.templates.essentials.virtioTools; + windowsUpdate = vmixLib.windows.templates.essentials.windowsUpdate {}; + in vmixLib.windows.customizeImage withVirtio (windowsUpdate // { vncDisplay = ":55"; compact = false; }) +' +``` + +Monitor via VNC on port 5955 (`localhost:55`): +```bash +gvnccapture localhost:55 /tmp/screenshot.png +``` From b6a080af4bb41b831c09e0f8113a6432ff8db487 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Tue, 9 Jun 2026 10:35:23 +0530 Subject: [PATCH 03/27] WIP: add Windows Update template with online COM API updates Add essentials.windowsUpdate template that boots Audit Mode, uses the Windows Update COM API to search/download/install all available updates (cumulative, .NET, Defender), handles multi-round reboots with Audit Mode preservation, and compacts the image afterward. Known issues being worked: - Audit Mode preservation after update reboot needs verification - Install takes ~60-90 min with 4GB RAM on slow machines Includes full session notes in wip/ with detailed test log, build commands, issue analysis, timing data, and Claude memory files. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/images/windows/templates/generalize.nix | 4 +- wip/claude-memory/MEMORY.md | 41 ++ wip/win10-update.session.md | 531 ++++++++++++++++++-- 3 files changed, 526 insertions(+), 50 deletions(-) create mode 100644 wip/claude-memory/MEMORY.md diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 6ff6c0a..cf1f2e2 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -81,7 +81,7 @@ in powershell -Command "Get-AppxPackage *MicrosoftEdgeDevToolsClient* | Remove-AppxPackage -ErrorAction SilentlyContinue" - :: Re-install product key to restore licensing after sysprep + :: 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 :: Restart SPP service and wait for it to settle @@ -95,7 +95,7 @@ in :: Activate Office using Ohook method (if Office is installed) if exist "C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE" ( if exist C:\MAS_AIO.cmd ( - echo. | call C:\MAS_AIO.cmd /Ohook >> C:\vmix-activation.log 2>&1 + echo. | call C:\MAS_AIO.cmd /Ohook ) ) del /q C:\MAS_AIO.cmd 2>nul diff --git a/wip/claude-memory/MEMORY.md b/wip/claude-memory/MEMORY.md new file mode 100644 index 0000000..fd5116d --- /dev/null +++ b/wip/claude-memory/MEMORY.md @@ -0,0 +1,41 @@ +# vmix.nix Project Memory + +## Build Preferences +- Always use VNC display (`:1` / port 5901) when building Windows images so progress can be monitored +- Pass `vncDisplay = ":1"` to customizeImage templates for build monitoring +- Use `gvnccapture localhost:1 /tmp/screenshot.png` to take VNC screenshots (package: gtk-vnc) +- For VNC inside vmix namespace: `ip netns exec windows.vmix nix-shell -p gtk-vnc --run 'gvnccapture 127.0.0.1:1 /tmp/screenshot.png'` + +## Key Architecture +- Offline registry uses `ControlSet001` (not `CurrentControlSet`) for virt-win-reg merges +- Sysprep resets offline registry changes — RDP must be re-enabled in post-OOBE script +- TermService won't listen on port 3389 in Audit Mode without a password on Administrator +- `LimitBlankPasswordUse=0` alone is NOT sufficient for RDP in Audit Mode — password required +- OOBE AutoLogon `` in unattend XML is unreliable — set via `reg add` in post-oobe.cmd instead +- OOBE creates user with blank password regardless of unattend — set real password via `net user` in post-oobe.cmd +- `sc config` can fail silently for some services — use `reg add` to set `Start` value directly + +## RDP on Win10 IoT Enterprise LTSC 2021 +- **CRITICAL**: `rdpwd.sys` and `tdtcp.sys` don't exist in this Windows build (removed in 19041+) +- `termsrv.dll` version is `10.0.19041.1202` — not supported by RDPWrap v1.6.2 or community INI files +- TermService runs but never creates the `rdp-tcp` WinStation listener — no port 3389 +- The ISO (`en-us_windows_10_iot_enterprise_ltsc_2021_x64_dvd_257ad90f.iso`) has 2 indexes: + - Index 1: Windows 10 Enterprise LTSC 2021 + - Index 2: Windows 10 IoT Enterprise LTSC 2021 +- Product key `M7XTQ-FN8P6-TTKYV-9D4CC-J462D` = Enterprise LTSC (not IoT) +- MAS HWID activation switches edition to IoT Enterprise S (partial key YY74H) +- **TODO**: Either generate custom RDPWrap config for termsrv 10.0.19041.1202, use a different ISO, or use third-party RDP server + +## generalize.nix Changes +- `enableRDP` flag added — applies RDP settings in post-oobe.cmd (survives sysprep) +- AutoLogon fix: blank password in unattend, real password + AutoAdminLogon registry in post-oobe.cmd +- `LogonCount=999` for persistent AutoLogon +- SessionEnv + UmRdpService set to auto-start via `reg add` (Start=2) +- Firewall: `Enable-NetFirewallRule -DisplayGroup 'Remote Desktop'` + `Set-NetFirewallRule -Profile Any` + +## labv2.nix junto Deployment +- vmix flake input rev is pinned in `flake.nix` — must update the URL to change versions +- Use `path:/storage/gitrepos/vmix.nix` for local dev, `git+https://...?rev=` for production +- `colmena apply-local` doesn't support `--override-input` +- DNS: `dns.resolver.useHostResolvConf = true` breaks when host uses systemd-resolved (127.0.0.53) — use explicit upstream like `1.1.1.1` +- QEMU Guest Agent socket at `/tmp/qga-win10.sock` — use from inside namespace diff --git a/wip/win10-update.session.md b/wip/win10-update.session.md index 379f4f3..60018ab 100644 --- a/wip/win10-update.session.md +++ b/wip/win10-update.session.md @@ -1,7 +1,47 @@ -# Windows Update Template - Development Session +# Windows Update Template - Full Development Session Log -## Goal -Create a `customizeImage` template that applies Windows Updates to an existing image via the Windows Update COM API in Audit Mode, then compacts the qcow2. +## Original Request +User wanted a reproducible way to update Windows images, inspired by https://massgrave.dev/update-windows-iso. The upstream Win10 LTSC 2021 ISO is stored in a git-lfs repo at `git.sagar.ch`. + +## Approach Evolution + +### Phase 1: ISO Update Approach (abandoned) +Initially explored creating an updated ISO by integrating cumulative updates. Considered: + +1. **UUP Dump approach**: The massgrave page links to uupdump.net which provides download scripts for building fresh ISOs from Microsoft's UUP (Unified Update Platform) files. The user received a bash script from UUP dump that: + - Uses `aria2c` to download UUP .cab files from Microsoft CDN + - Uses `cabextract`, `wimlib-imagex`, `chntpw` to build an ISO + - Uses `genisoimage`/`mkisofs` to create the final ISO + - Problem: downloads `core;professional` (Home/Pro), NOT LTSC + - Problem: download URLs contain auth tokens that expire — not reproducible + +2. **Offline DISM approach**: Considered creating a Nix derivation that: + - Boots a Windows worker VM from the existing upstream image + - Mounts the original ISO inside the VM + - Uses `DISM /Image` to service install.wim offline (mount WIM, apply .cab updates, unmount) + - Extracts the updated install.wim via guestfs + - Rebuilds the ISO with genisoimage on the Linux host + - Problem: very complex, requires managing WIM indexes, boot.wim, etc. + - Problem: needs a "worker" Windows VM just to run DISM + +3. **Offline .msu approach**: Considered downloading specific .msu files from Microsoft Update Catalog: + - Search catalog.update.microsoft.com for `cumulative update for windows 10 version 21H2 x64` + - Found latest: KB5087544 (May 2026), KB5088859 (.NET), KB2267602 (Defender) + - Problem: catalog uses JavaScript popups for downloads — can't scrape URLs + - Could use `curl` POST to `DownloadDialog.aspx` with update GUIDs, but GUIDs are hard to extract + - The download URLs from `catalog.s.download.windowsupdate.com` are stable (don't expire) but finding them requires browser interaction + +### Phase 2: customizeImage Template (adopted) +User said: "instead of iso, let's create a customizeImage template that basically updates the existing image and runs qcow2 compact on it" + +This is much simpler — apply updates to the running Windows image during Audit Mode, not to the ISO. + +### Phase 3: Online vs Offline Updates +Initially built the template with two modes: +- **Offline mode**: user provides `.msu`/`.cab` files as `fetchurl` derivations, applied via `DISM /Online /Add-Package` +- **Online mode**: triggers Windows Update service directly via COM API + +User said "just do online updates" — removed offline mode entirely. ## What was built @@ -9,26 +49,115 @@ Create a `customizeImage` template that applies Windows Updates to an existing i - `lib/images/windows/templates/essentials/windows-update.nix` — template that boots into Audit Mode, runs Windows Update via COM API, handles reboots automatically, compacts image ### Modified files -- `lib/images/windows/helpers/customizeImage.nix` — added `compact`, `qemuTimeout` parameters +- `lib/images/windows/helpers/customizeImage.nix` — added `compact`, `qemuTimeout` parameters (committed in `3b454b7` on master alongside other changes) - `lib/images/windows/templates/default.nix` — wired `windowsUpdate` into `essentials` - `lib/images/windows/win10/images.nix` — added `updated` step between `upstream` and `basic` - `lib/images/windows/win11/images.nix` — same ### New customizeImage parameters -- `compact ? false` — flattens COW chain via `qemu-img convert` into standalone qcow2 (no backing file) -- `qemuTimeout ? 1800` — configurable QEMU timeout in seconds (was hardcoded 30 min) + +#### `compact ? false` +Added to `customizeImage.nix`. When true, runs `qemu-img convert -O qcow2` after the audit boot to flatten the COW chain into a standalone qcow2 with no backing file dependency. This is important for the update template because the updates add several GB of data to the COW overlay. + +Implementation in `builderCommand`: +```bash +${lib.optionalString compact '' +echo "=== vmix: compacting image ===" +qemu-img convert -O qcow2 ${resultImg} compact.qcow2 +mv compact.qcow2 ${resultImg} +''} +mv ${resultImg} $out +``` + +#### `qemuTimeout ? 1800` +Added to `customizeImage.nix`. Replaces the hardcoded `timeout 1800` in the QEMU boot command. The Windows Update template sets this to `7200` (2 hours) because update installation with reboots can take a long time. + +Both `timeout` invocations in the builder (primary and SDL-fallback) now use `${toString qemuTimeout}`. ### How the template works -1. Boots image in Audit Mode with QEMU user networking -2. Starts `wuauserv` service, removes update-blocking policies -3. Uses PowerShell COM API (`Microsoft.Update.Session`) to search, download, install updates -4. Accepts EULAs, logs per-update results -5. If reboot required: copies script, preserves Audit Mode registry keys, re-registers via RunOnce, reboots -6. After reboot: continues with next round (up to `maxRounds`, default 3) -7. When done: runs `dism /Cleanup-Image /StartComponentCleanup /ResetBase`, shuts down -8. Host-side: `qemu-img convert` flattens COW chain (when `compact=true`) -### Usage +The template is a function that takes `{ maxRounds ? 3 }` and returns a customizeImage-compatible attrset: + +```nix +{ + name = "windows-update"; + compact = true; + memSize = 4096; + qemuTimeout = 7200; + auditScript = "..."; +} +``` + +#### Audit script flow + +1. **Round tracking**: Uses `C:\vmix-update-round.txt` to track which round we're on across reboots. First run creates the file with "1", subsequent runs read and increment. + +2. **Service initialization**: + ```batch + net start wuauserv 2>nul + sc config wuauserv start= auto + reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" /f 2>nul + reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /f 2>nul + ``` + The LTSC image may have update-blocking group policies. We delete them. We also wait 30 seconds on first round for the service to initialize and sync with Microsoft. + +3. **PowerShell COM API**: The core update logic uses `Microsoft.Update.Session`: + ```powershell + $session = New-Object -ComObject Microsoft.Update.Session + $searcher = $session.CreateUpdateSearcher() + $result = $searcher.Search('IsInstalled=0') + # ... accept EULAs, download, install ... + if ($installResult.RebootRequired) { exit 3010 } else { exit 0 } + ``` + - Exit code `3010` = reboot required + - Exit code `0` = no reboot needed (or no updates found) + - Exit code `1` = error (caught by try/catch) + - Per-update results are logged with HResult codes + +4. **Component cleanup**: After updates, runs `dism /Online /Cleanup-Image /StartComponentCleanup /ResetBase /Quiet` to reclaim space from superseded update components. + +5. **Reboot handling** (if exit code 3010 and round < maxRounds): + ```batch + :: Copy script to survive wrapper deletion + copy /y "%~f0" "C:\vmix-update-continue.cmd" >nul + :: Register for next boot + reg add "HKLM\...\RunOnce" /v vmixUpdate /d "cmd /c C:\vmix-update-continue.cmd" /f + :: Preserve Audit Mode + reg add "HKLM\SYSTEM\Setup\Status\AuditBoot" /v AuditBoot /t REG_DWORD /d 1 /f + reg add "HKLM\SYSTEM\Setup" /v AuditInProgress /t REG_DWORD /d 1 /f + :: Reboot + shutdown /r /f /t 0 + exit /b + ``` + +6. **Final shutdown**: When all rounds complete (or no more updates), the script shuts down: + ```batch + del /q "%ROUND_FILE%" 2>nul + del /q "C:\vmix-update-continue.cmd" 2>nul + echo === vmix: Windows Update complete === + shutdown /s /f /t 10 /c "vmix: windows-update complete" + ``` + +### Pipeline integration + +#### win10/images.nix +```nix +ltsc = rec { + upstream = makeImage { ... }; + updated = customizeImage upstream (templates.essentials.windowsUpdate {}); + basic = customizeImageFold updated (with templates; [ ... ]); + # ... withApps, withAMDGPU ... +}; +laptopUpstream = makeImage { ... useAHCI = true; }; +laptopUpdated = customizeImage laptopUpstream (templates.essentials.windowsUpdate {}); +laptopSlim = customizeImageFold laptopUpdated templates.bundles.laptopSlim; +laptop = customizeImageFold laptopUpdated templates.bundles.laptop; +``` + +#### win11/images.nix +Same pattern — `updated` step inserted between `upstream` and `basic`, `laptopUpdated` between `laptopUpstream` and `laptopSlim`/`laptop`. + +### Usage examples ```nix # Online mode (default) - triggers Windows Update service essentials.windowsUpdate {} @@ -37,47 +166,228 @@ essentials.windowsUpdate { maxRounds = 5; } # In image pipeline updated = customizeImage upstream (templates.essentials.windowsUpdate {}); basic = customizeImageFold updated [ ... ]; + +# Override VNC display and disable compact for debugging +vmixLib.windows.customizeImage upstream (windowsUpdate // { vncDisplay = ":55"; compact = false; }) ``` -## Test results +## Detailed Test Log -### What works -- Windows Update service starts successfully in Audit Mode -- COM API finds all available updates (cumulative, .NET, Defender, MSRT) -- Downloads and installs 6 updates including: - - 2026-05 Cumulative Update KB5087544 - - .NET Framework updates (KB5010472, KB5011048, KB5088859) - - Windows Malicious Software Removal Tool (KB890830) - - Microsoft Defender definitions (KB2267602) -- `compact` flag works — produces standalone qcow2 without backing file -- Image grows from 5.02 GB to ~8.33 GB with updates +### Build 1: First attempt (no VNC, wrong attr path) +```bash +nix build --print-build-logs --impure --option sandbox relaxed --expr ' + let vmixLib = (builtins.getFlake "path:/storage/gitrepos/vmix.nix").lib.x86_64-linux; + in vmixLib.images.win10.ltsc.updated +' +``` +**Result**: Error — `attribute 'images' missing`. The correct path is `vmixLib.windows.images.win10.ltsc.updated`, not `vmixLib.images.win10.ltsc.updated`. -### Issues found and fixed during session +### Build 2: Correct path, VNC :1, original script (no wuauserv start) +```bash +nix build ... --expr ' + let vmixLib = ...; + upstream = vmixLib.windows.images.win10.ltsc.upstream; + windowsUpdate = vmixLib.windows.templates.essentials.windowsUpdate {}; + in vmixLib.windows.customizeImage upstream (windowsUpdate // { vncDisplay = ":1"; }) +' +``` +**Result**: Build "succeeded" but image size identical to upstream (5.03 GB vs 5.02 GB). The COM API found 0 updates because: +- Windows Update service (`wuauserv`) wasn't running in Audit Mode +- No time given for service to initialize +- Possible update-blocking policies active -1. **First build (no service start)**: COM API found 0 updates because `wuauserv` wasn't running - - Fix: added `net start wuauserv` + `sc config wuauserv start= auto` + remove blocking policies + 30s wait +**Build log showed**: QEMU booted, script ran, shut down — no errors visible in nix log (VNC output isn't captured). -2. **Wrapper deletes script before reboot**: After round 1, the wrapper's `del /q C:\vmix-audit-script.cmd` runs before reboot completes, so RunOnce points to deleted file - - Fix: script copies itself to `C:\vmix-update-continue.cmd` before rebooting; registers that path in RunOnce +### Build 3: Added wuauserv start, policy removal, 30s wait +Updated `windows-update.nix` to add: +```batch +net start wuauserv 2>nul +sc config wuauserv start= auto +reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" /f 2>nul +reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /f 2>nul +timeout /t 30 /nobreak >nul +``` -3. **Audit Mode lost after reboot**: Cumulative updates can reset the Audit Mode flag, causing Windows to enter OOBE instead of Audit Mode after reboot - - Fix: explicitly set `AuditBoot=1` and `AuditInProgress=1` registry keys before rebooting +Also added EULA acceptance and try/catch error handling to the PowerShell block. -4. **No shutdown after round 2**: When script runs from RunOnce (not wrapper), no shutdown command executes - - Fix: script always calls `shutdown /s /f /t 10` when done, regardless of invocation method +**Build with VNC :55, chained virtio tools first**: +```bash +nix build ... --expr ' + let vmixLib = ...; + upstream = vmixLib.windows.images.win10.ltsc.upstream; + withVirtio = vmixLib.windows.customizeImage upstream vmixLib.windows.templates.essentials.virtioTools; + windowsUpdate = vmixLib.windows.templates.essentials.windowsUpdate {}; + in vmixLib.windows.customizeImage withVirtio (windowsUpdate // { vncDisplay = ":55"; compact = false; }) +' +``` -### Remaining TODO (for next session on faster machine) -- [ ] Verify the Audit Mode preservation fix (AuditBoot + AuditInProgress registry keys) actually works — last build was still in round 1 install when session ended -- [ ] The cumulative update install takes ~60-90 min with 4GB RAM — consider using 8GB for faster builds -- [ ] Test round 2 → round 3 flow (does it find additional updates after cumulative?) -- [ ] Test with `compact = true` to verify final image is standalone -- [ ] Test full pipeline: `upstream → updated → basic → generalize` -- [ ] Test win11 images too -- [ ] Consider: should `windowsUpdate` go before or after `virtioTools` in the pipeline? Currently it's before (applied to raw upstream), but having virtio drivers might help with disk I/O performance during update install -- [ ] The template is impure (downloads from Microsoft during build) — document this clearly -- [ ] Consider adding a `maxRounds = 1` fast mode that skips the reboot cycle (just installs whatever doesn't need reboot) +**VNC observations** (screenshots taken with `gvnccapture localhost:55 /tmp/screenshot.png`): -## Build command for testing +- **T+2min**: Boot screen "Please wait" +- **T+3min**: CMD window showing: + ``` + === vmix audit: windows-update === + === vmix: Windows Update round 1 of 3 === + The Windows Update service is starting. + The Windows Update service was started successfully. + [SC] ChangeServiceConfig SUCCESS + Waiting for Windows Update service to initialize... + Searching for updates... + ``` +- **T+7min**: Found 6 updates: + ``` + Found 6 updates + - 2022-02 Cumulative Update Preview for .NET Framework 3.5 and 4.8 for Windows 10 Version 21H2 for x64 (KB5010472) + - Microsoft .NET Framework 4.8.1 for Windows 10 Version 21H2 for x64 (KB5011048) + - Windows Malicious Software Removal Tool x64 - v5.141 (KB890830) + - 2026-05 Cumulative Update for .NET Framework 3.5, 4.8 and 4.8.1 for Windows 10 Version 21H2 for x64 (KB5088859) + - Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.451.326.0) - Current Channel (Broad) + - 2026-05 Cumulative Update for Windows 10 Version 21H2 for x64-based Systems (KB5087544) + Downloading... + ``` +- **T+12min**: "Installing..." +- **T+20min through T+55min**: Still "Installing..." — KB5087544 is a massive cumulative update spanning Oct 2021 → May 2026 +- **T+58min**: Sysprep dialog showing on desktop — the machine rebooted after update install and landed in Audit Mode desktop, but the round 2 script didn't run + +**Issue discovered**: The wrapper script (`vmix-audit-wrapper.cmd`) deletes `C:\vmix-audit-script.cmd` after the audit script returns but before the reboot completes. The RunOnce entry points to the deleted file. + +### Build 4: Added script copy fix + shutdown fix + +Updated script to: +1. Copy itself to `C:\vmix-update-continue.cmd` before rebooting +2. Register `cmd /c C:\vmix-update-continue.cmd` in RunOnce (not the original path) +3. Always call `shutdown /s /f /t 10` when done (handles both wrapper and RunOnce invocation) + +**Same build command as Build 3.** + +**VNC observations**: +- **T+3min**: Round 1 started, same 6 updates found +- **T+7min**: Downloading... +- **T+12min**: Installing... +- **T+55min**: TianoCore UEFI boot screen — machine rebooted! +- **T+57min**: "Working on updates — 90% complete" — Windows finalizing update installation after reboot +- **T+62min**: "Working on updates — 19% complete" (different counter — this is the post-reboot finalization) +- **T+70min**: Sysprep dialog... but NO round 2 script running + +**Issue discovered**: After reboot, Windows exited Audit Mode and entered OOBE ("Choose your keyboard layout" screen) instead of staying in Audit Mode. The cumulative update reset the Audit Mode flags during its finalization pass. + +Wait — actually on this build we saw the Sysprep dialog (which IS Audit Mode). The issue was the RunOnce not firing. On a subsequent build, we saw the keyboard layout screen (OOBE). The behavior is inconsistent. + +### Build 5: Added Audit Mode preservation + +Added registry keys before reboot to preserve Audit Mode: +```batch +reg add "HKLM\SYSTEM\Setup\Status\AuditBoot" /v AuditBoot /t REG_DWORD /d 1 /f +reg add "HKLM\SYSTEM\Setup" /v AuditInProgress /t REG_DWORD /d 1 /f +``` + +**Same build command. VNC observations**: +- **T+3min**: Round 1 started, same 6 updates found +- **T+70min**: Still on "Installing..." — round 1 didn't finish in 70 min on this machine +- **T+100min**: "Working on updates — 19% complete" — rebooted, finalizing +- **T+110min**: "Choose your keyboard layout" — OOBE screen again! + +**Session ended here** — the Audit Mode preservation fix hasn't been verified as working yet. The machine was moved to a faster machine for continued testing. + +## Detailed Issue Analysis + +### Issue 1: Windows Update service not running +**Root cause**: In Audit Mode, the Windows Update service (`wuauserv`) has `Start=3` (manual) and isn't auto-started. The COM API requires the service to be running to search for updates. + +**Fix**: Explicitly start the service and set it to auto-start: +```batch +net start wuauserv 2>nul +sc config wuauserv start= auto +``` + +Also remove any group policies that block updates (LTSC may have these pre-configured): +```batch +reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" /f 2>nul +reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /f 2>nul +``` + +And wait 30 seconds for the service to initialize and sync with Microsoft servers on first round. + +### Issue 2: Wrapper deletes script before reboot completes +**Root cause**: The `customizeImage.nix` wrapper script flow: +``` +call C:\vmix-audit-script.cmd ← our update script +del /q C:\vmix-audit-script.cmd ← DELETE happens here +shutdown /s /t 5 ← may override our shutdown /r +del /q C:\vmix-audit-wrapper.cmd +``` + +When our script calls `shutdown /r /f /t 0` and then `exit /b`, control returns to the wrapper. The wrapper deletes the script file. Even though `shutdown /r /t 0` was called, the batch file continues executing and the delete happens before the system actually reboots. + +The RunOnce entry points to `C:\vmix-audit-script.cmd` which no longer exists after reboot. + +Additionally, the wrapper's `shutdown /s /t 5` may override our `shutdown /r /t 0` (though in practice the `/t 0` reboot usually wins). + +**Fix**: Copy the script to a separate path before rebooting: +```batch +copy /y "%~f0" "C:\vmix-update-continue.cmd" >nul +reg add "HKLM\...\RunOnce" /v vmixUpdate /d "cmd /c C:\vmix-update-continue.cmd" /f +``` + +The wrapper only knows about `C:\vmix-audit-script.cmd` and `C:\vmix-audit-wrapper.cmd`. It doesn't know about `C:\vmix-update-continue.cmd`, so that file survives. + +### Issue 3: Audit Mode lost after update reboot +**Root cause**: Windows Audit Mode is maintained by registry keys: +- `HKLM\SYSTEM\Setup\Status\AuditBoot` (AuditBoot = 1) +- `HKLM\SYSTEM\Setup` (AuditInProgress = 1) + +Some cumulative updates include "specialize" or "generalize" passes during their finalization that can clear these keys, causing Windows to transition from Audit Mode to OOBE on the next boot. + +**Fix**: Explicitly set the Audit Mode registry keys right before rebooting: +```batch +reg add "HKLM\SYSTEM\Setup\Status\AuditBoot" /v AuditBoot /t REG_DWORD /d 1 /f +reg add "HKLM\SYSTEM\Setup" /v AuditInProgress /t REG_DWORD /d 1 /f +``` + +**Status**: NOT YET VERIFIED. The last build with this fix was still in the install phase when the session ended. The fix may or may not be sufficient — some updates may clear these keys AFTER the reboot during the "Working on updates X% complete" phase, which would happen after our registry writes. + +**Alternative approaches if the fix doesn't work**: +1. Use `C:\Windows\System32\Sysprep\sysprep.exe /audit /reboot /quiet` instead of `shutdown /r` — this explicitly tells Windows to re-enter Audit Mode +2. Set up a `SetupComplete.cmd` or `Specialize` unattend pass that forces Audit Mode +3. Use a scheduled task instead of RunOnce (scheduled tasks persist across mode transitions) +4. Accept single-round updates only (`maxRounds = 1`) — no reboot needed if updates don't require it + +### Issue 4: No shutdown after round 2 +**Root cause**: When the script is invoked via RunOnce (round 2+), it runs directly — not through the wrapper. The wrapper is what normally calls `shutdown /s /t 5` after the script completes. Without the wrapper, the script finishes and the machine just sits at the Audit Mode desktop indefinitely (until the 2-hour QEMU timeout). + +**Fix**: The script itself calls `shutdown /s /f /t 10` when all rounds are complete. This is harmless when called from the wrapper (two shutdown commands — the second one either fails silently or is a no-op since shutdown is already pending). + +## All Build Commands Used + +### Quick evaluation (check attribute paths) +```bash +nix eval --impure --expr ' + let vmixLib = (builtins.getFlake "path:/storage/gitrepos/vmix.nix").lib.x86_64-linux; + in builtins.attrNames vmixLib.windows.images.win10.ltsc +' +``` + +### Build updated image directly (no VNC, with compact) +```bash +nix build --print-build-logs --impure --option sandbox relaxed --expr ' + let + vmixLib = (builtins.getFlake "path:/storage/gitrepos/vmix.nix").lib.x86_64-linux; + in vmixLib.windows.images.win10.ltsc.updated +' +``` + +### Build with VNC monitoring on port 5901 (display :1) +```bash +nix build --print-build-logs --impure --option sandbox relaxed --expr ' + let + vmixLib = (builtins.getFlake "path:/storage/gitrepos/vmix.nix").lib.x86_64-linux; + upstream = vmixLib.windows.images.win10.ltsc.upstream; + windowsUpdate = vmixLib.windows.templates.essentials.windowsUpdate {}; + in vmixLib.windows.customizeImage upstream (windowsUpdate // { vncDisplay = ":1"; }) +' +``` + +### Build with virtio tools + VNC :55 + no compact (debugging) ```bash nix build --print-build-logs --impure --option sandbox relaxed --expr ' let @@ -89,7 +399,132 @@ nix build --print-build-logs --impure --option sandbox relaxed --expr ' ' ``` -Monitor via VNC on port 5955 (`localhost:55`): +### Build with more RAM (8GB) for faster installs ```bash -gvnccapture localhost:55 /tmp/screenshot.png +nix build --print-build-logs --impure --option sandbox relaxed --expr ' + let + vmixLib = (builtins.getFlake "path:/storage/gitrepos/vmix.nix").lib.x86_64-linux; + upstream = vmixLib.windows.images.win10.ltsc.upstream; + windowsUpdate = vmixLib.windows.templates.essentials.windowsUpdate {}; + in vmixLib.windows.customizeImage upstream (windowsUpdate // { vncDisplay = ":55"; memSize = 8192; }) +' ``` + +### Build with more rounds +```bash +nix build --print-build-logs --impure --option sandbox relaxed --expr ' + let + vmixLib = (builtins.getFlake "path:/storage/gitrepos/vmix.nix").lib.x86_64-linux; + upstream = vmixLib.windows.images.win10.ltsc.upstream; + windowsUpdate = vmixLib.windows.templates.essentials.windowsUpdate { maxRounds = 5; }; + in vmixLib.windows.customizeImage upstream (windowsUpdate // { vncDisplay = ":55"; }) +' +``` + +### Full pipeline test (upstream → updated → basic → generalize) +```bash +nix build --print-build-logs --impure --option sandbox relaxed --expr ' + let + vmixLib = (builtins.getFlake "path:/storage/gitrepos/vmix.nix").lib.x86_64-linux; + in vmixLib.windows.images.win10.ltsc.basic.generalize { + username = "User"; password = ""; hostname = "WIN-VM"; + vncDisplay = ":55"; + } +' +``` + +### VNC monitoring commands +```bash +# Take a screenshot +nix-shell -p gtk-vnc --run 'gvnccapture localhost:55 /tmp/screenshot.png' + +# Connect with a VNC viewer +vncviewer localhost:5955 + +# Check if QEMU is running +ps aux | grep 'qemu-system' | grep -v grep + +# Check nix build log for a store path +nix log /nix/store/HASH-windows-update-....qcow2 + +# Check image info +nix-shell -p qemu --run 'qemu-img info /nix/store/HASH-....qcow2' +``` + +## Image sizes observed +| Image | disk size | virtual size | +|-------|-----------|-------------| +| upstream (win10-ltsc-2021) | 5.02 GB | 64 GB | +| updated (killed build, round 1 only) | 8.33 GB | 64 GB | +| upstream (compacted, no updates) | 5.03 GB | 64 GB | + +## Updates found by Windows Update (Win10 LTSC 2021 → May 2026) +1. **2022-02 Cumulative Update Preview for .NET Framework 3.5 and 4.8** (KB5010472) +2. **Microsoft .NET Framework 4.8.1** for Windows 10 21H2 x64 (KB5011048) +3. **Windows Malicious Software Removal Tool x64 v5.141** (KB890830) +4. **2026-05 Cumulative Update for .NET Framework 3.5, 4.8 and 4.8.1** (KB5088859) +5. **Security Intelligence Update for Microsoft Defender Antivirus** (KB2267602, Version 1.451.326.0+) +6. **2026-05 Cumulative Update for Windows 10 Version 21H2** (KB5087544) — the big one, ~870 MB + +## Timing observations (4 vCPU, 4GB RAM machine) +- Nix evaluation: ~30 seconds +- VirtIO tools step (cached): instant +- Windows boot to Audit Mode desktop: ~60 seconds +- Windows Update search: ~2-3 minutes +- Download all 6 updates: ~3-5 minutes +- Install all updates (especially KB5087544): **50-70 minutes** +- Reboot + "Working on updates" finalization: ~10-15 minutes +- Total for round 1: ~70-90 minutes +- DISM cleanup: ~2-5 minutes +- qemu-img convert (compact): ~2-3 minutes + +## Remaining TODO (for next session on faster machine) +- [ ] **CRITICAL**: Verify the Audit Mode preservation fix works (AuditBoot + AuditInProgress registry keys) +- [ ] If Audit Mode fix doesn't work, try `sysprep /audit /reboot /quiet` instead of `shutdown /r` +- [ ] Test round 2 → round 3 flow (find additional updates after cumulative? likely Defender definition updates) +- [ ] Test with `compact = true` to verify final standalone image +- [ ] Test full pipeline: `upstream → updated → basic → generalize` +- [ ] Test win11 images +- [ ] Consider using 8GB RAM (`memSize = 8192`) for faster update installs +- [ ] Consider: should `windowsUpdate` go before or after `virtioTools`? Currently before in the pipeline, but the explicit build command chains virtio first for better I/O +- [ ] The template is impure (downloads from Microsoft during build) — this is intentional but should be documented +- [ ] Consider `maxRounds = 1` mode for builds where you only want non-reboot updates +- [ ] The `compact` step doesn't compress — consider adding `-c` flag to `qemu-img convert` for compressed qcow2 +- [ ] The wrapper's `shutdown /s /t 5` may still race with the script's `shutdown /r /t 0` — consider using `shutdown /a` (abort) before `shutdown /r` to cancel any pending shutdown + +## Architecture notes + +### How customizeImage works (for context) +1. Creates a COW overlay on the original image: `qemu-img create -f qcow2 -b ${originalImage} -F qcow2 disk.qcow2` +2. Optionally resizes: `qemu-img resize disk.qcow2 ${diskSize}` +3. Merges offline registry entries via `virt-win-reg --merge` +4. Injects audit script + wrapper via `virt-customize --upload` +5. Adds RunOnce registry entry for the wrapper +6. Boots QEMU with the image (user networking, UEFI, VirtIO or AHCI) +7. The wrapper runs the audit script, then shuts down +8. Optionally compacts: `qemu-img convert -O qcow2` +9. Moves result to `$out` + +### The wrapper problem +The wrapper (`vmix-audit-wrapper.cmd`) is designed for simple, single-boot templates: +```batch +call C:\vmix-audit-script.cmd +del /q C:\vmix-audit-script.cmd +shutdown /s /t 5 +del /q C:\vmix-audit-wrapper.cmd +``` + +This is fine for templates that don't reboot. But the Windows Update template needs multiple reboots, which conflicts with the wrapper's assumptions. The workarounds (copy script, self-shutdown) are necessary because modifying the wrapper would affect all templates. + +A future improvement might be to add a `multiboot` flag to customizeImage that changes the wrapper behavior for templates that need reboots. + +### Why QEMU user networking works for Windows Update +QEMU's `-nic user` (SLIRP) provides NAT networking. The guest gets DHCP and can reach the internet via the host. Windows Update uses HTTPS to Microsoft's servers, which works through NAT. No special firewall rules or port forwarding needed. + +### Why VirtIO tools are chained before updates (in test builds) +The upstream image uses VirtIO storage (`if=virtio`) but doesn't have VirtIO guest tools installed. The update template doesn't need guest tools to work, but having them improves: +- Disk I/O performance (VirtIO balloon, better driver) +- Memory management +- Guest agent for monitoring + +In the pipeline (`win10/images.nix`), updates are applied directly to `upstream` without virtio tools, because virtio tools installation is part of the `basic` step. For testing, we chain them explicitly. From 55697e5d8959f8693530bfc448314e7f7a93478d Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Tue, 9 Jun 2026 10:06:41 +0530 Subject: [PATCH 04/27] switch from HWID to TSforge activation - Switch MAS from /HWID to /Z-Windows (TSforge ZeroCID) which is hardware-independent and survives VM migration - Re-install product key and restart SPP service before TSforge to restore licensing state after sysprep - Add nicModel option to customizeImage and generalize for images without VirtIO drivers - Update MAS activation script to latest version Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/images/windows/helpers/customizeImage.nix | 13 +++++++++++-- lib/images/windows/templates/generalize.nix | 11 +++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/images/windows/helpers/customizeImage.nix b/lib/images/windows/helpers/customizeImage.nix index 79dc155..19758b9 100644 --- a/lib/images/windows/helpers/customizeImage.nix +++ b/lib/images/windows/helpers/customizeImage.nix @@ -25,6 +25,10 @@ smp ? 4, memSize ? 4096, nicModel ? null, + # Flatten COW chain into a standalone qcow2 (removes backing file dependency) + compact ? false, + # QEMU timeout in seconds (default 30 min, increase for Windows Update) + qemuTimeout ? 1800, }: let originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name); @@ -107,11 +111,11 @@ ${cdromArgs} \ -nic user,model=${if nicModel != null then nicModel else if isAHCI then "e1000" else "virtio-net-pci"}" - timeout 1800 qemu-system-x86_64 $VMIX_DISPLAY $QEMU_ARGS || \ + timeout ${toString qemuTimeout} qemu-system-x86_64 $VMIX_DISPLAY $QEMU_ARGS || \ if [[ "$VMIX_DISPLAY" == "-display sdl" ]]; then echo "=== vmix: SDL failed, retrying headless ===" cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd && chmod +w vars.fd - timeout 1800 qemu-system-x86_64 -nographic $QEMU_ARGS + timeout ${toString qemuTimeout} qemu-system-x86_64 -nographic $QEMU_ARGS else exit 1 fi @@ -125,6 +129,11 @@ [ -n "${diskSize}" ] && qemu-img resize ${resultImg} ${diskSize} ${virtWinRegMerge} ${auditBootCommands} + ${lib.optionalString compact '' + echo "=== vmix: compacting image ===" + qemu-img convert -O qcow2 ${resultImg} compact.qcow2 + mv compact.qcow2 ${resultImg} + ''} mv ${resultImg} $out ''; builtImage = pkgs.runCommand customImageName ({ diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 0caa5b9..cf1f2e2 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -81,9 +81,16 @@ in powershell -Command "Get-AppxPackage *MicrosoftEdgeDevToolsClient* | Remove-AppxPackage -ErrorAction SilentlyContinue" - :: Activate Windows using HWID method + :: 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 + :: Restart SPP service and wait for it to settle + net stop sppsvc /y 2>nul + net start sppsvc + ping -n 10 127.0.0.1 >nul + :: Activate Windows using TSforge if exist C:\MAS_AIO.cmd ( - echo. | call C:\MAS_AIO.cmd /HWID + echo. | call C:\MAS_AIO.cmd /Z-Windows ) :: Activate Office using Ohook method (if Office is installed) if exist "C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE" ( From 192ea9b54d346108de913a1a12d928c6ea3f58a7 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Mon, 15 Jun 2026 10:04:52 -0300 Subject: [PATCH 05/27] fix: use vmix's own locked nixpkgs for all image building The NixOS module was importing lib directly with the host's pkgs, causing image customization to use the host's guestfs-tools instead of vmix's locked version. guestfs-tools 1.52.2 (from host nixpkgs) has a bug that overwrites /boot/grub/grub.cfg with resolv.conf content, breaking VM boot. Now vmixLib is built once in flake.nix with vmix's own nixpkgs and passed through the overlay to pkgs.vmixLib. Removes overlay.nix and module.nix as the logic is inlined in flake.nix. Co-Authored-By: Claude Opus 4.6 (1M context) --- flake.nix | 7 +++++-- module.nix | 6 ------ nixos/default.nix | 6 ++---- overlay.nix | 7 ------- 4 files changed, 7 insertions(+), 19 deletions(-) delete mode 100644 module.nix delete mode 100644 overlay.nix diff --git a/flake.nix b/flake.nix index 9cbbbde..4ec2e5a 100644 --- a/flake.nix +++ b/flake.nix @@ -15,9 +15,12 @@ lib = pkgs.lib; vmixLib = import ./lib { inherit pkgs lib system; }; in { - overlays.default = import ./overlay.nix; + overlays.default = final: prev: { inherit vmixLib; }; - nixosModules.default = import ./module.nix; + nixosModules.default = { config, pkgs, lib, ... }: { + imports = [ ./nixos/default.nix ]; + config.nixpkgs.overlays = [ self.overlays.default ]; + }; lib.${system} = vmixLib; diff --git a/module.nix b/module.nix deleted file mode 100644 index 1f26736..0000000 --- a/module.nix +++ /dev/null @@ -1,6 +0,0 @@ -{ ... }: -{ - imports = [ - ./nixos/default.nix - ]; -} \ No newline at end of file diff --git a/nixos/default.nix b/nixos/default.nix index 2944e04..75dce49 100644 --- a/nixos/default.nix +++ b/nixos/default.nix @@ -1,7 +1,7 @@ { config, pkgs, lib, ... }: with lib; let - vmixLib = import ./../lib {inherit pkgs lib; }; + vmixLib = pkgs.vmixLib; args = { inherit config pkgs lib vmixLib; }; in { @@ -15,6 +15,4 @@ in (types.submodule (import ./namespaceSubmoduleOptions.nix args)); default = {}; }; - - config.nixpkgs.overlays = [ (import ../overlay.nix) ]; -} \ No newline at end of file +} diff --git a/overlay.nix b/overlay.nix deleted file mode 100644 index 0f26929..0000000 --- a/overlay.nix +++ /dev/null @@ -1,7 +0,0 @@ -final: prev: -let - # Pin vmixLib to nixpkgs 25-11 so all VM images are built with a consistent toolchain - vmixPkgs = prev.v25-11 or prev; -in { - vmixLib = vmixPkgs.callPackage ./lib {}; -} \ No newline at end of file From 40e80df84aebe62f197840725597f1fe6e225c78 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Mon, 15 Jun 2026 11:06:06 -0300 Subject: [PATCH 06/27] fix: ensure ip forwarding is enabled for vmix namespaces NixOS firewall sets conf.all.forwarding=false via mkDefault, which overrides ip_forward=1. Use normal priority to beat mkDefault. Co-Authored-By: Claude Opus 4.6 (1M context) --- nixos/networks/config.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nixos/networks/config.nix b/nixos/networks/config.nix index faaafba..37bf6a9 100644 --- a/nixos/networks/config.nix +++ b/nixos/networks/config.nix @@ -286,5 +286,6 @@ in { config.systemd.services = namespaceGlobalService // networkServices; config.systemd.targets = networkTargets; - config.boot.kernel.sysctl."net.ipv4.ip_forward" = lib.mkDefault 1; + config.boot.kernel.sysctl."net.ipv4.ip_forward" = lib.mkForce 1; + config.boot.kernel.sysctl."net.ipv4.conf.all.forwarding" = lib.mkForce true; } From 2f8925d4397a4eaf40546ef0ea8ecf5a8d827833 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 23 Jul 2026 20:23:54 -0300 Subject: [PATCH 07/27] fix: race in parallel wan veth creation cross-wiring namespaces All wan.net.vmix@* instances created their veth pair with the same temporary peer name 'vhost' in the host namespace before moving it into their netns. Parallel starts at boot could steal each other's peer ends, pairing a host-side vn- with another namespace's vhost (mismatched /30s, dead links) or leaving the pair stranded in the host namespace. Use a per-namespace temporary name (vh-) and rename to vhost only after the move, making concurrent creation collision-free. Co-Authored-By: Claude Fable 5 --- nixos/networks/config.nix | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/nixos/networks/config.nix b/nixos/networks/config.nix index 37bf6a9..658b6ac 100644 --- a/nixos/networks/config.nix +++ b/nixos/networks/config.nix @@ -172,6 +172,10 @@ let let wanCfg = cfg // { spaceName = spaceName; }; vethInNSToHost.iface = "vhost"; + # Temporary peer name, unique per namespace. The peer briefly exists in the + # host namespace before being moved; a shared name ("vhost") lets parallel + # wan.net.vmix@* starts steal each other's peer ends, cross-wiring namespaces. + vethInNSToHost.tempIface = "vh-${wanCfg.spaceName}"; vethOnHostToNS.iface = "vn-${wanCfg.spaceName}"; vethOnHostToNS.ipv4.address = calc.cidr.host 1 wanCfg.ipv4.range; vethInNSToHost.ipv4.address = calc.cidr.host 2 wanCfg.ipv4.range; @@ -180,8 +184,9 @@ let portForwardRules = lib.concatStringsSep "\n" (lib.mapAttrsToList (hostIPnPort: nsPort: "iptables -t nat -A PREROUTING -p tcp --dport ${hostIPnPort} -j DNAT --to-destination ${vethInNSToHost.ipv4.address}:${toString nsPort}") wanCfg.forwardPorts); createWanCommands = '' - ip link add ${vethOnHostToNS.iface} type veth peer name ${vethInNSToHost.iface} - ip link set ${vethInNSToHost.iface} netns ${wanCfg.spaceName}.vmix + ip link add ${vethOnHostToNS.iface} type veth peer name ${vethInNSToHost.tempIface} + ip link set ${vethInNSToHost.tempIface} netns ${wanCfg.spaceName}.vmix + ip netns exec ${wanCfg.spaceName}.vmix ip link set ${vethInNSToHost.tempIface} name ${vethInNSToHost.iface} ip address add ${vethOnHostToNS.ipv4.address}/${networkPrefix} dev ${vethOnHostToNS.iface} ip netns exec ${wanCfg.spaceName}.vmix ip address add ${vethInNSToHost.ipv4.address}/${networkPrefix} dev ${vethInNSToHost.iface} From 9784736260c8300d5d63b3f77a4690904b2cc5c0 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 23 Jul 2026 20:23:54 -0300 Subject: [PATCH 08/27] fix: manual-net-ifaces.d ordering never applied, vmbr0 randomly unconfigured After= was placed in [Service], which systemd ignores ('Unknown key After'), so the interfaces.d merge + ifreload raced networking.service on every boot. On losing boots vmbr0 never ran DHCP and the proxmox guest came up without its LAN IP (bridging still worked, so inner VMs stayed reachable while the PVE host itself was not). Move After= to [Unit] ordering against networking.service, and mkdir /run/network in ExecStartPre: the image-build workaround for ifupdown2#276 doesn't survive boots since /run is tmpfs. Co-Authored-By: Claude Fable 5 --- lib/images/linux/debian/templates.nix | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/images/linux/debian/templates.nix b/lib/images/linux/debian/templates.nix index 1fffeb3..af90a77 100644 --- a/lib/images/linux/debian/templates.nix +++ b/lib/images/linux/debian/templates.nix @@ -50,11 +50,18 @@ with scriptsNFiles; # proxmox makes it very hard to manually add interfaces directly on /etc/network/interfaces while the pve services are not running # it also doesn't pick up files in interfaces.d # so manually do that via service after boot + # After= must live in [Unit] — in [Service] systemd ignores it, leaving the + # merge/ifreload racing networking.service (and ifreload fails outright if it + # runs before /run/network exists, see ifupdown2#276). mergeNetIfacesDService = pkgs.writeText "manual-net-ifaces.d.service" '' + [Unit] + After = networking.service + Wants = networking.service + [Service] Type = oneshot + ExecStartPre = /bin/mkdir -p /run/network ExecStart = /bin/bash -c "cat /etc/network/interfaces.d/* >> /etc/network/interfaces; rm /etc/network/interfaces.d/*; ifreload -a;" - After = network.target [Install] WantedBy = multi-user.target From c213fc4db9ebda6bf185f6a7ee98981931160f93 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 9 Sep 2026 22:27:44 -0300 Subject: [PATCH 09/27] windows/pci: vIOMMU for nested passthrough, and a data disk built with the image Three things, all in service of putting Proxmox in a VM that can still hand a GPU to its own guests, and of a Windows VM whose profile survives its OS disk. pci.viommu.enable emits `-device intel-iommu,intremap=on,caching-mode=on` and forces kernel-irqchip=split, which interrupt remapping requires. Without an IOMMU of its own a guest cannot bind a passed-through device to vfio-pci, so it can never forward one on. The device leads the command line because QEMU realizes devices in order and intel-iommu must precede what it translates. pci.vgaPassthrough (default true, so nothing changes for existing VMs) makes x-vga=on optional. It was forced on the first passthrough device, which is wrong for a card the guest only forwards onward: it claims the VGA path the emulated console adapter needs. customizeImage gains extraDisk, a blank disk attached for the Audit Mode boot and emitted as the derivation's `data` output. generalize uses it for dataDisk and profilesDirectory, so the disk is partitioned and the profile relocated under OOBE in the build VM. That is what removes the need for delayOobeRun -- previously the volume ProfilesDirectory names could not exist until the image reached real hardware. A second output rather than a directory keeps ${image} meaning the OS qcow2 for every existing consumer. generalize also picks up staticIP and profilesDirectory. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/helpers/customizeImage.nix | 22 +++++- lib/images/windows/templates/generalize.nix | 67 ++++++++++++++++++- nixos/vms/config.nix | 12 +++- nixos/vms/submoduleOptions.nix | 25 +++++++ 4 files changed, 122 insertions(+), 4 deletions(-) diff --git a/lib/images/windows/helpers/customizeImage.nix b/lib/images/windows/helpers/customizeImage.nix index 19758b9..a1e7be8 100644 --- a/lib/images/windows/helpers/customizeImage.nix +++ b/lib/images/windows/helpers/customizeImage.nix @@ -29,6 +29,12 @@ compact ? false, # QEMU timeout in seconds (default 30 min, increase for Windows Update) qemuTimeout ? 1800, + # Blank disk attached for the Audit Mode boot, e.g. { size = "100G"; }. + # Windows sees it as disk 1, which is what lets a template partition it and + # relocate profiles onto it in that same boot instead of deferring OOBE to + # real hardware. Emitted as the derivation's `data` output, so whatever the + # template writes to it survives the build. + extraDisk ? null, }: let originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name); @@ -67,6 +73,11 @@ ]); cdromArgs = lib.concatMapStringsSep " \\\n " (cd: "-drive file=${cd},media=cdrom,readonly=on") cdroms; + extraDiskImg = "./extra.qcow2"; + extraDiskArgs = lib.optionalString (extraDisk != null) + (if isAHCI + then "-drive file=${extraDiskImg},format=qcow2,if=none,id=disk1 -device ide-hd,drive=disk1" + else "-drive file=${extraDiskImg},format=qcow2,if=virtio"); displayArg = if vncDisplay != null then "-vnc ${vncDisplay}" else null; @@ -109,6 +120,7 @@ then "-drive file=${resultImg},format=qcow2,if=none,id=disk0 -device ide-hd,drive=disk0" else "-drive file=${resultImg},format=qcow2,if=virtio"} \ ${cdromArgs} \ + ${extraDiskArgs} \ -nic user,model=${if nicModel != null then nicModel else if isAHCI then "e1000" else "virtio-net-pci"}" timeout ${toString qemuTimeout} qemu-system-x86_64 $VMIX_DISPLAY $QEMU_ARGS || \ @@ -127,6 +139,10 @@ # create resulting image backed by original image qemu-img create -f qcow2 -b ${originalImage} -F qcow2 ${resultImg} [ -n "${diskSize}" ] && qemu-img resize ${resultImg} ${diskSize} + ${lib.optionalString (extraDisk != null) '' + echo "=== vmix: creating extra disk (${extraDisk.size}) ===" + qemu-img create -f qcow2 ${extraDiskImg} ${extraDisk.size} + ''} ${virtWinRegMerge} ${auditBootCommands} ${lib.optionalString compact '' @@ -135,10 +151,14 @@ mv compact.qcow2 ${resultImg} ''} mv ${resultImg} $out + ${lib.optionalString (extraDisk != null) "mv ${extraDiskImg} $data"} ''; builtImage = pkgs.runCommand customImageName ({ nativeBuildInputs = with pkgs; [ qemu perl guestfs-tools ]; requiredSystemFeatures = [ "kvm" ]; - } // lib.optionalAttrs impure { __noChroot = true; }) builderCommand; + } // lib.optionalAttrs impure { __noChroot = true; } + # A second output rather than a directory, so ${image} keeps meaning the OS + # qcow2 for every existing consumer and the fold can still back onto it. + // lib.optionalAttrs (extraDisk != null) { outputs = [ "out" "data" ]; }) builderCommand; in builtImage // { _vmixOsType = "windows"; useAHCI = isAHCI; } diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index cf1f2e2..65194b2 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -23,6 +23,18 @@ in enableRDP ? false, # NIC model for the build VM (e.g. "e1000" for images without VirtIO drivers) nicModel ? null, + # Static IPv4 for the guest's single NIC, applied from inside Windows: + # { address = "10.10.10.26"; prefixLength = 24; gateway = "10.10.10.1"; + # dns = [ "10.10.10.1" ]; } + staticIP ? null, + # Relocate user profiles, e.g. "D:\\Users". Needs the volume to exist by the + # time specialize runs, which is what dataDisk arranges. + profilesDirectory ? null, + # Partition the non-OS disk and relocate user profiles onto it, e.g. + # { driveLetter = "D"; label = "data"; size = "100G"; }. The disk is attached + # during the build (see extraDisk in the returned set), so this is done and + # verified before the image ever reaches a host. + dataDisk ? null, # delayOobeRun = true: sysprep only, OOBE + activation on real hardware # delayOobeRun = false: sysprep + OOBE + activation in build VM delayOobeRun ? false, @@ -42,6 +54,45 @@ in stripHash = s: lib.removePrefix "#" s; bgRgb = if bgColor != null then hexToRgbStr (stripHash bgColor) else null; + staticDnsList = lib.optionalString (staticIP != null) + (lib.concatMapStringsSep "," (s: "'${s}'") staticIP.dns); + + dataDriveLetter = if dataDisk != null then (dataDisk.driveLetter or "D") else "D"; + dataLabel = if dataDisk != null then (dataDisk.label or "data") else "data"; + + # ProfilesDirectory is only honoured when the volume it names already exists, + # and a freshly created zvol arrives RAW. Initializing the disk here, in the + # same specialize pass, brings it up before oobeSystem creates any profile. + # + # Idempotent, because specialize runs again on every sysprep: a RAW disk gets + # 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" '' + @echo off + powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $d = Get-Disk | Where-Object Number -ne 0 | Sort-Object Number | Select-Object -First 1; if (-not $d) { exit 0 }; if ($d.PartitionStyle -eq 'RAW') { Initialize-Disk -Number $d.Number -PartitionStyle GPT -Confirm:$false; $p = New-Partition -DiskNumber $d.Number -UseMaximumSize -DriveLetter ${dataDriveLetter}; Format-Volume -Partition $p -FileSystem NTFS -NewFileSystemLabel '${dataLabel}' -Confirm:$false | Out-Null } else { $p = Get-Partition -DiskNumber $d.Number | Sort-Object Size -Descending | Select-Object -First 1; if ($p -and $p.DriveLetter -ne '${dataDriveLetter}') { Set-Partition -InputObject $p -NewDriveLetter ${dataDriveLetter} } }" + ''; + + folderLocationsXml = lib.optionalString (profilesDirectory != null) '' + + + ${profilesDirectory} + ''; + + dataDiskXml = lib.optionalString (dataDisk != null) '' + + + + + 1 + cmd /c C:\vmix-init-data-disk.cmd + vmix: initialize the data disk + + + ''; + # Post-OOBE script: runs as the created user via FirstLogonCommands. postOobeScript = pkgs.writeText "post-oobe.cmd" '' @echo off @@ -114,6 +165,13 @@ in reg add "HKLM\SYSTEM\CurrentControlSet\Services\TermService" /v Start /t REG_DWORD /d 2 /f ''} + ${lib.optionalString (staticIP != null) '' + :: 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. + :: Clearing first makes the command idempotent across re-runs. + powershell -NoProfile -Command "$a = Get-NetAdapter -Physical | Sort-Object ifIndex | Select-Object -First 1; Remove-NetIPAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; Remove-NetRoute -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; New-NetIPAddress -InterfaceIndex $a.ifIndex -IPAddress '${staticIP.address}' -PrefixLength ${toString staticIP.prefixLength} -DefaultGateway '${staticIP.gateway}' | Out-Null; Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ${staticDnsList}" + ''} + :: Clean up del /q C:\oobe-unattend.xml 2>nul del /q C:\vmix-audit-script.cmd 2>nul @@ -135,7 +193,9 @@ in Automatic +${folderLocationsXml} +${dataDiskXml} @@ -195,11 +255,16 @@ in in { name = if delayOobeRun then "generalize-delay-oobe" else "generalize"; inherit nicModel; + # 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; 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"; }; # delayOobeRun: sysprep + shutdown — OOBE runs on real hardware # generalize: sysprep + reboot into OOBE in the same QEMU session auditScript = '' diff --git a/nixos/vms/config.nix b/nixos/vms/config.nix index 42b0868..b9bac4b 100644 --- a/nixos/vms/config.nix +++ b/nixos/vms/config.nix @@ -79,6 +79,11 @@ let # Auto-detect Windows from _vmixOsType marker on the disk image isWindows = vmCfg.windows.enable || (hasOsDisk && (vmCfg.disks.os.file._vmixOsType or "linux") == "windows"); + # Interrupt remapping in the virtual IOMMU only works on a split irqchip, + # so viommu wins over the full in-kernel irqchip hideVirtualized asks for. + machineIrqchipArg = + if vmCfg.pci.viommu.enable then ",kernel-irqchip=split" + else optionalString vmCfg.cpu.hideVirtualized ",kernel_irqchip=on"; # Linux VMs: apply customizeImage with 9p fstab and machine-id setup linuxOsImage = vmixLib.linux.customizeImage vmCfg.disks.os.file { @@ -174,6 +179,9 @@ let ''} exec qemu-system-${vmCfg.arch} \ ${if vmCfg.nographic && vmCfg.pci.passthrough != [] then "-display none -vga none" else optionalString vmCfg.nographic "-nographic"} \ + ${# QEMU realizes devices in command-line order and intel-iommu must + # exist before anything it translates, so it leads the device list. + optionalString vmCfg.pci.viommu.enable "-device intel-iommu,intremap=on,caching-mode=on"} \ ${optionalString (vmCfg.vnc.enable && vmCfg.vnc.passwordFile != null) "-object secret,id=vnc-pass-${vmCfg.name},file=${escapeShellArg vmCfg.vnc.passwordFile}"} \ ${optionalString vmCfg.vnc.enable "-vnc ${vncArgs}"} \ ${optionalString (vmCfg.spice.enable && vmCfg.spice.passwordFile != null) "-object secret,id=spice-pass-${vmCfg.name},file=${escapeShellArg vmCfg.spice.passwordFile}"} \ @@ -189,7 +197,7 @@ let ${optionalString vmCfg.mem.balloon "-device virtio-balloon-pci"} \ -smp cores=${toString vmCfg.cpu.cores} \ -cpu ${vmCfg.cpu.model}${optionalString vmCfg.cpu.hideVirtualized ",kvm=off,hv_vendor_id=1234567890ab,-hypervisor"} \ - -machine type=${vmCfg.pc.type}${optionalString vmCfg.cpu.hideVirtualized ",kernel_irqchip=on"} \ + -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 @@ -220,7 +228,7 @@ let '') allMacvtaps)} \ ${concatStrings (imap1 (i: pciAddr: '' -device pcie-root-port,id=pci-passthrough${toString i},chassis=${toString i},slot=${toString i} \ - -device vfio-pci,host=${pciAddr},bus=pci-passthrough${toString i}${optionalString (i == 1) ",x-vga=on${optionalString (vmCfg.pci.romFile != null) ",romfile=${vmCfg.pci.romFile}"}"} \ + -device vfio-pci,host=${pciAddr},bus=pci-passthrough${toString i}${optionalString (i == 1) "${optionalString vmCfg.pci.vgaPassthrough ",x-vga=on"}${optionalString (vmCfg.pci.romFile != null) ",romfile=${vmCfg.pci.romFile}"}"} \ '') vmCfg.pci.passthrough)} \ ${concatMapStrings (usbDev: '' -device usb-host,vendorid=0x${usbDev.vendorId},productid=0x${usbDev.productId} \ diff --git a/nixos/vms/submoduleOptions.nix b/nixos/vms/submoduleOptions.nix index 0158c2b..c7cbd1a 100644 --- a/nixos/vms/submoduleOptions.nix +++ b/nixos/vms/submoduleOptions.nix @@ -262,11 +262,36 @@ with lib; default = []; description = "PCI device addresses to passthrough via VFIO (e.g. [\"0000:03:00.0\" \"0000:03:00.1\"])."; }; + pci.vgaPassthrough = mkOption { + type = types.bool; + default = true; + description = '' + Route legacy VGA to the first passthrough device (x-vga=on), which a + guest needs in order to drive that card as its own display. + + Turn it off when the guest only forwards the device onward to a nested + guest: x-vga=on claims the VGA path the emulated adapter wants, and the + nested guest does its own routing anyway. + ''; + }; pci.romFile = mkOption { type = types.nullOr types.path; default = null; description = "GPU VBIOS ROM file for the first passthrough device. Required when GPU PCI ROM BAR doesn't expose the full VBIOS (common with AMD Navi+)."; }; + pci.viommu.enable = mkOption { + type = types.bool; + default = false; + description = '' + Give the guest a virtual Intel IOMMU, so a guest that is itself a + hypervisor can bind a passed-through device to vfio-pci and hand it on + to a nested guest. Without one the guest sees no IOMMU and cannot + re-assign anything it was given. + + Implies kernel-irqchip=split, which interrupt remapping requires and + which replaces the full in-kernel irqchip cpu.hideVirtualized asks for. + ''; + }; usb.hostDevices = mkOption { default = []; From 9a8f5da998a6c703fb598f767c9792be69676adc Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 9 Sep 2026 23:22:40 -0300 Subject: [PATCH 10/27] generalize: writeFilter, putting UWF's overlay on the data volume A disk-mode overlay defaults to C:\uwfswap.sys -- on the very volume being protected, which is the opposite of the point. uwfmgr grew a create-swapfile subcommand for exactly this, and it only accepts the call while the filter is off and the overlay is already in disk mode, so the ordering in the generated script is forced rather than stylistic. Configuration is deferred to the target's first boot through RunOnce instead of running in the build. Two reasons: uwfmgr does not exist until the DISM feature has been through a reboot, and the swapfile belongs on the real data volume rather than on the throwaway copy the build attaches. Enabling the filter needs one more restart after that, which the script asks for itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 39 ++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 65194b2..c369eaa 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -35,6 +35,10 @@ in # during the build (see extraDisk in the returned set), so this is done and # verified before the image ever reaches a host. dataDisk ? null, + # Unified Write Filter: protect a volume by redirecting its writes to a + # disk-backed overlay held on another one, e.g. + # { protectedVolume = "C:"; swapfileVolume = "D:"; overlaySizeMB = 8192; } + writeFilter ? null, # delayOobeRun = true: sysprep only, OOBE + activation on real hardware # delayOobeRun = false: sysprep + OOBE + activation in build VM delayOobeRun ? false, @@ -54,6 +58,30 @@ in stripHash = s: lib.removePrefix "#" s; bgRgb = if bgColor != null then hexToRgbStr (stripHash bgColor) else null; + uwfProtected = if writeFilter != null then (writeFilter.protectedVolume or "C:") else "C:"; + uwfSwapVolume = if writeFilter != null then (writeFilter.swapfileVolume or "D:") else "D:"; + uwfOverlaySizeMB = if writeFilter != null then (writeFilter.overlaySizeMB or 8192) else 8192; + + # Runs from RunOnce on the target's first boot rather than during the build, + # for two reasons: enabling the DISM feature needs a reboot before uwfmgr + # exists at all, and the overlay swapfile has to be created on the real data + # volume rather than on the build's throwaway copy of it. + # + # Order is forced by uwfmgr: create-swapfile is only accepted while the + # filter is off and the overlay is already in disk mode. The default disk + # overlay would otherwise sit at C:\uwfswap.sys, on the volume being + # protected. Enabling the filter itself only takes effect after a restart. + uwfConfigScript = pkgs.writeText "vmix-uwf-config.cmd" '' + @echo off + uwfmgr.exe overlay set-type disk + uwfmgr.exe overlay set-size ${toString uwfOverlaySizeMB} + uwfmgr.exe volume create-swapfile ${uwfSwapVolume} + uwfmgr.exe volume protect ${uwfProtected} + uwfmgr.exe filter enable + del /q C:\vmix-uwf-config.cmd 2>nul + shutdown /r /t 10 /c "vmix: activating the write filter" + ''; + staticDnsList = lib.optionalString (staticIP != null) (lib.concatMapStringsSep "," (s: "'${s}'") staticIP.dns); @@ -172,6 +200,14 @@ in powershell -NoProfile -Command "$a = Get-NetAdapter -Physical | Sort-Object ifIndex | Select-Object -First 1; Remove-NetIPAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; Remove-NetRoute -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; New-NetIPAddress -InterfaceIndex $a.ifIndex -IPAddress '${staticIP.address}' -PrefixLength ${toString staticIP.prefixLength} -DefaultGateway '${staticIP.gateway}' | Out-Null; Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ${staticDnsList}" ''} + ${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 + :: real data volume, so RunOnce picks it up on the target's first boot. + dism /online /enable-feature /featurename:Client-UnifiedWriteFilter /all /norestart + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" /v "vmixUwf" /t REG_SZ /d "C:\vmix-uwf-config.cmd" /f + ''} + :: Clean up del /q C:\oobe-unattend.xml 2>nul del /q C:\vmix-audit-script.cmd 2>nul @@ -264,7 +300,8 @@ in { { 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.optional (dataDisk != null) { source = initDataDiskScript; dest = "/vmix-init-data-disk.cmd"; } + ++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; }; # delayOobeRun: sysprep + shutdown — OOBE runs on real hardware # generalize: sysprep + reboot into OOBE in the same QEMU session auditScript = '' From 97fdbce6d0620a4b04b2152571599207eb8a005f Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 10 Sep 2026 00:06:07 -0300 Subject: [PATCH 11/27] windows images: stop a non-matching display glob from poisoning the build env Image builds have been dying with "Argument list too long" from sed, mktemp and timeout alike -- commands whose argv is trivial, which is the tell that it was the environment that had grown, not the arguments. The X11 forwarding hint is looked up with `ls -t /tmp/.vmix-display-* | head -1`. Under nullglob a non-matching pattern is removed from the command line rather than passed through literally, so `ls -t` runs with no arguments at all and lists the working directory instead. In a nix build that directory is the build tree, whose newest file is nix's own env-vars dump. The result is that VMIX_DF becomes "env-vars", the SDL branch is taken on a machine with no X at all, and DISPLAY is exported with a slice of the env dump inside it. From that line onward every exec in the build fails with E2BIG. find does the same lookup without depending on how the shell treats an unmatched pattern, and -type f keeps a stray directory out of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/helpers/customizeImage.nix | 6 +++++- lib/images/windows/helpers/makeImage.nix | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/images/windows/helpers/customizeImage.nix b/lib/images/windows/helpers/customizeImage.nix index a1e7be8..51b4d30 100644 --- a/lib/images/windows/helpers/customizeImage.nix +++ b/lib/images/windows/helpers/customizeImage.nix @@ -99,7 +99,11 @@ VMIX_DISPLAY="-nographic" ${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-vnc ${vncDisplay}"''} ${lib.optionalString (vncDisplay == null) '' - VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1) + # find, not a glob: under nullglob a non-matching /tmp/.vmix-display-* + # disappears entirely, leaving `ls -t` to list the build directory and + # hand back nix's own env-vars dump. Exporting that as DISPLAY bloats + # the environment until every exec dies with E2BIG. + VMIX_DF=$(find /tmp -maxdepth 1 -type f -name '.vmix-display-*' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-) if [ -n "$VMIX_DF" ]; then export DISPLAY=$(sed -n '1p' "$VMIX_DF") export XAUTHORITY=$(sed -n '2p' "$VMIX_DF") diff --git a/lib/images/windows/helpers/makeImage.nix b/lib/images/windows/helpers/makeImage.nix index 983c6fb..0f16750 100644 --- a/lib/images/windows/helpers/makeImage.nix +++ b/lib/images/windows/helpers/makeImage.nix @@ -49,7 +49,11 @@ let VMIX_DISPLAY="-nographic" ${lib.optionalString (displayArg != null) ''VMIX_DISPLAY="${displayArg}"''} ${lib.optionalString (displayArg == null) '' - VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1) + # find, not a glob: under nullglob a non-matching /tmp/.vmix-display-* + # disappears entirely, leaving `ls -t` to list the build directory and + # hand back nix's own env-vars dump. Exporting that as DISPLAY bloats the + # environment until every exec dies with E2BIG. + VMIX_DF=$(find /tmp -maxdepth 1 -type f -name '.vmix-display-*' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-) if [ -n "$VMIX_DF" ]; then export DISPLAY=$(sed -n '1p' "$VMIX_DF") export XAUTHORITY=$(sed -n '2p' "$VMIX_DF") From 0e4619eb3931ea4f90ba2239466cf01a21047aa2 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 10 Sep 2026 04:58:54 -0300 Subject: [PATCH 12/27] pci: keep multifunction devices together; data disk: diskpart, not Storage cmdlets Two defects found while getting a GPU through Proxmox to a nested guest. Passthrough gave every address its own pcie-root-port, which splits a GPU from its own HDMI audio: 05:00.0 and 05:00.1 arrived in the guest as two devices on two buses instead of functions 0 and 1 of one device. Navi needs both halves on one device to reset or power-manage either, so the guest got a card stuck in D3 and a reset that could not be performed. Addresses are now grouped by everything left of the function digit, and each group goes behind one root port at one slot with multifunction=on on function 0 -- which is also where the VBIOS and the VGA route belong. The data-disk setup used Initialize-Disk/New-Partition/Format-Volume. Only the first of those works that early in specialize; the rest need services that are not up yet, and with ErrorActionPreference=Stop the script gave up straight after writing a GPT header. The result was a 50G disk carrying 24KB of nothing and a ProfilesDirectory pointing at a volume that never existed. diskpart works at that stage. It also tries assigning the letter before laying the disk out, so a disk that already holds a profile is lettered rather than cleaned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 28 ++++++++++++++++++++- nixos/vms/config.nix | 19 +++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index c369eaa..3e43a93 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -98,7 +98,33 @@ in # OS disk is added to QEMU first and so is always disk 0. initDataDiskScript = pkgs.writeText "vmix-init-data-disk.cmd" '' @echo off - powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $d = Get-Disk | Where-Object Number -ne 0 | Sort-Object Number | Select-Object -First 1; if (-not $d) { exit 0 }; if ($d.PartitionStyle -eq 'RAW') { Initialize-Disk -Number $d.Number -PartitionStyle GPT -Confirm:$false; $p = New-Partition -DiskNumber $d.Number -UseMaximumSize -DriveLetter ${dataDriveLetter}; Format-Volume -Partition $p -FileSystem NTFS -NewFileSystemLabel '${dataLabel}' -Confirm:$false | Out-Null } else { $p = Get-Partition -DiskNumber $d.Number | Sort-Object Size -Descending | Select-Object -First 1; if ($p -and $p.DriveLetter -ne '${dataDriveLetter}') { Set-Partition -InputObject $p -NewDriveLetter ${dataDriveLetter} } }" + :: diskpart rather than the Storage cmdlets. New-Partition and + :: Format-Volume need services that are not up yet this early in + :: specialize, so they fail where Initialize-Disk succeeds -- which left + :: the disk carrying a GPT header and nothing else, and ProfilesDirectory + :: pointing at a volume that never existed. + if exist ${dataDriveLetter}:\ goto :done + + :: The volume may already be laid out and merely unlettered, in which case + :: assigning is enough and cleaning would destroy the profile. + > 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 + if exist ${dataDriveLetter}:\ goto :cleanup + + :: Nothing there to keep, so lay the disk out from scratch. + > 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 + + :cleanup + del /q C:\Windows\Temp\vmix-dd-assign.txt C:\Windows\Temp\vmix-dd-init.txt 2>nul + :done ''; folderLocationsXml = lib.optionalString (profilesDirectory != null) '' diff --git a/nixos/vms/config.nix b/nixos/vms/config.nix index b9bac4b..4dc86a7 100644 --- a/nixos/vms/config.nix +++ b/nixos/vms/config.nix @@ -84,6 +84,18 @@ let machineIrqchipArg = if vmCfg.pci.viommu.enable then ",kernel-irqchip=split" else optionalString vmCfg.cpu.hideVirtualized ",kernel_irqchip=on"; + # Functions of one physical device have to reach the guest as functions + # of one device too. Giving each address its own root port splits a GPU + # from its own HDMI audio, and Navi cannot then reset or power-manage + # either half -- the guest ends up with a card stuck in D3. So group by + # everything left of the function digit and place each group behind a + # single root port, multifunction, at the same slot. + pciDeviceOf = addr: head (splitString "." addr); + pciFunctionOf = addr: last (splitString "." addr); + pciGroups = map + (dev: filter (a: pciDeviceOf a == dev) vmCfg.pci.passthrough) + (unique (map pciDeviceOf vmCfg.pci.passthrough)); + # Linux VMs: apply customizeImage with 9p fstab and machine-id setup linuxOsImage = vmixLib.linux.customizeImage vmCfg.disks.os.file { @@ -226,10 +238,11 @@ let -device ${vmCfg.nicModel},netdev=macvtap-${macvtap.name},mac=$(ip l show ${macvtap.iface} | awk '/link\/ether/{print $2}') \ -netdev tap,id=macvtap-${macvtap.name},fd=${toString (i+2)} ${toString (i+2)}<>/dev/tap$(ip l show ${macvtap.iface} | awk -F':' '/${macvtap.iface}/{print $1}') \ '') allMacvtaps)} \ - ${concatStrings (imap1 (i: pciAddr: '' + ${concatStrings (imap1 (i: group: '' -device pcie-root-port,id=pci-passthrough${toString i},chassis=${toString i},slot=${toString i} \ - -device vfio-pci,host=${pciAddr},bus=pci-passthrough${toString i}${optionalString (i == 1) "${optionalString vmCfg.pci.vgaPassthrough ",x-vga=on"}${optionalString (vmCfg.pci.romFile != null) ",romfile=${vmCfg.pci.romFile}"}"} \ - '') vmCfg.pci.passthrough)} \ + '' + concatStrings (imap0 (j: pciAddr: '' + -device vfio-pci,host=${pciAddr},bus=pci-passthrough${toString i},addr=0x0.${pciFunctionOf pciAddr}${optionalString (length group > 1 && j == 0) ",multifunction=on"}${optionalString (i == 1 && j == 0) "${optionalString vmCfg.pci.vgaPassthrough ",x-vga=on"}${optionalString (vmCfg.pci.romFile != null) ",romfile=${vmCfg.pci.romFile}"}"} \ + '') group)) pciGroups)} \ ${concatMapStrings (usbDev: '' -device usb-host,vendorid=0x${usbDev.vendorId},productid=0x${usbDev.productId} \ '') vmCfg.usb.hostDevices} \ From 23c539dbe2eed233d4ebfe78c49ff5c0ba94fc13 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 10 Sep 2026 05:11:40 -0300 Subject: [PATCH 13/27] generalize: lay the data disk out in Audit Mode, not only in specialize Formatting it from a specialize RunSynchronousCommand is not enough on its own. FolderLocations is applied by Shell-Setup while the disk is prepared by Deployment, and component order within a pass is not guaranteed -- so the relocation can be evaluated before the volume it names exists, which fails silently and leaves profiles on C:. That is what a correctly formatted data disk carrying nothing but NTFS metadata was telling us. Audit Mode is a fully booted OS with the disk already attached, so doing it before sysprep makes the volume unconditionally present by the time any pass looks for it. The specialize copy stays, now purely to re-assert the drive letter after generalize clears MountedDevices. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 3e43a93..3b4d3fe 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -336,6 +336,17 @@ 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) '' + :: 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 + :: by Deployment -- so relocation can be evaluated before the volume it + :: names exists, which silently leaves profiles on C:. Audit Mode is a + :: 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. + 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 ''; } From c0e12934050a2ae2c3d3f075ac805500f2ab9fca Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 10 Sep 2026 06:10:35 -0300 Subject: [PATCH 14/27] generalize: drop CopyProfile when relocating profiles, and name FolderLocations twice The data volume comes out correctly partitioned, formatted and labelled, and containing nothing but $RECYCLE.BIN and System Volume Information -- so the disk work lands and the relocation does not. Two candidates, both cheap to address together. CopyProfile is now dropped whenever profilesDirectory is set. Sysprep choosing a profile to copy into Default while the profile root is being moved is the likelier of the two, and a profile that persists is worth more than the Audit Mode customizations that CopyProfile preserves. FolderLocations is now named in oobeSystem as well as specialize. Which pass honours it is not something the documentation is crisp about, and saying it twice costs nothing. This matters more than it looks: with UWF protecting C: and the profile still on C:, every profile write lands in the overlay and is discarded on reboot, which makes the whole VM stateless. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 3b4d3fe..ed37374 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -247,11 +247,20 @@ in - + - true +${lib.optionalString (profilesDirectory == null) " true"} Automatic @@ -302,6 +311,7 @@ ${dataDiskXml} ${username} ${hostname} +${folderLocationsXml} ${timezone} From a378eb4ad7f9107291fdb6fd3f34bb963e82dd83 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 10 Sep 2026 06:56:00 -0300 Subject: [PATCH 15/27] generalize: turn DHCP off before asserting a static address, and answer pings The VM came up holding a DHCP lease rather than the address it was told to take, while RDP -- configured a few lines earlier in the same script -- worked fine. So post-oobe.cmd was running; only the addressing failed. Two reasons, both fixed. The interface arrives DHCP-managed and nothing turned DHCP off, so New-NetIPAddress had no lasting effect. And FirstLogonCommands can run before the adapter is up, so it is now waited for rather than assumed. Moved out of post-oobe.cmd into its own file. The command is long and full of quotes and pipes, which is not a thing to leave at the mercy of cmd's parsing. It also logs, so the next failure can be read off the disk instead of inferred. Pings are now allowed too. Windows blocks ICMP by default, which makes a box at a fixed address look dead to everything that checks it the obvious way -- including me, for a while. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index ed37374..5db8285 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -85,6 +85,22 @@ in staticDnsList = lib.optionalString (staticIP != null) (lib.concatMapStringsSep "," (s: "'${s}'") staticIP.dns); + # Its own file rather than inline in post-oobe.cmd: the command is long, and + # cmd's handling of quotes and pipes inside it is a needless hazard. + # + # Two things this has to get right. The adapter may not be up yet when + # FirstLogonCommands runs, so it is waited for rather than assumed. And the + # 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. + 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 + ''; + dataDriveLetter = if dataDisk != null then (dataDisk.driveLetter or "D") else "D"; dataLabel = if dataDisk != null then (dataDisk.label or "data") else "data"; @@ -223,7 +239,7 @@ in :: 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. :: Clearing first makes the command idempotent across re-runs. - powershell -NoProfile -Command "$a = Get-NetAdapter -Physical | Sort-Object ifIndex | Select-Object -First 1; Remove-NetIPAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; Remove-NetRoute -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; New-NetIPAddress -InterfaceIndex $a.ifIndex -IPAddress '${staticIP.address}' -PrefixLength ${toString staticIP.prefixLength} -DefaultGateway '${staticIP.gateway}' | Out-Null; Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ${staticDnsList}" + call C:\vmix-static-ip.cmd ''} ${lib.optionalString (writeFilter != null) '' @@ -337,7 +353,8 @@ in { { 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.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; }; + ++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; } + ++ lib.optional (staticIP != null) { source = staticIPScript; dest = "/vmix-static-ip.cmd"; }; # delayOobeRun: sysprep + shutdown — OOBE runs on real hardware # generalize: sysprep + reboot into OOBE in the same QEMU session auditScript = '' From 6a62a649bd407576d6595a90fa111da75512eca3 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 10 Sep 2026 07:28:14 -0300 Subject: [PATCH 16/27] generalize: assert the static address per boot, not once during the build Setting it from post-oobe.cmd could never have worked, and the reason is worth writing down. Without delayOobeRun, OOBE runs inside the build VM -- whose NIC is qemu user networking, on a different subnet, with a different MAC. The address was being applied to an adapter that does not exist on the real host. Windows then meets the target's NIC as new hardware and defaults to DHCP. RDP came through the same script unharmed because its settings are registry-wide rather than per-adapter, which is why one worked and the other did not despite sitting a few lines apart. So the script is now registered as an onstart scheduled task running as SYSTEM. It was already idempotent, and per-boot also survives the adapter being replaced again later. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 5db8285..52109d6 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -238,8 +238,13 @@ in ${lib.optionalString (staticIP != null) '' :: 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. - :: Clearing first makes the command idempotent across re-runs. - call C:\vmix-static-ip.cmd + :: Registered to run at every boot rather than applied here. OOBE runs in + :: the build VM, whose NIC is qemu user networking on another subnet with + :: another MAC -- so an address set now lands on an adapter that does not + :: exist on the real host. Windows sees the target's NIC as new hardware + :: and falls back to DHCP, which is exactly what happened. Per-boot also + :: survives the adapter being replaced again later. + schtasks /create /tn "vmix-static-ip" /tr "C:\vmix-static-ip.cmd" /sc onstart /ru SYSTEM /rl HIGHEST /f > nul 2>&1 ''} ${lib.optionalString (writeFilter != null) '' From d94c576df2676a01d94a80c5022d237aa6b150ae Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 10 Sep 2026 17:43:11 -0300 Subject: [PATCH 17/27] generalize: assign the data-disk letter every boot, and heal a temp profile The relocated profile went temporary on the target and stayed that way. The cause was not the profile: the image ships with ProfilesDirectory set to D:\Users but with no drive letter for the data disk. generalize strips MountedDevices, and the specialize pass that re-asserts the letter runs only in the build VM, never on the target -- so the zvol boots letterless, the first autologon cannot find D:\Users\sagar (event 1511), and Windows falls back to a temporary profile, renames the real ProfileList key to .bak, and the fault sticks on every later logon. Confirmed by reading the shipped image offline: ProfileList has the SID at D:\Users\TEMP with a .bak sibling at D:\Users\sagar, MountedDevices carries no \DosDevices\D:, and the sagar hive on the zvol is intact -- so nothing was wrong but the letter. An onstart SYSTEM task now runs the existing (idempotent) data-disk init, whose diskpart assign writes MountedDevices and so makes D: persistent for every later boot. Only the first boot is exposed to the race; if it left a .bak, a small PowerShell heal puts the key back, drops the temp profile, and reboots once -- after which D: is persistent and the real profile loads. Same onstart / SYSTEM mechanism the static address already uses. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 56 ++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 52109d6..9c2e7e8 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -143,6 +143,49 @@ in :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. + healProfileScript = pkgs.writeText "vmix-heal-profile.ps1" '' + $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 + } + ''; + + # Ensures D: exists on every boot and heals a profile that went temporary. + # + # generalize strips MountedDevices, so the shipped image carries no drive + # letter for the data disk, and the specialize pass that would re-assert it + # runs only in the build, not on the target. The zvol therefore boots + # letterless, the first autologon cannot find its relocated profile at + # ${profilesDirectory}\${username} (event 1511) and falls back to a temporary + # one, backing the real key up as .bak and making the fault stick. + # + # diskpart assign writes MountedDevices, so once this has run once D: is + # persistent for every later boot. Only the first boot is exposed, and if it + # left a .bak the heal puts it back and reboots -- the next boot, D: now + # persistent and ProfileList clean, logs straight into the real profile. + bootDataProfileScript = pkgs.writeText "vmix-data-profile.cmd" '' + @echo off + call C:\vmix-init-data-disk.cmd + 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" + ) + ''; + folderLocationsXml = lib.optionalString (profilesDirectory != null) '' @@ -247,6 +290,13 @@ 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 (dataDisk != null) '' + :: 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. + 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 @@ -357,7 +407,11 @@ in { { 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 = healProfileScript; dest = "/vmix-heal-profile.ps1"; } + { source = bootDataProfileScript; dest = "/vmix-data-profile.cmd"; } + ] ++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; } ++ lib.optional (staticIP != null) { source = staticIPScript; dest = "/vmix-static-ip.cmd"; }; # delayOobeRun: sysprep + shutdown — OOBE runs on real hardware From 5251bf72904cdd582f34d7f0ec8e9af38392c40b Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 10 Sep 2026 19:49:40 -0300 Subject: [PATCH 18/27] generalize: keep the static address from stranding the box after a reboot It worked on the first boot and was gone after a later internal reboot -- no IPv4 at all, not even DHCP. DHCP is turned off before the address is set, so anything that stops the set mid-way leaves the interface with nothing. A stale ARP entry for the address, left on the network by the previous instance, tripped duplicate-address detection and made New-NetIPAddress throw; with -ErrorAction Stop that aborted the script with DHCP already off. DadTransmits 0 turns that detection off so the static binds regardless of what the network remembers, and the assignment is now retried a few times rather than fatal on the first throw. Moved to its own .ps1 -- a wait loop and a retry are not worth keeping correct inside a cmd one-liner. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 49 ++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 9c2e7e8..b38811b 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -93,12 +93,48 @@ 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. + staticIPScriptPs1 = pkgs.writeText "vmix-static-ip.ps1" '' + $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 '${staticIP.address}' -PrefixLength ${toString staticIP.prefixLength} -DefaultGateway '${staticIP.gateway}' -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 ${staticDnsList} + 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) + ''; + + # 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"; @@ -413,7 +449,10 @@ in { { source = bootDataProfileScript; dest = "/vmix-data-profile.cmd"; } ] ++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; } - ++ lib.optional (staticIP != null) { source = staticIPScript; dest = "/vmix-static-ip.cmd"; }; + ++ lib.optionals (staticIP != null) [ + { 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 = '' From f7405b8e70b1c6e6527bfffe3782aec531ccf1fa Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Thu, 10 Sep 2026 20:05:27 -0300 Subject: [PATCH 19/27] generalize: keepMachineSid, for a profile that survives an OS rebuild sysprep /generalize regenerates the machine SID on every build, so an account built by one image does not match a profile left on a persistent disk by an earlier one -- different SID, so file ACLs, the NTUSER.DAT hive and ProfileList all mismatch, and the profile will not load. keepMachineSid drops /generalize and uses /oobe alone. The SID is then inherited from the cached base install derivation, which is content-addressed and so identical across every rebuild of the layers above it; the account, always RID 1000, comes out the same each time. A profile kept on a data disk then matches exactly, with no ownership or ProfileList fixups. Without /generalize the specialize pass does not run, so the profile relocation cannot ride the unattend there. It is written to the registry offline instead, before the build's OOBE, which virt-win-reg applies ahead of the Audit Mode boot. And because /generalize is also what strips MountedDevices, dropping it means the data disk keeps its drive letter into the shipped image -- the letterless-first-boot race that sent profiles temporary goes away at the root. Default is unchanged (/generalize), correct for an image deployed to many hosts; keepMachineSid is for an image that is always the same one machine. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 28 ++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index b38811b..d5eafae 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -42,6 +42,16 @@ in # delayOobeRun = true: sysprep only, OOBE + activation on real hardware # delayOobeRun = false: sysprep + OOBE + activation in build VM delayOobeRun ? 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, }: let # Convert "8e8cd8" hex to "142 140 216" decimal RGB for Windows registry hexToRgbStr = hex: let @@ -229,6 +239,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) '' Date: Thu, 10 Sep 2026 21:18:04 -0300 Subject: [PATCH 20/27] generalize: finalize OOBE state under keepMachineSid, or the image reboots into Setup The keepMachineSid image built and had everything right in the registry -- account, profile on D:, RDP enabled, MountedDevices intact -- but booted to a black screen with no services. The shipped image was set to run windeploy.exe (OOBE) on every boot: SetupType=2, OOBEInProgress=1, CmdLine=oobe\windeploy.exe. On the target there is no unattend left for it to consume, so it hangs. The cause is our shutdown in FirstLogonCommands. It cuts OOBE off before windeploy finalizes and resets that Setup state itself. The /generalize path finalizes it through its own specialize->oobe cycle; /oobe alone does not, so the flags are left set. Clearing them in post-oobe, only under keepMachineSid, sends the target straight to logon. The default /generalize path is untouched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index d5eafae..5299d7f 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -362,6 +362,16 @@ 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 + ''} :: Clean up del /q C:\oobe-unattend.xml 2>nul del /q C:\vmix-audit-script.cmd 2>nul From 60013006d61fa28bf6a03604adaf379830e473d8 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Sun, 13 Sep 2026 06:44:08 -0300 Subject: [PATCH 21/27] generalize: folderRedirect -- keep the SID generalized, persist user data on D: The alternative to keepMachineSid for "survives an OS rebuild". Instead of moving the whole SID-bound profile to D: (which forces a fixed SID), keep /generalize -- so every machine and every rebuild gets its own random SID -- and redirect only the user's data folders (Desktop, Documents, Downloads, ...) to the persistent data volume. Files survive a rebuild; per-user registry settings do not, which is the accepted trade for not touching the SID. Three pieces. A per-user script calls SHSetKnownFolderPath to point each known folder at D:\UserData\; it is registered through Active Setup, which runs it once per profile at first logon -- including the fresh profile each generalized rebuild creates. And the onstart SYSTEM boot script grants the well-known Users group inheritable full control on the data tree, so the account behind whatever SID this rebuild produced can reach files an earlier SID created. The heal/relocation path is now gated on profilesDirectory, so it and folderRedirect stay mutually exclusive and neither breaks the other's null. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 106 ++++++++++++++++---- 1 file changed, 84 insertions(+), 22 deletions(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 5299d7f..8c1c10b 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -52,6 +52,13 @@ in # 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 @@ -194,7 +201,56 @@ in # 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. - healProfileScript = pkgs.writeText "vmix-heal-profile.ps1" '' + # 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 @@ -207,29 +263,33 @@ in Remove-Item '${profilesDirectory}\TEMP' -Recurse -Force -ErrorAction SilentlyContinue New-Item -Path C:\Windows\Temp\vmix-profile-healed -ItemType File -Force | Out-Null } - ''; + ''); - # Ensures D: exists on every boot and heals a profile that went temporary. - # - # generalize strips MountedDevices, so the shipped image carries no drive - # letter for the data disk, and the specialize pass that would re-assert it - # runs only in the build, not on the target. The zvol therefore boots - # letterless, the first autologon cannot find its relocated profile at - # ${profilesDirectory}\${username} (event 1511) and falls back to a temporary - # one, backing the real key up as .bak and making the fault stick. - # - # diskpart assign writes MountedDevices, so once this has run once D: is - # persistent for every later boot. Only the first boot is exposed, and if it - # left a .bak the heal puts it back and reboots -- the next boot, D: now - # persistent and ProfileList clean, logs straight into the real profile. + # 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 - call C:\vmix-init-data-disk.cmd + ${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) '' @@ -469,7 +529,7 @@ in { # 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; + 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 @@ -479,11 +539,13 @@ in { { source = oobeXml; dest = "/oobe-unattend.xml"; } { source = postOobeScript; dest = "/post-oobe.cmd"; } { source = masScript; dest = "/MAS_AIO.cmd"; } - ] ++ lib.optionals (dataDisk != null) [ - { source = initDataDiskScript; dest = "/vmix-init-data-disk.cmd"; } - { source = healProfileScript; dest = "/vmix-heal-profile.ps1"; } - { source = bootDataProfileScript; dest = "/vmix-data-profile.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.optionals (staticIP != null) [ { source = staticIPScript; dest = "/vmix-static-ip.cmd"; } From c40f4460e3838c849aed5704df8479231b2957fb Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 16 Sep 2026 12:23:45 -0300 Subject: [PATCH 22/27] vms: disks.os.persistMode (copy|backing) + a GC-root for the backing chain persistMode=backing seeds the persistent OS disk as a thin qcow2 overlay (`qemu-img create -b `) instead of a full cp, so many VMs share one base image and each holds only its deltas. Because the overlay reads through a store path that nix does not otherwise pin (the disk lives outside the store), and because even a cp'd Windows disk backs onto the store chain, a per-VM oneshot `vm.vmix-gcroot@` reads the overlay's ACTUAL backing_file at boot (not the config's current image, which drifts after a rebuild) and symlinks it under /nix/var/nix/gcroots. It runs before the VM service and outside its ProtectSystem sandbox. Fires whenever the OS disk is persistent, covering copy too. Default stays copy, so existing VMs are unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- nixos/vms/config.nix | 38 ++++++++++++++++++++++++++++++++-- nixos/vms/submoduleOptions.nix | 11 ++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/nixos/vms/config.nix b/nixos/vms/config.nix index 4dc86a7..528671f 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 @@ -254,7 +275,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 +306,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..45a55c4 100644 --- a/nixos/vms/submoduleOptions.nix +++ b/nixos/vms/submoduleOptions.nix @@ -173,6 +173,17 @@ 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."; From 702f723e6d516432963114c65d3f55e739ba3cda Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 16 Sep 2026 13:01:12 -0300 Subject: [PATCH 23/27] 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 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/default.nix | 19 ++-- .../windows/helpers/makeConfigMedium.nix | 51 ++++++++++ lib/images/windows/templates/default.nix | 12 +++ lib/images/windows/templates/generalize.nix | 95 +++++++++++++++++-- nixos/vms/config.nix | 1 + nixos/vms/submoduleOptions.nix | 10 ++ 6 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 lib/images/windows/helpers/makeConfigMedium.nix 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 { From ee002586e568b3e7b3bf56a26633f59cb33ca6d7 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 16 Sep 2026 13:11:06 -0300 Subject: [PATCH 24/27] windows/seal: keep the config CD off D:, and win the ProfilesDirectory race Two first-boot hazards the sealed path hits that the baked path does not, fixed in a configMedium-only variant of the data-disk init (the shared path is byte-identical): - The per-VM config rides an optical drive. On the target's first boot the data disk is still raw and unlettered, so Windows gives the CD D: -- where the profile volume must go. The plain `if exist D:\` guard then sees the CD and skips, stranding ProfilesDirectory on read-only media. Now a marker (not the letter) tracks first boot, and any occupant of D: is parked on Y: before the data disk claims it. - Left to Shell-Setup's FolderLocations, ProfilesDirectory can be evaluated before the disk exists (unordered within specialize) and fall back to C:. It is now written to the registry in the same step that just created the volume, so the volume always exists first. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 56 ++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index a8c3a42..b710e0d 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -219,7 +219,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 @@ -248,7 +300,7 @@ 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 From 1510b6c5ff73a3a86fc21ec223b8de643344d687 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Fri, 18 Sep 2026 09:24:04 -0300 Subject: [PATCH 25/27] windows/seal: per-VM account on the config medium, not baked username/password move from the sealed image to makeConfigMedium, so two VMs get distinct logins (and, as before, distinct SIDs). The sealed image bakes only a generic bootstrap account ("vmixsetup") whose sole job is to carry OOBE to a logon; post-oobe then creates the real account from the config CD, switches autologon to it, and reboots so it builds its own SID-bound profile on D:\Users\. A one-shot cleanup (RunOnce, first logon of the real account) retires the bootstrap and its profile and applies the per-user tint. So nothing about the account is shared or baked, and the on-disk profile folder matches the real username. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- .../windows/helpers/makeConfigMedium.nix | 8 ++++ lib/images/windows/templates/default.nix | 8 +++- lib/images/windows/templates/generalize.nix | 47 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/images/windows/helpers/makeConfigMedium.nix b/lib/images/windows/helpers/makeConfigMedium.nix index e3c10cd..8a90be1 100644 --- a/lib/images/windows/helpers/makeConfigMedium.nix +++ b/lib/images/windows/helpers/makeConfigMedium.nix @@ -20,6 +20,12 @@ { 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, @@ -37,6 +43,8 @@ let 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} diff --git a/lib/images/windows/templates/default.nix b/lib/images/windows/templates/default.nix index a5a60fa..8bf1a10 100644 --- a/lib/images/windows/templates/default.nix +++ b/lib/images/windows/templates/default.nix @@ -43,10 +43,16 @@ in rec { # 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. + # + # 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); diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index b710e0d..9a54b4c 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -202,6 +202,44 @@ in } ''; + # 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 vmixUserCleanup -Value 'cmd /c C:\vmix-user-cleanup.cmd' -Type String + ''; + + # Runs once as the real account (RunOnce, after the hand-over reboot): retire + # the bootstrap account and its profile, and apply the per-user desktop tint + # (which the bootstrap ran against on the first boot, before this account + # existed). Bootstrap is idle here, so its profile is safe to remove. + userCleanupScript = pkgs.writeText "vmix-user-cleanup.cmd" '' + @echo off + 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 + powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-apply-config.ps1 >nul 2>&1 + del /q C:\vmix-user-cleanup.cmd 2>nul + ''; + # Thin launcher, so the scheduled task has a cmd to point at. staticIPScript = pkgs.writeText "vmix-static-ip.cmd" '' @echo off @@ -545,6 +583,13 @@ in 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 @@ -671,6 +716,8 @@ in { ++ 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 = userCleanupScript; dest = "/vmix-user-cleanup.cmd"; } ] ++ lib.optionals (staticIP != null || configMedium) [ { source = staticIPScript; dest = "/vmix-static-ip.cmd"; } From b7838f5501cf64351ea9f407091d74a0f8bb05b3 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Fri, 18 Sep 2026 10:39:49 -0300 Subject: [PATCH 26/27] vms/windows: keep RDP VMs awake -- disable sleep in guest, fix the qemu global An idle Windows VM was suspending itself off the network after 15 min: the Balanced power plan sleeps on idle, and the qemu S3/S4 disable was a no-op -- `-global ICH9-LMB.disable_s3` is the wrong device class (q35's bridge is ICH9-LPC), which qemu rejected as "invalid class name", so the sleep states stayed on offer. Fix both ends: correct the global to ICH9-LPC so the firmware stops advertising S3/S4, and in post-oobe (any enableRDP image) switch to the High Performance scheme, zero the standby/hibernate/monitor idle timeouts and turn hibernate off -- a machine exposed as a service must not sleep. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 10 ++++++++++ nixos/vms/config.nix | 7 +++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 9a54b4c..b9456ea 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -544,6 +544,16 @@ 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 || configMedium) '' diff --git a/nixos/vms/config.nix b/nixos/vms/config.nix index 91ab137..fa5c4cd 100644 --- a/nixos/vms/config.nix +++ b/nixos/vms/config.nix @@ -233,11 +233,14 @@ 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"} \ From 6d4b9155d6c1941bbce9681a66d81e4172ac74a4 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Sat, 19 Sep 2026 07:15:43 -0300 Subject: [PATCH 27/27] windows/seal: leave nothing on C:, activate on the fresh SID, unmount the CD Sealed VMs were left with setup scripts on C:\, the config CD mounted, and Windows unactivated. Rework the first-boot flow so a settled VM carries no vmix artifacts: - Activation moves off the throwaway bootstrap's boot 1 into the boot-2 finalize, which runs as the real account after its fresh SID/profile exist -- MAS on that SID is what actually sticks. MAS is kept until then, run once, and deleted. - The static address is set once into the persistent store on the target NIC (sealed images already OOBE on the real NIC), so no per-boot task and no script survive on disk. - Boot-2 finalize wipes every vmix-*, MAS_AIO.cmd, config and marker off C:\ after use, retires the bootstrap account/profile, and self-deletes. - The config CD is unmounted for good -- drop its letter and disable the mount manager's auto-lettering (D: keeps its explicit assignment). Non-configMedium generalize (the shared path) is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 64 +++++++++++++++++---- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index b9456ea..b883d89 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -225,19 +225,47 @@ in # 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 vmixUserCleanup -Value 'cmd /c C:\vmix-user-cleanup.cmd' -Type String + -Name vmixFinalize -Value 'cmd /c C:\vmix-finalize.cmd' -Type String ''; - # Runs once as the real account (RunOnce, after the hand-over reboot): retire - # the bootstrap account and its profile, and apply the per-user desktop tint - # (which the bootstrap ran against on the first boot, before this account - # existed). Bootstrap is idle here, so its profile is safe to remove. - userCleanupScript = pkgs.writeText "vmix-user-cleanup.cmd" '' + # 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 - powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-apply-config.ps1 >nul 2>&1 - del /q C:\vmix-user-cleanup.cmd 2>nul + :: 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. @@ -513,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 @@ -531,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 @@ -556,7 +588,7 @@ in powercfg /hibernate off ''} - ${lib.optionalString (staticIP != null || configMedium) '' + ${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 @@ -568,10 +600,20 @@ 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 (dataDisk != null) '' + ${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 ''} @@ -727,7 +769,7 @@ in { { source = loadConfigScript; dest = "/vmix-load-config.cmd"; } { source = applyConfigScript; dest = "/vmix-apply-config.ps1"; } { source = createUserScript; dest = "/vmix-create-user.ps1"; } - { source = userCleanupScript; dest = "/vmix-user-cleanup.cmd"; } + { source = finalizeScript; dest = "/vmix-finalize.cmd"; } ] ++ lib.optionals (staticIP != null || configMedium) [ { source = staticIPScript; dest = "/vmix-static-ip.cmd"; }