1078 lines
38 KiB
PowerShell
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
|