Initial version of the .NET HostAvailabilityMonitor.
This commit is contained in:
@@ -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
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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).
|
||||||
@@ -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"
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user