From c267069d029e9e0f62410e6447fd92580ab3fcc7 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Thu, 16 Apr 2026 12:40:53 +0200 Subject: [PATCH] Initial version of the .NET HostAvailabilityMonitor. --- .editorconfig | 15 + .gitignore | 32 ++ Dokumentation.md | 349 +++++++++++++++++ HostAvailabilityMonitor.sln | 21 ++ README.md | 213 +++++++++++ scripts/build-release.ps1 | 47 +++ scripts/register-event-source.ps1 | 16 + src/HostAvailabilityMonitor/App.config | 6 + .../Configuration/MonitorConfiguration.cs | 179 +++++++++ .../HostAvailabilityMonitor.csproj | 61 +++ .../Logging/FileLogger.cs | 185 +++++++++ .../Logging/WindowsEventLogger.cs | 125 ++++++ .../Monitoring/EndpointProbeService.cs | 357 ++++++++++++++++++ .../Monitoring/ProbeResult.cs | 38 ++ .../Notifications/EmailNotifier.cs | 196 ++++++++++ src/HostAvailabilityMonitor/Program.cs | 251 ++++++++++++ .../Properties/AssemblyInfo.cs | 15 + .../State/MonitorStateStore.cs | 76 ++++ src/HostAvailabilityMonitor/appsettings.json | 70 ++++ 19 files changed, 2252 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 Dokumentation.md create mode 100644 HostAvailabilityMonitor.sln create mode 100644 README.md create mode 100644 scripts/build-release.ps1 create mode 100644 scripts/register-event-source.ps1 create mode 100644 src/HostAvailabilityMonitor/App.config create mode 100644 src/HostAvailabilityMonitor/Configuration/MonitorConfiguration.cs create mode 100644 src/HostAvailabilityMonitor/HostAvailabilityMonitor.csproj create mode 100644 src/HostAvailabilityMonitor/Logging/FileLogger.cs create mode 100644 src/HostAvailabilityMonitor/Logging/WindowsEventLogger.cs create mode 100644 src/HostAvailabilityMonitor/Monitoring/EndpointProbeService.cs create mode 100644 src/HostAvailabilityMonitor/Monitoring/ProbeResult.cs create mode 100644 src/HostAvailabilityMonitor/Notifications/EmailNotifier.cs create mode 100644 src/HostAvailabilityMonitor/Program.cs create mode 100644 src/HostAvailabilityMonitor/Properties/AssemblyInfo.cs create mode 100644 src/HostAvailabilityMonitor/State/MonitorStateStore.cs create mode 100644 src/HostAvailabilityMonitor/appsettings.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f37a0c6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.cs] +indent_size = 4 + +[*.{json,md,ps1,yml,yaml}] +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec9f101 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +.vs/ +.vscode/ +_ReSharper*/ +*.user +*.suo +*.cache +*.pdb +*.obj +*.aps +*.tmp +*.bak +*.log +*.nupkg +*.snupkg +*.dbmdl +*.opendb +*.VC.db +*.psess +*.vsp +*.vspx + +[Bb]in/ +[Oo]bj/ +[TestResult*/ +artifacts/ +publish/ + +logs/ +state/ + +packages/ +.nuget/ diff --git a/Dokumentation.md b/Dokumentation.md new file mode 100644 index 0000000..911019a --- /dev/null +++ b/Dokumentation.md @@ -0,0 +1,349 @@ +# Dokumentation – Host Availability Monitor (.NET Framework 4.8) + +## 1. Zielbild + +Die Anwendung überwacht auf einem Windows Server 2019 in festen Intervallen die netzwerktechnische Verfügbarkeit definierter Ziele. Typische Einsatzzwecke: + +- interne Fileshares +- externe HTTPS-Endpunkte +- SFTP-Gegenstellen +- TCP-Dienste +- Hosts per Ping + +Der Betrieb erfolgt typischerweise über den **Windows Task Scheduler** alle 30 Minuten. + +## 2. Unterstützte Zieltypen + +### 2.1 UNC / SMB + +Beispiel: + +```text +\\internerserver1\share1 +``` + +Verhalten: + +1. TCP-Check auf Port 445 (oder überschriebenen Port) +2. Optional zusätzliche Pfadvalidierung mit `Directory.Exists(...)` / `File.Exists(...)` + +Hinweis: + +- `ValidateUncPathAccess=false` ist robuster, wenn es nur um Erreichbarkeit geht. +- `ValidateUncPathAccess=true` ist strenger, weil Berechtigungen, Namensauflösung und tatsächlicher Share-Zugriff geprüft werden. + +### 2.2 HTTP / HTTPS + +Beispiele: + +```text +https://host1.de/url +http://intranet.local/health +``` + +Verhalten: + +- Standardmäßig `HEAD` +- Fallback auf `GET`, wenn `HEAD` nicht unterstützt wird (`405` / `501`) oder der HEAD-Request fehlschlägt +- Jede empfangene HTTP-Antwort zählt als **netzwerktechnisch erreichbar** + +### 2.3 SFTP + +Beispiel: + +```text +sftp://partner.example.local/inbound +``` + +Verhalten: + +- TCP-Port-Prüfung, standardmäßig Port 22 +- kein Login, keine Dateioperation + +### 2.4 TCP + +Beispiel: + +```text +tcp://host2.example.local:8443 +``` + +Verhalten: + +- reiner Verbindungsaufbau auf dem Zielport + +### 2.5 ICMP + +Beispiel: + +```text +icmp://server01 +``` + +Verhalten: + +- Ping über `System.Net.NetworkInformation.Ping` + +### 2.6 Plain Hostnames + +Beispiele: + +```text +server01 +partner-gateway +``` + +Verhalten: + +- wenn `Port` gesetzt ist: TCP-Check +- wenn kein `Port` gesetzt ist: ICMP-Check + +## 3. Konfigurationsmodell + +Die Konfiguration erfolgt in `appsettings.json`. + +### 3.1 Runtime + +| Feld | Bedeutung | +|---|---| +| `MaxConcurrency` | Maximale parallele Checks | +| `DefaultValidateUncPathAccess` | Default für UNC-Pfadzugriffsprüfung | +| `NonZeroExitCodeOnAnyFailure` | Exit Code 2 bei mindestens einem fehlgeschlagenen Check | +| `NonZeroExitCodeOnExecutionError` | Exit Code 1 bei technischen Fehlern | +| `StateDirectory` | Verzeichnis für Statusdatei | + +### 3.2 Logging + +| Feld | Bedeutung | +|---|---| +| `LogDirectory` | Verzeichnis der Logdateien | +| `FilePrefix` | Präfix des Tageslogs | +| `RetentionDays` | Anzahl Tage zur Aufbewahrung | + +### 3.3 EventLog + +| Feld | Bedeutung | +|---|---| +| `Enabled` | Aktiviert Event Log | +| `LogName` | Standard: `Application` | +| `Source` | Event Source | +| `CreateSourceIfMissing` | Optionaler Versuch, die Source anzulegen | +| `WriteSuccessEntries` | Erfolgreiche Einzelchecks schreiben | +| `WriteSummaryEntries` | Laufzusammenfassung schreiben | + +### 3.4 Email + +| Feld | Bedeutung | +|---|---| +| `Enabled` | Aktiviert SMTP-Benachrichtigung | +| `SmtpHost` / `SmtpPort` | SMTP-Ziel | +| `UseSsl` | SSL/TLS für SMTP | +| `Username` / `Password` | Optionale Credentials | +| `From` | Absender | +| `To` | Empfängerliste | +| `SubjectPrefix` | Präfix im Betreff | +| `SendOnFailure` | Mail bei Fehler | +| `SendOnRecovery` | Mail bei Wiederherstellung | +| `OnlyOnStateChange` | Nur bei Statuswechsel | +| `CooldownMinutes` | Minimaler Abstand zwischen Benachrichtigungen | +| `TimeoutSeconds` | SMTP-Timeout | + +### 3.5 Targets + +| Feld | Bedeutung | +|---|---| +| `Name` | Anzeigename | +| `Endpoint` | UNC, URL, SFTP, TCP, ICMP oder Hostname | +| `TimeoutSeconds` | Timeout je Ziel | +| `Port` | Optionaler Zielport | +| `ValidateUncPathAccess` | Optional pro UNC-Ziel | +| `HttpMethod` | `HEAD` oder `GET` | +| `Enabled` | Ziel aktiv/inaktiv | + +## 4. Logging-Verhalten + +### 4.1 Dateilog + +Pro Tag entsteht genau eine Logdatei: + +```text +logs\host-availability-monitor-2026-04-16.log +``` + +Format: + +```text +2026-04-16 08:00:00.123 +02:00 | INFO | Lauf gestartet. +2026-04-16 08:00:00.532 +02:00 | SUCCESS | Name=Externe Webseite | Kind=Https | Endpoint=https://host1.de/url | DurationMs=278 | Status=Reachable | Detail=HTTP-Antwort erhalten (200 OK). | Host=host1.de | Port=443 | HttpStatus=200 +2026-04-16 08:00:01.003 +02:00 | FAIL | Name=SFTP Partner A | Kind=Sftp | Endpoint=sftp://partner.example.local/inbound | DurationMs=10005 | Status=Unreachable | Detail=TCP-Verbindung fehlgeschlagen: A connection attempt failed... | Host=partner.example.local | Port=22 | ErrorType=SocketException +``` + +### 4.2 Retention + +Beim Start eines Laufs werden alte Tageslogs bereinigt. + +Standard: + +- aktueller Tag + 4 vorherige Tage bleiben erhalten +- ältere Logs werden gelöscht + +## 5. Event Log + +Es wird in das Windows Application Event Log geschrieben. + +Typische Events: + +- Fehler bei Einzelchecks: Warning +- technische Fehler: Error +- Laufzusammenfassung: Information oder Warning + +Wichtig: + +- Das Erzeugen einer Event Source benötigt erhöhte Rechte. +- Im produktiven Betrieb sollte die Source einmalig per Skript registriert werden. +- Falls Event Log nicht verfügbar ist, fällt die Anwendung auf Dateilogging zurück und läuft weiter. + +## 6. Benachrichtigungslogik + +Die Anwendung speichert je Ziel einen Status in `state\monitor-state.json`. + +Gespeicherte Informationen pro Ziel: + +- `IsUp` +- `ConsecutiveFailures` +- `LastCheckedUtc` +- `LastChangedUtc` +- `LastNotificationUtc` +- `LastDetail` + +### 6.1 Benachrichtigung bei Fehler + +Es wird benachrichtigt, wenn: + +- `Email.Enabled=true` +- `SendOnFailure=true` +- das Ziel aktuell fehlschlägt +- und je nach Einstellung entweder + - ein Statuswechsel vorliegt oder + - der Cooldown abgelaufen ist + +### 6.2 Benachrichtigung bei Recovery + +Es wird benachrichtigt, wenn: + +- `Email.Enabled=true` +- `SendOnRecovery=true` +- das Ziel aktuell erfolgreich ist +- und der vorherige bekannte Zustand `down` war + +### 6.3 Anti-Spam + +Mit `OnlyOnStateChange=true` werden bei Dauerfehlern nicht bei jedem Lauf erneut Mails versendet. + +Mit `CooldownMinutes > 0` kann zusätzlich ein Zeitfenster definiert werden. + +## 7. Exit Codes + +| Exit Code | Bedeutung | +|---|---| +| `0` | Lauf erfolgreich abgeschlossen | +| `1` | technischer Fehler / Konfigurations- oder Laufzeitproblem | +| `2` | mindestens ein Ziel fehlgeschlagen und `NonZeroExitCodeOnAnyFailure=true` | + +## 8. Task Scheduler Empfehlung + +### 8.1 Allgemein + +- Trigger: alle 30 Minuten +- Benutzerkonto: dediziertes Servicekonto, falls UNC-Zugriffe Berechtigungen benötigen +- "Start in": Installationsverzeichnis setzen + +### 8.2 Beispiel + +**Aktion** + +- Program/script: + +```text +C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe +``` + +- Add arguments: + +```text +C:\Tools\HostAvailabilityMonitor\appsettings.json +``` + +- Start in: + +```text +C:\Tools\HostAvailabilityMonitor +``` + +## 9. Betriebshinweise + +### 9.1 UNC-Prüfung + +Wenn echte Share-Verfügbarkeit geprüft werden soll: + +- Task mit geeignetem Servicekonto ausführen +- `ValidateUncPathAccess=true` für das betreffende Ziel setzen + +Wenn nur der Zielserver bzw. SMB-Port relevant ist: + +- `ValidateUncPathAccess=false` belassen + +### 9.2 HTTP/HTTPS + +Die Anwendung prüft Erreichbarkeit, nicht Business-Logik. Für Applikations-Health kann eine dedizierte Health-URL sinnvoller sein. + +### 9.3 SFTP + +Da nur der Port geprüft wird, eignet sich der Check für "Ist die Gegenstelle erreichbar?", nicht für "Kann ich mich anmelden und Dateien lesen?". + +### 9.4 TLS + +Die Anwendung aktiviert zusätzlich gängige SecurityProtocol-Werte, damit HTTPS-Ziele in älteren .NET-Framework-Umgebungen robuster funktionieren. + +## 10. Build und Deployment + +### 10.1 Voraussetzungen + +- Windows Build Host +- Visual Studio oder Build Tools mit MSBuild +- .NET Framework 4.8 Targeting/Developer Pack + +### 10.2 Build + +```powershell +msbuild .\HostAvailabilityMonitor.sln /p:Configuration=Release /p:Platform="Any CPU" +``` + +### 10.3 Artefakte + +Relevant für Deployment: + +- `HostAvailabilityMonitor.exe` +- `HostAvailabilityMonitor.exe.config` +- `appsettings.json` +- weitere referenzierte .NET-Assemblies aus `bin\Release` + +## 11. Sicherheits- und Resilienz-Aspekte + +Die Anwendung ist bewusst defensiv aufgebaut: + +- Fehler einzelner Checks stoppen nicht den Gesamtlauf +- Event Log Ausfälle führen nicht zum Abbruch +- Statusdatei wird atomar aktualisiert +- Log-Bereinigung blockiert den Lauf nicht +- Mailversand markiert Benachrichtigungen nur bei echtem Erfolg + +## 12. Empfohlene nächste Ausbaustufen + +Optional könnte das Projekt später erweitert werden um: + +- CSV- oder HTML-Report pro Lauf +- pro Target eigene Empfängergruppen +- zusätzliche Protokolle wie FTP oder LDAP-Portchecks +- Performance Counter oder Windows Service statt Task Scheduler +- Gitea CI-Workflow für Windows Runner diff --git a/HostAvailabilityMonitor.sln b/HostAvailabilityMonitor.sln new file mode 100644 index 0000000..d4d7199 --- /dev/null +++ b/HostAvailabilityMonitor.sln @@ -0,0 +1,21 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HostAvailabilityMonitor", "src\HostAvailabilityMonitor\HostAvailabilityMonitor.csproj", "{0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..0518e39 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# Host Availability Monitor (.NET Framework 4.8) + +Robuste Windows-Console-Anwendung für **Windows Server 2019** zur Überwachung von UNC-Fileshares, HTTP/HTTPS-Endpunkten, SFTP-Servern, TCP-Ports und optional ICMP/Ping. + +Die Anwendung ist für den Einsatz mit dem **Task Scheduler** gedacht und schreibt: + +- ein tägliches Rolling-Logfile +- Einträge ins **Windows Application Event Log** +- Statusinformationen in eine lokale Statusdatei +- Benachrichtigungs-E-Mails bei Fehlern und optional bei Recovery + +## Warum .NET Framework 4.8? + +Windows Server 2019 enthält .NET Framework 4.7.2 und unterstützt als separat installierbare Version .NET Framework 4.8. Das macht .NET Framework 4.8 für dieses Umfeld zu einer sehr passenden Zielplattform. + +## Features + +- **Zieltypen** + - UNC / SMB (`\\server\share`) + - HTTP / HTTPS (`http://...`, `https://...`) + - SFTP (netzwerktechnisch per TCP-Port 22 oder konfiguriertem Port) + - TCP (`tcp://server:8443`) + - ICMP (`icmp://server`) + - Optional auch **Plain Hostnames**: + - mit `Port` => TCP-Check + - ohne `Port` => ICMP-Check +- **Tägliche Logrotation** über Dateiname `host-availability-monitor-yyyy-MM-dd.log` +- **Log-Retention** konfigurierbar, Standard: 5 Tage +- **Event Log** in `Application` +- **E-Mail-Benachrichtigung** bei Ausfall und optional bei Recovery +- **Cooldown / State Change Logik** gegen Mail-Spam +- **Resiliente Dateiverarbeitung** mit atomarem Schreiben der Statusdatei +- Für **Task Scheduler** geeignet + +## Projektstruktur + +```text +host-availability-monitor-net48/ +├── HostAvailabilityMonitor.sln +├── README.md +├── Dokumentation.md +├── .gitignore +├── .editorconfig +├── scripts/ +│ ├── build-release.ps1 +│ └── register-event-source.ps1 +└── src/ + └── HostAvailabilityMonitor/ + ├── App.config + ├── appsettings.json + ├── HostAvailabilityMonitor.csproj + ├── Program.cs + ├── Configuration/ + ├── Logging/ + ├── Monitoring/ + ├── Notifications/ + ├── Properties/ + └── State/ +``` + +## Build + +### Visual Studio + +- Solution `HostAvailabilityMonitor.sln` öffnen +- Configuration `Release` +- Build starten + +### MSBuild + +```powershell +msbuild .\HostAvailabilityMonitor.sln /p:Configuration=Release /p:Platform="Any CPU" +``` + +Alternativ liegt das Skript `scripts/build-release.ps1` bei. + +## Deployment + +1. Projekt im Release-Modus bauen +2. Den Inhalt von `src\HostAvailabilityMonitor\bin\Release\` auf den Server kopieren +3. `appsettings.json` anpassen +4. Optional Event Source registrieren: + +```powershell +.\scripts\register-event-source.ps1 -Source "HostAvailabilityMonitor" -LogName "Application" +``` + +5. Task Scheduler anlegen, z. B. alle 30 Minuten + +## Konfiguration + +Die Anwendung nutzt **`appsettings.json`** als Anwendungs-Konfiguration. + +Wichtig für .NET Framework 4.8 in dieser Umsetzung: + +- JSON muss **strict valid** sein +- keine Kommentare +- keine trailing commas + +### Beispiel + +```json +{ + "ApplicationName": "HostAvailabilityMonitor", + "Runtime": { + "MaxConcurrency": 4, + "DefaultValidateUncPathAccess": false, + "NonZeroExitCodeOnAnyFailure": false, + "NonZeroExitCodeOnExecutionError": true, + "StateDirectory": "state" + }, + "Logging": { + "LogDirectory": "logs", + "FilePrefix": "host-availability-monitor", + "RetentionDays": 5 + }, + "EventLog": { + "Enabled": true, + "LogName": "Application", + "Source": "HostAvailabilityMonitor", + "CreateSourceIfMissing": false, + "WriteSuccessEntries": false, + "WriteSummaryEntries": true + }, + "Email": { + "Enabled": false, + "SmtpHost": "smtp.example.local", + "SmtpPort": 25, + "UseSsl": false, + "Username": "", + "Password": "", + "From": "monitor@example.local", + "To": ["ops@example.local"], + "SubjectPrefix": "[HostAvailabilityMonitor]", + "SendOnFailure": true, + "SendOnRecovery": true, + "OnlyOnStateChange": true, + "CooldownMinutes": 0, + "TimeoutSeconds": 30 + }, + "Targets": [ + { + "Name": "Internes Fileshare 1", + "Endpoint": "\\\\internerserver1\\share1", + "TimeoutSeconds": 8, + "ValidateUncPathAccess": false, + "Enabled": true + }, + { + "Name": "Externe Webseite", + "Endpoint": "https://host1.de/url", + "TimeoutSeconds": 10, + "HttpMethod": "HEAD", + "Enabled": true + }, + { + "Name": "SFTP Partner A", + "Endpoint": "sftp://partner.example.local/inbound", + "TimeoutSeconds": 10, + "Port": 22, + "Enabled": true + }, + { + "Name": "Custom TCP Service", + "Endpoint": "tcp://host2.example.local:8443", + "TimeoutSeconds": 10, + "Enabled": true + } + ] +} +``` + +## Task Scheduler + +**Program/script** + +```text +C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe +``` + +**Start in** + +```text +C:\Tools\HostAvailabilityMonitor +``` + +**Optional arguments** + +```text +C:\Tools\HostAvailabilityMonitor\appsettings.json +``` + +## Logging + +- Dateilog: `logs\host-availability-monitor-yyyy-MM-dd.log` +- Retention: Standard 5 Tage +- Event Log: `Application` +- State-Datei: `state\monitor-state.json` + +## Benachrichtigung + +Benachrichtigung wird nur als erfolgreich markiert, wenn der SMTP-Versand wirklich erfolgreich war. Dadurch bleibt die Cooldown-/State-Change-Logik konsistent. + +## Hinweise + +- Für **UNC-Pfade** kann optional zusätzlich der Pfadzugriff validiert werden. Standardmäßig wird nur die **SMB-Erreichbarkeit** über TCP 445 geprüft. +- Für **HTTP/HTTPS** gilt jede empfangene HTTP-Antwort als netzwerktechnisch erreichbar, auch `404` oder `500`. +- Für **SFTP** wird bewusst nur die **Port-Erreichbarkeit** geprüft, kein Login und kein Dateizugriff. +- Das Anlegen einer neuen Event Source erfordert Administratorrechte. Deshalb ist dafür ein separates Installationsskript beigefügt. + +## Weiterführend + +Details zu Logging, Exit Codes, Fehlerfällen und Betriebsmodell stehen in [Dokumentation.md](Dokumentation.md). diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 new file mode 100644 index 0000000..c246854 --- /dev/null +++ b/scripts/build-release.ps1 @@ -0,0 +1,47 @@ +param( + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent $scriptDir +$solutionPath = Join-Path $repoRoot "HostAvailabilityMonitor.sln" + +function Get-MSBuildPath { + $msbuildFromPath = Get-Command msbuild.exe -ErrorAction SilentlyContinue + if ($msbuildFromPath) { + return $msbuildFromPath.Source + } + + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { + $installationPath = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -property installationPath + if ($installationPath) { + $candidate = Join-Path $installationPath "MSBuild\Current\Bin\MSBuild.exe" + if (Test-Path $candidate) { + return $candidate + } + } + } + + throw "MSBuild.exe wurde nicht gefunden. Bitte Visual Studio Build Tools oder Visual Studio installieren." +} + +$msbuild = Get-MSBuildPath +Write-Host "Verwende MSBuild: $msbuild" + +& $msbuild $solutionPath /t:Build /p:Configuration=$Configuration /p:Platform="Any CPU" + +$projectDir = Join-Path $repoRoot "src\HostAvailabilityMonitor" +$outputDir = Join-Path $projectDir "bin\$Configuration" +$publishDir = Join-Path $repoRoot "artifacts\publish\net48" + +if (Test-Path $publishDir) { + Remove-Item -Path $publishDir -Recurse -Force +} + +New-Item -ItemType Directory -Path $publishDir | Out-Null +Copy-Item -Path (Join-Path $outputDir "*") -Destination $publishDir -Recurse -Force + +Write-Host "Build abgeschlossen. Publish-Verzeichnis: $publishDir" diff --git a/scripts/register-event-source.ps1 b/scripts/register-event-source.ps1 new file mode 100644 index 0000000..3e7d0fb --- /dev/null +++ b/scripts/register-event-source.ps1 @@ -0,0 +1,16 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Source, + + [string]$LogName = "Application" +) + +$ErrorActionPreference = "Stop" + +if (-not [System.Diagnostics.EventLog]::SourceExists($Source)) { + New-EventLog -LogName $LogName -Source $Source + Write-Host "Event Source '$Source' wurde in '$LogName' angelegt." +} +else { + Write-Host "Event Source '$Source' existiert bereits." +} diff --git a/src/HostAvailabilityMonitor/App.config b/src/HostAvailabilityMonitor/App.config new file mode 100644 index 0000000..59bb8d6 --- /dev/null +++ b/src/HostAvailabilityMonitor/App.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/HostAvailabilityMonitor/Configuration/MonitorConfiguration.cs b/src/HostAvailabilityMonitor/Configuration/MonitorConfiguration.cs new file mode 100644 index 0000000..375e506 --- /dev/null +++ b/src/HostAvailabilityMonitor/Configuration/MonitorConfiguration.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Web.Script.Serialization; + +namespace HostAvailabilityMonitor.Configuration +{ + public sealed class MonitorConfiguration + { + public string ApplicationName { get; set; } = "HostAvailabilityMonitor"; + public RuntimeOptions Runtime { get; set; } = new RuntimeOptions(); + public LoggingOptions Logging { get; set; } = new LoggingOptions(); + public EventLogOptions EventLog { get; set; } = new EventLogOptions(); + public EmailOptions Email { get; set; } = new EmailOptions(); + public List Targets { get; set; } = new List(); + + public static MonitorConfiguration Load(string path) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException("Konfigurationsdatei wurde nicht gefunden: " + path, path); + } + + var json = File.ReadAllText(path); + var serializer = new JavaScriptSerializer(); + var config = serializer.Deserialize(json); + + if (config == null) + { + throw new InvalidOperationException("Konfigurationsdatei konnte nicht deserialisiert werden."); + } + + config.Runtime = config.Runtime ?? new RuntimeOptions(); + config.Logging = config.Logging ?? new LoggingOptions(); + config.EventLog = config.EventLog ?? new EventLogOptions(); + config.Email = config.Email ?? new EmailOptions(); + config.Targets = config.Targets ?? new List(); + + foreach (var target in config.Targets) + { + if (target == null) + { + continue; + } + + if (target.TimeoutSeconds <= 0) + { + target.TimeoutSeconds = 10; + } + } + + return config; + } + + public void Validate() + { + if (Targets == null || Targets.Count == 0) + { + throw new InvalidOperationException("Es wurde kein Ziel in der Konfiguration definiert."); + } + + foreach (var target in Targets) + { + if (target == null) + { + throw new InvalidOperationException("Die Konfiguration enthaelt ein leeres Target-Objekt."); + } + + if (string.IsNullOrWhiteSpace(target.Endpoint)) + { + throw new InvalidOperationException("Mindestens ein Target hat keinen Endpoint-Wert."); + } + + if (target.TimeoutSeconds <= 0) + { + throw new InvalidOperationException("Target '" + target.DisplayName + "' hat einen ungueltigen TimeoutSeconds-Wert."); + } + } + + if (Runtime.MaxConcurrency <= 0) + { + Runtime.MaxConcurrency = 1; + } + + if (Logging.RetentionDays < 0) + { + throw new InvalidOperationException("Logging.RetentionDays darf nicht negativ sein."); + } + + if (Email.Enabled) + { + if (string.IsNullOrWhiteSpace(Email.SmtpHost)) + { + throw new InvalidOperationException("Email.Enabled=true, aber SmtpHost ist leer."); + } + + if (string.IsNullOrWhiteSpace(Email.From)) + { + throw new InvalidOperationException("Email.Enabled=true, aber From ist leer."); + } + + if (Email.To == null || Email.To.Count == 0) + { + throw new InvalidOperationException("Email.Enabled=true, aber es sind keine Empfaenger in Email.To konfiguriert."); + } + + if (Email.TimeoutSeconds <= 0) + { + Email.TimeoutSeconds = 30; + } + } + } + } + + public sealed class RuntimeOptions + { + public int MaxConcurrency { get; set; } = 4; + public bool DefaultValidateUncPathAccess { get; set; } = false; + public bool NonZeroExitCodeOnAnyFailure { get; set; } = false; + public bool NonZeroExitCodeOnExecutionError { get; set; } = true; + public string StateDirectory { get; set; } = "state"; + } + + public sealed class LoggingOptions + { + public string LogDirectory { get; set; } = "logs"; + public string FilePrefix { get; set; } = "host-availability-monitor"; + public int RetentionDays { get; set; } = 5; + } + + public sealed class EventLogOptions + { + public bool Enabled { get; set; } = true; + public string LogName { get; set; } = "Application"; + public string Source { get; set; } = "HostAvailabilityMonitor"; + public bool CreateSourceIfMissing { get; set; } = false; + public bool WriteSuccessEntries { get; set; } = false; + public bool WriteSummaryEntries { get; set; } = true; + } + + public sealed class EmailOptions + { + public bool Enabled { get; set; } = false; + public string SmtpHost { get; set; } = string.Empty; + public int SmtpPort { get; set; } = 25; + public bool UseSsl { get; set; } = false; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string From { get; set; } = string.Empty; + public List To { get; set; } = new List(); + public string SubjectPrefix { get; set; } = "[HostAvailabilityMonitor]"; + public bool SendOnFailure { get; set; } = true; + public bool SendOnRecovery { get; set; } = true; + public bool OnlyOnStateChange { get; set; } = true; + public int CooldownMinutes { get; set; } = 0; + public int TimeoutSeconds { get; set; } = 30; + } + + public sealed class TargetOptions + { + public string Name { get; set; } = string.Empty; + public string Endpoint { get; set; } = string.Empty; + public int TimeoutSeconds { get; set; } = 10; + public int? Port { get; set; } + public bool? ValidateUncPathAccess { get; set; } + public string HttpMethod { get; set; } + public bool Enabled { get; set; } = true; + + public string DisplayName + { + get { return string.IsNullOrWhiteSpace(Name) ? Endpoint : Name; } + } + + public string StateKey + { + get { return string.IsNullOrWhiteSpace(Name) ? Endpoint : Name.Trim(); } + } + } +} diff --git a/src/HostAvailabilityMonitor/HostAvailabilityMonitor.csproj b/src/HostAvailabilityMonitor/HostAvailabilityMonitor.csproj new file mode 100644 index 0000000..7acc044 --- /dev/null +++ b/src/HostAvailabilityMonitor/HostAvailabilityMonitor.csproj @@ -0,0 +1,61 @@ + + + + + Debug + AnyCPU + {0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B} + Exe + HostAvailabilityMonitor + HostAvailabilityMonitor + v4.8 + 512 + true + true + 7.3 + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/HostAvailabilityMonitor/Logging/FileLogger.cs b/src/HostAvailabilityMonitor/Logging/FileLogger.cs new file mode 100644 index 0000000..86ba284 --- /dev/null +++ b/src/HostAvailabilityMonitor/Logging/FileLogger.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using HostAvailabilityMonitor.Monitoring; + +namespace HostAvailabilityMonitor.Logging +{ + public sealed class FileLogger + { + private readonly object _sync = new object(); + private readonly string _directory; + private readonly string _filePrefix; + private readonly int _retentionDays; + + public FileLogger(string directory, string filePrefix, int retentionDays) + { + _directory = directory; + _filePrefix = filePrefix; + _retentionDays = retentionDays; + + Directory.CreateDirectory(_directory); + } + + public void CleanupOldFiles() + { + if (_retentionDays <= 0) + { + return; + } + + var cutoff = DateTime.Today.AddDays(-Math.Max(0, _retentionDays - 1)); + foreach (var file in Directory.EnumerateFiles(_directory, _filePrefix + "-*.log", SearchOption.TopDirectoryOnly)) + { + try + { + if (TryGetDateFromFileName(file, out var fileDate)) + { + if (fileDate < cutoff) + { + File.Delete(file); + } + } + else + { + var info = new FileInfo(file); + if (info.LastWriteTime.Date < cutoff) + { + info.Delete(); + } + } + } + catch + { + // Cleanup darf den Lauf nicht blockieren. + } + } + } + + public void Info(string message) + { + Write("INFO", message); + } + + public void Warn(string message) + { + Write("WARN", message); + } + + public void Error(string message) + { + Write("ERROR", message); + } + + public void Probe(ProbeResult result) + { + var level = result.IsSuccess ? "SUCCESS" : "FAIL"; + var parts = new List + { + "Name=" + Escape(result.Name), + "Kind=" + result.Kind, + "Endpoint=" + Escape(result.Endpoint), + "DurationMs=" + Math.Max(0L, (long)result.Duration.TotalMilliseconds), + "Status=" + Escape(result.StatusText), + "Detail=" + Escape(result.Detail) + }; + + if (!string.IsNullOrWhiteSpace(result.Host)) + { + parts.Add("Host=" + Escape(result.Host)); + } + + if (result.Port.HasValue) + { + parts.Add("Port=" + result.Port.Value); + } + + if (result.HttpStatusCode.HasValue) + { + parts.Add("HttpStatus=" + result.HttpStatusCode.Value); + } + + if (!string.IsNullOrWhiteSpace(result.ErrorType)) + { + parts.Add("ErrorType=" + Escape(result.ErrorType)); + } + + Write(level, string.Join(" | ", parts)); + } + + private void Write(string level, string message) + { + var line = string.Format( + CultureInfo.InvariantCulture, + "{0:yyyy-MM-dd HH:mm:ss.fff zzz} | {1} | {2}{3}", + DateTimeOffset.Now, + level, + message, + Environment.NewLine); + + var path = GetCurrentLogFilePath(); + + lock (_sync) + { + AppendWithRetries(path, line); + } + } + + private void AppendWithRetries(string path, string line) + { + Exception lastException = null; + + for (var attempt = 1; attempt <= 3; attempt++) + { + try + { + File.AppendAllText(path, line); + return; + } + catch (IOException ex) + { + lastException = ex; + Thread.Sleep(100 * attempt); + } + catch (UnauthorizedAccessException ex) + { + lastException = ex; + Thread.Sleep(100 * attempt); + } + } + + if (lastException != null) + { + throw lastException; + } + } + + private string GetCurrentLogFilePath() + { + var fileName = _filePrefix + "-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"; + return Path.Combine(_directory, fileName); + } + + private bool TryGetDateFromFileName(string path, out DateTime date) + { + date = DateTime.MinValue; + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path); + var prefix = _filePrefix + "-"; + if (!fileNameWithoutExtension.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var datePart = fileNameWithoutExtension.Substring(prefix.Length); + return DateTime.TryParseExact(datePart, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date); + } + + private static string Escape(string value) + { + return (value ?? string.Empty).Replace("\r", " ").Replace("\n", " "); + } + } +} diff --git a/src/HostAvailabilityMonitor/Logging/WindowsEventLogger.cs b/src/HostAvailabilityMonitor/Logging/WindowsEventLogger.cs new file mode 100644 index 0000000..0e8400e --- /dev/null +++ b/src/HostAvailabilityMonitor/Logging/WindowsEventLogger.cs @@ -0,0 +1,125 @@ +using System; +using System.Diagnostics; +using System.Security; +using HostAvailabilityMonitor.Configuration; + +namespace HostAvailabilityMonitor.Logging +{ + public sealed class WindowsEventLogger + { + private readonly EventLogOptions _options; + private readonly FileLogger _fallbackLogger; + private bool _disabled; + + public WindowsEventLogger(EventLogOptions options, FileLogger fallbackLogger) + { + _options = options; + _fallbackLogger = fallbackLogger; + _disabled = !_options.Enabled; + } + + public void TryInitialize() + { + if (_disabled) + { + return; + } + + if (!_options.CreateSourceIfMissing) + { + return; + } + + try + { + if (!EventLog.SourceExists(_options.Source)) + { + var creationData = new EventSourceCreationData(_options.Source, _options.LogName); + EventLog.CreateEventSource(creationData); + _fallbackLogger.Info("EventLog-Quelle '" + _options.Source + "' wurde neu angelegt."); + } + } + catch (SecurityException ex) + { + _fallbackLogger.Warn("EventLog-Quelle konnte nicht geprüft/angelegt werden (fehlende Rechte). EventLog wird nur best effort verwendet. " + ex.Message); + } + catch (Exception ex) + { + _fallbackLogger.Warn("EventLog-Initialisierung konnte die Quelle nicht anlegen. " + ex.GetType().Name + ": " + ex.Message); + } + } + + public void Info(string message, int eventId) + { + Write(message, EventLogEntryType.Information, eventId, forceWrite: true); + } + + public void InfoIfSuccessEnabled(string message, int eventId) + { + if (_options.WriteSuccessEntries) + { + Write(message, EventLogEntryType.Information, eventId, forceWrite: true); + } + } + + public void Warning(string message, int eventId) + { + Write(message, EventLogEntryType.Warning, eventId, forceWrite: true); + } + + public void Error(string message, int eventId) + { + Write(message, EventLogEntryType.Error, eventId, forceWrite: true); + } + + public void Summary(string message, bool hasFailures) + { + if (!_options.WriteSummaryEntries) + { + return; + } + + Write( + message, + hasFailures ? EventLogEntryType.Warning : EventLogEntryType.Information, + hasFailures ? 2100 : 1100, + forceWrite: true); + } + + private void Write(string message, EventLogEntryType entryType, int eventId, bool forceWrite) + { + if (_disabled || !forceWrite) + { + return; + } + + try + { + EventLog.WriteEntry(_options.Source, message, entryType, eventId); + } + catch (SecurityException ex) + { + DisableAndFallback("EventLog-Schreiben fehlgeschlagen (SecurityException). " + ex.Message); + } + catch (ArgumentException ex) + { + DisableAndFallback("EventLog-Schreiben fehlgeschlagen. Vermutlich existiert die Event Source '" + _options.Source + "' nicht. " + ex.Message); + } + catch (Exception ex) + { + DisableAndFallback("EventLog-Schreiben fehlgeschlagen. " + ex.GetType().Name + ": " + ex.Message); + } + } + + private void DisableAndFallback(string reason) + { + if (_disabled) + { + return; + } + + _disabled = true; + _fallbackLogger.Warn(reason); + } + } +} diff --git a/src/HostAvailabilityMonitor/Monitoring/EndpointProbeService.cs b/src/HostAvailabilityMonitor/Monitoring/EndpointProbeService.cs new file mode 100644 index 0000000..11b638a --- /dev/null +++ b/src/HostAvailabilityMonitor/Monitoring/EndpointProbeService.cs @@ -0,0 +1,357 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using HostAvailabilityMonitor.Configuration; + +namespace HostAvailabilityMonitor.Monitoring +{ + public sealed class EndpointProbeService + { + private static readonly HttpClient HttpClient = CreateHttpClient(); + private readonly RuntimeOptions _runtimeOptions; + + public EndpointProbeService(RuntimeOptions runtimeOptions) + { + _runtimeOptions = runtimeOptions; + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12; + } + + public async Task ProbeAsync(TargetOptions target, CancellationToken cancellationToken) + { + var startedAt = DateTimeOffset.Now; + + try + { + var endpoint = (target.Endpoint ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(endpoint)) + { + return Fail(target, TargetKind.Unknown, endpoint, startedAt, "Leerer Endpoint.", null, null, null); + } + + if (endpoint.StartsWith("\\\\", StringComparison.Ordinal)) + { + return await CheckUncAsync(target, endpoint, startedAt, cancellationToken).ConfigureAwait(false); + } + + Uri uri; + if (Uri.TryCreate(endpoint, UriKind.Absolute, out uri)) + { + var scheme = uri.Scheme.ToLowerInvariant(); + switch (scheme) + { + case "http": + return await CheckHttpAsync(target, uri, startedAt, TargetKind.Http, cancellationToken).ConfigureAwait(false); + case "https": + return await CheckHttpAsync(target, uri, startedAt, TargetKind.Https, cancellationToken).ConfigureAwait(false); + case "sftp": + return await CheckTcpAsync(target, uri.Host, target.Port ?? ResolvePort(uri, 22), startedAt, TargetKind.Sftp, endpoint, cancellationToken).ConfigureAwait(false); + case "tcp": + return await CheckTcpAsync(target, uri.Host, target.Port ?? ResolvePort(uri, null), startedAt, TargetKind.Tcp, endpoint, cancellationToken).ConfigureAwait(false); + case "icmp": + return await CheckIcmpAsync(target, uri.Host, startedAt, endpoint).ConfigureAwait(false); + case "ftp": + return await CheckTcpAsync(target, uri.Host, target.Port ?? ResolvePort(uri, 21), startedAt, TargetKind.Ftp, endpoint, cancellationToken).ConfigureAwait(false); + default: + return Fail(target, TargetKind.Unknown, endpoint, startedAt, "Nicht unterstütztes Schema '" + uri.Scheme + "'. Unterstützt: UNC, http, https, sftp, ftp, tcp, icmp.", null, uri.Host, uri.IsDefaultPort ? (int?)null : uri.Port); + } + } + + return await CheckPlainHostAsync(target, endpoint, startedAt, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) + { + return Fail(target, TargetKind.Unknown, target.Endpoint, startedAt, "Timeout oder Abbruch während des Checks.", ex, null, null); + } + catch (Exception ex) + { + return Fail(target, TargetKind.Unknown, target.Endpoint, startedAt, ex.Message, ex, null, null); + } + } + + private async Task CheckPlainHostAsync(TargetOptions target, string host, DateTimeOffset startedAt, CancellationToken cancellationToken) + { + if (target.Port.HasValue) + { + return await CheckTcpAsync(target, host, target.Port.Value, startedAt, TargetKind.Host, host, cancellationToken).ConfigureAwait(false); + } + + return await CheckIcmpAsync(target, host, startedAt, host).ConfigureAwait(false); + } + + private async Task CheckUncAsync(TargetOptions target, string endpoint, DateTimeOffset startedAt, CancellationToken cancellationToken) + { + var host = ExtractUncServer(endpoint); + var port = target.Port ?? 445; + + var tcpResult = await CheckTcpAsync(target, host, port, startedAt, TargetKind.Unc, endpoint, cancellationToken).ConfigureAwait(false); + if (!tcpResult.IsSuccess) + { + return tcpResult; + } + + var validatePath = target.ValidateUncPathAccess ?? _runtimeOptions.DefaultValidateUncPathAccess; + if (!validatePath) + { + return Success(target, TargetKind.Unc, endpoint, startedAt, host, port, "SMB-Port erreichbar; UNC-Pfadvalidierung übersprungen.", null); + } + + try + { + var existsTask = Task.Run(function: () => Directory.Exists(endpoint) || File.Exists(endpoint), cancellationToken); + var timed = await CompleteWithinAsync(existsTask, TimeSpan.FromSeconds(target.TimeoutSeconds), cancellationToken).ConfigureAwait(false); + if (!timed.Completed) + { + return Fail(target, TargetKind.Unc, endpoint, startedAt, "Timeout während der UNC-Pfadvalidierung.", null, host, port); + } + + if (timed.Result) + { + return Success(target, TargetKind.Unc, endpoint, startedAt, host, port, "SMB-Port erreichbar; UNC-Pfad wurde erfolgreich validiert.", null); + } + + return Fail(target, TargetKind.Unc, endpoint, startedAt, "SMB-Port erreichbar, aber UNC-Pfadvalidierung war nicht erfolgreich.", null, host, port); + } + catch (Exception ex) + { + return Fail(target, TargetKind.Unc, endpoint, startedAt, "UNC-Pfadvalidierung fehlgeschlagen: " + ex.Message, ex, host, port); + } + } + + private async Task CheckHttpAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, TargetKind kind, CancellationToken cancellationToken) + { + var timeout = TimeSpan.FromSeconds(target.TimeoutSeconds); + var methodText = string.IsNullOrWhiteSpace(target.HttpMethod) ? "HEAD" : target.HttpMethod.Trim().ToUpperInvariant(); + var method = new HttpMethod(methodText); + + using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + using (var request = new HttpRequestMessage(method, uri)) + { + timeoutCts.CancelAfter(timeout); + try + { + using (var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token).ConfigureAwait(false)) + { + if (string.Equals(methodText, "HEAD", StringComparison.OrdinalIgnoreCase) + && (response.StatusCode == HttpStatusCode.MethodNotAllowed || response.StatusCode == HttpStatusCode.NotImplemented)) + { + return await RetryHttpWithGetAsync(target, uri, startedAt, kind, new InvalidOperationException("HEAD nicht unterstützt (" + (int)response.StatusCode + " " + response.ReasonPhrase + ")."), cancellationToken).ConfigureAwait(false); + } + + return Success(target, kind, uri.ToString(), startedAt, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80), "HTTP-Antwort erhalten (" + (int)response.StatusCode + " " + response.ReasonPhrase + ").", (int)response.StatusCode); + } + } + catch (HttpRequestException ex) + { + if (string.Equals(methodText, "HEAD", StringComparison.OrdinalIgnoreCase)) + { + return await RetryHttpWithGetAsync(target, uri, startedAt, kind, ex, cancellationToken).ConfigureAwait(false); + } + + return Fail(target, kind, uri.ToString(), startedAt, "HTTP-Check fehlgeschlagen: " + ex.Message, ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80)); + } + catch (TaskCanceledException ex) + { + return Fail(target, kind, uri.ToString(), startedAt, "HTTP-Check ist in einen Timeout gelaufen.", ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80)); + } + catch (Exception ex) + { + return Fail(target, kind, uri.ToString(), startedAt, "HTTP-Check fehlgeschlagen: " + ex.Message, ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80)); + } + } + } + + private async Task RetryHttpWithGetAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, TargetKind kind, Exception headException, CancellationToken cancellationToken) + { + using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + using (var request = new HttpRequestMessage(HttpMethod.Get, uri)) + { + timeoutCts.CancelAfter(TimeSpan.FromSeconds(target.TimeoutSeconds)); + try + { + using (var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token).ConfigureAwait(false)) + { + return Success(target, kind, uri.ToString(), startedAt, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80), "HEAD nicht erfolgreich (" + headException.GetType().Name + "), GET erfolgreich (" + (int)response.StatusCode + " " + response.ReasonPhrase + ").", (int)response.StatusCode); + } + } + catch (Exception ex) + { + return Fail(target, kind, uri.ToString(), startedAt, "HTTP-Check fehlgeschlagen. HEAD: " + headException.Message + "; GET: " + ex.Message, ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80)); + } + } + } + + private async Task CheckTcpAsync(TargetOptions target, string host, int? port, DateTimeOffset startedAt, TargetKind kind, string endpoint, CancellationToken cancellationToken) + { + if (!port.HasValue || port.Value <= 0) + { + return Fail(target, kind, endpoint, startedAt, "Für diesen Endpoint konnte kein gültiger TCP-Port ermittelt werden.", null, host, port); + } + + var timeout = TimeSpan.FromSeconds(target.TimeoutSeconds); + + using (var client = new TcpClient()) + { + try + { + var connectTask = client.ConnectAsync(host, port.Value); + var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout, cancellationToken)).ConfigureAwait(false); + if (completedTask != connectTask) + { + try + { + client.Close(); + } + catch + { + } + + cancellationToken.ThrowIfCancellationRequested(); + return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung fehlgeschlagen: Timeout.", null, host, port); + } + + await connectTask.ConfigureAwait(false); + return Success(target, kind, endpoint, startedAt, host, port, "TCP-Verbindung erfolgreich aufgebaut.", null); + } + catch (Exception ex) + { + return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung fehlgeschlagen: " + ex.Message, ex, host, port); + } + } + } + + private async Task CheckIcmpAsync(TargetOptions target, string host, DateTimeOffset startedAt, string endpoint) + { + try + { + using (var ping = new Ping()) + { + var reply = await ping.SendPingAsync(host, (int)TimeSpan.FromSeconds(target.TimeoutSeconds).TotalMilliseconds).ConfigureAwait(false); + if (reply.Status == IPStatus.Success) + { + return Success(target, TargetKind.Icmp, endpoint, startedAt, host, null, "ICMP erfolgreich, RTT=" + reply.RoundtripTime + "ms.", null); + } + + return Fail(target, TargetKind.Icmp, endpoint, startedAt, "ICMP fehlgeschlagen: " + reply.Status + ".", null, host, null); + } + } + catch (Exception ex) + { + return Fail(target, TargetKind.Icmp, endpoint, startedAt, "ICMP-Check fehlgeschlagen: " + ex.Message, ex, host, null); + } + } + + private static ProbeResult Success(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string host, int? port, string detail, int? httpStatusCode) + { + return new ProbeResult + { + StateKey = target.StateKey, + Name = target.DisplayName, + Endpoint = endpoint, + Kind = kind, + IsSuccess = true, + StatusText = "Reachable", + Detail = detail, + Host = host, + Port = port, + HttpStatusCode = httpStatusCode, + ErrorType = string.Empty, + StartedAtLocal = startedAt, + FinishedAtLocal = DateTimeOffset.Now + }; + } + + private static ProbeResult Fail(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string detail, Exception exception, string host, int? port) + { + return new ProbeResult + { + StateKey = target.StateKey, + Name = target.DisplayName, + Endpoint = endpoint, + Kind = kind, + IsSuccess = false, + StatusText = "Unreachable", + Detail = detail, + Host = host, + Port = port, + ErrorType = exception == null ? string.Empty : exception.GetType().Name, + StartedAtLocal = startedAt, + FinishedAtLocal = DateTimeOffset.Now + }; + } + + private static HttpClient CreateHttpClient() + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + UseProxy = false + }; + + var client = new HttpClient(handler); + client.Timeout = Timeout.InfiniteTimeSpan; + return client; + } + + private static int? ResolvePort(Uri uri, int? fallback) + { + if (uri == null) + { + return fallback; + } + + if (!uri.IsDefaultPort && uri.Port > 0) + { + return uri.Port; + } + + return fallback; + } + + private static string ExtractUncServer(string uncPath) + { + if (string.IsNullOrWhiteSpace(uncPath)) + { + return string.Empty; + } + + var trimmed = uncPath.TrimStart('\\'); + var firstSeparator = trimmed.IndexOf('\\'); + if (firstSeparator < 0) + { + return trimmed; + } + + return trimmed.Substring(0, firstSeparator); + } + + private static async Task> CompleteWithinAsync(Task task, TimeSpan timeout, CancellationToken cancellationToken) + { + var completedTask = await Task.WhenAny(task, Task.Delay(timeout, cancellationToken)).ConfigureAwait(false); + if (completedTask == task) + { + return new TimedResult(true, await task.ConfigureAwait(false)); + } + + cancellationToken.ThrowIfCancellationRequested(); + return new TimedResult(false, default(T)); + } + + private struct TimedResult + { + public TimedResult(bool completed, T result) + { + Completed = completed; + Result = result; + } + + public bool Completed { get; private set; } + public T Result { get; private set; } + } + } +} diff --git a/src/HostAvailabilityMonitor/Monitoring/ProbeResult.cs b/src/HostAvailabilityMonitor/Monitoring/ProbeResult.cs new file mode 100644 index 0000000..52632b1 --- /dev/null +++ b/src/HostAvailabilityMonitor/Monitoring/ProbeResult.cs @@ -0,0 +1,38 @@ +using System; + +namespace HostAvailabilityMonitor.Monitoring +{ + public enum TargetKind + { + Unknown = 0, + Unc = 1, + Http = 2, + Https = 3, + Sftp = 4, + Tcp = 5, + Icmp = 6, + Host = 7, + Ftp = 8 + } + + public sealed class ProbeResult + { + public string StateKey { get; set; } + public string Name { get; set; } + public string Endpoint { get; set; } + public TargetKind Kind { get; set; } + public bool IsSuccess { get; set; } + public string StatusText { get; set; } + public string Detail { get; set; } + public string Host { get; set; } + public int? Port { get; set; } + public int? HttpStatusCode { get; set; } + public string ErrorType { get; set; } + public DateTimeOffset StartedAtLocal { get; set; } + public DateTimeOffset FinishedAtLocal { get; set; } + public TimeSpan Duration + { + get { return FinishedAtLocal - StartedAtLocal; } + } + } +} diff --git a/src/HostAvailabilityMonitor/Notifications/EmailNotifier.cs b/src/HostAvailabilityMonitor/Notifications/EmailNotifier.cs new file mode 100644 index 0000000..670dcb3 --- /dev/null +++ b/src/HostAvailabilityMonitor/Notifications/EmailNotifier.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Mail; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using HostAvailabilityMonitor.Configuration; +using HostAvailabilityMonitor.Monitoring; +using HostAvailabilityMonitor.State; + +namespace HostAvailabilityMonitor.Notifications +{ + public sealed class EmailNotifier + { + private readonly EmailOptions _options; + + public EmailNotifier(EmailOptions options) + { + _options = options ?? new EmailOptions(); + } + + public bool IsEnabled + { + get { return _options.Enabled; } + } + + public bool ShouldNotify(ProbeResult result, TargetState previousState) + { + if (!IsEnabled || result == null) + { + return false; + } + + if (result.IsSuccess && !_options.SendOnRecovery) + { + return false; + } + + if (!result.IsSuccess && !_options.SendOnFailure) + { + return false; + } + + var nowUtc = DateTime.UtcNow; + var cooldown = TimeSpan.FromMinutes(Math.Max(0, _options.CooldownMinutes)); + var cooldownPassed = previousState == null + || !previousState.LastNotificationUtc.HasValue + || cooldown == TimeSpan.Zero + || (nowUtc - previousState.LastNotificationUtc.Value) >= cooldown; + + if (_options.OnlyOnStateChange) + { + if (previousState == null) + { + return !result.IsSuccess && cooldownPassed; + } + + if (previousState.IsUp == result.IsSuccess) + { + return false; + } + + return cooldownPassed; + } + + if (result.IsSuccess && previousState == null) + { + return false; + } + + return cooldownPassed; + } + + public async Task SendAsync( + IReadOnlyCollection failures, + IReadOnlyCollection recoveries, + IDictionary currentState, + CancellationToken cancellationToken) + { + if (!IsEnabled) + { + return; + } + + var failureCount = failures == null ? 0 : failures.Count; + var recoveryCount = recoveries == null ? 0 : recoveries.Count; + if (failureCount == 0 && recoveryCount == 0) + { + return; + } + + var subject = BuildSubject(failureCount, recoveryCount); + var body = BuildBody(failures, recoveries, currentState); + + using (var message = new MailMessage()) + { + message.From = new MailAddress(_options.From); + foreach (var recipient in _options.To.Where(x => !string.IsNullOrWhiteSpace(x))) + { + message.To.Add(recipient); + } + + message.Subject = subject; + message.Body = body; + message.IsBodyHtml = false; + message.BodyEncoding = Encoding.UTF8; + message.SubjectEncoding = Encoding.UTF8; + + using (var client = new SmtpClient(_options.SmtpHost, _options.SmtpPort)) + { + client.EnableSsl = _options.UseSsl; + client.Timeout = Math.Max(1, _options.TimeoutSeconds) * 1000; + + if (!string.IsNullOrWhiteSpace(_options.Username)) + { + client.Credentials = new NetworkCredential(_options.Username, _options.Password); + } + + cancellationToken.ThrowIfCancellationRequested(); + await client.SendMailAsync(message).ConfigureAwait(false); + } + } + } + + private string BuildSubject(int failureCount, int recoveryCount) + { + if (failureCount > 0 && recoveryCount > 0) + { + return string.Format("{0} {1} Ausfall/Ausfälle, {2} Recovery/Recoveries", _options.SubjectPrefix, failureCount, recoveryCount); + } + + if (failureCount > 0) + { + return string.Format("{0} {1} Ausfall/Ausfälle erkannt", _options.SubjectPrefix, failureCount); + } + + return string.Format("{0} {1} Recovery/Recoveries erkannt", _options.SubjectPrefix, recoveryCount); + } + + private string BuildBody(IReadOnlyCollection failures, IReadOnlyCollection recoveries, IDictionary currentState) + { + var sb = new StringBuilder(); + sb.AppendLine("Host Availability Monitor"); + sb.AppendLine("Zeitpunkt: " + DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz")); + sb.AppendLine(); + + if (failures != null && failures.Count > 0) + { + sb.AppendLine("Fehlgeschlagene Checks:"); + foreach (var failure in failures.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)) + { + AppendResult(sb, failure, currentState); + } + sb.AppendLine(); + } + + if (recoveries != null && recoveries.Count > 0) + { + sb.AppendLine("Wiederhergestellte Checks:"); + foreach (var recovery in recoveries.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)) + { + AppendResult(sb, recovery, currentState); + } + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static void AppendResult(StringBuilder sb, ProbeResult result, IDictionary currentState) + { + sb.AppendLine("- Name: " + result.Name); + sb.AppendLine(" Endpoint: " + result.Endpoint); + sb.AppendLine(" Status: " + result.StatusText); + sb.AppendLine(" Detail: " + result.Detail); + if (!string.IsNullOrWhiteSpace(result.Host)) + { + sb.AppendLine(" Host: " + result.Host); + } + if (result.Port.HasValue) + { + sb.AppendLine(" Port: " + result.Port.Value); + } + sb.AppendLine(" Dauer(ms): " + Math.Max(0L, (long)result.Duration.TotalMilliseconds)); + + TargetState state; + if (currentState != null && currentState.TryGetValue(result.StateKey, out state) && state != null) + { + sb.AppendLine(" ConsecutiveFailures: " + state.ConsecutiveFailures); + sb.AppendLine(" LastChangedUtc: " + state.LastChangedUtc.ToString("yyyy-MM-dd HH:mm:ss") + "Z"); + } + } + } +} diff --git a/src/HostAvailabilityMonitor/Program.cs b/src/HostAvailabilityMonitor/Program.cs new file mode 100644 index 0000000..23ff9a2 --- /dev/null +++ b/src/HostAvailabilityMonitor/Program.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using HostAvailabilityMonitor.Configuration; +using HostAvailabilityMonitor.Logging; +using HostAvailabilityMonitor.Monitoring; +using HostAvailabilityMonitor.Notifications; +using HostAvailabilityMonitor.State; + +namespace HostAvailabilityMonitor +{ + internal static class Program + { + private static int Main(string[] args) + { + return MainAsync(args).GetAwaiter().GetResult(); + } + + private static async Task MainAsync(string[] args) + { + FileLogger logger = null; + WindowsEventLogger eventLogger = null; + var nonZeroExitCodeOnExecutionError = true; + + try + { + var baseDirectory = AppDomain.CurrentDomain.BaseDirectory; + var configPath = args != null && args.Length > 0 && !string.IsNullOrWhiteSpace(args[0]) + ? args[0] + : Path.Combine(baseDirectory, "appsettings.json"); + + var config = MonitorConfiguration.Load(configPath); + config.Validate(); + nonZeroExitCodeOnExecutionError = config.Runtime.NonZeroExitCodeOnExecutionError; + + var logDirectory = ResolvePath(baseDirectory, config.Logging.LogDirectory); + var stateDirectory = ResolvePath(baseDirectory, config.Runtime.StateDirectory); + var stateFilePath = Path.Combine(stateDirectory, "monitor-state.json"); + + logger = new FileLogger(logDirectory, config.Logging.FilePrefix, config.Logging.RetentionDays); + logger.CleanupOldFiles(); + logger.Info(config.ApplicationName + " gestartet."); + logger.Info("Konfiguration geladen: " + Path.GetFullPath(configPath)); + + eventLogger = new WindowsEventLogger(config.EventLog, logger); + eventLogger.TryInitialize(); + eventLogger.Info(config.ApplicationName + " gestartet.", 1000); + + Directory.CreateDirectory(stateDirectory); + var stateStore = new MonitorStateStore(stateFilePath); + Dictionary previousState; + try + { + previousState = stateStore.Load(); + } + catch (Exception ex) + { + logger.Warn("Statusdatei konnte nicht geladen werden. Es wird mit leerem Zustand fortgefahren. " + ex.GetType().Name + ": " + ex.Message); + previousState = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var currentState = new Dictionary(previousState, StringComparer.OrdinalIgnoreCase); + var probeService = new EndpointProbeService(config.Runtime); + var enabledTargets = config.Targets.Where(t => t != null && t.Enabled).ToList(); + + if (enabledTargets.Count == 0) + { + logger.Warn("Es sind keine aktivierten Targets vorhanden. Der Lauf endet ohne Checks."); + eventLogger.Summary("HostAvailabilityMonitor ausgeführt, aber es waren keine aktivierten Targets vorhanden.", false); + return 0; + } + + var results = await ExecuteChecksAsync(enabledTargets, probeService, config.Runtime.MaxConcurrency).ConfigureAwait(false); + + foreach (var result in results.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase)) + { + logger.Probe(result); + TargetState previous; + previousState.TryGetValue(result.StateKey, out previous); + UpdateState(currentState, result, previous); + + if (!result.IsSuccess) + { + eventLogger.Warning("Check fehlgeschlagen: " + result.Name + " | " + result.Endpoint + " | " + result.Detail, 2000); + } + else + { + eventLogger.InfoIfSuccessEnabled("Check erfolgreich: " + result.Name + " | " + result.Endpoint + " | " + result.Detail, 1001); + } + } + + var notifier = new EmailNotifier(config.Email); + var failuresToNotify = new List(); + var recoveriesToNotify = new List(); + var notifiedKeys = new List(); + + foreach (var result in results) + { + TargetState oldState; + previousState.TryGetValue(result.StateKey, out oldState); + if (!notifier.ShouldNotify(result, oldState)) + { + continue; + } + + if (result.IsSuccess) + { + recoveriesToNotify.Add(result); + } + else + { + failuresToNotify.Add(result); + } + + notifiedKeys.Add(result.StateKey); + } + + if (notifier.IsEnabled) + { + try + { + await notifier.SendAsync(failuresToNotify, recoveriesToNotify, currentState, CancellationToken.None).ConfigureAwait(false); + if (failuresToNotify.Count > 0 || recoveriesToNotify.Count > 0) + { + foreach (var key in notifiedKeys) + { + TargetState stateEntry; + if (currentState.TryGetValue(key, out stateEntry) && stateEntry != null) + { + stateEntry.LastNotificationUtc = DateTime.UtcNow; + } + } + + logger.Info("Benachrichtigungs-E-Mail versendet. Failures=" + failuresToNotify.Count + ", Recoveries=" + recoveriesToNotify.Count); + } + } + catch (Exception ex) + { + logger.Error("E-Mail-Versand fehlgeschlagen: " + ex.GetType().Name + ": " + ex.Message); + eventLogger.Error("E-Mail-Versand fehlgeschlagen: " + ex.Message, 9001); + } + } + + stateStore.Save(currentState); + + var total = results.Count; + var ok = results.Count(r => r.IsSuccess); + var failed = total - ok; + var summary = "Lauf beendet. Gesamt=" + total + ", Erfolgreich=" + ok + ", Fehlgeschlagen=" + failed + "."; + logger.Info(summary); + eventLogger.Summary(summary, failed > 0); + + return failed > 0 && config.Runtime.NonZeroExitCodeOnAnyFailure ? 2 : 0; + } + catch (Exception ex) + { + if (logger != null) + { + logger.Error("Fataler Fehler: " + ex.GetType().Name + ": " + ex.Message); + } + + if (eventLogger != null) + { + eventLogger.Error("Fataler Fehler: " + ex.Message, 9000); + } + + return nonZeroExitCodeOnExecutionError ? 1 : 0; + } + } + + private static async Task> ExecuteChecksAsync(IReadOnlyCollection targets, EndpointProbeService probeService, int maxConcurrency) + { + var results = new List(); + using (var semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency))) + { + var tasks = targets.Select(async target => + { + await semaphore.WaitAsync().ConfigureAwait(false); + try + { + return await probeService.ProbeAsync(target, CancellationToken.None).ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + }).ToArray(); + + var completed = await Task.WhenAll(tasks).ConfigureAwait(false); + results.AddRange(completed); + } + + return results; + } + + private static void UpdateState(Dictionary currentState, ProbeResult result, TargetState previousState) + { + var nowUtc = DateTime.UtcNow; + TargetState state; + + if (previousState == null) + { + state = new TargetState + { + IsUp = result.IsSuccess, + LastCheckedUtc = nowUtc, + LastChangedUtc = nowUtc, + ConsecutiveFailures = result.IsSuccess ? 0 : 1, + LastDetail = result.Detail + }; + } + else + { + state = new TargetState + { + IsUp = previousState.IsUp, + LastCheckedUtc = nowUtc, + LastChangedUtc = previousState.LastChangedUtc, + ConsecutiveFailures = previousState.ConsecutiveFailures, + LastNotificationUtc = previousState.LastNotificationUtc, + LastDetail = result.Detail + }; + } + + if (previousState == null || previousState.IsUp != result.IsSuccess) + { + state.LastChangedUtc = nowUtc; + } + + state.IsUp = result.IsSuccess; + state.LastCheckedUtc = nowUtc; + state.LastDetail = result.Detail; + state.ConsecutiveFailures = result.IsSuccess ? 0 : Math.Max(1, (previousState == null ? 0 : previousState.ConsecutiveFailures) + 1); + + currentState[result.StateKey] = state; + } + + private static string ResolvePath(string baseDirectory, string configuredPath) + { + if (Path.IsPathRooted(configuredPath)) + { + return configuredPath; + } + + return Path.GetFullPath(Path.Combine(baseDirectory, configuredPath)); + } + } +} diff --git a/src/HostAvailabilityMonitor/Properties/AssemblyInfo.cs b/src/HostAvailabilityMonitor/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..a39465c --- /dev/null +++ b/src/HostAvailabilityMonitor/Properties/AssemblyInfo.cs @@ -0,0 +1,15 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("HostAvailabilityMonitor")] +[assembly: AssemblyDescription("Windows Server 2019 kompatibler Host Availability Monitor auf .NET Framework 4.8")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("JR IT Services")] +[assembly: AssemblyProduct("HostAvailabilityMonitor")] +[assembly: AssemblyCopyright("Copyright © JR IT Services")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] +[assembly: ComVisible(false)] +[assembly: Guid("8f55f0bf-6041-4367-93a6-777387f436bf")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/src/HostAvailabilityMonitor/State/MonitorStateStore.cs b/src/HostAvailabilityMonitor/State/MonitorStateStore.cs new file mode 100644 index 0000000..bf7d74b --- /dev/null +++ b/src/HostAvailabilityMonitor/State/MonitorStateStore.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Web.Script.Serialization; + +namespace HostAvailabilityMonitor.State +{ + public sealed class MonitorStateStore + { + private readonly string _filePath; + + public MonitorStateStore(string filePath) + { + _filePath = filePath; + } + + public Dictionary Load() + { + if (!File.Exists(_filePath)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var json = File.ReadAllText(_filePath); + var serializer = new JavaScriptSerializer(); + var states = serializer.Deserialize>(json); + + if (states == null) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return new Dictionary(states, StringComparer.OrdinalIgnoreCase); + } + + public void Save(Dictionary states) + { + var directory = Path.GetDirectoryName(_filePath); + if (string.IsNullOrWhiteSpace(directory)) + { + throw new InvalidOperationException("Das Verzeichnis der Statusdatei konnte nicht ermittelt werden."); + } + + Directory.CreateDirectory(directory); + + var serializer = new JavaScriptSerializer(); + var json = serializer.Serialize(states ?? new Dictionary(StringComparer.OrdinalIgnoreCase)); + var tempFile = _filePath + ".tmp"; + File.WriteAllText(tempFile, json); + + if (File.Exists(_filePath)) + { + var backupFile = _filePath + ".bak"; + File.Replace(tempFile, _filePath, backupFile, true); + if (File.Exists(backupFile)) + { + File.Delete(backupFile); + } + } + else + { + File.Move(tempFile, _filePath); + } + } + } + + public sealed class TargetState + { + public bool IsUp { get; set; } + public int ConsecutiveFailures { get; set; } + public DateTime LastCheckedUtc { get; set; } + public DateTime LastChangedUtc { get; set; } + public DateTime? LastNotificationUtc { get; set; } + public string LastDetail { get; set; } = string.Empty; + } +} diff --git a/src/HostAvailabilityMonitor/appsettings.json b/src/HostAvailabilityMonitor/appsettings.json new file mode 100644 index 0000000..6ca5166 --- /dev/null +++ b/src/HostAvailabilityMonitor/appsettings.json @@ -0,0 +1,70 @@ +{ + "ApplicationName": "HostAvailabilityMonitor", + "Runtime": { + "MaxConcurrency": 4, + "DefaultValidateUncPathAccess": false, + "NonZeroExitCodeOnAnyFailure": false, + "NonZeroExitCodeOnExecutionError": true, + "StateDirectory": "state" + }, + "Logging": { + "LogDirectory": "logs", + "FilePrefix": "host-availability-monitor", + "RetentionDays": 5 + }, + "EventLog": { + "Enabled": true, + "LogName": "Application", + "Source": "HostAvailabilityMonitor", + "CreateSourceIfMissing": false, + "WriteSuccessEntries": false, + "WriteSummaryEntries": true + }, + "Email": { + "Enabled": false, + "SmtpHost": "smtp.example.local", + "SmtpPort": 25, + "UseSsl": false, + "Username": "", + "Password": "", + "From": "monitor@example.local", + "To": [ + "operations@example.local" + ], + "SubjectPrefix": "[HostAvailabilityMonitor]", + "SendOnFailure": true, + "SendOnRecovery": true, + "OnlyOnStateChange": true, + "CooldownMinutes": 0, + "TimeoutSeconds": 30 + }, + "Targets": [ + { + "Name": "Internes Fileshare 1", + "Endpoint": "\\\\internerserver1\\share1", + "TimeoutSeconds": 8, + "ValidateUncPathAccess": false, + "Enabled": true + }, + { + "Name": "Externe Webseite", + "Endpoint": "https://host1.de/url", + "TimeoutSeconds": 10, + "HttpMethod": "HEAD", + "Enabled": true + }, + { + "Name": "SFTP Partner A", + "Endpoint": "sftp://partner-sftp.example.local/inbound", + "TimeoutSeconds": 10, + "Port": 22, + "Enabled": true + }, + { + "Name": "Custom TCP Service", + "Endpoint": "tcp://host2.example.local:8443", + "TimeoutSeconds": 10, + "Enabled": true + } + ] +}