Compare commits

..

3 commits

Author SHA1 Message Date
40e80df84a 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) <noreply@anthropic.com>
2026-06-15 11:10:32 -03:00
192ea9b54d 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) <noreply@anthropic.com>
2026-06-15 10:04:52 -03:00
55697e5d89 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) <noreply@anthropic.com>
2026-06-09 13:18:12 +05:30
11 changed files with 15 additions and 718 deletions

View file

@ -15,9 +15,12 @@
lib = pkgs.lib; lib = pkgs.lib;
vmixLib = import ./lib { inherit pkgs lib system; }; vmixLib = import ./lib { inherit pkgs lib system; };
in { 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; lib.${system} = vmixLib;

View file

@ -20,7 +20,6 @@ in rec {
bestPerformance = import ./essentials/best-performance.nix args; bestPerformance = import ./essentials/best-performance.nix args;
clearFileAssociations = import ./essentials/clear-file-associations.nix args; clearFileAssociations = import ./essentials/clear-file-associations.nix args;
virtioDrivers = import ./essentials/virtio-drivers.nix args; virtioDrivers = import ./essentials/virtio-drivers.nix args;
windowsUpdate = import ./essentials/windows-update.nix args;
}; };
# Applications # Applications

View file

@ -1,114 +0,0 @@
# 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"
'';
}

View file

@ -9,9 +9,7 @@ rec {
upstreamISO = upstreamISOs.win10-ltsc-2021; upstreamISO = upstreamISOs.win10-ltsc-2021;
productKey = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D"; productKey = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D";
}; };
# Apply all available Windows Updates (cumulative, .NET, Defender) basic = customizeImageFold upstream (with templates; [
updated = customizeImage upstream (templates.essentials.windowsUpdate {});
basic = customizeImageFold updated (with templates; [
essentials.virtioTools essentials.virtioTools
essentials.removeIE essentials.removeIE
essentials.removeWMP essentials.removeWMP
@ -40,9 +38,8 @@ rec {
productKey = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D"; productKey = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D";
useAHCI = true; useAHCI = true;
}; };
laptopUpdated = customizeImage laptopUpstream (templates.essentials.windowsUpdate {});
laptopSlim = customizeImageFold laptopUpdated templates.bundles.laptopSlim; laptopSlim = customizeImageFold laptopUpstream templates.bundles.laptopSlim;
laptop = customizeImageFold laptopUpdated templates.bundles.laptop; laptop = customizeImageFold laptopUpstream templates.bundles.laptop;
} }

View file

@ -12,8 +12,7 @@ rec {
windowsVersionForVirtioDrivers = "w11"; windowsVersionForVirtioDrivers = "w11";
}; };
updated = customizeImage upstream (templates.essentials.windowsUpdate {}); basic = customizeImageFold upstream (with templates; [
basic = customizeImageFold updated (with templates; [
essentials.virtioTools essentials.virtioTools
essentials.removeIE essentials.removeIE
essentials.removeWMP essentials.removeWMP
@ -46,11 +45,9 @@ rec {
windowsVersionForVirtioDrivers = "w11"; windowsVersionForVirtioDrivers = "w11";
}; };
laptopUpdated = customizeImage laptopUpstream (templates.essentials.windowsUpdate {}); laptopSlim = customizeImageFold laptopUpstream
laptopSlim = customizeImageFold laptopUpdated
(templates.bundles.laptopSlim ++ [ templates.reg.disableUCPD ]); (templates.bundles.laptopSlim ++ [ templates.reg.disableUCPD ]);
laptop = customizeImageFold laptopUpdated laptop = customizeImageFold laptopUpstream
(templates.bundles.laptop ++ [ templates.reg.disableUCPD ]); (templates.bundles.laptop ++ [ templates.reg.disableUCPD ]);
} }

View file

@ -1,6 +0,0 @@
{ ... }:
{
imports = [
./nixos/default.nix
];
}

View file

@ -1,7 +1,7 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
with lib; with lib;
let let
vmixLib = import ./../lib {inherit pkgs lib; }; vmixLib = pkgs.vmixLib;
args = { inherit config pkgs lib vmixLib; }; args = { inherit config pkgs lib vmixLib; };
in in
{ {
@ -15,6 +15,4 @@ in
(types.submodule (import ./namespaceSubmoduleOptions.nix args)); (types.submodule (import ./namespaceSubmoduleOptions.nix args));
default = {}; default = {};
}; };
}
config.nixpkgs.overlays = [ (import ../overlay.nix) ];
}

View file

@ -286,5 +286,6 @@ in
{ {
config.systemd.services = namespaceGlobalService // networkServices; config.systemd.services = namespaceGlobalService // networkServices;
config.systemd.targets = networkTargets; 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;
} }

View file

@ -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 {};
}

View file

@ -1,41 +0,0 @@
# 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 `<Password>` 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=<hash>` 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

View file

@ -1,530 +0,0 @@
# Windows Update Template - Full Development Session Log
## 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
### 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 (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`
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
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 {}
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; })
```
## Detailed Test Log
### 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`.
### 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
**Build log showed**: QEMU booted, script ran, shut down — no errors visible in nix log (VNC output isn't captured).
### 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
```
Also added EULA acceptance and try/catch error handling to the PowerShell block.
**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; })
'
```
**VNC observations** (screenshots taken with `gvnccapture localhost:55 /tmp/screenshot.png`):
- **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
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; })
'
```
### Build with more RAM (8GB) for faster installs
```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 = ":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.