From 05c0ad43d279e4dc3e0e1d21a433d068d54bdfc9 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 13 Aug 2026 10:36:22 +0000 Subject: [PATCH] feat: Check and apply Windows updates through the Windows Update COM API --- agent/internal/updates/updates_windows.go | 117 ++++++++++++++++++++++ agent/internal/updates/winparse.go | 48 +++++++++ agent/internal/updates/winparse_test.go | 69 +++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 agent/internal/updates/updates_windows.go create mode 100644 agent/internal/updates/winparse.go create mode 100644 agent/internal/updates/winparse_test.go diff --git a/agent/internal/updates/updates_windows.go b/agent/internal/updates/updates_windows.go new file mode 100644 index 0000000..1e73729 --- /dev/null +++ b/agent/internal/updates/updates_windows.go @@ -0,0 +1,117 @@ +package updates + +import ( + "context" + "fmt" + "strings" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" +) + +const ( + // The first search after a boot contacts Microsoft Update (or WSUS) and is + // routinely slow. Ten minutes is not generous, it is realistic. + searchTimeout = 10 * time.Minute + + // A patch-Tuesday cumulative genuinely takes this long to download and + // install on a modest server. + applyTimeout = 60 * time.Minute + + rebootTimeout = 2 * time.Minute +) + +// The Windows Update COM API is used rather than the PSWindowsUpdate module: it +// is present on every supported Windows, needs no PowerShell Gallery install, +// and works unchanged against a WSUS server on an air-gapped fleet. The agent +// runs as LocalSystem, which holds the rights it requires. +const searchScript = ` +$ErrorActionPreference = 'Stop' +$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher() +$result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0") +$rows = @() +foreach ($u in $result.Updates) { + $ids = @($u.KBArticleIDs) + $kb = '' + if ($ids.Count -gt 0) { $kb = [string]$ids[0] } + $rows += [pscustomobject]@{ title = [string]$u.Title; kb = $kb } +} +ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress +` + +const applyScript = ` +$ErrorActionPreference = 'Stop' +$session = New-Object -ComObject Microsoft.Update.Session +$result = $session.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software' and IsHidden=0") + +$batch = New-Object -ComObject Microsoft.Update.UpdateColl +foreach ($u in $result.Updates) { + if ($u.InstallationBehavior.CanRequestUserInput) { continue } + if (-not $u.EulaAccepted) { + try { $u.AcceptEula() } catch { continue } + } + $null = $batch.Add($u) +} + +if ($batch.Count -eq 0) { Write-Output 'nothing-to-install'; exit 0 } + +$downloader = $session.CreateUpdateDownloader() +$downloader.Updates = $batch +$null = $downloader.Download() + +$installer = $session.CreateUpdateInstaller() +$installer.Updates = $batch +$r = $installer.Install() + +Write-Output ('resultcode=' + $r.ResultCode) +# 2 = succeeded, 3 = succeeded with errors. Anything else failed, and this +# process must exit non-zero so the agent logs a failure rather than an ack. +if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 } +exit 0 +` + +const rebootScript = ` +$ErrorActionPreference = 'SilentlyContinue' +$si = New-Object -ComObject Microsoft.Update.SystemInfo +if ($si.RebootRequired) { Write-Output 'true'; exit 0 } +$keys = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending', + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' +) +foreach ($k in $keys) { if (Test-Path $k) { Write-Output 'true'; exit 0 } } +$sm = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations +if ($sm -and $sm.PendingFileRenameOperations) { Write-Output 'true'; exit 0 } +Write-Output 'false' +` + +func checkAvailable() ([]PackageUpdate, error) { + ctx, cancel := context.WithTimeout(context.Background(), searchTimeout) + defer cancel() + + out, err := winexec.Run(ctx, searchScript) + if err != nil { + return nil, fmt.Errorf("windows update search: %w", err) + } + return parseUpdateSearch(out) +} + +func applyAll() error { + ctx, cancel := context.WithTimeout(context.Background(), applyTimeout) + defer cancel() + + if _, err := winexec.Run(ctx, applyScript); err != nil { + return fmt.Errorf("windows update install: %w", err) + } + return nil +} + +func rebootRequired() bool { + ctx, cancel := context.WithTimeout(context.Background(), rebootTimeout) + defer cancel() + + out, err := winexec.Run(ctx, rebootScript) + if err != nil { + return false + } + return strings.TrimSpace(out) == "true" +} diff --git a/agent/internal/updates/winparse.go b/agent/internal/updates/winparse.go new file mode 100644 index 0000000..cd3f5f4 --- /dev/null +++ b/agent/internal/updates/winparse.go @@ -0,0 +1,48 @@ +package updates + +import ( + "encoding/json" + "strings" +) + +// winUpdate is one row of the Windows Update searcher's output, in the shape +// searchScript emits it. +type winUpdate struct { + Title string `json:"title"` + KB string `json:"kb"` +} + +// parseUpdateSearch reads the searcher's JSON. +// +// It carries no build tag on purpose: this is the half of the Windows update +// path that can be tested on a development machine, and the agent module has no +// Windows CI. +func parseUpdateSearch(jsonText string) ([]PackageUpdate, error) { + s := strings.TrimSpace(jsonText) + if s == "" || s == "null" { + return nil, nil + } + + var rows []winUpdate + if err := json.Unmarshal([]byte(s), &rows); err != nil { + // ConvertTo-Json renders a one-element array as a bare object. + var one winUpdate + if err2 := json.Unmarshal([]byte(s), &one); err2 != nil { + return nil, err + } + rows = []winUpdate{one} + } + + out := make([]PackageUpdate, 0, len(rows)) + for _, r := range rows { + u := PackageUpdate{Name: r.Title} + if kb := strings.TrimSpace(r.KB); kb != "" { + if !strings.HasPrefix(strings.ToUpper(kb), "KB") { + kb = "KB" + kb + } + u.NewVersion = kb + } + out = append(out, u) + } + return out, nil +} diff --git a/agent/internal/updates/winparse_test.go b/agent/internal/updates/winparse_test.go new file mode 100644 index 0000000..d56aad9 --- /dev/null +++ b/agent/internal/updates/winparse_test.go @@ -0,0 +1,69 @@ +package updates + +import "testing" + +func TestParseUpdateSearchArray(t *testing.T) { + in := `[{"title":"2026-08 Cumulative Update for Windows Server 2022","kb":"5034123"}, + {"title":"Windows Malicious Software Removal Tool","kb":"890830"}]` + + got, err := parseUpdateSearch(in) + if err != nil { + t.Fatalf("parseUpdateSearch: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d updates, want 2", len(got)) + } + if got[0].Name != "2026-08 Cumulative Update for Windows Server 2022" { + t.Errorf("Name = %q", got[0].Name) + } + if got[0].NewVersion != "KB5034123" { + t.Errorf("NewVersion = %q, want KB5034123", got[0].NewVersion) + } + if got[0].CurrentVersion != "" { + t.Errorf("CurrentVersion = %q, want empty", got[0].CurrentVersion) + } +} + +// PowerShell 5.1's ConvertTo-Json collapses a one-element array into a bare +// object. A host with exactly one pending update is common, and a parser that +// only accepts arrays reports it as zero. +func TestParseUpdateSearchSingleObject(t *testing.T) { + got, err := parseUpdateSearch(`{"title":"Security Intelligence Update","kb":"2267602"}`) + if err != nil { + t.Fatalf("parseUpdateSearch: %v", err) + } + if len(got) != 1 || got[0].NewVersion != "KB2267602" { + t.Fatalf("got %+v", got) + } +} + +func TestParseUpdateSearchNoKB(t *testing.T) { + got, err := parseUpdateSearch(`[{"title":"Driver update for Contoso NIC","kb":""}]`) + if err != nil { + t.Fatalf("parseUpdateSearch: %v", err) + } + if len(got) != 1 || got[0].NewVersion != "" { + t.Fatalf("got %+v, want one update with an empty NewVersion", got) + } +} + +// An empty result set is "nothing pending", not a parse failure. +func TestParseUpdateSearchEmpty(t *testing.T) { + for _, in := range []string{"", " \r\n", "[]", "null"} { + got, err := parseUpdateSearch(in) + if err != nil { + t.Fatalf("parseUpdateSearch(%q): %v", in, err) + } + if len(got) != 0 { + t.Fatalf("parseUpdateSearch(%q) = %+v, want none", in, got) + } + } +} + +// A KB already carrying its prefix must not become KBKB5034123. +func TestParseUpdateSearchPrefixedKB(t *testing.T) { + got, _ := parseUpdateSearch(`[{"title":"x","kb":"KB5034123"}]`) + if got[0].NewVersion != "KB5034123" { + t.Fatalf("NewVersion = %q", got[0].NewVersion) + } +}