#Requires -Version 5.1 <# Script: windows-updater.ps1 Description: Automated Windows Update management with notifications, winget support, logging, history tracking, and reboot control. Platforms: Windows 10/11, Server 2016/2019/2022/2025 Author: Patrick Asmus Web: https://www.cleveradmin.de Repository: https://git.techniverse.net/scriptos/windows-updater.git License: MIT Version: 2.9.0 Date: 14.07.2026 Modifications: Add ForceReboot option to force reboot after Windows Updates regardless of pending reboot state ##################################################### #> param( [switch]$DefenderOnly, [switch]$DryRun, [string]$ConfigFile ) # =================================================== #region Administrator Check # =================================================== if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host "[ERROR] This script must be run as Administrator." -ForegroundColor Red exit 1 } try { if ([enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13') { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls13 -bor [Net.SecurityProtocolType]::Tls12 } else { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } #endregion # =================================================== #region Global Variables # =================================================== $script:LogFile = $null $script:Config = $null $script:OSInfo = $null $script:WSUSInfo = $null $script:HasErrors = $false $script:RebootPending = $false $script:HistoryEntries = [System.Collections.ArrayList]::new() $script:UseXlsx = $false $script:UpdateResults = [System.Collections.ArrayList]::new() $script:WingetResults = [System.Collections.ArrayList]::new() $script:ScriptStartTime = Get-Date $script:DefenderOnlyMode = $DefenderOnly.IsPresent $script:DryRunParam = $DryRun.IsPresent $script:ConfigFilePath = $ConfigFile #endregion # =================================================== #region INI Config Parser # =================================================== function Read-IniFile { param([string]$Path) if (-not (Test-Path -Path $Path)) { Write-Host "[ERROR] Configuration file not found: $Path" -ForegroundColor Red Write-Host "[ERROR] Copy config.example.ini to config.ini and adjust values." -ForegroundColor Red exit 1 } $config = @{} $currentSection = $null foreach ($line in (Get-Content -Path $Path -Encoding UTF8)) { $trimmed = $line.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { continue } if ($trimmed.StartsWith('#') -or $trimmed.StartsWith(';')) { continue } if ($trimmed -match '^\[(.+)\]$') { $currentSection = $Matches[1] if (-not $config.ContainsKey($currentSection)) { $config[$currentSection] = @{} } continue } if ($null -ne $currentSection -and $trimmed -match '^([^=]+)=(.*)$') { $key = $Matches[1].Trim() $value = $Matches[2].Trim() $config[$currentSection][$key] = $value } } return $config } function Get-ConfigValue { param( [hashtable]$Config, [string]$Section, [string]$Key, [object]$Default = $null, [ValidateSet('string', 'bool', 'int')] [string]$Type = 'string' ) $value = $null if ($Config.ContainsKey($Section) -and $Config[$Section].ContainsKey($Key)) { $value = $Config[$Section][$Key] } if ([string]::IsNullOrWhiteSpace($value)) { return $Default } switch ($Type) { 'bool' { return $value -match '^(true|1|yes)$' } 'int' { $parsed = 0 if ([int]::TryParse($value, [ref]$parsed)) { return $parsed } return $Default } default { return $value } } } #endregion # =================================================== #region Logging # =================================================== function Initialize-Logging { param([string]$LogPath, [int]$RetentionDays) if (-not (Test-Path -Path $LogPath)) { New-Item -ItemType Directory -Path $LogPath -Force | Out-Null } $dateStr = (Get-Date).ToString("yyyy-MM-dd") $script:LogFile = Join-Path -Path $LogPath -ChildPath "windows-updater_$dateStr.log" Remove-OldLogs -LogPath $LogPath -RetentionDays $RetentionDays } function Remove-OldLogs { param([string]$LogPath, [int]$RetentionDays) $cutoff = (Get-Date).AddDays(-$RetentionDays) $oldLogs = Get-ChildItem -Path $LogPath -Filter "windows-updater_*.log" -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt $cutoff } if ($oldLogs) { $count = ($oldLogs | Measure-Object).Count $oldLogs | Remove-Item -Force Write-Log "Removed $count log file(s) older than $RetentionDays days" -Level INFO } } function Write-Log { param( [string]$Message, [ValidateSet('INFO', 'WARN', 'ERROR')] [string]$Level = 'INFO' ) $timestamp = (Get-Date).ToString("dd.MM.yyyy HH:mm:ss") $logLine = "[$timestamp] [$Level] $Message" switch ($Level) { 'INFO' { Write-Host $logLine -ForegroundColor Cyan } 'WARN' { Write-Host $logLine -ForegroundColor Yellow } 'ERROR' { Write-Host $logLine -ForegroundColor Red } } if ($script:LogFile) { $logLine | Out-File -Append -FilePath $script:LogFile -Encoding UTF8 } } #endregion # =================================================== #region OS Detection # =================================================== function Get-OSInfo { $os = Get-CimInstance -ClassName Win32_OperatingSystem $info = @{ Caption = $os.Caption Version = $os.Version BuildNumber = [int]$os.BuildNumber IsServer = ($os.ProductType -ne 1) OSType = if ($os.ProductType -eq 1) { "Client" } else { "Server" } } return $info } #endregion # =================================================== #region Module Management # =================================================== function Initialize-PSWindowsUpdate { $moduleName = "PSWindowsUpdate" $existing = Get-Module -Name $moduleName -ListAvailable -ErrorAction SilentlyContinue if ($existing) { Import-Module -Name $moduleName -Force -ErrorAction SilentlyContinue if (Get-Module -Name $moduleName) { Write-Log "PSWindowsUpdate module loaded (already installed, v$($existing[0].Version))" return $true } } Write-Log "Attempting online installation of PSWindowsUpdate..." try { $nuget = Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue if (-not $nuget -or $nuget[0].Version -lt [version]"2.8.5.201") { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop | Out-Null } Install-Module -Name $moduleName -Force -Scope AllUsers -AllowClobber -ErrorAction Stop Import-Module -Name $moduleName -Force -ErrorAction Stop Write-Log "PSWindowsUpdate module installed and loaded from PSGallery" return $true } catch { Write-Log "Online installation failed: $($_.Exception.Message)" -Level WARN } Write-Log "Falling back to offline module from repository..." $offlinePath = Join-Path -Path $PSScriptRoot -ChildPath "Modules" if (Test-Path -Path $offlinePath) { $env:PSModulePath = "$offlinePath;$env:PSModulePath" try { Import-Module -Name $moduleName -Force -ErrorAction Stop Write-Log "PSWindowsUpdate module loaded from offline fallback" return $true } catch { Write-Log "Offline module import failed: $($_.Exception.Message)" -Level ERROR } } else { Write-Log "Offline module path not found: $offlinePath" -Level ERROR } Write-Log "Failed to load PSWindowsUpdate module from any source" -Level ERROR return $false } function Initialize-ImportExcel { $moduleName = "ImportExcel" $existing = Get-Module -Name $moduleName -ListAvailable -ErrorAction SilentlyContinue if ($existing) { Import-Module -Name $moduleName -Force -ErrorAction SilentlyContinue if (Get-Module -Name $moduleName) { Write-Log "ImportExcel module loaded (v$($existing[0].Version))" return $true } } Write-Log "Attempting online installation of ImportExcel..." try { Install-Module -Name $moduleName -Force -Scope AllUsers -AllowClobber -ErrorAction Stop Import-Module -Name $moduleName -Force -ErrorAction Stop Write-Log "ImportExcel module installed and loaded from PSGallery" return $true } catch { Write-Log "ImportExcel not available, falling back to CSV for history export" -Level WARN return $false } } #endregion # =================================================== #region WSUS Detection # =================================================== function Get-WSUSConfig { $result = @{ IsConfigured = $false ServerUrl = "" } $wuRegPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" $auRegPath = "$wuRegPath\AU" if (-not (Test-Path -Path $wuRegPath)) { return $result } try { $useWU = Get-ItemProperty -Path $auRegPath -Name UseWUServer -ErrorAction SilentlyContinue if ($useWU -and $useWU.UseWUServer -eq 1) { $wuServer = Get-ItemProperty -Path $wuRegPath -Name WUServer -ErrorAction SilentlyContinue if ($wuServer -and -not [string]::IsNullOrWhiteSpace($wuServer.WUServer)) { $result.IsConfigured = $true $result.ServerUrl = $wuServer.WUServer } } } catch { Write-Log "Error reading WSUS configuration: $($_.Exception.Message)" -Level WARN } return $result } #endregion # =================================================== #region Notifications # =================================================== function Send-NtfyNotification { param( [string]$Title, [string]$Body, [string]$Priority = "default" ) $url = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyUrl" -Default "" $topic = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyTopic" -Default "" $token = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyToken" -Default "" if ([string]::IsNullOrWhiteSpace($url) -or [string]::IsNullOrWhiteSpace($topic)) { Write-Log "ntfy URL or topic not configured, skipping ntfy notification" -Level WARN return } $endpoint = "$url/$topic" $headers = @{ "Title" = $Title "Priority" = $Priority } if (-not [string]::IsNullOrWhiteSpace($token)) { $headers["Authorization"] = "Bearer $token" } try { Invoke-RestMethod -Uri $endpoint -Method Post -Body $Body -Headers $headers -ContentType "text/plain; charset=utf-8" -ErrorAction Stop | Out-Null Write-Log "ntfy notification sent: $Title" } catch { Write-Log "Failed to send ntfy notification: $($_.Exception.Message)" -Level WARN } } function New-HtmlUpdateReport { param([array]$Results, [string]$Hostname, [string]$OSCaption) $rows = "" foreach ($r in $Results) { $color = if ($r.Result -match "^Installed|^Succeeded|^Success") { "#2d7d2d" } else { "#c0392b" } $bgColor = if ($r.Result -match "^Installed|^Succeeded|^Success") { "#e8f5e9" } else { "#fde8e8" } $eDate = [System.Net.WebUtility]::HtmlEncode($r.Date) $eKB = [System.Net.WebUtility]::HtmlEncode($r.KB) $eTitle = [System.Net.WebUtility]::HtmlEncode($r.Title) $eSize = [System.Net.WebUtility]::HtmlEncode($r.Size) $eResult = [System.Net.WebUtility]::HtmlEncode($r.Result) $rows += @"
Host: $eHostname
OS: $eOSCaption
Date: $(Get-Date -Format "dd.MM.yyyy HH:mm:ss")
Successful: $successCount | Failed: $failCount
| Date | KB | Title | Size | Result |
|---|
Generated by Windows Updater Script
"@ return $html } function Send-EmailNotification { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] param( [string]$Subject, [string]$Body, [switch]$IsHtml, [array]$UpdateResults = @() ) $smtpServer = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "SmtpServer" -Default "" $smtpPort = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "SmtpPort" -Default 587 -Type int $useSsl = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "SmtpUseSsl" -Default $true -Type bool $username = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "SmtpUsername" -Default "" $password = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "SmtpPassword" -Default "" $from = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "EmailFrom" -Default "" $to = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "EmailTo" -Default "" $htmlReport = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "EmailHtmlReport" -Default $true -Type bool if ([string]::IsNullOrWhiteSpace($smtpServer) -or [string]::IsNullOrWhiteSpace($from) -or [string]::IsNullOrWhiteSpace($to)) { Write-Log "Email not fully configured (SmtpServer/EmailFrom/EmailTo), skipping" -Level WARN return } $recipients = $to -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $mailParams = @{ SmtpServer = $smtpServer Port = $smtpPort From = $from To = $recipients Subject = $Subject Encoding = [System.Text.Encoding]::UTF8 } if ($useSsl) { $mailParams["UseSsl"] = $true } if (-not [string]::IsNullOrWhiteSpace($username) -and -not [string]::IsNullOrWhiteSpace($password)) { $secPass = ConvertTo-SecureString -String $password -AsPlainText -Force $cred = New-Object System.Management.Automation.PSCredential($username, $secPass) $mailParams["Credential"] = $cred } if ($IsHtml -or ($htmlReport -and $UpdateResults.Count -gt 0)) { if ($UpdateResults.Count -gt 0) { $htmlBody = New-HtmlUpdateReport -Results $UpdateResults -Hostname $env:COMPUTERNAME -OSCaption $script:OSInfo.Caption $mailParams["Body"] = $htmlBody } else { $mailParams["Body"] = $Body } $mailParams["BodyAsHtml"] = $true } else { $mailParams["Body"] = $Body } try { Send-MailMessage @mailParams -ErrorAction Stop Write-Log "Email notification sent: $Subject" } catch { Write-Log "Failed to send email: $($_.Exception.Message)" -Level WARN } } function Send-TeamsNotification { param( [string]$Title, [string]$Body, [array]$UpdateResults = @() ) $webhookUrl = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "TeamsWebhookUrl" -Default "" if ([string]::IsNullOrWhiteSpace($webhookUrl)) { Write-Log "Teams webhook URL not configured, skipping Teams notification" -Level WARN return } $bodyItems = @( @{ type = "TextBlock" text = $Title size = "Large" weight = "Bolder" color = "Accent" }, @{ type = "FactSet" facts = @( @{ title = "Host"; value = $env:COMPUTERNAME }, @{ title = "Date"; value = (Get-Date -Format "dd.MM.yyyy HH:mm:ss") } ) }, @{ type = "TextBlock" text = $Body wrap = $true } ) if ($UpdateResults.Count -gt 0) { $bodyItems += @{ type = "TextBlock" text = "Update Details" weight = "Bolder" separator = $true spacing = "Medium" } foreach ($r in $UpdateResults) { $icon = if ($r.Result -match "^Installed|^Succeeded|^Success") { "✅" } else { "❌" } $bodyItems += @{ type = "TextBlock" text = "$icon **$($r.KB)** — $($r.Title) ($($r.Size)) — $($r.Result)" wrap = $true spacing = "Small" } } } $card = @{ type = "message" attachments = @( @{ contentType = "application/vnd.microsoft.card.adaptive" contentUrl = $null content = @{ '$schema' = "http://adaptivecards.io/schemas/adaptive-card.json" type = "AdaptiveCard" version = "1.4" body = $bodyItems } } ) } $json = $card | ConvertTo-Json -Depth 10 -Compress try { Invoke-RestMethod -Uri $webhookUrl -Method Post -Body $json -ContentType "application/json; charset=utf-8" -ErrorAction Stop | Out-Null Write-Log "Teams notification sent: $Title" } catch { Write-Log "Failed to send Teams notification: $($_.Exception.Message)" -Level WARN } } function Test-NotificationEvent { param( [ValidateSet('ModuleError', 'HookFailure', 'NoUpdates', 'DryRun', 'UpdateComplete', 'Reboot', 'RebootRequired', 'ServiceRestartFailure', 'ScanError')] [string]$EventName ) $key = "NotifyOn$EventName" return Get-ConfigValue -Config $script:Config -Section "NotificationEvents" -Key $key -Default $true -Type bool } function Send-Notification { param( [string]$Subject, [string]$Body, [string]$Priority = "default", [array]$UpdateResults = @(), [string]$EventName = "" ) if (-not [string]::IsNullOrWhiteSpace($EventName)) { if (-not (Test-NotificationEvent -EventName $EventName)) { Write-Log "Notification event '$EventName' is disabled, skipping" return } } $ntfyEnabled = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyEnabled" -Default $false -Type bool $emailEnabled = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "EmailEnabled" -Default $false -Type bool $teamsEnabled = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "TeamsEnabled" -Default $false -Type bool $ntfyPriority = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyPriority" -Default "default" if (-not $ntfyEnabled -and -not $emailEnabled -and -not $teamsEnabled) { return } if ($ntfyEnabled) { $effectivePriority = if ($Priority -ne "default") { $Priority } else { $ntfyPriority } Send-NtfyNotification -Title $Subject -Body $Body -Priority $effectivePriority } if ($emailEnabled) { Send-EmailNotification -Subject $Subject -Body $Body -UpdateResults $UpdateResults } if ($teamsEnabled) { Send-TeamsNotification -Title $Subject -Body $Body -UpdateResults $UpdateResults } } #endregion # =================================================== #region Update History # =================================================== function Add-UpdateHistoryEntry { param( [string]$KB, [string]$Title, [string]$Size, [string]$Result, [string]$UpdateType = "WindowsUpdate" ) $entry = [PSCustomObject]@{ Date = (Get-Date).ToString("dd.MM.yyyy HH:mm:ss") ComputerName = $env:COMPUTERNAME KB = $KB Title = $Title Size = $Size Result = $Result UpdateType = $UpdateType } $script:HistoryEntries.Add($entry) | Out-Null } function Export-UpdateHistory { $historyEnabled = Get-ConfigValue -Config $script:Config -Section "History" -Key "Enabled" -Default $false -Type bool if (-not $historyEnabled) { return } if ($script:HistoryEntries.Count -eq 0) { Write-Log "No history entries to export" return } $basePath = Get-ConfigValue -Config $script:Config -Section "History" -Key "HistoryPath" -Default "C:\logs\windows-updater\update-history" $directory = Split-Path -Path $basePath -Parent if (-not (Test-Path -Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } if ($script:UseXlsx) { $xlsxPath = "$basePath.xlsx" try { $script:HistoryEntries | Export-Excel -Path $xlsxPath -Append -WorksheetName "UpdateHistory" -AutoSize -TableStyle Medium2 -ErrorAction Stop Write-Log "Update history exported to: $xlsxPath" return } catch { Write-Log "XLSX export failed, falling back to CSV: $($_.Exception.Message)" -Level WARN } } $csvPath = "$basePath.csv" try { if (Test-Path -Path $csvPath) { $existing = Import-Csv -Path $csvPath -Encoding UTF8 $all = @($existing) + @($script:HistoryEntries) $all | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 -Force } else { $script:HistoryEntries | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 } Write-Log "Update history exported to: $csvPath" } catch { Write-Log "Failed to export history: $($_.Exception.Message)" -Level ERROR } } #endregion # =================================================== #region Pre/Post Hooks # =================================================== function Invoke-UpdateHook { param( [ValidateSet('Pre', 'Post')] [string]$Phase ) $configKey = "${Phase}UpdateScript" $scriptPath = Get-ConfigValue -Config $script:Config -Section "Hooks" -Key $configKey -Default "" if ([string]::IsNullOrWhiteSpace($scriptPath)) { return $true } if (-not (Test-Path -Path $scriptPath)) { Write-Log "$Phase-update hook script not found: $scriptPath" -Level ERROR if ($Phase -eq "Pre") { $abort = Get-ConfigValue -Config $script:Config -Section "Hooks" -Key "AbortOnPreHookFailure" -Default $false -Type bool return (-not $abort) } return $true } Write-Log "Executing $Phase-update hook: $scriptPath" try { $hookProcess = Start-Process -FilePath "powershell.exe" ` -ArgumentList "-ExecutionPolicy Bypass -File `"$scriptPath`"" ` -Wait -PassThru -NoNewWindow -ErrorAction Stop if ($hookProcess.ExitCode -eq 0) { Write-Log "$Phase-update hook completed successfully" return $true } else { Write-Log "$Phase-update hook failed with exit code: $($hookProcess.ExitCode)" -Level ERROR if ($Phase -eq "Pre") { $abort = Get-ConfigValue -Config $script:Config -Section "Hooks" -Key "AbortOnPreHookFailure" -Default $false -Type bool if ($abort) { Write-Log "Aborting update process due to pre-hook failure" -Level ERROR Send-Notification -Subject "Windows Update ABORTED - $env:COMPUTERNAME" ` -Body "Pre-update hook failed with exit code $($hookProcess.ExitCode). Update process aborted.`nHook: $scriptPath" ` -Priority "high" -EventName "HookFailure" return $false } } return $true } } catch { Write-Log "$Phase-update hook execution error: $($_.Exception.Message)" -Level ERROR return $true } } #endregion # =================================================== #region Windows Update Logic # =================================================== function Get-AvailableUpdates { $params = @{} $useMU = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "UseMicrosoftUpdate" -Default $true -Type bool $excludeKBs = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "ExcludeKBs" -Default "" $excludeDrivers = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "ExcludeDrivers" -Default $false -Type bool if ($useMU -and -not $script:WSUSInfo.IsConfigured) { $params["MicrosoftUpdate"] = $true } if ($script:DefenderOnlyMode) { $params["Category"] = @("Definition Updates") Write-Log "Filtering for Defender definition updates only" } if (-not [string]::IsNullOrWhiteSpace($excludeKBs)) { $kbList = $excludeKBs -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($kbList.Count -gt 0) { $params["NotKBArticleID"] = $kbList Write-Log "Excluding KBs: $($kbList -join ', ')" } } if ($excludeDrivers -and -not $script:DefenderOnlyMode) { $params["NotCategory"] = "Drivers" Write-Log "Excluding driver updates" } Write-Log "Scanning for available updates..." try { $updates = Get-WindowsUpdate @params -ErrorAction Stop return $updates } catch { Write-Log "Error scanning for updates: $($_.Exception.Message)" -Level ERROR $script:HasErrors = $true return $null } } function Install-PendingUpdates { $params = @{ AcceptAll = $true } $useMU = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "UseMicrosoftUpdate" -Default $true -Type bool $excludeKBs = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "ExcludeKBs" -Default "" $excludeDrivers = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "ExcludeDrivers" -Default $false -Type bool $retryCount = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "RetryCount" -Default 3 -Type int if ($useMU -and -not $script:WSUSInfo.IsConfigured) { $params["MicrosoftUpdate"] = $true } if ($script:DefenderOnlyMode) { $params["Category"] = @("Definition Updates") } if (-not [string]::IsNullOrWhiteSpace($excludeKBs)) { $kbList = $excludeKBs -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($kbList.Count -gt 0) { $params["NotKBArticleID"] = $kbList } } if ($excludeDrivers -and -not $script:DefenderOnlyMode) { $params["NotCategory"] = "Drivers" } Write-Log "Installing Windows updates..." try { $results = Install-WindowsUpdate @params -ErrorAction Stop } catch { Write-Log "Error during update installation: $($_.Exception.Message)" -Level ERROR $script:HasErrors = $true return @() } if (-not $results) { Write-Log "No updates were installed" return @() } $resultArray = @($results) # PSWindowsUpdate returns multiple objects per update (one per phase: search, download, install). # Group by KB (or Title as fallback for driver/feature updates without KB) and keep only the last (final) result per update. $grouped = $resultArray | Group-Object -Property { if ($_.KB) { $_.KB } else { $_.Title } } $deduplicated = foreach ($group in $grouped) { $group.Group | Select-Object -Last 1 } foreach ($r in $deduplicated) { $sizeStr = if ($r.Size) { "{0:N2} MB" -f ($r.Size / 1MB) } else { "N/A" } $resultStr = if ($r.Result) { $r.Result.ToString() } else { $r.Status.ToString() } $kbStr = if ($r.KB) { $r.KB } else { "N/A" } Write-Log " $resultStr - $kbStr - $($r.Title) ($sizeStr)" $entry = [PSCustomObject]@{ Date = (Get-Date).ToString("dd.MM.yyyy HH:mm:ss") KB = $kbStr Title = $r.Title Size = $sizeStr Result = $resultStr } $script:UpdateResults.Add($entry) | Out-Null Add-UpdateHistoryEntry -KB $kbStr -Title $r.Title -Size $sizeStr -Result $resultStr } $failed = $deduplicated | Where-Object { $_.Result -and $_.Result.ToString() -notmatch "^Installed|^Succeeded|^Success" } if ($failed -and $retryCount -gt 0) { Write-Log "Retrying $(@($failed).Count) failed update(s)..." -Level WARN foreach ($f in $failed) { $kbId = if ($f.KB) { $f.KB } else { $script:HasErrors = $true; continue } $retrySuccess = $false for ($i = 1; $i -le $retryCount; $i++) { Write-Log " Retry $i/$retryCount for $kbId - $($f.Title)" -Level WARN try { $retryResult = Install-WindowsUpdate -KBArticleID $kbId -AcceptAll -ErrorAction Stop $finalRetryResult = if ($retryResult) { @($retryResult) | Select-Object -Last 1 } else { $null } if ($finalRetryResult -and $finalRetryResult.Result -and $finalRetryResult.Result.ToString() -match "^Installed|^Succeeded|^Success") { Write-Log " Retry successful for $kbId" -Level INFO $retrySuccess = $true $idx = -1 for ($j = 0; $j -lt $script:UpdateResults.Count; $j++) { if ($script:UpdateResults[$j].KB -eq $kbId) { $idx = $j; break } } if ($idx -ge 0) { $script:UpdateResults[$idx] = [PSCustomObject]@{ Date = (Get-Date).ToString("dd.MM.yyyy HH:mm:ss") KB = $kbId Title = $f.Title Size = if ($f.Size) { "{0:N2} MB" -f ($f.Size / 1MB) } else { "N/A" } Result = "Installed (Retry)" } } break } } catch { Write-Log " Retry $i failed: $($_.Exception.Message)" -Level ERROR } } if (-not $retrySuccess) { Write-Log " All retries exhausted for $kbId" -Level ERROR $script:HasErrors = $true } } } elseif ($failed) { $script:HasErrors = $true } return $resultArray } #endregion # =================================================== #region Winget Updates # =================================================== function Invoke-WingetUpgrade { $wingetEnabled = Get-ConfigValue -Config $script:Config -Section "Winget" -Key "Enabled" -Default $false -Type bool if (-not $wingetEnabled) { return } $wingetCmd = Get-Command winget -ErrorAction SilentlyContinue if (-not $wingetCmd) { try { $appxPkg = Get-AppxPackage -AllUsers -Name "Microsoft.DesktopAppInstaller" -ErrorAction SilentlyContinue if ($appxPkg) { $wingetPath = Join-Path -Path $appxPkg.InstallLocation -ChildPath "winget.exe" if (Test-Path -Path $wingetPath) { $wingetCmd = $wingetPath } } } catch { Write-Log "Appx-based winget detection failed: $($_.Exception.Message)" -Level WARN } } if (-not $wingetCmd) { Write-Log "Winget is not available on this system, skipping winget updates" -Level WARN return } Write-Log "Running winget upgrade --all..." try { $wingetExe = if ($wingetCmd -is [System.Management.Automation.ApplicationInfo]) { $wingetCmd.Source } else { $wingetCmd } $output = & $wingetExe upgrade --all --accept-source-agreements --accept-package-agreements --silent 2>&1 $exitCode = $LASTEXITCODE $outputStr = $output | Out-String Write-Log "Winget output:`n$outputStr" if ($exitCode -eq 0) { Write-Log "Winget upgrade completed successfully" } else { Write-Log "Winget upgrade finished with exit code: $exitCode" -Level WARN $script:HasErrors = $true } Add-UpdateHistoryEntry -KB "winget" -Title "Winget Upgrade --all" -Size "N/A" -Result "ExitCode: $exitCode" -UpdateType "Winget" } catch { Write-Log "Winget upgrade error: $($_.Exception.Message)" -Level ERROR $script:HasErrors = $true } } #endregion # =================================================== #region Reboot Management # =================================================== function Test-PendingReboot { $rebootRequired = $false try { $wuReboot = Get-WURebootStatus -Silent -ErrorAction SilentlyContinue if ($wuReboot) { $rebootRequired = $true } } catch { Write-Log "WURebootStatus check failed: $($_.Exception.Message)" -Level WARN } $regPaths = @( "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired" ) foreach ($path in $regPaths) { if (Test-Path -Path $path) { $rebootRequired = $true break } } try { $sessionMgr = Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" -Name PendingFileRenameOperations -ErrorAction SilentlyContinue if ($sessionMgr -and $sessionMgr.PendingFileRenameOperations) { $rebootRequired = $true } } catch { Write-Log "PendingFileRenameOperations check failed: $($_.Exception.Message)" -Level WARN } return $rebootRequired } function Invoke-ScheduledReboot { $rebootEnabled = Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "Enabled" -Default $false -Type bool if (-not $rebootEnabled) { return $false } $mode = Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "Mode" -Default "immediate" switch ($mode.ToLower()) { "immediate" { Write-Log "Initiating immediate reboot..." Send-Notification -Subject "Reboot - $env:COMPUTERNAME" ` -Body "System is rebooting now after Windows Updates." ` -Priority "high" -UpdateResults @($script:UpdateResults) -EventName "Reboot" Start-Sleep -Seconds 5 Restart-Computer -Force } "delayed" { $delayMinutes = Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "DelayMinutes" -Default 5 -Type int $delaySec = $delayMinutes * 60 Write-Log "Scheduling reboot in $delayMinutes minute(s)..." Send-Notification -Subject "Reboot in $delayMinutes min - $env:COMPUTERNAME" ` -Body "System will reboot in $delayMinutes minute(s) after Windows Updates." ` -Priority "high" -UpdateResults @($script:UpdateResults) -EventName "Reboot" & shutdown /r /t $delaySec /f /c "Windows Updater: Scheduled reboot in $delayMinutes minute(s)" } "scheduled" { $scheduledTime = Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "ScheduledTime" -Default "03:00" if ($scheduledTime -notmatch '^\d{1,2}:\d{2}$') { Write-Log "Invalid ScheduledTime format '$scheduledTime' (expected HH:mm), falling back to 03:00" -Level WARN $scheduledTime = "03:00" } $parts = $scheduledTime.Split(':') $hour = [int]$parts[0] $minute = [int]$parts[1] if ($hour -lt 0 -or $hour -gt 23 -or $minute -lt 0 -or $minute -gt 59) { Write-Log "ScheduledTime '$scheduledTime' out of range, falling back to 03:00" -Level WARN $hour = 3; $minute = 0; $scheduledTime = "03:00" } $now = Get-Date $target = Get-Date -Hour $hour -Minute $minute -Second 0 if ($target -le $now) { $target = $target.AddDays(1) } $delaySec = [int]($target - $now).TotalSeconds Write-Log "Scheduling reboot at $scheduledTime (in $([math]::Round($delaySec / 60)) minutes)..." Send-Notification -Subject "Reboot scheduled at $scheduledTime - $env:COMPUTERNAME" ` -Body "System will reboot at $scheduledTime after Windows Updates." ` -Priority "high" -UpdateResults @($script:UpdateResults) -EventName "Reboot" & shutdown /r /t $delaySec /f /c "Windows Updater: Scheduled reboot at $scheduledTime" } default { Write-Log "Unknown reboot mode: $mode" -Level WARN return $false } } return $true } #endregion # =================================================== #region Service Watchdog # =================================================== function Invoke-ServiceWatchdog { $watchdogEnabled = Get-ConfigValue -Config $script:Config -Section "ServiceWatchdog" -Key "Enabled" -Default $false -Type bool if (-not $watchdogEnabled) { return } $serviceList = Get-ConfigValue -Config $script:Config -Section "ServiceWatchdog" -Key "Services" -Default "" if ([string]::IsNullOrWhiteSpace($serviceList)) { Write-Log "ServiceWatchdog enabled but no services configured" -Level WARN return } $services = $serviceList -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($services.Count -eq 0) { return } $waitSeconds = Get-ConfigValue -Config $script:Config -Section "ServiceWatchdog" -Key "WaitSeconds" -Default 30 -Type int $retryCount = Get-ConfigValue -Config $script:Config -Section "ServiceWatchdog" -Key "RetryCount" -Default 3 -Type int Write-Log "Service Watchdog: Restarting $($services.Count) service(s)..." $failedServices = [System.Collections.ArrayList]::new() foreach ($svcName in $services) { Write-Log " Restarting service: $svcName" $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue if (-not $svc) { Write-Log " Service '$svcName' not found on this system" -Level ERROR $failedServices.Add([PSCustomObject]@{ Name = $svcName; Reason = "Service not found" }) | Out-Null continue } $started = $false for ($attempt = 1; $attempt -le $retryCount; $attempt++) { try { if ($attempt -eq 1) { Restart-Service -Name $svcName -Force -ErrorAction Stop } else { Start-Service -Name $svcName -ErrorAction Stop } $svc.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromSeconds($waitSeconds)) $svc.Refresh() if ($svc.Status -eq 'Running') { Write-Log " Service '$svcName' is running (attempt $attempt/$retryCount)" $started = $true break } } catch { Write-Log " Attempt $attempt/$retryCount for '$svcName' failed: $($_.Exception.Message)" -Level WARN if ($attempt -lt $retryCount) { Start-Sleep -Seconds 5 } } } if (-not $started) { $svc.Refresh() Write-Log " Service '$svcName' could not be started (status: $($svc.Status))" -Level ERROR $failedServices.Add([PSCustomObject]@{ Name = $svcName; Reason = "Failed after $retryCount attempts (status: $($svc.Status))" }) | Out-Null $script:HasErrors = $true } } if ($failedServices.Count -gt 0) { $failList = ($failedServices | ForEach-Object { " - $($_.Name): $($_.Reason)" }) -join "`n" $body = "Service Watchdog on $env:COMPUTERNAME failed to restart $($failedServices.Count) service(s):`n$failList" Write-Log "Service Watchdog: $($failedServices.Count) service(s) failed" -Level ERROR Send-Notification -Subject "Service Restart FAILED - $env:COMPUTERNAME" ` -Body $body -Priority "high" -EventName "ServiceRestartFailure" } else { Write-Log "Service Watchdog: All services restarted successfully" } } #endregion # =================================================== #region Main Execution # =================================================== function Start-WindowsUpdater { # --- Load Config --- if (-not [string]::IsNullOrWhiteSpace($script:ConfigFilePath)) { $configPath = $script:ConfigFilePath } else { $configPath = Join-Path -Path $PSScriptRoot -ChildPath "config.ini" } $script:Config = Read-IniFile -Path $configPath # --- Resolve DefenderOnly mode (parameter overrides config) --- if (-not $script:DefenderOnlyMode) { $configUpdateType = Get-ConfigValue -Config $script:Config -Section "General" -Key "UpdateType" -Default "All" if ($configUpdateType -eq "DefenderOnly") { $script:DefenderOnlyMode = $true } } # --- Initialize Logging --- $logPath = Get-ConfigValue -Config $script:Config -Section "General" -Key "LogPath" -Default "C:\logs\windows-updater" $logRetention = Get-ConfigValue -Config $script:Config -Section "General" -Key "LogRetentionDays" -Default 365 -Type int Initialize-Logging -LogPath $logPath -RetentionDays $logRetention $modeLabel = if ($script:DefenderOnlyMode) { "Defender Only" } else { "Full Update" } Write-Log "==========================================" Write-Log "Windows Updater started (Mode: $modeLabel)" Write-Log "==========================================" # --- OS Detection --- $script:OSInfo = Get-OSInfo Write-Log "OS: $($script:OSInfo.Caption) ($($script:OSInfo.OSType))" Write-Log "Version: $($script:OSInfo.Version) (Build $($script:OSInfo.BuildNumber))" Write-Log "Hostname: $env:COMPUTERNAME" # --- Load PSWindowsUpdate --- if (-not (Initialize-PSWindowsUpdate)) { Write-Log "Cannot continue without PSWindowsUpdate module" -Level ERROR Send-Notification -Subject "Windows Update FAILED - $env:COMPUTERNAME" ` -Body "Failed to load PSWindowsUpdate module. No updates were installed." ` -Priority "high" -EventName "ModuleError" exit 1 } # --- Initialize ImportExcel (for history) --- $historyEnabled = Get-ConfigValue -Config $script:Config -Section "History" -Key "Enabled" -Default $false -Type bool if ($historyEnabled) { $script:UseXlsx = Initialize-ImportExcel } # --- WSUS Detection --- $script:WSUSInfo = Get-WSUSConfig if ($script:WSUSInfo.IsConfigured) { Write-Log "WSUS server detected: $($script:WSUSInfo.ServerUrl)" } else { Write-Log "No WSUS configured, using default update sources" } # --- Pre-Update Hook --- $preHookResult = Invoke-UpdateHook -Phase Pre if (-not $preHookResult) { exit 1 } # --- Scan for Updates --- $dryRun = Get-ConfigValue -Config $script:Config -Section "General" -Key "DryRun" -Default $false -Type bool if ($script:DryRunParam) { $dryRun = $true } $availableUpdates = Get-AvailableUpdates if ($null -eq $availableUpdates) { Write-Log "Update scan failed, aborting" -Level ERROR Send-Notification -Subject "Scan Error - $env:COMPUTERNAME" ` -Body "Windows update scan failed on $env:COMPUTERNAME. Check logs for details." ` -Priority "high" -EventName "ScanError" Invoke-UpdateHook -Phase Post | Out-Null Write-Log "Windows Updater finished (scan error)" exit 1 } $updateLabel = if ($script:DefenderOnlyMode) { "Defender" } else { "Windows" } if (@($availableUpdates).Count -eq 0) { Write-Log "No updates available" Send-Notification -Subject "No $updateLabel Updates - $env:COMPUTERNAME" ` -Body "No $updateLabel updates are currently available for $env:COMPUTERNAME." ` -EventName "NoUpdates" if (-not $dryRun) { if (-not $script:DefenderOnlyMode) { Invoke-WingetUpgrade } Export-UpdateHistory } Invoke-UpdateHook -Phase Post | Out-Null if ($script:HasErrors) { Write-Log "Windows Updater finished (no updates, but errors occurred)" -Level WARN exit 1 } Write-Log "Windows Updater finished (no updates)" exit 0 } $updateCount = @($availableUpdates).Count Write-Log "Found $updateCount update(s):" foreach ($u in $availableUpdates) { $sizeStr = if ($u.Size) { "{0:N2} MB" -f ($u.Size / 1MB) } else { "N/A" } $kbStr = if ($u.KB) { $u.KB } else { "N/A" } Write-Log " [$kbStr] $($u.Title) ($sizeStr)" } # --- Dry-Run Mode --- if ($dryRun) { Write-Log "DRY-RUN mode active - no updates will be installed" $reportLines = @("Available $updateLabel Updates for $env:COMPUTERNAME ($updateCount):`n") foreach ($u in $availableUpdates) { $sizeStr = if ($u.Size) { "{0:N2} MB" -f ($u.Size / 1MB) } else { "N/A" } $kbStr = if ($u.KB) { $u.KB } else { "N/A" } $reportLines += " [$kbStr] $($u.Title) ($sizeStr)" } $reportBody = $reportLines -join "`n" Send-Notification -Subject "$updateLabel Dry-Run Report - $env:COMPUTERNAME" -Body $reportBody -EventName "DryRun" Invoke-UpdateHook -Phase Post | Out-Null Write-Log "Windows Updater finished (dry-run)" exit 0 } # --- Install Updates --- Install-PendingUpdates # --- Winget (skip in DefenderOnly mode) --- if (-not $script:DefenderOnlyMode) { Invoke-WingetUpgrade } # --- Export History --- Export-UpdateHistory # --- Send Summary Notification --- $successCount = ($script:UpdateResults | Where-Object { $_.Result -match "^Installed|^Succeeded|^Success" } | Measure-Object).Count $failCount = ($script:UpdateResults | Where-Object { $_.Result -notmatch "^Installed|^Succeeded|^Success" } | Measure-Object).Count $summaryBody = @( "$updateLabel Update Summary for $env:COMPUTERNAME" "OS: $($script:OSInfo.Caption)" "Date: $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')" "" "Successful: $successCount" "Failed: $failCount" "" ) foreach ($r in $script:UpdateResults) { $summaryBody += " [$($r.Result)] $($r.KB) - $($r.Title)" } $summaryText = $summaryBody -join "`n" $hasFailures = $failCount -gt 0 -or $script:HasErrors $priority = if ($hasFailures) { "high" } else { "default" } $subjectPrefix = if ($hasFailures) { "$updateLabel Updates (with errors)" } else { "$updateLabel Updates OK" } Send-Notification -Subject "$subjectPrefix - $env:COMPUTERNAME" ` -Body $summaryText -Priority $priority ` -UpdateResults @($script:UpdateResults) -EventName "UpdateComplete" # --- Post-Update Hook --- Invoke-UpdateHook -Phase Post | Out-Null # --- Reboot Handling (skip in DefenderOnly mode) --- if (-not $script:DefenderOnlyMode) { $script:RebootPending = Test-PendingReboot $forceReboot = Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "ForceReboot" -Default $false -Type bool if ($script:RebootPending -or $forceReboot) { if ($forceReboot -and -not $script:RebootPending) { Write-Log "ForceReboot is enabled, rebooting even though no reboot is pending" } else { Write-Log "System reboot is pending" } $rebootHandled = Invoke-ScheduledReboot if (-not $rebootHandled) { Write-Log "Auto-reboot is disabled, manual reboot required" -Level WARN Send-Notification -Subject "Reboot Required - $env:COMPUTERNAME" ` -Body "Windows updates have been installed on $env:COMPUTERNAME but a reboot is required. Auto-reboot is disabled." ` -Priority "high" -EventName "RebootRequired" } } else { Write-Log "No reboot required" # --- Service Watchdog (only when no reboot is pending) --- Invoke-ServiceWatchdog } } # --- Finish --- $duration = (Get-Date) - $script:ScriptStartTime Write-Log "Windows Updater finished in $([math]::Round($duration.TotalMinutes, 1)) minutes" Write-Log "==========================================" if ($script:HasErrors) { exit 1 } if (-not $script:DefenderOnlyMode -and $script:RebootPending -and -not (Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "Enabled" -Default $false -Type bool)) { exit 2 } exit 0 } # --- Entry Point --- Start-WindowsUpdater #endregion