Files
windows-updater/windows-updater.ps1

1078 lines
38 KiB
PowerShell

#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.0.0
Datum: 05.07.2026
Modifiaktione: Neuaufbau, viele neue Funktionen, verbesserte Fehlerbehandlung, optimierte Logik, erweiterte Benachrichtigungsoptionen, Winget-Integration, History-Export in CSV/XLSX, Pre/Post Hooks, Reboot-Kontrolle und mehr.
#####################################################
#>
# ===================================================
#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
}
[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
#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 "Install|Success") { "#2d7d2d" } else { "#c0392b" }
$bgColor = if ($r.Result -match "Install|Success") { "#e8f5e9" } else { "#fde8e8" }
$rows += @"
<tr style="background-color: $bgColor;">
<td style="padding: 8px; border: 1px solid #ddd;">$($r.Date)</td>
<td style="padding: 8px; border: 1px solid #ddd;">$($r.KB)</td>
<td style="padding: 8px; border: 1px solid #ddd;">$($r.Title)</td>
<td style="padding: 8px; border: 1px solid #ddd;">$($r.Size)</td>
<td style="padding: 8px; border: 1px solid #ddd; color: $color; font-weight: bold;">$($r.Result)</td>
</tr>
"@
}
$successCount = ($Results | Where-Object { $_.Result -match "Install|Success" } | Measure-Object).Count
$failCount = ($Results | Where-Object { $_.Result -notmatch "Install|Success" } | Measure-Object).Count
$html = @"
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Segoe UI, Arial, sans-serif; color: #333; max-width: 900px; margin: 0 auto; padding: 20px;">
<h2 style="color: #1a5276;">Windows Update Report</h2>
<p><strong>Host:</strong> $Hostname<br>
<strong>OS:</strong> $OSCaption<br>
<strong>Date:</strong> $(Get-Date -Format "dd.MM.yyyy HH:mm:ss")<br>
<strong>Successful:</strong> $successCount | <strong>Failed:</strong> $failCount</p>
<table style="border-collapse: collapse; width: 100%; font-size: 14px;">
<thead>
<tr style="background-color: #1a5276; color: white;">
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Date</th>
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">KB</th>
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Title</th>
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Size</th>
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Result</th>
</tr>
</thead>
<tbody>
$rows
</tbody>
</table>
<p style="color: #888; font-size: 12px; margin-top: 20px;">Generated by Windows Updater Script</p>
</body>
</html>
"@
return $html
}
function Send-EmailNotification {
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 ($htmlReport -and $UpdateResults.Count -gt 0) {
$htmlBody = New-HtmlUpdateReport -Results $UpdateResults -Hostname $env:COMPUTERNAME -OSCaption $script:OSInfo.Caption
$mailParams["Body"] = $htmlBody
$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-Notification {
param(
[string]$Subject,
[string]$Body,
[string]$Priority = "default",
[array]$UpdateResults = @()
)
$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
$ntfyPriority = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyPriority" -Default "default"
if (-not $ntfyEnabled -and -not $emailEnabled) {
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
}
}
#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"
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 (-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) {
$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
return @()
}
}
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 (-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) {
$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)
foreach ($r in $resultArray) {
$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 = $resultArray | Where-Object { $_.Result -and $_.Result.ToString() -notmatch "Install|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 { 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
if ($retryResult -and $retryResult.Result -and $retryResult.Result.ToString() -match "Install|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 {}
}
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
}
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 WARN
}
}
#endregion
# ===================================================
#region Reboot Management
# ===================================================
function Test-PendingReboot {
$rebootRequired = $false
try {
$wuReboot = Get-WURebootStatus -Silent -ErrorAction SilentlyContinue
if ($wuReboot) { $rebootRequired = $true }
}
catch {}
$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 {}
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)
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)
& 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"
$now = Get-Date
$target = Get-Date -Hour ([int]($scheduledTime.Split(':')[0])) -Minute ([int]($scheduledTime.Split(':')[1])) -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)
& 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 Main Execution
# ===================================================
function Start-WindowsUpdater {
# --- Load Config ---
$configPath = Join-Path -Path $PSScriptRoot -ChildPath "config.ini"
$script:Config = Read-IniFile -Path $configPath
# --- 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
Write-Log "=========================================="
Write-Log "Windows Updater started"
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"
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
$availableUpdates = Get-AvailableUpdates
if (-not $availableUpdates -or @($availableUpdates).Count -eq 0) {
Write-Log "No updates available"
Send-Notification -Subject "No Updates - $env:COMPUTERNAME" `
-Body "No Windows updates are currently available for $env:COMPUTERNAME."
Invoke-WingetUpgrade
Invoke-UpdateHook -Phase Post | Out-Null
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 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 "Dry-Run Report - $env:COMPUTERNAME" -Body $reportBody
Invoke-WingetUpgrade
Invoke-UpdateHook -Phase Post | Out-Null
Write-Log "Windows Updater finished (dry-run)"
exit 0
}
# --- Install Updates ---
Install-PendingUpdates
# --- Winget ---
Invoke-WingetUpgrade
# --- Export History ---
Export-UpdateHistory
# --- Send Summary Notification ---
$successCount = ($script:UpdateResults | Where-Object { $_.Result -match "Install|Success" } | Measure-Object).Count
$failCount = ($script:UpdateResults | Where-Object { $_.Result -notmatch "Install|Success" } | Measure-Object).Count
$summaryBody = @(
"Windows 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"
$priority = if ($failCount -gt 0) { "high" } else { "default" }
$subjectPrefix = if ($failCount -gt 0) { "Updates (with errors)" } else { "Updates OK" }
Send-Notification -Subject "$subjectPrefix - $env:COMPUTERNAME" `
-Body $summaryText -Priority $priority `
-UpdateResults @($script:UpdateResults)
# --- Post-Update Hook ---
Invoke-UpdateHook -Phase Post | Out-Null
# --- Reboot Handling ---
$script:RebootPending = Test-PendingReboot
if ($script:RebootPending) {
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"
}
}
else {
Write-Log "No reboot required"
}
# --- 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 ($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