diff --git a/Dokumentation.md b/Dokumentation.md index 667f4f5..1cb96d4 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -89,7 +89,7 @@ Ablauf: 2. Tageslogs gemaess `LogRetentionDays` bereinigen. 3. exklusives Handle auf `.provider.lock` halten. 4. WMI-, SQL- und Event-Log-Probes ausfuehren. -5. acht stabile und optionale dynamische Checkmk-Zeilen formatieren. +5. neun stabile und optionale dynamische Checkmk-Zeilen formatieren. 6. Snapshot in einer eindeutigen Temporaerdatei desselben Verzeichnisses schreiben. 7. `Flush(true)` ausfuehren und Temporaerdatei atomar publizieren. @@ -132,7 +132,7 @@ Der Consumer: - gibt bei Erfolg ausschliesslich den validierten Payload aus. Er gibt immer Exitcode `0` zurueck, damit ein Fehler die komplette -Checkmk-Agentsektion nicht zerstoert. Jede Ablehnung erzeugt acht +Checkmk-Agentsektion nicht zerstoert. Jede Ablehnung erzeugt neun `UNKNOWN`-Zeilen und einen Eintrag im Consumer-Log. ## 4. Snapshot-Vertrag @@ -210,7 +210,7 @@ Metrik ausgewiesen und zugleich in `suspended_nonresumable` mitgezaehlt. ### 5.2 Kompaktes Checkmk-Servicebild Die alte Sammelzeile `BizTalk Runtime Artifacts` wurde durch drei kurze -Services ersetzt. Insgesamt entstehen acht stabile Services: +Services ersetzt. Insgesamt entstehen neun stabile Services: 1. `BizTalk Platform` 2. `BizTalk SQL Access` @@ -218,8 +218,9 @@ Services ersetzt. Insgesamt entstehen acht stabile Services: 4. `BizTalk Host Instances` 5. `BizTalk Receive Locations` 6. `BizTalk Send Ports` -7. `BizTalk Orchestrations` -8. `BizTalk Event Log` +7. `BizTalk Endpoint Reachability` +8. `BizTalk Orchestrations` +9. `BizTalk Event Log` Receive Locations und Send Ports alarmieren nur fuer unerwartete Aus-Zustaende. Fachlich bewusst deaktivierte Namen werden exakt, ohne @@ -232,7 +233,46 @@ Artefakte werden als `(+n more)` zusammengefasst. `MaxDetailCharacters` begrenzt jede Detailausgabe, damit Checkmk-Ansichten kompakt bleiben, waehrend alle numerischen Metriken vollstaendig erhalten bleiben. -### 5.3 SQL-Zugriffsprobe +### 5.3 Endpoint-Discovery und Netzwerkprobe + +Die Endpoint-Funktion erweitert keine WMI-Klassenmenge. Aus den ohnehin +minuetlich abgefragten Instanzen werden nur dokumentierte Properties gelesen: + +| WMI-Klasse | Properties | +| --- | --- | +| `MSBTS_SendPort` | `Status`, `IsDynamic`, `PTAddress`, `STAddress`, `PTTransportType`, `STTransportType` | +| `MSBTS_ReceiveLocation` | `IsDisabled`, `InboundTransportURL`, `AdapterName` | + +Nur `Status=Started` beziehungsweise `IsDisabled=false` wird geprueft. +Inaktive Artefakte bleiben aus der Probe ausgeschlossen, auch wenn sie noch im +Katalog stehen. Dynamische Send Ports, lokale Dateipfade, SMTP-Empfaengerlisten +und Named Pipes werden nicht als externe Host/Port-Ziele behandelt. + +Der lokale Katalog +`%ProgramData%\BizTalkCheckmkPulse\data\endpoints.xml` wird beim ersten +erfolgreichen Lauf erzeugt und danach standardmaessig alle 168 Stunden atomar +abgeglichen. Er enthaelt keine vollstaendigen URIs, Querystrings, +Benutzernamen oder Kennwoerter, sondern nur Artefaktbezug, Adapter, Host, Port +und `TCP`/`UDP`. Automatisch erzeugte Eintraege tragen +`autoDiscovered="true"`. Ein bewusst manueller Override wird mit +`autoDiscovered="false"` markiert und beim Wochenabgleich erhalten; sein +`key` muss dem Artefakteintrag entsprechen. Eigenstaendige manuelle Ziele +verwenden `artifactType="Manual"`. + +HTTP(S), SFTP, FTP, UNC/SMB, WCF und `net.tcp` werden ausschliesslich per +TCP-Connect geprueft. Es findet kein fachlicher Request und keine Anmeldung +statt. Explizite `udp://host:port`-Eintraege senden ein einzelnes Datagramm; +das bestaetigt DNS, Route und lokalen Versand, kann ohne +applikationsspezifisches Antwortprotokoll aber keinen entfernten Dienstzustand +beweisen. Gemeinsame Host/Port-Ziele werden dedupliziert und mit begrenzter +Parallelitaet getestet. + +Der Checkmk-Service nennt im OK-Fall nur die Gesamtzahl. Bei Ausfaellen werden +ausschliesslich die nicht erreichbaren Artefakte in `unavailable=` angezeigt; +`MaxSummaryItems` und `MaxDetailCharacters` begrenzen die Darstellung. Alle +Einzelfehler bleiben vollstaendig im Provider-Log. + +### 5.4 SQL-Zugriffsprobe Der Provider oeffnet fuer Management- und Master-MessageBox-Datenbank eine kurze `System.Data.SqlClient`-Verbindung mit integrierter @@ -243,7 +283,7 @@ Die Ausgabe `execution_identity=` zeigt deshalb das Provider-Konto, nicht mehr `NT AUTHORITY\SYSTEM`. Der Check beweist, dass genau das Scheduled-Task-Konto die Ziele erreichen kann. -### 5.4 Event Log +### 5.5 Event Log Der Provider liest das lokale Application Log im konfigurierten Zeitfenster und filtert Quellen wie `BizTalk Server`, `XLANG/s`, `ENTSSO`, @@ -378,7 +418,7 @@ Snapshot nicht zusaetzlich verzoegert. Nach dem Agent-Dump: 1. Service Discovery fuer den BizTalk-Host ausfuehren. -2. acht stabile Services aufnehmen; den alten Service +2. neun stabile Services aufnehmen; den alten Service `BizTalk Runtime Artifacts` nach erfolgreicher Discovery entfernen. 3. Changes aktivieren. 4. Views/Benachrichtigungen nach Umgebung konfigurieren. @@ -485,6 +525,8 @@ Runtime: | `SnapshotMaxBytes` | `1048576` | 4096 bis 16777216 | | `LogDirectory` | `%ProgramData%\BizTalkCheckmkPulse\logs` | absolut | | `LogRetentionDays` | `30` | 1 bis 365 | +| `EndpointCatalogPath` | `%ProgramData%\BizTalkCheckmkPulse\data\endpoints.xml` | absolut | +| `EndpointCatalogMaxBytes` | `1048576` | 4096 bis 16777216 | BizTalk-Probes: @@ -496,6 +538,11 @@ BizTalk-Probes: | `SqlConnectionTimeoutSeconds` | `5` | | `ProbeEventLog` | `true` | | `EventLogLookbackMinutes` | `60` | +| `ProbeEndpointConnectivity` | `true` | +| `EndpointDiscoveryIntervalHours` | `168` | +| `EndpointProbeTimeoutMilliseconds` | `3000` | +| `EndpointProbeMaxConcurrency` | `12` | +| `EndpointMaxCount` | `500` | Alarmierung: @@ -519,7 +566,7 @@ Alarmierung: Automatisiert: - Release-Build .NET Framework 4.7.2, -- exakt acht Self-Test-Services, +- exakt neun Self-Test-Services, - unbekannte Quellen werden `UNKNOWN`, - dynamische Anwendungsservices nur bei bekannter Anwendung, - WMI-Queryvertrag nutzt dokumentierte Properties, @@ -529,10 +576,13 @@ Automatisiert: - Receive-/Send-Allowlisten trennen bewusste von unerwarteten Aus-Zustaenden, - ein fehlender `IsDisabled`-Wert wird `UNKNOWN` statt still als enabled, - betroffene Namen und Gesamtlaenge der Summary bleiben begrenzt, +- Endpoint-Adressen werden ohne URI-Secrets auf Host/Port reduziert, +- manuelle Endpoint-Overrides ueberleben den automatischen Wochenabgleich, +- nur nicht erreichbare Endpoints erscheinen im Checkmk-Detail, - Snapshot-Roundtrip und Ersatz, - SHA-256-Manipulation wird verworfen, - Stale-Snapshot wird verworfen, -- Consumer-Fallback enthaelt acht `UNKNOWN`-Services. +- Consumer-Fallback enthaelt neun `UNKNOWN`-Services. Windows-/ACC-Abnahme: @@ -549,9 +599,17 @@ Windows-/ACC-Abnahme: 9. Einen Testnamen aus der Allowlist entfernen und den erwarteten CRIT mit kurzem `affected=`-Detail pruefen. 10. ACL-Test: `LocalSystem` kann Snapshot lesen, nicht schreiben. -11. Task deaktivieren: nach 180 Sekunden acht stale-`UNKNOWN`s. -12. Task wieder aktivieren: naechster Snapshot stellt Echtzustand her. -13. Agent-Dump und Checkmk Service Discovery erfolgreich. +11. `endpoints.xml` wurde erzeugt; sie enthaelt keine vollstaendigen URIs oder + Zugangsdaten und nur Started-/Enabled-Artefakte. +12. Einen erreichbaren Testport sperren: nur `Endpoint Reachability` wird CRIT + und nennt ausschliesslich das nicht erreichbare Ziel. +13. Ein Artefakt stoppen/deaktivieren: es wird im naechsten Minutenlauf nicht + mehr geprueft. +14. Katalogzeit auf aelter als 168 Stunden setzen beziehungsweise Intervall + testweise reduzieren: erfolgreicher atomarer Umgebungsabgleich. +15. Task deaktivieren: nach 180 Sekunden neun stale-`UNKNOWN`s. +16. Task wieder aktivieren: naechster Snapshot stellt Echtzustand her. +17. Agent-Dump und Checkmk Service Discovery erfolgreich. Erst nach ACC-Abnahme erfolgt der gestufte Rollout nach DEV/TST/PRD. diff --git a/README.md b/README.md index 99037d1..9bdb2c2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ lokales BizTalk-WMI + BizTalk-SQL + Application Event Log Checkmk Windows Agent (LocalSystem) | v -acht kompakte Checkmk Local Checks +neun kompakte Checkmk Local Checks ``` Damit bekommt `LocalSystem` keine BizTalk- oder SQL-Berechtigung. Nur das @@ -62,7 +62,7 @@ Der Datenaustausch ist bewusst defensiv: Checkmk-Zeilen, korrekte SHA-256-Pruefsumme und ein maximales Alter von standardmaessig 180 Sekunden. - Fehlende, veraltete, abgeschnittene, manipulierte oder unlesbare Dateien - ergeben acht gueltige `UNKNOWN`-Services statt einer kaputten Agent-Ausgabe. + ergeben neun gueltige `UNKNOWN`-Services statt einer kaputten Agent-Ausgabe. - Ein exklusives Lock und die Task-Einstellung `IgnoreNew` verhindern ueberlappende Providerlaeufe. - Ein unerwarteter Providerfehler erzeugt nach Moeglichkeit einen aktuellen @@ -82,6 +82,7 @@ Standardmaessig entstehen: - `BizTalk Host Instances` - `BizTalk Receive Locations` - `BizTalk Send Ports` +- `BizTalk Endpoint Reachability` - `BizTalk Orchestrations` - `BizTalk Event Log` @@ -91,8 +92,25 @@ Zustand und Graphen direkt erkennbar, ohne eine lange Sammelzeile zu lesen: - Suspensions: total, resumable, non-resumable und Routing Failure Reports - Receive Locations: total, enabled, unerwartet/bewusst disabled und unbekannt - Send Ports: total, started, stopped, bound, unbekannt sowie bewusst inactive +- Endpoint Reachability: nur aktive Send-/Receive-Artefakte; im OK-Fall nur + eine Gesamtaussage, im Fehlerfall ausschliesslich nicht erreichbare Ziele - Orchestrations: total, started, stopped, bound, unbound und unbekannt +Der Provider nutzt fuer die Endpoint-Discovery keine neue WMI-Klasse. Er liest +`PTAddress`, `STAddress`, `PTTransportType`, `STTransportType`, +`InboundTransportURL` und `AdapterName` aus den bereits vorhandenen +`MSBTS_SendPort`-/`MSBTS_ReceiveLocation`-Abfragen. Der Check selbst ist ein +reiner Host/Port-Test: HTTP(S), SFTP, FTP, WCF/net.tcp und UNC werden per TCP +geprueft; explizite `udp://`-Ziele per UDP-Datagramm. Es werden keine +HTTP-Requests, Anmeldungen oder fachlichen Nachrichten gesendet. + +Der geheimnisfreie Katalog liegt unter +`%ProgramData%\BizTalkCheckmkPulse\data\endpoints.xml`. Fehlt er, wird er beim +naechsten erfolgreichen Providerlauf erstellt. Alle 168 Stunden wird er gegen +die Umgebung abgeglichen. Automatisch verwaltete Eintraege fuer inzwischen +inaktive Artefakte verschwinden beim Abgleich; bei jedem Minutenlauf werden +sie zusaetzlich gegen den aktuellen Started-/Enabled-Zustand gefiltert. + Pro Service werden standardmaessig maximal fuenf betroffene Namen gezeigt. Weitere Treffer erscheinen nur als `(+n more)`; Details sind zusaetzlich auf 1600 Zeichen begrenzt. Metriken bleiben trotzdem vollstaendig. @@ -104,6 +122,9 @@ Statuslogik und Metriken: [docs/CheckmkServices.md](docs/CheckmkServices.md) Beispielausgaben: [docs/ExampleOutput.md](docs/ExampleOutput.md) +Endpoint-Katalog, manuelle Overrides und Protokollgrenzen: +[docs/EndpointCatalog.md](docs/EndpointCatalog.md) + ## Voraussetzungen Build-Host: @@ -161,7 +182,7 @@ Format-Self-Test ohne WMI, SQL oder Event Log: artifacts\BizTalkCheckmkPulse-deploy\application\BizTalkCheckmkPulse.exe --self-test ``` -Erwartet werden exakt acht `OK`-Zeilen. Die Regressionstests pruefen +Erwartet werden exakt neun `OK`-Zeilen. Die Regressionstests pruefen zusaetzlich Snapshot-Roundtrip, atomaren Ersatz, SHA-256-Manipulation, Stale-Erkennung, stabile Fallbacks und die bestehenden BizTalk-WMI-Diagnosen. Ein Mono-Build ist eine hilfreiche Quellcodepruefung, ersetzt aber nicht die @@ -207,7 +228,7 @@ Konfigurationsdatei noch in den Logs. Wenn das Kennwort rotiert oder ablaeuft, muss es im Scheduled Task aktualisiert werden. Bis dahin wird der Snapshot nach 180 Sekunden stale und Checkmk zeigt -alle acht Services als `UNKNOWN`. +alle neun Services als `UNKNOWN`. Ein gMSA kann weiterhin optional mit `-Gmsa` installiert werden; die produktive Standardbeschreibung geht vom normalen Servicekonto aus. @@ -268,7 +289,7 @@ Verbindlicher Test im echten `LocalSystem`-Kontext: Select-String -Pattern "BizTalk|UNKNOWN|Snapshot|Permission" -Context 0,1 ``` -Danach in Checkmk eine Service Discovery ausfuehren, die acht Services +Danach in Checkmk eine Service Discovery ausfuehren, die neun Services aufnehmen und Changes aktivieren. Ein zusaetzlicher Checkmk-Async-Cache ist nicht erforderlich: Der Consumer liest nur eine kleine lokale Datei und der Provider besitzt bereits seinen eigenen Minutentakt. @@ -284,6 +305,7 @@ Provider besitzt bereits seinen eigenen Minutentakt. data\ biztalk-checkmk-pulse.snapshot biztalk-checkmk-pulse.snapshot.provider.lock + endpoints.xml logs\ biztalk-checkmk-pulse-YYYYMMDD.log @@ -321,6 +343,12 @@ Wichtige Werte: | `SnapshotMaxBytes` | `1048576` | Harte Eingabegroesse fuer den Consumer. | | `LogDirectory` | `%ProgramData%\BizTalkCheckmkPulse\logs` | Tageslogs. | | `LogRetentionDays` | `30` | Provider bereinigt aeltere Logs. | +| `ProbeEndpointConnectivity` | `true` | Aktiviert den aggregierten TCP-/UDP-Netzwerkcheck. | +| `EndpointCatalogPath` | `%ProgramData%\BizTalkCheckmkPulse\data\endpoints.xml` | Lokal gepflegte Endpoint-Konfiguration ohne vollstaendige URIs/Secrets. | +| `EndpointDiscoveryIntervalHours` | `168` | Intervall fuer den vollstaendigen Umgebungsabgleich. | +| `EndpointProbeTimeoutMilliseconds` | `3000` | Timeout je dedupliziertem Host/Port-Ziel. | +| `EndpointProbeMaxConcurrency` | `12` | Begrenzte parallele Socket-Probes. | +| `EndpointMaxCount` | `500` | Harte Obergrenze gegen fehlerhafte/uebergrosse Konfiguration. | | `QueryTimeoutSeconds` | `25` | WMI-Timeout je Query. | | `SqlConnectionTimeoutSeconds` | `5` | SQL-Timeout je Ziel. | | `WarnResumableThreshold` | `1` | WARN ab n resumable Suspensions. | @@ -342,11 +370,13 @@ liest den naechsten atomar publizierten Snapshot. | Beobachtung | Ursache / Massnahme | | --- | --- | -| Alle acht Services melden fehlenden Snapshot | Task, Provider-Log, Task-Konto/Kennwort und ACL pruefen. | +| Alle neun Services melden fehlenden Snapshot | Task, Provider-Log, Task-Konto/Kennwort und ACL pruefen. | | Snapshot ist `stale` | `LastTaskResult`, Laufzeit, WMI-/SQL-Timeout und Log pruefen. | | SHA-256 oder Format ungueltig | Datei nicht manuell bearbeiten; Datentraeger/AV und Schreibpfad pruefen, Task neu starten. | | Provider meldet `Login failed` | Provider-Konto und exakt konfigurierte Read-Only-Gruppe sowie `BTS_READONLY_USERS` pruefen. | | Receive Locations / Send Ports sind CRIT | `affected=` pruefen; nur fachlich bewusst inaktive Namen exakt in die jeweilige Allowlist aufnehmen. | +| Endpoint Reachability ist CRIT | Nur `unavailable=` pruefen; DNS, Zielport, Firewall und externen Dienst kontrollieren. | +| Endpoint Reachability ist UNKNOWN | WMI-Vollstaendigkeit, `endpoints.xml`, woechentlichen Refresh und nicht automatisch aufloesbare externe Adapteradresse pruefen. | | `Wmi/Schema` | Klasse/Properties gegen BizTalk-2020-Schema pruefen; keine Rechte ausweiten. | | Nur Event Log `UNKNOWN` | lokalen Application-Log-Zugriff des Provider-Kontos pruefen. | | Task-Result `2` | Parallelstart oder Snapshot-I/O; Log und Lock/ACL pruefen. | @@ -388,6 +418,8 @@ Die konkrete Datei und SHA-256-Summe werden bei der Uebergabe genannt. - Microsoft: BizTalk `MSBTS_GroupSetting.BizTalkReadOnlyUserGroup` - Microsoft: Windows Groups and User Accounts in BizTalk Server - Microsoft: Managing BizTalk Server Security +- Microsoft: [`MSBTS_SendPort` (WMI)](https://learn.microsoft.com/en-us/biztalk/core/technical-reference/msbts-sendport-wmi) +- Microsoft: [`MSBTS_ReceiveLocation` (WMI)](https://learn.microsoft.com/en-us/biztalk/core/technical-reference/msbts-receivelocation-wmi) - Checkmk: Windows Agent und Local Checks Die genauen Links stehen in [Dokumentation.md](Dokumentation.md). diff --git a/deployment/Install-BizTalkCheckmkPulse.ps1 b/deployment/Install-BizTalkCheckmkPulse.ps1 index 73ea7ad..190e3d7 100644 --- a/deployment/Install-BizTalkCheckmkPulse.ps1 +++ b/deployment/Install-BizTalkCheckmkPulse.ps1 @@ -155,8 +155,8 @@ else { Write-Host "Fuehre formatseitigen Self-Test aus ..." $selfTest = & $targetExe --self-test -if ($LASTEXITCODE -ne 0 -or @($selfTest).Count -ne 8) { - throw "Self-Test fehlgeschlagen. Erwartet wurden acht Checkmk-Zeilen." +if ($LASTEXITCODE -ne 0 -or @($selfTest).Count -ne 9) { + throw "Self-Test fehlgeschlagen. Erwartet wurden neun Checkmk-Zeilen." } Write-Host "Starte den Provider einmalig ..." diff --git a/docs/CheckmkServices.md b/docs/CheckmkServices.md index e3eaa05..2faad1f 100644 --- a/docs/CheckmkServices.md +++ b/docs/CheckmkServices.md @@ -11,7 +11,7 @@ Die Ausgabe ist so gebaut, dass sie auch mit Checkmk 2.4.0p4 kompatibel bleibt: Die fachlichen Daten werden minuetlich vom privilegierten Scheduled-Task- Provider erzeugt. Der Checkmk-Agent liest als `LocalSystem` nur den integritaets- und altersgeprueften Snapshot. Ist dieser Transport nicht -verfuegbar, liefern alle acht stabilen Services `UNKNOWN`. +verfuegbar, liefern alle neun stabilen Services `UNKNOWN`. ## BizTalk Platform @@ -174,6 +174,46 @@ Metriken: | `biztalk_send_ports_expected_inactive` | Bewusst inactive und exakt allowlisted. | | `biztalk_send_ports_unknown` | Nicht interpretierbarer Send-Port-Status. | +## BizTalk Endpoint Reachability + +Zweck: + +- Prueft nur Receive Locations mit `IsDisabled=false` und Send Ports mit + `Status=Started`. +- Reduziert die Adresse auf Host/Port und fuehrt keinen fachlichen + HTTP-, FTP-, SFTP-, WCF- oder BizTalk-Aufruf aus. +- Dedupliziert gemeinsame Host/Port-Ziele und prueft sie mit begrenzter + Parallelitaet; das Ergebnis wird danach wieder den Artefakten zugeordnet. +- Zeigt im gesunden Zustand nur `Alle n ... erreichbar`. Nur bei Fehlern + erscheint die begrenzte Liste `unavailable=`. + +Status: + +| Status | Bedingung | +| --- | --- | +| `OK` | Katalog und Runtimezustand vollstaendig; alle aktiven pruefbaren Ziele erreichbar. | +| `CRIT` | Mindestens ein getestetes TCP-/UDP-Ziel nicht erreichbar. | +| `UNKNOWN` | Katalog fehlt/ist ungueltig, Wochenabgleich scheitert, Runtimezustand ist unvollstaendig oder ein aktiver externer Endpoint kann nicht sicher auf Host/Port reduziert werden. | +| `OK` mit Hinweis | Nur wenn `ProbeEndpointConnectivity=false`; der Test ist bewusst deaktiviert. | + +Metriken: + +| Metrik | Bedeutung | +| --- | --- | +| `biztalk_endpoints_configured` | Eintraege in `endpoints.xml`, einschliesslich derzeit inaktiver. | +| `biztalk_endpoints_active` | Im aktuellen Lauf Started/Enabled und fuer die Probe ausgewaehlt. | +| `biztalk_endpoints_tested` | Vollstaendig abgeschlossene Artefaktprobes. | +| `biztalk_endpoints_available` | Erreichbare aktive Artefaktziele. | +| `biztalk_endpoints_failed` | Nicht erreichbare aktive Artefaktziele; CRIT ab 1. | +| `biztalk_endpoints_unsupported` | Aktive externe Adressen ohne sicher bestimmbaren Host/Port. | +| `biztalk_endpoints_inactive_skipped` | Konfigurierte Eintraege, deren BizTalk-Artefakt aktuell nicht aktiv ist. | + +Bei expliziten `udp://host:port`-Eintraegen kann ein generischer Check ohne +applikationsspezifisches Protokoll nur DNS, Route und erfolgreichen lokalen +Datagrammversand bestaetigen. Er beweist keine Antwort des entfernten +UDP-Dienstes. Fuer alle ueblichen streambasierten Adapter wird deshalb TCP +verwendet. + ## BizTalk Orchestrations Status: diff --git a/docs/EndpointCatalog.md b/docs/EndpointCatalog.md new file mode 100644 index 0000000..976aca2 --- /dev/null +++ b/docs/EndpointCatalog.md @@ -0,0 +1,98 @@ +# Endpoint-Katalog und Netzwerkprobe + +## Automatischer Betrieb + +Der privilegierte Provider liest bei seiner bereits vorhandenen +`MSBTS_SendPort`-/`MSBTS_ReceiveLocation`-Abfrage auch die dokumentierten +Transportadressen. Fehlt die Datei + +```text +%ProgramData%\BizTalkCheckmkPulse\data\endpoints.xml +``` + +wird sie im ersten erfolgreichen Lauf erzeugt. Danach erfolgt der vollstaendige +Abgleich alle `EndpointDiscoveryIntervalHours` (Default: 168 Stunden). Ein +Fehler beim Abgleich ersetzt keinen letzten gueltigen Katalog. Checkmk meldet +den ueberfaelligen/fehlgeschlagenen Abgleich trotzdem als `UNKNOWN`. + +Unabhaengig vom Wochenabgleich wird bei jedem Minutenlauf gegen den aktuellen +BizTalk-Runtimezustand gefiltert: + +- Receive Location nur bei `IsDisabled=false` +- Send Port nur bei `Status=Started` +- dynamischer Send Port nie als statisches Ziel + +Damit wird ein inzwischen gestopptes/deaktiviertes Artefakt sofort nicht mehr +geprueft. Neue Artefakte werden spaetestens beim naechsten Wochenabgleich +aufgenommen. + +## Sicherheitsmodell + +Der Katalog speichert nicht die vollstaendige BizTalk-Adresse. Persistiert +werden nur: + +- Artefakttyp, Anwendung, Name und Transportrolle +- Adaptername +- `TCP` oder `UDP` +- Host und Port +- `enabled` und `autoDiscovered` + +URI-Benutzerinformationen, Kennwoerter, Pfade und Querystrings werden nicht +geschrieben. Der Katalog liegt im bereits ACL-geschuetzten `data`-Verzeichnis; +nur Provider und Administratoren duerfen ihn aendern. `LocalSystem` liest nur +den separaten, validierten Checkmk-Snapshot. + +## Manuelle Korrektur eines automatisch nicht aufloesbaren Endpoints + +Vor einer manuellen Aenderung den Scheduled Task kurz stoppen und die Datei +sichern. Einen vorhandenen Eintrag auf den gewuenschten Host/Port korrigieren +und `autoDiscovered="false"` setzen. Der vorhandene `key` bleibt unveraendert: + +```xml + +``` + +Manuelle Eintraege werden beim Wochenabgleich erhalten. Stimmen Artefakttyp +und `key` mit einem Send-/Receive-Artefakt ueberein, gilt weiterhin dessen +Started-/Enabled-Filter. Ein zusaetzliches, nicht an BizTalk gebundenes Ziel +kann `artifactType="Manual"` verwenden; es wird bei `enabled="true"` immer +geprueft. + +Nach dem Speichern Task wieder starten und pruefen: + +```powershell +Start-ScheduledTask -TaskName 'BizTalk Checkmk Pulse Provider' +Get-Content "$env:ProgramData\BizTalkCheckmkPulse\logs\*.log" -Tail 100 +& "$env:ProgramFiles\BizTalkCheckmkPulse\BizTalkCheckmkPulse.exe" --consume +``` + +Ungueltiges XML, doppelte Keys, falsche Maschine/Umgebung, unbekannte +Protokolle, ungueltige Ports und uebergrosse Dateien werden verworfen. Der +Provider kann bei vollstaendig verfuegbarem BizTalk-Runtimezustand einen +ungueltigen rein automatisch erzeugten Katalog neu aufbauen. Deshalb muss ein +manueller Eintrag vor dem produktiven Einsatz gesichert und im Provider-Log +verifiziert werden. + +## Protokollgrenzen + +TCP-Connect ist fuer HTTP(S), SFTP, FTP, SMB/UNC, WCF und `net.tcp` der +passende nebenwirkungsfreie Netzwerktest. Er bestaetigt DNS, Routing, +Firewall und einen annehmenden TCP-Port, aber nicht die fachliche Antwort der +Zielanwendung. + +UDP ist verbindungslos. Bei `udp://host:port` bestaetigt der Check nur DNS, +Route und erfolgreichen lokalen Datagrammversand. Fuer einen belastbaren +UDP-Dienstcheck waere ein adapter-/protokollspezifisches Request/Response- +Payload erforderlich; ein solches fachliches Paket wird bewusst nicht +erfunden. diff --git a/docs/ExampleOutput.md b/docs/ExampleOutput.md index f3eed0f..10d84d2 100644 --- a/docs/ExampleOutput.md +++ b/docs/ExampleOutput.md @@ -9,6 +9,7 @@ 0 "BizTalk Host Instances" biztalk_host_instances_total=0;;;0 Self test OK. No WMI query was executed. 0 "BizTalk Receive Locations" biztalk_receive_locations_total=0;;;0 Self test OK. No WMI query was executed. 0 "BizTalk Send Ports" biztalk_send_ports_total=0;;;0 Self test OK. No WMI query was executed. +0 "BizTalk Endpoint Reachability" biztalk_endpoints_active=0;;;0 Self test OK. No network connection was opened. 0 "BizTalk Orchestrations" biztalk_orchestrations_total=0;;;0 Self test OK. No WMI query was executed. 0 "BizTalk Event Log" biztalk_eventlog_errors=0;;;0 Self test OK. No event log was read. ``` @@ -55,9 +56,23 @@ werden nicht doppelt zum Total addiert. Die Zaehler und Performance-Daten enthalten weiterhin alle 22 betroffenen Artefakte. Nur die Textliste ist begrenzt. +## Endpoint-Erreichbarkeit + +Gesund, ohne Auflistung aller Ziele: + +```text +0 "BizTalk ACC Endpoint Reachability" biztalk_endpoints_configured=63;;;0|biztalk_endpoints_active=61;;;0|biztalk_endpoints_tested=61;;;0|biztalk_endpoints_available=61;;;0|biztalk_endpoints_failed=0;;1;0|biztalk_endpoints_unsupported=0;;1;0|biztalk_endpoints_inactive_skipped=2;;;0 Alle 61 aktiven, pruefbaren Send-/Receive-Endpunkte sind erreichbar; catalog_utc=2026-08-04T10:00:00.0000000Z +``` + +Fehlerhaft, nur mit nicht erreichbaren Zielen: + +```text +2 "BizTalk ACC Endpoint Reachability" ... Endpoint reachability active=61, tested=61, available=59, failed=2, unsupported_external=0; unavailable=SendPort:Orders\SP_Order_API[Primary]->orders.example:443/TCP(TCP timeout), ReceiveLocation:Invoices\RL_SFTP[Inbound]->sftp.example:22/TCP(SocketError=ConnectionRefused) +``` + ## Fehlender oder veralteter Provider-Snapshot -Alle acht Services werden `UNKNOWN`, zum Beispiel: +Alle neun Services werden `UNKNOWN`, zum Beispiel: ```text 3 "BizTalk Platform" - Privilegierter BizTalk-Datensnapshot nicht verfuegbar: Snapshot is stale: age=241s, maximum=180s. Massnahme: Scheduled Task 'BizTalk Checkmk Pulse Provider', Provider-Log, Snapshot-Alter und ACL pruefen. diff --git a/docs/Integration.md b/docs/Integration.md index 8b3ef11..9e03290 100644 --- a/docs/Integration.md +++ b/docs/Integration.md @@ -61,7 +61,7 @@ Verbindlicher Agent-Dump: 2. `LastTaskResult=0`, frischen Snapshot und Log pruefen. 3. Agent-Dump im `LocalSystem`-Kontext pruefen. 4. Service Discovery fuer den BizTalk-Host ausfuehren. -5. acht stabile Services aufnehmen und den alten +5. neun stabile Services aufnehmen und den alten `BizTalk Runtime Artifacts`-Service entfernen. 6. Changes aktivieren. 7. Views, Servicegruppen und Benachrichtigungen einrichten. @@ -111,11 +111,13 @@ Soll: - SQL Access zeigt `targets=2`, `available=2`. - Suspended Instances zeigt total/resumable/non-resumable/routing failures. - Receive Locations und Send Ports zeigen getrennte expected/unexpected Werte. +- Endpoint Reachability zeigt bei Erfolg nur die Gesamtzahl und bei Fehlern + ausschliesslich nicht erreichbare Ziele. - keine berechtigungsbedingten `UNKNOWN`-Services. ## Alarmierung der Transportkette -Ein Ausfall des Providers wird ueber alle acht Services als `UNKNOWN` +Ein Ausfall des Providers wird ueber alle neun Services als `UNKNOWN` sichtbar. Die Summary nennt fehlenden, unlesbaren, ungueltigen oder stale Snapshot. Als Betriebsregel sollte `UNKNOWN` dieser Services genauso eskaliert werden wie ein technischer Monitoringausfall. diff --git a/src/BizTalkCheckmkPulse/App.config b/src/BizTalkCheckmkPulse/App.config index f02c176..a5165e6 100644 --- a/src/BizTalkCheckmkPulse/App.config +++ b/src/BizTalkCheckmkPulse/App.config @@ -14,6 +14,16 @@ + + + + + + + + + + diff --git a/src/BizTalkCheckmkPulse/BizTalkCheckmkPulse.csproj b/src/BizTalkCheckmkPulse/BizTalkCheckmkPulse.csproj index 28a58fd..774ffa1 100644 --- a/src/BizTalkCheckmkPulse/BizTalkCheckmkPulse.csproj +++ b/src/BizTalkCheckmkPulse/BizTalkCheckmkPulse.csproj @@ -38,11 +38,16 @@ + + + + + diff --git a/src/BizTalkCheckmkPulse/CheckmkLocalFormatter.cs b/src/BizTalkCheckmkPulse/CheckmkLocalFormatter.cs index c35a277..7299cb6 100644 --- a/src/BizTalkCheckmkPulse/CheckmkLocalFormatter.cs +++ b/src/BizTalkCheckmkPulse/CheckmkLocalFormatter.cs @@ -46,6 +46,7 @@ namespace BizTalkCheckmkPulse yield return FormatHostInstances(result); yield return FormatReceiveLocations(result); yield return FormatSendPorts(result); + yield return FormatEndpointConnectivity(result); yield return FormatOrchestrations(result); yield return FormatEventLog(result); @@ -70,6 +71,7 @@ namespace BizTalkCheckmkPulse yield return BuildLine(CheckState.Ok, _options.ServiceName("Host Instances"), "biztalk_host_instances_total=0;;;0", "Self test OK. No WMI query was executed."); yield return BuildLine(CheckState.Ok, _options.ServiceName("Receive Locations"), "biztalk_receive_locations_total=0;;;0", "Self test OK. No WMI query was executed."); yield return BuildLine(CheckState.Ok, _options.ServiceName("Send Ports"), "biztalk_send_ports_total=0;;;0", "Self test OK. No WMI query was executed."); + yield return BuildLine(CheckState.Ok, _options.ServiceName("Endpoint Reachability"), "biztalk_endpoints_active=0;;;0", "Self test OK. No network connection was opened."); yield return BuildLine(CheckState.Ok, _options.ServiceName("Orchestrations"), "biztalk_orchestrations_total=0;;;0", "Self test OK. No WMI query was executed."); yield return BuildLine(CheckState.Ok, _options.ServiceName("Event Log"), "biztalk_eventlog_errors=0;;;0", "Self test OK. No event log was read."); } @@ -88,6 +90,7 @@ namespace BizTalkCheckmkPulse yield return BuildLine(CheckState.Unknown, _options.ServiceName("Host Instances"), "-", action); yield return BuildLine(CheckState.Unknown, _options.ServiceName("Receive Locations"), "-", action); yield return BuildLine(CheckState.Unknown, _options.ServiceName("Send Ports"), "-", action); + yield return BuildLine(CheckState.Unknown, _options.ServiceName("Endpoint Reachability"), "-", action); yield return BuildLine(CheckState.Unknown, _options.ServiceName("Orchestrations"), "-", action); yield return BuildLine(CheckState.Unknown, _options.ServiceName("Event Log"), "-", action); } @@ -106,6 +109,7 @@ namespace BizTalkCheckmkPulse yield return BuildLine(CheckState.Unknown, _options.ServiceName("Host Instances"), "-", detail); yield return BuildLine(CheckState.Unknown, _options.ServiceName("Receive Locations"), "-", detail); yield return BuildLine(CheckState.Unknown, _options.ServiceName("Send Ports"), "-", detail); + yield return BuildLine(CheckState.Unknown, _options.ServiceName("Endpoint Reachability"), "-", detail); yield return BuildLine(CheckState.Unknown, _options.ServiceName("Orchestrations"), "-", detail); yield return BuildLine(CheckState.Unknown, _options.ServiceName("Event Log"), "-", detail); } @@ -350,6 +354,80 @@ namespace BizTalkCheckmkPulse return BuildLine(state, _options.ServiceName("Send Ports"), metrics, detail.ToString()); } + /// + /// Formatiert die Netzwerk-Erreichbarkeit aller aktuell aktiven, pruefbaren BizTalk-Endpunkte. + /// + private string FormatEndpointConnectivity(ProbeResult result) + { + var endpointState = result.EndpointConnectivity; + if (endpointState.Disabled) + { + return BuildLine( + CheckState.Ok, + _options.ServiceName("Endpoint Reachability"), + "-", + "Endpoint connectivity probe disabled by configuration."); + } + + if (!endpointState.RuntimeStateAvailable || !endpointState.CatalogAvailable) + { + return BuildLine( + CheckState.Unknown, + _options.ServiceName("Endpoint Reachability"), + "-", + "Endpoint-Pruefung nicht verlaesslich moeglich. " + EmptyAsUnknown(endpointState.Failure)); + } + + var failed = endpointState.Results.Where(x => !x.Available).ToArray(); + var available = endpointState.Results.Count(x => x.Available); + var incomplete = endpointState.Results.Count != endpointState.Active + || endpointState.UnsupportedActive > 0 + || endpointState.RefreshRequired && !endpointState.RefreshSucceeded + || !string.IsNullOrWhiteSpace(endpointState.Failure); + var state = failed.Length > 0 + ? CheckState.Critical + : incomplete + ? CheckState.Unknown + : CheckState.Ok; + var metrics = string.Format( + CultureInfo.InvariantCulture, + "biztalk_endpoints_configured={0};;;0|biztalk_endpoints_active={1};;;0|biztalk_endpoints_tested={2};;;0|biztalk_endpoints_available={3};;;0|biztalk_endpoints_failed={4};;1;0|biztalk_endpoints_unsupported={5};;1;0|biztalk_endpoints_inactive_skipped={6};;;0", + endpointState.Configured, + endpointState.Active, + endpointState.Results.Count, + available, + failed.Length, + endpointState.UnsupportedActive, + endpointState.SkippedInactive); + var detail = new StringBuilder(); + if (failed.Length == 0 && !incomplete) + { + detail.Append("Alle ").Append(endpointState.Active).Append(" aktiven, pruefbaren Send-/Receive-Endpunkte sind erreichbar"); + } + else + { + detail.Append("Endpoint reachability active=").Append(endpointState.Active) + .Append(", tested=").Append(endpointState.Results.Count) + .Append(", available=").Append(available) + .Append(", failed=").Append(failed.Length) + .Append(", unsupported_external=").Append(endpointState.UnsupportedActive); + AppendLimitedList(detail, "unavailable", failed.Select(x => + EndpointConnectivityProbe.Display(x.Endpoint) + "(" + EmptyAsUnknown(x.Failure) + ")")); + if (!string.IsNullOrWhiteSpace(endpointState.Failure)) + { + detail.Append("; catalog_or_probe_error=").Append(endpointState.Failure); + } + } + + if (endpointState.CatalogSynchronizedUtc.HasValue) + { + detail.Append("; catalog_utc=") + .Append(endpointState.CatalogSynchronizedUtc.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)); + } + + return BuildLine(state, _options.ServiceName("Endpoint Reachability"), metrics, detail.ToString()); + } + private string FormatOrchestrations(ProbeResult result) { if (!result.Platform.OrchestrationsDataAvailable) diff --git a/src/BizTalkCheckmkPulse/EndpointAddressParser.cs b/src/BizTalkCheckmkPulse/EndpointAddressParser.cs new file mode 100644 index 0000000..516bf66 --- /dev/null +++ b/src/BizTalkCheckmkPulse/EndpointAddressParser.cs @@ -0,0 +1,310 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +namespace BizTalkCheckmkPulse +{ + /// + /// Reduziert adapter-spezifische BizTalk-Adressen auf einen geheimnisfreien Host/Port-Test. + /// + internal static class EndpointAddressParser + { + private static readonly Regex HostPortPattern = new Regex( + @"^(?\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):(?\d{1,5})(?:[/\\].*)?$", + RegexOptions.CultureInvariant); + + /// + /// Erstellt aus einer aktiven BizTalk-Transportadresse einen TCP-/UDP-Katalogeintrag. + /// + public static bool TryCreate( + EndpointCandidate candidate, + out EndpointCatalogEntry entry, + out string reason) + { + entry = null; + reason = string.Empty; + if (candidate == null) + { + reason = "Endpoint-Kandidat fehlt."; + return false; + } + + if (candidate.Dynamic) + { + reason = "Dynamischer Send Port besitzt kein statisch pruefbares Ziel."; + return false; + } + + var address = (candidate.Address ?? string.Empty).Trim(); + if (address.Length == 0) + { + reason = "Transportadresse ist leer."; + return false; + } + + string protocol; + string host; + int port; + if (!TryResolve(address, candidate.AdapterName, out protocol, out host, out port, out reason)) + { + return false; + } + + entry = new EndpointCatalogEntry + { + Key = candidate.Key, + ArtifactType = candidate.ArtifactType, + ApplicationName = candidate.ApplicationName, + ArtifactName = candidate.ArtifactName, + TransportRole = candidate.TransportRole, + AdapterName = candidate.AdapterName, + Protocol = protocol, + Host = host, + Port = port, + Enabled = true, + AutoDiscovered = true + }; + return true; + } + + /// + /// Unterscheidet externe, aber nicht automatisch aufloesbare Ziele von bewusst lokalen/variablen Adressen. + /// + public static bool IsPotentialExternalEndpoint(EndpointCandidate candidate) + { + if (candidate == null || candidate.Dynamic) + { + return false; + } + + var address = (candidate.Address ?? string.Empty).Trim(); + if (address.Length == 0 + || LooksLikeEmailRecipients(address) + || Regex.IsMatch(address, @"^[a-zA-Z]:[\\/]", RegexOptions.CultureInvariant) + || address.StartsWith("/", StringComparison.Ordinal) + || address.StartsWith("net.pipe:", StringComparison.OrdinalIgnoreCase) + || address.StartsWith("npipe:", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (Contains(candidate.AdapterName, "SMTP") + || Contains(candidate.AdapterName, "FILE") && !address.StartsWith("\\\\", StringComparison.Ordinal)) + { + return false; + } + + if (address.StartsWith("\\\\", StringComparison.Ordinal) + || HostPortPattern.IsMatch(address) + || Regex.IsMatch(address, @"(?:FORMATNAME:)?DIRECT=(?:OS|TCP):", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + return true; + } + + Uri uri; + if (Uri.TryCreate(address, UriKind.Absolute, out uri)) + { + return !string.Equals(uri.Scheme, "file", StringComparison.OrdinalIgnoreCase) + && !string.Equals(uri.Scheme, "mailto", StringComparison.OrdinalIgnoreCase); + } + + return Contains(candidate.AdapterName, "SFTP") + || Contains(candidate.AdapterName, "FTP") + || Contains(candidate.AdapterName, "HTTP") + || Contains(candidate.AdapterName, "SOAP") + || Contains(candidate.AdapterName, "WCF"); + } + + private static bool TryResolve( + string address, + string adapterName, + out string protocol, + out string host, + out int port, + out string reason) + { + protocol = "TCP"; + host = string.Empty; + port = 0; + reason = string.Empty; + + if (LooksLikeEmailRecipients(address)) + { + reason = "SMTP-Empfaengerliste ist kein pruefbarer Netzwerk-Endpunkt."; + return false; + } + + if (address.StartsWith("\\\\", StringComparison.Ordinal)) + { + var end = address.IndexOf('\\', 2); + host = end < 0 ? address.Substring(2) : address.Substring(2, end - 2); + port = 445; + return Validate(host, port, out reason); + } + + if (Regex.IsMatch(address, @"^[a-zA-Z]:[\\/]", RegexOptions.CultureInvariant) + || Path.IsPathRooted(address) && !address.StartsWith("/", StringComparison.Ordinal)) + { + reason = "Lokaler Dateipfad benoetigt keine externe Netzwerkprobe."; + return false; + } + + if (TryResolveMsmq(address, out host)) + { + port = 1801; + return Validate(host, port, out reason); + } + + Uri uri; + if (Uri.TryCreate(address, UriKind.Absolute, out uri) + && !string.IsNullOrWhiteSpace(uri.Scheme)) + { + var scheme = uri.Scheme.ToLowerInvariant(); + if (scheme == "file") + { + if (string.IsNullOrWhiteSpace(uri.Host)) + { + reason = "Lokaler file-Endpunkt benoetigt keine externe Netzwerkprobe."; + return false; + } + + host = uri.Host; + port = 445; + return Validate(host, port, out reason); + } + + if (scheme == "net.pipe" || scheme == "npipe") + { + reason = "Named Pipes sind keine TCP-/UDP-Endpunkte."; + return false; + } + + host = uri.Host; + protocol = scheme == "udp" ? "UDP" : "TCP"; + port = ResolvePort(uri, scheme); + if (port <= 0 && HasExplicitPort(address)) + { + port = uri.Port; + } + + if (port <= 0) + { + reason = "Fuer das Schema '" + scheme + "' konnte kein TCP-/UDP-Port bestimmt werden."; + return false; + } + + return Validate(host, port, out reason); + } + + var match = HostPortPattern.Match(address); + if (match.Success) + { + host = match.Groups["host"].Value.Trim('[', ']'); + if (!int.TryParse(match.Groups["port"].Value, out port)) + { + port = 0; + } + + return Validate(host, port, out reason); + } + + if (Contains(adapterName, "SFTP")) + { + host = address.Trim('/'); + port = 22; + return Validate(host, port, out reason); + } + + if (Contains(adapterName, "FTP")) + { + host = address.Trim('/'); + port = 21; + return Validate(host, port, out reason); + } + + reason = "Transportadresse enthaelt keinen sicher bestimmbaren Host und Port."; + return false; + } + + private static int ResolvePort(Uri uri, string scheme) + { + if (uri.Port > 0 && !uri.IsDefaultPort) + { + return uri.Port; + } + + switch (scheme) + { + case "http": return 80; + case "https": return 443; + case "ftp": return 21; + case "sftp": return 22; + case "smb": return 445; + case "smtp": return 25; + case "ldap": return 389; + case "ldaps": return 636; + case "pop3": return 110; + case "pop3s": return 995; + case "imap": return 143; + case "imaps": return 993; + case "msmq": return 1801; + default: return uri.Port > 0 ? uri.Port : 0; + } + } + + private static bool HasExplicitPort(string address) + { + var authorityEnd = address.IndexOfAny(new[] { '/', '?' }, address.IndexOf("://", StringComparison.Ordinal) + 3); + var authority = authorityEnd < 0 ? address : address.Substring(0, authorityEnd); + return Regex.IsMatch(authority, @":\d{1,5}$", RegexOptions.CultureInvariant); + } + + private static bool TryResolveMsmq(string address, out string host) + { + host = string.Empty; + var match = Regex.Match( + address, + @"(?:FORMATNAME:)?DIRECT=(?:OS|TCP):(?[^\\/;]+)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success) + { + return false; + } + + host = match.Groups["host"].Value.Trim(); + return host.Length > 0; + } + + private static bool LooksLikeEmailRecipients(string value) + { + return value.IndexOf('@') > 0 + && value.IndexOf("://", StringComparison.Ordinal) < 0 + && !value.StartsWith("\\\\", StringComparison.Ordinal); + } + + private static bool Contains(string value, string fragment) + { + return !string.IsNullOrWhiteSpace(value) + && value.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool Validate(string host, int port, out string reason) + { + host = (host ?? string.Empty).Trim(); + if (host.Length == 0 || host.IndexOfAny(new[] { ' ', '\t', '\r', '\n', '*', '%' }) >= 0) + { + reason = "Host ist leer oder enthaelt nicht aufgeloeste Platzhalter."; + return false; + } + + if (port < 1 || port > 65535) + { + reason = "Port liegt ausserhalb des gueltigen Bereichs."; + return false; + } + + reason = string.Empty; + return true; + } + } +} diff --git a/src/BizTalkCheckmkPulse/EndpointCatalogStore.cs b/src/BizTalkCheckmkPulse/EndpointCatalogStore.cs new file mode 100644 index 0000000..237d9fb --- /dev/null +++ b/src/BizTalkCheckmkPulse/EndpointCatalogStore.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace BizTalkCheckmkPulse +{ + /// + /// Liest und schreibt die lokale Endpoint-Konfiguration atomar und mit strikter Validierung. + /// + internal sealed class EndpointCatalogStore + { + private const string RootName = "BizTalkEndpointCatalog"; + private const string Version = "1"; + private readonly string _path; + private readonly int _maxBytes; + private readonly int _maxEntries; + + public EndpointCatalogStore(string path, int maxBytes, int maxEntries) + { + _path = path; + _maxBytes = maxBytes; + _maxEntries = maxEntries; + } + + public EndpointCatalog Read(string environmentName) + { + var info = new FileInfo(_path); + if (!info.Exists) + { + throw new FileNotFoundException("Endpoint catalog does not exist.", _path); + } + + if (info.Length <= 0 || info.Length > _maxBytes) + { + throw new InvalidDataException("Endpoint catalog size is outside the configured range."); + } + + XDocument document; + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = _maxBytes + }; + using (var stream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)) + using (var reader = XmlReader.Create(stream, settings)) + { + document = XDocument.Load(reader, LoadOptions.None); + } + + var root = document.Root; + if (root == null + || root.Name.LocalName != RootName + || ReadAttribute(root, "version") != Version) + { + throw new InvalidDataException("Endpoint catalog format or version is invalid."); + } + + var machine = ReadAttribute(root, "machine"); + if (!string.Equals(machine, Environment.MachineName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("Endpoint catalog was created for a different machine."); + } + + var catalogEnvironment = ReadAttribute(root, "environment"); + if (!string.Equals(catalogEnvironment, environmentName ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("Endpoint catalog was created for a different environment."); + } + + DateTime synchronizedUtc; + if (!DateTime.TryParseExact( + ReadAttribute(root, "synchronizedUtc"), + "o", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out synchronizedUtc)) + { + throw new InvalidDataException("Endpoint catalog synchronization timestamp is invalid."); + } + + var catalog = new EndpointCatalog + { + MachineName = machine, + EnvironmentName = catalogEnvironment, + SynchronizedUtc = synchronizedUtc, + ActiveCandidates = ReadInt(root, "activeCandidates", 0, _maxEntries), + UnsupportedCandidates = ReadInt(root, "unsupportedCandidates", 0, _maxEntries) + }; + + foreach (var node in root.Elements("Endpoint")) + { + if (catalog.Entries.Count >= _maxEntries) + { + throw new InvalidDataException("Endpoint catalog exceeds EndpointMaxCount."); + } + + var entry = new EndpointCatalogEntry + { + Key = ReadAttribute(node, "key"), + ArtifactType = ReadAttribute(node, "artifactType"), + ApplicationName = ReadAttribute(node, "application"), + ArtifactName = ReadAttribute(node, "artifact"), + TransportRole = ReadAttribute(node, "transportRole"), + AdapterName = ReadAttribute(node, "adapter"), + Protocol = ReadAttribute(node, "protocol").ToUpperInvariant(), + Host = ReadAttribute(node, "host"), + Port = ReadInt(node, "port", 1, 65535), + Enabled = ReadBool(node, "enabled"), + AutoDiscovered = ReadBool(node, "autoDiscovered") + }; + ValidateEntry(entry); + catalog.Entries.Add(entry); + } + + if (catalog.Entries.Select(x => x.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != catalog.Entries.Count) + { + throw new InvalidDataException("Endpoint catalog contains duplicate keys."); + } + + return catalog; + } + + public void Write(EndpointCatalog catalog) + { + if (catalog == null) + { + throw new ArgumentNullException("catalog"); + } + + if (catalog.Entries.Count > _maxEntries) + { + throw new InvalidDataException("Endpoint catalog exceeds EndpointMaxCount."); + } + + foreach (var entry in catalog.Entries) + { + ValidateEntry(entry); + } + + var root = new XElement( + RootName, + new XAttribute("version", Version), + new XAttribute("machine", Environment.MachineName), + new XAttribute("environment", catalog.EnvironmentName ?? string.Empty), + new XAttribute("synchronizedUtc", catalog.SynchronizedUtc.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)), + new XAttribute("activeCandidates", catalog.ActiveCandidates), + new XAttribute("unsupportedCandidates", catalog.UnsupportedCandidates)); + foreach (var entry in catalog.Entries + .OrderBy(x => x.ApplicationName, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.ArtifactType, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.ArtifactName, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.TransportRole, StringComparer.OrdinalIgnoreCase)) + { + root.Add(new XElement( + "Endpoint", + new XAttribute("key", entry.Key), + new XAttribute("artifactType", entry.ArtifactType ?? string.Empty), + new XAttribute("application", entry.ApplicationName ?? string.Empty), + new XAttribute("artifact", entry.ArtifactName ?? string.Empty), + new XAttribute("transportRole", entry.TransportRole ?? string.Empty), + new XAttribute("adapter", entry.AdapterName ?? string.Empty), + new XAttribute("protocol", entry.Protocol), + new XAttribute("host", entry.Host), + new XAttribute("port", entry.Port), + new XAttribute("enabled", entry.Enabled), + new XAttribute("autoDiscovered", entry.AutoDiscovered))); + } + + var document = new XDocument(new XDeclaration("1.0", "utf-8", null), root); + byte[] bytes; + using (var memory = new MemoryStream()) + using (var writer = XmlWriter.Create(memory, new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = true, + NewLineChars = "\r\n", + NewLineHandling = NewLineHandling.Replace + })) + { + document.Save(writer); + writer.Flush(); + bytes = memory.ToArray(); + } + + if (bytes.Length > _maxBytes) + { + throw new InvalidDataException("Endpoint catalog exceeds EndpointCatalogMaxBytes."); + } + + AtomicWrite(bytes); + } + + private void AtomicWrite(byte[] bytes) + { + var directory = Path.GetDirectoryName(_path); + if (string.IsNullOrWhiteSpace(directory)) + { + throw new InvalidOperationException("Endpoint catalog path has no parent directory."); + } + + Directory.CreateDirectory(directory); + var temporaryPath = Path.Combine(directory, Path.GetFileName(_path) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + try + { + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + if (File.Exists(_path)) + { + File.Replace(temporaryPath, _path, null, true); + } + else + { + File.Move(temporaryPath, _path); + } + } + finally + { + if (File.Exists(temporaryPath)) + { + try { File.Delete(temporaryPath); } catch (IOException) { } + } + } + } + + private static void ValidateEntry(EndpointCatalogEntry entry) + { + if (entry == null + || string.IsNullOrWhiteSpace(entry.Key) + || string.IsNullOrWhiteSpace(entry.ArtifactType) + || string.IsNullOrWhiteSpace(entry.ArtifactName) + || string.IsNullOrWhiteSpace(entry.Host) + || entry.Host.IndexOfAny(new[] { '\r', '\n', '\t', ' ' }) >= 0 + || entry.Port < 1 + || entry.Port > 65535 + || !(string.Equals(entry.Protocol, "TCP", StringComparison.OrdinalIgnoreCase) + || string.Equals(entry.Protocol, "UDP", StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidDataException("Endpoint catalog contains an invalid entry."); + } + } + + private static string ReadAttribute(XElement element, string name) + { + var attribute = element.Attribute(name); + if (attribute == null) + { + throw new InvalidDataException("Endpoint catalog attribute is missing: " + name); + } + + return attribute.Value.Trim(); + } + + private static int ReadInt(XElement element, string name, int min, int max) + { + int value; + if (!int.TryParse(ReadAttribute(element, name), NumberStyles.Integer, CultureInfo.InvariantCulture, out value) + || value < min + || value > max) + { + throw new InvalidDataException("Endpoint catalog integer is invalid: " + name); + } + + return value; + } + + private static bool ReadBool(XElement element, string name) + { + bool value; + if (!bool.TryParse(ReadAttribute(element, name), out value)) + { + throw new InvalidDataException("Endpoint catalog boolean is invalid: " + name); + } + + return value; + } + } +} diff --git a/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs b/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs new file mode 100644 index 0000000..7896b01 --- /dev/null +++ b/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs @@ -0,0 +1,383 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace BizTalkCheckmkPulse +{ + /// + /// Pflegt den woechentlichen Endpoint-Katalog und prueft aktive Ziele parallel auf Netzwerkebene. + /// + internal sealed class EndpointConnectivityProbe + { + private readonly MonitoringOptions _options; + private readonly FileLogger _logger; + + public EndpointConnectivityProbe(MonitoringOptions options, FileLogger logger) + { + _options = options; + _logger = logger; + } + + public void Query(ProbeResult result) + { + var state = result.EndpointConnectivity; + if (!_options.ProbeEndpointConnectivity) + { + state.Disabled = true; + return; + } + + state.RuntimeStateAvailable = result.Platform.ReceiveLocationsDataAvailable + && result.Platform.SendPortsDataAvailable; + if (!state.RuntimeStateAvailable) + { + state.Failure = "Aktive Send Ports und Receive Locations konnten nicht vollstaendig bestimmt werden."; + return; + } + + var store = new EndpointCatalogStore( + _options.EndpointCatalogPath, + _options.EndpointCatalogMaxBytes, + _options.EndpointMaxCount); + EndpointCatalog catalog = null; + string readFailure = null; + try + { + catalog = store.Read(_options.EnvironmentName); + } + catch (FileNotFoundException ex) + { + readFailure = ex.Message; + } + catch (Exception ex) + { + readFailure = ex.GetType().Name + ": " + ex.Message; + _logger.Warning("Endpoint catalog rejected. path=" + _options.EndpointCatalogPath + " reason=" + readFailure); + } + + var now = DateTime.UtcNow; + state.RefreshRequired = catalog == null + || catalog.SynchronizedUtc > now.AddMinutes(5) + || now - catalog.SynchronizedUtc >= TimeSpan.FromHours(_options.EndpointDiscoveryIntervalHours); + if (state.RefreshRequired) + { + try + { + catalog = Synchronize(catalog, result.EndpointCandidates, now); + store.Write(catalog); + state.RefreshSucceeded = true; + _logger.Info( + "Endpoint catalog synchronized. path=" + _options.EndpointCatalogPath + + " active_candidates=" + catalog.ActiveCandidates + + " configured=" + catalog.Entries.Count + + " unsupported_external=" + catalog.UnsupportedCandidates); + } + catch (Exception ex) + { + state.RefreshSucceeded = false; + state.Failure = "Endpoint-Katalog konnte nicht synchronisiert werden: " + ex.GetType().Name + ": " + ex.Message; + _logger.Error("Endpoint catalog synchronization failed.", ex); + } + } + else + { + state.RefreshSucceeded = true; + } + + if (catalog == null) + { + state.CatalogAvailable = false; + if (string.IsNullOrWhiteSpace(state.Failure)) + { + state.Failure = "Keine gueltige Endpoint-Konfiguration verfuegbar: " + readFailure; + } + + return; + } + + state.CatalogAvailable = true; + state.CatalogSynchronizedUtc = catalog.SynchronizedUtc; + state.Configured = catalog.Entries.Count; + state.UnsupportedActive = CountUnsupportedExternal(result.EndpointCandidates); + + var activeKeys = new HashSet( + result.EndpointCandidates.Where(x => x.Active).Select(x => x.Key), + StringComparer.OrdinalIgnoreCase); + var activeEntries = catalog.Entries + .Where(x => x.Enabled) + .Where(x => string.Equals(x.ArtifactType, "Manual", StringComparison.OrdinalIgnoreCase) + || activeKeys.Contains(x.Key)) + .Take(_options.EndpointMaxCount) + .ToArray(); + state.Active = activeEntries.Length; + state.SkippedInactive = catalog.Entries.Count(x => x.Enabled) - activeEntries.Length; + + try + { + foreach (var probeResult in ProbeAllAsync(activeEntries).GetAwaiter().GetResult()) + { + state.Results.Add(probeResult); + if (!probeResult.Available) + { + _logger.Warning( + "Endpoint unavailable. endpoint=" + Display(probeResult.Endpoint) + + " duration_ms=" + probeResult.DurationMilliseconds + + " reason=" + probeResult.Failure); + } + } + } + catch (Exception ex) + { + state.Failure = "Endpoint-Probes konnten nicht abgeschlossen werden: " + ex.GetType().Name + ": " + ex.Message; + _logger.Error("Endpoint probes failed.", ex); + } + } + + internal static EndpointCatalog SynchronizeCatalog( + EndpointCatalog existing, + IEnumerable candidates, + DateTime synchronizedUtc) + { + var active = (candidates ?? Enumerable.Empty()) + .Where(x => x != null && x.Active) + .ToArray(); + var discovered = new List(); + var unsupported = 0; + foreach (var candidate in active) + { + EndpointCatalogEntry entry; + string reason; + if (EndpointAddressParser.TryCreate(candidate, out entry, out reason)) + { + discovered.Add(entry); + } + else if (EndpointAddressParser.IsPotentialExternalEndpoint(candidate)) + { + unsupported++; + } + } + + var manual = existing == null + ? new EndpointCatalogEntry[0] + : existing.Entries.Where(x => !x.AutoDiscovered).ToArray(); + var manualKeys = new HashSet(manual.Select(x => x.Key), StringComparer.OrdinalIgnoreCase); + var merged = manual + .Concat(discovered.Where(x => !manualKeys.Contains(x.Key))) + .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(); + + var catalog = new EndpointCatalog + { + MachineName = Environment.MachineName, + EnvironmentName = existing == null ? string.Empty : existing.EnvironmentName, + SynchronizedUtc = synchronizedUtc.ToUniversalTime(), + ActiveCandidates = active.Length, + UnsupportedCandidates = unsupported + }; + catalog.Entries.AddRange(merged); + return catalog; + } + + private EndpointCatalog Synchronize( + EndpointCatalog existing, + IEnumerable candidates, + DateTime synchronizedUtc) + { + var catalog = SynchronizeCatalog(existing, candidates, synchronizedUtc); + catalog.EnvironmentName = _options.EnvironmentName ?? string.Empty; + if (catalog.Entries.Count > _options.EndpointMaxCount + || catalog.ActiveCandidates > _options.EndpointMaxCount) + { + throw new InvalidDataException("Discovered endpoint candidates exceed EndpointMaxCount."); + } + + return catalog; + } + + private static int CountUnsupportedExternal(IEnumerable candidates) + { + var count = 0; + foreach (var candidate in candidates.Where(x => x.Active)) + { + EndpointCatalogEntry ignored; + string reason; + if (!EndpointAddressParser.TryCreate(candidate, out ignored, out reason) + && EndpointAddressParser.IsPotentialExternalEndpoint(candidate)) + { + count++; + } + } + + return count; + } + + private async Task> ProbeAllAsync(EndpointCatalogEntry[] endpoints) + { + var unique = endpoints + .GroupBy(x => x.Protocol.ToUpperInvariant() + "|" + x.Host.ToUpperInvariant() + "|" + x.Port) + .ToArray(); + var outcomes = new Dictionary(StringComparer.OrdinalIgnoreCase); + using (var gate = new SemaphoreSlim(_options.EndpointProbeMaxConcurrency)) + { + var tasks = unique.Select(async group => + { + await gate.WaitAsync().ConfigureAwait(false); + try + { + var endpoint = group.First(); + var outcome = await ProbeOneAsync(endpoint).ConfigureAwait(false); + lock (outcomes) + { + outcomes[group.Key] = outcome; + } + } + finally + { + gate.Release(); + } + }).ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + return endpoints.Select(endpoint => + { + var key = endpoint.Protocol.ToUpperInvariant() + "|" + endpoint.Host.ToUpperInvariant() + "|" + endpoint.Port; + var outcome = outcomes[key]; + return new EndpointProbeResult + { + Endpoint = endpoint, + Available = outcome.Available, + DurationMilliseconds = outcome.DurationMilliseconds, + Failure = outcome.Failure + }; + }).ToArray(); + } + + private async Task ProbeOneAsync(EndpointCatalogEntry endpoint) + { + return string.Equals(endpoint.Protocol, "UDP", StringComparison.OrdinalIgnoreCase) + ? await ProbeUdpAsync(endpoint).ConfigureAwait(false) + : await ProbeTcpAsync(endpoint).ConfigureAwait(false); + } + + private async Task ProbeTcpAsync(EndpointCatalogEntry endpoint) + { + var stopwatch = Stopwatch.StartNew(); + using (var client = new TcpClient()) + { + try + { + var connect = client.ConnectAsync(endpoint.Host, endpoint.Port); + var completed = await Task.WhenAny(connect, Task.Delay(_options.EndpointProbeTimeoutMilliseconds)).ConfigureAwait(false); + if (completed != connect) + { + client.Close(); + ObserveFault(connect); + return NetworkOutcome.Failed(stopwatch.ElapsedMilliseconds, "TCP timeout"); + } + + await connect.ConfigureAwait(false); + return client.Connected + ? NetworkOutcome.Success(stopwatch.ElapsedMilliseconds) + : NetworkOutcome.Failed(stopwatch.ElapsedMilliseconds, "TCP connection not established"); + } + catch (Exception ex) + { + return NetworkOutcome.Failed(stopwatch.ElapsedMilliseconds, CompactFailure(ex)); + } + } + } + + private async Task ProbeUdpAsync(EndpointCatalogEntry endpoint) + { + var stopwatch = Stopwatch.StartNew(); + try + { + var resolution = Dns.GetHostAddressesAsync(endpoint.Host); + var completed = await Task.WhenAny(resolution, Task.Delay(_options.EndpointProbeTimeoutMilliseconds)).ConfigureAwait(false); + if (completed != resolution) + { + ObserveFault(resolution); + return NetworkOutcome.Failed(stopwatch.ElapsedMilliseconds, "UDP DNS timeout"); + } + + var addresses = await resolution.ConfigureAwait(false); + var address = addresses.FirstOrDefault(x => x.AddressFamily == AddressFamily.InterNetwork) + ?? addresses.FirstOrDefault(); + if (address == null) + { + return NetworkOutcome.Failed(stopwatch.ElapsedMilliseconds, "UDP host resolved without an address"); + } + + using (var socket = new Socket(address.AddressFamily, SocketType.Dgram, ProtocolType.Udp)) + { + socket.Connect(new IPEndPoint(address, endpoint.Port)); + socket.Send(new byte[0]); + } + + // UDP ist verbindungslos: Erfolg bestaetigt DNS, Route und lokalen Datagrammversand, + // aber ohne applikationsspezifische Antwort nicht den entfernten Dienstzustand. + return NetworkOutcome.Success(stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + return NetworkOutcome.Failed(stopwatch.ElapsedMilliseconds, CompactFailure(ex)); + } + } + + private static void ObserveFault(Task task) + { + task.ContinueWith( + completed => { var ignored = completed.Exception; }, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); + } + + private static string CompactFailure(Exception exception) + { + var socket = exception as SocketException; + return socket == null + ? exception.GetType().Name + ": " + exception.Message + : "SocketError=" + socket.SocketErrorCode + ": " + socket.Message; + } + + internal static string Display(EndpointCatalogEntry endpoint) + { + var artifact = string.IsNullOrWhiteSpace(endpoint.ApplicationName) + || string.Equals(endpoint.ApplicationName, "(unknown)", StringComparison.OrdinalIgnoreCase) + ? endpoint.ArtifactName + : endpoint.ApplicationName + "\\" + endpoint.ArtifactName; + return endpoint.ArtifactType + ":" + artifact + + "[" + endpoint.TransportRole + "]->" + + endpoint.Host + ":" + endpoint.Port + "/" + endpoint.Protocol.ToUpperInvariant(); + } + + private sealed class NetworkOutcome + { + public bool Available { get; private set; } + public long DurationMilliseconds { get; private set; } + public string Failure { get; private set; } + + public static NetworkOutcome Success(long durationMilliseconds) + { + return new NetworkOutcome { Available = true, DurationMilliseconds = durationMilliseconds }; + } + + public static NetworkOutcome Failed(long durationMilliseconds, string failure) + { + return new NetworkOutcome + { + Available = false, + DurationMilliseconds = durationMilliseconds, + Failure = failure + }; + } + } + } +} diff --git a/src/BizTalkCheckmkPulse/Models.cs b/src/BizTalkCheckmkPulse/Models.cs index fbb87fa..79fc8f7 100644 --- a/src/BizTalkCheckmkPulse/Models.cs +++ b/src/BizTalkCheckmkPulse/Models.cs @@ -101,6 +101,8 @@ namespace BizTalkCheckmkPulse Applications = new List(); SqlTargets = new List(); EventLog = new EventLogState(); + EndpointCandidates = new List(); + EndpointConnectivity = new EndpointConnectivityState(); } public List Diagnostics { get; private set; } @@ -114,6 +116,8 @@ namespace BizTalkCheckmkPulse public string ExecutionIdentity { get; set; } public string NetworkIdentityHint { get; set; } public EventLogState EventLog { get; private set; } + public List EndpointCandidates { get; private set; } + public EndpointConnectivityState EndpointConnectivity { get; private set; } } /// @@ -236,4 +240,116 @@ namespace BizTalkCheckmkPulse public DateTime Since { get; set; } public string Failure { get; set; } } + + /// + /// Rohdaten eines BizTalk-Transports aus einer bereits ausgefuehrten Runtime-Abfrage. + /// Vollstaendige URIs werden nur im Speicher gehalten und weder persistiert noch ausgegeben. + /// + internal sealed class EndpointCandidate + { + public string ArtifactType { get; set; } + public string ApplicationName { get; set; } + public string ArtifactName { get; set; } + public string TransportRole { get; set; } + public string AdapterName { get; set; } + public string Address { get; set; } + public bool Active { get; set; } + public bool Dynamic { get; set; } + + public string Key + { + get + { + return EndpointCatalogEntry.BuildKey( + ArtifactType, + ApplicationName, + ArtifactName, + TransportRole); + } + } + } + + /// + /// Ein geheimnisfreier, lokal persistierbarer Netzwerk-Endpunkt. + /// + internal sealed class EndpointCatalogEntry + { + public string Key { get; set; } + public string ArtifactType { get; set; } + public string ApplicationName { get; set; } + public string ArtifactName { get; set; } + public string TransportRole { get; set; } + public string AdapterName { get; set; } + public string Protocol { get; set; } + public string Host { get; set; } + public int Port { get; set; } + public bool Enabled { get; set; } + public bool AutoDiscovered { get; set; } + + public static string BuildKey(string artifactType, string applicationName, string artifactName, string transportRole) + { + return NormalizeKeyPart(artifactType) + + "|" + NormalizeKeyPart(applicationName) + + "|" + NormalizeKeyPart(artifactName) + + "|" + NormalizeKeyPart(transportRole); + } + + private static string NormalizeKeyPart(string value) + { + return (value ?? string.Empty).Trim().ToUpperInvariant(); + } + } + + /// + /// Versionierter Inhalt der automatisch gepflegten lokalen Endpoint-Konfiguration. + /// + internal sealed class EndpointCatalog + { + public EndpointCatalog() + { + Entries = new List(); + } + + public DateTime SynchronizedUtc { get; set; } + public string MachineName { get; set; } + public string EnvironmentName { get; set; } + public int ActiveCandidates { get; set; } + public int UnsupportedCandidates { get; set; } + public List Entries { get; private set; } + } + + /// + /// Ergebnis einer einzelnen TCP- beziehungsweise UDP-Netzwerkprobe. + /// + internal sealed class EndpointProbeResult + { + public EndpointCatalogEntry Endpoint { get; set; } + public bool Available { get; set; } + public long DurationMilliseconds { get; set; } + public string Failure { get; set; } + } + + /// + /// Aggregierter Zustand des Endpoint-Katalogs und aller aktiven Netzwerkprobes. + /// + internal sealed class EndpointConnectivityState + { + public EndpointConnectivityState() + { + Results = new List(); + } + + public bool Disabled { get; set; } + public bool CatalogAvailable { get; set; } + public bool RuntimeStateAvailable { get; set; } + public bool RefreshRequired { get; set; } + public bool RefreshSucceeded { get; set; } + public DateTime? CatalogSynchronizedUtc { get; set; } + public int Configured { get; set; } + public int Active { get; set; } + public int SkippedInactive { get; set; } + public int UnsupportedActive { get; set; } + public string Failure { get; set; } + public List Results { get; private set; } + } } diff --git a/src/BizTalkCheckmkPulse/MonitoringOptions.cs b/src/BizTalkCheckmkPulse/MonitoringOptions.cs index 1b44645..dafa858 100644 --- a/src/BizTalkCheckmkPulse/MonitoringOptions.cs +++ b/src/BizTalkCheckmkPulse/MonitoringOptions.cs @@ -38,6 +38,13 @@ namespace BizTalkCheckmkPulse public int SnapshotMaxBytes { get; set; } public string LogDirectory { get; set; } public int LogRetentionDays { get; set; } + public bool ProbeEndpointConnectivity { get; set; } + public string EndpointCatalogPath { get; set; } + public int EndpointCatalogMaxBytes { get; set; } + public int EndpointDiscoveryIntervalHours { get; set; } + public int EndpointProbeTimeoutMilliseconds { get; set; } + public int EndpointProbeMaxConcurrency { get; set; } + public int EndpointMaxCount { get; set; } public bool Collect { get; set; } public bool SelfTest { get; set; } @@ -73,6 +80,13 @@ namespace BizTalkCheckmkPulse SnapshotMaxBytes = 1048576; LogDirectory = Path.Combine(commonData, "BizTalkCheckmkPulse", "logs"); LogRetentionDays = 30; + ProbeEndpointConnectivity = true; + EndpointCatalogPath = Path.Combine(commonData, "BizTalkCheckmkPulse", "data", "endpoints.xml"); + EndpointCatalogMaxBytes = 1048576; + EndpointDiscoveryIntervalHours = 168; + EndpointProbeTimeoutMilliseconds = 3000; + EndpointProbeMaxConcurrency = 12; + EndpointMaxCount = 500; } /// @@ -124,6 +138,13 @@ namespace BizTalkCheckmkPulse options.SnapshotMaxBytes = ReadInt(settings, "SnapshotMaxBytes", options.SnapshotMaxBytes, 4096, 16777216); options.LogDirectory = Environment.ExpandEnvironmentVariables(ReadString(settings, "LogDirectory", options.LogDirectory)); options.LogRetentionDays = ReadInt(settings, "LogRetentionDays", options.LogRetentionDays, 1, 365); + options.ProbeEndpointConnectivity = ReadBool(settings, "ProbeEndpointConnectivity", options.ProbeEndpointConnectivity); + options.EndpointCatalogPath = Environment.ExpandEnvironmentVariables(ReadString(settings, "EndpointCatalogPath", options.EndpointCatalogPath)); + options.EndpointCatalogMaxBytes = ReadInt(settings, "EndpointCatalogMaxBytes", options.EndpointCatalogMaxBytes, 4096, 16777216); + options.EndpointDiscoveryIntervalHours = ReadInt(settings, "EndpointDiscoveryIntervalHours", options.EndpointDiscoveryIntervalHours, 1, 8760); + options.EndpointProbeTimeoutMilliseconds = ReadInt(settings, "EndpointProbeTimeoutMilliseconds", options.EndpointProbeTimeoutMilliseconds, 250, 30000); + options.EndpointProbeMaxConcurrency = ReadInt(settings, "EndpointProbeMaxConcurrency", options.EndpointProbeMaxConcurrency, 1, 64); + options.EndpointMaxCount = ReadInt(settings, "EndpointMaxCount", options.EndpointMaxCount, 1, 5000); ApplyArguments(options, args ?? new string[0]); if (!options.SelfTest) @@ -192,6 +213,19 @@ namespace BizTalkCheckmkPulse { throw new ConfigurationErrorsException("LogDirectory must be an absolute path."); } + + if (string.IsNullOrWhiteSpace(options.EndpointCatalogPath) || !Path.IsPathRooted(options.EndpointCatalogPath)) + { + throw new ConfigurationErrorsException("EndpointCatalogPath must be an absolute path."); + } + + if (string.Equals( + Path.GetFullPath(options.EndpointCatalogPath), + Path.GetFullPath(options.SnapshotPath), + StringComparison.OrdinalIgnoreCase)) + { + throw new ConfigurationErrorsException("EndpointCatalogPath and SnapshotPath must be different files."); + } } /// diff --git a/src/BizTalkCheckmkPulse/Program.cs b/src/BizTalkCheckmkPulse/Program.cs index d5048ec..84f3718 100644 --- a/src/BizTalkCheckmkPulse/Program.cs +++ b/src/BizTalkCheckmkPulse/Program.cs @@ -69,7 +69,7 @@ namespace BizTalkCheckmkPulse using (new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)) { logger.Info("Collection started. snapshot=" + options.SnapshotPath); - var result = Collect(options); + var result = Collect(options, logger); foreach (var diagnostic in result.Diagnostics) { logger.Warning("Probe diagnostic: " + diagnostic.ToDisplayText()); @@ -98,6 +98,10 @@ namespace BizTalkCheckmkPulse + result.SendPorts.Count + " send_ports_not_started=" + result.SendPorts.Count(x => x.Status != 3) + + " endpoints_active=" + + result.EndpointConnectivity.Active + + " endpoints_failed=" + + result.EndpointConnectivity.Results.Count(x => !x.Available) + " elapsed_ms=" + stopwatch.ElapsedMilliseconds); return 0; @@ -155,11 +159,12 @@ namespace BizTalkCheckmkPulse return 0; } - private static ProbeResult Collect(MonitoringOptions options) + private static ProbeResult Collect(MonitoringOptions options, FileLogger logger) { var result = new ProbeResult(); new WmiBizTalkProbe(options).Query(result); new SqlConnectivityProbe(options).Query(result); + new EndpointConnectivityProbe(options, logger).Query(result); if (options.ProbeEventLog) { diff --git a/src/BizTalkCheckmkPulse/Properties/AssemblyInfo.cs b/src/BizTalkCheckmkPulse/Properties/AssemblyInfo.cs index 76c7c40..8152431 100644 --- a/src/BizTalkCheckmkPulse/Properties/AssemblyInfo.cs +++ b/src/BizTalkCheckmkPulse/Properties/AssemblyInfo.cs @@ -6,5 +6,5 @@ using System.Reflection; [assembly: AssemblyDescription("Privileged BizTalk data provider and validated Checkmk snapshot consumer")] [assembly: AssemblyCompany("BEW")] [assembly: AssemblyProduct("BizTalk Checkmk Pulse")] -[assembly: AssemblyVersion("2.1.0.0")] -[assembly: AssemblyFileVersion("2.1.0.0")] +[assembly: AssemblyVersion("2.2.0.0")] +[assembly: AssemblyFileVersion("2.2.0.0")] diff --git a/src/BizTalkCheckmkPulse/SnapshotStore.cs b/src/BizTalkCheckmkPulse/SnapshotStore.cs index 2daed38..8083768 100644 --- a/src/BizTalkCheckmkPulse/SnapshotStore.cs +++ b/src/BizTalkCheckmkPulse/SnapshotStore.cs @@ -258,9 +258,9 @@ namespace BizTalkCheckmkPulse } } - if (count < 8) + if (count < 9) { - throw new InvalidDataException("Snapshot must contain all eight stable services."); + throw new InvalidDataException("Snapshot must contain all nine stable services."); } } diff --git a/src/BizTalkCheckmkPulse/WmiBizTalkProbe.cs b/src/BizTalkCheckmkPulse/WmiBizTalkProbe.cs index 619e468..de3cf3c 100644 --- a/src/BizTalkCheckmkPulse/WmiBizTalkProbe.cs +++ b/src/BizTalkCheckmkPulse/WmiBizTalkProbe.cs @@ -221,6 +221,17 @@ namespace BizTalkCheckmkPulse ApplicationName = applicationName, IsDisabled = isDisabled }); + result.EndpointCandidates.Add(new EndpointCandidate + { + ArtifactType = "ReceiveLocation", + ApplicationName = applicationName, + ArtifactName = name, + TransportRole = "Inbound", + AdapterName = WmiHelpers.GetString(item, "AdapterName"), + Address = WmiHelpers.GetString(item, "InboundTransportURL"), + Active = isDisabled == false, + Dynamic = false + }); var app = GetApplication(apps, applicationName); app.ReceiveLocationTotal++; if (isDisabled == true) @@ -241,6 +252,33 @@ namespace BizTalkCheckmkPulse ApplicationName = applicationName, Status = status }); + var isDynamic = WmiHelpers.GetBoolean(item, "IsDynamic", false); + result.EndpointCandidates.Add(new EndpointCandidate + { + ArtifactType = "SendPort", + ApplicationName = applicationName, + ArtifactName = string.IsNullOrWhiteSpace(name) ? "(unknown)" : name, + TransportRole = "Primary", + AdapterName = WmiHelpers.GetString(item, "PTTransportType"), + Address = WmiHelpers.GetString(item, "PTAddress"), + Active = status == SendPortStarted, + Dynamic = isDynamic + }); + var secondaryAddress = WmiHelpers.GetString(item, "STAddress"); + if (!string.IsNullOrWhiteSpace(secondaryAddress)) + { + result.EndpointCandidates.Add(new EndpointCandidate + { + ArtifactType = "SendPort", + ApplicationName = applicationName, + ArtifactName = string.IsNullOrWhiteSpace(name) ? "(unknown)" : name, + TransportRole = "Secondary", + AdapterName = WmiHelpers.GetString(item, "STTransportType"), + Address = secondaryAddress, + Active = status == SendPortStarted, + Dynamic = isDynamic + }); + } var app = GetApplication(apps, applicationName); app.SendPortTotal++; switch (status) diff --git a/tests/BizTalkCheckmkPulse.Tests/Program.cs b/tests/BizTalkCheckmkPulse.Tests/Program.cs index 5883562..a8bf0b1 100644 --- a/tests/BizTalkCheckmkPulse.Tests/Program.cs +++ b/tests/BizTalkCheckmkPulse.Tests/Program.cs @@ -28,6 +28,10 @@ namespace BizTalkCheckmkPulse.Tests Run("UnknownReceiveLocationStateIsNotGreen", UnknownReceiveLocationStateIsNotGreen); Run("SendPortAllowlistSeparatesExpectedState", SendPortAllowlistSeparatesExpectedState); Run("ArtifactSummaryIsBounded", ArtifactSummaryIsBounded); + Run("EndpointAddressesResolveWithoutSecrets", EndpointAddressesResolveWithoutSecrets); + Run("EndpointCatalogPreservesManualOverrides", EndpointCatalogPreservesManualOverrides); + Run("EndpointCatalogRoundTrip", EndpointCatalogRoundTrip); + Run("EndpointOutputListsOnlyUnavailableTargets", EndpointOutputListsOnlyUnavailableTargets); Run("SnapshotRoundTripPreservesLines", SnapshotRoundTripPreservesLines); Run("SnapshotRejectsTampering", SnapshotRejectsTampering); Run("SnapshotRejectsStaleData", SnapshotRejectsStaleData); @@ -52,19 +56,20 @@ namespace BizTalkCheckmkPulse.Tests private static void SelfTestEmitsAllStableServices() { var lines = new CheckmkLocalFormatter(new MonitoringOptions()).FormatSelfTest().ToArray(); - AssertEqual(8, lines.Length, "self-test line count"); - AssertEqual(8, lines.Distinct(StringComparer.Ordinal).Count(), "unique self-test lines"); + AssertEqual(9, lines.Length, "self-test line count"); + AssertEqual(9, lines.Distinct(StringComparer.Ordinal).Count(), "unique self-test lines"); Assert(lines.All(x => x.StartsWith("0 \"BizTalk ", StringComparison.Ordinal)), "every self-test line must be OK"); Assert(lines.Any(x => x.Contains("\"BizTalk Event Log\"")), "Event Log service missing"); Assert(lines.Any(x => x.Contains("\"BizTalk Receive Locations\"")), "Receive Locations service missing"); Assert(lines.Any(x => x.Contains("\"BizTalk Send Ports\"")), "Send Ports service missing"); + Assert(lines.Any(x => x.Contains("\"BizTalk Endpoint Reachability\"")), "Endpoint Reachability service missing"); Assert(lines.Any(x => x.Contains("\"BizTalk Orchestrations\"")), "Orchestrations service missing"); } private static void UnavailableSourcesAreUnknown() { - var lines = new CheckmkLocalFormatter(new MonitoringOptions()).Format(new ProbeResult()).Take(8).ToArray(); - AssertEqual(8, lines.Length, "stable service count"); + var lines = new CheckmkLocalFormatter(new MonitoringOptions()).Format(new ProbeResult()).Take(9).ToArray(); + AssertEqual(9, lines.Length, "stable service count"); Assert(lines.All(x => x.StartsWith("3 \"BizTalk ", StringComparison.Ordinal)), "unavailable sources must be UNKNOWN"); } @@ -73,7 +78,7 @@ namespace BizTalkCheckmkPulse.Tests var options = new MonitoringOptions { EmitPerApplicationSuspensionServices = true }; var result = CreateSuspensionResult("(unknown)"); var lines = new CheckmkLocalFormatter(options).Format(result).ToArray(); - AssertEqual(8, lines.Length, "unknown application must not create a dynamic service"); + AssertEqual(9, lines.Length, "unknown application must not create a dynamic service"); } private static void KnownApplicationCreatesService() @@ -81,7 +86,7 @@ namespace BizTalkCheckmkPulse.Tests var options = new MonitoringOptions { EmitPerApplicationSuspensionServices = true }; var result = CreateSuspensionResult("Orders"); var lines = new CheckmkLocalFormatter(options).Format(result).ToArray(); - AssertEqual(9, lines.Length, "known application should create one dynamic service"); + AssertEqual(10, lines.Length, "known application should create one dynamic service"); Assert(lines.Any(x => x.Contains("\"BizTalk Suspended Orders\"")), "known application service missing"); } @@ -328,6 +333,166 @@ namespace BizTalkCheckmkPulse.Tests }); } + private static void EndpointAddressesResolveWithoutSecrets() + { + EndpointCatalogEntry endpoint; + string reason; + var candidate = new EndpointCandidate + { + ArtifactType = "SendPort", + ApplicationName = "Orders", + ArtifactName = "SP Orders", + TransportRole = "Primary", + AdapterName = "WCF-BasicHttp", + Address = "https://api-user:top-secret@example.test/orders?q=secret", + Active = true + }; + Assert(EndpointAddressParser.TryCreate(candidate, out endpoint, out reason), "HTTPS endpoint should resolve: " + reason); + AssertEqual("example.test", endpoint.Host, "HTTPS host"); + AssertEqual(443, endpoint.Port, "HTTPS port"); + Assert(endpoint.Key.IndexOf("secret", StringComparison.OrdinalIgnoreCase) < 0, "catalog key must not contain URI secrets"); + Assert(endpoint.Host.IndexOf("secret", StringComparison.OrdinalIgnoreCase) < 0, "catalog host must not contain URI secrets"); + + candidate.Address = @"\\fileserver\drop\%MessageID%.xml"; + candidate.AdapterName = "FILE"; + Assert(EndpointAddressParser.TryCreate(candidate, out endpoint, out reason), "UNC endpoint should resolve: " + reason); + AssertEqual("fileserver", endpoint.Host, "UNC host"); + AssertEqual(445, endpoint.Port, "UNC SMB port"); + + candidate.Address = "recipient@example.test"; + candidate.AdapterName = "SMTP"; + Assert(!EndpointAddressParser.TryCreate(candidate, out endpoint, out reason), "SMTP recipient must not become a host probe"); + Assert(!EndpointAddressParser.IsPotentialExternalEndpoint(candidate), "SMTP recipient must not cause UNKNOWN"); + + candidate.Address = "/Orders/Receive.svc"; + candidate.AdapterName = "WCF-CustomIsolated"; + Assert(!EndpointAddressParser.IsPotentialExternalEndpoint(candidate), "relative local receive address must not cause UNKNOWN"); + } + + private static void EndpointCatalogPreservesManualOverrides() + { + var candidate = new EndpointCandidate + { + ArtifactType = "SendPort", + ApplicationName = "Orders", + ArtifactName = "SP Orders", + TransportRole = "Primary", + AdapterName = "SFTP", + Address = "sftp://discovered.example.test/out", + Active = true + }; + var inactive = new EndpointCandidate + { + ArtifactType = "ReceiveLocation", + ApplicationName = "Orders", + ArtifactName = "RL Disabled", + TransportRole = "Inbound", + AdapterName = "HTTPS", + Address = "https://disabled.example.test/in", + Active = false + }; + var existing = new EndpointCatalog { EnvironmentName = "ACC" }; + existing.Entries.Add(new EndpointCatalogEntry + { + Key = candidate.Key, + ArtifactType = candidate.ArtifactType, + ApplicationName = candidate.ApplicationName, + ArtifactName = candidate.ArtifactName, + TransportRole = candidate.TransportRole, + AdapterName = candidate.AdapterName, + Protocol = "TCP", + Host = "manual.example.test", + Port = 2222, + Enabled = true, + AutoDiscovered = false + }); + + var synchronized = EndpointConnectivityProbe.SynchronizeCatalog(existing, new[] { candidate, inactive }, DateTime.UtcNow); + AssertEqual(1, synchronized.Entries.Count, "manual override count"); + AssertEqual("manual.example.test", synchronized.Entries[0].Host, "manual override host"); + AssertEqual(2222, synchronized.Entries[0].Port, "manual override port"); + Assert(!synchronized.Entries[0].AutoDiscovered, "manual override marker"); + Assert(synchronized.Entries.All(x => x.ArtifactName != "RL Disabled"), "inactive receive location must not be added"); + } + + private static void EndpointOutputListsOnlyUnavailableTargets() + { + var result = new ProbeResult(); + result.EndpointConnectivity.RuntimeStateAvailable = true; + result.EndpointConnectivity.CatalogAvailable = true; + result.EndpointConnectivity.RefreshSucceeded = true; + result.EndpointConnectivity.Configured = 2; + result.EndpointConnectivity.Active = 2; + result.EndpointConnectivity.Results.Add(new EndpointProbeResult + { + Endpoint = TestEndpoint("Reachable", "up.example.test"), + Available = true + }); + result.EndpointConnectivity.Results.Add(new EndpointProbeResult + { + Endpoint = TestEndpoint("Unavailable", "down.example.test"), + Available = false, + Failure = "TCP timeout" + }); + + var line = FindServiceLine(result, "Endpoint Reachability"); + Assert(line.StartsWith("2 \"BizTalk Endpoint Reachability\"", StringComparison.Ordinal), "failed endpoint must be CRIT"); + Assert(line.Contains("Unavailable"), "unavailable endpoint name missing"); + Assert(line.Contains("down.example.test:443/TCP"), "unavailable host missing"); + Assert(line.IndexOf("Reachable", StringComparison.Ordinal) < 0, "reachable endpoint must not clutter output"); + Assert(line.IndexOf("up.example.test", StringComparison.Ordinal) < 0, "reachable host must not clutter output"); + } + + private static void EndpointCatalogRoundTrip() + { + var directory = Path.Combine(Path.GetTempPath(), "BizTalkCheckmkPulse.CatalogTests." + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "endpoints.xml"); + try + { + var catalog = new EndpointCatalog + { + EnvironmentName = "ACC", + SynchronizedUtc = DateTime.UtcNow, + ActiveCandidates = 1, + UnsupportedCandidates = 0 + }; + catalog.Entries.Add(TestEndpoint("Orders", "api.example.test")); + var store = new EndpointCatalogStore(path, 1048576, 500); + store.Write(catalog); + var loaded = store.Read("ACC"); + + AssertEqual(1, loaded.Entries.Count, "catalog entry count"); + AssertEqual("api.example.test", loaded.Entries[0].Host, "catalog host"); + AssertEqual(443, loaded.Entries[0].Port, "catalog port"); + Assert(File.ReadAllText(path).IndexOf("top-secret", StringComparison.OrdinalIgnoreCase) < 0, "catalog must not contain URI secrets"); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + private static EndpointCatalogEntry TestEndpoint(string name, string host) + { + return new EndpointCatalogEntry + { + Key = name, + ArtifactType = "SendPort", + ApplicationName = "Orders", + ArtifactName = name, + TransportRole = "Primary", + Protocol = "TCP", + Host = host, + Port = 443, + Enabled = true, + AutoDiscovered = true + }; + } + private static void SnapshotRejectsTampering() { WithTemporarySnapshot((path, store) => @@ -392,7 +557,7 @@ namespace BizTalkCheckmkPulse.Tests var lines = new CheckmkLocalFormatter(new MonitoringOptions()) .FormatSnapshotFailure("Snapshot file does not exist.") .ToArray(); - AssertEqual(8, lines.Length, "snapshot failure stable service count"); + AssertEqual(9, lines.Length, "snapshot failure stable service count"); Assert(lines.All(x => x.StartsWith("3 \"BizTalk ", StringComparison.Ordinal)), "snapshot failure must be UNKNOWN"); Assert(lines.All(x => x.Contains("Scheduled Task")), "snapshot failure must contain provider action"); }