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.
+532 -349
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 ### 1. HostAvailabilityMonitor
- externe HTTPS-Endpunkte
- SFTP-Gegenstellen Der Monitor prüft technische Erreichbarkeit und schreibt:
- TCP-Dienste
- Hosts per Ping - Rolling-Dateilog
- Windows Application Event Log
Der Betrieb erfolgt typischerweise über den **Windows Task Scheduler** alle 30 Minuten. - E-Mail-Benachrichtigungen bei Ausfall, Recovery und täglichem Status
- Statusdatei für Zustandswechsel, Cooldown-Logik und die tägliche Summary-Mail
## 2. Unterstützte Zieltypen
### 2. BizTalkMonitorConfigGenerator
### 2.1 UNC / SMB
Der Generator liest BizTalk-Artefakte aus und erzeugt daraus eine Monitor-Konfiguration.
Beispiel:
Ziel ist, dass die Ziel-Liste nicht manuell gepflegt werden muss, sondern direkt aus BizTalk stammt.
```text
\\internerserver1\share1 ---
```
## Repository-Struktur für Gitea
Verhalten:
```text
1. TCP-Check auf Port 445 (oder überschriebenen Port) .
2. Optional zusätzliche Pfadvalidierung mit `Directory.Exists(...)` / `File.Exists(...)` ├── .editorconfig
├── .gitignore
Hinweis: ├── Documentation.en.md
├── Dokumentation.md
- `ValidateUncPathAccess=false` ist robuster, wenn es nur um Erreichbarkeit geht. ├── HostAvailabilityMonitor.sln
- `ValidateUncPathAccess=true` ist strenger, weil Berechtigungen, Namensauflösung und tatsächlicher Share-Zugriff geprüft werden. ├── README.md
├── installers/
### 2.2 HTTP / HTTPS │ ├── README.md
│ ├── powershell/
Beispiele: │ │ ├── Install-BizTalkMonitorConfigGenerator.ps1
│ │ ├── Install-Both.ps1
```text │ │ ├── Install-HostAvailabilityMonitor.ps1
https://host1.de/url │ │ ├── Uninstall-BizTalkMonitorConfigGenerator.ps1
http://intranet.local/health │ │ └── Uninstall-HostAvailabilityMonitor.ps1
``` │ └── wix/
│ ├── BizTalkMonitorConfigGenerator.wxs
Verhalten: │ ├── HostAvailabilityMonitor.wxs
│ └── build-msi.ps1
- Standardmäßig `HEAD` ├── scripts/
- Fallback auf `GET`, wenn `HEAD` nicht unterstützt wird (`405` / `501`) oder der HEAD-Request fehlschlägt │ ├── build-release.ps1
- Jede empfangene HTTP-Antwort zählt als **netzwerktechnisch erreichbar** │ ├── generate-monitor-config.ps1
│ ├── install-generator-on-server.ps1
### 2.3 SFTP │ ├── install-on-server.ps1
│ ├── package-deployment.ps1
Beispiel: │ └── register-event-source.ps1
└── src/
```text ├── BizTalkMonitorConfigGenerator/
sftp://partner.example.local/inbound │ ├── App.config
``` │ ├── BizTalkMonitorConfigGenerator.csproj
│ ├── Program.cs
Verhalten: │ └── generator-settings.json
└── HostAvailabilityMonitor/
- TCP-Port-Prüfung, standardmäßig Port 22 ├── App.config
- kein Login, keine Dateioperation ├── HostAvailabilityMonitor.csproj
├── Program.cs
### 2.4 TCP └── appsettings.json
```
Beispiel:
Diese Struktur ist für ein normales Gitea-Repository ausgelegt. Build und Deployment können aus der Solution oder projektweise erfolgen.
```text
tcp://host2.example.local:8443 ---
```
## Unterstützte BizTalk-Quellen
Verhalten:
### Send Ports
- reiner Verbindungsaufbau auf dem Zielport
Es werden standardmäßig nur **gestartete Send Ports** übernommen.
### 2.5 ICMP
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.
Beispiel:
### Receive Locations
```text
icmp://server01 Es werden standardmäßig nur **aktivierte Receive Locations** übernommen.
```
---
Verhalten:
## Endpunkt-Normalisierung
- Ping über `System.Net.NetworkInformation.Ping`
Der Generator versucht, BizTalk-Adressen in Monitor-kompatible Endpunkte zu überführen.
### 2.6 Plain Hostnames
### Direkt unterstützt
Beispiele:
- `\\server\share`
```text - `file://...`
server01 - `http://...`
partner-gateway - `https://...`
``` - `ftp://...`
- `sftp://...`
Verhalten: - `tcp://...`
- `icmp://...`
- wenn `Port` gesetzt ist: TCP-Check - Plain Host / Host:Port (best effort)
- wenn kein `Port` gesetzt ist: ICMP-Check
### Spezielle Behandlung
## 3. Konfigurationsmodell
- `net.tcp://host:port/...` wird auf `tcp://host:port/...` umgeschrieben
Die Konfiguration erfolgt in `appsettings.json`. - lokale Dateipfade wie `C:\Drop\In` werden nur übernommen, wenn `IncludeLocalFilePaths=true`
- `net.pipe://...` wird übersprungen, weil der Monitor diesen Typ nicht prüft
### 3.1 Runtime
---
| Feld | Bedeutung |
|---|---| ## HostAvailabilityMonitor: Mail- und Statusverhalten
| `MaxConcurrency` | Maximale parallele Checks |
| `DefaultValidateUncPathAccess` | Default für UNC-Pfadzugriffsprüfung | Der Monitor kann drei Arten von Mail-Benachrichtigungen versenden:
| `NonZeroExitCodeOnAnyFailure` | Exit Code 2 bei mindestens einem fehlgeschlagenen Check |
| `NonZeroExitCodeOnExecutionError` | Exit Code 1 bei technischen Fehlern | 1. **Failure-Mail**: wenn ein Endpoint von erreichbar auf nicht erreichbar wechselt
| `StateDirectory` | Verzeichnis für Statusdatei | 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**
### 3.2 Logging
Die Tagesstatus-Mail wird absichtlich **nicht** über einen zweiten Task oder separaten Scheduler innerhalb der Anwendung realisiert.
| Feld | Bedeutung |
|---|---| Stattdessen gilt:
| `LogDirectory` | Verzeichnis der Logdateien |
| `FilePrefix` | Präfix des Tageslogs | - der normale geplante Task startet die Anwendung wie bisher
| `RetentionDays` | Anzahl Tage zur Aufbewahrung | - 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
### 3.3 EventLog - pro Kalendertag wird **maximal eine** Tagesstatus-Mail gesendet
- nur ein **erfolgreich versendeter** Tagesstatus wird im State gespeichert
| Feld | Bedeutung |
|---|---| Das ist robust für den Betrieb auf BizTalk-Servern, weil keine zusätzliche Orchestrierung erforderlich ist.
| `Enabled` | Aktiviert Event Log |
| `LogName` | Standard: `Application` | ---
| `Source` | Event Source |
| `CreateSourceIfMissing` | Optionaler Versuch, die Source anzulegen | ## Konfiguration des Monitors im Detail
| `WriteSuccessEntries` | Erfolgreiche Einzelchecks schreiben |
| `WriteSummaryEntries` | Laufzusammenfassung schreiben | Datei: `src\HostAvailabilityMonitor\appsettings.json`
### 3.4 Email ### Root-Felder
| Feld | Bedeutung | - `ApplicationName`: Name des Monitors
|---|---| - `EnvironmentName`: Umgebungsname wie `HIP Produktion`
| `Enabled` | Aktiviert SMTP-Benachrichtigung | - `Runtime`: Laufzeitverhalten
| `SmtpHost` / `SmtpPort` | SMTP-Ziel | - `Logging`: Dateilog-Konfiguration
| `UseSsl` | SSL/TLS für SMTP | - `EventLog`: Windows Event Log Konfiguration
| `Username` / `Password` | Optionale Credentials | - `Email`: SMTP- und Benachrichtigungskonfiguration
| `From` | Absender | - `Targets`: die zu prüfenden Endpunkte
| `To` | Empfängerliste |
| `SubjectPrefix` | Präfix im Betreff | ### Email
| `SendOnFailure` | Mail bei Fehler |
| `SendOnRecovery` | Mail bei Wiederherstellung | Wichtige Felder im Abschnitt `Email`:
| `OnlyOnStateChange` | Nur bei Statuswechsel |
| `CooldownMinutes` | Minimaler Abstand zwischen Benachrichtigungen | - `Enabled`: Mailversand insgesamt ein/aus
| `TimeoutSeconds` | SMTP-Timeout | - `SmtpHost`, `SmtpPort`, `UseSsl`, `UseDefaultCredentials`, `Username`, `Password`
- `From`, `To`, `Cc`, `Bcc`, `SubjectPrefix`
### 3.5 Targets - `SendOnFailure`: Mail bei Ausfall senden
- `SendOnRecovery`: Mail bei Recovery senden
| Feld | Bedeutung | - `SendDailySummary`: tägliche Status-Mail senden
|---|---| - `DailySummaryHourLocal`: lokale Stunde des Servers, z. B. `14`
| `Name` | Anzeigename | - `DailySummaryMinuteLocal`: lokale Minute des Servers, z. B. `0`
| `Endpoint` | UNC, URL, SFTP, TCP, ICMP oder Hostname | - `OnlyOnStateChange`: bei Failure-/Recovery-Mails nur auf Zustandswechsel reagieren
| `TimeoutSeconds` | Timeout je Ziel | - `CooldownMinutes`: Cooldown für wiederholte Failure-Mails, wenn `OnlyOnStateChange=false`
| `Port` | Optionaler Zielport | - `TimeoutSeconds`: SMTP-Timeout
| `ValidateUncPathAccess` | Optional pro UNC-Ziel |
| `HttpMethod` | `HEAD` oder `GET` | Hinweise zu Empfaengern:
| `Enabled` | Ziel aktiv/inaktiv |
- `To`, `Cc` und `Bcc` sind jeweils JSON-Arrays von Mailadressen.
## 4. Logging-Verhalten - Mehrere Empfaenger werden ueber mehrere Array-Eintraege konfiguriert.
- Eine einzelne Zeichenkette mit Semikolon-Liste ist nicht vorgesehen.
### 4.1 Dateilog - Mindestens ein Empfaenger in `To`, `Cc` oder `Bcc` muss vorhanden sein, wenn `Email.Enabled=true`.
Pro Tag entsteht genau eine Logdatei: ### Beispiel für den Email-Block
```text ```json
logs\host-availability-monitor-2026-04-16.log "Email": {
``` "Enabled": true,
"SmtpHost": "smtp.example.local",
Format: "SmtpPort": 25,
"UseSsl": false,
```text "UseDefaultCredentials": false,
2026-04-16 08:00:00.123 +02:00 | INFO | Lauf gestartet. "Username": "",
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 "Password": "",
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 "From": "monitor@example.local",
``` "To": [
"operations@example.local",
### 4.2 Retention "integration-oncall@example.local"
],
Beim Start eines Laufs werden alte Tageslogs bereinigt. "Cc": [
"integration-leads@example.local"
Standard: ],
"Bcc": [],
- aktueller Tag + 4 vorherige Tage bleiben erhalten "SubjectPrefix": "[HostAvailabilityMonitor]",
- ältere Logs werden gelöscht "SendOnFailure": true,
"SendOnRecovery": true,
## 5. Event Log "SendDailySummary": true,
"DailySummaryHourLocal": 14,
Es wird in das Windows Application Event Log geschrieben. "DailySummaryMinuteLocal": 0,
"OnlyOnStateChange": true,
Typische Events: "CooldownMinutes": 0,
"TimeoutSeconds": 30
- Fehler bei Einzelchecks: Warning }
- technische Fehler: Error ```
- Laufzusammenfassung: Information oder Warning
### Beispiel fuer mehrere Empfaenger
Wichtig:
```json
- Das Erzeugen einer Event Source benötigt erhöhte Rechte. "Email": {
- Im produktiven Betrieb sollte die Source einmalig per Skript registriert werden. "Enabled": true,
- Falls Event Log nicht verfügbar ist, fällt die Anwendung auf Dateilogging zurück und läuft weiter. "From": "monitor@example.local",
"To": [
## 6. Benachrichtigungslogik "operations@example.local",
"integration-oncall@example.local"
Die Anwendung speichert je Ziel einen Status in `state\monitor-state.json`. ],
"Cc": [
Gespeicherte Informationen pro Ziel: "integration-leads@example.local"
],
- `IsUp` "Bcc": [
- `ConsecutiveFailures` "audit@example.local"
- `LastCheckedUtc` ]
- `LastChangedUtc` }
- `LastNotificationUtc` ```
- `LastDetail`
### Inhalt der Tagesstatus-Mail
### 6.1 Benachrichtigung bei Fehler
Die Tagesstatus-Mail enthält:
Es wird benachrichtigt, wenn:
- Monitorname
- `Email.Enabled=true` - Umgebung
- `SendOnFailure=true` - Servername
- das Ziel aktuell fehlschlägt - Zeitfenster von Mitternacht bis Versandzeitpunkt
- und je nach Einstellung entweder - Anzahl heutiger erfolgreicher und fehlgeschlagener Checks laut Tageslog
- ein Statuswechsel vorliegt oder - aktueller Snapshot des letzten Monitor-Laufs
- der Cooldown abgelaufen ist - Liste aktuell DOWN befindlicher Endpunkte inklusive:
- Anwendung
### 6.2 Benachrichtigung bei Recovery - Name
- Endpoint
Es wird benachrichtigt, wenn: - Typ
- Status
- `Email.Enabled=true` - Detail
- `SendOnRecovery=true` - Host/Port/HTTP-Status, soweit vorhanden
- das Ziel aktuell erfolgreich ist
- und der vorherige bekannte Zustand `down` war Hinweis:
### 6.3 Anti-Spam 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.
Mit `OnlyOnStateChange=true` werden bei Dauerfehlern nicht bei jedem Lauf erneut Mails versendet. ---
Mit `CooldownMinutes > 0` kann zusätzlich ein Zeitfenster definiert werden. ## Generator-Konfiguration im Detail
## 7. Exit Codes Datei: `generator-settings.json`
| Exit Code | Bedeutung | ### Root-Felder
|---|---|
| `0` | Lauf erfolgreich abgeschlossen | - `GeneratorName`: Anzeigename für Logs/Eventlog des Generators
| `1` | technischer Fehler / Konfigurations- oder Laufzeitproblem | - `EnvironmentName`: Umgebung wie `HIP Produktion`
| `2` | mindestens ein Ziel fehlgeschlagen und `NonZeroExitCodeOnAnyFailure=true` | - `Logging`: Logging des Generators
- `EventLog`: Eventlog des Generators
## 8. Task Scheduler Empfehlung - `BizTalk`: Discovery-Regeln
- `Output`: Zielpfad und Erzeugungsregeln
### 8.1 Allgemein - `GeneratedMonitorConfig`: Vorlage für die endgültige Monitor-JSON
- Trigger: alle 30 Minuten ### BizTalk
- Benutzerkonto: dediziertes Servicekonto, falls UNC-Zugriffe Berechtigungen benötigen
- "Start in": Installationsverzeichnis setzen - `ConnectionString`: Verbindung zur BizTalkMgmtDb
- `IncludeSendPorts`: Send Ports auslesen
### 8.2 Beispiel - `IncludeReceiveLocations`: Receive Locations auslesen
- `OnlyStartedSendPorts`: nur aktive Send Ports
**Aktion** - `OnlyEnabledReceiveLocations`: nur aktive Receive Locations
- `IncludeSecondaryTransportOnSendPorts`: sekundäre Send-Port-Transporte zusätzlich aufnehmen
- Program/script: - SMTP-Sendports bzw. SMTP-Secondary-Transports werden vom Generator automatisch übersprungen
- `IncludeDynamicSendPorts`: dynamische Send Ports aufnehmen
```text - `IncludeLocalFilePaths`: lokale Pfade aufnehmen
C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe - `IgnoreSystemApplications`: Systemanwendungen ignorieren
``` - `IgnoreApplications`: Anwendungen per Pattern ausblenden
- `IgnoreArtifactNamePatterns`: Ports/Locations per Pattern ignorieren
- Add arguments: - `ApplicationMappings`: Mapping BizTalk-App -> fachliche App
- `ArtifactMappings`: Mapping einzelner Artefakte -> fachliche App
```text
C:\Tools\HostAvailabilityMonitor\appsettings.json ### Output
```
- `Path`: Pfad der erzeugten Monitor-JSON
- Start in: - `OverwriteExisting`: bestehende Datei überschreiben
- `FailIfNoTargetsFound`: ExitCode 2, wenn keine Targets gefunden wurden
```text - `TargetTimeoutSeconds`: Standard-Timeout pro generiertem Target
C:\Tools\HostAvailabilityMonitor - `TargetEnabled`: `Enabled`-Default in jedem Target
``` - `DefaultHttpMethod`: Standard für generierte HTTP/HTTPS-Ziele
## 9. Betriebshinweise ### GeneratedMonitorConfig
### 9.1 UNC-Prüfung Dies ist die Vorlage für den späteren Monitor.
Wenn echte Share-Verfügbarkeit geprüft werden soll: Hier werden gepflegt:
- Task mit geeignetem Servicekonto ausführen - `ApplicationName` des Monitors
- `ValidateUncPathAccess=true` für das betreffende Ziel setzen - `EnvironmentName`
- `Runtime`
Wenn nur der Zielserver bzw. SMB-Port relevant ist: - `Logging`
- `EventLog`
- `ValidateUncPathAccess=false` belassen - `Email`
### 9.2 HTTP/HTTPS `Targets` wird vom Generator zur Laufzeit neu erzeugt.
Die Anwendung prüft Erreichbarkeit, nicht Business-Logik. Für Applikations-Health kann eine dedizierte Health-URL sinnvoller sein. Wichtig:
### 9.3 SFTP 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.
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 ## Generator verwenden
Die Anwendung aktiviert zusätzlich gängige SecurityProtocol-Werte, damit HTTPS-Ziele in älteren .NET-Framework-Umgebungen robuster funktionieren. ### Variante A: direkt per EXE
## 10. Build und Deployment ```powershell
C:\Tools\BizTalkMonitorConfigGenerator\BizTalkMonitorConfigGenerator.exe C:\Tools\BizTalkMonitorConfigGenerator\generator-settings.json
### 10.1 Voraussetzungen ```
- Windows Build Host ### Variante B: per Wrapper-Skript
- Visual Studio oder Build Tools mit MSBuild
- .NET Framework 4.8 Targeting/Developer Pack ```powershell
.\scripts\generate-monitor-config.ps1 -InstallPath C:\Tools\BizTalkMonitorConfigGenerator
### 10.2 Build ```
```powershell ### Erwartetes Ergebnis
msbuild .\HostAvailabilityMonitor.sln /p:Configuration=Release /p:Platform="Any CPU"
``` Nach dem Lauf sollte vorhanden sein:
### 10.3 Artefakte - generierte Datei, z. B. `C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json`
- Logdatei des Generators unter `logs\`
Relevant für Deployment: - Eventlog-Einträge im `Application`-Log
- `HostAvailabilityMonitor.exe` ### Monitor danach starten
- `HostAvailabilityMonitor.exe.config`
- `appsettings.json` ```powershell
- weitere referenzierte .NET-Assemblies aus `bin\Release` C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe C:\Tools\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json
```
## 11. Sicherheits- und Resilienz-Aspekte
---
Die Anwendung ist bewusst defensiv aufgebaut:
## Beispielablauf auf einem BizTalk-Server
- Fehler einzelner Checks stoppen nicht den Gesamtlauf
- Event Log Ausfälle führen nicht zum Abbruch ### 1. Generator installieren
- Statusdatei wird atomar aktualisiert
- Log-Bereinigung blockiert den Lauf nicht ```powershell
- Mailversand markiert Benachrichtigungen nur bei echtem Erfolg .\scripts\install-generator-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\BizTalkMonitorConfigGenerator `
## 12. Empfohlene nächste Ausbaustufen -InstallPath C:\Tools\BizTalkMonitorConfigGenerator `
-RegisterEventSource
Optional könnte das Projekt später erweitert werden um: ```
- CSV- oder HTML-Report pro Lauf ### 2. Generator-Konfiguration anpassen
- pro Target eigene Empfängergruppen
- zusätzliche Protokolle wie FTP oder LDAP-Portchecks Datei bearbeiten:
- Performance Counter oder Windows Service statt Task Scheduler
- Gitea CI-Workflow für Windows Runner ```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.
+27 -21
View File
@@ -1,21 +1,27 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59 VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HostAvailabilityMonitor", "src\HostAvailabilityMonitor\HostAvailabilityMonitor.csproj", "{0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HostAvailabilityMonitor", "src\HostAvailabilityMonitor\HostAvailabilityMonitor.csproj", "{0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}"
EndProject EndProject
Global Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkMonitorConfigGenerator", "src\BizTalkMonitorConfigGenerator\BizTalkMonitorConfigGenerator.csproj", "{E8C93E45-DA2B-4E30-8891-637A2C7FAE0B}"
GlobalSection(SolutionConfigurationPlatforms) = preSolution EndProject
Debug|Any CPU = Debug|Any CPU Global
Release|Any CPU = Release|Any CPU GlobalSection(SolutionConfigurationPlatforms) = preSolution
EndGlobalSection Debug|Any CPU = Debug|Any CPU
GlobalSection(ProjectConfigurationPlatforms) = postSolution Release|Any CPU = Release|Any CPU
{0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU EndGlobalSection
{0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Debug|Any CPU.Build.0 = Debug|Any CPU GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Release|Any CPU.ActiveCfg = Release|Any CPU {0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Release|Any CPU.Build.0 = Release|Any CPU {0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Debug|Any CPU.Build.0 = Debug|Any CPU
EndGlobalSection {0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Release|Any CPU.ActiveCfg = Release|Any CPU
GlobalSection(SolutionProperties) = preSolution {0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}.Release|Any CPU.Build.0 = Release|Any CPU
HideSolutionNode = FALSE {E8C93E45-DA2B-4E30-8891-637A2C7FAE0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
EndGlobalSection {E8C93E45-DA2B-4E30-8891-637A2C7FAE0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
EndGlobal {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
EndGlobalSection
EndGlobal
+400 -213
View File
@@ -1,213 +1,400 @@
# 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
- ein tägliches Rolling-Logfile - schreibt Rolling-Logs, Windows Application Event Log und E-Mails bei DOWN, Recovery und täglichem Status
- Einträge ins **Windows Application Event Log**
- Statusinformationen in eine lokale Statusdatei 2. **BizTalkMonitorConfigGenerator**
- Benachrichtigungs-E-Mails bei Fehlern und optional bei Recovery - liest aktive **BizTalk Send Ports** und aktivierte **Receive Locations** aus
- erzeugt daraus automatisch die JSON-Konfiguration, die der `HostAvailabilityMonitor` direkt verwenden kann
## Warum .NET Framework 4.8? - übernimmt Umgebung, Default-Werte und fachliche Anwendungszuordnung
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. ## Zielbild
## Features Der typische Betrieb auf einer BizTalk-Maschine ist:
- **Zieltypen** 1. Der **Generator** läuft mit Admin-Rechten auf dem jeweiligen BizTalk-Server.
- UNC / SMB (`\\server\share`) 2. Er liest die aktiven BizTalk-Artefakte aus.
- HTTP / HTTPS (`http://...`, `https://...`) 3. Er erzeugt eine vollständige Monitor-Konfiguration im JSON-Format.
- SFTP (netzwerktechnisch per TCP-Port 22 oder konfiguriertem Port) 4. Der **Monitor** läuft anschließend mit genau dieser JSON-Datei.
- TCP (`tcp://server:8443`) 5. Bei DOWN und bei `DOWN -> UP` gehen Mails raus.
- ICMP (`icmp://server`) 6. Zusätzlich geht **einmal pro Tag** eine **Status-Mail** mit Tageszusammenfassung raus, standardmäßig um **14:00 Uhr lokal**.
- Optional auch **Plain Hostnames**:
- mit `Port` => TCP-Check ## Gitea-kompatible Repository-Struktur
- ohne `Port` => ICMP-Check
- **Tägliche Logrotation** über Dateiname `host-availability-monitor-yyyy-MM-dd.log` ```text
- **Log-Retention** konfigurierbar, Standard: 5 Tage .
- **Event Log** in `Application` ├── .editorconfig
- **E-Mail-Benachrichtigung** bei Ausfall und optional bei Recovery ├── .gitignore
- **Cooldown / State Change Logik** gegen Mail-Spam ├── Documentation.en.md
- **Resiliente Dateiverarbeitung** mit atomarem Schreiben der Statusdatei ├── Dokumentation.md
- Für **Task Scheduler** geeignet ├── HostAvailabilityMonitor.sln
├── README.md
## Projektstruktur ├── installers/
│ ├── README.md
```text │ ├── powershell/
host-availability-monitor-net48/ │ │ ├── Install-BizTalkMonitorConfigGenerator.ps1
├── HostAvailabilityMonitor.sln │ │ ├── Install-Both.ps1
├── README.md │ │ ├── Install-HostAvailabilityMonitor.ps1
├── Dokumentation.md │ │ ├── Uninstall-BizTalkMonitorConfigGenerator.ps1
├── .gitignore │ │ └── Uninstall-HostAvailabilityMonitor.ps1
├── .editorconfig │ └── wix/
├── scripts/ │ ├── BizTalkMonitorConfigGenerator.wxs
├── build-release.ps1 ├── HostAvailabilityMonitor.wxs
└── register-event-source.ps1 └── build-msi.ps1
── src/ ── scripts/
── HostAvailabilityMonitor/ ── build-release.ps1
├── App.config ├── generate-monitor-config.ps1
├── appsettings.json ├── install-generator-on-server.ps1
├── HostAvailabilityMonitor.csproj ├── install-on-server.ps1
├── Program.cs ├── package-deployment.ps1
── Configuration/ ── register-event-source.ps1
├── Logging/ └── src/
├── Monitoring/ ├── BizTalkMonitorConfigGenerator/
├── Notifications/ ├── App.config
├── Properties/ ├── BizTalkMonitorConfigGenerator.csproj
── State/ ── Program.cs
``` │ └── generator-settings.json
└── HostAvailabilityMonitor/
## Build ├── App.config
├── HostAvailabilityMonitor.csproj
### Visual Studio ├── Program.cs
└── appsettings.json
- Solution `HostAvailabilityMonitor.sln` öffnen ```
- Configuration `Release`
- Build starten Diese Struktur ist bewusst einfach gehalten, damit das Repository sauber in **Gitea** eingecheckt, gebaut und auf Windows-Servern ausgerollt werden kann.
### MSBuild ## Lösung / Solution
```powershell - `src/HostAvailabilityMonitor/`
msbuild .\HostAvailabilityMonitor.sln /p:Configuration=Release /p:Platform="Any CPU" - `src/BizTalkMonitorConfigGenerator/`
``` - `scripts/build-release.ps1`
- `scripts/install-on-server.ps1`
Alternativ liegt das Skript `scripts/build-release.ps1` bei. - `scripts/install-generator-on-server.ps1`
- `scripts/generate-monitor-config.ps1`
## Deployment - `scripts/package-deployment.ps1`
- `installers/powershell/*`
1. Projekt im Release-Modus bauen - `installers/wix/*`
2. Den Inhalt von `src\HostAvailabilityMonitor\bin\Release\` auf den Server kopieren
3. `appsettings.json` anpassen ## Technische Eckpunkte
4. Optional Event Source registrieren:
### HostAvailabilityMonitor
```powershell
.\scripts\register-event-source.ps1 -Source "HostAvailabilityMonitor" -LogName "Application" - Task-Scheduler-tauglich
``` - tägliche Logrotation
- Log-Retention standardmäßig 5 Tage
5. Task Scheduler anlegen, z. B. alle 30 Minuten - Windows Application Event Log
- SMTP-Mails bei DOWN, Recovery (`DOWN -> UP`) und täglichem Status
## Konfiguration - 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**
Die Anwendung nutzt **`appsettings.json`** als Anwendungs-Konfiguration. - State-Datei gegen Mail-Spam und für die Einmal-pro-Tag-Logik der Status-Mail
- `ApplicationName` pro Target
Wichtig für .NET Framework 4.8 in dieser Umsetzung: - `EnvironmentName` global
- JSON muss **strict valid** sein ### BizTalkMonitorConfigGenerator
- keine Kommentare
- keine trailing commas - liest BizTalk über **Microsoft.BizTalk.ExplorerOM**
- berücksichtigt standardmäßig nur:
### Beispiel - **gestartete Send Ports**
- **aktivierte Receive Locations**
```json - unterstützt optional:
{ - Secondary Transport von Send Ports
"ApplicationName": "HostAvailabilityMonitor", - lokale Pfade
"Runtime": { - Mapping von BizTalk-Anwendungsnamen auf fachliche `ApplicationName`-Werte
"MaxConcurrency": 4, - schreibt Rolling-Log und Event Log
"DefaultValidateUncPathAccess": false, - schreibt die Ziel-JSON **atomar** über Temp-Datei + Rename
"NonZeroExitCodeOnAnyFailure": false,
"NonZeroExitCodeOnExecutionError": true, ## Build
"StateDirectory": "state"
}, ### Voraussetzung
"Logging": {
"LogDirectory": "logs", 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.
"FilePrefix": "host-availability-monitor",
"RetentionDays": 5 ### MSBuild
},
"EventLog": { ```powershell
"Enabled": true, msbuild .\HostAvailabilityMonitor.sln /p:Configuration=Release /p:Platform="Any CPU"
"LogName": "Application", ```
"Source": "HostAvailabilityMonitor",
"CreateSourceIfMissing": false, ### Build-Skript
"WriteSuccessEntries": false,
"WriteSummaryEntries": true ```powershell
}, .\scripts\build-release.ps1
"Email": { ```
"Enabled": false,
"SmtpHost": "smtp.example.local", Das Skript erstellt anschließend typischerweise:
"SmtpPort": 25,
"UseSsl": false, - `artifacts\publish\net472\HostAvailabilityMonitor\`
"Username": "", - `artifacts\publish\net472\BizTalkMonitorConfigGenerator\`
"Password": "",
"From": "monitor@example.local", Optional direkt mit Deployment-Paket:
"To": ["ops@example.local"],
"SubjectPrefix": "[HostAvailabilityMonitor]", ```powershell
"SendOnFailure": true, .\scripts\build-release.ps1 -CreateDeploymentPackage
"SendOnRecovery": true, ```
"OnlyOnStateChange": true,
"CooldownMinutes": 0, Oder separat:
"TimeoutSeconds": 30
}, ```powershell
"Targets": [ .\scripts\package-deployment.ps1
{ ```
"Name": "Internes Fileshare 1",
"Endpoint": "\\\\internerserver1\\share1", Danach liegt ein verteilerfertiges ZIP unter `artifacts\deploy\net472\`.
"TimeoutSeconds": 8,
"ValidateUncPathAccess": false,
"Enabled": true ## Installer / Setup-Optionen
},
{ Es gibt jetzt **zwei Installer-Wege**:
"Name": "Externe Webseite",
"Endpoint": "https://host1.de/url", ### 1. PowerShell-Installer (empfohlen fuer BizTalk/Windows Server)
"TimeoutSeconds": 10,
"HttpMethod": "HEAD", Diese Variante ist in restriktiven Serverumgebungen meist am praktikabelsten.
"Enabled": true
}, Wichtige Dateien:
{
"Name": "SFTP Partner A", - `installers\powershell\Install-HostAvailabilityMonitor.ps1`
"Endpoint": "sftp://partner.example.local/inbound", - `installers\powershell\Install-BizTalkMonitorConfigGenerator.ps1`
"TimeoutSeconds": 10, - `installers\powershell\Install-Both.ps1`
"Port": 22, - `installers\powershell\Uninstall-HostAvailabilityMonitor.ps1`
"Enabled": true - `installers\powershell\Uninstall-BizTalkMonitorConfigGenerator.ps1`
},
{ Beispiel fuer ein gemeinsames Setup aus dem Deployment-ZIP:
"Name": "Custom TCP Service",
"Endpoint": "tcp://host2.example.local:8443", ```powershell
"TimeoutSeconds": 10, .\installers\powershell\Install-Both.ps1 `
"Enabled": true -PackageRoot . `
} -BaseInstallPath "C:\Program Files\JR IT Services\BizTalk Endpoint Monitor" `
] -BaseConfigPath "C:\ProgramData\JR IT Services\BizTalk Endpoint Monitor" `
} -RegisterEventSources
``` ```
## Task Scheduler ### 2. WiX-MSI-Quellen (optional)
**Program/script** Fuer beide Programme liegen **WiX-Quellen** bei:
```text - `installers\wix\HostAvailabilityMonitor.wxs`
C:\Tools\HostAvailabilityMonitor\HostAvailabilityMonitor.exe - `installers\wix\BizTalkMonitorConfigGenerator.wxs`
``` - `installers\wix\build-msi.ps1`
**Start in** Damit kannst du auf einem Windows-Buildsystem mit WiX Toolset v3 echte MSI-Pakete erzeugen.
```text Beispiel:
C:\Tools\HostAvailabilityMonitor
``` ```powershell
.\installers\wix\build-msi.ps1 `
**Optional arguments** -PublishRoot .\artifacts\publish\net472 `
-OutputRoot .\artifacts\msi
```text ```
C:\Tools\HostAvailabilityMonitor\appsettings.json
``` 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.
## Logging ## Installation auf dem Server
- Dateilog: `logs\host-availability-monitor-yyyy-MM-dd.log` ### Monitor
- Retention: Standard 5 Tage
- Event Log: `Application` ```powershell
- State-Datei: `state\monitor-state.json` .\scripts\install-on-server.ps1 `
-SourcePath .\artifacts\publish\net472\HostAvailabilityMonitor `
## Benachrichtigung -InstallPath C:\Tools\HostAvailabilityMonitor `
-RegisterEventSource
Benachrichtigung wird nur als erfolgreich markiert, wenn der SMTP-Versand wirklich erfolgreich war. Dadurch bleibt die Cooldown-/State-Change-Logik konsistent. ```
## Hinweise ### Generator
- Für **UNC-Pfade** kann optional zusätzlich der Pfadzugriff validiert werden. Standardmäßig wird nur die **SMB-Erreichbarkeit** über TCP 445 geprüft. ```powershell
- Für **HTTP/HTTPS** gilt jede empfangene HTTP-Antwort als netzwerktechnisch erreichbar, auch `404` oder `500`. .\scripts\install-generator-on-server.ps1 `
- Für **SFTP** wird bewusst nur die **Port-Erreichbarkeit** geprüft, kein Login und kein Dateizugriff. -SourcePath .\artifacts\publish\net472\BizTalkMonitorConfigGenerator `
- Das Anlegen einer neuen Event Source erfordert Administratorrechte. Deshalb ist dafür ein separates Installationsskript beigefügt. -InstallPath C:\Tools\BizTalkMonitorConfigGenerator `
-RegisterEventSource
## Weiterführend ```
Details zu Logging, Exit Codes, Fehlerfällen und Betriebsmodell stehen in [Dokumentation.md](Dokumentation.md). ## Monitor-Konfiguration für tägliche Status-Mail
In `appsettings.json` bzw. in `GeneratedMonitorConfig.Email` des Generators stehen jetzt zusätzlich diese Felder:
```json
"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"
]
}
```
Wichtig:
- 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
src\BizTalkMonitorConfigGenerator\generator-settings.json
```
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\BizTalkMonitorConfigGenerator\generated-monitor-appsettings.json
```
Zusätzlich prüfen:
- 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
```
## Praxisbeispiel für den Betrieb auf der BizTalk-Maschine
### Task 1: Generator
- 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
### 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
- Fuer .NET Framework 4.7.2 ist der Build-Output jetzt unter `artifacts\publish\net472\` vorgesehen.
- 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.
## 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"
}
+60 -47
View File
@@ -1,47 +1,60 @@
param( param(
[string]$Configuration = "Release" [string]$Configuration = "Release",
) [switch]$CreateDeploymentPackage
)
$ErrorActionPreference = "Stop"
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$solutionPath = Join-Path $repoRoot "HostAvailabilityMonitor.sln" $repoRoot = Split-Path -Parent $scriptDir
$solutionPath = Join-Path $repoRoot "HostAvailabilityMonitor.sln"
function Get-MSBuildPath {
$msbuildFromPath = Get-Command msbuild.exe -ErrorAction SilentlyContinue function Get-MSBuildPath {
if ($msbuildFromPath) { $msbuildFromPath = Get-Command msbuild.exe -ErrorAction SilentlyContinue
return $msbuildFromPath.Source if ($msbuildFromPath) {
} return $msbuildFromPath.Source
}
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vswhere) { $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$installationPath = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -property installationPath if (Test-Path $vswhere) {
if ($installationPath) { $installationPath = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -property installationPath
$candidate = Join-Path $installationPath "MSBuild\Current\Bin\MSBuild.exe" if ($installationPath) {
if (Test-Path $candidate) { $candidate = Join-Path $installationPath "MSBuild\Current\Bin\MSBuild.exe"
return $candidate if (Test-Path $candidate) {
} return $candidate
} }
} }
}
throw "MSBuild.exe wurde nicht gefunden. Bitte Visual Studio Build Tools oder Visual Studio installieren."
} throw "MSBuild.exe wurde nicht gefunden. Bitte Visual Studio Build Tools oder Visual Studio installieren."
}
$msbuild = Get-MSBuildPath
Write-Host "Verwende MSBuild: $msbuild" $msbuild = Get-MSBuildPath
Write-Host "Verwende MSBuild: $msbuild"
& $msbuild $solutionPath /t:Build /p:Configuration=$Configuration /p:Platform="Any CPU"
& $msbuild $solutionPath /t:Build /p:Configuration=$Configuration /p:Platform="Any CPU"
$projectDir = Join-Path $repoRoot "src\HostAvailabilityMonitor"
$outputDir = Join-Path $projectDir "bin\$Configuration" $publishRoot = Join-Path $repoRoot "artifacts\publish\net472"
$publishDir = Join-Path $repoRoot "artifacts\publish\net48" if (Test-Path $publishRoot) {
Remove-Item -Path $publishRoot -Recurse -Force
if (Test-Path $publishDir) { }
Remove-Item -Path $publishDir -Recurse -Force New-Item -ItemType Directory -Path $publishRoot | Out-Null
}
$projects = @(
New-Item -ItemType Directory -Path $publishDir | Out-Null @{ Name = "HostAvailabilityMonitor"; ProjectDir = Join-Path $repoRoot "src\HostAvailabilityMonitor" },
Copy-Item -Path (Join-Path $outputDir "*") -Destination $publishDir -Recurse -Force @{ Name = "BizTalkMonitorConfigGenerator"; ProjectDir = Join-Path $repoRoot "src\BizTalkMonitorConfigGenerator" }
)
Write-Host "Build abgeschlossen. Publish-Verzeichnis: $publishDir"
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"
}
Write-Host "Build abgeschlossen. Publish-Root: $publishRoot"
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": []
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<configuration> <configuration>
<startup> <startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" /> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup> </startup>
</configuration> </configuration>
@@ -1,179 +1,272 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Web.Script.Serialization; using System.Linq;
using System.Web.Script.Serialization;
namespace HostAvailabilityMonitor.Configuration
{ namespace HostAvailabilityMonitor.Configuration
public sealed class MonitorConfiguration {
{ public sealed class MonitorConfiguration
public string ApplicationName { get; set; } = "HostAvailabilityMonitor"; {
public RuntimeOptions Runtime { get; set; } = new RuntimeOptions(); public string ApplicationName { get; set; } = "HostAvailabilityMonitor";
public LoggingOptions Logging { get; set; } = new LoggingOptions(); public string EnvironmentName { get; set; } = "Unspecified Environment";
public EventLogOptions EventLog { get; set; } = new EventLogOptions(); public RuntimeOptions Runtime { get; set; } = new RuntimeOptions();
public EmailOptions Email { get; set; } = new EmailOptions(); public LoggingOptions Logging { get; set; } = new LoggingOptions();
public List<TargetOptions> Targets { get; set; } = new List<TargetOptions>(); public EventLogOptions EventLog { get; set; } = new EventLogOptions();
public EmailOptions Email { get; set; } = new EmailOptions();
public static MonitorConfiguration Load(string path) public List<TargetOptions> Targets { get; set; } = new List<TargetOptions>();
{
if (!File.Exists(path)) public static MonitorConfiguration Load(string path)
{ {
throw new FileNotFoundException("Konfigurationsdatei wurde nicht gefunden: " + path, path); if (!File.Exists(path))
} {
throw new FileNotFoundException("Konfigurationsdatei wurde nicht gefunden: " + path, path);
var json = File.ReadAllText(path); }
var serializer = new JavaScriptSerializer();
var config = serializer.Deserialize<MonitorConfiguration>(json); var json = File.ReadAllText(path);
var serializer = new JavaScriptSerializer();
if (config == null) var config = serializer.Deserialize<MonitorConfiguration>(json);
{
throw new InvalidOperationException("Konfigurationsdatei konnte nicht deserialisiert werden."); if (config == null)
} {
throw new InvalidOperationException("Konfigurationsdatei konnte nicht deserialisiert werden.");
config.Runtime = config.Runtime ?? new RuntimeOptions(); }
config.Logging = config.Logging ?? new LoggingOptions();
config.EventLog = config.EventLog ?? new EventLogOptions(); config.Runtime = config.Runtime ?? new RuntimeOptions();
config.Email = config.Email ?? new EmailOptions(); config.Logging = config.Logging ?? new LoggingOptions();
config.Targets = config.Targets ?? new List<TargetOptions>(); config.EventLog = config.EventLog ?? new EventLogOptions();
config.Email = config.Email ?? new EmailOptions();
foreach (var target in config.Targets) config.Email.To = SanitizeRecipientList(config.Email.To);
{ config.Email.Cc = SanitizeRecipientList(config.Email.Cc);
if (target == null) config.Email.Bcc = SanitizeRecipientList(config.Email.Bcc);
{ config.Targets = config.Targets ?? new List<TargetOptions>();
continue;
} if (string.IsNullOrWhiteSpace(config.ApplicationName))
{
if (target.TimeoutSeconds <= 0) config.ApplicationName = "HostAvailabilityMonitor";
{ }
target.TimeoutSeconds = 10;
} if (string.IsNullOrWhiteSpace(config.EnvironmentName))
} {
config.EnvironmentName = "Unspecified Environment";
return config; }
}
foreach (var target in config.Targets)
public void Validate() {
{ if (target == null)
if (Targets == null || Targets.Count == 0) {
{ continue;
throw new InvalidOperationException("Es wurde kein Ziel in der Konfiguration definiert."); }
}
if (target.TimeoutSeconds <= 0)
foreach (var target in Targets) {
{ target.TimeoutSeconds = 10;
if (target == null) }
{ }
throw new InvalidOperationException("Die Konfiguration enthaelt ein leeres Target-Objekt.");
} if (config.Email.TimeoutSeconds <= 0)
{
if (string.IsNullOrWhiteSpace(target.Endpoint)) config.Email.TimeoutSeconds = 30;
{ }
throw new InvalidOperationException("Mindestens ein Target hat keinen Endpoint-Wert.");
} if (config.Email.DailySummaryHourLocal < 0 || config.Email.DailySummaryHourLocal > 23)
{
if (target.TimeoutSeconds <= 0) config.Email.DailySummaryHourLocal = 14;
{ }
throw new InvalidOperationException("Target '" + target.DisplayName + "' hat einen ungueltigen TimeoutSeconds-Wert.");
} if (config.Email.DailySummaryMinuteLocal < 0 || config.Email.DailySummaryMinuteLocal > 59)
} {
config.Email.DailySummaryMinuteLocal = 0;
if (Runtime.MaxConcurrency <= 0) }
{
Runtime.MaxConcurrency = 1; return config;
} }
if (Logging.RetentionDays < 0) public void Validate()
{ {
throw new InvalidOperationException("Logging.RetentionDays darf nicht negativ sein."); if (Targets == null || Targets.Count == 0)
} {
throw new InvalidOperationException("Es wurde kein Ziel in der Konfiguration definiert.");
if (Email.Enabled) }
{
if (string.IsNullOrWhiteSpace(Email.SmtpHost)) foreach (var target in Targets)
{ {
throw new InvalidOperationException("Email.Enabled=true, aber SmtpHost ist leer."); if (target == null)
} {
throw new InvalidOperationException("Die Konfiguration enthaelt ein leeres Target-Objekt.");
if (string.IsNullOrWhiteSpace(Email.From)) }
{
throw new InvalidOperationException("Email.Enabled=true, aber From ist leer."); if (string.IsNullOrWhiteSpace(target.Endpoint))
} {
throw new InvalidOperationException("Mindestens ein Target hat keinen Endpoint-Wert.");
if (Email.To == null || Email.To.Count == 0) }
{
throw new InvalidOperationException("Email.Enabled=true, aber es sind keine Empfaenger in Email.To konfiguriert."); if (target.TimeoutSeconds <= 0)
} {
throw new InvalidOperationException("Target '" + target.DisplayName + "' hat einen ungueltigen TimeoutSeconds-Wert.");
if (Email.TimeoutSeconds <= 0) }
{ }
Email.TimeoutSeconds = 30;
} if (Runtime.MaxConcurrency <= 0)
} {
} Runtime.MaxConcurrency = 1;
} }
public sealed class RuntimeOptions if (Logging.RetentionDays < 0)
{ {
public int MaxConcurrency { get; set; } = 4; throw new InvalidOperationException("Logging.RetentionDays darf nicht negativ sein.");
public bool DefaultValidateUncPathAccess { get; set; } = false; }
public bool NonZeroExitCodeOnAnyFailure { get; set; } = false;
public bool NonZeroExitCodeOnExecutionError { get; set; } = true; if (Email.Enabled)
public string StateDirectory { get; set; } = "state"; {
} if (string.IsNullOrWhiteSpace(Email.SmtpHost))
{
public sealed class LoggingOptions throw new InvalidOperationException("Email.Enabled=true, aber SmtpHost ist leer.");
{ }
public string LogDirectory { get; set; } = "logs";
public string FilePrefix { get; set; } = "host-availability-monitor"; if (string.IsNullOrWhiteSpace(Email.From))
public int RetentionDays { get; set; } = 5; {
} throw new InvalidOperationException("Email.Enabled=true, aber From ist leer.");
}
public sealed class EventLogOptions
{ if (GetConfiguredRecipientCount(Email) <= 0)
public bool Enabled { get; set; } = true; {
public string LogName { get; set; } = "Application"; throw new InvalidOperationException("Email.Enabled=true, aber es ist kein Empfaenger in Email.To, Email.Cc oder Email.Bcc konfiguriert.");
public string Source { get; set; } = "HostAvailabilityMonitor"; }
public bool CreateSourceIfMissing { get; set; } = false;
public bool WriteSuccessEntries { get; set; } = false; if (Email.TimeoutSeconds <= 0)
public bool WriteSummaryEntries { get; set; } = true; {
} Email.TimeoutSeconds = 30;
}
public sealed class EmailOptions
{ if (Email.DailySummaryHourLocal < 0 || Email.DailySummaryHourLocal > 23)
public bool Enabled { get; set; } = false; {
public string SmtpHost { get; set; } = string.Empty; throw new InvalidOperationException("Email.DailySummaryHourLocal muss zwischen 0 und 23 liegen.");
public int SmtpPort { get; set; } = 25; }
public bool UseSsl { get; set; } = false;
public string Username { get; set; } = string.Empty; if (Email.DailySummaryMinuteLocal < 0 || Email.DailySummaryMinuteLocal > 59)
public string Password { get; set; } = string.Empty; {
public string From { get; set; } = string.Empty; throw new InvalidOperationException("Email.DailySummaryMinuteLocal muss zwischen 0 und 59 liegen.");
public List<string> To { get; set; } = new List<string>(); }
public string SubjectPrefix { get; set; } = "[HostAvailabilityMonitor]"; }
public bool SendOnFailure { get; set; } = true; }
public bool SendOnRecovery { get; set; } = true; private static List<string> SanitizeRecipientList(List<string> values)
public bool OnlyOnStateChange { get; set; } = true; {
public int CooldownMinutes { get; set; } = 0; if (values == null)
public int TimeoutSeconds { get; set; } = 30; {
} return new List<string>();
}
public sealed class TargetOptions
{ return values
public string Name { get; set; } = string.Empty; .Where(x => !string.IsNullOrWhiteSpace(x))
public string Endpoint { get; set; } = string.Empty; .Select(x => x.Trim())
public int TimeoutSeconds { get; set; } = 10; .Distinct(StringComparer.OrdinalIgnoreCase)
public int? Port { get; set; } .ToList();
public bool? ValidateUncPathAccess { get; set; } }
public string HttpMethod { get; set; }
public bool Enabled { get; set; } = true; private static int GetConfiguredRecipientCount(EmailOptions email)
{
public string DisplayName if (email == null)
{ {
get { return string.IsNullOrWhiteSpace(Name) ? Endpoint : Name; } return 0;
} }
public string StateKey return SanitizeRecipientList(email.To).Count
{ + SanitizeRecipientList(email.Cc).Count
get { return string.IsNullOrWhiteSpace(Name) ? Endpoint : Name.Trim(); } + SanitizeRecipientList(email.Bcc).Count;
} }
} }
}
public sealed class RuntimeOptions
{
public int MaxConcurrency { get; set; } = 4;
public bool DefaultValidateUncPathAccess { get; set; } = false;
public bool NonZeroExitCodeOnAnyFailure { get; set; } = false;
public bool NonZeroExitCodeOnExecutionError { get; set; } = true;
public string StateDirectory { get; set; } = "state";
}
public sealed class LoggingOptions
{
public string LogDirectory { get; set; } = "logs";
public string FilePrefix { get; set; } = "host-availability-monitor";
public int RetentionDays { get; set; } = 5;
}
public sealed class EventLogOptions
{
public bool Enabled { get; set; } = true;
public string LogName { get; set; } = "Application";
public string Source { get; set; } = "HostAvailabilityMonitor";
public bool CreateSourceIfMissing { get; set; } = false;
public bool WriteSuccessEntries { get; set; } = false;
public bool WriteSummaryEntries { get; set; } = true;
}
public sealed class EmailOptions
{
public bool Enabled { get; set; } = false;
public string SmtpHost { get; set; } = string.Empty;
public int SmtpPort { get; set; } = 25;
public bool UseSsl { get; set; } = false;
public 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;
}
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; }
public bool? ValidateUncPathAccess { get; set; }
public string HttpMethod { get; set; }
public bool Enabled { get; set; } = true;
public string DisplayName
{
get { return string.IsNullOrWhiteSpace(Name) ? Endpoint : Name; }
}
public string EffectiveApplicationName
{
get { return string.IsNullOrWhiteSpace(ApplicationName) ? "Unassigned Application" : ApplicationName.Trim(); }
}
public string StateKey
{
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();
}
}
}
@@ -1,61 +1,61 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <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')" /> <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup> <PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}</ProjectGuid> <ProjectGuid>{0E9D281E-8E18-4A97-BFA6-ED0F0B6A242B}</ProjectGuid>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<RootNamespace>HostAvailabilityMonitor</RootNamespace> <RootNamespace>HostAvailabilityMonitor</RootNamespace>
<AssemblyName>HostAvailabilityMonitor</AssemblyName> <AssemblyName>HostAvailabilityMonitor</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion> <TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment> <FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic> <Deterministic>true</Deterministic>
<LangVersion>7.3</LangVersion> <LangVersion>7.3</LangVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget> <PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols> <DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType> <DebugType>full</DebugType>
<Optimize>false</Optimize> <Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath> <OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants> <DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport> <ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel> <WarningLevel>4</WarningLevel>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget> <PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType> <DebugType>pdbonly</DebugType>
<Optimize>true</Optimize> <Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath> <OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants> <DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport> <ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel> <WarningLevel>4</WarningLevel>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Reference Include="System" /> <Reference Include="System" />
<Reference Include="System.Configuration" /> <Reference Include="System.Configuration" />
<Reference Include="System.Core" /> <Reference Include="System.Core" />
<Reference Include="System.Net.Http" /> <Reference Include="System.Net.Http" />
<Reference Include="System.Web.Extensions" /> <Reference Include="System.Web.Extensions" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Configuration\MonitorConfiguration.cs" /> <Compile Include="Configuration\MonitorConfiguration.cs" />
<Compile Include="Logging\FileLogger.cs" /> <Compile Include="Logging\FileLogger.cs" />
<Compile Include="Logging\WindowsEventLogger.cs" /> <Compile Include="Logging\WindowsEventLogger.cs" />
<Compile Include="Monitoring\EndpointProbeService.cs" /> <Compile Include="Monitoring\EndpointProbeService.cs" />
<Compile Include="Monitoring\ProbeResult.cs" /> <Compile Include="Monitoring\ProbeResult.cs" />
<Compile Include="Notifications\EmailNotifier.cs" /> <Compile Include="Notifications\EmailNotifier.cs" />
<Compile Include="Program.cs" /> <Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="State\MonitorStateStore.cs" /> <Compile Include="State\MonitorStateStore.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="App.config" /> <None Include="App.config" />
<Content Include="appsettings.json"> <Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
</ItemGroup> </ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project> </Project>
+192 -185
View File
@@ -1,185 +1,192 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Threading;
using System.Threading; using HostAvailabilityMonitor.Monitoring;
using HostAvailabilityMonitor.Monitoring;
namespace HostAvailabilityMonitor.Logging
namespace HostAvailabilityMonitor.Logging {
{ public sealed class FileLogger
public sealed class FileLogger {
{ private readonly object _sync = new object();
private readonly object _sync = new object(); private readonly string _directory;
private readonly string _directory; private readonly string _filePrefix;
private readonly string _filePrefix; private readonly int _retentionDays;
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; _directory = directory;
_retentionDays = retentionDays; _filePrefix = filePrefix;
_retentionDays = retentionDays;
Directory.CreateDirectory(_directory); _monitorName = string.IsNullOrWhiteSpace(monitorName) ? "HostAvailabilityMonitor" : monitorName.Trim();
} _environmentName = string.IsNullOrWhiteSpace(environmentName) ? "Unspecified Environment" : environmentName.Trim();
public void CleanupOldFiles() Directory.CreateDirectory(_directory);
{ }
if (_retentionDays <= 0)
{ public void CleanupOldFiles()
return; {
} if (_retentionDays <= 0)
{
var cutoff = DateTime.Today.AddDays(-Math.Max(0, _retentionDays - 1)); return;
foreach (var file in Directory.EnumerateFiles(_directory, _filePrefix + "-*.log", SearchOption.TopDirectoryOnly)) }
{
try var cutoff = DateTime.Today.AddDays(-Math.Max(0, _retentionDays - 1));
{ foreach (var file in Directory.EnumerateFiles(_directory, _filePrefix + "-*.log", SearchOption.TopDirectoryOnly))
if (TryGetDateFromFileName(file, out var fileDate)) {
{ try
if (fileDate < cutoff) {
{ DateTime fileDate;
File.Delete(file); if (TryGetDateFromFileName(file, out fileDate))
} {
} if (fileDate < cutoff)
else {
{ File.Delete(file);
var info = new FileInfo(file); }
if (info.LastWriteTime.Date < cutoff) }
{ else
info.Delete(); {
} var info = new FileInfo(file);
} if (info.LastWriteTime.Date < cutoff)
} {
catch info.Delete();
{ }
// Cleanup darf den Lauf nicht blockieren. }
} }
} catch
} {
// Cleanup darf den Lauf nicht blockieren.
public void Info(string message) }
{ }
Write("INFO", message); }
}
public void Info(string message)
public void Warn(string message) {
{ Write("INFO", message);
Write("WARN", message); }
}
public void Warn(string message)
public void Error(string message) {
{ Write("WARN", message);
Write("ERROR", message); }
}
public void Error(string message)
public void Probe(ProbeResult result) {
{ Write("ERROR", message);
var level = result.IsSuccess ? "SUCCESS" : "FAIL"; }
var parts = new List<string>
{ public void Probe(ProbeResult result)
"Name=" + Escape(result.Name), {
"Kind=" + result.Kind, var level = result.IsSkipped ? "SKIP" : (result.IsSuccess ? "SUCCESS" : "FAIL");
"Endpoint=" + Escape(result.Endpoint), var parts = new List<string>
"DurationMs=" + Math.Max(0L, (long)result.Duration.TotalMilliseconds), {
"Status=" + Escape(result.StatusText), "Monitor=" + Escape(_monitorName),
"Detail=" + Escape(result.Detail) "Environment=" + Escape(_environmentName),
}; "Application=" + Escape(result.ApplicationName),
"Name=" + Escape(result.Name),
if (!string.IsNullOrWhiteSpace(result.Host)) "Kind=" + result.Kind,
{ "Endpoint=" + Escape(result.Endpoint),
parts.Add("Host=" + Escape(result.Host)); "DurationMs=" + Math.Max(0L, (long)result.Duration.TotalMilliseconds),
} "Status=" + Escape(result.StatusText),
"Detail=" + Escape(result.Detail)
if (result.Port.HasValue) };
{
parts.Add("Port=" + result.Port.Value); if (!string.IsNullOrWhiteSpace(result.Host))
} {
parts.Add("Host=" + Escape(result.Host));
if (result.HttpStatusCode.HasValue) }
{
parts.Add("HttpStatus=" + result.HttpStatusCode.Value); if (result.Port.HasValue)
} {
parts.Add("Port=" + result.Port.Value);
if (!string.IsNullOrWhiteSpace(result.ErrorType)) }
{
parts.Add("ErrorType=" + Escape(result.ErrorType)); if (result.HttpStatusCode.HasValue)
} {
parts.Add("HttpStatus=" + result.HttpStatusCode.Value);
Write(level, string.Join(" | ", parts)); }
}
if (!string.IsNullOrWhiteSpace(result.ErrorType))
private void Write(string level, string message) {
{ parts.Add("ErrorType=" + Escape(result.ErrorType));
var line = string.Format( }
CultureInfo.InvariantCulture,
"{0:yyyy-MM-dd HH:mm:ss.fff zzz} | {1} | {2}{3}", Write(level, string.Join(" | ", parts));
DateTimeOffset.Now, }
level,
message, private void Write(string level, string message)
Environment.NewLine); {
var line = string.Format(
var path = GetCurrentLogFilePath(); CultureInfo.InvariantCulture,
"{0:yyyy-MM-dd HH:mm:ss.fff zzz} | {1} | {2}{3}",
lock (_sync) DateTimeOffset.Now,
{ level,
AppendWithRetries(path, line); message,
} Environment.NewLine);
}
var path = GetCurrentLogFilePath();
private void AppendWithRetries(string path, string line)
{ lock (_sync)
Exception lastException = null; {
AppendWithRetries(path, line);
for (var attempt = 1; attempt <= 3; attempt++) }
{ }
try
{ private void AppendWithRetries(string path, string line)
File.AppendAllText(path, line); {
return; Exception lastException = null;
}
catch (IOException ex) for (var attempt = 1; attempt <= 3; attempt++)
{ {
lastException = ex; try
Thread.Sleep(100 * attempt); {
} File.AppendAllText(path, line);
catch (UnauthorizedAccessException ex) return;
{ }
lastException = ex; catch (IOException ex)
Thread.Sleep(100 * attempt); {
} lastException = ex;
} Thread.Sleep(100 * attempt);
}
if (lastException != null) catch (UnauthorizedAccessException ex)
{ {
throw lastException; lastException = ex;
} Thread.Sleep(100 * attempt);
} }
}
private string GetCurrentLogFilePath()
{ if (lastException != null)
var fileName = _filePrefix + "-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"; {
return Path.Combine(_directory, fileName); throw lastException;
} }
}
private bool TryGetDateFromFileName(string path, out DateTime date)
{ private string GetCurrentLogFilePath()
date = DateTime.MinValue; {
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path); var fileName = _filePrefix + "-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log";
var prefix = _filePrefix + "-"; return Path.Combine(_directory, fileName);
if (!fileNameWithoutExtension.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) }
{
return false; private bool TryGetDateFromFileName(string path, out DateTime date)
} {
date = DateTime.MinValue;
var datePart = fileNameWithoutExtension.Substring(prefix.Length); var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path);
return DateTime.TryParseExact(datePart, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date); var prefix = _filePrefix + "-";
} if (!fileNameWithoutExtension.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
private static string Escape(string value) return false;
{ }
return (value ?? string.Empty).Replace("\r", " ").Replace("\n", " ");
} var datePart = fileNameWithoutExtension.Substring(prefix.Length);
} return DateTime.TryParseExact(datePart, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date);
} }
private static string Escape(string value)
{
return (value ?? string.Empty).Replace("\r", " ").Replace("\n", " ");
}
}
}
@@ -1,357 +1,515 @@
using System; using System;
using System.IO; using System.IO;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.NetworkInformation; using System.Net.NetworkInformation;
using System.Net.Sockets; using System.Net.Sockets;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using HostAvailabilityMonitor.Configuration; using HostAvailabilityMonitor.Configuration;
namespace HostAvailabilityMonitor.Monitoring namespace HostAvailabilityMonitor.Monitoring
{ {
public sealed class EndpointProbeService public sealed class EndpointProbeService
{ {
private static readonly HttpClient HttpClient = CreateHttpClient(); private static readonly HttpClient HttpClient = CreateHttpClient();
private readonly RuntimeOptions _runtimeOptions; private readonly RuntimeOptions _runtimeOptions;
public EndpointProbeService(RuntimeOptions runtimeOptions) public EndpointProbeService(RuntimeOptions runtimeOptions)
{ {
_runtimeOptions = runtimeOptions; _runtimeOptions = runtimeOptions ?? new RuntimeOptions();
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12; ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12;
} }
public async Task<ProbeResult> ProbeAsync(TargetOptions target, CancellationToken cancellationToken) public async Task<ProbeResult> ProbeAsync(TargetOptions target, CancellationToken cancellationToken)
{ {
var startedAt = DateTimeOffset.Now; var startedAt = DateTimeOffset.Now;
try try
{ {
var endpoint = (target.Endpoint ?? string.Empty).Trim(); var endpoint = (target.Endpoint ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(endpoint)) if (string.IsNullOrWhiteSpace(endpoint))
{ {
return Fail(target, TargetKind.Unknown, endpoint, startedAt, "Leerer Endpoint.", null, null, null); return Fail(target, TargetKind.Unknown, endpoint, startedAt, "Leerer Endpoint.", null, null, null);
} }
ProbeResult skippedResult;
if (endpoint.StartsWith("\\\\", StringComparison.Ordinal)) if (TryCreateSkipResult(target, endpoint, startedAt, out skippedResult))
{ {
return await CheckUncAsync(target, endpoint, startedAt, cancellationToken).ConfigureAwait(false); return skippedResult;
} }
Uri uri;
if (Uri.TryCreate(endpoint, UriKind.Absolute, out uri)) if (endpoint.StartsWith("\\\\", StringComparison.Ordinal))
{ {
var scheme = uri.Scheme.ToLowerInvariant(); return await CheckUncAsync(target, endpoint, startedAt, cancellationToken).ConfigureAwait(false);
switch (scheme) }
{
case "http": if (Path.IsPathRooted(endpoint))
return await CheckHttpAsync(target, uri, startedAt, TargetKind.Http, cancellationToken).ConfigureAwait(false); {
case "https": return await CheckLocalPathAsync(target, endpoint, startedAt, cancellationToken).ConfigureAwait(false);
return await CheckHttpAsync(target, uri, startedAt, TargetKind.Https, cancellationToken).ConfigureAwait(false); }
case "sftp":
return await CheckTcpAsync(target, uri.Host, target.Port ?? ResolvePort(uri, 22), startedAt, TargetKind.Sftp, endpoint, cancellationToken).ConfigureAwait(false); Uri uri;
case "tcp": if (Uri.TryCreate(endpoint, UriKind.Absolute, out uri))
return await CheckTcpAsync(target, uri.Host, target.Port ?? ResolvePort(uri, null), startedAt, TargetKind.Tcp, endpoint, cancellationToken).ConfigureAwait(false); {
case "icmp": var scheme = uri.Scheme.ToLowerInvariant();
return await CheckIcmpAsync(target, uri.Host, startedAt, endpoint).ConfigureAwait(false); switch (scheme)
case "ftp": {
return await CheckTcpAsync(target, uri.Host, target.Port ?? ResolvePort(uri, 21), startedAt, TargetKind.Ftp, endpoint, cancellationToken).ConfigureAwait(false); case "http":
default: return await CheckHttpAsync(target, uri, startedAt, TargetKind.Http, cancellationToken).ConfigureAwait(false);
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); case "https":
} return await CheckHttpAsync(target, uri, startedAt, TargetKind.Https, cancellationToken).ConfigureAwait(false);
} case "sftp":
return await CheckTcpAsync(target, uri.Host, target.Port ?? ResolvePort(uri, 22), startedAt, TargetKind.Sftp, endpoint, cancellationToken).ConfigureAwait(false);
return await CheckPlainHostAsync(target, endpoint, startedAt, cancellationToken).ConfigureAwait(false); case "tcp":
} return await CheckTcpAsync(target, uri.Host, target.Port ?? ResolvePort(uri, null), startedAt, TargetKind.Tcp, endpoint, cancellationToken).ConfigureAwait(false);
catch (OperationCanceledException ex) case "icmp":
{ return await CheckIcmpAsync(target, uri.Host, startedAt, endpoint, cancellationToken).ConfigureAwait(false);
return Fail(target, TargetKind.Unknown, target.Endpoint, startedAt, "Timeout oder Abbruch während des Checks.", ex, null, null); case "ftp":
} return await CheckTcpAsync(target, uri.Host, target.Port ?? ResolvePort(uri, 21), startedAt, TargetKind.Ftp, endpoint, cancellationToken).ConfigureAwait(false);
catch (Exception ex) case "file":
{ return await CheckFileUriAsync(target, uri, startedAt, cancellationToken).ConfigureAwait(false);
return Fail(target, TargetKind.Unknown, target.Endpoint, startedAt, ex.Message, ex, null, null); default:
} 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);
} }
}
private async Task<ProbeResult> CheckPlainHostAsync(TargetOptions target, string host, DateTimeOffset startedAt, CancellationToken cancellationToken)
{ return await CheckPlainHostAsync(target, endpoint, startedAt, cancellationToken).ConfigureAwait(false);
if (target.Port.HasValue) }
{ catch (OperationCanceledException ex)
return await CheckTcpAsync(target, host, target.Port.Value, startedAt, TargetKind.Host, host, cancellationToken).ConfigureAwait(false); {
} return Fail(target, TargetKind.Unknown, target.Endpoint, startedAt, "Timeout oder Abbruch während des Checks.", ex, null, null);
}
return await CheckIcmpAsync(target, host, startedAt, host).ConfigureAwait(false); catch (Exception ex)
} {
return Fail(target, TargetKind.Unknown, target.Endpoint, startedAt, ex.Message, ex, null, null);
private async Task<ProbeResult> CheckUncAsync(TargetOptions target, string endpoint, DateTimeOffset startedAt, CancellationToken cancellationToken) }
{ }
var host = ExtractUncServer(endpoint);
var port = target.Port ?? 445; private async Task<ProbeResult> CheckPlainHostAsync(TargetOptions target, string host, DateTimeOffset startedAt, CancellationToken cancellationToken)
{
var tcpResult = await CheckTcpAsync(target, host, port, startedAt, TargetKind.Unc, endpoint, cancellationToken).ConfigureAwait(false); if (target.Port.HasValue)
if (!tcpResult.IsSuccess) {
{ return await CheckTcpAsync(target, host, target.Port.Value, startedAt, TargetKind.Host, host, cancellationToken).ConfigureAwait(false);
return tcpResult; }
}
return await CheckIcmpAsync(target, host, startedAt, host, cancellationToken).ConfigureAwait(false);
var validatePath = target.ValidateUncPathAccess ?? _runtimeOptions.DefaultValidateUncPathAccess; }
if (!validatePath)
{ private async Task<ProbeResult> CheckFileUriAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, CancellationToken cancellationToken)
return Success(target, TargetKind.Unc, endpoint, startedAt, host, port, "SMB-Port erreichbar; UNC-Pfadvalidierung übersprungen.", null); {
} if (uri.IsUnc || !string.IsNullOrWhiteSpace(uri.Host))
{
try var uncPath = uri.LocalPath;
{ if (!uncPath.StartsWith("\\\\", StringComparison.Ordinal))
var existsTask = Task.Run(function: () => Directory.Exists(endpoint) || File.Exists(endpoint), cancellationToken); {
var timed = await CompleteWithinAsync(existsTask, TimeSpan.FromSeconds(target.TimeoutSeconds), cancellationToken).ConfigureAwait(false); uncPath = "\\\\" + uri.Host + uri.LocalPath.Replace('/', '\\');
if (!timed.Completed) }
{
return Fail(target, TargetKind.Unc, endpoint, startedAt, "Timeout während der UNC-Pfadvalidierung.", null, host, port); return await CheckUncAsync(target, uncPath, startedAt, cancellationToken).ConfigureAwait(false);
} }
if (timed.Result) var localPath = Uri.UnescapeDataString(uri.LocalPath ?? string.Empty);
{ return await CheckLocalPathAsync(target, localPath, startedAt, cancellationToken).ConfigureAwait(false);
return Success(target, TargetKind.Unc, endpoint, startedAt, host, port, "SMB-Port erreichbar; UNC-Pfad wurde erfolgreich validiert.", null); }
}
private async Task<ProbeResult> CheckLocalPathAsync(TargetOptions target, string path, DateTimeOffset startedAt, CancellationToken cancellationToken)
return Fail(target, TargetKind.Unc, endpoint, startedAt, "SMB-Port erreichbar, aber UNC-Pfadvalidierung war nicht erfolgreich.", null, host, port); {
} try
catch (Exception ex) {
{ var existsTask = Task.Run(function: () => Directory.Exists(path) || File.Exists(path), cancellationToken);
return Fail(target, TargetKind.Unc, endpoint, startedAt, "UNC-Pfadvalidierung fehlgeschlagen: " + ex.Message, ex, host, port); 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);
private async Task<ProbeResult> CheckHttpAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, TargetKind kind, CancellationToken cancellationToken) }
{
var timeout = TimeSpan.FromSeconds(target.TimeoutSeconds); if (timed.Result)
var methodText = string.IsNullOrWhiteSpace(target.HttpMethod) ? "HEAD" : target.HttpMethod.Trim().ToUpperInvariant(); {
var method = new HttpMethod(methodText); return Success(target, TargetKind.File, path, startedAt, Environment.MachineName, null, "Datei- oder Pfadprüfung erfolgreich.", null);
}
using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
using (var request = new HttpRequestMessage(method, uri)) return Fail(target, TargetKind.File, path, startedAt, "Datei oder Verzeichnis ist nicht vorhanden oder nicht zugreifbar.", null, Environment.MachineName, null);
{ }
timeoutCts.CancelAfter(timeout); catch (Exception ex)
try {
{ return Fail(target, TargetKind.File, path, startedAt, "Datei-/Pfadprüfung fehlgeschlagen: " + ex.Message, ex, Environment.MachineName, null);
using (var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token).ConfigureAwait(false)) }
{ }
if (string.Equals(methodText, "HEAD", StringComparison.OrdinalIgnoreCase)
&& (response.StatusCode == HttpStatusCode.MethodNotAllowed || response.StatusCode == HttpStatusCode.NotImplemented)) private async Task<ProbeResult> CheckUncAsync(TargetOptions target, string endpoint, DateTimeOffset startedAt, CancellationToken cancellationToken)
{ {
return await RetryHttpWithGetAsync(target, uri, startedAt, kind, new InvalidOperationException("HEAD nicht unterstützt (" + (int)response.StatusCode + " " + response.ReasonPhrase + ")."), cancellationToken).ConfigureAwait(false); var host = ExtractUncServer(endpoint);
} var port = target.Port ?? 445;
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); var tcpResult = await CheckTcpAsync(target, host, port, startedAt, TargetKind.Unc, endpoint, cancellationToken).ConfigureAwait(false);
} if (!tcpResult.IsSuccess)
} {
catch (HttpRequestException ex) return tcpResult;
{ }
if (string.Equals(methodText, "HEAD", StringComparison.OrdinalIgnoreCase))
{ var validatePath = target.ValidateUncPathAccess ?? _runtimeOptions.DefaultValidateUncPathAccess;
return await RetryHttpWithGetAsync(target, uri, startedAt, kind, ex, cancellationToken).ConfigureAwait(false); if (!validatePath)
} {
return Success(target, TargetKind.Unc, endpoint, startedAt, host, port, "SMB-Port erreichbar; UNC-Pfadvalidierung übersprungen.", null);
return Fail(target, kind, uri.ToString(), startedAt, "HTTP-Check fehlgeschlagen: " + ex.Message, ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80)); }
}
catch (TaskCanceledException ex) try
{ {
return Fail(target, kind, uri.ToString(), startedAt, "HTTP-Check ist in einen Timeout gelaufen.", ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80)); var existsTask = Task.Run(function: () => Directory.Exists(endpoint) || File.Exists(endpoint), cancellationToken);
} var timed = await CompleteWithinAsync(existsTask, TimeSpan.FromSeconds(target.TimeoutSeconds), cancellationToken).ConfigureAwait(false);
catch (Exception ex) if (!timed.Completed)
{ {
return Fail(target, kind, uri.ToString(), startedAt, "HTTP-Check fehlgeschlagen: " + ex.Message, ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80)); return Fail(target, TargetKind.Unc, endpoint, startedAt, "Timeout während der UNC-Pfadvalidierung.", null, host, port);
} }
}
} if (timed.Result)
{
private async Task<ProbeResult> RetryHttpWithGetAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, TargetKind kind, Exception headException, CancellationToken cancellationToken) return Success(target, TargetKind.Unc, endpoint, startedAt, host, port, "SMB-Port erreichbar; UNC-Pfad wurde erfolgreich validiert.", null);
{ }
using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
using (var request = new HttpRequestMessage(HttpMethod.Get, uri)) return Fail(target, TargetKind.Unc, endpoint, startedAt, "SMB-Port erreichbar, aber UNC-Pfadvalidierung war nicht erfolgreich.", null, host, port);
{ }
timeoutCts.CancelAfter(TimeSpan.FromSeconds(target.TimeoutSeconds)); catch (Exception ex)
try {
{ return Fail(target, TargetKind.Unc, endpoint, startedAt, "UNC-Pfadvalidierung fehlgeschlagen: " + ex.Message, ex, host, port);
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);
} private async Task<ProbeResult> CheckHttpAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, TargetKind kind, CancellationToken cancellationToken)
} {
catch (Exception ex) var methodText = string.IsNullOrWhiteSpace(target.HttpMethod) ? "HEAD" : target.HttpMethod.Trim().ToUpperInvariant();
{ var method = new HttpMethod(methodText);
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));
} using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
} using (var request = new HttpRequestMessage(method, uri))
} {
timeoutCts.CancelAfter(TimeSpan.FromSeconds(target.TimeoutSeconds));
private async Task<ProbeResult> CheckTcpAsync(TargetOptions target, string host, int? port, DateTimeOffset startedAt, TargetKind kind, string endpoint, CancellationToken cancellationToken) try
{ {
if (!port.HasValue || port.Value <= 0) using (var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token).ConfigureAwait(false))
{ {
return Fail(target, kind, endpoint, startedAt, "Für diesen Endpoint konnte kein gültiger TCP-Port ermittelt werden.", null, host, port); if (string.Equals(methodText, "HEAD", StringComparison.OrdinalIgnoreCase)
} && (response.StatusCode == HttpStatusCode.MethodNotAllowed || response.StatusCode == HttpStatusCode.NotImplemented))
{
var timeout = TimeSpan.FromSeconds(target.TimeoutSeconds); return await RetryHttpWithGetAsync(target, uri, startedAt, kind, cancellationToken).ConfigureAwait(false);
}
using (var client = new TcpClient())
{ 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);
try }
{ }
var connectTask = client.ConnectAsync(host, port.Value); catch (HttpRequestException ex)
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout, cancellationToken)).ConfigureAwait(false); {
if (completedTask != connectTask) if (string.Equals(methodText, "HEAD", StringComparison.OrdinalIgnoreCase))
{ {
try return await RetryHttpWithGetAsync(target, uri, startedAt, kind, cancellationToken).ConfigureAwait(false);
{ }
client.Close();
} return Fail(target, kind, uri.ToString(), startedAt, "HTTP-Check fehlgeschlagen: " + ex.Message, ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80));
catch }
{ 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));
cancellationToken.ThrowIfCancellationRequested(); }
return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung fehlgeschlagen: Timeout.", null, host, port); catch (Exception ex)
} {
return Fail(target, kind, uri.ToString(), startedAt, "HTTP-Check fehlgeschlagen: " + ex.Message, ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80));
await connectTask.ConfigureAwait(false); }
return Success(target, kind, endpoint, startedAt, host, port, "TCP-Verbindung erfolgreich aufgebaut.", null); }
} }
catch (Exception ex)
{ private async Task<ProbeResult> RetryHttpWithGetAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, TargetKind kind, CancellationToken cancellationToken)
return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung fehlgeschlagen: " + ex.Message, ex, host, port); {
} using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
} using (var request = new HttpRequestMessage(HttpMethod.Get, uri))
} {
timeoutCts.CancelAfter(TimeSpan.FromSeconds(target.TimeoutSeconds));
private async Task<ProbeResult> CheckIcmpAsync(TargetOptions target, string host, DateTimeOffset startedAt, string endpoint) try
{ {
try using (var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token).ConfigureAwait(false))
{ {
using (var ping = new Ping()) 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);
{ }
var reply = await ping.SendPingAsync(host, (int)TimeSpan.FromSeconds(target.TimeoutSeconds).TotalMilliseconds).ConfigureAwait(false); }
if (reply.Status == IPStatus.Success) catch (TaskCanceledException ex)
{ {
return Success(target, TargetKind.Icmp, endpoint, startedAt, host, null, "ICMP erfolgreich, RTT=" + reply.RoundtripTime + "ms.", null); 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, TargetKind.Icmp, endpoint, startedAt, "ICMP fehlgeschlagen: " + reply.Status + ".", null, host, null); {
} return Fail(target, kind, uri.ToString(), startedAt, "HTTP-Check fehlgeschlagen: " + ex.Message, ex, uri.Host, ResolvePort(uri, kind == TargetKind.Https ? 443 : 80));
} }
catch (Exception ex) }
{ }
return Fail(target, TargetKind.Icmp, endpoint, startedAt, "ICMP-Check fehlgeschlagen: " + ex.Message, ex, host, null);
} private async Task<ProbeResult> CheckTcpAsync(TargetOptions target, string host, int? port, DateTimeOffset startedAt, TargetKind kind, string endpoint, CancellationToken cancellationToken)
} {
if (string.IsNullOrWhiteSpace(host))
private static ProbeResult Success(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string host, int? port, string detail, int? httpStatusCode) {
{ return Fail(target, kind, endpoint, startedAt, "Host ist leer.", null, host, port);
return new ProbeResult }
{
StateKey = target.StateKey, if (!port.HasValue || port.Value <= 0 || port.Value > 65535)
Name = target.DisplayName, {
Endpoint = endpoint, return Fail(target, kind, endpoint, startedAt, "Es wurde kein gültiger Port aufgelöst.", null, host, port);
Kind = kind, }
IsSuccess = true,
StatusText = "Reachable", using (var client = new TcpClient())
Detail = detail, using (cancellationToken.Register(() =>
Host = host, {
Port = port, try
HttpStatusCode = httpStatusCode, {
ErrorType = string.Empty, client.Close();
StartedAtLocal = startedAt, }
FinishedAtLocal = DateTimeOffset.Now catch
}; {
} }
}))
private static ProbeResult Fail(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string detail, Exception exception, string host, int? port) {
{ try
return new ProbeResult {
{ var connectTask = client.ConnectAsync(host, port.Value);
StateKey = target.StateKey, var timed = await CompleteWithinAsync(connectTask, TimeSpan.FromSeconds(target.TimeoutSeconds), cancellationToken).ConfigureAwait(false);
Name = target.DisplayName, if (!timed.Completed)
Endpoint = endpoint, {
Kind = kind, return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung ist in einen Timeout gelaufen.", null, host, port);
IsSuccess = false, }
StatusText = "Unreachable",
Detail = detail, if (!client.Connected)
Host = host, {
Port = port, return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung konnte nicht aufgebaut werden.", null, host, port);
ErrorType = exception == null ? string.Empty : exception.GetType().Name, }
StartedAtLocal = startedAt,
FinishedAtLocal = DateTimeOffset.Now return Success(target, kind, endpoint, startedAt, host, port, "TCP-Verbindung erfolgreich aufgebaut.", null);
}; }
} catch (SocketException ex)
{
private static HttpClient CreateHttpClient() return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung fehlgeschlagen: " + ex.Message, ex, host, port);
{ }
var handler = new HttpClientHandler catch (ObjectDisposedException ex)
{ {
AllowAutoRedirect = false, return Fail(target, kind, endpoint, startedAt, "TCP-Check wurde abgebrochen oder ist in einen Timeout gelaufen.", ex, host, port);
UseCookies = false, }
UseProxy = false catch (Exception ex)
}; {
return Fail(target, kind, endpoint, startedAt, "TCP-Verbindung fehlgeschlagen: " + ex.Message, ex, host, port);
var client = new HttpClient(handler); }
client.Timeout = Timeout.InfiniteTimeSpan; }
return client; }
}
private async Task<ProbeResult> CheckIcmpAsync(TargetOptions target, string host, DateTimeOffset startedAt, string endpoint, CancellationToken cancellationToken)
private static int? ResolvePort(Uri uri, int? fallback) {
{ using (var ping = new Ping())
if (uri == null) {
{ try
return fallback; {
} 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 (!uri.IsDefaultPort && uri.Port > 0) if (!timed.Completed)
{ {
return uri.Port; return Fail(target, TargetKind.Icmp, endpoint, startedAt, "ICMP-Check ist in einen Timeout gelaufen.", null, host, null);
} }
return fallback; var reply = timed.Result;
} if (reply != null && reply.Status == IPStatus.Success)
{
private static string ExtractUncServer(string uncPath) return Success(target, TargetKind.Icmp, endpoint, startedAt, host, null, "Ping erfolgreich.", null);
{ }
if (string.IsNullOrWhiteSpace(uncPath))
{ var status = reply == null ? "Unbekannt" : reply.Status.ToString();
return string.Empty; return Fail(target, TargetKind.Icmp, endpoint, startedAt, "Ping fehlgeschlagen: " + status + ".", null, host, null);
} }
catch (PingException ex)
var trimmed = uncPath.TrimStart('\\'); {
var firstSeparator = trimmed.IndexOf('\\'); return Fail(target, TargetKind.Icmp, endpoint, startedAt, "ICMP-Check fehlgeschlagen: " + ex.Message, ex, host, null);
if (firstSeparator < 0) }
{ catch (Exception ex)
return trimmed; {
} return Fail(target, TargetKind.Icmp, endpoint, startedAt, "ICMP-Check fehlgeschlagen: " + ex.Message, ex, host, null);
}
return trimmed.Substring(0, firstSeparator); }
} }
private static async Task<TimedResult<T>> CompleteWithinAsync<T>(Task<T> task, TimeSpan timeout, CancellationToken cancellationToken) private static int? ResolvePort(Uri uri, int? defaultPort)
{ {
var completedTask = await Task.WhenAny(task, Task.Delay(timeout, cancellationToken)).ConfigureAwait(false); if (uri == null)
if (completedTask == task) {
{ return defaultPort;
return new TimedResult<T>(true, await task.ConfigureAwait(false)); }
}
if (!uri.IsDefaultPort)
cancellationToken.ThrowIfCancellationRequested(); {
return new TimedResult<T>(false, default(T)); return uri.Port;
} }
private struct TimedResult<T> return defaultPort;
{ }
public TimedResult(bool completed, T result)
{ private static string ExtractUncServer(string endpoint)
Completed = completed; {
Result = result; if (string.IsNullOrWhiteSpace(endpoint))
} {
return string.Empty;
public bool Completed { get; private set; } }
public T Result { get; private set; }
} var trimmed = endpoint.TrimStart('\\');
} var firstSeparator = trimmed.IndexOf('\\');
} return firstSeparator >= 0 ? trimmed.Substring(0, firstSeparator) : trimmed;
}
private static HttpClient CreateHttpClient()
{
var handler = new HttpClientHandler();
var client = new HttpClient(handler, disposeHandler: true);
client.Timeout = Timeout.InfiniteTimeSpan;
return client;
}
private static ProbeResult Success(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string host, int? port, string detail, int? httpStatusCode)
{
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;
}
public bool Completed { get; private set; }
public T Result { get; private set; }
}
}
}
@@ -1,38 +1,41 @@
using System; using System;
namespace HostAvailabilityMonitor.Monitoring namespace HostAvailabilityMonitor.Monitoring
{ {
public enum TargetKind public enum TargetKind
{ {
Unknown = 0, Unknown = 0,
Unc = 1, Unc = 1,
Http = 2, Http = 2,
Https = 3, Https = 3,
Sftp = 4, Sftp = 4,
Tcp = 5, Tcp = 5,
Icmp = 6, Icmp = 6,
Host = 7, Host = 7,
Ftp = 8 Ftp = 8,
} File = 9
}
public sealed class ProbeResult
{ public sealed class ProbeResult
public string StateKey { get; set; } {
public string Name { get; set; } public string StateKey { get; set; }
public string Endpoint { get; set; } public string Name { get; set; }
public TargetKind Kind { get; set; } public string ApplicationName { get; set; }
public bool IsSuccess { get; set; } public string Endpoint { get; set; }
public string StatusText { get; set; } public TargetKind Kind { get; set; }
public string Detail { get; set; } public bool IsSuccess { get; set; }
public string Host { get; set; } public bool IsSkipped { get; set; }
public int? Port { get; set; } public string StatusText { get; set; }
public int? HttpStatusCode { get; set; } public string Detail { get; set; }
public string ErrorType { get; set; } public string Host { get; set; }
public DateTimeOffset StartedAtLocal { get; set; } public int? Port { get; set; }
public DateTimeOffset FinishedAtLocal { get; set; } public int? HttpStatusCode { get; set; }
public TimeSpan Duration public string ErrorType { get; set; }
{ public DateTimeOffset StartedAtLocal { get; set; }
get { return FinishedAtLocal - StartedAtLocal; } public DateTimeOffset FinishedAtLocal { get; set; }
} public TimeSpan Duration
} {
} get { return FinishedAtLocal - StartedAtLocal; }
}
}
}
@@ -1,196 +1,334 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Mail; using System.Net.Mail;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using HostAvailabilityMonitor.Configuration; using HostAvailabilityMonitor.Configuration;
using HostAvailabilityMonitor.Monitoring; using HostAvailabilityMonitor.Monitoring;
using HostAvailabilityMonitor.State; using HostAvailabilityMonitor.State;
namespace HostAvailabilityMonitor.Notifications namespace HostAvailabilityMonitor.Notifications
{ {
public sealed class EmailNotifier public sealed class EmailNotifier
{ {
private readonly EmailOptions _options; private readonly EmailOptions _options;
private readonly string _monitorName;
public EmailNotifier(EmailOptions options) private readonly string _environmentName;
{
_options = options ?? new EmailOptions(); public EmailNotifier(EmailOptions options, string monitorName, string environmentName)
} {
_options = options ?? new EmailOptions();
public bool IsEnabled _monitorName = string.IsNullOrWhiteSpace(monitorName) ? "HostAvailabilityMonitor" : monitorName.Trim();
{ _environmentName = string.IsNullOrWhiteSpace(environmentName) ? "Unspecified Environment" : environmentName.Trim();
get { return _options.Enabled; } }
}
public bool IsEnabled
public bool ShouldNotify(ProbeResult result, TargetState previousState) {
{ get { return _options.Enabled; }
if (!IsEnabled || result == null) }
{
return false; public bool ShouldNotify(ProbeResult result, TargetState previousState)
} {
if (!IsEnabled || result == null)
if (result.IsSuccess && !_options.SendOnRecovery) {
{ return false;
return false; }
}
if (result.IsSkipped)
if (!result.IsSuccess && !_options.SendOnFailure) {
{ return false;
return false; }
}
var nowUtc = DateTime.UtcNow;
var nowUtc = DateTime.UtcNow; var cooldown = TimeSpan.FromMinutes(Math.Max(0, _options.CooldownMinutes));
var cooldown = TimeSpan.FromMinutes(Math.Max(0, _options.CooldownMinutes)); var cooldownPassed = previousState == null
var cooldownPassed = previousState == null || !previousState.LastNotificationUtc.HasValue
|| !previousState.LastNotificationUtc.HasValue || cooldown == TimeSpan.Zero
|| cooldown == TimeSpan.Zero || (nowUtc - previousState.LastNotificationUtc.Value) >= cooldown;
|| (nowUtc - previousState.LastNotificationUtc.Value) >= cooldown;
if (IsRecoveryTransition(result, previousState))
if (_options.OnlyOnStateChange) {
{ return _options.SendOnRecovery && cooldownPassed;
if (previousState == null) }
{
return !result.IsSuccess && cooldownPassed; if (result.IsSuccess)
} {
return false;
if (previousState.IsUp == result.IsSuccess) }
{
return false; if (!_options.SendOnFailure)
} {
return false;
return cooldownPassed; }
}
if (_options.OnlyOnStateChange)
if (result.IsSuccess && previousState == null) {
{ return previousState == null || previousState.IsUp;
return false; }
}
return cooldownPassed;
return cooldownPassed; }
}
public bool ShouldSendDailySummary(MonitorStateMetadata metadata, DateTime nowLocal)
public async Task SendAsync( {
IReadOnlyCollection<ProbeResult> failures, if (!IsEnabled || !_options.SendDailySummary)
IReadOnlyCollection<ProbeResult> recoveries, {
IDictionary<string, TargetState> currentState, return false;
CancellationToken cancellationToken) }
{
if (!IsEnabled) var trigger = new TimeSpan(Math.Max(0, _options.DailySummaryHourLocal), Math.Max(0, _options.DailySummaryMinuteLocal), 0);
{ if (nowLocal.TimeOfDay < trigger)
return; {
} return false;
}
var failureCount = failures == null ? 0 : failures.Count;
var recoveryCount = recoveries == null ? 0 : recoveries.Count; var localDate = nowLocal.ToString("yyyy-MM-dd");
if (failureCount == 0 && recoveryCount == 0) return metadata == null || !string.Equals(metadata.LastDailySummarySentLocalDate, localDate, StringComparison.Ordinal);
{ }
return;
} public async Task SendAsync(
IReadOnlyCollection<ProbeResult> failures,
var subject = BuildSubject(failureCount, recoveryCount); IReadOnlyCollection<ProbeResult> recoveries,
var body = BuildBody(failures, recoveries, currentState); IDictionary<string, TargetState> currentState,
CancellationToken cancellationToken)
using (var message = new MailMessage()) {
{ if (!IsEnabled)
message.From = new MailAddress(_options.From); {
foreach (var recipient in _options.To.Where(x => !string.IsNullOrWhiteSpace(x))) return;
{ }
message.To.Add(recipient);
} var failureCount = failures == null ? 0 : failures.Count;
var recoveryCount = recoveries == null ? 0 : recoveries.Count;
message.Subject = subject; if (failureCount == 0 && recoveryCount == 0)
message.Body = body; {
message.IsBodyHtml = false; return;
message.BodyEncoding = Encoding.UTF8; }
message.SubjectEncoding = Encoding.UTF8;
var subject = BuildSubject(failureCount, recoveryCount);
using (var client = new SmtpClient(_options.SmtpHost, _options.SmtpPort)) var body = BuildBody(failures, recoveries, currentState);
{ await SendMailAsync(subject, body, cancellationToken).ConfigureAwait(false);
client.EnableSsl = _options.UseSsl; }
client.Timeout = Math.Max(1, _options.TimeoutSeconds) * 1000;
public async Task SendDailySummaryAsync(DailySummaryEmailData summary, CancellationToken cancellationToken)
if (!string.IsNullOrWhiteSpace(_options.Username)) {
{ if (!IsEnabled || summary == null)
client.Credentials = new NetworkCredential(_options.Username, _options.Password); {
} return;
}
cancellationToken.ThrowIfCancellationRequested();
await client.SendMailAsync(message).ConfigureAwait(false); var subject = BuildDailySummarySubject(summary);
} var body = BuildDailySummaryBody(summary);
} await SendMailAsync(subject, body, cancellationToken).ConfigureAwait(false);
} }
private string BuildSubject(int failureCount, int recoveryCount) private async Task SendMailAsync(string subject, string body, CancellationToken cancellationToken)
{ {
if (failureCount > 0 && recoveryCount > 0) using (var message = new MailMessage())
{ {
return string.Format("{0} {1} Ausfall/Ausfälle, {2} Recovery/Recoveries", _options.SubjectPrefix, failureCount, recoveryCount); message.From = new MailAddress(_options.From);
} AddRecipients(message.To, _options.To);
AddRecipients(message.CC, _options.Cc);
if (failureCount > 0) AddRecipients(message.Bcc, _options.Bcc);
{
return string.Format("{0} {1} Ausfall/Ausfälle erkannt", _options.SubjectPrefix, failureCount); if (message.To.Count + message.CC.Count + message.Bcc.Count <= 0)
} {
throw new InvalidOperationException("Es ist kein gueltiger Empfaenger in To, Cc oder Bcc konfiguriert.");
return string.Format("{0} {1} Recovery/Recoveries erkannt", _options.SubjectPrefix, recoveryCount); }
}
message.Subject = subject;
private string BuildBody(IReadOnlyCollection<ProbeResult> failures, IReadOnlyCollection<ProbeResult> recoveries, IDictionary<string, TargetState> currentState) message.Body = body;
{ message.IsBodyHtml = false;
var sb = new StringBuilder(); message.BodyEncoding = Encoding.UTF8;
sb.AppendLine("Host Availability Monitor"); message.SubjectEncoding = Encoding.UTF8;
sb.AppendLine("Zeitpunkt: " + DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"));
sb.AppendLine(); using (var client = new SmtpClient(_options.SmtpHost, _options.SmtpPort))
{
if (failures != null && failures.Count > 0) client.EnableSsl = _options.UseSsl;
{ client.Timeout = Math.Max(1, _options.TimeoutSeconds) * 1000;
sb.AppendLine("Fehlgeschlagene Checks:"); client.UseDefaultCredentials = _options.UseDefaultCredentials;
foreach (var failure in failures.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
{ if (!_options.UseDefaultCredentials && !string.IsNullOrWhiteSpace(_options.Username))
AppendResult(sb, failure, currentState); {
} client.Credentials = new NetworkCredential(_options.Username, _options.Password);
sb.AppendLine(); }
}
cancellationToken.ThrowIfCancellationRequested();
if (recoveries != null && recoveries.Count > 0) await client.SendMailAsync(message).ConfigureAwait(false);
{ }
sb.AppendLine("Wiederhergestellte Checks:"); }
foreach (var recovery in recoveries.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)) }
{
AppendResult(sb, recovery, currentState); private static void AddRecipients(MailAddressCollection collection, IEnumerable<string> recipients)
} {
sb.AppendLine(); if (collection == null || recipients == null)
} {
return;
return sb.ToString(); }
}
foreach (var recipient in recipients.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase))
private static void AppendResult(StringBuilder sb, ProbeResult result, IDictionary<string, TargetState> currentState) {
{ collection.Add(recipient);
sb.AppendLine("- Name: " + result.Name); }
sb.AppendLine(" Endpoint: " + result.Endpoint); }
sb.AppendLine(" Status: " + result.StatusText);
sb.AppendLine(" Detail: " + result.Detail); private static bool IsRecoveryTransition(ProbeResult result, TargetState previousState)
if (!string.IsNullOrWhiteSpace(result.Host)) {
{ return result != null
sb.AppendLine(" Host: " + result.Host); && result.IsSuccess
} && previousState != null
if (result.Port.HasValue) && !previousState.IsUp;
{ }
sb.AppendLine(" Port: " + result.Port.Value);
} private string BuildSubject(int failureCount, int recoveryCount)
sb.AppendLine(" Dauer(ms): " + Math.Max(0L, (long)result.Duration.TotalMilliseconds)); {
var prefix = string.IsNullOrWhiteSpace(_options.SubjectPrefix) ? "[HostAvailabilityMonitor]" : _options.SubjectPrefix.Trim();
TargetState state; var envPart = "[" + _environmentName + "]";
if (currentState != null && currentState.TryGetValue(result.StateKey, out state) && state != null)
{ if (failureCount > 0 && recoveryCount > 0)
sb.AppendLine(" ConsecutiveFailures: " + state.ConsecutiveFailures); {
sb.AppendLine(" LastChangedUtc: " + state.LastChangedUtc.ToString("yyyy-MM-dd HH:mm:ss") + "Z"); return string.Format("{0} {1} {2} Ausfall/Ausfälle, {3} Recovery/Recoveries", prefix, envPart, failureCount, recoveryCount);
} }
}
} if (failureCount > 0)
} {
return string.Format("{0} {1} {2} Ausfall/Ausfälle erkannt", prefix, envPart, failureCount);
}
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(_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 / Failed checks:");
foreach (var failure in failures.OrderBy(x => x.ApplicationName, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
{
AppendResult(sb, failure, currentState, "DOWN");
}
sb.AppendLine();
}
if (recoveries != null && recoveries.Count > 0)
{
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, "DOWN -> UP");
}
sb.AppendLine();
}
return sb.ToString();
}
private string BuildDailySummaryBody(DailySummaryEmailData summary)
{
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);
}
if (result.Port.HasValue)
{
sb.AppendLine(" Port: " + result.Port.Value);
}
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)
{
sb.AppendLine(" ConsecutiveFailures: " + state.ConsecutiveFailures);
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);
}
}
+387 -251
View File
@@ -1,251 +1,387 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using HostAvailabilityMonitor.Configuration; using HostAvailabilityMonitor.Configuration;
using HostAvailabilityMonitor.Logging; using HostAvailabilityMonitor.Logging;
using HostAvailabilityMonitor.Monitoring; using HostAvailabilityMonitor.Monitoring;
using HostAvailabilityMonitor.Notifications; using HostAvailabilityMonitor.Notifications;
using HostAvailabilityMonitor.State; using HostAvailabilityMonitor.State;
namespace HostAvailabilityMonitor namespace HostAvailabilityMonitor
{ {
internal static class Program internal static class Program
{ {
private static int Main(string[] args) private static int Main(string[] args)
{ {
return MainAsync(args).GetAwaiter().GetResult(); return MainAsync(args).GetAwaiter().GetResult();
} }
private static async Task<int> MainAsync(string[] args) private static async Task<int> MainAsync(string[] args)
{ {
FileLogger logger = null; FileLogger logger = null;
WindowsEventLogger eventLogger = null; WindowsEventLogger eventLogger = null;
var nonZeroExitCodeOnExecutionError = true; var nonZeroExitCodeOnExecutionError = true;
try try
{ {
var baseDirectory = AppDomain.CurrentDomain.BaseDirectory; var baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
var configPath = args != null && args.Length > 0 && !string.IsNullOrWhiteSpace(args[0]) var configPath = args != null && args.Length > 0 && !string.IsNullOrWhiteSpace(args[0])
? args[0] ? args[0]
: Path.Combine(baseDirectory, "appsettings.json"); : Path.Combine(baseDirectory, "appsettings.json");
var config = MonitorConfiguration.Load(configPath); var config = MonitorConfiguration.Load(configPath);
config.Validate(); config.Validate();
nonZeroExitCodeOnExecutionError = config.Runtime.NonZeroExitCodeOnExecutionError; nonZeroExitCodeOnExecutionError = config.Runtime.NonZeroExitCodeOnExecutionError;
var logDirectory = ResolvePath(baseDirectory, config.Logging.LogDirectory); var logDirectory = ResolvePath(baseDirectory, config.Logging.LogDirectory);
var stateDirectory = ResolvePath(baseDirectory, config.Runtime.StateDirectory); var stateDirectory = ResolvePath(baseDirectory, config.Runtime.StateDirectory);
var stateFilePath = Path.Combine(stateDirectory, "monitor-state.json"); 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.CleanupOldFiles();
logger.Info(config.ApplicationName + " gestartet."); logger.Info(config.ApplicationName + " gestartet. Umgebung=" + config.EnvironmentName + ", Server=" + Environment.MachineName + ".");
logger.Info("Konfiguration geladen: " + Path.GetFullPath(configPath)); logger.Info("Konfiguration geladen: " + Path.GetFullPath(configPath));
eventLogger = new WindowsEventLogger(config.EventLog, logger); eventLogger = new WindowsEventLogger(config.EventLog, logger);
eventLogger.TryInitialize(); eventLogger.TryInitialize();
eventLogger.Info(config.ApplicationName + " gestartet.", 1000); eventLogger.Info(config.ApplicationName + " gestartet. Umgebung=" + config.EnvironmentName + ", Server=" + Environment.MachineName + ".", 1000);
Directory.CreateDirectory(stateDirectory); Directory.CreateDirectory(stateDirectory);
var stateStore = new MonitorStateStore(stateFilePath); var stateStore = new MonitorStateStore(stateFilePath);
Dictionary<string, TargetState> previousState; MonitorStateDocument stateDocument;
try Dictionary<string, TargetState> previousState;
{ try
previousState = stateStore.Load(); {
} stateDocument = stateStore.LoadDocument();
catch (Exception ex) previousState = stateDocument.TargetStates;
{ }
logger.Warn("Statusdatei konnte nicht geladen werden. Es wird mit leerem Zustand fortgefahren. " + ex.GetType().Name + ": " + ex.Message); catch (Exception ex)
previousState = new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase); {
} logger.Warn("Statusdatei konnte nicht geladen werden. Es wird mit leerem Zustand fortgefahren. " + ex.GetType().Name + ": " + ex.Message);
stateDocument = new MonitorStateDocument();
var currentState = new Dictionary<string, TargetState>(previousState, StringComparer.OrdinalIgnoreCase); previousState = new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase);
var probeService = new EndpointProbeService(config.Runtime); }
var enabledTargets = config.Targets.Where(t => t != null && t.Enabled).ToList();
var currentState = new Dictionary<string, TargetState>(previousState, StringComparer.OrdinalIgnoreCase);
if (enabledTargets.Count == 0) var probeService = new EndpointProbeService(config.Runtime);
{ var enabledTargets = config.Targets.Where(t => t != null && t.Enabled).ToList();
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); if (enabledTargets.Count == 0)
return 0; {
} logger.Warn("Es sind keine aktivierten Targets vorhanden. Der Lauf endet ohne Checks.");
eventLogger.Summary(config.ApplicationName + " ausgeführt, aber es waren keine aktivierten Targets vorhanden. Umgebung=" + config.EnvironmentName + ".", false);
var results = await ExecuteChecksAsync(enabledTargets, probeService, config.Runtime.MaxConcurrency).ConfigureAwait(false); return 0;
}
foreach (var result in results.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase))
{ var results = await ExecuteChecksAsync(enabledTargets, probeService, config.Runtime.MaxConcurrency).ConfigureAwait(false);
logger.Probe(result);
TargetState previous; foreach (var result in results.OrderBy(r => r.ApplicationName, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase))
previousState.TryGetValue(result.StateKey, out previous); {
UpdateState(currentState, result, previous); logger.Probe(result);
TargetState previous;
if (!result.IsSuccess) previousState.TryGetValue(result.StateKey, out previous);
{ var isRecovery = previous != null && !previous.IsUp && result.IsSuccess;
eventLogger.Warning("Check fehlgeschlagen: " + result.Name + " | " + result.Endpoint + " | " + result.Detail, 2000); if (!result.IsSkipped)
} {
else UpdateState(currentState, result, previous);
{ }
eventLogger.InfoIfSuccessEnabled("Check erfolgreich: " + result.Name + " | " + result.Endpoint + " | " + result.Detail, 1001);
} var eventMessage = BuildEventMessage(config, result);
} if (result.IsSkipped)
{
var notifier = new EmailNotifier(config.Email); logger.Warn("Target uebersprungen. Umgebung=" + config.EnvironmentName + ", Anwendung=" + result.ApplicationName + ", Name=" + result.Name + ", Endpoint=" + result.Endpoint + ", Detail=" + result.Detail + ".");
var failuresToNotify = new List<ProbeResult>(); eventLogger.Info("Target uebersprungen. " + eventMessage, 1100);
var recoveriesToNotify = new List<ProbeResult>(); }
var notifiedKeys = new List<string>(); else if (!result.IsSuccess)
{
foreach (var result in results) eventLogger.Warning(eventMessage, 2000);
{ }
TargetState oldState; else if (isRecovery)
previousState.TryGetValue(result.StateKey, out oldState); {
if (!notifier.ShouldNotify(result, oldState)) logger.Info("Recovery erkannt. Umgebung=" + config.EnvironmentName + ", Anwendung=" + result.ApplicationName + ", Name=" + result.Name + ", Endpoint=" + result.Endpoint + ".");
{ eventLogger.Info("Recovery erkannt. " + eventMessage, 1201);
continue; }
} else
{
if (result.IsSuccess) eventLogger.InfoIfSuccessEnabled(eventMessage, 1001);
{ }
recoveriesToNotify.Add(result); }
}
else stateDocument.TargetStates = currentState;
{ stateDocument.Metadata = stateDocument.Metadata ?? new MonitorStateMetadata();
failuresToNotify.Add(result);
} var notifier = new EmailNotifier(config.Email, config.ApplicationName, config.EnvironmentName);
var failuresToNotify = new List<ProbeResult>();
notifiedKeys.Add(result.StateKey); var recoveriesToNotify = new List<ProbeResult>();
} var notifiedKeys = new List<string>();
if (notifier.IsEnabled) foreach (var result in results)
{ {
try TargetState oldState;
{ previousState.TryGetValue(result.StateKey, out oldState);
await notifier.SendAsync(failuresToNotify, recoveriesToNotify, currentState, CancellationToken.None).ConfigureAwait(false); if (!notifier.ShouldNotify(result, oldState))
if (failuresToNotify.Count > 0 || recoveriesToNotify.Count > 0) {
{ continue;
foreach (var key in notifiedKeys) }
{
TargetState stateEntry; if (result.IsSuccess)
if (currentState.TryGetValue(key, out stateEntry) && stateEntry != null) {
{ recoveriesToNotify.Add(result);
stateEntry.LastNotificationUtc = DateTime.UtcNow; }
} else
} {
failuresToNotify.Add(result);
logger.Info("Benachrichtigungs-E-Mail versendet. Failures=" + failuresToNotify.Count + ", Recoveries=" + recoveriesToNotify.Count); }
}
} notifiedKeys.Add(result.StateKey);
catch (Exception ex) }
{
logger.Error("E-Mail-Versand fehlgeschlagen: " + ex.GetType().Name + ": " + ex.Message); if (notifier.IsEnabled)
eventLogger.Error("E-Mail-Versand fehlgeschlagen: " + ex.Message, 9001); {
} try
} {
await notifier.SendAsync(failuresToNotify, recoveriesToNotify, currentState, CancellationToken.None).ConfigureAwait(false);
stateStore.Save(currentState); if (failuresToNotify.Count > 0 || recoveriesToNotify.Count > 0)
{
var total = results.Count; foreach (var key in notifiedKeys)
var ok = results.Count(r => r.IsSuccess); {
var failed = total - ok; TargetState stateEntry;
var summary = "Lauf beendet. Gesamt=" + total + ", Erfolgreich=" + ok + ", Fehlgeschlagen=" + failed + "."; if (currentState.TryGetValue(key, out stateEntry) && stateEntry != null)
logger.Info(summary); {
eventLogger.Summary(summary, failed > 0); stateEntry.LastNotificationUtc = DateTime.UtcNow;
}
return failed > 0 && config.Runtime.NonZeroExitCodeOnAnyFailure ? 2 : 0; }
}
catch (Exception ex) logger.Info("Benachrichtigungs-E-Mail versendet. Umgebung=" + config.EnvironmentName + ", Failures=" + failuresToNotify.Count + ", Recoveries=" + recoveriesToNotify.Count + ".");
{ }
if (logger != null) }
{ catch (Exception ex)
logger.Error("Fataler Fehler: " + ex.GetType().Name + ": " + ex.Message); {
} logger.Error("E-Mail-Versand fehlgeschlagen: " + ex.GetType().Name + ": " + ex.Message);
eventLogger.Error("E-Mail-Versand fehlgeschlagen. Umgebung=" + config.EnvironmentName + ". Fehler=" + ex.Message, 9001);
if (eventLogger != null) }
{ }
eventLogger.Error("Fataler Fehler: " + ex.Message, 9000);
} await TrySendDailySummaryAsync(config, notifier, stateDocument, currentState, results, logDirectory, logger, eventLogger).ConfigureAwait(false);
stateStore.Save(stateDocument);
return nonZeroExitCodeOnExecutionError ? 1 : 0;
} var total = results.Count(r => !r.IsSkipped);
} var ok = results.Count(r => r.IsSuccess);
var failed = results.Count(r => !r.IsSkipped && !r.IsSuccess);
private static async Task<List<ProbeResult>> ExecuteChecksAsync(IReadOnlyCollection<TargetOptions> targets, EndpointProbeService probeService, int maxConcurrency) var skipped = results.Count(r => r.IsSkipped);
{ var summary = config.ApplicationName + " Lauf beendet. Umgebung=" + config.EnvironmentName + ", Gesamt=" + total + ", Erfolgreich=" + ok + ", Fehlgeschlagen=" + failed + ", Uebersprungen=" + skipped + ".";
var results = new List<ProbeResult>(); logger.Info(summary);
using (var semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency))) eventLogger.Summary(summary, failed > 0);
{
var tasks = targets.Select(async target => return failed > 0 && config.Runtime.NonZeroExitCodeOnAnyFailure ? 2 : 0;
{ }
await semaphore.WaitAsync().ConfigureAwait(false); catch (Exception ex)
try {
{ if (logger != null)
return await probeService.ProbeAsync(target, CancellationToken.None).ConfigureAwait(false); {
} logger.Error("Fataler Fehler: " + ex.GetType().Name + ": " + ex.Message);
finally }
{
semaphore.Release(); if (eventLogger != null)
} {
}).ToArray(); eventLogger.Error("Fataler Fehler: " + ex.Message, 9000);
}
var completed = await Task.WhenAll(tasks).ConfigureAwait(false);
results.AddRange(completed); return nonZeroExitCodeOnExecutionError ? 1 : 0;
} }
}
return results;
} private static async Task<List<ProbeResult>> ExecuteChecksAsync(IReadOnlyCollection<TargetOptions> targets, EndpointProbeService probeService, int maxConcurrency)
{
private static void UpdateState(Dictionary<string, TargetState> currentState, ProbeResult result, TargetState previousState) var results = new List<ProbeResult>();
{ using (var semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency)))
var nowUtc = DateTime.UtcNow; {
TargetState state; var tasks = targets.Select(async target =>
{
if (previousState == null) await semaphore.WaitAsync().ConfigureAwait(false);
{ try
state = new TargetState {
{ return await probeService.ProbeAsync(target, CancellationToken.None).ConfigureAwait(false);
IsUp = result.IsSuccess, }
LastCheckedUtc = nowUtc, finally
LastChangedUtc = nowUtc, {
ConsecutiveFailures = result.IsSuccess ? 0 : 1, semaphore.Release();
LastDetail = result.Detail }
}; }).ToArray();
}
else var completed = await Task.WhenAll(tasks).ConfigureAwait(false);
{ results.AddRange(completed);
state = new TargetState }
{
IsUp = previousState.IsUp, return results;
LastCheckedUtc = nowUtc, }
LastChangedUtc = previousState.LastChangedUtc,
ConsecutiveFailures = previousState.ConsecutiveFailures, private static async Task TrySendDailySummaryAsync(
LastNotificationUtc = previousState.LastNotificationUtc, MonitorConfiguration config,
LastDetail = result.Detail EmailNotifier notifier,
}; MonitorStateDocument stateDocument,
} IDictionary<string, TargetState> currentState,
IReadOnlyCollection<ProbeResult> results,
if (previousState == null || previousState.IsUp != result.IsSuccess) string logDirectory,
{ FileLogger logger,
state.LastChangedUtc = nowUtc; WindowsEventLogger eventLogger)
} {
if (config == null || notifier == null || stateDocument == null)
state.IsUp = result.IsSuccess; {
state.LastCheckedUtc = nowUtc; return;
state.LastDetail = result.Detail; }
state.ConsecutiveFailures = result.IsSuccess ? 0 : Math.Max(1, (previousState == null ? 0 : previousState.ConsecutiveFailures) + 1);
var nowLocal = DateTime.Now;
currentState[result.StateKey] = state; if (!notifier.ShouldSendDailySummary(stateDocument.Metadata, nowLocal))
} {
return;
private static string ResolvePath(string baseDirectory, string configuredPath) }
{
if (Path.IsPathRooted(configuredPath)) try
{ {
return configuredPath; 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();
return Path.GetFullPath(Path.Combine(baseDirectory, configuredPath)); 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;
TargetState state;
if (previousState == null)
{
state = new TargetState
{
IsUp = result.IsSuccess,
LastCheckedUtc = nowUtc,
LastChangedUtc = nowUtc,
ConsecutiveFailures = result.IsSuccess ? 0 : 1,
LastDetail = result.Detail
};
}
else
{
state = new TargetState
{
IsUp = previousState.IsUp,
LastCheckedUtc = nowUtc,
LastChangedUtc = previousState.LastChangedUtc,
ConsecutiveFailures = previousState.ConsecutiveFailures,
LastNotificationUtc = previousState.LastNotificationUtc,
LastDetail = result.Detail
};
}
if (previousState == null || previousState.IsUp != result.IsSuccess)
{
state.LastChangedUtc = nowUtc;
}
state.IsUp = result.IsSuccess;
state.LastCheckedUtc = nowUtc;
state.LastDetail = result.Detail;
state.ConsecutiveFailures = result.IsSuccess ? 0 : Math.Max(1, (previousState == null ? 0 : previousState.ConsecutiveFailures) + 1);
currentState[result.StateKey] = state;
}
private static string ResolvePath(string baseDirectory, string configuredPath)
{
if (Path.IsPathRooted(configuredPath))
{
return configuredPath;
}
return Path.GetFullPath(Path.Combine(baseDirectory, configuredPath));
}
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; }
}
}
}
@@ -1,15 +1,15 @@
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
[assembly: AssemblyTitle("HostAvailabilityMonitor")] [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: AssemblyConfiguration("")]
[assembly: AssemblyCompany("JR IT Services")] [assembly: AssemblyCompany("JR IT Services")]
[assembly: AssemblyProduct("HostAvailabilityMonitor")] [assembly: AssemblyProduct("HostAvailabilityMonitor")]
[assembly: AssemblyCopyright("Copyright © JR IT Services")] [assembly: AssemblyCopyright("Copyright © JR IT Services")]
[assembly: AssemblyTrademark("")] [assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")] [assembly: AssemblyCulture("")]
[assembly: ComVisible(false)] [assembly: ComVisible(false)]
[assembly: Guid("8f55f0bf-6041-4367-93a6-777387f436bf")] [assembly: Guid("8f55f0bf-6041-4367-93a6-777387f436bf")]
[assembly: AssemblyVersion("1.0.0.0")] [assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyFileVersion("1.0.0.0")]
@@ -1,76 +1,139 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Web.Script.Serialization; using System.Web.Script.Serialization;
namespace HostAvailabilityMonitor.State namespace HostAvailabilityMonitor.State
{ {
public sealed class MonitorStateStore public sealed class MonitorStateStore
{ {
private readonly string _filePath; private readonly string _filePath;
public MonitorStateStore(string filePath) public MonitorStateStore(string filePath)
{ {
_filePath = filePath; _filePath = filePath;
} }
public Dictionary<string, TargetState> Load() public MonitorStateDocument LoadDocument()
{ {
if (!File.Exists(_filePath)) if (!File.Exists(_filePath))
{ {
return new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase); return new MonitorStateDocument();
} }
var json = File.ReadAllText(_filePath); var json = File.ReadAllText(_filePath);
var serializer = new JavaScriptSerializer(); var serializer = new JavaScriptSerializer();
var states = serializer.Deserialize<Dictionary<string, TargetState>>(json);
try
if (states == null) {
{ var document = serializer.Deserialize<MonitorStateDocument>(json);
return new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase); if (document != null && document.TargetStates != null)
} {
return Normalize(document);
return new Dictionary<string, TargetState>(states, StringComparer.OrdinalIgnoreCase); }
} }
catch
public void Save(Dictionary<string, TargetState> states) {
{ // Fall back to legacy format.
var directory = Path.GetDirectoryName(_filePath); }
if (string.IsNullOrWhiteSpace(directory))
{ try
throw new InvalidOperationException("Das Verzeichnis der Statusdatei konnte nicht ermittelt werden."); {
} var states = serializer.Deserialize<Dictionary<string, TargetState>>(json);
return new MonitorStateDocument
Directory.CreateDirectory(directory); {
TargetStates = states == null
var serializer = new JavaScriptSerializer(); ? new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase)
var json = serializer.Serialize(states ?? new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase)); : new Dictionary<string, TargetState>(states, StringComparer.OrdinalIgnoreCase),
var tempFile = _filePath + ".tmp"; Metadata = new MonitorStateMetadata()
File.WriteAllText(tempFile, json); };
}
if (File.Exists(_filePath)) catch
{ {
var backupFile = _filePath + ".bak"; return new MonitorStateDocument();
File.Replace(tempFile, _filePath, backupFile, true); }
if (File.Exists(backupFile)) }
{
File.Delete(backupFile); public Dictionary<string, TargetState> Load()
} {
} return LoadDocument().TargetStates;
else }
{
File.Move(tempFile, _filePath); public void Save(MonitorStateDocument document)
} {
} var directory = Path.GetDirectoryName(_filePath);
} if (string.IsNullOrWhiteSpace(directory))
{
public sealed class TargetState throw new InvalidOperationException("Das Verzeichnis der Statusdatei konnte nicht ermittelt werden.");
{ }
public bool IsUp { get; set; }
public int ConsecutiveFailures { get; set; } Directory.CreateDirectory(directory);
public DateTime LastCheckedUtc { get; set; }
public DateTime LastChangedUtc { get; set; } var normalized = Normalize(document ?? new MonitorStateDocument());
public DateTime? LastNotificationUtc { get; set; } var serializer = new JavaScriptSerializer();
public string LastDetail { get; set; } = string.Empty; var json = serializer.Serialize(normalized);
} var tempFile = _filePath + ".tmp";
} File.WriteAllText(tempFile, json);
if (File.Exists(_filePath))
{
var backupFile = _filePath + ".bak";
File.Replace(tempFile, _filePath, backupFile, true);
if (File.Exists(backupFile))
{
File.Delete(backupFile);
}
}
else
{
File.Move(tempFile, _filePath);
}
}
public 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
{
public bool IsUp { get; set; }
public int ConsecutiveFailures { get; set; }
public DateTime LastCheckedUtc { get; set; }
public DateTime LastChangedUtc { get; set; }
public DateTime? LastNotificationUtc { get; set; }
public string LastDetail { get; set; } = string.Empty;
}
}
+92 -70
View File
@@ -1,70 +1,92 @@
{ {
"ApplicationName": "HostAvailabilityMonitor", "ApplicationName": "HostAvailabilityMonitor",
"Runtime": { "EnvironmentName": "HIP Produktion",
"MaxConcurrency": 4, "Runtime": {
"DefaultValidateUncPathAccess": false, "MaxConcurrency": 4,
"NonZeroExitCodeOnAnyFailure": false, "DefaultValidateUncPathAccess": false,
"NonZeroExitCodeOnExecutionError": true, "NonZeroExitCodeOnAnyFailure": false,
"StateDirectory": "state" "NonZeroExitCodeOnExecutionError": true,
}, "StateDirectory": "state"
"Logging": { },
"LogDirectory": "logs", "Logging": {
"FilePrefix": "host-availability-monitor", "LogDirectory": "logs",
"RetentionDays": 5 "FilePrefix": "host-availability-monitor",
}, "RetentionDays": 5
"EventLog": { },
"Enabled": true, "EventLog": {
"LogName": "Application", "Enabled": true,
"Source": "HostAvailabilityMonitor", "LogName": "Application",
"CreateSourceIfMissing": false, "Source": "HostAvailabilityMonitor",
"WriteSuccessEntries": false, "CreateSourceIfMissing": false,
"WriteSummaryEntries": true "WriteSuccessEntries": false,
}, "WriteSummaryEntries": true
"Email": { },
"Enabled": false, "Email": {
"SmtpHost": "smtp.example.local", "Enabled": false,
"SmtpPort": 25, "SmtpHost": "smtp.example.local",
"UseSsl": false, "SmtpPort": 25,
"Username": "", "UseSsl": false,
"Password": "", "UseDefaultCredentials": false,
"From": "monitor@example.local", "Username": "",
"To": [ "Password": "",
"operations@example.local" "From": "monitor@example.local",
], "To": [
"SubjectPrefix": "[HostAvailabilityMonitor]", "operations@example.local",
"SendOnFailure": true, "integration-oncall@example.local"
"SendOnRecovery": true, ],
"OnlyOnStateChange": true, "Cc": [
"CooldownMinutes": 0, "integration-leads@example.local"
"TimeoutSeconds": 30 ],
}, "Bcc": [],
"Targets": [ "SubjectPrefix": "[HostAvailabilityMonitor]",
{ "SendOnFailure": true,
"Name": "Internes Fileshare 1", "SendOnRecovery": true,
"Endpoint": "\\\\internerserver1\\share1", "SendDailySummary": true,
"TimeoutSeconds": 8, "DailySummaryHourLocal": 14,
"ValidateUncPathAccess": false, "DailySummaryMinuteLocal": 0,
"Enabled": true "OnlyOnStateChange": true,
}, "CooldownMinutes": 0,
{ "TimeoutSeconds": 30
"Name": "Externe Webseite", },
"Endpoint": "https://host1.de/url", "Targets": [
"TimeoutSeconds": 10, {
"HttpMethod": "HEAD", "Name": "Internes Fileshare 1",
"Enabled": true "ApplicationName": "HIP FileTransfer",
}, "Endpoint": "\\\\internerserver1\\share1",
{ "TimeoutSeconds": 8,
"Name": "SFTP Partner A", "ValidateUncPathAccess": false,
"Endpoint": "sftp://partner-sftp.example.local/inbound", "Enabled": true
"TimeoutSeconds": 10, },
"Port": 22, {
"Enabled": true "Name": "Externe Webseite",
}, "ApplicationName": "HIP WebPortal",
{ "Endpoint": "https://host1.de/url",
"Name": "Custom TCP Service", "TimeoutSeconds": 10,
"Endpoint": "tcp://host2.example.local:8443", "HttpMethod": "HEAD",
"TimeoutSeconds": 10, "Enabled": true
"Enabled": true },
} {
] "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
}
]
}