From fbe28fc6bb08a534deeb214334e1bda32f88514f Mon Sep 17 00:00:00 2001 From: Patrick Asmus Date: Sun, 5 Jul 2026 21:09:14 +0200 Subject: [PATCH] Neuaufbau, viele neue Funktionen, verbesserte Fehlerbehandlung, optimierte Logik, erweiterte Benachrichtigungsoptionen, Winget-Integration, History-Export in CSV/XLSX, Pre/Post Hooks, Reboot-Kontrolle und mehr. --- .gitignore | 1 + LICENSE | 28 +- README.md | 112 ++- Windows Updates mit AutoReboot.xml | 50 -- config.example.ini | 78 ++ docs/configuration.md | 175 +++++ docs/notifications.md | 134 ++++ docs/task-scheduler.md | 149 ++++ docs/troubleshooting.md | 225 ++++++ pswindowsupdate-installer.ps1 | 25 - windows-updater.ps1 | 1143 ++++++++++++++++++++++++++-- 11 files changed, 1968 insertions(+), 152 deletions(-) create mode 100644 .gitignore delete mode 100644 Windows Updates mit AutoReboot.xml create mode 100644 config.example.ini create mode 100644 docs/configuration.md create mode 100644 docs/notifications.md create mode 100644 docs/task-scheduler.md create mode 100644 docs/troubleshooting.md delete mode 100644 pswindowsupdate-installer.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fa7ce7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.ini diff --git a/LICENSE b/LICENSE index 28c00e5..a0176ce 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,29 @@ MIT License -Copyright (c) 2023 scriptos +--- -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Copyright © Patrick Asmus (scriptos) -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +Website: https://www.patrick-asmus.de +Blog: https://www.cleveradmin.de +Email: support@techniverse.net -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 95451cc..3cc9f67 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,109 @@ -# Windows Updates per Powershell installieren +

+ + Techniverse Community + +

-Dieses Script lädt das Modul PSWindowsUpdate lokal in Powershell, installiert anschließend Windows Updates und startet den Rechner neu, falls notwendig. -Alternativ hab ich noch ein kleines PS-Script mit rein, welches das Modul online installieren kann. -Das eigentliche Script ist jedoch darauf ausgelegt das Modul aus dem Repo zu verwenden, weil lokal. +

Windows Updater

+ +

+ Automatisiertes Windows Update Management per PowerShell. Installiert Windows Updates, optional Winget-Pakete, und benachrichtigt per ntfy und/oder E-Mail. +

+ +
+ 🏰 Website + · + 📰 Community + · + 🐘 Mastodon + · + 💬 Support +
+

-## Weiterführende Links/Quellen: -https://de.minitool.com/datentraegerverwaltung/powershell-windows-updaten.html +**Repository:** [https://git.techniverse.net/scriptos/windows-updater](https://git.techniverse.net/scriptos/windows-updater) -https://www.powershellgallery.com/packages/PSWindowsUpdate \ No newline at end of file +## Features + +- **Windows Updates** automatisch installieren via PSWindowsUpdate-Modul +- **Winget-Updates** optional (`winget upgrade --all`), automatische Erkennung der Verfügbarkeit +- **Benachrichtigungen** via ntfy und/oder E-Mail (inkl. HTML-Report mit Ergebnis-Tabelle) +- **Dry-Run Modus** zum Prüfen verfügbarer Updates ohne Installation +- **Update-Ausschlüsse** für bestimmte KBs oder Treiber-Updates +- **Retry-Logik** für fehlgeschlagene Updates (konfigurierbare Anzahl) +- **Log-Rotation** mit einer Datei pro Tag und konfigurierbarer Aufbewahrung +- **Update-History** als XLSX (mit ImportExcel-Modul) oder CSV-Fallback +- **WSUS-Erkennung** automatisch, inkl. Anzeige des WSUS-Servers +- **Reboot-Management** mit drei Modi: sofort, verzögert, oder zu bestimmter Uhrzeit +- **Pre-/Post-Update Hooks** für eigene Scripts vor und nach dem Update-Vorgang +- **OS-Erkennung** für Windows 10/11, Server 2016/2019/2022/2025 + +## Unterstützte Betriebssysteme + +| OS | Windows Updates | Winget | +|---|---|---| +| Windows 10 | Ja | Ja (wenn installiert) | +| Windows 11 | Ja | Ja | +| Windows Server 2016 | Ja | Nein* | +| Windows Server 2019 | Ja | Nein* | +| Windows Server 2022 | Ja | Nein* | +| Windows Server 2025 | Ja | Nein* | + +*Winget ist auf Servern standardmäßig nicht verfügbar. Wird es manuell installiert, erkennt das Script dies automatisch. + +## Schnellstart + +### 1. Repository klonen / Dateien kopieren + +```powershell +git clone https://git.techniverse.net/scriptos/windows-updater.git C:\scripts\windows-updater +``` + +### 2. Konfiguration erstellen + +```powershell +Copy-Item C:\scripts\windows-updater\config.example.ini C:\scripts\windows-updater\config.ini +``` + +Danach `config.ini` mit einem Texteditor öffnen und die gewünschten Werte anpassen. Siehe [Konfiguration](docs/configuration.md) für alle Optionen. + +### 3. Script ausführen (als Administrator) + +```powershell +powershell.exe -ExecutionPolicy Bypass -File "C:\scripts\windows-updater\windows-updater.ps1" +``` + +### 4. Optional: Aufgabenplanung einrichten + +Für automatische Ausführung per Windows Aufgabenplanung siehe [Aufgabenplanung einrichten](docs/task-scheduler.md). + +## Exit-Codes + +| Code | Bedeutung | +|---|---| +| 0 | Erfolg (Updates installiert, keine Updates verfügbar, oder Dry-Run) | +| 1 | Fehler (Modul-Fehler, Config fehlt, Updates fehlgeschlagen) | +| 2 | Neustart erforderlich, aber Auto-Reboot ist deaktiviert | + +## PSWindowsUpdate-Modul + +Das Script versucht zuerst, das [PSWindowsUpdate](https://github.com/mgajda83/PSWindowsUpdate)-Modul online von der PowerShell Gallery zu installieren. Falls das nicht möglich ist (kein Internet, Firewall), wird automatisch die im Repository enthaltene Offline-Kopie verwendet. + +## Dokumentation + +- [Konfiguration](docs/configuration.md) - Alle INI-Optionen im Detail +- [Benachrichtigungen](docs/notifications.md) - ntfy und E-Mail einrichten +- [Aufgabenplanung](docs/task-scheduler.md) - Windows Task Scheduler Setup +- [Problembehandlung](docs/troubleshooting.md) - Häufige Fehler und Lösungen + +

+

+ +

+ +

+ + © Patrick Asmus · Techniverse Network · Lizenz + +

\ No newline at end of file diff --git a/Windows Updates mit AutoReboot.xml b/Windows Updates mit AutoReboot.xml deleted file mode 100644 index e98945f..0000000 --- a/Windows Updates mit AutoReboot.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - 2023-10-21T22:57:08.4089431 - SYSTEM - \Windows Updates mit AutoReboot - - - - 2023-11-29T04:00:00 - true - - 1 - - - - - - S-1-5-18 - LeastPrivilege - - - - IgnoreNew - false - true - true - false - false - - true - false - - true - true - false - false - false - true - false - PT1H - 7 - - - - C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe - -executionpolicy bypass -command "C:\scripts\windows-updater\windows-updater.ps1" - - - \ No newline at end of file diff --git a/config.example.ini b/config.example.ini new file mode 100644 index 0000000..8e70fdd --- /dev/null +++ b/config.example.ini @@ -0,0 +1,78 @@ +############################################### +# Windows Updater - Configuration +# Lines starting with # or ; are comments +# Copy this file to config.ini and adjust values +############################################### + +[General] +# Path for log files (one file per day) +LogPath = C:\logs\windows-updater +# Number of days to keep log files (default: 365) +LogRetentionDays = 365 +# Dry-run mode: scan and report only, do not install (true/false) +DryRun = false + +[WindowsUpdate] +# Use Microsoft Update (includes Office, .NET, drivers) instead of Windows Update only +UseMicrosoftUpdate = true +# Exclude specific KB articles (comma-separated, e.g. KB1234567,KB7654321) +ExcludeKBs = +# Exclude driver updates (true/false) +ExcludeDrivers = false +# Number of retry attempts for failed updates (default: 3) +RetryCount = 3 + +[Winget] +# Enable winget upgrade --all (true/false, default: false) +# Only works on Windows 10/11 with winget installed, auto-skipped if unavailable +Enabled = false + +[Reboot] +# Enable automatic reboot after updates if required (true/false) +Enabled = false +# Reboot mode: immediate, delayed, scheduled +Mode = immediate +# Delay in minutes before reboot (only for Mode = delayed) +DelayMinutes = 5 +# Time to reboot at in HH:mm format (only for Mode = scheduled, e.g. 03:00) +ScheduledTime = 03:00 + +[Notification] +# --- ntfy --- +NtfyEnabled = false +# ntfy server URL (e.g. https://ntfy.sh or your self-hosted instance) +NtfyUrl = https://ntfy.sh +# ntfy topic name +NtfyTopic = windows-updates +# ntfy access token (leave empty if not required) +NtfyToken = +# ntfy priority: min, low, default, high, urgent +NtfyPriority = default + +# --- Email --- +EmailEnabled = false +SmtpServer = smtp.example.com +SmtpPort = 587 +SmtpUseSsl = true +SmtpUsername = +SmtpPassword = +EmailFrom = updater@example.com +# Multiple recipients comma-separated (e.g. admin@example.com,ops@example.com) +EmailTo = admin@example.com +# Send HTML formatted report with update table (true/false) +EmailHtmlReport = true + +[Hooks] +# Path to PowerShell script to execute BEFORE updates (leave empty to disable) +PreUpdateScript = +# Abort update process if pre-hook script fails (true/false) +AbortOnPreHookFailure = false +# Path to PowerShell script to execute AFTER updates (leave empty to disable) +PostUpdateScript = + +[History] +# Enable persistent update history tracking (true/false) +Enabled = false +# Path for the history file +# Uses .xlsx format if ImportExcel module is available, otherwise falls back to .csv +HistoryPath = C:\logs\windows-updater\update-history diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..487b1bf --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,175 @@ +# Konfiguration + +Alle Einstellungen werden in der Datei `config.ini` vorgenommen, die sich im selben Verzeichnis wie das Script befindet. Als Vorlage dient `config.example.ini`. + +## Übersicht aller Optionen + +### [General] + +| Option | Typ | Standard | Beschreibung | +|---|---|---|---| +| `LogPath` | String | `C:\logs\windows-updater` | Verzeichnis für Log-Dateien (eine Datei pro Tag) | +| `LogRetentionDays` | Integer | `365` | Anzahl Tage, nach denen alte Log-Dateien gelöscht werden | +| `DryRun` | Boolean | `false` | Nur prüfen welche Updates verfügbar sind, ohne zu installieren | + +### [WindowsUpdate] + +| Option | Typ | Standard | Beschreibung | +|---|---|---|---| +| `UseMicrosoftUpdate` | Boolean | `true` | Microsoft Update verwenden (inkl. Office, .NET, Treiber). Bei `false` nur Windows Update | +| `ExcludeKBs` | String | *(leer)* | Kommagetrennte Liste von KB-Artikeln die übersprungen werden sollen (z.B. `KB1234567,KB7654321`) | +| `ExcludeDrivers` | Boolean | `false` | Treiber-Updates ausschließen | +| `RetryCount` | Integer | `3` | Anzahl Wiederholungsversuche für fehlgeschlagene Updates | + +### [Winget] + +| Option | Typ | Standard | Beschreibung | +|---|---|---|---| +| `Enabled` | Boolean | `false` | Winget-Updates aktivieren (`winget upgrade --all`). Wird automatisch übersprungen, wenn winget nicht verfügbar ist | + +### [Reboot] + +| Option | Typ | Standard | Beschreibung | +|---|---|---|---| +| `Enabled` | Boolean | `false` | Automatischen Neustart nach Updates aktivieren | +| `Mode` | String | `immediate` | Neustart-Modus: `immediate` (sofort), `delayed` (verzögert), `scheduled` (geplante Uhrzeit) | +| `DelayMinutes` | Integer | `5` | Verzögerung in Minuten (nur bei `Mode = delayed`) | +| `ScheduledTime` | String | `03:00` | Uhrzeit im Format HH:mm (nur bei `Mode = scheduled`). Liegt die Uhrzeit in der Vergangenheit, wird der nächste Tag verwendet | + +### [Notification] + +#### ntfy + +| Option | Typ | Standard | Beschreibung | +|---|---|---|---| +| `NtfyEnabled` | Boolean | `false` | ntfy-Benachrichtigungen aktivieren | +| `NtfyUrl` | String | `https://ntfy.sh` | URL des ntfy-Servers | +| `NtfyTopic` | String | `windows-updates` | ntfy-Topic-Name | +| `NtfyToken` | String | *(leer)* | Zugangs-Token (leer lassen wenn nicht benötigt) | +| `NtfyPriority` | String | `default` | Priorität: `min`, `low`, `default`, `high`, `urgent` | + +#### E-Mail + +| Option | Typ | Standard | Beschreibung | +|---|---|---|---| +| `EmailEnabled` | Boolean | `false` | E-Mail-Benachrichtigungen aktivieren | +| `SmtpServer` | String | *(leer)* | SMTP-Server Adresse | +| `SmtpPort` | Integer | `587` | SMTP-Port | +| `SmtpUseSsl` | Boolean | `true` | SSL/TLS für SMTP verwenden | +| `SmtpUsername` | String | *(leer)* | SMTP-Benutzername (leer für anonyme Authentifizierung) | +| `SmtpPassword` | String | *(leer)* | SMTP-Passwort | +| `EmailFrom` | String | *(leer)* | Absender-Adresse | +| `EmailTo` | String | *(leer)* | Empfänger-Adresse(n), kommagetrennt | +| `EmailHtmlReport` | Boolean | `true` | HTML-formatierten Report mit Update-Tabelle senden | + +### [Hooks] + +| Option | Typ | Standard | Beschreibung | +|---|---|---|---| +| `PreUpdateScript` | String | *(leer)* | Pfad zu einem PowerShell-Script, das VOR den Updates ausgeführt wird | +| `AbortOnPreHookFailure` | Boolean | `false` | Update-Prozess abbrechen wenn das Pre-Hook-Script fehlschlägt | +| `PostUpdateScript` | String | *(leer)* | Pfad zu einem PowerShell-Script, das NACH den Updates ausgeführt wird | + +### [History] + +| Option | Typ | Standard | Beschreibung | +|---|---|---|---| +| `Enabled` | Boolean | `false` | Update-History-Tracking aktivieren | +| `HistoryPath` | String | `C:\logs\windows-updater\update-history` | Basispfad für die History-Datei (Dateiendung wird automatisch gewählt: `.xlsx` wenn ImportExcel verfügbar, sonst `.csv`) | + +## Boolean-Werte + +Folgende Werte werden als `true` erkannt (Groß-/Kleinschreibung egal): +- `true`, `1`, `yes` + +Alles andere wird als `false` interpretiert. + +## Beispiel-Konfigurationen + +### Minimal (nur Windows Updates) + +```ini +[General] +LogPath = C:\logs\windows-updater +DryRun = false + +[WindowsUpdate] +UseMicrosoftUpdate = true +``` + +### Mit E-Mail Benachrichtigung + +```ini +[General] +LogPath = C:\logs\windows-updater +DryRun = false + +[WindowsUpdate] +UseMicrosoftUpdate = true +ExcludeDrivers = true + +[Notification] +EmailEnabled = true +SmtpServer = smtp.example.com +SmtpPort = 587 +SmtpUseSsl = true +SmtpUsername = user@example.com +SmtpPassword = geheim123 +EmailFrom = updater@example.com +EmailTo = admin@example.com +EmailHtmlReport = true + +[Reboot] +Enabled = true +Mode = delayed +DelayMinutes = 10 +``` + +### Vollständig mit ntfy und History + +```ini +[General] +LogPath = C:\logs\windows-updater +LogRetentionDays = 365 +DryRun = false + +[WindowsUpdate] +UseMicrosoftUpdate = true +ExcludeKBs = KB1234567,KB7654321 +ExcludeDrivers = true +RetryCount = 3 + +[Winget] +Enabled = true + +[Reboot] +Enabled = true +Mode = scheduled +ScheduledTime = 03:00 + +[Notification] +NtfyEnabled = true +NtfyUrl = https://ntfy.mein-server.de +NtfyTopic = windows-updates +NtfyToken = tk_mein_token +NtfyPriority = default + +EmailEnabled = true +SmtpServer = smtp.example.com +SmtpPort = 587 +SmtpUseSsl = true +SmtpUsername = user@example.com +SmtpPassword = geheim123 +EmailFrom = updater@example.com +EmailTo = admin@example.com,ops@example.com +EmailHtmlReport = true + +[Hooks] +PreUpdateScript = C:\scripts\pre-update.ps1 +AbortOnPreHookFailure = true +PostUpdateScript = C:\scripts\post-update.ps1 + +[History] +Enabled = true +HistoryPath = C:\logs\windows-updater\update-history +``` diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..00b50e2 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,134 @@ +# Benachrichtigungen einrichten + +Das Script unterstützt zwei unabhängige Benachrichtigungskanäle, die einzeln oder gleichzeitig genutzt werden können. + +## ntfy + +[ntfy](https://ntfy.sh) ist ein einfacher Push-Notification-Dienst. Es kann die öffentliche Instanz (`ntfy.sh`) oder ein selbst gehosteter Server verwendet werden. + +### Einrichtung + +1. **Topic wählen** - Ein Topic-Name ist wie ein Kanal, auf den man Nachrichten sendet (z.B. `windows-updates-meinserver`) + +2. **App installieren** (optional) - ntfy-App für [Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy), [iOS](https://apps.apple.com/app/ntfy/id1625396347) oder [Web](https://ntfy.sh) öffnen und das Topic abonnieren + +3. **Token erstellen** (optional) - Wenn der ntfy-Server Authentifizierung erfordert, einen Access Token erstellen + +4. **Config anpassen:** + +```ini +[Notification] +NtfyEnabled = true +NtfyUrl = https://ntfy.sh +NtfyTopic = windows-updates-meinserver +NtfyToken = +NtfyPriority = default +``` + +### Prioritäten + +| Wert | Bedeutung | +|---|---| +| `min` | Keine Vibration, kein Sound | +| `low` | Keine Vibration | +| `default` | Standard-Verhalten | +| `high` | Hohe Priorität mit Sound | +| `urgent` | Höchste Priorität, durchbricht "Nicht stören" | + +Das Script verwendet automatisch `high` für Fehler und Reboot-Benachrichtigungen. + +### Selbst gehosteter ntfy-Server + +Bei einem eigenen ntfy-Server die URL entsprechend anpassen: + +```ini +NtfyUrl = https://ntfy.mein-server.de +NtfyToken = tk_mein_zugangstoken +``` + +## E-Mail + +### Einrichtung + +1. **SMTP-Zugangsdaten beschaffen** - Diese bekommt man vom E-Mail-Provider oder dem eigenen Mailserver + +2. **Config anpassen:** + +```ini +[Notification] +EmailEnabled = true +SmtpServer = smtp.example.com +SmtpPort = 587 +SmtpUseSsl = true +SmtpUsername = user@example.com +SmtpPassword = mein-passwort +EmailFrom = updater@example.com +EmailTo = admin@example.com +EmailHtmlReport = true +``` + +### Mehrere Empfänger + +Kommagetrennt in `EmailTo`: + +```ini +EmailTo = admin@example.com,ops@example.com,chef@example.com +``` + +### HTML-Report + +Wenn `EmailHtmlReport = true` gesetzt ist, enthält die E-Mail nach der Update-Installation eine formatierte HTML-Tabelle mit: + +- Hostname und Betriebssystem +- Datum und Uhrzeit +- Anzahl erfolgreicher und fehlgeschlagener Updates +- Tabelle mit allen Updates (KB, Titel, Größe, Ergebnis) +- Farbcodierung: Grün für Erfolg, Rot für Fehler + +Bei `EmailHtmlReport = false` wird stattdessen eine einfache Text-E-Mail gesendet. + +### Häufige SMTP-Einstellungen + +| Provider | Server | Port | SSL | +|---|---|---|---| +| Microsoft 365 | `smtp.office365.com` | 587 | true | +| Gmail | `smtp.gmail.com` | 587 | true | +| Eigener Server | `mail.example.com` | 587 oder 465 | true | +| Lokaler Relay | `localhost` | 25 | false | + +**Hinweis:** Bei Gmail wird ein App-spezifisches Passwort benötigt, kein normales Kontopasswort. + +### E-Mail ohne Authentifizierung + +Bei einem lokalen SMTP-Relay ohne Authentifizierung: + +```ini +SmtpServer = mailrelay.local +SmtpPort = 25 +SmtpUseSsl = false +SmtpUsername = +SmtpPassword = +``` + +## Benachrichtigungs-Ereignisse + +Das Script sendet Benachrichtigungen bei folgenden Ereignissen: + +| Ereignis | Priorität | Beschreibung | +|---|---|---| +| Keine Updates | default | Keine Updates verfügbar | +| Dry-Run Report | default | Liste der verfügbaren Updates (ohne Installation) | +| Updates OK | default | Zusammenfassung aller erfolgreich installierten Updates | +| Updates mit Fehlern | high | Zusammenfassung inkl. fehlgeschlagener Updates | +| Modul-Fehler | high | PSWindowsUpdate konnte nicht geladen werden | +| Pre-Hook fehlgeschlagen | high | Pre-Update Script ist fehlgeschlagen, Updates abgebrochen | +| Neustart steht an | high | System wird neugestartet (mit Modus und Zeitpunkt) | +| Neustart erforderlich | high | Neustart nötig, aber Auto-Reboot ist deaktiviert | + +## Sicherheitshinweis + +Die `config.ini` enthält möglicherweise Zugangsdaten (SMTP-Passwort, ntfy-Token). Stelle sicher, dass: + +- Die Datei `config.ini` in `.gitignore` eingetragen ist (ist standardmäßig der Fall) +- Die Dateiberechtigungen auf dem Server eingeschränkt sind (nur Administratoren) +- Das SMTP-Passwort idealerweise ein App-spezifisches Passwort ist, nicht das Hauptpasswort diff --git a/docs/task-scheduler.md b/docs/task-scheduler.md new file mode 100644 index 0000000..7f26e64 --- /dev/null +++ b/docs/task-scheduler.md @@ -0,0 +1,149 @@ +# Aufgabenplanung (Task Scheduler) + +Das Script kann über die Windows Aufgabenplanung automatisch ausgeführt werden, z.B. täglich zu einer bestimmten Uhrzeit. + +## Voraussetzungen + +- Das Script muss als **Administrator** (bzw. **SYSTEM**) ausgeführt werden +- Eine `config.ini` muss im Script-Verzeichnis vorhanden sein +- PowerShell 5.1 muss verfügbar sein (auf allen unterstützten OS standardmäßig vorhanden) + +## Aufgabe per PowerShell erstellen (empfohlen) + +Folgende Befehle in einer **administrativen PowerShell** ausführen: + +### Täglich um 04:00 Uhr + +```powershell +$action = New-ScheduledTaskAction ` + -Execute "powershell.exe" ` + -Argument "-ExecutionPolicy Bypass -NoProfile -File `"C:\scripts\windows-updater\windows-updater.ps1`"" + +$trigger = New-ScheduledTaskTrigger -Daily -At "04:00" + +$principal = New-ScheduledTaskPrincipal ` + -UserId "SYSTEM" ` + -RunLevel Highest ` + -LogonType ServiceAccount + +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -ExecutionTimeLimit (New-TimeSpan -Hours 2) + +Register-ScheduledTask ` + -TaskName "Windows Updater" ` + -Action $action ` + -Trigger $trigger ` + -Principal $principal ` + -Settings $settings ` + -Description "Automatische Windows Updates mit Benachrichtigung" +``` + +### Wöchentlich (z.B. Sonntags um 03:00 Uhr) + +```powershell +$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At "03:00" +``` + +Den Rest wie oben, nur den Trigger austauschen. + +### Bestehende Aufgabe ändern + +```powershell +# Uhrzeit ändern +$trigger = New-ScheduledTaskTrigger -Daily -At "02:00" +Set-ScheduledTask -TaskName "Windows Updater" -Trigger $trigger + +# Aufgabe deaktivieren +Disable-ScheduledTask -TaskName "Windows Updater" + +# Aufgabe aktivieren +Enable-ScheduledTask -TaskName "Windows Updater" + +# Aufgabe löschen +Unregister-ScheduledTask -TaskName "Windows Updater" -Confirm:$false +``` + +### Aufgabe manuell starten (zum Testen) + +```powershell +Start-ScheduledTask -TaskName "Windows Updater" +``` + +## Aufgabe per GUI erstellen + +1. `taskschd.msc` öffnen (oder "Aufgabenplanung" im Startmenü suchen) +2. Rechtsklick auf "Aufgabenplanungsbibliothek" → "Aufgabe erstellen..." +3. Reiter **Allgemein**: + - Name: `Windows Updater` + - "Mit höchsten Privilegien ausführen" aktivieren + - "Unabhängig von der Benutzeranmeldung ausführen" wählen + - Benutzer auf `SYSTEM` ändern +4. Reiter **Trigger**: + - Neuer Trigger → Täglich → Startzeit z.B. 04:00 +5. Reiter **Aktionen**: + - Neue Aktion → "Programm starten" + - Programm: `powershell.exe` + - Argumente: `-ExecutionPolicy Bypass -NoProfile -File "C:\scripts\windows-updater\windows-updater.ps1"` +6. Reiter **Einstellungen**: + - "Aufgabe so schnell wie möglich nach einem verpassten Start ausführen" aktivieren + - "Aufgabe beenden, falls sie länger ausgeführt wird als:" 2 Stunden + +## Wichtige Hinweise + +### SYSTEM-Kontext + +Das Script läuft als `SYSTEM`-Benutzer. Beachte dabei: + +- **Netzlaufwerke** sind nicht verfügbar (SYSTEM hat keine zugeordneten Laufwerke) +- **Umgebungsvariablen** wie `%USERPROFILE%` zeigen auf `C:\Windows\system32\config\systemprofile` +- **winget** muss ggf. über den vollen Pfad aufgerufen werden (das Script behandelt dies automatisch) +- **Log-Dateien** müssen in einem Verzeichnis liegen, auf das SYSTEM Schreibrechte hat (z.B. `C:\logs\`) + +### Execution Policy + +Das Script wird mit `-ExecutionPolicy Bypass` aufgerufen, damit es ohne vorherige Änderung der systemweiten Execution Policy läuft. Dies ist sicher, da nur dieses spezifische Script betroffen ist. + +### Timeout + +Die Standard-Ausführungszeitbegrenzung ist auf 2 Stunden gesetzt. Bei Servern mit vielen Updates kann dies ggf. erhöht werden: + +```powershell +$settings = New-ScheduledTaskSettingsSet ` + -ExecutionTimeLimit (New-TimeSpan -Hours 4) +Set-ScheduledTask -TaskName "Windows Updater" -Settings $settings +``` + +### Log prüfen + +Nach der ersten automatischen Ausführung die Logs prüfen: + +```powershell +# Heutiges Log anzeigen +$today = Get-Date -Format "yyyy-MM-dd" +Get-Content "C:\logs\windows-updater\windows-updater_$today.log" + +# Letzten Lauf in der Aufgabenplanung prüfen +Get-ScheduledTaskInfo -TaskName "Windows Updater" | Select-Object LastRunTime, LastTaskResult +``` + +### Exit-Codes in der Aufgabenplanung + +| Task Result | Bedeutung | +|---|---| +| 0 | Erfolg | +| 1 | Fehler (Details im Log) | +| 2 | Neustart erforderlich (Auto-Reboot deaktiviert) | +| 0x1 | Allgemeiner Fehler | +| 0x41301 | Aufgabe läuft gerade | + +## Empfohlene Zeitplanung + +| Szenario | Empfehlung | +|---|---| +| Arbeitsstation | Täglich, 12:00 (Mittagspause) mit DryRun, Manuelles Update später | +| Server (nicht-kritisch) | Täglich, 03:00-05:00 mit Auto-Reboot | +| Server (kritisch/Produktion) | Wöchentlich Sonntag, 03:00 mit DryRun. Manuell nach Prüfung | +| Testumgebung | Täglich, 02:00 mit Auto-Reboot | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..ea830a4 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,225 @@ +# Problembehandlung + +## PSWindowsUpdate-Modul lässt sich nicht laden + +### Symptom +``` +[ERROR] Failed to load PSWindowsUpdate module from any source +``` + +### Lösungen + +**Online-Installation schlägt fehl:** +1. Prüfen ob TLS 1.2 verfügbar ist: + ```powershell + [Net.ServicePointManager]::SecurityProtocol + ``` +2. NuGet-Provider manuell installieren: + ```powershell + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force + ``` +3. PSGallery als vertrauenswürdige Quelle setzen: + ```powershell + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + ``` +4. Proxy prüfen (falls vorhanden): + ```powershell + [System.Net.WebRequest]::DefaultWebProxy + ``` + +**Offline-Fallback schlägt fehl:** +- Prüfen ob der Ordner `Modules\PSWindowsUpdate` im Script-Verzeichnis existiert +- Prüfen ob die DLL-Datei vorhanden ist: `Modules\PSWindowsUpdate\2.2.1.2\PSWindowsUpdate.dll` + +## Keine Updates gefunden + +### Mögliche Ursachen + +1. **WSUS blockiert Updates:** + - Script zeigt "WSUS server detected" im Log + - WSUS-Server hat möglicherweise keine Updates freigegeben + - Lösung: WSUS-Administrator kontaktieren oder `UseMicrosoftUpdate = true` setzen (funktioniert nur ohne WSUS-GPO) + +2. **Windows Update Service gestoppt:** + ```powershell + Get-Service wuauserv | Select-Object Status + # Falls gestoppt: + Start-Service wuauserv + ``` + +3. **Updates bereits installiert:** + - Das Script zeigt "No updates available" — alle Updates sind aktuell + +## E-Mail wird nicht gesendet + +### Checkliste + +1. **SMTP-Server erreichbar?** + ```powershell + Test-NetConnection -ComputerName smtp.example.com -Port 587 + ``` + +2. **Zugangsdaten korrekt?** Im Log nach folgender Meldung suchen: + ``` + [WARN] Failed to send email: ... + ``` + +3. **App-Passwort bei Gmail/Microsoft 365:** + - Normales Passwort funktioniert oft nicht + - App-spezifisches Passwort in den Kontoeinstellungen erstellen + +4. **Firewall:** Port 587 (oder 465/25) muss ausgehend offen sein + +5. **SSL/TLS:** `SmtpUseSsl = true` für Port 587/465, `false` für Port 25 (lokal) + +## ntfy-Benachrichtigung kommt nicht an + +### Checkliste + +1. **Server erreichbar?** + ```powershell + Invoke-RestMethod -Uri "https://ntfy.sh/test-topic" -Method Post -Body "Test" + ``` + +2. **Topic korrekt?** Topic-Name muss in Config und App identisch sein + +3. **Token korrekt?** Falls Authentifizierung konfiguriert: + ```powershell + $headers = @{ "Authorization" = "Bearer tk_mein_token" } + Invoke-RestMethod -Uri "https://ntfy.mein-server.de/test" -Method Post -Body "Test" -Headers $headers + ``` + +4. **App abonniert?** In der ntfy-App prüfen ob das Topic abonniert ist + +## Winget wird nicht erkannt + +### Auf Client-Systemen (Windows 10/11) + +1. **Winget installiert?** + ```powershell + Get-Command winget + # oder + Get-AppxPackage -Name Microsoft.DesktopAppInstaller + ``` + +2. **Winget aktualisieren:** + - Microsoft Store öffnen → "App-Installer" aktualisieren + +3. **Im SYSTEM-Kontext:** Winget ist eine per-User MSIX-App. Das Script versucht automatisch den Pfad über `Get-AppxPackage -AllUsers` zu finden. + +### Auf Server-Systemen + +Winget ist auf Windows Server standardmäßig **nicht verfügbar**. Es kann manuell installiert werden: + +1. Aktuelle Version von [GitHub](https://github.com/microsoft/winget-cli/releases) herunterladen +2. `.msixbundle` installieren (erfordert ggf. zusätzliche Abhängigkeiten) + +Das Script erkennt automatisch ob winget verfügbar ist und überspringt die Funktion wenn nicht. + +## ImportExcel / XLSX-Export funktioniert nicht + +### Symptom +``` +[WARN] ImportExcel not available, falling back to CSV for history export +``` + +### Lösungen + +1. **Manuell installieren:** + ```powershell + Install-Module -Name ImportExcel -Force -Scope AllUsers + ``` + +2. **Kein Internet:** Das Script versucht automatisch das Modul zu installieren. Ohne Internet wird automatisch CSV als Fallback verwendet. + +3. **CSV statt XLSX:** Funktioniert immer, keine zusätzlichen Module nötig. Die History-Datei wird dann als `.csv` statt `.xlsx` gespeichert. + +## Script bricht mit "must be run as Administrator" ab + +Das Script erfordert Administrator-Rechte für: +- Installation von Windows Updates +- Installation von PowerShell-Modulen +- Zugriff auf Windows Update COM-Objekte + +### Lösung +- PowerShell als Administrator starten (Rechtsklick → "Als Administrator ausführen") +- In der Aufgabenplanung: "Mit höchsten Privilegien ausführen" aktivieren und als `SYSTEM` ausführen + +## Updates schlagen wiederholt fehl + +### Mögliche Ursachen + +1. **Speicherplatz:** Windows Updates benötigen freien Speicher + ```powershell + Get-PSDrive C | Select-Object Used, Free + ``` + +2. **Windows Update Komponenten zurücksetzen:** + ```powershell + # PSWindowsUpdate bietet dafür ein Cmdlet + Reset-WUComponents -Verbose + ``` + +3. **Bestimmte KB ausschließen:** Falls ein spezifisches Update immer fehlschlägt: + ```ini + [WindowsUpdate] + ExcludeKBs = KB1234567 + ``` + +4. **Retry-Count erhöhen:** Manchmal hilft ein weiterer Versuch: + ```ini + [WindowsUpdate] + RetryCount = 5 + ``` + +## Log-Dateien werden nicht erstellt + +1. **Verzeichnis-Berechtigungen prüfen:** + ```powershell + # SYSTEM muss Schreibrechte haben + icacls "C:\logs\windows-updater" + ``` + +2. **Verzeichnis erstellen:** + ```powershell + New-Item -ItemType Directory -Path "C:\logs\windows-updater" -Force + ``` + +3. **LogPath in config.ini prüfen** — Pfad muss ohne Anführungszeichen angegeben werden + +## Hook-Scripts werden nicht ausgeführt + +1. **Pfad korrekt?** Absoluter Pfad verwenden, keine Umgebungsvariablen +2. **Script vorhanden?** `Test-Path` auf den konfigurierten Pfad prüfen +3. **Execution Policy:** Hook-Scripts werden mit `-ExecutionPolicy Bypass` aufgerufen +4. **Exit-Code:** Hook muss mit `exit 0` enden für Erfolg, jeder andere Code gilt als Fehler + +## Allgemeine Diagnose + +### Log-Ausgabe im Detail prüfen + +```powershell +# Heutiges Log anzeigen +$today = Get-Date -Format "yyyy-MM-dd" +Get-Content "C:\logs\windows-updater\windows-updater_$today.log" + +# Nach Fehlern suchen +Select-String -Path "C:\logs\windows-updater\windows-updater_*.log" -Pattern "\[ERROR\]" +``` + +### Script manuell mit Ausgabe testen + +```powershell +# In einer Admin-PowerShell ausführen +powershell.exe -ExecutionPolicy Bypass -File "C:\scripts\windows-updater\windows-updater.ps1" +``` + +### Dry-Run zum Testen + +In `config.ini` setzen: +```ini +[General] +DryRun = true +``` + +Dies prüft alle Funktionen (Logging, Benachrichtigungen, Modulladung) ohne tatsächlich Updates zu installieren. diff --git a/pswindowsupdate-installer.ps1 b/pswindowsupdate-installer.ps1 deleted file mode 100644 index 5502750..0000000 --- a/pswindowsupdate-installer.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -# Script Name: pswindowsupdate-installer.ps1 -# Beschreibung: Installiert das Modul 'PSWindowsUpdate' und alle anderen Voraussetzungen per Internet -# Lauffähig unter Windows Server 2016, 2019 und 2022 -# Aufruf: - -# Autor: Patrick Asmus -# Web: https://www.media-techport.de -# Git-Reposit.: https://git.media-techport.de/scriptos/PSWindowsUpdate.git -# Version: 1.0 -# Datum: 29.11.2023 -# Modifikation: Erste Veröffentlichung -##################################################### - -# Setze TLS 1.2 als Sicherheitsprotokoll -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - -# Überprüfe, ob das PowerShellGet-Modul vorhanden ist. Falls nicht, installiere es. -if (-not (Get-Module -Name PowerShellGet -ListAvailable)) { - Install-Module -Name PowerShellGet -Force -AllowClobber -} - -# Installiere PSWindowsUpdate-Modul -Install-Module -Name PSWindowsUpdate -Force - -# Zeige installiertes PSWindowsUpdate-Modul an -Get-Package -Name PSWindowsUpdate \ No newline at end of file diff --git a/windows-updater.ps1 b/windows-updater.ps1 index d951757..cccb2a6 100644 --- a/windows-updater.ps1 +++ b/windows-updater.ps1 @@ -1,66 +1,1077 @@ -# Script Name: windows-updater.ps1 -# Beschreibung: Lädt Module offline in Powershell, installiert Windows Updates und startet den Rechner ggf. neu -# Lauffähig unter Windows Server 2016, 2019 und 2022 -# Aufruf: - -# Autor: Patrick Asmus -# Web: https://www.media-techport.de -# Git-Reposit.: https://git.media-techport.de/scriptos/windows-updater.git -# Version: 1.1 -# Datum: 30.11.2023 -# Modifikation: log-dir wird erstellt wenn nicht vorhanden -##################################################### - -# Variablen -$LogPfad = "C:\logs\windows-updater.log" -$ModulVerzeichnis = Join-Path -Path $PSScriptRoot -ChildPath "Modules" -$env:PSModulePath += ";$ModulVerzeichnis" - -# Funktion zur Formatierung des Datums im deutschen Stil -function Format-DeutschesDatum ([datetime] $date) { - return $date.ToString("dd.MM.yyyy HH:mm:ss") -} - -# Überprüfe, ob der Log-Ordner existiert, andernfalls erstelle ihn -if (-not (Test-Path -Path $LogPfad)) { - New-Item -ItemType Directory -Path (Split-Path $LogPfad) -Force -} - -# Zeitstempel für das Logfile -$LogZeitstempel = Format-DeutschesDatum (Get-Date) -$LogEintrag = "Windows Updates gestartet am: $LogZeitstempel" -Write-Host $LogEintrag -$LogEintrag | Out-File -Append -FilePath $LogPfad - -# Importiere das PSWindowsUpdate-Modul -Import-Module -Name "PSWindowsUpdate" -Verbose - -# Überprüfe, ob das Modul erfolgreich geladen wurde -if (Get-Module -Name PSWindowsUpdate -ListAvailable) { - Write-Host "PSWindowsUpdate-Modul erfolgreich geladen." - - # Installiere Windows-Updates und speichere die Ausgabe - $Ausgabe = Install-WindowsUpdate -MicrosoftUpdate -AcceptAll -AutoReboot - - # Überprüfe, ob ein Neustart erforderlich ist - if ($Ausgabe.RestartRequired) { - Write-Host "Ein Neustart ist erforderlich." - } else { - Write-Host "Kein Neustart erforderlich." - } - - # Gib die Ausgabe der Windows-Update-Installation in der Kommandozeile aus und schreibe sie ins Logfile - $Ausgabe | ForEach-Object { - $LogMeldung = "ComputerName: $($_.ComputerName) Result: $($_.Result) KB: $($_.KB) Size: $($_.Size) Title: $($_.Title)" - Write-Host $LogMeldung - $LogMeldung | Out-File -Append -FilePath $LogPfad - } -} else { - Write-Host "Fehler beim Laden des PSWindowsUpdate-Moduls." -} - -# Zeitstempel für das Logfile nach Abschluss des Skripts -$LogZeitstempel = Format-DeutschesDatum (Get-Date) -$LogEintrag = "Windows Updates beendet am: $LogZeitstempel" -Write-Host $LogEintrag -$LogEintrag | Out-File -Append -FilePath $LogPfad -"----------------------------" | Out-File -Append -FilePath $LogPfad \ No newline at end of file +#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 += @" + + $($r.Date) + $($r.KB) + $($r.Title) + $($r.Size) + $($r.Result) + +"@ + } + + $successCount = ($Results | Where-Object { $_.Result -match "Install|Success" } | Measure-Object).Count + $failCount = ($Results | Where-Object { $_.Result -notmatch "Install|Success" } | Measure-Object).Count + + $html = @" + + + + +

Windows Update Report

+

Host: $Hostname
+ OS: $OSCaption
+ Date: $(Get-Date -Format "dd.MM.yyyy HH:mm:ss")
+ Successful: $successCount | Failed: $failCount

+ + + + + + + + + + + + $rows + +
DateKBTitleSizeResult
+

Generated by Windows Updater Script

+ + +"@ + + 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