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 + } + ] +}