feat: Check and apply Windows updates through the Windows Update COM API
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user