1378 lines
50 KiB
PowerShell
1378 lines
50 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.8.0
|
|
Date: 07.07.2026
|
|
Modifications: Add -DefenderOnly, -DryRun, -ConfigFile parameters and UpdateType config option
|
|
#####################################################
|
|
#>
|
|
|
|
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 += @"
|
|
<tr style="background-color: $bgColor;">
|
|
<td style="padding: 8px; border: 1px solid #ddd;">$eDate</td>
|
|
<td style="padding: 8px; border: 1px solid #ddd;">$eKB</td>
|
|
<td style="padding: 8px; border: 1px solid #ddd;">$eTitle</td>
|
|
<td style="padding: 8px; border: 1px solid #ddd;">$eSize</td>
|
|
<td style="padding: 8px; border: 1px solid #ddd; color: $color; font-weight: bold;">$eResult</td>
|
|
</tr>
|
|
"@
|
|
}
|
|
|
|
$successCount = ($Results | Where-Object { $_.Result -match "^Installed|^Succeeded|^Success" } | Measure-Object).Count
|
|
$failCount = ($Results | Where-Object { $_.Result -notmatch "^Installed|^Succeeded|^Success" } | Measure-Object).Count
|
|
|
|
$eHostname = [System.Net.WebUtility]::HtmlEncode($Hostname)
|
|
$eOSCaption = [System.Net.WebUtility]::HtmlEncode($OSCaption)
|
|
|
|
$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> $eHostname<br>
|
|
<strong>OS:</strong> $eOSCaption<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 {
|
|
[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
|
|
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" -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
|