Updated to .NET Framework 4.7.2 due to platform limitations, added email cc, bcc and some fixes.

This commit is contained in:
2026-04-21 11:46:55 +02:00
parent c267069d02
commit b8b702bda3
40 changed files with 5666 additions and 2064 deletions
+532
View File
@@ -0,0 +1,532 @@
# Documentation
## Overview
This repository is organized in a **Gitea-friendly repository structure** and contains two applications:
### 1. HostAvailabilityMonitor
The monitor checks technical reachability and writes:
- rolling file logs
- Windows Application Event Log
- email notifications for failures, recoveries, and a daily status summary
- a state file for transition detection, cooldown handling, and once-per-day summary tracking
### 2. BizTalkMonitorConfigGenerator
The generator reads BizTalk artifacts and creates a monitor configuration from them.
The goal is to avoid manually maintaining the target list and instead derive it directly from BizTalk.
---
## Repository structure for Gitea
```text
.
├── .editorconfig
├── .gitignore
├── Documentation.en.md
├── Dokumentation.md
├── HostAvailabilityMonitor.sln
├── README.md
├── installers/
│ ├── README.md
│ ├── powershell/
│ │ ├── Install-BizTalkMonitorConfigGenerator.ps1
│ │ ├── Install-Both.ps1
│ │ ├── Install-HostAvailabilityMonitor.ps1
│ │ ├── Uninstall-BizTalkMonitorConfigGenerator.ps1
│ │ └── Uninstall-HostAvailabilityMonitor.ps1
│ └── wix/
│ ├── BizTalkMonitorConfigGenerator.wxs
│ ├── HostAvailabilityMonitor.wxs
│ └── build-msi.ps1
├── scripts/
│ ├── build-release.ps1
│ ├── generate-monitor-config.ps1
│ ├── install-generator-on-server.ps1
│ ├── install-on-server.ps1
│ ├── package-deployment.ps1
│ └── register-event-source.ps1
└── src/
├── BizTalkMonitorConfigGenerator/
│ ├── App.config
│ ├── BizTalkMonitorConfigGenerator.csproj
│ ├── Program.cs
│ └── generator-settings.json
└── HostAvailabilityMonitor/
├── App.config
├── HostAvailabilityMonitor.csproj
├── Program.cs
└── appsettings.json
```
This layout is intentionally simple so it can be committed to **Gitea**, built on Windows, and deployed to BizTalk or application servers with minimal friction.
---
## Supported BizTalk sources
### Send Ports
By default, only **started send ports** are included.
Optionally, the **secondary transport** of a send port can also be included. Secondary transports using the **SMTP** adapter are intentionally excluded from monitor target generation because their BizTalk address usually represents an email recipient list rather than a network endpoint.
### Receive Locations
By default, only **enabled receive locations** are included.
---
## Endpoint normalization
The generator tries to transform BizTalk addresses into monitor-compatible endpoints.
### Directly supported
- `\\server\share`
- `file://...`
- `http://...`
- `https://...`
- `ftp://...`
- `sftp://...`
- `tcp://...`
- `icmp://...`
- plain host / host:port values on a best-effort basis
### Special handling
- `net.tcp://host:port/...` is rewritten to `tcp://host:port/...`
- local file system paths such as `C:\Drop\In` are only included when `IncludeLocalFilePaths=true`
- `net.pipe://...` is skipped because the monitor does not support it
---
## HostAvailabilityMonitor: mail and status behavior
The monitor can send three kinds of emails:
1. **Failure email** when an endpoint changes from available to unavailable
2. **Recovery email** when an endpoint changes from unavailable back to available
3. **Daily status email** once per day at or after a configured local time, default **14:00 local server time**
The daily summary is intentionally **not** implemented as a second Windows task or a separate internal scheduler.
Instead, the behavior is:
- the normal scheduled monitor run starts as usual
- on every run, the monitor checks whether the configured local time has already been reached
- the **first run at or after that time** sends the daily summary email
- only **one** daily summary email is sent per calendar day
- the daily summary is marked as sent **only if SMTP delivery succeeded**
This keeps the solution resilient and simple for BizTalk server operations.
---
## Monitor configuration details
File: `src\HostAvailabilityMonitor\appsettings.json`
### Root fields
- `ApplicationName`: monitor name
- `EnvironmentName`: environment label such as `HIP Production`
- `Runtime`: runtime behavior
- `Logging`: file logging configuration
- `EventLog`: Windows Event Log configuration
- `Email`: SMTP and notification configuration
- `Targets`: endpoints to probe
### Email section
Important fields in `Email`:
- `Enabled`: enable or disable email delivery completely
- `SmtpHost`, `SmtpPort`, `UseSsl`, `UseDefaultCredentials`, `Username`, `Password`
- `From`, `To`, `Cc`, `Bcc`, `SubjectPrefix`
- `SendOnFailure`: send an email on failure
- `SendOnRecovery`: send an email on recovery
- `SendDailySummary`: send a daily status summary
- `DailySummaryHourLocal`: local server hour, for example `14`
- `DailySummaryMinuteLocal`: local server minute, for example `0`
- `OnlyOnStateChange`: for failure/recovery emails, react only to state changes
- `CooldownMinutes`: cooldown for repeated failure emails when `OnlyOnStateChange=false`
- `TimeoutSeconds`: SMTP timeout
Recipient notes:
- `To`, `Cc`, and `Bcc` are JSON arrays of email addresses.
- Configure multiple recipients by adding multiple array entries.
- A single semicolon-separated string is not the supported format.
- At least one recipient across `To`, `Cc`, or `Bcc` must be configured when `Email.Enabled=true`.
### Example email block
```json
"Email": {
"Enabled": true,
"SmtpHost": "smtp.example.local",
"SmtpPort": 25,
"UseSsl": false,
"UseDefaultCredentials": false,
"Username": "",
"Password": "",
"From": "monitor@example.local",
"To": [
"operations@example.local",
"integration-oncall@example.local"
],
"Cc": [
"integration-leads@example.local"
],
"Bcc": [],
"SubjectPrefix": "[HostAvailabilityMonitor]",
"SendOnFailure": true,
"SendOnRecovery": true,
"SendDailySummary": true,
"DailySummaryHourLocal": 14,
"DailySummaryMinuteLocal": 0,
"OnlyOnStateChange": true,
"CooldownMinutes": 0,
"TimeoutSeconds": 30
}
```
### Example for multiple recipients
```json
"Email": {
"Enabled": true,
"From": "monitor@example.local",
"To": [
"operations@example.local",
"integration-oncall@example.local"
],
"Cc": [
"integration-leads@example.local"
],
"Bcc": [
"audit@example.local"
]
}
```
### Daily summary content
The daily status email contains:
- monitor name
- environment
- machine name
- time window from local midnight until send time
- today's successful and failed probe counts based on the current day's rolling log
- a current snapshot of the latest monitor run
- a list of currently unavailable endpoints including:
- application
- name
- endpoint
- kind
- status
- detail
- host/port/HTTP status when available
Note:
The daily counters are derived from the **current day's rolling log file**. If that evaluation fails for any reason, email delivery still continues; the counters fall back to `0` and the incident is logged.
---
## Generator configuration details
File: `generator-settings.json`
### Root fields
- `GeneratorName`: display name for generator logs and event log entries
- `EnvironmentName`: environment label such as `HIP Production`
- `Logging`: generator logging settings
- `EventLog`: generator event log settings
- `BizTalk`: discovery rules
- `Output`: output path and generation rules
- `GeneratedMonitorConfig`: template for the final monitor JSON
### BizTalk section
- `ConnectionString`: connection to BizTalkMgmtDb
- `IncludeSendPorts`: read send ports
- `IncludeReceiveLocations`: read receive locations
- `OnlyStartedSendPorts`: include active send ports only
- `OnlyEnabledReceiveLocations`: include active receive locations only
- `IncludeSecondaryTransportOnSendPorts`: include secondary send transports
- SMTP send ports and SMTP secondary transports are skipped automatically by the generator
- `IncludeDynamicSendPorts`: include dynamic send ports
- `IncludeLocalFilePaths`: include local file system paths
- `IgnoreSystemApplications`: skip system applications
- `IgnoreApplications`: ignore applications by wildcard pattern
- `IgnoreArtifactNamePatterns`: ignore ports/locations by wildcard pattern
- `ApplicationMappings`: map BizTalk application names to business application names
- `ArtifactMappings`: map specific artifacts to business application names
### Output section
- `Path`: target path of the generated monitor JSON
- `OverwriteExisting`: overwrite existing file
- `FailIfNoTargetsFound`: return exit code 2 when no targets were found
- `TargetTimeoutSeconds`: default timeout for each generated target
- `TargetEnabled`: default `Enabled` value for each generated target
- `DefaultHttpMethod`: default HTTP method for generated HTTP/HTTPS targets
### GeneratedMonitorConfig section
This is the template for the monitor runtime configuration.
It contains:
- monitor `ApplicationName`
- `EnvironmentName`
- `Runtime`
- `Logging`
- `EventLog`
- `Email`
`Targets` is rebuilt by the generator at runtime.
Important:
When the generator writes the monitor JSON, the daily summary options are also copied into the generated target file. That means `GeneratedMonitorConfig.Email.SendDailySummary`, `DailySummaryHourLocal`, and `DailySummaryMinuteLocal` flow directly into the output monitor configuration.
---
## How to use the generator
### Option A: run the EXE directly
```powershell
C:\Tools\BizTalkMonitorConfigGenerator\BizTalkMonitorConfigGenerator.exe C:\Tools\BizTalkMonitorConfigGenerator\generator-settings.json
```
### Option B: run the wrapper script
```powershell
.\scripts\generate-monitor-config.ps1 -InstallPath C:\Tools\BizTalkMonitorConfigGenerator
```
### Expected result
After the run, you should have:
- a generated JSON file, for example `C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json`
- a generator log file under `logs\`
- event log entries in the Windows `Application` log
### Run the monitor afterwards
```powershell
C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json
```
---
## Example flow on a BizTalk server
### 1. Install the generator
```powershell
.\scripts\install-generator-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\BizTalkMonitorConfigGenerator `
-InstallPath C:\Tools\BizTalkMonitorConfigGenerator `
-RegisterEventSource
```
### 2. Adjust the generator settings
Edit this file:
```text
C:\Tools\BizTalkMonitorConfigGenerator\generator-settings.json
```
Typical values:
- `EnvironmentName = "HIP Production"`
- `Output.Path = "C:\\Tools\\BizTalkMonitorConfigGenerator\\generated-monitor-appsettings.json"`
- `BizTalk.ConnectionString = "SERVER=.;DATABASE=BizTalkMgmtDb;Integrated Security=SSPI"`
- `GeneratedMonitorConfig.Email.SendDailySummary = true`
- `GeneratedMonitorConfig.Email.DailySummaryHourLocal = 14`
- `GeneratedMonitorConfig.Email.DailySummaryMinuteLocal = 0`
### 3. Run the generator
```powershell
C:\Tools\BizTalkMonitorConfigGenerator\BizTalkMonitorConfigGenerator.exe C:\Tools\BizTalkMonitorConfigGenerator\generator-settings.json
```
### 4. Check the generated JSON
Confirm that the file was created and contains `Targets`.
Also verify:
- the generated JSON contains `SendDailySummary`
- the daily summary time values are correct
### 5. Install the monitor
```powershell
.\scripts\install-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\HostAvailabilityMonitor `
-InstallPath C:\Tools\HostAvailabilityMonitor `
-RegisterEventSource
```
### 6. Start the monitor with the generated JSON
```powershell
C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json
```
### 7. Configure Task Scheduler
Recommended setup:
- generator every 30 minutes
- monitor every 30 minutes, 1 to 2 minutes after the generator
- ensure there is at least one monitor run **after 14:00 local time** so the daily summary can be sent
---
## Generated target names
To make logs and alert emails easy to understand, the generator creates descriptive names such as:
- `SendPort: SAP_Outbound (Primary) [WCF-SQL]`
- `SendPort: PartnerA_SFTP (Secondary) [SFTP]`
- `ReceiveLocation: InboundOrders / RL_FileDrop [FILE]`
The business `ApplicationName` of the generated target is resolved in this order:
1. `ArtifactMappings`
2. `ApplicationMappings`
3. BizTalk application name as fallback
---
## Generator logging and resilience
The generator is designed defensively:
- daily rolling logs
- retries when appending to the log file
- event log is best effort only
- atomic output file writing (`.tmp` + rename)
- existing target file is backed up as `.bak` before overwrite
- unsupported endpoints are logged and skipped
- discovery continues even if individual artifacts are unusable
---
## Monitor logging and resilience
The monitor is also defensive:
- daily rolling logs
- default retention of 5 days
- event log writes are best effort only
- state file is written atomically
- failure/recovery emails follow explicit notification rules
- daily summary is sent only once per day
- the summary is marked as sent only after successful SMTP delivery
- targets that look like SMTP/email recipient lists are handled defensively as `Skipped` and do not raise DOWN alerts
- log parsing or SMTP errors do not crash the monitor run hard
---
## Server installation quick guide
### Create a deployment package
```powershell
.\scripts\build-release.ps1 -CreateDeploymentPackage
```
or separately:
```powershell
.\scripts\package-deployment.ps1
```
The result is a ZIP under `artifacts\deploy\net472\`.
### PowerShell installers (recommended)
Install both applications from the deployment ZIP:
```powershell
.\installers\powershell\Install-Both.ps1 `
-PackageRoot . `
-BaseInstallPath "C:\Program Files\JR IT Services\BizTalk Endpoint Monitor" `
-BaseConfigPath "C:\ProgramData\JR IT Services\BizTalk Endpoint Monitor" `
-RegisterEventSources
```
Install monitor only:
```powershell
.\installers\powershell\Install-HostAvailabilityMonitor.ps1 `
-PackageRoot .
```
Install generator only:
```powershell
.\installers\powershell\Install-BizTalkMonitorConfigGenerator.ps1 `
-PackageRoot .
```
### WiX MSI sources (optional)
WiX source files are included for both applications. On a Windows build machine with WiX Toolset v3 you can generate MSI packages:
```powershell
.\installers\wix\build-msi.ps1 `
-PublishRoot .\artifacts\publish\net472 `
-OutputRoot .\artifacts\msi
```
### Individual installation using the original scripts
#### Monitor
```powershell
.\scripts\install-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\HostAvailabilityMonitor `
-InstallPath C:\Tools\HostAvailabilityMonitor `
-RegisterEventSource
```
#### Generator
```powershell
.\scripts\install-generator-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\BizTalkMonitorConfigGenerator `
-InstallPath C:\Tools\BizTalkMonitorConfigGenerator `
-RegisterEventSource
```
Then typically:
1. adjust `generator-settings.json`
2. run the generator
3. verify the generated monitor JSON
4. run the monitor against that JSON
5. configure Task Scheduler
---
## Build note
In this environment, no real Windows build against .NET Framework 4.7.2 and BizTalk ExplorerOM could be executed. The same applies to real WiX-built MSI binaries because no Windows/WiX toolchain is available here. The code and repository structure were updated, but the first real build and validation must happen on a Windows/BizTalk system.
+485 -302
View File
@@ -1,349 +1,532 @@
# Dokumentation Host Availability Monitor (.NET Framework 4.8)
# Dokumentation
## 1. Zielbild
## Überblick
Die Anwendung überwacht auf einem Windows Server 2019 in festen Intervallen die netzwerktechnische Verfügbarkeit definierter Ziele. Typische Einsatzzwecke:
Dieses Repository ist für den Betrieb in einer **Gitea-Repo-Struktur** vorbereitet und enthält zwei Anwendungen:
- interne Fileshares
- externe HTTPS-Endpunkte
- SFTP-Gegenstellen
- TCP-Dienste
- Hosts per Ping
### 1. HostAvailabilityMonitor
Der Betrieb erfolgt typischerweise über den **Windows Task Scheduler** alle 30 Minuten.
Der Monitor prüft technische Erreichbarkeit und schreibt:
## 2. Unterstützte Zieltypen
- Rolling-Dateilog
- Windows Application Event Log
- E-Mail-Benachrichtigungen bei Ausfall, Recovery und täglichem Status
- Statusdatei für Zustandswechsel, Cooldown-Logik und die tägliche Summary-Mail
### 2.1 UNC / SMB
### 2. BizTalkMonitorConfigGenerator
Beispiel:
Der Generator liest BizTalk-Artefakte aus und erzeugt daraus eine Monitor-Konfiguration.
Ziel ist, dass die Ziel-Liste nicht manuell gepflegt werden muss, sondern direkt aus BizTalk stammt.
---
## Repository-Struktur für Gitea
```text
\\internerserver1\share1
.
├── .editorconfig
├── .gitignore
├── Documentation.en.md
├── Dokumentation.md
├── HostAvailabilityMonitor.sln
├── README.md
├── installers/
│ ├── README.md
│ ├── powershell/
│ │ ├── Install-BizTalkMonitorConfigGenerator.ps1
│ │ ├── Install-Both.ps1
│ │ ├── Install-HostAvailabilityMonitor.ps1
│ │ ├── Uninstall-BizTalkMonitorConfigGenerator.ps1
│ │ └── Uninstall-HostAvailabilityMonitor.ps1
│ └── wix/
│ ├── BizTalkMonitorConfigGenerator.wxs
│ ├── HostAvailabilityMonitor.wxs
│ └── build-msi.ps1
├── scripts/
│ ├── build-release.ps1
│ ├── generate-monitor-config.ps1
│ ├── install-generator-on-server.ps1
│ ├── install-on-server.ps1
│ ├── package-deployment.ps1
│ └── register-event-source.ps1
└── src/
├── BizTalkMonitorConfigGenerator/
│ ├── App.config
│ ├── BizTalkMonitorConfigGenerator.csproj
│ ├── Program.cs
│ └── generator-settings.json
└── HostAvailabilityMonitor/
├── App.config
├── HostAvailabilityMonitor.csproj
├── Program.cs
└── appsettings.json
```
Verhalten:
Diese Struktur ist für ein normales Gitea-Repository ausgelegt. Build und Deployment können aus der Solution oder projektweise erfolgen.
1. TCP-Check auf Port 445 (oder überschriebenen Port)
2. Optional zusätzliche Pfadvalidierung mit `Directory.Exists(...)` / `File.Exists(...)`
---
## Unterstützte BizTalk-Quellen
### Send Ports
Es werden standardmäßig nur **gestartete Send Ports** übernommen.
Zusätzlich kann optional auch der **Secondary Transport** eines Send Ports berücksichtigt werden. Secondary Transports mit dem Adapter **SMTP** werden jedoch bewusst nicht als Monitor-Target übernommen, weil deren Address-Wert in BizTalk typischerweise nur eine Empfängerliste und keine prüfbare Netzwerkadresse darstellt.
### Receive Locations
Es werden standardmäßig nur **aktivierte Receive Locations** übernommen.
---
## Endpunkt-Normalisierung
Der Generator versucht, BizTalk-Adressen in Monitor-kompatible Endpunkte zu überführen.
### Direkt unterstützt
- `\\server\share`
- `file://...`
- `http://...`
- `https://...`
- `ftp://...`
- `sftp://...`
- `tcp://...`
- `icmp://...`
- Plain Host / Host:Port (best effort)
### Spezielle Behandlung
- `net.tcp://host:port/...` wird auf `tcp://host:port/...` umgeschrieben
- lokale Dateipfade wie `C:\Drop\In` werden nur übernommen, wenn `IncludeLocalFilePaths=true`
- `net.pipe://...` wird übersprungen, weil der Monitor diesen Typ nicht prüft
---
## HostAvailabilityMonitor: Mail- und Statusverhalten
Der Monitor kann drei Arten von Mail-Benachrichtigungen versenden:
1. **Failure-Mail**: wenn ein Endpoint von erreichbar auf nicht erreichbar wechselt
2. **Recovery-Mail**: wenn ein Endpoint von nicht erreichbar auf wieder erreichbar wechselt
3. **Tagesstatus-Mail**: einmal pro Tag ab einer konfigurierten lokalen Uhrzeit, standardmäßig **14:00 Uhr**
Die Tagesstatus-Mail wird absichtlich **nicht** über einen zweiten Task oder separaten Scheduler innerhalb der Anwendung realisiert.
Stattdessen gilt:
- der normale geplante Task startet die Anwendung wie bisher
- die Anwendung prüft bei jedem Lauf, ob die konfigurierte Uhrzeit bereits erreicht ist
- die **erste Ausführung ab dieser Uhrzeit** verschickt die Tagesstatus-Mail
- pro Kalendertag wird **maximal eine** Tagesstatus-Mail gesendet
- nur ein **erfolgreich versendeter** Tagesstatus wird im State gespeichert
Das ist robust für den Betrieb auf BizTalk-Servern, weil keine zusätzliche Orchestrierung erforderlich ist.
---
## Konfiguration des Monitors im Detail
Datei: `src\HostAvailabilityMonitor\appsettings.json`
### Root-Felder
- `ApplicationName`: Name des Monitors
- `EnvironmentName`: Umgebungsname wie `HIP Produktion`
- `Runtime`: Laufzeitverhalten
- `Logging`: Dateilog-Konfiguration
- `EventLog`: Windows Event Log Konfiguration
- `Email`: SMTP- und Benachrichtigungskonfiguration
- `Targets`: die zu prüfenden Endpunkte
### Email
Wichtige Felder im Abschnitt `Email`:
- `Enabled`: Mailversand insgesamt ein/aus
- `SmtpHost`, `SmtpPort`, `UseSsl`, `UseDefaultCredentials`, `Username`, `Password`
- `From`, `To`, `Cc`, `Bcc`, `SubjectPrefix`
- `SendOnFailure`: Mail bei Ausfall senden
- `SendOnRecovery`: Mail bei Recovery senden
- `SendDailySummary`: tägliche Status-Mail senden
- `DailySummaryHourLocal`: lokale Stunde des Servers, z. B. `14`
- `DailySummaryMinuteLocal`: lokale Minute des Servers, z. B. `0`
- `OnlyOnStateChange`: bei Failure-/Recovery-Mails nur auf Zustandswechsel reagieren
- `CooldownMinutes`: Cooldown für wiederholte Failure-Mails, wenn `OnlyOnStateChange=false`
- `TimeoutSeconds`: SMTP-Timeout
Hinweise zu Empfaengern:
- `To`, `Cc` und `Bcc` sind jeweils JSON-Arrays von Mailadressen.
- Mehrere Empfaenger werden ueber mehrere Array-Eintraege konfiguriert.
- Eine einzelne Zeichenkette mit Semikolon-Liste ist nicht vorgesehen.
- Mindestens ein Empfaenger in `To`, `Cc` oder `Bcc` muss vorhanden sein, wenn `Email.Enabled=true`.
### Beispiel für den Email-Block
```json
"Email": {
"Enabled": true,
"SmtpHost": "smtp.example.local",
"SmtpPort": 25,
"UseSsl": false,
"UseDefaultCredentials": false,
"Username": "",
"Password": "",
"From": "monitor@example.local",
"To": [
"operations@example.local",
"integration-oncall@example.local"
],
"Cc": [
"integration-leads@example.local"
],
"Bcc": [],
"SubjectPrefix": "[HostAvailabilityMonitor]",
"SendOnFailure": true,
"SendOnRecovery": true,
"SendDailySummary": true,
"DailySummaryHourLocal": 14,
"DailySummaryMinuteLocal": 0,
"OnlyOnStateChange": true,
"CooldownMinutes": 0,
"TimeoutSeconds": 30
}
```
### Beispiel fuer mehrere Empfaenger
```json
"Email": {
"Enabled": true,
"From": "monitor@example.local",
"To": [
"operations@example.local",
"integration-oncall@example.local"
],
"Cc": [
"integration-leads@example.local"
],
"Bcc": [
"audit@example.local"
]
}
```
### Inhalt der Tagesstatus-Mail
Die Tagesstatus-Mail enthält:
- Monitorname
- Umgebung
- Servername
- Zeitfenster von Mitternacht bis Versandzeitpunkt
- Anzahl heutiger erfolgreicher und fehlgeschlagener Checks laut Tageslog
- aktueller Snapshot des letzten Monitor-Laufs
- Liste aktuell DOWN befindlicher Endpunkte inklusive:
- Anwendung
- Name
- Endpoint
- Typ
- Status
- Detail
- Host/Port/HTTP-Status, soweit vorhanden
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.
Die Tageszähler werden aus dem **heutigen Rolling-Log** ausgewertet. Sollte diese Auswertung ausnahmsweise fehlschlagen, bleibt die Mail-Funktion funktionsfähig; die Tageszähler werden dann defensiv mit `0` angegeben und der Vorfall wird geloggt.
### 2.2 HTTP / HTTPS
---
Beispiele:
## Generator-Konfiguration im Detail
```text
https://host1.de/url
http://intranet.local/health
```
Datei: `generator-settings.json`
Verhalten:
### Root-Felder
- 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**
- `GeneratorName`: Anzeigename für Logs/Eventlog des Generators
- `EnvironmentName`: Umgebung wie `HIP Produktion`
- `Logging`: Logging des Generators
- `EventLog`: Eventlog des Generators
- `BizTalk`: Discovery-Regeln
- `Output`: Zielpfad und Erzeugungsregeln
- `GeneratedMonitorConfig`: Vorlage für die endgültige Monitor-JSON
### 2.3 SFTP
### BizTalk
Beispiel:
- `ConnectionString`: Verbindung zur BizTalkMgmtDb
- `IncludeSendPorts`: Send Ports auslesen
- `IncludeReceiveLocations`: Receive Locations auslesen
- `OnlyStartedSendPorts`: nur aktive Send Ports
- `OnlyEnabledReceiveLocations`: nur aktive Receive Locations
- `IncludeSecondaryTransportOnSendPorts`: sekundäre Send-Port-Transporte zusätzlich aufnehmen
- SMTP-Sendports bzw. SMTP-Secondary-Transports werden vom Generator automatisch übersprungen
- `IncludeDynamicSendPorts`: dynamische Send Ports aufnehmen
- `IncludeLocalFilePaths`: lokale Pfade aufnehmen
- `IgnoreSystemApplications`: Systemanwendungen ignorieren
- `IgnoreApplications`: Anwendungen per Pattern ausblenden
- `IgnoreArtifactNamePatterns`: Ports/Locations per Pattern ignorieren
- `ApplicationMappings`: Mapping BizTalk-App -> fachliche App
- `ArtifactMappings`: Mapping einzelner Artefakte -> fachliche App
```text
sftp://partner.example.local/inbound
```
### Output
Verhalten:
- `Path`: Pfad der erzeugten Monitor-JSON
- `OverwriteExisting`: bestehende Datei überschreiben
- `FailIfNoTargetsFound`: ExitCode 2, wenn keine Targets gefunden wurden
- `TargetTimeoutSeconds`: Standard-Timeout pro generiertem Target
- `TargetEnabled`: `Enabled`-Default in jedem Target
- `DefaultHttpMethod`: Standard für generierte HTTP/HTTPS-Ziele
- TCP-Port-Prüfung, standardmäßig Port 22
- kein Login, keine Dateioperation
### GeneratedMonitorConfig
### 2.4 TCP
Dies ist die Vorlage für den späteren Monitor.
Beispiel:
Hier werden gepflegt:
```text
tcp://host2.example.local:8443
```
- `ApplicationName` des Monitors
- `EnvironmentName`
- `Runtime`
- `Logging`
- `EventLog`
- `Email`
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
`Targets` wird vom Generator zur Laufzeit neu erzeugt.
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.
Wenn der Generator die Monitor-JSON erzeugt, werden auch die Felder für die Tagesstatus-Mail mit erzeugt. Das heißt: `GeneratedMonitorConfig.Email.SendDailySummary`, `DailySummaryHourLocal` und `DailySummaryMinuteLocal` werden direkt in die Zielkonfiguration übernommen.
## 6. Benachrichtigungslogik
---
Die Anwendung speichert je Ziel einen Status in `state\monitor-state.json`.
## Generator verwenden
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
### Variante A: direkt per EXE
```powershell
msbuild .\HostAvailabilityMonitor.sln /p:Configuration=Release /p:Platform="Any CPU"
C:\Tools\BizTalkMonitorConfigGenerator\BizTalkMonitorConfigGenerator.exe C:\Tools\BizTalkMonitorConfigGenerator\generator-settings.json
```
### 10.3 Artefakte
### Variante B: per Wrapper-Skript
Relevant für Deployment:
```powershell
.\scripts\generate-monitor-config.ps1 -InstallPath C:\Tools\BizTalkMonitorConfigGenerator
```
- `HostAvailabilityMonitor.exe`
- `HostAvailabilityMonitor.exe.config`
- `appsettings.json`
- weitere referenzierte .NET-Assemblies aus `bin\Release`
### Erwartetes Ergebnis
## 11. Sicherheits- und Resilienz-Aspekte
Nach dem Lauf sollte vorhanden sein:
Die Anwendung ist bewusst defensiv aufgebaut:
- generierte Datei, z. B. `C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json`
- Logdatei des Generators unter `logs\`
- Eventlog-Einträge im `Application`-Log
- 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
### Monitor danach starten
## 12. Empfohlene nächste Ausbaustufen
```powershell
C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json
```
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
## Beispielablauf auf einem BizTalk-Server
### 1. Generator installieren
```powershell
.\scripts\install-generator-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\BizTalkMonitorConfigGenerator `
-InstallPath C:\Tools\BizTalkMonitorConfigGenerator `
-RegisterEventSource
```
### 2. Generator-Konfiguration anpassen
Datei bearbeiten:
```text
C:\Tools\BizTalkMonitorConfigGenerator\generator-settings.json
```
Typische Werte:
- `EnvironmentName = "HIP Produktion"`
- `Output.Path = "C:\\Tools\\BizTalkMonitorConfigGenerator\\generated-monitor-appsettings.json"`
- `BizTalk.ConnectionString = "SERVER=.;DATABASE=BizTalkMgmtDb;Integrated Security=SSPI"`
- `GeneratedMonitorConfig.Email.SendDailySummary = true`
- `GeneratedMonitorConfig.Email.DailySummaryHourLocal = 14`
- `GeneratedMonitorConfig.Email.DailySummaryMinuteLocal = 0`
### 3. Generator ausführen
```powershell
C:\Tools\BizTalkMonitorConfigGenerator\BizTalkMonitorConfigGenerator.exe C:\Tools\BizTalkMonitorConfigGenerator\generator-settings.json
```
### 4. Generierte JSON prüfen
Prüfen, ob die Datei erzeugt wurde und `Targets` enthält.
Zusätzlich prüfen:
- ob `GeneratedMonitorConfig.Email.SendDailySummary` in der erzeugten JSON übernommen wurde
- ob die Uhrzeitfelder korrekt gesetzt sind
### 5. Monitor installieren
```powershell
.\scripts\install-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\HostAvailabilityMonitor `
-InstallPath C:\Tools\HostAvailabilityMonitor `
-RegisterEventSource
```
### 6. Monitor mit der generierten JSON starten
```powershell
C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json
```
### 7. Task Scheduler konfigurieren
Empfehlung:
- Generator z. B. alle 30 Minuten
- Monitor z. B. 1 bis 2 Minuten später ebenfalls alle 30 Minuten
- wichtig ist mindestens ein Monitor-Lauf **nach 14:00 Uhr lokal**, damit die Tagesstatus-Mail gesendet werden kann
---
## Erzeugte Target-Namen
Damit im Log und in der Mail eindeutig erkennbar ist, woher ein Ziel stammt, werden sprechende Namen erzeugt, z. B.:
- `SendPort: SAP_Outbound (Primary) [WCF-SQL]`
- `SendPort: PartnerA_SFTP (Secondary) [SFTP]`
- `ReceiveLocation: InboundOrders / RL_FileDrop [FILE]`
Das fachliche `ApplicationName` des Targets kommt aus:
1. `ArtifactMappings`
2. `ApplicationMappings`
3. BizTalk-Anwendungsname (Fallback)
---
## Logging und Resilienz des Generators
Der Generator ist defensiv gebaut:
- tägliche Logrotation
- Retry beim Schreiben ins Logfile
- Eventlog nur best effort
- atomisches Schreiben der Ausgabedatei (`.tmp` + Rename)
- bestehende Zieldatei wird vor Überschreiben als `.bak` gesichert
- nicht unterstützte Endpunkte werden protokolliert und übersprungen
- Discovery läuft möglichst weiter, auch wenn einzelne Artefakte unbrauchbar sind
---
## Logging und Resilienz des Monitors
Der Monitor ist ebenfalls defensiv gebaut:
- Rolling-Dateilog mit täglicher Rotation
- Retention standardmäßig 5 Tage
- Eventlog-Schreiben nur best effort
- Statusdatei mit atomischem Schreiben
- Failure-/Recovery-Mails nur bei definierten Bedingungen
- Tagesstatus nur einmal pro Tag
- Tagesstatus wird nur nach erfolgreichem SMTP-Versand als versendet markiert
- Ziele, die wie SMTP-/E-Mail-Empfängerlisten aussehen, werden vom Monitor defensiv als `Skipped` protokolliert und nicht als DOWN alarmiert
- Fehler bei der Tageslog-Auswertung oder beim SMTP-Versand stoppen den Monitorlauf nicht hart
---
## Server-Installation kompakt
### Deployment-Paket erzeugen
```powershell
.\scripts\build-release.ps1 -CreateDeploymentPackage
```
oder getrennt:
```powershell
.\scripts\package-deployment.ps1
```
Das Ergebnis liegt dann unter `artifacts\deploy\net472\` als ZIP.
### PowerShell-Installer (empfohlen)
Komplette Installation beider Programme aus dem Deployment-ZIP:
```powershell
.\installers\powershell\Install-Both.ps1 `
-PackageRoot . `
-BaseInstallPath "C:\Program Files\JR IT Services\BizTalk Endpoint Monitor" `
-BaseConfigPath "C:\ProgramData\JR IT Services\BizTalk Endpoint Monitor" `
-RegisterEventSources
```
Nur Monitor:
```powershell
.\installers\powershell\Install-HostAvailabilityMonitor.ps1 `
-PackageRoot .
```
Nur Generator:
```powershell
.\installers\powershell\Install-BizTalkMonitorConfigGenerator.ps1 `
-PackageRoot .
```
### WiX-MSI-Quellen (optional)
Fuer beide Programme liegen WiX-Quellen bereit. Auf einem Windows-Buildserver mit WiX Toolset v3 kannst du daraus MSI-Dateien erzeugen:
```powershell
.\installers\wix\build-msi.ps1 `
-PublishRoot .\artifacts\publish\net472 `
-OutputRoot .\artifacts\msi
```
### Einzelinstallation ueber die bisherigen Skripte
#### Monitor
```powershell
.\scripts\install-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\HostAvailabilityMonitor `
-InstallPath C:\Tools\HostAvailabilityMonitor `
-RegisterEventSource
```
#### Generator
```powershell
.\scripts\install-generator-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\BizTalkMonitorConfigGenerator `
-InstallPath C:\Tools\BizTalkMonitorConfigGenerator `
-RegisterEventSource
```
Danach typischerweise:
1. `generator-settings.json` anpassen
2. Generator ausführen
3. generierte Monitor-JSON prüfen
4. Monitor mit dieser JSON starten
5. Task Scheduler einrichten
---
## Hinweis zum Build
In dieser Arbeitsumgebung konnte kein echter Windows-Build gegen .NET Framework 4.7.2 und BizTalk ExplorerOM ausgefuehrt werden. Ebenso konnte hier kein echtes WiX-MSI gebaut werden, weil keine Windows-/WiX-Toolchain verfuegbar war. Der Code und die Repo-Struktur sind angepasst, der erste echte Build und Test muss auf einem Windows-/BizTalk-System erfolgen.
+6
View File
@@ -4,6 +4,8 @@ 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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkMonitorConfigGenerator", "src\BizTalkMonitorConfigGenerator\BizTalkMonitorConfigGenerator.csproj", "{E8C93E45-DA2B-4E30-8891-637A2C7FAE0B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -14,6 +16,10 @@ Global
{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
{E8C93E45-DA2B-4E30-8891-637A2C7FAE0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8C93E45-DA2B-4E30-8891-637A2C7FAE0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8C93E45-DA2B-4E30-8891-637A2C7FAE0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8C93E45-DA2B-4E30-8891-637A2C7FAE0B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+334 -147
View File
@@ -1,70 +1,116 @@
# Host Availability Monitor (.NET Framework 4.8)
# Host Availability Monitor + BizTalk Monitor Config Generator (.NET Framework 4.7.2)
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.
Dieses Repository ist für eine **Gitea-kompatible Repo-Struktur** aufgebaut und enthält zwei Konsolenanwendungen für **Windows Server 2019 / BizTalk Server 2019** auf **.NET Framework 4.7.2**:
Die Anwendung ist für den Einsatz mit dem **Task Scheduler** gedacht und schreibt:
1. **HostAvailabilityMonitor**
- prüft Endpunkte wie UNC/SMB, `file://`, HTTP/HTTPS, SFTP, FTP, TCP und ICMP
- schreibt Rolling-Logs, Windows Application Event Log und E-Mails bei DOWN, Recovery und täglichem Status
- 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
2. **BizTalkMonitorConfigGenerator**
- liest aktive **BizTalk Send Ports** und aktivierte **Receive Locations** aus
- erzeugt daraus automatisch die JSON-Konfiguration, die der `HostAvailabilityMonitor` direkt verwenden kann
- übernimmt Umgebung, Default-Werte und fachliche Anwendungszuordnung
## Warum .NET Framework 4.8?
## Zielbild
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.
Der typische Betrieb auf einer BizTalk-Maschine ist:
## Features
1. Der **Generator** läuft mit Admin-Rechten auf dem jeweiligen BizTalk-Server.
2. Er liest die aktiven BizTalk-Artefakte aus.
3. Er erzeugt eine vollständige Monitor-Konfiguration im JSON-Format.
4. Der **Monitor** läuft anschließend mit genau dieser JSON-Datei.
5. Bei DOWN und bei `DOWN -> UP` gehen Mails raus.
6. Zusätzlich geht **einmal pro Tag** eine **Status-Mail** mit Tageszusammenfassung raus, standardmäßig um **14:00 Uhr lokal**.
- **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
## Gitea-kompatible Repository-Struktur
```text
host-availability-monitor-net48/
.
├── .editorconfig
├── .gitignore
├── Documentation.en.md
├── Dokumentation.md
├── HostAvailabilityMonitor.sln
├── README.md
├── Dokumentation.md
├── .gitignore
├── .editorconfig
├── installers/
│ ├── README.md
│ ├── powershell/
│ │ ├── Install-BizTalkMonitorConfigGenerator.ps1
│ │ ├── Install-Both.ps1
│ │ ├── Install-HostAvailabilityMonitor.ps1
│ │ ├── Uninstall-BizTalkMonitorConfigGenerator.ps1
│ │ └── Uninstall-HostAvailabilityMonitor.ps1
│ └── wix/
│ ├── BizTalkMonitorConfigGenerator.wxs
│ ├── HostAvailabilityMonitor.wxs
│ └── build-msi.ps1
├── scripts/
│ ├── build-release.ps1
│ ├── generate-monitor-config.ps1
│ ├── install-generator-on-server.ps1
│ ├── install-on-server.ps1
│ ├── package-deployment.ps1
│ └── register-event-source.ps1
└── src/
├── BizTalkMonitorConfigGenerator/
│ ├── App.config
│ ├── BizTalkMonitorConfigGenerator.csproj
│ ├── Program.cs
│ └── generator-settings.json
└── HostAvailabilityMonitor/
├── App.config
├── appsettings.json
├── HostAvailabilityMonitor.csproj
├── Program.cs
── Configuration/
├── Logging/
├── Monitoring/
├── Notifications/
├── Properties/
└── State/
── appsettings.json
```
Diese Struktur ist bewusst einfach gehalten, damit das Repository sauber in **Gitea** eingecheckt, gebaut und auf Windows-Servern ausgerollt werden kann.
## Lösung / Solution
- `src/HostAvailabilityMonitor/`
- `src/BizTalkMonitorConfigGenerator/`
- `scripts/build-release.ps1`
- `scripts/install-on-server.ps1`
- `scripts/install-generator-on-server.ps1`
- `scripts/generate-monitor-config.ps1`
- `scripts/package-deployment.ps1`
- `installers/powershell/*`
- `installers/wix/*`
## Technische Eckpunkte
### HostAvailabilityMonitor
- Task-Scheduler-tauglich
- tägliche Logrotation
- Log-Retention standardmäßig 5 Tage
- Windows Application Event Log
- SMTP-Mails bei DOWN, Recovery (`DOWN -> UP`) und täglichem Status
- SMTP-Sendports bzw. reine E-Mail-Empfaengerlisten werden vom Generator uebersprungen und vom Monitor defensiv nur als `Skipped` protokolliert, nicht alarmiert
- tägliche Status-Mail auf Basis des **ersten Laufs ab der konfigurierten Uhrzeit**
- State-Datei gegen Mail-Spam und für die Einmal-pro-Tag-Logik der Status-Mail
- `ApplicationName` pro Target
- `EnvironmentName` global
### BizTalkMonitorConfigGenerator
- liest BizTalk über **Microsoft.BizTalk.ExplorerOM**
- berücksichtigt standardmäßig nur:
- **gestartete Send Ports**
- **aktivierte Receive Locations**
- unterstützt optional:
- Secondary Transport von Send Ports
- lokale Pfade
- Mapping von BizTalk-Anwendungsnamen auf fachliche `ApplicationName`-Werte
- schreibt Rolling-Log und Event Log
- schreibt die Ziel-JSON **atomar** über Temp-Datei + Rename
## Build
### Visual Studio
### Voraussetzung
- Solution `HostAvailabilityMonitor.sln` öffnen
- Configuration `Release`
- Build starten
Für den Generator muss auf dem Build- oder Zielsystem die BizTalk-Assembly **`Microsoft.BizTalk.ExplorerOM.dll`** verfügbar sein, typischerweise durch installierten BizTalk Server bzw. BizTalk Developer/Admin Components.
### MSBuild
@@ -72,142 +118,283 @@ host-availability-monitor-net48/
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:
### Build-Skript
```powershell
.\scripts\register-event-source.ps1 -Source "HostAvailabilityMonitor" -LogName "Application"
.\scripts\build-release.ps1
```
5. Task Scheduler anlegen, z. B. alle 30 Minuten
Das Skript erstellt anschließend typischerweise:
## Konfiguration
- `artifacts\publish\net472\HostAvailabilityMonitor\`
- `artifacts\publish\net472\BizTalkMonitorConfigGenerator\`
Die Anwendung nutzt **`appsettings.json`** als Anwendungs-Konfiguration.
Optional direkt mit Deployment-Paket:
Wichtig für .NET Framework 4.8 in dieser Umsetzung:
```powershell
.\scripts\build-release.ps1 -CreateDeploymentPackage
```
- JSON muss **strict valid** sein
- keine Kommentare
- keine trailing commas
Oder separat:
### Beispiel
```powershell
.\scripts\package-deployment.ps1
```
Danach liegt ein verteilerfertiges ZIP unter `artifacts\deploy\net472\`.
## Installer / Setup-Optionen
Es gibt jetzt **zwei Installer-Wege**:
### 1. PowerShell-Installer (empfohlen fuer BizTalk/Windows Server)
Diese Variante ist in restriktiven Serverumgebungen meist am praktikabelsten.
Wichtige Dateien:
- `installers\powershell\Install-HostAvailabilityMonitor.ps1`
- `installers\powershell\Install-BizTalkMonitorConfigGenerator.ps1`
- `installers\powershell\Install-Both.ps1`
- `installers\powershell\Uninstall-HostAvailabilityMonitor.ps1`
- `installers\powershell\Uninstall-BizTalkMonitorConfigGenerator.ps1`
Beispiel fuer ein gemeinsames Setup aus dem Deployment-ZIP:
```powershell
.\installers\powershell\Install-Both.ps1 `
-PackageRoot . `
-BaseInstallPath "C:\Program Files\JR IT Services\BizTalk Endpoint Monitor" `
-BaseConfigPath "C:\ProgramData\JR IT Services\BizTalk Endpoint Monitor" `
-RegisterEventSources
```
### 2. WiX-MSI-Quellen (optional)
Fuer beide Programme liegen **WiX-Quellen** bei:
- `installers\wix\HostAvailabilityMonitor.wxs`
- `installers\wix\BizTalkMonitorConfigGenerator.wxs`
- `installers\wix\build-msi.ps1`
Damit kannst du auf einem Windows-Buildsystem mit WiX Toolset v3 echte MSI-Pakete erzeugen.
Beispiel:
```powershell
.\installers\wix\build-msi.ps1 `
-PublishRoot .\artifacts\publish\net472 `
-OutputRoot .\artifacts\msi
```
Hinweis: In diesem Arbeitsraum konnte ich **keine MSI-Binaries real bauen**, weil hier keine Windows-/WiX-Toolchain bereitsteht. Ich habe dir daher **robuste PowerShell-Installer** und **MSI-Quellen fuer den Windows-Build** bereitgestellt.
## Installation auf dem Server
### Monitor
```powershell
.\scripts\install-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\HostAvailabilityMonitor `
-InstallPath C:\Tools\HostAvailabilityMonitor `
-RegisterEventSource
```
### Generator
```powershell
.\scripts\install-generator-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\BizTalkMonitorConfigGenerator `
-InstallPath C:\Tools\BizTalkMonitorConfigGenerator `
-RegisterEventSource
```
## Monitor-Konfiguration für tägliche Status-Mail
In `appsettings.json` bzw. in `GeneratedMonitorConfig.Email` des Generators stehen jetzt zusätzlich diese Felder:
```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
}
"Email": {
"Enabled": true,
"To": [ "operations@example.local", "integration-oncall@example.local" ],
"Cc": [ "integration-leads@example.local" ],
"Bcc": [],
"SendOnFailure": true,
"SendOnRecovery": true,
"SendDailySummary": true,
"DailySummaryHourLocal": 14,
"DailySummaryMinuteLocal": 0
}
```
Bedeutung:
- `SendDailySummary`: schaltet die tägliche Status-Mail ein oder aus
- `DailySummaryHourLocal`: lokale Stunde auf dem Server, z. B. `14`
- `DailySummaryMinuteLocal`: lokale Minute auf dem Server, z. B. `0`
Empfaengerfelder:
- `To`: Hauptempfaenger als JSON-Liste
- `Cc`: optionale CC-Empfaenger als JSON-Liste
- `Bcc`: optionale BCC-Empfaenger als JSON-Liste
- Es muss mindestens ein Empfaenger in `To`, `Cc` oder `Bcc` vorhanden sein.
- Mehrere Empfaenger werden **nicht** per Semikolon in einem String konfiguriert, sondern als mehrere Array-Eintraege.
Wichtig für den Betrieb:
- Die Mail wird **nicht durch einen separaten zweiten Job** verschickt.
- Sie wird vom normalen Monitor-Lauf versendet.
- Das heißt: Der **erste erfolgreiche Programmlauf ab 14:00 Uhr lokal** verschickt die Tageszusammenfassung.
- Läuft der Task z. B. alle 30 Minuten, dann geht die Status-Mail je nach Startzeit um `14:00`, `14:30`, `15:00` usw. raus — aber **nur einmal pro Tag**.
Inhalt der Tagesstatus-Mail:
- Monitorname, Umgebung, Servername
- Zeitfenster von Mitternacht bis Versandzeitpunkt
- Anzahl aller heutigen Checks
- Anzahl heutiger erfolgreicher und fehlgeschlagener Checks
- aktueller Snapshot des letzten Laufs
- Liste der aktuell DOWN befindlichen Endpunkte mit Anwendung und Umgebung
## Mehrere Mail-Empfaenger konfigurieren
Beispiel:
```json
"Email": {
"Enabled": true,
"From": "monitor@example.local",
"To": [
"operations@example.local",
"integration-oncall@example.local"
],
"Cc": [
"integration-leads@example.local"
],
"Bcc": [
"audit@example.local"
]
}
```
## Task Scheduler
Wichtig:
**Program/script**
- pro Adresse ein eigener Eintrag im Array
- keine Semikolon-getrennte Empfaengerliste in einem einzelnen String
- `Cc` und `Bcc` sind optional
## Generator verwenden
### 1. Generator-Einstellungen prüfen oder anpassen
Datei:
```text
C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe
src\BizTalkMonitorConfigGenerator\generator-settings.json
```
**Start in**
Wichtige Felder:
- `EnvironmentName`: z. B. `HIP Produktion`
- `BizTalk.ConnectionString`: meist `SERVER=.;DATABASE=BizTalkMgmtDb;Integrated Security=SSPI`
- `BizTalk.OnlyStartedSendPorts`: nur gestartete Send Ports übernehmen
- `BizTalk.OnlyEnabledReceiveLocations`: nur aktivierte Receive Locations übernehmen
- `BizTalk.IncludeSecondaryTransportOnSendPorts`: Secondary Transport mit aufnehmen
- Hinweis: Secondary Transports mit Adapter `SMTP` werden bewusst nicht als Monitor-Targets erzeugt
- `BizTalk.IncludeLocalFilePaths`: lokale Pfade zulassen oder verwerfen
- `BizTalk.ApplicationMappings`: Mapping von BizTalk-Anwendungsnamen auf fachliche App-Namen
- `Output.Path`: Pfad der zu erzeugenden Monitor-JSON
- `GeneratedMonitorConfig`: Vorlage für Logging, Runtime, Event Log und Mail des Monitors
- `GeneratedMonitorConfig.Email.SendDailySummary`: tägliche Status-Mail in der generierten Monitor-JSON einschalten
- `GeneratedMonitorConfig.Email.DailySummaryHourLocal`: lokale Uhrzeit der Tageszusammenfassung
- `GeneratedMonitorConfig.Email.DailySummaryMinuteLocal`: lokale Minute der Tageszusammenfassung
### 2. Generator direkt starten
```powershell
C:\Tools\BizTalkMonitorConfigGenerator\BizTalkMonitorConfigGenerator.exe C:\Tools\BizTalkMonitorConfigGenerator\generator-settings.json
```
### 3. Generator per Hilfsskript starten
```powershell
.\scripts\generate-monitor-config.ps1 -InstallPath C:\Tools\BizTalkMonitorConfigGenerator
```
### 4. Ergebnis prüfen
Danach sollte eine generierte Monitor-Datei vorhanden sein, z. B.:
```text
C:\Tools\HostAvailabilityMonitor
C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json
```
**Optional arguments**
Zusätzlich prüfen:
```text
C:\Tools\HostAvailabilityMonitor\appsettings.json
- Generator-Logdatei unter `logs\`
- Einträge im Windows Application Event Log
- Inhalt der generierten JSON
### 5. Monitor mit der erzeugten Datei starten
```powershell
C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json
```
## Logging
## Praxisbeispiel für den Betrieb auf der BizTalk-Maschine
- Dateilog: `logs\host-availability-monitor-yyyy-MM-dd.log`
- Retention: Standard 5 Tage
- Event Log: `Application`
- State-Datei: `state\monitor-state.json`
### Task 1: Generator
## Benachrichtigung
- Programm: `C:\Tools\BizTalkMonitorConfigGenerator\BizTalkMonitorConfigGenerator.exe`
- Argumente: `C:\Tools\BizTalkMonitorConfigGenerator\generator-settings.json`
- Starten in: `C:\Tools\BizTalkMonitorConfigGenerator`
- Konto: BizTalk-Admin / technisches Administratorkonto
- Intervall: z. B. alle 30 Minuten oder vor dem Monitor-Lauf
Benachrichtigung wird nur als erfolgreich markiert, wenn der SMTP-Versand wirklich erfolgreich war. Dadurch bleibt die Cooldown-/State-Change-Logik konsistent.
### Task 2: Monitor
- Programm: `C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe`
- Argumente: `C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json`
- Starten in: `C:\Tools\HostAvailabilityMonitor`
- Konto: Dienstkonto mit technischem Zugriff auf Shares, HTTP(S), FTP/SFTP und andere Zielsysteme
- Intervall: kurz nach dem Generator, z. B. 1 bis 2 Minuten später
Empfehlung für die Status-Mail um 14:00:
- den Monitor mindestens alle 15 oder 30 Minuten laufen lassen
- sicherstellen, dass in dieser Zeit auch **mindestens ein Lauf nach 14:00 Uhr** stattfindet
- kein separater Status-Task notwendig
## Beispiel für ein Generator-Mapping
```json
"ApplicationMappings": [
{
"BizTalkApplicationName": "HIP*",
"ApplicationName": "HIP"
},
{
"BizTalkApplicationName": "PartnerA*",
"ApplicationName": "HIP Partneranbindung"
}
]
```
## 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.
- Fuer .NET Framework 4.7.2 ist der Build-Output jetzt unter `artifacts\publish\net472\` vorgesehen.
## Weiterführend
- Nicht unterstützte Endpunkte wie `net.pipe://` werden sauber übersprungen und protokolliert.
- Für `net.tcp://host:port/...` wird eine Monitor-kompatible `tcp://...`-Adresse erzeugt.
- Für lokale Dateipfade ist standardmäßig `IncludeLocalFilePaths=false` gesetzt.
- Die erzeugte Monitor-JSON enthält `EnvironmentName` global und `ApplicationName` pro Target.
- Der Generator versendet selbst keine E-Mails, sondern erzeugt nur die Konfiguration.
- Die Event-Source-Erstellung benötigt Administratorrechte.
- Die Tagesstatus-Mail wird nur dann als „versendet“ gespeichert, wenn der SMTP-Versand erfolgreich war.
Details zu Logging, Exit Codes, Fehlerfällen und Betriebsmodell stehen in [Dokumentation.md](Dokumentation.md).
## Dokumentation
- Deutsch: [Dokumentation.md](Dokumentation.md)
- English: [Documentation.en.md](Documentation.en.md)
+37
View File
@@ -0,0 +1,37 @@
# Installers
This repository contains two installer approaches for both console applications.
## 1. PowerShell installers
Recommended for most BizTalk / Windows Server environments.
Files:
- `powershell/Install-HostAvailabilityMonitor.ps1`
- `powershell/Install-BizTalkMonitorConfigGenerator.ps1`
- `powershell/Install-Both.ps1`
- `powershell/Uninstall-HostAvailabilityMonitor.ps1`
- `powershell/Uninstall-BizTalkMonitorConfigGenerator.ps1`
These scripts install the binaries, create required directories, copy a default configuration when missing, and can optionally register event log sources.
## 2. WiX MSI sources
Optional MSI source files are provided in `wix/`.
Files:
- `wix/HostAvailabilityMonitor.wxs`
- `wix/BizTalkMonitorConfigGenerator.wxs`
- `wix/build-msi.ps1`
Requirements:
- Windows build machine
- WiX Toolset v3 in PATH (`candle.exe`, `light.exe`)
- publish output already present in `artifacts\publish\net472`
Note: the WiX files are intentionally minimal starter installers and may be extended later with service/task registration, shortcuts, custom actions, or richer upgrade rules.
## Email configuration
The deployed monitor configuration supports `To`, `Cc`, and `Bcc` arrays for email recipients. Configure one address per array element.
@@ -0,0 +1,34 @@
param(
[string]$PackageRoot = (Split-Path -Parent $PSScriptRoot),
[string]$InstallPath = "C:\Program Files\JR IT Services\BizTalkMonitorConfigGenerator",
[string]$ConfigRoot = "C:\ProgramData\JR IT Services\BizTalkMonitorConfigGenerator",
[switch]$RegisterEventSource,
[switch]$CreateBackup
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repoScripts = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'scripts'
$generatorSource = Join-Path $PackageRoot 'generator'
if (-not (Test-Path -LiteralPath $generatorSource)) {
$generatorSource = Join-Path (Split-Path -Parent $PackageRoot) 'generator'
}
if (-not (Test-Path -LiteralPath $generatorSource)) {
throw "Generator source folder not found below package root: $PackageRoot"
}
& (Join-Path $repoScripts 'install-generator-on-server.ps1') `
-SourcePath $generatorSource `
-InstallPath $InstallPath `
-RegisterEventSource:$RegisterEventSource `
-CreateBackup:$CreateBackup
New-Item -Path $ConfigRoot -ItemType Directory -Force | Out-Null
$configFile = Join-Path $ConfigRoot 'generator-settings.json'
$defaultConfig = Join-Path $InstallPath 'generator-settings.json'
if ((Test-Path -LiteralPath $defaultConfig) -and (-not (Test-Path -LiteralPath $configFile))) {
Copy-Item -Path $defaultConfig -Destination $configFile -Force
}
Write-Host "[INFO] BizTalkMonitorConfigGenerator installed to $InstallPath"
Write-Host "[INFO] Config location: $configFile"
+33
View File
@@ -0,0 +1,33 @@
param(
[string]$PackageRoot = (Split-Path -Parent $PSScriptRoot),
[string]$BaseInstallPath = "C:\Program Files\JR IT Services\BizTalk Endpoint Monitor",
[string]$BaseConfigPath = "C:\ProgramData\JR IT Services\BizTalk Endpoint Monitor",
[switch]$RegisterEventSources,
[switch]$CreateBackup
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$hostScript = Join-Path $PSScriptRoot 'Install-HostAvailabilityMonitor.ps1'
$genScript = Join-Path $PSScriptRoot 'Install-BizTalkMonitorConfigGenerator.ps1'
& $genScript `
-PackageRoot $PackageRoot `
-InstallPath (Join-Path $BaseInstallPath 'BizTalkMonitorConfigGenerator') `
-ConfigRoot (Join-Path $BaseConfigPath 'BizTalkMonitorConfigGenerator') `
-RegisterEventSource:$RegisterEventSources `
-CreateBackup:$CreateBackup
& $hostScript `
-PackageRoot $PackageRoot `
-InstallPath (Join-Path $BaseInstallPath 'HostAvailabilityMonitor') `
-ConfigRoot (Join-Path $BaseConfigPath 'HostAvailabilityMonitor') `
-RegisterEventSource:$RegisterEventSources `
-CreateBackup:$CreateBackup
Write-Host "[INFO] Both applications have been installed."
Write-Host "[INFO] Suggested next steps:"
Write-Host "[INFO] 1. Adjust generator settings in ProgramData."
Write-Host "[INFO] 2. Run generator once to create generated-monitor-appsettings.json."
Write-Host "[INFO] 3. Start HostAvailabilityMonitor with the generated JSON path."
@@ -0,0 +1,34 @@
param(
[string]$PackageRoot = (Split-Path -Parent $PSScriptRoot),
[string]$InstallPath = "C:\Program Files\JR IT Services\HostAvailabilityMonitor",
[string]$ConfigRoot = "C:\ProgramData\JR IT Services\HostAvailabilityMonitor",
[switch]$RegisterEventSource,
[switch]$CreateBackup
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repoScripts = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'scripts'
$monitorSource = Join-Path $PackageRoot 'monitor'
if (-not (Test-Path -LiteralPath $monitorSource)) {
$monitorSource = Join-Path (Split-Path -Parent $PackageRoot) 'monitor'
}
if (-not (Test-Path -LiteralPath $monitorSource)) {
throw "Monitor source folder not found below package root: $PackageRoot"
}
& (Join-Path $repoScripts 'install-on-server.ps1') `
-SourcePath $monitorSource `
-InstallPath $InstallPath `
-RegisterEventSource:$RegisterEventSource `
-CreateBackup:$CreateBackup
New-Item -Path $ConfigRoot -ItemType Directory -Force | Out-Null
$configFile = Join-Path $ConfigRoot 'appsettings.json'
$defaultConfig = Join-Path $InstallPath 'appsettings.json'
if ((Test-Path -LiteralPath $defaultConfig) -and (-not (Test-Path -LiteralPath $configFile))) {
Copy-Item -Path $defaultConfig -Destination $configFile -Force
}
Write-Host "[INFO] HostAvailabilityMonitor installed to $InstallPath"
Write-Host "[INFO] Config location: $configFile"
@@ -0,0 +1,18 @@
param(
[string]$InstallPath = "C:\Program Files\JR IT Services\BizTalkMonitorConfigGenerator",
[string]$ConfigRoot = "C:\ProgramData\JR IT Services\BizTalkMonitorConfigGenerator",
[switch]$RemoveConfig
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (Test-Path -LiteralPath $InstallPath) {
Remove-Item -LiteralPath $InstallPath -Recurse -Force
Write-Host "[INFO] Removed $InstallPath"
}
if ($RemoveConfig -and (Test-Path -LiteralPath $ConfigRoot)) {
Remove-Item -LiteralPath $ConfigRoot -Recurse -Force
Write-Host "[INFO] Removed $ConfigRoot"
}
@@ -0,0 +1,18 @@
param(
[string]$InstallPath = "C:\Program Files\JR IT Services\HostAvailabilityMonitor",
[string]$ConfigRoot = "C:\ProgramData\JR IT Services\HostAvailabilityMonitor",
[switch]$RemoveConfig
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (Test-Path -LiteralPath $InstallPath) {
Remove-Item -LiteralPath $InstallPath -Recurse -Force
Write-Host "[INFO] Removed $InstallPath"
}
if ($RemoveConfig -and (Test-Path -LiteralPath $ConfigRoot)) {
Remove-Item -LiteralPath $ConfigRoot -Recurse -Force
Write-Host "[INFO] Removed $ConfigRoot"
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*" Name="BizTalkMonitorConfigGenerator" Language="1033" Version="1.0.0.0" Manufacturer="JR IT Services" UpgradeCode="6E45A4E8-C0C3-4267-8873-B2CF8173E9B2">
<Package InstallerVersion="500" Compressed="yes" InstallScope="perMachine" />
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed." />
<MediaTemplate EmbedCab="yes" />
<PropertyRef Id="WIX_IS_NETFRAMEWORK_472_OR_LATER_INSTALLED" />
<Condition Message=".NET Framework 4.7.2 or later is required."><![CDATA[Installed OR WIX_IS_NETFRAMEWORK_472_OR_LATER_INSTALLED]]></Condition>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFilesFolder">
<Directory Id="JRITSERVICES" Name="JR IT Services">
<Directory Id="INSTALLDIR" Name="BizTalkMonitorConfigGenerator" />
</Directory>
</Directory>
</Directory>
<Feature Id="MainFeature" Title="BizTalkMonitorConfigGenerator" Level="1">
<ComponentGroupRef Id="GeneratorHarvest" />
</Feature>
</Product>
</Wix>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*" Name="HostAvailabilityMonitor" Language="1033" Version="1.0.0.0" Manufacturer="JR IT Services" UpgradeCode="A4FC4B18-EA15-4F83-8B19-06D79AA2F2A8">
<Package InstallerVersion="500" Compressed="yes" InstallScope="perMachine" />
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed." />
<MediaTemplate EmbedCab="yes" />
<PropertyRef Id="WIX_IS_NETFRAMEWORK_472_OR_LATER_INSTALLED" />
<Condition Message=".NET Framework 4.7.2 or later is required."><![CDATA[Installed OR WIX_IS_NETFRAMEWORK_472_OR_LATER_INSTALLED]]></Condition>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFilesFolder">
<Directory Id="JRITSERVICES" Name="JR IT Services">
<Directory Id="INSTALLDIR" Name="HostAvailabilityMonitor" />
</Directory>
</Directory>
</Directory>
<Feature Id="MainFeature" Title="HostAvailabilityMonitor" Level="1">
<ComponentGroupRef Id="MonitorHarvest" />
</Feature>
</Product>
</Wix>
+47
View File
@@ -0,0 +1,47 @@
param(
[string]$PublishRoot = ".\artifacts\publish\net472",
[string]$OutputRoot = ".\artifacts\msi"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-WixTool {
param([string]$Name)
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
throw "$Name was not found. Install WiX Toolset v3 and ensure heat.exe, candle.exe and light.exe are in PATH."
}
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$publishRootFull = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $PublishRoot))
$outputRootFull = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutputRoot))
New-Item -Path $outputRootFull -ItemType Directory -Force | Out-Null
$heat = Get-WixTool -Name 'heat.exe'
$candle = Get-WixTool -Name 'candle.exe'
$light = Get-WixTool -Name 'light.exe'
$packages = @(
@{ Name = 'HostAvailabilityMonitor'; ProductFile = 'HostAvailabilityMonitor.wxs'; HarvestFile = 'Harvest_HostAvailabilityMonitor.wxs'; PublishDir = 'HostAvailabilityMonitor'; ComponentGroup = 'MonitorHarvest' },
@{ Name = 'BizTalkMonitorConfigGenerator'; ProductFile = 'BizTalkMonitorConfigGenerator.wxs'; HarvestFile = 'Harvest_BizTalkMonitorConfigGenerator.wxs'; PublishDir = 'BizTalkMonitorConfigGenerator'; ComponentGroup = 'GeneratorHarvest' }
)
foreach ($package in $packages) {
$sourceDir = Join-Path $publishRootFull $package.PublishDir
if (-not (Test-Path -LiteralPath $sourceDir)) {
throw "Publish directory not found: $sourceDir"
}
$harvestPath = Join-Path $outputRootFull $package.HarvestFile
$productPath = Join-Path $PSScriptRoot $package.ProductFile
$productObj = Join-Path $outputRootFull ($package.Name + '.wixobj')
$harvestObj = Join-Path $outputRootFull ($package.Name + '.harvest.wixobj')
$msiPath = Join-Path $outputRootFull ($package.Name + '.msi')
& $heat dir $sourceDir -cg $package.ComponentGroup -dr INSTALLDIR -gg -scom -sreg -sfrag -var var.SourceDir -out $harvestPath
& $candle -ext WixNetFxExtension -dSourceDir=$sourceDir -out $productObj $productPath
& $candle -ext WixNetFxExtension -dSourceDir=$sourceDir -out $harvestObj $harvestPath
& $light -ext WixNetFxExtension -out $msiPath $productObj $harvestObj
Write-Host "Built MSI: $msiPath"
}
+22 -9
View File
@@ -1,5 +1,6 @@
param(
[string]$Configuration = "Release"
[string]$Configuration = "Release",
[switch]$CreateDeploymentPackage
)
$ErrorActionPreference = "Stop"
@@ -33,15 +34,27 @@ 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"
$publishRoot = Join-Path $repoRoot "artifacts\publish\net472"
if (Test-Path $publishRoot) {
Remove-Item -Path $publishRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $publishRoot | Out-Null
if (Test-Path $publishDir) {
Remove-Item -Path $publishDir -Recurse -Force
$projects = @(
@{ Name = "HostAvailabilityMonitor"; ProjectDir = Join-Path $repoRoot "src\HostAvailabilityMonitor" },
@{ Name = "BizTalkMonitorConfigGenerator"; ProjectDir = Join-Path $repoRoot "src\BizTalkMonitorConfigGenerator" }
)
foreach ($project in $projects) {
$outputDir = Join-Path $project.ProjectDir "bin\$Configuration"
$targetDir = Join-Path $publishRoot $project.Name
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
Copy-Item -Path (Join-Path $outputDir "*") -Destination $targetDir -Recurse -Force
Write-Host "Publish-Verzeichnis für $($project.Name): $targetDir"
}
New-Item -ItemType Directory -Path $publishDir | Out-Null
Copy-Item -Path (Join-Path $outputDir "*") -Destination $publishDir -Recurse -Force
Write-Host "Build abgeschlossen. Publish-Root: $publishRoot"
Write-Host "Build abgeschlossen. Publish-Verzeichnis: $publishDir"
if ($CreateDeploymentPackage) {
& (Join-Path $scriptDir 'package-deployment.ps1') -PublishRoot '.\artifacts\publish\net472'
}
+25
View File
@@ -0,0 +1,25 @@
param(
[string]$InstallPath = 'C:\Tools\BizTalkMonitorConfigGenerator',
[string]$ConfigPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not $ConfigPath) {
$ConfigPath = Join-Path $InstallPath 'generator-settings.json'
}
$exePath = Join-Path $InstallPath 'BizTalkMonitorConfigGenerator.exe'
if (-not (Test-Path -LiteralPath $exePath)) {
throw "Executable wurde nicht gefunden: $exePath"
}
if (-not (Test-Path -LiteralPath $ConfigPath)) {
throw "Konfigurationsdatei wurde nicht gefunden: $ConfigPath"
}
& $exePath $ConfigPath
$exitCode = $LASTEXITCODE
Write-Host "ExitCode: $exitCode"
exit $exitCode
+58
View File
@@ -0,0 +1,58 @@
param(
[Parameter(Mandatory = $true)]
[string]$SourcePath,
[Parameter(Mandatory = $true)]
[string]$InstallPath,
[string]$EventSource = "BizTalkMonitorConfigGenerator",
[string]$EventLogName = "Application",
[switch]$RegisterEventSource,
[switch]$CreateBackup
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message"
}
if (-not (Test-Path -LiteralPath $SourcePath)) {
throw "SourcePath '$SourcePath' existiert nicht."
}
$sourceFullPath = (Resolve-Path -LiteralPath $SourcePath).Path
$installFullPath = [System.IO.Path]::GetFullPath($InstallPath)
Write-Info "Quelldateien: $sourceFullPath"
Write-Info "Zielverzeichnis: $installFullPath"
if (Test-Path -LiteralPath $installFullPath) {
if ($CreateBackup) {
$backupRoot = Join-Path -Path ([System.IO.Path]::GetDirectoryName($installFullPath)) -ChildPath ((Split-Path -Path $installFullPath -Leaf) + '_backup_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))
Write-Info "Erstelle Backup unter $backupRoot"
Copy-Item -Path (Join-Path $installFullPath '*') -Destination $backupRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
else {
New-Item -Path $installFullPath -ItemType Directory | Out-Null
}
Write-Info "Kopiere Dateien..."
Copy-Item -Path (Join-Path $sourceFullPath '*') -Destination $installFullPath -Recurse -Force
New-Item -Path (Join-Path $installFullPath 'logs') -ItemType Directory -Force | Out-Null
if ($RegisterEventSource) {
$registerScript = Join-Path -Path $PSScriptRoot -ChildPath 'register-event-source.ps1'
if (-not (Test-Path -LiteralPath $registerScript)) {
throw "register-event-source.ps1 wurde nicht gefunden: $registerScript"
}
Write-Info "Registriere Event Source '$EventSource' im Log '$EventLogName'"
& $registerScript -Source $EventSource -LogName $EventLogName
}
Write-Info 'Installation des BizTalkMonitorConfigGenerator abgeschlossen.'
Write-Info 'Prüfen Sie nun generator-settings.json und führen Sie einen Testlauf aus.'
+60
View File
@@ -0,0 +1,60 @@
param(
[Parameter(Mandatory = $true)]
[string]$SourcePath,
[Parameter(Mandatory = $true)]
[string]$InstallPath,
[string]$EventSource = "HostAvailabilityMonitor",
[string]$EventLogName = "Application",
[switch]$RegisterEventSource,
[switch]$CreateBackup
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message"
}
if (-not (Test-Path -LiteralPath $SourcePath)) {
throw "SourcePath '$SourcePath' existiert nicht."
}
$sourceFullPath = (Resolve-Path -LiteralPath $SourcePath).Path
$installFullPath = [System.IO.Path]::GetFullPath($InstallPath)
Write-Info "Quelldateien: $sourceFullPath"
Write-Info "Zielverzeichnis: $installFullPath"
if (Test-Path -LiteralPath $installFullPath) {
if ($CreateBackup) {
$backupRoot = Join-Path -Path ([System.IO.Path]::GetDirectoryName($installFullPath)) -ChildPath ((Split-Path -Path $installFullPath -Leaf) + '_backup_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))
Write-Info "Erstelle Backup unter $backupRoot"
Copy-Item -Path (Join-Path $installFullPath '*') -Destination $backupRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
else {
New-Item -Path $installFullPath -ItemType Directory | Out-Null
}
Write-Info "Kopiere Dateien..."
Copy-Item -Path (Join-Path $sourceFullPath '*') -Destination $installFullPath -Recurse -Force
New-Item -Path (Join-Path $installFullPath 'logs') -ItemType Directory -Force | Out-Null
New-Item -Path (Join-Path $installFullPath 'state') -ItemType Directory -Force | Out-Null
if ($RegisterEventSource) {
$registerScript = Join-Path -Path $PSScriptRoot -ChildPath 'register-event-source.ps1'
if (-not (Test-Path -LiteralPath $registerScript)) {
throw "register-event-source.ps1 wurde nicht gefunden: $registerScript"
}
Write-Info "Registriere Event Source '$EventSource' im Log '$EventLogName'"
& $registerScript -Source $EventSource -LogName $EventLogName
}
Write-Info 'Installation abgeschlossen.'
Write-Info "Prüfen Sie nun appsettings.json, Task Scheduler und Servicekonto-Berechtigungen."
+73
View File
@@ -0,0 +1,73 @@
param(
[string]$PublishRoot = ".\artifacts\publish\net472",
[string]$OutputRoot = ".\artifacts\deploy\net472",
[string]$PackageName = "biztalk-endpoint-monitor-net472-deployment"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Ensure-Path {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Pfad nicht gefunden: $Path"
}
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$publishRootFull = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $PublishRoot))
$outputRootFull = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutputRoot))
$stagingRoot = Join-Path $outputRootFull $PackageName
$zipPath = Join-Path $outputRootFull ($PackageName + '.zip')
Ensure-Path -Path $publishRootFull
Ensure-Path -Path (Join-Path $publishRootFull 'HostAvailabilityMonitor')
Ensure-Path -Path (Join-Path $publishRootFull 'BizTalkMonitorConfigGenerator')
if (Test-Path -LiteralPath $stagingRoot) {
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
}
if (Test-Path -LiteralPath $zipPath) {
Remove-Item -LiteralPath $zipPath -Force
}
New-Item -Path $stagingRoot -ItemType Directory -Force | Out-Null
New-Item -Path (Join-Path $stagingRoot 'monitor') -ItemType Directory -Force | Out-Null
New-Item -Path (Join-Path $stagingRoot 'generator') -ItemType Directory -Force | Out-Null
New-Item -Path (Join-Path $stagingRoot 'scripts') -ItemType Directory -Force | Out-Null
New-Item -Path (Join-Path $stagingRoot 'installers') -ItemType Directory -Force | Out-Null
New-Item -Path (Join-Path $stagingRoot 'docs') -ItemType Directory -Force | Out-Null
Copy-Item -Path (Join-Path $publishRootFull 'HostAvailabilityMonitor\*') -Destination (Join-Path $stagingRoot 'monitor') -Recurse -Force
Copy-Item -Path (Join-Path $publishRootFull 'BizTalkMonitorConfigGenerator\*') -Destination (Join-Path $stagingRoot 'generator') -Recurse -Force
Copy-Item -Path (Join-Path $repoRoot 'scripts\*') -Destination (Join-Path $stagingRoot 'scripts') -Recurse -Force
Copy-Item -Path (Join-Path $repoRoot 'installers\*') -Destination (Join-Path $stagingRoot 'installers') -Recurse -Force
Copy-Item -Path (Join-Path $repoRoot 'README.md') -Destination (Join-Path $stagingRoot 'docs\README.md') -Force
Copy-Item -Path (Join-Path $repoRoot 'Dokumentation.md') -Destination (Join-Path $stagingRoot 'docs\Dokumentation.md') -Force
Copy-Item -Path (Join-Path $repoRoot 'Documentation.en.md') -Destination (Join-Path $stagingRoot 'docs\Documentation.en.md') -Force
$deployNotes = @"
Deployment package: $PackageName
Created: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
Typical installation order:
1. Install generator
2. Adjust generator-settings.json
3. Generate monitor JSON
4. Install monitor
5. Configure Task Scheduler
PowerShell installers:
- installers\powershell\Install-HostAvailabilityMonitor.ps1
- installers\powershell\Install-BizTalkMonitorConfigGenerator.ps1
- installers\powershell\Install-Both.ps1
Optional MSI sources:
- installers\wix\HostAvailabilityMonitor.wxs
- installers\wix\BizTalkMonitorConfigGenerator.wxs
- installers\wix\build-msi.ps1
"@
$deployNotes | Set-Content -Path (Join-Path $stagingRoot 'deploy-notes.txt') -Encoding UTF8
Compress-Archive -Path (Join-Path $stagingRoot '*') -DestinationPath $zipPath -CompressionLevel Optimal
Write-Host "Deployment package created: $zipPath"
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup useLegacyV2RuntimeActivationPolicy="true">
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
@@ -0,0 +1,517 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using BizTalkMonitorConfigGenerator.Configuration;
using BizTalkMonitorConfigGenerator.Logging;
using Microsoft.BizTalk.ExplorerOM;
namespace BizTalkMonitorConfigGenerator.BizTalk
{
public sealed class BizTalkCatalogDiscoveryService
{
private readonly BizTalkOptions _options;
private readonly OutputOptions _outputOptions;
private readonly GeneratorFileLogger _logger;
public BizTalkCatalogDiscoveryService(BizTalkOptions options, OutputOptions outputOptions, GeneratorFileLogger logger)
{
_options = options ?? new BizTalkOptions();
_outputOptions = outputOptions ?? new OutputOptions();
_logger = logger;
}
public IReadOnlyCollection<DiscoveredEndpoint> Discover()
{
var discovered = new List<DiscoveredEndpoint>();
var catalog = new BtsCatalogExplorer();
catalog.ConnectionString = _options.ConnectionString;
foreach (Application application in catalog.Applications)
{
if (application == null)
{
continue;
}
if (ShouldSkipApplication(application))
{
_logger.Info("BizTalk-Anwendung übersprungen: " + application.Name);
continue;
}
if (_options.IncludeSendPorts)
{
DiscoverSendPorts(application, discovered);
}
if (_options.IncludeReceiveLocations)
{
DiscoverReceiveLocations(application, discovered);
}
}
return discovered
.OrderBy(x => x.MappedApplicationName, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.TargetName, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.NormalizedEndpoint, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private void DiscoverSendPorts(Application application, List<DiscoveredEndpoint> discovered)
{
foreach (SendPort sendPort in application.SendPorts)
{
if (sendPort == null)
{
continue;
}
if (_options.OnlyStartedSendPorts && sendPort.Status != PortStatus.Started)
{
_logger.Discovery("SKIP", "SendPort", application.Name, sendPort.Name, ResolveAdapterName(sendPort.PrimaryTransport), SafeAddress(sendPort.PrimaryTransport), string.Empty, "SendPort ist nicht gestartet. Status=" + sendPort.Status);
continue;
}
if (sendPort.IsDynamic && !_options.IncludeDynamicSendPorts)
{
_logger.Discovery("SKIP", "SendPort", application.Name, sendPort.Name, ResolveAdapterName(sendPort.PrimaryTransport), SafeAddress(sendPort.PrimaryTransport), string.Empty, "Dynamischer SendPort wird per Konfiguration übersprungen.");
continue;
}
AddTransportIfSupported(discovered, application.Name, "SendPort", sendPort.Name, sendPort.Name, sendPort.PrimaryTransport, false);
if (_options.IncludeSecondaryTransportOnSendPorts)
{
AddTransportIfSupported(discovered, application.Name, "SendPortSecondary", sendPort.Name + " (Secondary Transport)", sendPort.Name, sendPort.SecondaryTransport, true);
}
}
}
private void DiscoverReceiveLocations(Application application, List<DiscoveredEndpoint> discovered)
{
foreach (ReceivePort receivePort in application.ReceivePorts)
{
if (receivePort == null)
{
continue;
}
foreach (ReceiveLocation receiveLocation in receivePort.ReceiveLocations)
{
if (receiveLocation == null)
{
continue;
}
if (_options.OnlyEnabledReceiveLocations && !receiveLocation.Enable)
{
_logger.Discovery("SKIP", "ReceiveLocation", application.Name, receiveLocation.Name, ResolveAdapterName(receiveLocation.TransportType), receiveLocation.Address, string.Empty, "Receive Location ist deaktiviert.");
continue;
}
if (ShouldSkipArtifact(receiveLocation.Name))
{
_logger.Discovery("SKIP", "ReceiveLocation", application.Name, receiveLocation.Name, ResolveAdapterName(receiveLocation.TransportType), receiveLocation.Address, string.Empty, "Receive Location entspricht einem Ignore-Pattern.");
continue;
}
AddEndpointIfSupported(
discovered,
application.Name,
"ReceiveLocation",
receivePort.Name + " / " + receiveLocation.Name,
receiveLocation.Name,
ResolveAdapterName(receiveLocation.TransportType),
receiveLocation.Address,
false);
}
}
}
private void AddTransportIfSupported(List<DiscoveredEndpoint> discovered, string bizTalkApplicationName, string artifactType, string targetNameBase, string artifactName, TransportInfo transport, bool isSecondary)
{
if (transport == null)
{
_logger.Discovery("SKIP", artifactType, bizTalkApplicationName, artifactName, string.Empty, string.Empty, string.Empty, "Kein Transport konfiguriert.");
return;
}
AddEndpointIfSupported(
discovered,
bizTalkApplicationName,
artifactType,
targetNameBase + (isSecondary ? " (Secondary)" : " (Primary)"),
artifactName,
ResolveAdapterName(transport),
SafeAddress(transport),
isSecondary);
}
private void AddEndpointIfSupported(List<DiscoveredEndpoint> discovered, string bizTalkApplicationName, string artifactType, string targetNameBase, string artifactName, string adapterName, string sourceAddress, bool isSecondary)
{
if (ShouldSkipArtifact(artifactName))
{
_logger.Discovery("SKIP", artifactType, bizTalkApplicationName, artifactName, adapterName, sourceAddress, string.Empty, "Artefakt entspricht einem Ignore-Pattern.");
return;
}
string normalizedEndpoint;
int? port;
string httpMethod;
string reason;
if (!TryNormalizeEndpoint(sourceAddress, adapterName, out normalizedEndpoint, out port, out httpMethod, out reason))
{
_logger.Discovery("SKIP", artifactType, bizTalkApplicationName, artifactName, adapterName, sourceAddress, string.Empty, reason);
return;
}
var mappedApplication = ResolveMappedApplicationName(bizTalkApplicationName, artifactType, artifactName);
var targetName = BuildTargetName(artifactType, targetNameBase, adapterName, isSecondary);
var endpoint = new DiscoveredEndpoint
{
ArtifactType = artifactType,
BizTalkApplicationName = bizTalkApplicationName,
MappedApplicationName = mappedApplication,
ArtifactName = artifactName,
AdapterName = adapterName,
SourceAddress = sourceAddress,
NormalizedEndpoint = normalizedEndpoint,
TargetName = targetName,
HttpMethod = httpMethod,
Port = port,
ValidateUncPathAccess = null,
Detail = "Endpoint erfolgreich aus BizTalk übernommen."
};
discovered.Add(endpoint);
_logger.Discovery("ADD", artifactType, bizTalkApplicationName, artifactName, adapterName, sourceAddress, normalizedEndpoint, "Zuordnung zu ApplicationName='" + mappedApplication + "'.");
}
private bool ShouldSkipApplication(Application application)
{
if (application == null)
{
return true;
}
if (_options.IgnoreSystemApplications && application.IsSystem)
{
return true;
}
return _options.IgnoreApplications.Any(pattern => WildcardMatch(application.Name, pattern));
}
private bool ShouldSkipArtifact(string artifactName)
{
if (string.IsNullOrWhiteSpace(artifactName))
{
return false;
}
return _options.IgnoreArtifactNamePatterns.Any(pattern => WildcardMatch(artifactName, pattern));
}
private string ResolveMappedApplicationName(string bizTalkApplicationName, string artifactType, string artifactName)
{
foreach (var rule in _options.ArtifactMappings.Where(r => r != null && !string.IsNullOrWhiteSpace(r.ApplicationName)))
{
if (!string.IsNullOrWhiteSpace(rule.ArtifactType) && !string.Equals(rule.ArtifactType.Trim(), artifactType, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (WildcardMatch(artifactName, rule.ArtifactNamePattern))
{
return rule.ApplicationName.Trim();
}
}
foreach (var rule in _options.ApplicationMappings.Where(r => r != null && !string.IsNullOrWhiteSpace(r.ApplicationName)))
{
if (WildcardMatch(bizTalkApplicationName, rule.BizTalkApplicationName))
{
return rule.ApplicationName.Trim();
}
}
return string.IsNullOrWhiteSpace(bizTalkApplicationName) ? "Unassigned Application" : bizTalkApplicationName.Trim();
}
private string BuildTargetName(string artifactType, string baseName, string adapterName, bool isSecondary)
{
var prefix = string.Equals(artifactType, "ReceiveLocation", StringComparison.OrdinalIgnoreCase)
? "ReceiveLocation"
: "SendPort";
var name = prefix + ": " + baseName;
if (!string.IsNullOrWhiteSpace(adapterName))
{
name += " [" + adapterName + "]";
}
if (isSecondary && name.IndexOf("Secondary", StringComparison.OrdinalIgnoreCase) < 0)
{
name += " [Secondary]";
}
return name;
}
private bool TryNormalizeEndpoint(string sourceAddress, string adapterName, out string normalizedEndpoint, out int? port, out string httpMethod, out string reason)
{
normalizedEndpoint = string.Empty;
port = null;
httpMethod = null;
reason = string.Empty;
var address = (sourceAddress ?? string.Empty).Trim();
var adapter = (adapterName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(address))
{
reason = "Address/URI ist leer.";
return false;
}
if (AdapterContains(adapter, "SMTP"))
{
reason = "SMTP-Adapter wird uebersprungen, weil dessen Address-Wert keine pruefbare Netzwerkadresse, sondern eine Empfaengerliste ist.";
return false;
}
if (LooksLikeEmailAddressList(address))
{
reason = "Adresse sieht wie eine SMTP-/E-Mail-Empfaengerliste aus und wird uebersprungen.";
return false;
}
if (address.StartsWith("\\\\", StringComparison.Ordinal))
{
normalizedEndpoint = address;
return true;
}
if (Path.IsPathRooted(address))
{
if (!_options.IncludeLocalFilePaths)
{
reason = "Lokaler Dateipfad wird per Konfiguration nicht übernommen.";
return false;
}
normalizedEndpoint = address;
return true;
}
Uri uri;
if (Uri.TryCreate(address, UriKind.Absolute, out uri))
{
switch ((uri.Scheme ?? string.Empty).ToLowerInvariant())
{
case "http":
case "https":
normalizedEndpoint = uri.AbsoluteUri;
httpMethod = _outputOptions.DefaultHttpMethod;
return true;
case "ftp":
case "sftp":
case "tcp":
case "icmp":
case "file":
normalizedEndpoint = uri.IsAbsoluteUri ? uri.AbsoluteUri : address;
return true;
case "net.tcp":
if (string.IsNullOrWhiteSpace(uri.Host) || uri.Port <= 0)
{
reason = "net.tcp-Adresse konnte nicht auf Host/Port aufgelöst werden.";
return false;
}
normalizedEndpoint = "tcp://" + uri.Host + ":" + uri.Port + uri.PathAndQuery;
port = uri.Port;
return true;
case "net.pipe":
case "npipe":
reason = "Named-Pipe-Endpunkte werden vom Monitor nicht unterstützt.";
return false;
default:
reason = "Schema '" + uri.Scheme + "' wird vom Monitor nicht unterstützt.";
return false;
}
}
if (LooksLikeHostPort(address))
{
if (AdapterContains(adapter, "SFTP"))
{
normalizedEndpoint = "sftp://" + address;
port = ParsePort(address);
return true;
}
if (AdapterContains(adapter, "FTP"))
{
normalizedEndpoint = "ftp://" + address;
port = ParsePort(address);
return true;
}
normalizedEndpoint = "tcp://" + address;
port = ParsePort(address);
return true;
}
if (AdapterContains(adapter, "SFTP"))
{
normalizedEndpoint = "sftp://" + address.TrimStart('/');
return true;
}
if (AdapterContains(adapter, "FTP"))
{
normalizedEndpoint = "ftp://" + address.TrimStart('/');
return true;
}
if (AdapterContains(adapter, "HTTP") || AdapterContains(adapter, "SOAP") || AdapterContains(adapter, "WCF"))
{
reason = "HTTP/WCF-Adapteradresse konnte nicht als absolute URI erkannt werden.";
return false;
}
normalizedEndpoint = address;
return true;
}
private static string ResolveAdapterName(TransportInfo transport)
{
try
{
return transport == null || transport.TransportType == null ? string.Empty : transport.TransportType.Name;
}
catch
{
return string.Empty;
}
}
private static string ResolveAdapterName(ProtocolType transportType)
{
try
{
return transportType == null ? string.Empty : transportType.Name;
}
catch
{
return string.Empty;
}
}
private static string SafeAddress(TransportInfo transport)
{
try
{
return transport == null ? string.Empty : transport.Address;
}
catch
{
return string.Empty;
}
}
private static bool AdapterContains(string adapterName, string fragment)
{
return !string.IsNullOrWhiteSpace(adapterName)
&& adapterName.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool LooksLikeEmailAddressList(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
if (trimmed.IndexOf('@') < 0)
{
return false;
}
if (trimmed.IndexOf("http://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("https://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("sftp://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("ftp://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("tcp://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("file://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.StartsWith("\\", StringComparison.Ordinal))
{
return false;
}
var entries = trimmed.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
if (entries.Length == 0)
{
return false;
}
foreach (var entry in entries)
{
var part = entry.Trim();
if (string.IsNullOrWhiteSpace(part))
{
continue;
}
if (part.IndexOf('@') <= 0 || part.IndexOf(' ') >= 0)
{
return false;
}
}
return true;
}
private static bool WildcardMatch(string input, string pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return false;
}
input = input ?? string.Empty;
var regex = "^" + Regex.Escape(pattern.Trim()).Replace("\\*", ".*").Replace("\\?", ".") + "$";
return Regex.IsMatch(input, regex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
private static bool LooksLikeHostPort(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
return Regex.IsMatch(value.Trim(), @"^[a-zA-Z0-9._-]+:\d{1,5}($|/.*$)");
}
private static int? ParsePort(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var match = Regex.Match(value, @":(?<port>\d{1,5})(?:$|/)");
int parsed;
if (match.Success && int.TryParse(match.Groups["port"].Value, out parsed))
{
return parsed;
}
return null;
}
}
}
@@ -0,0 +1,18 @@
namespace BizTalkMonitorConfigGenerator.BizTalk
{
public sealed class DiscoveredEndpoint
{
public string ArtifactType { get; set; }
public string BizTalkApplicationName { get; set; }
public string MappedApplicationName { get; set; }
public string ArtifactName { get; set; }
public string AdapterName { get; set; }
public string SourceAddress { get; set; }
public string NormalizedEndpoint { get; set; }
public string TargetName { get; set; }
public string HttpMethod { get; set; }
public int? Port { get; set; }
public bool? ValidateUncPathAccess { get; set; }
public string Detail { get; set; }
}
}
@@ -0,0 +1,63 @@
<?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>{E8C93E45-DA2B-4E30-8891-637A2C7FAE0B}</ProjectGuid>
<OutputType>Exe</OutputType>
<RootNamespace>BizTalkMonitorConfigGenerator</RootNamespace>
<AssemblyName>BizTalkMonitorConfigGenerator</AssemblyName>
<TargetFrameworkVersion>v4.7.2</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="Microsoft.BizTalk.ExplorerOM" />
<Reference Include="System" />
<Reference Include="System.Configuration" />
<Reference Include="System.Core" />
<Reference Include="System.Web.Extensions" />
</ItemGroup>
<ItemGroup>
<Compile Include="BizTalk\BizTalkCatalogDiscoveryService.cs" />
<Compile Include="BizTalk\DiscoveredEndpoint.cs" />
<Compile Include="Configuration\GeneratorConfiguration.cs" />
<Compile Include="Logging\GeneratorFileLogger.cs" />
<Compile Include="Logging\GeneratorEventLogger.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Serialization\JsonFileWriter.cs" />
<Compile Include="..\HostAvailabilityMonitor\Configuration\MonitorConfiguration.cs">
<Link>Shared\MonitorConfiguration.cs</Link>
</Compile>
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
<Content Include="generator-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
@@ -0,0 +1,315 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Web.Script.Serialization;
using HostAvailabilityMonitor.Configuration;
namespace BizTalkMonitorConfigGenerator.Configuration
{
public sealed class GeneratorConfiguration
{
public string GeneratorName { get; set; } = "BizTalkMonitorConfigGenerator";
public string EnvironmentName { get; set; } = "Unspecified Environment";
public GeneratorLoggingOptions Logging { get; set; } = new GeneratorLoggingOptions();
public GeneratorEventLogOptions EventLog { get; set; } = new GeneratorEventLogOptions();
public BizTalkOptions BizTalk { get; set; } = new BizTalkOptions();
public OutputOptions Output { get; set; } = new OutputOptions();
public MonitorConfiguration GeneratedMonitorConfig { get; set; } = new MonitorConfiguration();
public static GeneratorConfiguration 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<GeneratorConfiguration>(json);
if (config == null)
{
throw new InvalidOperationException("Generator-Konfiguration konnte nicht deserialisiert werden.");
}
config.Logging = config.Logging ?? new GeneratorLoggingOptions();
config.EventLog = config.EventLog ?? new GeneratorEventLogOptions();
config.BizTalk = config.BizTalk ?? new BizTalkOptions();
config.Output = config.Output ?? new OutputOptions();
config.GeneratedMonitorConfig = config.GeneratedMonitorConfig ?? new MonitorConfiguration();
config.GeneratedMonitorConfig.Runtime = config.GeneratedMonitorConfig.Runtime ?? new RuntimeOptions();
config.GeneratedMonitorConfig.Logging = config.GeneratedMonitorConfig.Logging ?? new LoggingOptions();
config.GeneratedMonitorConfig.EventLog = config.GeneratedMonitorConfig.EventLog ?? new EventLogOptions();
config.GeneratedMonitorConfig.Email = config.GeneratedMonitorConfig.Email ?? new EmailOptions();
config.GeneratedMonitorConfig.Email.To = RecipientListHelper.SanitizeRecipientList(config.GeneratedMonitorConfig.Email.To);
config.GeneratedMonitorConfig.Email.Cc = RecipientListHelper.SanitizeRecipientList(config.GeneratedMonitorConfig.Email.Cc);
config.GeneratedMonitorConfig.Email.Bcc = RecipientListHelper.SanitizeRecipientList(config.GeneratedMonitorConfig.Email.Bcc);
config.GeneratedMonitorConfig.Targets = config.GeneratedMonitorConfig.Targets ?? new List<TargetOptions>();
config.BizTalk.IgnoreApplications = config.BizTalk.IgnoreApplications ?? new List<string>();
config.BizTalk.IgnoreArtifactNamePatterns = config.BizTalk.IgnoreArtifactNamePatterns ?? new List<string>();
config.BizTalk.ApplicationMappings = config.BizTalk.ApplicationMappings ?? new List<ApplicationMappingRule>();
config.BizTalk.ArtifactMappings = config.BizTalk.ArtifactMappings ?? new List<ArtifactMappingRule>();
if (string.IsNullOrWhiteSpace(config.GeneratorName))
{
config.GeneratorName = "BizTalkMonitorConfigGenerator";
}
if (string.IsNullOrWhiteSpace(config.EnvironmentName))
{
config.EnvironmentName = "Unspecified Environment";
}
if (string.IsNullOrWhiteSpace(config.GeneratedMonitorConfig.ApplicationName))
{
config.GeneratedMonitorConfig.ApplicationName = "HostAvailabilityMonitor";
}
if (string.IsNullOrWhiteSpace(config.GeneratedMonitorConfig.EnvironmentName))
{
config.GeneratedMonitorConfig.EnvironmentName = config.EnvironmentName;
}
return config;
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(BizTalk.ConnectionString))
{
throw new InvalidOperationException("BizTalk.ConnectionString ist leer.");
}
if (string.IsNullOrWhiteSpace(Output.Path))
{
throw new InvalidOperationException("Output.Path ist leer.");
}
if (!BizTalk.IncludeSendPorts && !BizTalk.IncludeReceiveLocations)
{
throw new InvalidOperationException("Mindestens eine Quelle muss aktiviert sein: IncludeSendPorts oder IncludeReceiveLocations.");
}
if (Logging.RetentionDays < 0)
{
throw new InvalidOperationException("Logging.RetentionDays darf nicht negativ sein.");
}
if (Output.TargetTimeoutSeconds <= 0)
{
Output.TargetTimeoutSeconds = 10;
}
if (string.IsNullOrWhiteSpace(Output.DefaultHttpMethod))
{
Output.DefaultHttpMethod = "HEAD";
}
if (GeneratedMonitorConfig.Targets == null)
{
GeneratedMonitorConfig.Targets = new List<TargetOptions>();
}
GeneratedMonitorConfig.ValidateTemplateWithoutTargets();
}
}
internal static class RecipientListHelper
{
internal static List<string> SanitizeRecipientList(List<string> values)
{
if (values == null)
{
return new List<string>();
}
return values
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
internal static int GetConfiguredRecipientCount(EmailOptions email)
{
if (email == null)
{
return 0;
}
return SanitizeRecipientList(email.To).Count
+ SanitizeRecipientList(email.Cc).Count
+ SanitizeRecipientList(email.Bcc).Count;
}
}
public sealed class GeneratorLoggingOptions
{
public string LogDirectory { get; set; } = "logs";
public string FilePrefix { get; set; } = "biztalk-monitor-config-generator";
public int RetentionDays { get; set; } = 5;
}
public sealed class GeneratorEventLogOptions
{
public bool Enabled { get; set; } = true;
public string LogName { get; set; } = "Application";
public string Source { get; set; } = "BizTalkMonitorConfigGenerator";
public bool CreateSourceIfMissing { get; set; } = false;
public bool WriteSuccessEntries { get; set; } = false;
public bool WriteSummaryEntries { get; set; } = true;
}
public sealed class BizTalkOptions
{
public string ConnectionString { get; set; } = "SERVER=.;DATABASE=BizTalkMgmtDb;Integrated Security=SSPI";
public bool IncludeSendPorts { get; set; } = true;
public bool IncludeReceiveLocations { get; set; } = true;
public bool OnlyStartedSendPorts { get; set; } = true;
public bool OnlyEnabledReceiveLocations { get; set; } = true;
public bool IncludeSecondaryTransportOnSendPorts { get; set; } = true;
public bool IncludeDynamicSendPorts { get; set; } = false;
public bool IncludeLocalFilePaths { get; set; } = false;
public bool IgnoreSystemApplications { get; set; } = true;
public List<string> IgnoreApplications { get; set; } = new List<string>();
public List<string> IgnoreArtifactNamePatterns { get; set; } = new List<string>();
public List<ApplicationMappingRule> ApplicationMappings { get; set; } = new List<ApplicationMappingRule>();
public List<ArtifactMappingRule> ArtifactMappings { get; set; } = new List<ArtifactMappingRule>();
}
public sealed class OutputOptions
{
public string Path { get; set; } = "generated-monitor-appsettings.json";
public bool OverwriteExisting { get; set; } = true;
public bool FailIfNoTargetsFound { get; set; } = false;
public int TargetTimeoutSeconds { get; set; } = 10;
public bool TargetEnabled { get; set; } = true;
public string DefaultHttpMethod { get; set; } = "HEAD";
}
public sealed class ApplicationMappingRule
{
public string BizTalkApplicationName { get; set; } = string.Empty;
public string ApplicationName { get; set; } = string.Empty;
}
public sealed class ArtifactMappingRule
{
public string ArtifactType { get; set; } = string.Empty;
public string ArtifactNamePattern { get; set; } = string.Empty;
public string ApplicationName { get; set; } = string.Empty;
}
internal static class MonitorConfigurationTemplateExtensions
{
public static void ValidateTemplateWithoutTargets(this MonitorConfiguration configuration)
{
if (configuration == null)
{
throw new InvalidOperationException("GeneratedMonitorConfig ist null.");
}
configuration.Runtime = configuration.Runtime ?? new RuntimeOptions();
configuration.Logging = configuration.Logging ?? new LoggingOptions();
configuration.EventLog = configuration.EventLog ?? new EventLogOptions();
configuration.Email = configuration.Email ?? new EmailOptions();
configuration.Targets = configuration.Targets ?? new List<TargetOptions>();
if (configuration.Runtime.MaxConcurrency <= 0)
{
configuration.Runtime.MaxConcurrency = 1;
}
if (configuration.Logging.RetentionDays < 0)
{
throw new InvalidOperationException("GeneratedMonitorConfig.Logging.RetentionDays darf nicht negativ sein.");
}
if (configuration.Email.Enabled)
{
if (string.IsNullOrWhiteSpace(configuration.Email.SmtpHost))
{
throw new InvalidOperationException("GeneratedMonitorConfig.Email.Enabled=true, aber SmtpHost ist leer.");
}
if (string.IsNullOrWhiteSpace(configuration.Email.From))
{
throw new InvalidOperationException("GeneratedMonitorConfig.Email.Enabled=true, aber From ist leer.");
}
if (RecipientListHelper.GetConfiguredRecipientCount(configuration.Email) <= 0)
{
throw new InvalidOperationException("GeneratedMonitorConfig.Email.Enabled=true, aber es ist kein Empfänger in Email.To, Email.Cc oder Email.Bcc definiert.");
}
if (configuration.Email.DailySummaryHourLocal < 0 || configuration.Email.DailySummaryHourLocal > 23)
{
throw new InvalidOperationException("GeneratedMonitorConfig.Email.DailySummaryHourLocal muss zwischen 0 und 23 liegen.");
}
if (configuration.Email.DailySummaryMinuteLocal < 0 || configuration.Email.DailySummaryMinuteLocal > 59)
{
throw new InvalidOperationException("GeneratedMonitorConfig.Email.DailySummaryMinuteLocal muss zwischen 0 und 59 liegen.");
}
}
}
public static MonitorConfiguration CloneWithoutTargets(this MonitorConfiguration configuration)
{
var clone = new MonitorConfiguration
{
ApplicationName = configuration.ApplicationName,
EnvironmentName = configuration.EnvironmentName,
Runtime = new RuntimeOptions
{
MaxConcurrency = configuration.Runtime.MaxConcurrency,
DefaultValidateUncPathAccess = configuration.Runtime.DefaultValidateUncPathAccess,
NonZeroExitCodeOnAnyFailure = configuration.Runtime.NonZeroExitCodeOnAnyFailure,
NonZeroExitCodeOnExecutionError = configuration.Runtime.NonZeroExitCodeOnExecutionError,
StateDirectory = configuration.Runtime.StateDirectory
},
Logging = new LoggingOptions
{
LogDirectory = configuration.Logging.LogDirectory,
FilePrefix = configuration.Logging.FilePrefix,
RetentionDays = configuration.Logging.RetentionDays
},
EventLog = new EventLogOptions
{
Enabled = configuration.EventLog.Enabled,
LogName = configuration.EventLog.LogName,
Source = configuration.EventLog.Source,
CreateSourceIfMissing = configuration.EventLog.CreateSourceIfMissing,
WriteSuccessEntries = configuration.EventLog.WriteSuccessEntries,
WriteSummaryEntries = configuration.EventLog.WriteSummaryEntries
},
Email = new EmailOptions
{
Enabled = configuration.Email.Enabled,
SmtpHost = configuration.Email.SmtpHost,
SmtpPort = configuration.Email.SmtpPort,
UseSsl = configuration.Email.UseSsl,
UseDefaultCredentials = configuration.Email.UseDefaultCredentials,
Username = configuration.Email.Username,
Password = configuration.Email.Password,
From = configuration.Email.From,
To = configuration.Email.To == null ? new List<string>() : configuration.Email.To.ToList(),
Cc = configuration.Email.Cc == null ? new List<string>() : configuration.Email.Cc.ToList(),
Bcc = configuration.Email.Bcc == null ? new List<string>() : configuration.Email.Bcc.ToList(),
SubjectPrefix = configuration.Email.SubjectPrefix,
SendOnFailure = configuration.Email.SendOnFailure,
SendOnRecovery = configuration.Email.SendOnRecovery,
SendDailySummary = configuration.Email.SendDailySummary,
DailySummaryHourLocal = configuration.Email.DailySummaryHourLocal,
DailySummaryMinuteLocal = configuration.Email.DailySummaryMinuteLocal,
OnlyOnStateChange = configuration.Email.OnlyOnStateChange,
CooldownMinutes = configuration.Email.CooldownMinutes,
TimeoutSeconds = configuration.Email.TimeoutSeconds
},
Targets = new List<TargetOptions>()
};
return clone;
}
}
}
@@ -0,0 +1,115 @@
using System;
using System.Diagnostics;
using System.Security;
using BizTalkMonitorConfigGenerator.Configuration;
namespace BizTalkMonitorConfigGenerator.Logging
{
public sealed class GeneratorEventLogger
{
private readonly GeneratorEventLogOptions _options;
private readonly GeneratorFileLogger _fallbackLogger;
private bool _disabled;
public GeneratorEventLogger(GeneratorEventLogOptions options, GeneratorFileLogger fallbackLogger)
{
_options = options ?? new GeneratorEventLogOptions();
_fallbackLogger = fallbackLogger;
_disabled = !_options.Enabled;
}
public void TryInitialize()
{
if (_disabled || !_options.CreateSourceIfMissing)
{
return;
}
try
{
if (!EventLog.SourceExists(_options.Source))
{
EventLog.CreateEventSource(new EventSourceCreationData(_options.Source, _options.LogName));
_fallbackLogger.Info("EventLog-Quelle '" + _options.Source + "' wurde neu angelegt.");
}
}
catch (SecurityException ex)
{
_fallbackLogger.Warn("EventLog-Quelle konnte nicht geprüft/angelegt werden (fehlende Rechte). " + ex.Message);
}
catch (Exception ex)
{
_fallbackLogger.Warn("EventLog-Initialisierung fehlgeschlagen. " + ex.GetType().Name + ": " + ex.Message);
}
}
public void Info(string message, int eventId)
{
Write(message, EventLogEntryType.Information, eventId, true);
}
public void InfoIfSuccessEnabled(string message, int eventId)
{
if (_options.WriteSuccessEntries)
{
Write(message, EventLogEntryType.Information, eventId, true);
}
}
public void Warning(string message, int eventId)
{
Write(message, EventLogEntryType.Warning, eventId, true);
}
public void Error(string message, int eventId)
{
Write(message, EventLogEntryType.Error, eventId, true);
}
public void Summary(string message, bool hasWarnings)
{
if (!_options.WriteSummaryEntries)
{
return;
}
Write(message, hasWarnings ? EventLogEntryType.Warning : EventLogEntryType.Information, hasWarnings ? 2100 : 1100, 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 fehlt die Event Source '" + _options.Source + "'. " + 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,164 @@
using System;
using System.Globalization;
using System.IO;
using System.Threading;
namespace BizTalkMonitorConfigGenerator.Logging
{
public sealed class GeneratorFileLogger
{
private readonly object _sync = new object();
private readonly string _directory;
private readonly string _filePrefix;
private readonly int _retentionDays;
private readonly string _generatorName;
private readonly string _environmentName;
public GeneratorFileLogger(string directory, string filePrefix, int retentionDays, string generatorName, string environmentName)
{
_directory = directory;
_filePrefix = filePrefix;
_retentionDays = retentionDays;
_generatorName = string.IsNullOrWhiteSpace(generatorName) ? "BizTalkMonitorConfigGenerator" : generatorName.Trim();
_environmentName = string.IsNullOrWhiteSpace(environmentName) ? "Unspecified Environment" : environmentName.Trim();
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
{
DateTime fileDate;
if (TryGetDateFromFileName(file, out fileDate))
{
if (fileDate < cutoff)
{
File.Delete(file);
}
}
else if (File.GetLastWriteTime(file).Date < cutoff)
{
File.Delete(file);
}
}
catch
{
}
}
}
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 Discovery(string action, string artifactType, string applicationName, string artifactName, string adapterName, string sourceAddress, string normalizedEndpoint, string detail)
{
Write("DISCOVERY", string.Format(
CultureInfo.InvariantCulture,
"Action={0} | ArtifactType={1} | BizTalkApplication={2} | ArtifactName={3} | Adapter={4} | SourceAddress={5} | NormalizedEndpoint={6} | Detail={7}",
Escape(action),
Escape(artifactType),
Escape(applicationName),
Escape(artifactName),
Escape(adapterName),
Escape(sourceAddress),
Escape(normalizedEndpoint),
Escape(detail)));
}
private void Write(string level, string message)
{
var line = string.Format(
CultureInfo.InvariantCulture,
"{0:yyyy-MM-dd HH:mm:ss.fff zzz} | {1} | Generator={2} | Environment={3} | {4}{5}",
DateTimeOffset.Now,
level,
Escape(_generatorName),
Escape(_environmentName),
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;
}
return DateTime.TryParseExact(
fileNameWithoutExtension.Substring(prefix.Length),
"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,127 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using BizTalkMonitorConfigGenerator.BizTalk;
using BizTalkMonitorConfigGenerator.Configuration;
using BizTalkMonitorConfigGenerator.Logging;
using BizTalkMonitorConfigGenerator.Serialization;
using HostAvailabilityMonitor.Configuration;
namespace BizTalkMonitorConfigGenerator
{
internal static class Program
{
private static int Main(string[] args)
{
GeneratorFileLogger logger = null;
GeneratorEventLogger eventLogger = null;
try
{
var baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
var configPath = args != null && args.Length > 0 && !string.IsNullOrWhiteSpace(args[0])
? args[0]
: Path.Combine(baseDirectory, "generator-settings.json");
var configuration = GeneratorConfiguration.Load(configPath);
configuration.Validate();
var logDirectory = ResolvePath(baseDirectory, configuration.Logging.LogDirectory);
logger = new GeneratorFileLogger(logDirectory, configuration.Logging.FilePrefix, configuration.Logging.RetentionDays, configuration.GeneratorName, configuration.EnvironmentName);
logger.CleanupOldFiles();
logger.Info(configuration.GeneratorName + " gestartet. Umgebung=" + configuration.EnvironmentName + ", Server=" + Environment.MachineName + ".");
logger.Info("Generator-Konfiguration geladen: " + Path.GetFullPath(configPath));
eventLogger = new GeneratorEventLogger(configuration.EventLog, logger);
eventLogger.TryInitialize();
eventLogger.Info(configuration.GeneratorName + " gestartet. Umgebung=" + configuration.EnvironmentName + ", Server=" + Environment.MachineName + ".", 1000);
var discoveryService = new BizTalkCatalogDiscoveryService(configuration.BizTalk, configuration.Output, logger);
var discovered = discoveryService.Discover();
logger.Info("Discovery abgeschlossen. Gefundene unterstützte Targets=" + discovered.Count + ".");
var outputConfiguration = BuildMonitorConfiguration(configuration, discovered);
var outputPath = ResolvePath(baseDirectory, configuration.Output.Path);
JsonFileWriter.WritePrettyJsonAtomic(outputPath, outputConfiguration, configuration.Output.OverwriteExisting);
logger.Info("Monitor-Konfiguration geschrieben: " + outputPath);
eventLogger.InfoIfSuccessEnabled("Monitor-Konfiguration erfolgreich generiert: " + outputPath, 1001);
if (discovered.Count == 0)
{
var message = "Es wurden keine aktiven und unterstützten BizTalk-Endpunkte gefunden. Ausgabe wurde dennoch geschrieben: " + outputPath;
logger.Warn(message);
eventLogger.Summary(message, true);
return configuration.Output.FailIfNoTargetsFound ? 2 : 0;
}
var summary = string.Format(
"Generatorlauf erfolgreich beendet. Umgebung={0}, GesamtTargets={1}, Ausgabedatei={2}",
configuration.EnvironmentName,
discovered.Count,
outputPath);
logger.Info(summary);
eventLogger.Summary(summary, false);
return 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 1;
}
}
private static MonitorConfiguration BuildMonitorConfiguration(GeneratorConfiguration configuration, IReadOnlyCollection<DiscoveredEndpoint> discovered)
{
var result = configuration.GeneratedMonitorConfig.CloneWithoutTargets();
result.EnvironmentName = string.IsNullOrWhiteSpace(configuration.GeneratedMonitorConfig.EnvironmentName)
? configuration.EnvironmentName
: configuration.GeneratedMonitorConfig.EnvironmentName;
var targets = new List<TargetOptions>();
foreach (var item in discovered)
{
var target = new TargetOptions
{
Name = item.TargetName,
ApplicationName = item.MappedApplicationName,
Endpoint = item.NormalizedEndpoint,
TimeoutSeconds = configuration.Output.TargetTimeoutSeconds,
Port = item.Port,
ValidateUncPathAccess = item.ValidateUncPathAccess,
HttpMethod = item.HttpMethod,
Enabled = configuration.Output.TargetEnabled
};
targets.Add(target);
}
result.Targets = targets
.OrderBy(x => x.ApplicationName, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Endpoint, StringComparer.OrdinalIgnoreCase)
.ToList();
return result;
}
private static string ResolvePath(string baseDirectory, string configuredPath)
{
if (Path.IsPathRooted(configuredPath))
{
return configuredPath;
}
return Path.GetFullPath(Path.Combine(baseDirectory, configuredPath));
}
}
}
@@ -0,0 +1,17 @@
using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("BizTalkMonitorConfigGenerator")]
[assembly: AssemblyDescription("Generates HostAvailabilityMonitor JSON config from active BizTalk send ports and receive locations.")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("JR IT Services")]
[assembly: AssemblyProduct("BizTalkMonitorConfigGenerator")]
[assembly: AssemblyCopyright("Copyright © JR IT Services")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
[assembly: ComVisible(false)]
[assembly: Guid("f03c8d6d-1619-4cb6-b8a2-19d96473fddd")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
@@ -0,0 +1,137 @@
using System;
using System.IO;
using System.Text;
using System.Web.Script.Serialization;
namespace BizTalkMonitorConfigGenerator.Serialization
{
public static class JsonFileWriter
{
public static void WritePrettyJsonAtomic(string path, object value, bool overwriteExisting)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Pfad ist leer.", nameof(path));
}
var fullPath = Path.GetFullPath(path);
var directory = Path.GetDirectoryName(fullPath);
if (string.IsNullOrWhiteSpace(directory))
{
throw new InvalidOperationException("Aus dem Zielpfad konnte kein Verzeichnis aufgelöst werden: " + path);
}
Directory.CreateDirectory(directory);
if (File.Exists(fullPath) && !overwriteExisting)
{
throw new IOException("Zieldatei existiert bereits und OverwriteExisting=false: " + fullPath);
}
var serializer = new JavaScriptSerializer();
serializer.MaxJsonLength = int.MaxValue;
var compact = serializer.Serialize(value);
var pretty = PrettyPrint(compact);
var tempPath = fullPath + ".tmp";
File.WriteAllText(tempPath, pretty, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
if (File.Exists(fullPath))
{
File.Copy(fullPath, fullPath + ".bak", true);
File.Delete(fullPath);
}
File.Move(tempPath, fullPath);
}
private static string PrettyPrint(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return string.Empty;
}
var sb = new StringBuilder();
var indent = 0;
var quoted = false;
for (var i = 0; i < json.Length; i++)
{
var ch = json[i];
switch (ch)
{
case '{':
case '[':
sb.Append(ch);
if (!quoted)
{
sb.AppendLine();
indent++;
AppendIndent(sb, indent);
}
break;
case '}':
case ']':
if (!quoted)
{
sb.AppendLine();
indent--;
AppendIndent(sb, indent);
sb.Append(ch);
}
else
{
sb.Append(ch);
}
break;
case '"':
sb.Append(ch);
if (i > 0 && json[i - 1] == '\\')
{
break;
}
quoted = !quoted;
break;
case ',':
sb.Append(ch);
if (!quoted)
{
sb.AppendLine();
AppendIndent(sb, indent);
}
break;
case ':':
if (quoted)
{
sb.Append(ch);
}
else
{
sb.Append(": ");
}
break;
default:
if (!quoted && char.IsWhiteSpace(ch))
{
break;
}
sb.Append(ch);
break;
}
}
return sb.ToString();
}
private static void AppendIndent(StringBuilder sb, int indent)
{
for (var i = 0; i < indent; i++)
{
sb.Append(" ");
}
}
}
}
@@ -0,0 +1,99 @@
{
"GeneratorName": "BizTalkMonitorConfigGenerator",
"EnvironmentName": "HIP Produktion",
"Logging": {
"LogDirectory": "logs",
"FilePrefix": "biztalk-monitor-config-generator",
"RetentionDays": 5
},
"EventLog": {
"Enabled": true,
"LogName": "Application",
"Source": "BizTalkMonitorConfigGenerator",
"CreateSourceIfMissing": false,
"WriteSuccessEntries": false,
"WriteSummaryEntries": true
},
"BizTalk": {
"ConnectionString": "SERVER=.;DATABASE=BizTalkMgmtDb;Integrated Security=SSPI",
"IncludeSendPorts": true,
"IncludeReceiveLocations": true,
"OnlyStartedSendPorts": true,
"OnlyEnabledReceiveLocations": true,
"IncludeSecondaryTransportOnSendPorts": true,
"IncludeDynamicSendPorts": false,
"IncludeLocalFilePaths": false,
"IgnoreSystemApplications": true,
"IgnoreApplications": [
"BizTalk.System"
],
"IgnoreArtifactNamePatterns": [],
"ApplicationMappings": [
{
"BizTalkApplicationName": "HIP*",
"ApplicationName": "HIP"
}
],
"ArtifactMappings": []
},
"Output": {
"Path": "generated-monitor-appsettings.json",
"OverwriteExisting": true,
"FailIfNoTargetsFound": false,
"TargetTimeoutSeconds": 10,
"TargetEnabled": true,
"DefaultHttpMethod": "HEAD"
},
"GeneratedMonitorConfig": {
"ApplicationName": "HostAvailabilityMonitor",
"EnvironmentName": "HIP Produktion",
"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,
"UseDefaultCredentials": false,
"Username": "",
"Password": "",
"From": "monitor@example.local",
"To": [
"operations@example.local",
"integration-oncall@example.local"
],
"Cc": [
"integration-leads@example.local"
],
"Bcc": [],
"SubjectPrefix": "[HostAvailabilityMonitor]",
"SendOnFailure": true,
"SendOnRecovery": true,
"SendDailySummary": true,
"DailySummaryHourLocal": 14,
"DailySummaryMinuteLocal": 0,
"OnlyOnStateChange": true,
"CooldownMinutes": 0,
"TimeoutSeconds": 30
},
"Targets": []
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Web.Script.Serialization;
namespace HostAvailabilityMonitor.Configuration
@@ -8,6 +9,7 @@ namespace HostAvailabilityMonitor.Configuration
public sealed class MonitorConfiguration
{
public string ApplicationName { get; set; } = "HostAvailabilityMonitor";
public string EnvironmentName { get; set; } = "Unspecified Environment";
public RuntimeOptions Runtime { get; set; } = new RuntimeOptions();
public LoggingOptions Logging { get; set; } = new LoggingOptions();
public EventLogOptions EventLog { get; set; } = new EventLogOptions();
@@ -34,8 +36,21 @@ namespace HostAvailabilityMonitor.Configuration
config.Logging = config.Logging ?? new LoggingOptions();
config.EventLog = config.EventLog ?? new EventLogOptions();
config.Email = config.Email ?? new EmailOptions();
config.Email.To = SanitizeRecipientList(config.Email.To);
config.Email.Cc = SanitizeRecipientList(config.Email.Cc);
config.Email.Bcc = SanitizeRecipientList(config.Email.Bcc);
config.Targets = config.Targets ?? new List<TargetOptions>();
if (string.IsNullOrWhiteSpace(config.ApplicationName))
{
config.ApplicationName = "HostAvailabilityMonitor";
}
if (string.IsNullOrWhiteSpace(config.EnvironmentName))
{
config.EnvironmentName = "Unspecified Environment";
}
foreach (var target in config.Targets)
{
if (target == null)
@@ -49,6 +64,21 @@ namespace HostAvailabilityMonitor.Configuration
}
}
if (config.Email.TimeoutSeconds <= 0)
{
config.Email.TimeoutSeconds = 30;
}
if (config.Email.DailySummaryHourLocal < 0 || config.Email.DailySummaryHourLocal > 23)
{
config.Email.DailySummaryHourLocal = 14;
}
if (config.Email.DailySummaryMinuteLocal < 0 || config.Email.DailySummaryMinuteLocal > 59)
{
config.Email.DailySummaryMinuteLocal = 0;
}
return config;
}
@@ -99,17 +129,52 @@ namespace HostAvailabilityMonitor.Configuration
throw new InvalidOperationException("Email.Enabled=true, aber From ist leer.");
}
if (Email.To == null || Email.To.Count == 0)
if (GetConfiguredRecipientCount(Email) <= 0)
{
throw new InvalidOperationException("Email.Enabled=true, aber es sind keine Empfaenger in Email.To konfiguriert.");
throw new InvalidOperationException("Email.Enabled=true, aber es ist kein Empfaenger in Email.To, Email.Cc oder Email.Bcc konfiguriert.");
}
if (Email.TimeoutSeconds <= 0)
{
Email.TimeoutSeconds = 30;
}
if (Email.DailySummaryHourLocal < 0 || Email.DailySummaryHourLocal > 23)
{
throw new InvalidOperationException("Email.DailySummaryHourLocal muss zwischen 0 und 23 liegen.");
}
if (Email.DailySummaryMinuteLocal < 0 || Email.DailySummaryMinuteLocal > 59)
{
throw new InvalidOperationException("Email.DailySummaryMinuteLocal muss zwischen 0 und 59 liegen.");
}
}
}
private static List<string> SanitizeRecipientList(List<string> values)
{
if (values == null)
{
return new List<string>();
}
return values
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static int GetConfiguredRecipientCount(EmailOptions email)
{
if (email == null)
{
return 0;
}
return SanitizeRecipientList(email.To).Count
+ SanitizeRecipientList(email.Cc).Count
+ SanitizeRecipientList(email.Bcc).Count;
}
}
public sealed class RuntimeOptions
@@ -144,13 +209,19 @@ namespace HostAvailabilityMonitor.Configuration
public string SmtpHost { get; set; } = string.Empty;
public int SmtpPort { get; set; } = 25;
public bool UseSsl { get; set; } = false;
public bool UseDefaultCredentials { 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 List<string> Cc { get; set; } = new List<string>();
public List<string> Bcc { 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 SendDailySummary { get; set; } = false;
public int DailySummaryHourLocal { get; set; } = 14;
public int DailySummaryMinuteLocal { get; set; } = 0;
public bool OnlyOnStateChange { get; set; } = true;
public int CooldownMinutes { get; set; } = 0;
public int TimeoutSeconds { get; set; } = 30;
@@ -159,6 +230,7 @@ namespace HostAvailabilityMonitor.Configuration
public sealed class TargetOptions
{
public string Name { get; set; } = string.Empty;
public string ApplicationName { get; set; } = string.Empty;
public string Endpoint { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 10;
public int? Port { get; set; }
@@ -171,9 +243,30 @@ namespace HostAvailabilityMonitor.Configuration
get { return string.IsNullOrWhiteSpace(Name) ? Endpoint : Name; }
}
public string EffectiveApplicationName
{
get { return string.IsNullOrWhiteSpace(ApplicationName) ? "Unassigned Application" : ApplicationName.Trim(); }
}
public string StateKey
{
get { return string.IsNullOrWhiteSpace(Name) ? Endpoint : Name.Trim(); }
get
{
var parts = new[]
{
Normalize(ApplicationName),
Normalize(Name),
Normalize(Endpoint)
}.Where(x => !string.IsNullOrWhiteSpace(x));
var key = string.Join("|", parts);
return string.IsNullOrWhiteSpace(key) ? Guid.NewGuid().ToString("N") : key;
}
}
private static string Normalize(string value)
{
return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
}
}
}
@@ -8,7 +8,7 @@
<OutputType>Exe</OutputType>
<RootNamespace>HostAvailabilityMonitor</RootNamespace>
<AssemblyName>HostAvailabilityMonitor</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using HostAvailabilityMonitor.Monitoring;
@@ -14,12 +13,16 @@ namespace HostAvailabilityMonitor.Logging
private readonly string _directory;
private readonly string _filePrefix;
private readonly int _retentionDays;
private readonly string _monitorName;
private readonly string _environmentName;
public FileLogger(string directory, string filePrefix, int retentionDays)
public FileLogger(string directory, string filePrefix, int retentionDays, string monitorName, string environmentName)
{
_directory = directory;
_filePrefix = filePrefix;
_retentionDays = retentionDays;
_monitorName = string.IsNullOrWhiteSpace(monitorName) ? "HostAvailabilityMonitor" : monitorName.Trim();
_environmentName = string.IsNullOrWhiteSpace(environmentName) ? "Unspecified Environment" : environmentName.Trim();
Directory.CreateDirectory(_directory);
}
@@ -36,7 +39,8 @@ namespace HostAvailabilityMonitor.Logging
{
try
{
if (TryGetDateFromFileName(file, out var fileDate))
DateTime fileDate;
if (TryGetDateFromFileName(file, out fileDate))
{
if (fileDate < cutoff)
{
@@ -76,9 +80,12 @@ namespace HostAvailabilityMonitor.Logging
public void Probe(ProbeResult result)
{
var level = result.IsSuccess ? "SUCCESS" : "FAIL";
var level = result.IsSkipped ? "SKIP" : (result.IsSuccess ? "SUCCESS" : "FAIL");
var parts = new List<string>
{
"Monitor=" + Escape(_monitorName),
"Environment=" + Escape(_environmentName),
"Application=" + Escape(result.ApplicationName),
"Name=" + Escape(result.Name),
"Kind=" + result.Kind,
"Endpoint=" + Escape(result.Endpoint),
@@ -17,7 +17,7 @@ namespace HostAvailabilityMonitor.Monitoring
public EndpointProbeService(RuntimeOptions runtimeOptions)
{
_runtimeOptions = runtimeOptions;
_runtimeOptions = runtimeOptions ?? new RuntimeOptions();
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12;
}
@@ -32,12 +32,23 @@ namespace HostAvailabilityMonitor.Monitoring
{
return Fail(target, TargetKind.Unknown, endpoint, startedAt, "Leerer Endpoint.", null, null, null);
}
ProbeResult skippedResult;
if (TryCreateSkipResult(target, endpoint, startedAt, out skippedResult))
{
return skippedResult;
}
if (endpoint.StartsWith("\\\\", StringComparison.Ordinal))
{
return await CheckUncAsync(target, endpoint, startedAt, cancellationToken).ConfigureAwait(false);
}
if (Path.IsPathRooted(endpoint))
{
return await CheckLocalPathAsync(target, endpoint, startedAt, cancellationToken).ConfigureAwait(false);
}
Uri uri;
if (Uri.TryCreate(endpoint, UriKind.Absolute, out uri))
{
@@ -53,11 +64,13 @@ namespace HostAvailabilityMonitor.Monitoring
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);
return await CheckIcmpAsync(target, uri.Host, startedAt, endpoint, cancellationToken).ConfigureAwait(false);
case "ftp":
return await CheckTcpAsync(target, uri.Host, target.Port ?? ResolvePort(uri, 21), startedAt, TargetKind.Ftp, endpoint, cancellationToken).ConfigureAwait(false);
case "file":
return await CheckFileUriAsync(target, uri, startedAt, 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 Fail(target, TargetKind.Unknown, endpoint, startedAt, "Nicht unterstütztes Schema '" + uri.Scheme + "'. Unterstützt: UNC, file, http, https, sftp, ftp, tcp, icmp.", null, uri.Host, uri.IsDefaultPort ? (int?)null : uri.Port);
}
}
@@ -80,7 +93,48 @@ namespace HostAvailabilityMonitor.Monitoring
return await CheckTcpAsync(target, host, target.Port.Value, startedAt, TargetKind.Host, host, cancellationToken).ConfigureAwait(false);
}
return await CheckIcmpAsync(target, host, startedAt, host).ConfigureAwait(false);
return await CheckIcmpAsync(target, host, startedAt, host, cancellationToken).ConfigureAwait(false);
}
private async Task<ProbeResult> CheckFileUriAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, CancellationToken cancellationToken)
{
if (uri.IsUnc || !string.IsNullOrWhiteSpace(uri.Host))
{
var uncPath = uri.LocalPath;
if (!uncPath.StartsWith("\\\\", StringComparison.Ordinal))
{
uncPath = "\\\\" + uri.Host + uri.LocalPath.Replace('/', '\\');
}
return await CheckUncAsync(target, uncPath, startedAt, cancellationToken).ConfigureAwait(false);
}
var localPath = Uri.UnescapeDataString(uri.LocalPath ?? string.Empty);
return await CheckLocalPathAsync(target, localPath, startedAt, cancellationToken).ConfigureAwait(false);
}
private async Task<ProbeResult> CheckLocalPathAsync(TargetOptions target, string path, DateTimeOffset startedAt, CancellationToken cancellationToken)
{
try
{
var existsTask = Task.Run(function: () => Directory.Exists(path) || File.Exists(path), cancellationToken);
var timed = await CompleteWithinAsync(existsTask, TimeSpan.FromSeconds(target.TimeoutSeconds), cancellationToken).ConfigureAwait(false);
if (!timed.Completed)
{
return Fail(target, TargetKind.File, path, startedAt, "Timeout während der Datei-/Pfadprüfung.", null, null, null);
}
if (timed.Result)
{
return Success(target, TargetKind.File, path, startedAt, Environment.MachineName, null, "Datei- oder Pfadprüfung erfolgreich.", null);
}
return Fail(target, TargetKind.File, path, startedAt, "Datei oder Verzeichnis ist nicht vorhanden oder nicht zugreifbar.", null, Environment.MachineName, null);
}
catch (Exception ex)
{
return Fail(target, TargetKind.File, path, startedAt, "Datei-/Pfadprüfung fehlgeschlagen: " + ex.Message, ex, Environment.MachineName, null);
}
}
private async Task<ProbeResult> CheckUncAsync(TargetOptions target, string endpoint, DateTimeOffset startedAt, CancellationToken cancellationToken)
@@ -124,14 +178,13 @@ namespace HostAvailabilityMonitor.Monitoring
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);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(target.TimeoutSeconds));
try
{
using (var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token).ConfigureAwait(false))
@@ -139,7 +192,7 @@ namespace HostAvailabilityMonitor.Monitoring
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 await RetryHttpWithGetAsync(target, uri, startedAt, kind, 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);
@@ -149,7 +202,7 @@ namespace HostAvailabilityMonitor.Monitoring
{
if (string.Equals(methodText, "HEAD", StringComparison.OrdinalIgnoreCase))
{
return await RetryHttpWithGetAsync(target, uri, startedAt, kind, ex, cancellationToken).ConfigureAwait(false);
return await RetryHttpWithGetAsync(target, uri, startedAt, kind, 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));
@@ -165,7 +218,7 @@ namespace HostAvailabilityMonitor.Monitoring
}
}
private async Task<ProbeResult> RetryHttpWithGetAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, TargetKind kind, Exception headException, CancellationToken cancellationToken)
private async Task<ProbeResult> RetryHttpWithGetAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, TargetKind kind, CancellationToken cancellationToken)
{
using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
using (var request = new HttpRequestMessage(HttpMethod.Get, uri))
@@ -175,48 +228,68 @@ namespace HostAvailabilityMonitor.Monitoring
{
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);
return Success(target, kind, uri.ToString(), startedAt, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80), "HTTP-Antwort nach GET-Fallback erhalten (" + (int)response.StatusCode + " " + response.ReasonPhrase + ").", (int)response.StatusCode);
}
}
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. HEAD: " + headException.Message + "; GET: " + ex.Message, ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80));
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> CheckTcpAsync(TargetOptions target, string host, int? port, DateTimeOffset startedAt, TargetKind kind, string endpoint, CancellationToken cancellationToken)
{
if (!port.HasValue || port.Value <= 0)
if (string.IsNullOrWhiteSpace(host))
{
return Fail(target, kind, endpoint, startedAt, "Für diesen Endpoint konnte kein gültiger TCP-Port ermittelt werden.", null, host, port);
return Fail(target, kind, endpoint, startedAt, "Host ist leer.", null, host, port);
}
var timeout = TimeSpan.FromSeconds(target.TimeoutSeconds);
if (!port.HasValue || port.Value <= 0 || port.Value > 65535)
{
return Fail(target, kind, endpoint, startedAt, "Es wurde kein gültiger Port aufgelöst.", null, host, port);
}
using (var client = new TcpClient())
using (cancellationToken.Register(() =>
{
try
{
client.Close();
}
catch
{
}
}))
{
try
{
var connectTask = client.ConnectAsync(host, port.Value);
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout, cancellationToken)).ConfigureAwait(false);
if (completedTask != connectTask)
var timed = await CompleteWithinAsync(connectTask, TimeSpan.FromSeconds(target.TimeoutSeconds), cancellationToken).ConfigureAwait(false);
if (!timed.Completed)
{
try
{
client.Close();
}
catch
{
}
cancellationToken.ThrowIfCancellationRequested();
return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung fehlgeschlagen: Timeout.", null, host, port);
return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung ist in einen Timeout gelaufen.", null, host, port);
}
if (!client.Connected)
{
return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung konnte nicht aufgebaut werden.", null, host, port);
}
await connectTask.ConfigureAwait(false);
return Success(target, kind, endpoint, startedAt, host, port, "TCP-Verbindung erfolgreich aufgebaut.", null);
}
catch (SocketException ex)
{
return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung fehlgeschlagen: " + ex.Message, ex, host, port);
}
catch (ObjectDisposedException ex)
{
return Fail(target, kind, endpoint, startedAt, "TCP-Check wurde abgebrochen oder ist in einen Timeout gelaufen.", ex, host, port);
}
catch (Exception ex)
{
return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung fehlgeschlagen: " + ex.Message, ex, host, port);
@@ -224,127 +297,212 @@ namespace HostAvailabilityMonitor.Monitoring
}
}
private async Task<ProbeResult> CheckIcmpAsync(TargetOptions target, string host, DateTimeOffset startedAt, string endpoint)
private async Task<ProbeResult> CheckIcmpAsync(TargetOptions target, string host, DateTimeOffset startedAt, string endpoint, CancellationToken cancellationToken)
{
try
using (var ping = new Ping())
{
using (var ping = new Ping())
try
{
var reply = await ping.SendPingAsync(host, (int)TimeSpan.FromSeconds(target.TimeoutSeconds).TotalMilliseconds).ConfigureAwait(false);
if (reply.Status == IPStatus.Success)
var replyTask = ping.SendPingAsync(host, Math.Max(1000, target.TimeoutSeconds * 1000));
var timed = await CompleteWithinAsync(replyTask, TimeSpan.FromSeconds(target.TimeoutSeconds + 1), cancellationToken).ConfigureAwait(false);
if (!timed.Completed)
{
return Success(target, TargetKind.Icmp, endpoint, startedAt, host, null, "ICMP erfolgreich, RTT=" + reply.RoundtripTime + "ms.", null);
return Fail(target, TargetKind.Icmp, endpoint, startedAt, "ICMP-Check ist in einen Timeout gelaufen.", null, host, null);
}
return Fail(target, TargetKind.Icmp, endpoint, startedAt, "ICMP fehlgeschlagen: " + reply.Status + ".", null, host, null);
var reply = timed.Result;
if (reply != null && reply.Status == IPStatus.Success)
{
return Success(target, TargetKind.Icmp, endpoint, startedAt, host, null, "Ping erfolgreich.", null);
}
var status = reply == null ? "Unbekannt" : reply.Status.ToString();
return Fail(target, TargetKind.Icmp, endpoint, startedAt, "Ping fehlgeschlagen: " + status + ".", null, host, null);
}
catch (PingException ex)
{
return Fail(target, TargetKind.Icmp, endpoint, startedAt, "ICMP-Check fehlgeschlagen: " + ex.Message, ex, host, null);
}
catch (Exception ex)
{
return Fail(target, TargetKind.Icmp, endpoint, startedAt, "ICMP-Check fehlgeschlagen: " + ex.Message, ex, 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)
private static int? ResolvePort(Uri uri, int? defaultPort)
{
if (uri == null)
{
return fallback;
return defaultPort;
}
if (!uri.IsDefaultPort && uri.Port > 0)
if (!uri.IsDefaultPort)
{
return uri.Port;
}
return fallback;
return defaultPort;
}
private static string ExtractUncServer(string uncPath)
private static string ExtractUncServer(string endpoint)
{
if (string.IsNullOrWhiteSpace(uncPath))
if (string.IsNullOrWhiteSpace(endpoint))
{
return string.Empty;
}
var trimmed = uncPath.TrimStart('\\');
var trimmed = endpoint.TrimStart('\\');
var firstSeparator = trimmed.IndexOf('\\');
if (firstSeparator < 0)
{
return trimmed;
}
return trimmed.Substring(0, firstSeparator);
return firstSeparator >= 0 ? trimmed.Substring(0, firstSeparator) : trimmed;
}
private static async Task<TimedResult<T>> CompleteWithinAsync<T>(Task<T> task, TimeSpan timeout, CancellationToken cancellationToken)
private static HttpClient CreateHttpClient()
{
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));
var handler = new HttpClientHandler();
var client = new HttpClient(handler, disposeHandler: true);
client.Timeout = Timeout.InfiniteTimeSpan;
return client;
}
private struct TimedResult<T>
private static ProbeResult Success(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string host, int? port, string detail, int? httpStatusCode)
{
public TimedResult(bool completed, T result)
return BuildResult(target, kind, endpoint, startedAt, true, false, "Reachable", detail, host, port, httpStatusCode, null);
}
private static ProbeResult Fail(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string detail, Exception ex, string host, int? port)
{
return BuildResult(target, kind, endpoint, startedAt, false, false, "Unreachable", detail, host, port, null, ex == null ? null : ex.GetType().Name);
}
private static ProbeResult Skip(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string detail)
{
return BuildResult(target, kind, endpoint, startedAt, false, true, "Skipped", detail, null, null, null, null);
}
private static ProbeResult BuildResult(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, bool isSuccess, bool isSkipped, string statusText, string detail, string host, int? port, int? httpStatusCode, string errorType)
{
return new ProbeResult
{
StateKey = target.StateKey,
Name = target.DisplayName,
ApplicationName = target.EffectiveApplicationName,
Endpoint = endpoint,
Kind = kind,
IsSuccess = isSuccess,
IsSkipped = isSkipped,
StatusText = statusText,
Detail = detail,
Host = host,
Port = port,
HttpStatusCode = httpStatusCode,
ErrorType = errorType,
StartedAtLocal = startedAt,
FinishedAtLocal = DateTimeOffset.Now
};
}
private static bool TryCreateSkipResult(TargetOptions target, string endpoint, DateTimeOffset startedAt, out ProbeResult result)
{
result = null;
if (LooksLikeSmtpAdapterTarget(target, endpoint))
{
result = Skip(target, TargetKind.Unknown, endpoint, startedAt, "Target wurde als SMTP-/E-Mail-Empfängerliste erkannt und wird nicht netzwerktechnisch geprüft.");
return true;
}
return false;
}
private static bool LooksLikeSmtpAdapterTarget(TargetOptions target, string endpoint)
{
var name = target == null ? string.Empty : (target.Name ?? string.Empty);
var value = endpoint ?? string.Empty;
if (name.IndexOf("[SMTP]", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
return LooksLikeEmailAddressList(value);
}
private static bool LooksLikeEmailAddressList(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
if (trimmed.IndexOf('@') < 0)
{
return false;
}
if (trimmed.IndexOf("http://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("https://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("sftp://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("ftp://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("tcp://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("file://", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.StartsWith("\\", StringComparison.Ordinal))
{
return false;
}
var entries = trimmed.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
if (entries.Length == 0)
{
return false;
}
foreach (var entry in entries)
{
var part = entry.Trim();
if (string.IsNullOrWhiteSpace(part))
{
continue;
}
if (part.IndexOf('@') <= 0 || part.IndexOf(' ') >= 0)
{
return false;
}
}
return true;
}
private static async Task<CompletionResult<T>> CompleteWithinAsync<T>(Task<T> task, TimeSpan timeout, CancellationToken cancellationToken)
{
var delayTask = Task.Delay(timeout, cancellationToken);
var completedTask = await Task.WhenAny(task, delayTask).ConfigureAwait(false);
if (completedTask != task)
{
return new CompletionResult<T>(false, default(T));
}
return new CompletionResult<T>(true, await task.ConfigureAwait(false));
}
private static async Task<CompletionResult<bool>> CompleteWithinAsync(Task task, TimeSpan timeout, CancellationToken cancellationToken)
{
var delayTask = Task.Delay(timeout, cancellationToken);
var completedTask = await Task.WhenAny(task, delayTask).ConfigureAwait(false);
if (completedTask != task)
{
return new CompletionResult<bool>(false, false);
}
await task.ConfigureAwait(false);
return new CompletionResult<bool>(true, true);
}
private struct CompletionResult<T>
{
public CompletionResult(bool completed, T result)
{
Completed = completed;
Result = result;
@@ -12,16 +12,19 @@ namespace HostAvailabilityMonitor.Monitoring
Tcp = 5,
Icmp = 6,
Host = 7,
Ftp = 8
Ftp = 8,
File = 9
}
public sealed class ProbeResult
{
public string StateKey { get; set; }
public string Name { get; set; }
public string ApplicationName { get; set; }
public string Endpoint { get; set; }
public TargetKind Kind { get; set; }
public bool IsSuccess { get; set; }
public bool IsSkipped { get; set; }
public string StatusText { get; set; }
public string Detail { get; set; }
public string Host { get; set; }
@@ -15,10 +15,14 @@ namespace HostAvailabilityMonitor.Notifications
public sealed class EmailNotifier
{
private readonly EmailOptions _options;
private readonly string _monitorName;
private readonly string _environmentName;
public EmailNotifier(EmailOptions options)
public EmailNotifier(EmailOptions options, string monitorName, string environmentName)
{
_options = options ?? new EmailOptions();
_monitorName = string.IsNullOrWhiteSpace(monitorName) ? "HostAvailabilityMonitor" : monitorName.Trim();
_environmentName = string.IsNullOrWhiteSpace(environmentName) ? "Unspecified Environment" : environmentName.Trim();
}
public bool IsEnabled
@@ -33,12 +37,7 @@ namespace HostAvailabilityMonitor.Notifications
return false;
}
if (result.IsSuccess && !_options.SendOnRecovery)
{
return false;
}
if (!result.IsSuccess && !_options.SendOnFailure)
if (result.IsSkipped)
{
return false;
}
@@ -50,29 +49,46 @@ namespace HostAvailabilityMonitor.Notifications
|| cooldown == TimeSpan.Zero
|| (nowUtc - previousState.LastNotificationUtc.Value) >= cooldown;
if (_options.OnlyOnStateChange)
if (IsRecoveryTransition(result, previousState))
{
if (previousState == null)
{
return !result.IsSuccess && cooldownPassed;
}
if (previousState.IsUp == result.IsSuccess)
{
return false;
}
return cooldownPassed;
return _options.SendOnRecovery && cooldownPassed;
}
if (result.IsSuccess && previousState == null)
if (result.IsSuccess)
{
return false;
}
if (!_options.SendOnFailure)
{
return false;
}
if (_options.OnlyOnStateChange)
{
return previousState == null || previousState.IsUp;
}
return cooldownPassed;
}
public bool ShouldSendDailySummary(MonitorStateMetadata metadata, DateTime nowLocal)
{
if (!IsEnabled || !_options.SendDailySummary)
{
return false;
}
var trigger = new TimeSpan(Math.Max(0, _options.DailySummaryHourLocal), Math.Max(0, _options.DailySummaryMinuteLocal), 0);
if (nowLocal.TimeOfDay < trigger)
{
return false;
}
var localDate = nowLocal.ToString("yyyy-MM-dd");
return metadata == null || !string.Equals(metadata.LastDailySummarySentLocalDate, localDate, StringComparison.Ordinal);
}
public async Task SendAsync(
IReadOnlyCollection<ProbeResult> failures,
IReadOnlyCollection<ProbeResult> recoveries,
@@ -93,13 +109,33 @@ namespace HostAvailabilityMonitor.Notifications
var subject = BuildSubject(failureCount, recoveryCount);
var body = BuildBody(failures, recoveries, currentState);
await SendMailAsync(subject, body, cancellationToken).ConfigureAwait(false);
}
public async Task SendDailySummaryAsync(DailySummaryEmailData summary, CancellationToken cancellationToken)
{
if (!IsEnabled || summary == null)
{
return;
}
var subject = BuildDailySummarySubject(summary);
var body = BuildDailySummaryBody(summary);
await SendMailAsync(subject, body, cancellationToken).ConfigureAwait(false);
}
private async Task SendMailAsync(string subject, string body, CancellationToken cancellationToken)
{
using (var message = new MailMessage())
{
message.From = new MailAddress(_options.From);
foreach (var recipient in _options.To.Where(x => !string.IsNullOrWhiteSpace(x)))
AddRecipients(message.To, _options.To);
AddRecipients(message.CC, _options.Cc);
AddRecipients(message.Bcc, _options.Bcc);
if (message.To.Count + message.CC.Count + message.Bcc.Count <= 0)
{
message.To.Add(recipient);
throw new InvalidOperationException("Es ist kein gueltiger Empfaenger in To, Cc oder Bcc konfiguriert.");
}
message.Subject = subject;
@@ -112,8 +148,9 @@ namespace HostAvailabilityMonitor.Notifications
{
client.EnableSsl = _options.UseSsl;
client.Timeout = Math.Max(1, _options.TimeoutSeconds) * 1000;
client.UseDefaultCredentials = _options.UseDefaultCredentials;
if (!string.IsNullOrWhiteSpace(_options.Username))
if (!_options.UseDefaultCredentials && !string.IsNullOrWhiteSpace(_options.Username))
{
client.Credentials = new NetworkCredential(_options.Username, _options.Password);
}
@@ -124,44 +161,77 @@ namespace HostAvailabilityMonitor.Notifications
}
}
private static void AddRecipients(MailAddressCollection collection, IEnumerable<string> recipients)
{
if (collection == null || recipients == null)
{
return;
}
foreach (var recipient in recipients.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase))
{
collection.Add(recipient);
}
}
private static bool IsRecoveryTransition(ProbeResult result, TargetState previousState)
{
return result != null
&& result.IsSuccess
&& previousState != null
&& !previousState.IsUp;
}
private string BuildSubject(int failureCount, int recoveryCount)
{
var prefix = string.IsNullOrWhiteSpace(_options.SubjectPrefix) ? "[HostAvailabilityMonitor]" : _options.SubjectPrefix.Trim();
var envPart = "[" + _environmentName + "]";
if (failureCount > 0 && recoveryCount > 0)
{
return string.Format("{0} {1} Ausfall/Ausfälle, {2} Recovery/Recoveries", _options.SubjectPrefix, failureCount, recoveryCount);
return string.Format("{0} {1} {2} Ausfall/Ausfälle, {3} Recovery/Recoveries", prefix, envPart, failureCount, recoveryCount);
}
if (failureCount > 0)
{
return string.Format("{0} {1} Ausfall/Ausfälle erkannt", _options.SubjectPrefix, failureCount);
return string.Format("{0} {1} {2} Ausfall/Ausfälle erkannt", prefix, envPart, failureCount);
}
return string.Format("{0} {1} Recovery/Recoveries erkannt", _options.SubjectPrefix, recoveryCount);
return string.Format("{0} {1} {2} Endpoint(s) wieder erreichbar", prefix, envPart, recoveryCount);
}
private string BuildDailySummarySubject(DailySummaryEmailData summary)
{
var prefix = string.IsNullOrWhiteSpace(_options.SubjectPrefix) ? "[HostAvailabilityMonitor]" : _options.SubjectPrefix.Trim();
var envPart = "[" + _environmentName + "]";
return string.Format("{0} {1} Tagesstatus {2:yyyy-MM-dd}", prefix, envPart, summary.GeneratedAtLocal.LocalDateTime.Date);
}
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(_monitorName);
sb.AppendLine("Umgebung / Environment: " + _environmentName);
sb.AppendLine("Server / Machine: " + Environment.MachineName);
sb.AppendLine("Zeitpunkt / Timestamp: " + 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))
sb.AppendLine("Fehlgeschlagene Checks / Failed checks:");
foreach (var failure in failures.OrderBy(x => x.ApplicationName, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
{
AppendResult(sb, failure, currentState);
AppendResult(sb, failure, currentState, "DOWN");
}
sb.AppendLine();
}
if (recoveries != null && recoveries.Count > 0)
{
sb.AppendLine("Wiederhergestellte Checks:");
foreach (var recovery in recoveries.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
sb.AppendLine("Wieder erreichbare Checks / Recovered checks:");
foreach (var recovery in recoveries.OrderBy(x => x.ApplicationName, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
{
AppendResult(sb, recovery, currentState);
AppendResult(sb, recovery, currentState, "DOWN -> UP");
}
sb.AppendLine();
}
@@ -169,12 +239,55 @@ namespace HostAvailabilityMonitor.Notifications
return sb.ToString();
}
private static void AppendResult(StringBuilder sb, ProbeResult result, IDictionary<string, TargetState> currentState)
private string BuildDailySummaryBody(DailySummaryEmailData summary)
{
sb.AppendLine("- Name: " + result.Name);
sb.AppendLine(" Endpoint: " + result.Endpoint);
sb.AppendLine(" Status: " + result.StatusText);
sb.AppendLine(" Detail: " + result.Detail);
var sb = new StringBuilder();
sb.AppendLine(_monitorName + " - Tagesstatus / Daily Summary");
sb.AppendLine("Umgebung / Environment: " + _environmentName);
sb.AppendLine("Server / Machine: " + Environment.MachineName);
sb.AppendLine("Zusammenfassung bis / Summary until: " + summary.GeneratedAtLocal.ToString("yyyy-MM-dd HH:mm:ss zzz"));
sb.AppendLine("Zeitfenster / Window: " + summary.WindowStartLocal.ToString("yyyy-MM-dd HH:mm:ss zzz") + " bis " + summary.GeneratedAtLocal.ToString("yyyy-MM-dd HH:mm:ss zzz"));
sb.AppendLine();
sb.AppendLine("Checks des heutigen Tages / Today's checks:");
sb.AppendLine("- Gesamt / Total: " + summary.ProbesTodayTotal);
sb.AppendLine("- Erfolgreich / Successful: " + summary.ProbesTodaySuccess);
sb.AppendLine("- Fehlgeschlagen / Failed: " + summary.ProbesTodayFailure);
sb.AppendLine("- Übersprungen / Skipped: " + summary.ProbesTodaySkipped);
sb.AppendLine();
sb.AppendLine("Aktueller Snapshot des letzten Laufs / Current snapshot of latest run:");
sb.AppendLine("- Targets gesamt / Total targets: " + summary.CurrentTargetsTotal);
sb.AppendLine("- Aktuell UP: " + summary.CurrentTargetsUp);
sb.AppendLine("- Aktuell DOWN: " + summary.CurrentTargetsDown);
sb.AppendLine("- Aktuell übersprungen / Currently skipped: " + summary.CurrentTargetsSkipped);
sb.AppendLine();
if (summary.CurrentFailures != null && summary.CurrentFailures.Count > 0)
{
sb.AppendLine("Aktuell nicht erreichbare Endpunkte / Currently unavailable endpoints:");
foreach (var failure in summary.CurrentFailures.OrderBy(x => x.ApplicationName, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
{
AppendResult(sb, failure, summary.CurrentState, "Aktuell DOWN / Currently DOWN");
}
}
else
{
sb.AppendLine("Aktuell sind keine Endpunkte down / There are currently no unavailable endpoints.");
}
return sb.ToString();
}
private static void AppendResult(StringBuilder sb, ProbeResult result, IDictionary<string, TargetState> currentState, string transitionText)
{
sb.AppendLine("- Anwendung / Application: " + Safe(result.ApplicationName));
sb.AppendLine(" Name: " + Safe(result.Name));
sb.AppendLine(" Endpoint: " + Safe(result.Endpoint));
sb.AppendLine(" Typ / Kind: " + result.Kind);
sb.AppendLine(" Transition: " + Safe(transitionText));
sb.AppendLine(" Status: " + Safe(result.StatusText));
sb.AppendLine(" Detail: " + Safe(result.Detail));
if (!string.IsNullOrWhiteSpace(result.Host))
{
sb.AppendLine(" Host: " + result.Host);
@@ -183,7 +296,11 @@ namespace HostAvailabilityMonitor.Notifications
{
sb.AppendLine(" Port: " + result.Port.Value);
}
sb.AppendLine(" Dauer(ms): " + Math.Max(0L, (long)result.Duration.TotalMilliseconds));
if (result.HttpStatusCode.HasValue)
{
sb.AppendLine(" HTTP Status: " + result.HttpStatusCode.Value);
}
sb.AppendLine(" Dauer(ms) / Duration(ms): " + Math.Max(0L, (long)result.Duration.TotalMilliseconds));
TargetState state;
if (currentState != null && currentState.TryGetValue(result.StateKey, out state) && state != null)
@@ -192,5 +309,26 @@ namespace HostAvailabilityMonitor.Notifications
sb.AppendLine(" LastChangedUtc: " + state.LastChangedUtc.ToString("yyyy-MM-dd HH:mm:ss") + "Z");
}
}
private static string Safe(string value)
{
return string.IsNullOrWhiteSpace(value) ? "n/a" : value;
}
}
public sealed class DailySummaryEmailData
{
public DateTimeOffset GeneratedAtLocal { get; set; }
public DateTimeOffset WindowStartLocal { get; set; }
public int ProbesTodayTotal { get; set; }
public int ProbesTodaySuccess { get; set; }
public int ProbesTodayFailure { get; set; }
public int ProbesTodaySkipped { get; set; }
public int CurrentTargetsTotal { get; set; }
public int CurrentTargetsUp { get; set; }
public int CurrentTargetsDown { get; set; }
public int CurrentTargetsSkipped { get; set; }
public List<ProbeResult> CurrentFailures { get; set; } = new List<ProbeResult>();
public IDictionary<string, TargetState> CurrentState { get; set; } = new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase);
}
}
+154 -18
View File
@@ -40,25 +40,28 @@ namespace HostAvailabilityMonitor
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 = new FileLogger(logDirectory, config.Logging.FilePrefix, config.Logging.RetentionDays, config.ApplicationName, config.EnvironmentName);
logger.CleanupOldFiles();
logger.Info(config.ApplicationName + " gestartet.");
logger.Info(config.ApplicationName + " gestartet. Umgebung=" + config.EnvironmentName + ", Server=" + Environment.MachineName + ".");
logger.Info("Konfiguration geladen: " + Path.GetFullPath(configPath));
eventLogger = new WindowsEventLogger(config.EventLog, logger);
eventLogger.TryInitialize();
eventLogger.Info(config.ApplicationName + " gestartet.", 1000);
eventLogger.Info(config.ApplicationName + " gestartet. Umgebung=" + config.EnvironmentName + ", Server=" + Environment.MachineName + ".", 1000);
Directory.CreateDirectory(stateDirectory);
var stateStore = new MonitorStateStore(stateFilePath);
MonitorStateDocument stateDocument;
Dictionary<string, TargetState> previousState;
try
{
previousState = stateStore.Load();
stateDocument = stateStore.LoadDocument();
previousState = stateDocument.TargetStates;
}
catch (Exception ex)
{
logger.Warn("Statusdatei konnte nicht geladen werden. Es wird mit leerem Zustand fortgefahren. " + ex.GetType().Name + ": " + ex.Message);
stateDocument = new MonitorStateDocument();
previousState = new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase);
}
@@ -69,30 +72,48 @@ namespace HostAvailabilityMonitor
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);
eventLogger.Summary(config.ApplicationName + " ausgeführt, aber es waren keine aktivierten Targets vorhanden. Umgebung=" + config.EnvironmentName + ".", 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))
foreach (var result in results.OrderBy(r => r.ApplicationName, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase))
{
logger.Probe(result);
TargetState previous;
previousState.TryGetValue(result.StateKey, out previous);
UpdateState(currentState, result, previous);
if (!result.IsSuccess)
var isRecovery = previous != null && !previous.IsUp && result.IsSuccess;
if (!result.IsSkipped)
{
eventLogger.Warning("Check fehlgeschlagen: " + result.Name + " | " + result.Endpoint + " | " + result.Detail, 2000);
UpdateState(currentState, result, previous);
}
var eventMessage = BuildEventMessage(config, result);
if (result.IsSkipped)
{
logger.Warn("Target uebersprungen. Umgebung=" + config.EnvironmentName + ", Anwendung=" + result.ApplicationName + ", Name=" + result.Name + ", Endpoint=" + result.Endpoint + ", Detail=" + result.Detail + ".");
eventLogger.Info("Target uebersprungen. " + eventMessage, 1100);
}
else if (!result.IsSuccess)
{
eventLogger.Warning(eventMessage, 2000);
}
else if (isRecovery)
{
logger.Info("Recovery erkannt. Umgebung=" + config.EnvironmentName + ", Anwendung=" + result.ApplicationName + ", Name=" + result.Name + ", Endpoint=" + result.Endpoint + ".");
eventLogger.Info("Recovery erkannt. " + eventMessage, 1201);
}
else
{
eventLogger.InfoIfSuccessEnabled("Check erfolgreich: " + result.Name + " | " + result.Endpoint + " | " + result.Detail, 1001);
eventLogger.InfoIfSuccessEnabled(eventMessage, 1001);
}
}
var notifier = new EmailNotifier(config.Email);
stateDocument.TargetStates = currentState;
stateDocument.Metadata = stateDocument.Metadata ?? new MonitorStateMetadata();
var notifier = new EmailNotifier(config.Email, config.ApplicationName, config.EnvironmentName);
var failuresToNotify = new List<ProbeResult>();
var recoveriesToNotify = new List<ProbeResult>();
var notifiedKeys = new List<string>();
@@ -134,22 +155,24 @@ namespace HostAvailabilityMonitor
}
}
logger.Info("Benachrichtigungs-E-Mail versendet. Failures=" + failuresToNotify.Count + ", Recoveries=" + recoveriesToNotify.Count);
logger.Info("Benachrichtigungs-E-Mail versendet. Umgebung=" + config.EnvironmentName + ", 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);
eventLogger.Error("E-Mail-Versand fehlgeschlagen. Umgebung=" + config.EnvironmentName + ". Fehler=" + ex.Message, 9001);
}
}
stateStore.Save(currentState);
await TrySendDailySummaryAsync(config, notifier, stateDocument, currentState, results, logDirectory, logger, eventLogger).ConfigureAwait(false);
stateStore.Save(stateDocument);
var total = results.Count;
var total = results.Count(r => !r.IsSkipped);
var ok = results.Count(r => r.IsSuccess);
var failed = total - ok;
var summary = "Lauf beendet. Gesamt=" + total + ", Erfolgreich=" + ok + ", Fehlgeschlagen=" + failed + ".";
var failed = results.Count(r => !r.IsSkipped && !r.IsSuccess);
var skipped = results.Count(r => r.IsSkipped);
var summary = config.ApplicationName + " Lauf beendet. Umgebung=" + config.EnvironmentName + ", Gesamt=" + total + ", Erfolgreich=" + ok + ", Fehlgeschlagen=" + failed + ", Uebersprungen=" + skipped + ".";
logger.Info(summary);
eventLogger.Summary(summary, failed > 0);
@@ -196,6 +219,100 @@ namespace HostAvailabilityMonitor
return results;
}
private static async Task TrySendDailySummaryAsync(
MonitorConfiguration config,
EmailNotifier notifier,
MonitorStateDocument stateDocument,
IDictionary<string, TargetState> currentState,
IReadOnlyCollection<ProbeResult> results,
string logDirectory,
FileLogger logger,
WindowsEventLogger eventLogger)
{
if (config == null || notifier == null || stateDocument == null)
{
return;
}
var nowLocal = DateTime.Now;
if (!notifier.ShouldSendDailySummary(stateDocument.Metadata, nowLocal))
{
return;
}
try
{
var counters = ReadTodayProbeCounters(logDirectory, config.Logging.FilePrefix, nowLocal, logger);
var latestResults = results == null ? new List<ProbeResult>() : results.ToList();
var currentFailures = latestResults.Where(r => !r.IsSkipped && !r.IsSuccess).ToList();
var summary = new DailySummaryEmailData
{
GeneratedAtLocal = DateTimeOffset.Now,
WindowStartLocal = new DateTimeOffset(nowLocal.Date),
ProbesTodaySuccess = counters.SuccessCount,
ProbesTodayFailure = counters.FailureCount,
ProbesTodaySkipped = counters.SkipCount,
ProbesTodayTotal = counters.SuccessCount + counters.FailureCount + counters.SkipCount,
CurrentTargetsTotal = latestResults.Count,
CurrentTargetsUp = latestResults.Count(r => r.IsSuccess),
CurrentTargetsDown = latestResults.Count(r => !r.IsSkipped && !r.IsSuccess),
CurrentTargetsSkipped = latestResults.Count(r => r.IsSkipped),
CurrentFailures = currentFailures,
CurrentState = currentState
};
await notifier.SendDailySummaryAsync(summary, CancellationToken.None).ConfigureAwait(false);
stateDocument.Metadata.LastDailySummarySentLocalDate = nowLocal.ToString("yyyy-MM-dd");
logger.Info("Tagesstatus-E-Mail versendet. Umgebung=" + config.EnvironmentName + ", Versandzeitpunkt=" + nowLocal.ToString("yyyy-MM-dd HH:mm:ss") + ", AktuellDown=" + summary.CurrentTargetsDown + ", AktuellUebersprungen=" + summary.CurrentTargetsSkipped + ", TagesChecks=" + summary.ProbesTodayTotal + ".");
eventLogger.Info("Tagesstatus-E-Mail versendet. Umgebung=" + config.EnvironmentName + ", AktuellDown=" + summary.CurrentTargetsDown + ", AktuellUebersprungen=" + summary.CurrentTargetsSkipped + ", TagesChecks=" + summary.ProbesTodayTotal + ".", 1300);
}
catch (Exception ex)
{
logger.Error("Tagesstatus-E-Mail konnte nicht versendet werden: " + ex.GetType().Name + ": " + ex.Message);
eventLogger.Error("Tagesstatus-E-Mail fehlgeschlagen. Umgebung=" + config.EnvironmentName + ". Fehler=" + ex.Message, 9002);
}
}
private static ProbeLogCounters ReadTodayProbeCounters(string logDirectory, string filePrefix, DateTime nowLocal, FileLogger logger)
{
var counters = new ProbeLogCounters();
try
{
var logPath = Path.Combine(logDirectory, filePrefix + "-" + nowLocal.ToString("yyyy-MM-dd") + ".log");
if (!File.Exists(logPath))
{
return counters;
}
foreach (var line in File.ReadLines(logPath))
{
if (line.IndexOf("Monitor=", StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
if (line.IndexOf("| SUCCESS |", StringComparison.OrdinalIgnoreCase) >= 0)
{
counters.SuccessCount++;
}
else if (line.IndexOf("| FAIL |", StringComparison.OrdinalIgnoreCase) >= 0)
{
counters.FailureCount++;
}
else if (line.IndexOf("| SKIP |", StringComparison.OrdinalIgnoreCase) >= 0)
{
counters.SkipCount++;
}
}
}
catch (Exception ex)
{
logger.Warn("Tageslog konnte für die Status-Mail nicht ausgewertet werden. Es wird mit 0 Tageszählern fortgefahren. " + ex.GetType().Name + ": " + ex.Message);
}
return counters;
}
private static void UpdateState(Dictionary<string, TargetState> currentState, ProbeResult result, TargetState previousState)
{
var nowUtc = DateTime.UtcNow;
@@ -247,5 +364,24 @@ namespace HostAvailabilityMonitor
return Path.GetFullPath(Path.Combine(baseDirectory, configuredPath));
}
private static string BuildEventMessage(MonitorConfiguration config, ProbeResult result)
{
return string.Format(
"Umgebung={0} | Anwendung={1} | Name={2} | Endpoint={3} | Status={4} | Detail={5}",
config.EnvironmentName,
result.ApplicationName,
result.Name,
result.Endpoint,
result.StatusText,
result.Detail);
}
private sealed class ProbeLogCounters
{
public int SuccessCount { get; set; }
public int FailureCount { get; set; }
public int SkipCount { get; set; }
}
}
}
@@ -2,7 +2,7 @@ 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: AssemblyDescription("Windows Server 2019 kompatibler Host Availability Monitor auf .NET Framework 4.7.2")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("JR IT Services")]
[assembly: AssemblyProduct("HostAvailabilityMonitor")]
@@ -14,26 +14,52 @@ namespace HostAvailabilityMonitor.State
_filePath = filePath;
}
public Dictionary<string, TargetState> Load()
public MonitorStateDocument LoadDocument()
{
if (!File.Exists(_filePath))
{
return new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase);
return new MonitorStateDocument();
}
var json = File.ReadAllText(_filePath);
var serializer = new JavaScriptSerializer();
var states = serializer.Deserialize<Dictionary<string, TargetState>>(json);
if (states == null)
try
{
return new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase);
var document = serializer.Deserialize<MonitorStateDocument>(json);
if (document != null && document.TargetStates != null)
{
return Normalize(document);
}
}
catch
{
// Fall back to legacy format.
}
return new Dictionary<string, TargetState>(states, StringComparer.OrdinalIgnoreCase);
try
{
var states = serializer.Deserialize<Dictionary<string, TargetState>>(json);
return new MonitorStateDocument
{
TargetStates = states == null
? new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, TargetState>(states, StringComparer.OrdinalIgnoreCase),
Metadata = new MonitorStateMetadata()
};
}
catch
{
return new MonitorStateDocument();
}
}
public void Save(Dictionary<string, TargetState> states)
public Dictionary<string, TargetState> Load()
{
return LoadDocument().TargetStates;
}
public void Save(MonitorStateDocument document)
{
var directory = Path.GetDirectoryName(_filePath);
if (string.IsNullOrWhiteSpace(directory))
@@ -43,8 +69,9 @@ namespace HostAvailabilityMonitor.State
Directory.CreateDirectory(directory);
var normalized = Normalize(document ?? new MonitorStateDocument());
var serializer = new JavaScriptSerializer();
var json = serializer.Serialize(states ?? new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase));
var json = serializer.Serialize(normalized);
var tempFile = _filePath + ".tmp";
File.WriteAllText(tempFile, json);
@@ -62,6 +89,42 @@ namespace HostAvailabilityMonitor.State
File.Move(tempFile, _filePath);
}
}
public void Save(Dictionary<string, TargetState> states)
{
Save(new MonitorStateDocument
{
TargetStates = states == null
? new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, TargetState>(states, StringComparer.OrdinalIgnoreCase),
Metadata = new MonitorStateMetadata()
});
}
private static MonitorStateDocument Normalize(MonitorStateDocument document)
{
if (document == null)
{
document = new MonitorStateDocument();
}
document.TargetStates = document.TargetStates == null
? new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, TargetState>(document.TargetStates, StringComparer.OrdinalIgnoreCase);
document.Metadata = document.Metadata ?? new MonitorStateMetadata();
return document;
}
}
public sealed class MonitorStateDocument
{
public Dictionary<string, TargetState> TargetStates { get; set; } = new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase);
public MonitorStateMetadata Metadata { get; set; } = new MonitorStateMetadata();
}
public sealed class MonitorStateMetadata
{
public string LastDailySummarySentLocalDate { get; set; } = string.Empty;
}
public sealed class TargetState
+23 -1
View File
@@ -1,5 +1,6 @@
{
"ApplicationName": "HostAvailabilityMonitor",
"EnvironmentName": "HIP Produktion",
"Runtime": {
"MaxConcurrency": 4,
"DefaultValidateUncPathAccess": false,
@@ -25,15 +26,24 @@
"SmtpHost": "smtp.example.local",
"SmtpPort": 25,
"UseSsl": false,
"UseDefaultCredentials": false,
"Username": "",
"Password": "",
"From": "monitor@example.local",
"To": [
"operations@example.local"
"operations@example.local",
"integration-oncall@example.local"
],
"Cc": [
"integration-leads@example.local"
],
"Bcc": [],
"SubjectPrefix": "[HostAvailabilityMonitor]",
"SendOnFailure": true,
"SendOnRecovery": true,
"SendDailySummary": true,
"DailySummaryHourLocal": 14,
"DailySummaryMinuteLocal": 0,
"OnlyOnStateChange": true,
"CooldownMinutes": 0,
"TimeoutSeconds": 30
@@ -41,6 +51,7 @@
"Targets": [
{
"Name": "Internes Fileshare 1",
"ApplicationName": "HIP FileTransfer",
"Endpoint": "\\\\internerserver1\\share1",
"TimeoutSeconds": 8,
"ValidateUncPathAccess": false,
@@ -48,6 +59,7 @@
},
{
"Name": "Externe Webseite",
"ApplicationName": "HIP WebPortal",
"Endpoint": "https://host1.de/url",
"TimeoutSeconds": 10,
"HttpMethod": "HEAD",
@@ -55,13 +67,23 @@
},
{
"Name": "SFTP Partner A",
"ApplicationName": "HIP Partneranbindung",
"Endpoint": "sftp://partner-sftp.example.local/inbound",
"TimeoutSeconds": 10,
"Port": 22,
"Enabled": true
},
{
"Name": "Network File URI",
"ApplicationName": "HIP Dokumentenservice",
"Endpoint": "file://fileserver01/share/export",
"TimeoutSeconds": 10,
"ValidateUncPathAccess": false,
"Enabled": true
},
{
"Name": "Custom TCP Service",
"ApplicationName": "HIP Adapterdienst",
"Endpoint": "tcp://host2.example.local:8443",
"TimeoutSeconds": 10,
"Enabled": true