Initial version of the .NET HostAvailabilityMonitor.

This commit is contained in:
2026-04-16 12:40:53 +02:00
commit c267069d02
19 changed files with 2252 additions and 0 deletions
+15
View File
@@ -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
+32
View File
@@ -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/
+349
View File
@@ -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
+21
View File
@@ -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
+213
View File
@@ -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).
+47
View File
@@ -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"
+16
View File
@@ -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."
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
</configuration>
@@ -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<TargetOptions> Targets { get; set; } = new List<TargetOptions>();
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<MonitorConfiguration>(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<TargetOptions>();
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<string> To { get; set; } = new List<string>();
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(); }
}
}
}
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}</ProjectGuid>
<OutputType>Exe</OutputType>
<RootNamespace>HostAvailabilityMonitor</RootNamespace>
<AssemblyName>HostAvailabilityMonitor</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<LangVersion>7.3</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Configuration" />
<Reference Include="System.Core" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Web.Extensions" />
</ItemGroup>
<ItemGroup>
<Compile Include="Configuration\MonitorConfiguration.cs" />
<Compile Include="Logging\FileLogger.cs" />
<Compile Include="Logging\WindowsEventLogger.cs" />
<Compile Include="Monitoring\EndpointProbeService.cs" />
<Compile Include="Monitoring\ProbeResult.cs" />
<Compile Include="Notifications\EmailNotifier.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="State\MonitorStateStore.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
@@ -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<string>
{
"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", " ");
}
}
}
@@ -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);
}
}
}
@@ -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<ProbeResult> 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<ProbeResult> 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<ProbeResult> 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<ProbeResult> 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<ProbeResult> 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<ProbeResult> 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<ProbeResult> 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<TimedResult<T>> CompleteWithinAsync<T>(Task<T> task, TimeSpan timeout, CancellationToken cancellationToken)
{
var completedTask = await Task.WhenAny(task, Task.Delay(timeout, cancellationToken)).ConfigureAwait(false);
if (completedTask == task)
{
return new TimedResult<T>(true, await task.ConfigureAwait(false));
}
cancellationToken.ThrowIfCancellationRequested();
return new TimedResult<T>(false, default(T));
}
private struct TimedResult<T>
{
public TimedResult(bool completed, T result)
{
Completed = completed;
Result = result;
}
public bool Completed { get; private set; }
public T Result { get; private set; }
}
}
}
@@ -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; }
}
}
}
@@ -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<ProbeResult> failures,
IReadOnlyCollection<ProbeResult> recoveries,
IDictionary<string, TargetState> 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<ProbeResult> failures, IReadOnlyCollection<ProbeResult> recoveries, IDictionary<string, TargetState> 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<string, TargetState> 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");
}
}
}
}
+251
View File
@@ -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<int> 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<string, TargetState> 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<string, TargetState>(StringComparer.OrdinalIgnoreCase);
}
var currentState = new Dictionary<string, TargetState>(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<ProbeResult>();
var recoveriesToNotify = new List<ProbeResult>();
var notifiedKeys = new List<string>();
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<List<ProbeResult>> ExecuteChecksAsync(IReadOnlyCollection<TargetOptions> targets, EndpointProbeService probeService, int maxConcurrency)
{
var results = new List<ProbeResult>();
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<string, TargetState> 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));
}
}
}
@@ -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")]
@@ -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<string, TargetState> Load()
{
if (!File.Exists(_filePath))
{
return new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase);
}
var json = File.ReadAllText(_filePath);
var serializer = new JavaScriptSerializer();
var states = serializer.Deserialize<Dictionary<string, TargetState>>(json);
if (states == null)
{
return new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase);
}
return new Dictionary<string, TargetState>(states, StringComparer.OrdinalIgnoreCase);
}
public void Save(Dictionary<string, TargetState> 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<string, TargetState>(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;
}
}
@@ -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
}
]
}