From a4745834de55ffb571a86b0bafb68eb0ee490692 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 07:56:28 +0000 Subject: [PATCH] feat: Windows security-only updates and agent-initiated reboot --- internal/updates/updates_windows.go | 55 ++++++++--------------------- internal/updates/winscript.go | 54 ++++++++++++++++++++++++++++ internal/updates/winscript_test.go | 29 +++++++++++++++ 3 files changed, 98 insertions(+), 40 deletions(-) create mode 100644 internal/updates/winscript.go create mode 100644 internal/updates/winscript_test.go diff --git a/internal/updates/updates_windows.go b/internal/updates/updates_windows.go index 7dfc46f..9a9da4a 100644 --- a/internal/updates/updates_windows.go +++ b/internal/updates/updates_windows.go @@ -3,6 +3,7 @@ package updates import ( "context" "fmt" + "os/exec" "strings" "time" @@ -14,10 +15,6 @@ const ( // 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 ) @@ -39,37 +36,6 @@ foreach ($u in $result.Updates) { 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 @@ -95,14 +61,23 @@ func checkAvailable() ([]PackageUpdate, error) { return parseUpdateSearch(out) } -func applyAll() error { - ctx, cancel := context.WithTimeout(context.Background(), applyTimeout) +func apply(securityOnly bool, deadline time.Time) (Result, error) { + ctx, cancel := context.WithDeadline(context.Background(), deadline) defer cancel() - if _, err := winexec.Run(ctx, applyScript); err != nil { - return fmt.Errorf("windows update install: %w", err) + out, err := winexec.Run(ctx, applyScriptFor(securityOnly)) + tail := newTailBuffer(outputTailMax) + _, _ = tail.Write([]byte(out)) + if err != nil { + return Result{Output: tail.String()}, fmt.Errorf("windows update install: %w", err) } - return nil + return Result{Output: tail.String()}, nil +} + +// scheduleReboot gives the host sixty seconds, so the PatchResult announcing +// the reboot is sent before the service stops. +func scheduleReboot() error { + return exec.Command("shutdown", "/r", "/t", "60", "/c", "Vantage patch policy").Run() } func rebootRequired() bool { diff --git a/internal/updates/winscript.go b/internal/updates/winscript.go new file mode 100644 index 0000000..c6ba6fb --- /dev/null +++ b/internal/updates/winscript.go @@ -0,0 +1,54 @@ +package updates + +// applyScriptFor builds the Windows Update install script. It lives in a file +// with no build tag so its content is tested on Linux: this module has no +// Windows CI. +// +// Security-only keeps updates in the Security Updates or Critical Updates +// classifications. Those two GUIDs are fixed by Microsoft and identical on +// every Windows Update and WSUS server. +func applyScriptFor(securityOnly bool) string { + flag := "$false" + if securityOnly { + flag = "$true" + } + return "$SecurityOnly = " + flag + "\n" + applyScriptBody +} + +const applyScriptBody = ` +$ErrorActionPreference = 'Stop' +$securityCats = @('0FA1201D-4330-4FA8-8AE9-B877473B6441', 'E6CF1350-C01B-414D-A61F-263D14D133B4') +$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 ($SecurityOnly) { + $isSec = $false + foreach ($c in $u.Categories) { if ($securityCats -contains $c.CategoryID.ToUpper()) { $isSec = $true } } + if (-not $isSec) { continue } + } + if (-not $u.EulaAccepted) { + try { $u.AcceptEula() } catch { continue } + } + Write-Output ('selected: ' + $u.Title) + $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 reports a failure rather than an ack. +if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 } +exit 0 +` diff --git a/internal/updates/winscript_test.go b/internal/updates/winscript_test.go new file mode 100644 index 0000000..c71f13f --- /dev/null +++ b/internal/updates/winscript_test.go @@ -0,0 +1,29 @@ +package updates + +import ( + "strings" + "testing" +) + +const ( + catSecurity = "0FA1201D-4330-4FA8-8AE9-B877473B6441" + catCritical = "E6CF1350-C01B-414D-A61F-263D14D133B4" +) + +func TestApplyScriptSecurityOnly(t *testing.T) { + s := applyScriptFor(true) + if !strings.HasPrefix(strings.TrimSpace(s), "$SecurityOnly = $true") { + t.Fatalf("script must open with the flag set:\n%s", s) + } + for _, id := range []string{catSecurity, catCritical} { + if !strings.Contains(s, id) { + t.Errorf("script missing category %s", id) + } + } +} + +func TestApplyScriptAll(t *testing.T) { + if !strings.HasPrefix(strings.TrimSpace(applyScriptFor(false)), "$SecurityOnly = $false") { + t.Fatal("script must open with the flag cleared") + } +}