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