From ac51714a884756de30a8cc56acd1a535bb7866a7 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Thu, 6 Aug 2026 15:58:18 +0200 Subject: [PATCH] Harden endpoint reachability resolution --- .editorconfig | 3 +- .gitattributes | 1 + Dokumentation.md | 32 ++- README.md | 33 +-- deployment/INSTALLATION.md | 9 +- docs/CheckmkServices.md | 9 +- docs/EndpointCatalog.md | 15 +- docs/ExampleOutput.md | 4 +- docs/Integration.md | 2 +- docs/architecture.svg | 2 +- src/BizTalkCheckmkPulse/App.config | 2 + .../CheckmkLocalFormatter.cs | 16 +- .../EndpointAddressParser.cs | 205 +++++++++++++----- .../EndpointCatalogStore.cs | 9 +- .../EndpointConnectivityProbe.cs | 170 +++++++++++---- src/BizTalkCheckmkPulse/Models.cs | 28 ++- src/BizTalkCheckmkPulse/MonitoringOptions.cs | 3 + src/BizTalkCheckmkPulse/Program.cs | 6 + tests/BizTalkCheckmkPulse.Tests/Program.cs | 159 +++++++++++++- 19 files changed, 556 insertions(+), 152 deletions(-) create mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig index 8f2608c..ea93d79 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,6 +8,5 @@ indent_style = space indent_size = 4 [*.md] -end_of_line = lf +end_of_line = crlf trim_trailing_whitespace = false - diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0c42f3c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=crlf diff --git a/Dokumentation.md b/Dokumentation.md index 04dea78..53e7661 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -246,7 +246,11 @@ minütlich abgefragten Instanzen werden nur dokumentierte Properties gelesen: Nur `Status=Started` beziehungsweise `IsDisabled=false` wird geprüft. Inaktive Artefakte bleiben aus der Probe ausgeschlossen, auch wenn sie noch im Katalog stehen. Dynamische Send Ports, lokale Dateipfade, SMTP-Empfängerlisten -und Named Pipes werden nicht als externe Host/Port-Ziele behandelt. +und Named Pipes sowie relative HTTP-/WCF-Receive-Listener werden als erwartbar +nicht socket-prüfbar behandelt und erzeugen kein `UNKNOWN`. WCF-SQL-Adressen +im Format `mssql://server[:port]/instanz/datenbank` werden auf TCP 1433 oder +den expliziten Port reduziert. Echte Auflösungslücken bleiben sichtbar und +nennen Artefakt, Adapter und Grund, aber niemals die vollständige URI. Der lokale Katalog `%ProgramData%\BizTalkCheckmkPulse\data\endpoints.xml` wird beim ersten @@ -380,9 +384,7 @@ scripts\package-release.cmd Der Release-Build der Visual-Studio-2019-Solution baut Anwendung, grafischen Installer und Packaging-Werkzeug. Danach liegen der Paketordner `artifacts\BizTalkCheckmkPulse-Setup` und das direkt transportierbare -`artifacts\BizTalkCheckmkPulse-Setup.zip` bereit. Zusätzlich entstehen -`BizTalkCheckmkPulse-Setup.zip.b64.txt` für `certutil -decode` und eine -SHA-256-Textdatei. +`artifacts\BizTalkCheckmkPulse-Setup.zip` bereit. ### 8.2 Normales Servicekonto @@ -553,7 +555,8 @@ BizTalk-Probes: | `EndpointDiscoveryIntervalHours` | `168` | | `EndpointProbeTimeoutMilliseconds` | `3000` | | `EndpointProbeMaxConcurrency` | `16` | -| `EndpointMaxCount` | `100` | +| `EndpointCatalogMaxEntries` | `1000` | +| `EndpointMaxCount` | `100` eindeutige Netzwerkziele | Alarmierung: @@ -589,7 +592,8 @@ Automatisiert: - betroffene Namen und Gesamtlänge der Summary bleiben begrenzt, - Endpoint-Adressen werden ohne URI-Secrets auf Host/Port reduziert, - manuelle Endpoint-Overrides überleben den automatischen Wochenabgleich, -- nur nicht erreichbare Endpoints erscheinen im Checkmk-Detail, +- nur nicht erreichbare oder wirklich unaufgelöste Endpoints erscheinen im + Checkmk-Detail, - 70 vollständig timeoutende eindeutige Ziele bleiben mit den Defaults in einem theoretischen Socket-Budget von etwa 15 Sekunden; das konfigurierte Maximum von 100 Zielen in etwa 21 Sekunden, @@ -616,15 +620,17 @@ Windows-/ACC-Abnahme: 10. ACL-Test: `LocalSystem` kann Snapshot lesen, nicht schreiben. 11. `endpoints.xml` wurde erzeugt; sie enthält keine vollständigen URIs oder Zugangsdaten und nur Started-/Enabled-Artefakte. -12. Einen erreichbaren Testport sperren: nur `Endpoint Reachability` wird CRIT +12. `Endpoint Reachability` zeigt `unresolved=0`; erwartbar lokale oder + adapterinterne Adressen stehen nur unter `expected_non_socket` und bleiben OK. +13. Einen erreichbaren Testport sperren: nur `Endpoint Reachability` wird CRIT und nennt ausschließlich das nicht erreichbare Ziel. -13. Ein Artefakt stoppen/deaktivieren: es wird im nächsten Minutenlauf nicht +14. Ein Artefakt stoppen/deaktivieren: es wird im nächsten Minutenlauf nicht mehr geprüft. -14. Katalogzeit auf älter als 168 Stunden setzen beziehungsweise Intervall +15. Katalogzeit auf älter 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: nächster Snapshot stellt Echtzustand her. -17. Agent-Dump und Checkmk Service Discovery erfolgreich. +16. Task deaktivieren: nach 180 Sekunden neun stale-`UNKNOWN`s. +17. Task wieder aktivieren: nächster Snapshot stellt Echtzustand her. +18. Agent-Dump und Checkmk Service Discovery erfolgreich. Erst nach ACC-Abnahme erfolgt der gestufte Rollout nach DEV/TST/PRD. @@ -650,6 +656,8 @@ Collector-Konto umgestellt werden. - https://learn.microsoft.com/en-us/biztalk/core/types-of-message-failures - https://learn.microsoft.com/en-us/biztalk/core/technical-reference/msbts-receivelocation-isdisabled-property-wmi - https://learn.microsoft.com/en-us/biztalk/core/technical-reference/msbts-sendport-status-property-wmi +- https://learn.microsoft.com/en-us/biztalk/adapters-and-accelerators/adapter-sql/create-the-sql-server-connection-uri +- https://learn.microsoft.com/en-us/biztalk/adapters-and-accelerators/adapter-oracle-database/create-the-oracle-database-connection-uri - https://docs.checkmk.com/latest/en/agent_windows.html - https://docs.checkmk.com/latest/en/localchecks.html - https://docs.checkmk.com/latest/en/spool_directory.html diff --git a/README.md b/README.md index 5b22d6a..7970915 100644 --- a/README.md +++ b/README.md @@ -108,16 +108,24 @@ Zustand und Graphen direkt erkennbar, ohne eine lange Sammelzeile zu lesen: - 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 ausschließlich nicht erreichbare Ziele + eine Gesamtaussage, im Fehlerfall ausschließlich nicht erreichbare oder + wirklich unaufgelöste Ziele - Orchestrations: total, started, stopped, bound, unbound und unbekannt Der Provider nutzt für 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 -geprüft; explizite `udp://`-Ziele per UDP-Datagramm. Es werden keine -HTTP-Requests, Anmeldungen oder fachlichen Nachrichten gesendet. +reiner Host/Port-Test: HTTP(S), SFTP, FTP, WCF/net.tcp, WCF-SQL/mssql und UNC +werden per TCP geprüft; explizite `udp://`-Ziele per UDP-Datagramm. Es werden +keine HTTP-Requests, Anmeldungen oder fachlichen Nachrichten gesendet. + +Nicht jede aktive BizTalk-Adresse ist selbst ein Socket-Ziel. Dynamische Send +Ports, lokale Pfade, SMTP-Empfänger, Named Pipes und relative HTTP-/WCF- +Receive-Listener werden deshalb als erwartbar nicht socket-prüfbar behandelt +und erzeugen kein `UNKNOWN`. Nur eine aktive statische Adresse, die tatsächlich +ein Netzwerkziel enthalten müsste und nicht sicher aufgelöst werden kann, +bleibt `UNKNOWN` und wird geheimnisfrei mit Artefakt, Adapter und Grund genannt. Der geheimnisfreie Katalog liegt unter `%ProgramData%\BizTalkCheckmkPulse\data\endpoints.xml`. Fehlt er, wird er beim @@ -185,8 +193,6 @@ und das transportierbare ZIP: ```text artifacts\ BizTalkCheckmkPulse-Setup.zip - BizTalkCheckmkPulse-Setup.zip.b64.txt - BizTalkCheckmkPulse-Setup.zip.sha256.txt BizTalkCheckmkPulse-Setup\ Setup.exe INSTALLATION.md @@ -200,14 +206,6 @@ Das Packaging-Projekt ist Teil von `BizTalkCheckmkPulse.sln`. Ein normaler `Release`-Build in Visual Studio 2019 genügt daher; ein separates PowerShell-/WiX-/MSI-Tooling wird nicht benötigt. -Die Base64-TXT kann auf dem Windows-Zielsystem ohne Zusatzwerkzeug dekodiert -werden: - -```cmd -certutil -decode BizTalkCheckmkPulse-Setup.zip.b64.txt BizTalkCheckmkPulse-Setup.zip -certutil -hashfile BizTalkCheckmkPulse-Setup.zip SHA256 -``` - Format-Self-Test ohne WMI, SQL oder Event Log: ```cmd @@ -362,10 +360,11 @@ Wichtige Werte: | `LogRetentionDays` | `30` | Provider bereinigt ältere Logs. | | `ProbeEndpointConnectivity` | `true` | Aktiviert den aggregierten TCP-/UDP-Netzwerkcheck. | | `EndpointCatalogPath` | `%ProgramData%\BizTalkCheckmkPulse\data\endpoints.xml` | Lokal gepflegte Endpoint-Konfiguration ohne vollständige URIs/Secrets. | +| `EndpointCatalogMaxEntries` | `1000` | Harte Größenbegrenzung für Artefakte im Katalog. | | `EndpointDiscoveryIntervalHours` | `168` | Intervall für den vollständigen Umgebungsabgleich. | | `EndpointProbeTimeoutMilliseconds` | `3000` | Timeout je dedupliziertem Host/Port-Ziel. | | `EndpointProbeMaxConcurrency` | `16` | Begrenzte parallele Socket-Probes. | -| `EndpointMaxCount` | `100` | Harte Obergrenze und Laufzeitbudget; bei 3 s Timeout theoretisch maximal etwa 21 s. | +| `EndpointMaxCount` | `100` | Obergrenze eindeutiger Host/Port/Protokoll-Ziele und damit Laufzeitbudget; bei 3 s Timeout theoretisch maximal etwa 21 s. | | `QueryTimeoutSeconds` | `25` | WMI-Timeout je Query. | | `SqlConnectionTimeoutSeconds` | `5` | SQL-Timeout je Ziel. | | `WarnResumableThreshold` | `1` | WARN ab n resumable Suspensions. | @@ -393,7 +392,7 @@ liest den nächsten atomar publizierten Snapshot. | Provider meldet `Login failed` | Provider-Konto und exakt konfigurierte Read-Only-Gruppe sowie `BTS_READONLY_USERS` prüfen. | | Receive Locations / Send Ports sind CRIT | `affected=` prüfen; nur fachlich bewusst inaktive Namen exakt in die jeweilige Allowlist aufnehmen. | | Endpoint Reachability ist CRIT | Nur `unavailable=` prüfen; DNS, Zielport, Firewall und externen Dienst kontrollieren. | -| Endpoint Reachability ist UNKNOWN | WMI-Vollständigkeit, `endpoints.xml`, wöchentlichen Refresh und nicht automatisch auflösbare externe Adapteradresse prüfen. | +| Endpoint Reachability ist UNKNOWN | `unresolved_endpoints=`, WMI-Vollständigkeit, `endpoints.xml` und wöchentlichen Refresh prüfen; indirekte Adapterziele bei Bedarf geheimnisfrei manuell überschreiben. | | `Wmi/Schema` | Klasse/Properties gegen BizTalk-2020-Schema prüfen; keine Rechte ausweiten. | | Nur Event Log `UNKNOWN` | lokalen Application-Log-Zugriff des Provider-Kontos prüfen. | | Task-Result `2` | Parallelstart oder Snapshot-I/O; Log und Lock/ACL prüfen. | @@ -416,6 +415,8 @@ geändert und muss separat durch AD-/BizTalk-Administration entfernt werden. - 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) +- Microsoft: [SQL-Adapter-Verbindungs-URI](https://learn.microsoft.com/en-us/biztalk/adapters-and-accelerators/adapter-sql/create-the-sql-server-connection-uri) +- Microsoft: [Oracle-Database-Adapter-Verbindungs-URI](https://learn.microsoft.com/en-us/biztalk/adapters-and-accelerators/adapter-oracle-database/create-the-oracle-database-connection-uri) - Checkmk: Windows Agent und Local Checks Die genauen Links stehen in [Dokumentation.md](Dokumentation.md). diff --git a/deployment/INSTALLATION.md b/deployment/INSTALLATION.md index 1196a72..b5073c0 100644 --- a/deployment/INSTALLATION.md +++ b/deployment/INSTALLATION.md @@ -1,12 +1,5 @@ # Installation auf dem BizTalk-Server -Falls nur die TXT-Datei übertragen wurde: - -```cmd -certutil -decode BizTalkCheckmkPulse-Setup.zip.b64.txt BizTalkCheckmkPulse-Setup.zip -certutil -hashfile BizTalkCheckmkPulse-Setup.zip SHA256 -``` - 1. `BizTalkCheckmkPulse-Setup.zip` vollständig in ein lokales Verzeichnis entpacken. 2. `Setup.exe` als lokaler Administrator starten und die UAC-Abfrage bestätigen. 3. Das Collector-Konto im Format `DOMAIN\Benutzer` eingeben, zum Beispiel @@ -25,6 +18,8 @@ registriert wird. Die früheren unveränderten Endpoint-Defaults `12` parallele Probes und `EndpointMaxCount=500` werden beim Update auf `16` beziehungsweise `100` migriert. Abweichende, bewusst konfigurierte Werte bleiben erhalten. +`EndpointMaxCount` begrenzt nun eindeutige Socket-Ziele statt Artefakte; der +neue Wert `EndpointCatalogMaxEntries=1000` begrenzt separat die Kataloggröße. Vor dem Stoppen des vorhandenen Tasks wird die neue Version separat getestet. Bei normalen Servicekonten prüft der Installer außerdem Kennwort und diff --git a/docs/CheckmkServices.md b/docs/CheckmkServices.md index d9a87d0..06a04c2 100644 --- a/docs/CheckmkServices.md +++ b/docs/CheckmkServices.md @@ -182,6 +182,11 @@ Zweck: `Status=Started`. - Reduziert die Adresse auf Host/Port und führt keinen fachlichen HTTP-, FTP-, SFTP-, WCF- oder BizTalk-Aufruf aus. +- Erkennt unter anderem Standard-URIs, UNC, MSMQ, `net.tcp`, WCF-SQL- + `mssql`-URIs und lokale HTTP-/WCF-Wildcard-Listener. +- Behandelt Adressen ohne eigenständiges Socket-Ziel (zum Beispiel lokale + Pfade, SMTP-Empfänger, Named Pipes und relative Receive-Listener) als + erwartbar ausgenommen und nicht als Abdeckungsfehler. - Dedupliziert gemeinsame Host/Port-Ziele und prüft sie mit begrenzter Parallelität; das Ergebnis wird danach wieder den Artefakten zugeordnet. - Zeigt im gesunden Zustand nur `Alle n ... erreichbar`. Nur bei Fehlern @@ -206,7 +211,9 @@ Metriken: | `biztalk_endpoints_tested` | Vollständig 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_unresolved` | Aktive statische Netzwerkadressen ohne sicher bestimmbaren Host/Port; `UNKNOWN` ab 1, sofern kein manueller Override existiert. | +| `biztalk_endpoints_expected_non_socket` | Aktive Artefakte, deren Adresse erwartbar kein eigenständiges TCP-/UDP-Ziel enthält. | +| `biztalk_endpoints_manual_overrides` | Aktive Ziele, die bewusst über einen geheimnisfreien manuellen Katalogeintrag aufgelöst werden. | | `biztalk_endpoints_inactive_skipped` | Konfigurierte Einträge, deren BizTalk-Artefakt aktuell nicht aktiv ist. | | `biztalk_endpoint_probe_duration_ms` | Gesamtlaufzeit der parallelisierten Socket-Probes in Millisekunden. | diff --git a/docs/EndpointCatalog.md b/docs/EndpointCatalog.md index 68eb987..dd1755b 100644 --- a/docs/EndpointCatalog.md +++ b/docs/EndpointCatalog.md @@ -23,8 +23,11 @@ BizTalk-Runtimezustand gefiltert: - dynamischer Send Port nie als statisches Ziel Damit wird ein inzwischen gestopptes/deaktiviertes Artefakt sofort nicht mehr -geprüft. Neue Artefakte werden spätestens beim nächsten Wochenabgleich -aufgenommen. +geprüft. Prüffähige aktuelle WMI-Adressen werden bei jedem Minutenlauf direkt +verwendet; der Wochenabgleich pflegt nur den persistenten, geheimnisfreien +Katalog. Dadurch wird weder ein neues Ziel verzögert noch eine bis zu sieben +Tage alte Auto-Discovery-Adresse weiter geprüft. Manuelle Overrides gewinnen +weiterhin anhand ihres stabilen Artefaktschlüssels. ## Laufzeitbudget @@ -33,8 +36,12 @@ maximal `EndpointProbeMaxConcurrency=16` parallelen Verbindungen ausgeführt. Bei `EndpointProbeTimeoutMilliseconds=3000` benötigen 70 vollständig timeoutende eindeutige Ziele theoretisch etwa 15 Sekunden. Die Standardgrenze `EndpointMaxCount=100` begrenzt diesen Anteil eines Providerlaufs auf etwa -21 Sekunden. Nicht prüfbare aktive BizTalk-Artefakte zählen nicht gegen diese -Grenze; sie werden separat als `unsupported` gemeldet. +21 Sekunden. Die Grenze gilt für deduplizierte Host/Port/Protokoll-Ziele, nicht +für die Anzahl der darauf abgebildeten BizTalk-Artefakte. Der Katalog ist +separat über `EndpointCatalogMaxEntries=1000` begrenzt. Erwartbar nicht als +Socket prüfbare Artefakte zählen nicht gegen das Laufzeitbudget; echte +Auflösungslücken werden als `unresolved` mit einem geheimnisfreien Grund +gemeldet. Checkmk selbst wartet nicht auf diese Verbindungen, sondern liest nur den zuletzt atomar geschriebenen Snapshot. Die reale ACC-Laufzeit ist nach dem diff --git a/docs/ExampleOutput.md b/docs/ExampleOutput.md index 41afa38..f161040 100644 --- a/docs/ExampleOutput.md +++ b/docs/ExampleOutput.md @@ -61,13 +61,13 @@ Artefakte. Nur die Textliste ist begrenzt. Gesund, ohne Auflistung aller Ziele: ```text -0 "BizTalk ACC Endpoint Reachability" biztalk_endpoints_configured=63;;;0|biztalk_endpoints_active=61;;;0|biztalk_endpoints_unique_targets=44;;;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|biztalk_endpoint_probe_duration_ms=842;;;0 Alle 61 aktiven, pruefbaren Send-/Receive-Endpunkte sind erreichbar (unique_targets=44, probe_ms=842); catalog_utc=2026-08-04T10:00:00.0000000Z +0 "BizTalk ACC Endpoint Reachability" biztalk_endpoints_configured=63;;;0|biztalk_endpoints_active=61;;;0|biztalk_endpoints_unique_targets=44;;;0|biztalk_endpoints_tested=61;;;0|biztalk_endpoints_available=61;;;0|biztalk_endpoints_failed=0;;1;0|biztalk_endpoints_unresolved=0;;1;0|biztalk_endpoints_expected_non_socket=12;;;0|biztalk_endpoints_manual_overrides=0;;;0|biztalk_endpoints_inactive_skipped=2;;;0|biztalk_endpoint_probe_duration_ms=842;;;0 Alle 61 aktiven, pruefbaren Send-/Receive-Endpunkte sind erreichbar (unique_targets=44, expected_non_socket=12, manual_overrides=0, probe_ms=842); 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) +2 "BizTalk ACC Endpoint Reachability" ... Endpoint reachability active=61, tested=61, available=59, failed=2, unresolved=0, expected_non_socket=12, manual_overrides=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 diff --git a/docs/Integration.md b/docs/Integration.md index 4ef61fa..2df07cb 100644 --- a/docs/Integration.md +++ b/docs/Integration.md @@ -108,7 +108,7 @@ Soll: - 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 - ausschließlich nicht erreichbare Ziele. + ausschließlich nicht erreichbare oder wirklich unaufgelöste Ziele. - keine berechtigungsbedingten `UNKNOWN`-Services. ## Alarmierung der Transportkette diff --git a/docs/architecture.svg b/docs/architecture.svg index 67ed84f..dc98409 100644 --- a/docs/architecture.svg +++ b/docs/architecture.svg @@ -52,7 +52,7 @@ Externe BizTalk-Endpunkte - TCP: HTTP(S), SFTP, FTP, WCF, SMB + TCP: HTTP(S), SFTP/FTP, WCF/SQL, SMB UDP: explizite udp://-Ziele nur Started / Enabled diff --git a/src/BizTalkCheckmkPulse/App.config b/src/BizTalkCheckmkPulse/App.config index 987ae1b..a5b4301 100644 --- a/src/BizTalkCheckmkPulse/App.config +++ b/src/BizTalkCheckmkPulse/App.config @@ -18,6 +18,8 @@ + + diff --git a/src/BizTalkCheckmkPulse/CheckmkLocalFormatter.cs b/src/BizTalkCheckmkPulse/CheckmkLocalFormatter.cs index 1c6aa9e..8c2a1e9 100644 --- a/src/BizTalkCheckmkPulse/CheckmkLocalFormatter.cs +++ b/src/BizTalkCheckmkPulse/CheckmkLocalFormatter.cs @@ -381,7 +381,7 @@ namespace BizTalkCheckmkPulse 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.UnresolvedActive > 0 || endpointState.RefreshRequired && !endpointState.RefreshSucceeded || !string.IsNullOrWhiteSpace(endpointState.Failure); var state = failed.Length > 0 @@ -391,14 +391,16 @@ namespace BizTalkCheckmkPulse : CheckState.Ok; var metrics = string.Format( CultureInfo.InvariantCulture, - "biztalk_endpoints_configured={0};;;0|biztalk_endpoints_active={1};;;0|biztalk_endpoints_unique_targets={2};;;0|biztalk_endpoints_tested={3};;;0|biztalk_endpoints_available={4};;;0|biztalk_endpoints_failed={5};;1;0|biztalk_endpoints_unsupported={6};;1;0|biztalk_endpoints_inactive_skipped={7};;;0|biztalk_endpoint_probe_duration_ms={8};;;0", + "biztalk_endpoints_configured={0};;;0|biztalk_endpoints_active={1};;;0|biztalk_endpoints_unique_targets={2};;;0|biztalk_endpoints_tested={3};;;0|biztalk_endpoints_available={4};;;0|biztalk_endpoints_failed={5};;1;0|biztalk_endpoints_unresolved={6};;1;0|biztalk_endpoints_expected_non_socket={7};;;0|biztalk_endpoints_manual_overrides={8};;;0|biztalk_endpoints_inactive_skipped={9};;;0|biztalk_endpoint_probe_duration_ms={10};;;0", endpointState.Configured, endpointState.Active, endpointState.UniqueTargets, endpointState.Results.Count, available, failed.Length, - endpointState.UnsupportedActive, + endpointState.UnresolvedActive, + endpointState.ExpectedNonProbeableActive, + endpointState.ManualOverridesActive, endpointState.SkippedInactive, endpointState.ProbeDurationMilliseconds); var detail = new StringBuilder(); @@ -406,6 +408,8 @@ namespace BizTalkCheckmkPulse { detail.Append("Alle ").Append(endpointState.Active).Append(" aktiven, pruefbaren Send-/Receive-Endpunkte sind erreichbar") .Append(" (unique_targets=").Append(endpointState.UniqueTargets) + .Append(", expected_non_socket=").Append(endpointState.ExpectedNonProbeableActive) + .Append(", manual_overrides=").Append(endpointState.ManualOverridesActive) .Append(", probe_ms=").Append(endpointState.ProbeDurationMilliseconds).Append(")"); } else @@ -414,7 +418,9 @@ namespace BizTalkCheckmkPulse .Append(", tested=").Append(endpointState.Results.Count) .Append(", available=").Append(available) .Append(", failed=").Append(failed.Length) - .Append(", unsupported_external=").Append(endpointState.UnsupportedActive) + .Append(", unresolved=").Append(endpointState.UnresolvedActive) + .Append(", expected_non_socket=").Append(endpointState.ExpectedNonProbeableActive) + .Append(", manual_overrides=").Append(endpointState.ManualOverridesActive) .Append(", unique_targets=").Append(endpointState.UniqueTargets) .Append(", probe_ms=").Append(endpointState.ProbeDurationMilliseconds); AppendLimitedList(detail, "unavailable", failed.Select(x => @@ -423,6 +429,8 @@ namespace BizTalkCheckmkPulse { detail.Append("; catalog_or_probe_error=").Append(endpointState.Failure); } + + AppendLimitedList(detail, "unresolved_endpoints", endpointState.ResolutionIssues); } if (endpointState.CatalogSynchronizedUtc.HasValue) diff --git a/src/BizTalkCheckmkPulse/EndpointAddressParser.cs b/src/BizTalkCheckmkPulse/EndpointAddressParser.cs index 516bf66..203f616 100644 --- a/src/BizTalkCheckmkPulse/EndpointAddressParser.cs +++ b/src/BizTalkCheckmkPulse/EndpointAddressParser.cs @@ -21,33 +21,77 @@ namespace BizTalkCheckmkPulse out EndpointCatalogEntry entry, out string reason) { - entry = null; - reason = string.Empty; + var resolution = Analyze(candidate); + entry = resolution.Entry; + reason = resolution.Reason ?? string.Empty; + return resolution.Status == EndpointResolutionStatus.Probeable; + } + + /// + /// Trennt sicher pruefbare Ziele von erwartbar lokalen/adapterinternen Adressen und echten Aufloesungsluecken. + /// + public static EndpointResolution Analyze(EndpointCandidate candidate) + { if (candidate == null) { - reason = "Endpoint-Kandidat fehlt."; - return false; + return Unresolved("Endpoint-Kandidat fehlt."); } if (candidate.Dynamic) { - reason = "Dynamischer Send Port besitzt kein statisch pruefbares Ziel."; - return false; + return Expected("Dynamischer Send Port besitzt kein statisch pruefbares Ziel."); } var address = (candidate.Address ?? string.Empty).Trim(); if (address.Length == 0) { - reason = "Transportadresse ist leer."; - return false; + return Unresolved("Aktive statische Transportadresse ist leer."); } + if (LooksLikeEmailRecipients(address) || Contains(candidate.AdapterName, "SMTP")) + { + return Expected("SMTP-Empfaenger beziehungsweise Adapteradresse enthaelt nicht den SMTP-Server."); + } + + if (Regex.IsMatch(address, @"^[a-zA-Z]:[\\/]", RegexOptions.CultureInvariant) + || Contains(candidate.AdapterName, "FILE") && !address.StartsWith("\\\\", StringComparison.Ordinal)) + { + return Expected("Lokaler Dateipfad benoetigt keine Netzwerkprobe."); + } + + if (address.StartsWith("net.pipe:", StringComparison.OrdinalIgnoreCase) + || address.StartsWith("npipe:", StringComparison.OrdinalIgnoreCase)) + { + return Expected("Named Pipes besitzen kein TCP-/UDP-Ziel."); + } + + if (address.StartsWith("/", StringComparison.Ordinal) + && IsInboundListener(candidate)) + { + return Expected("Relative Receive-Listener-Adresse enthaelt keine eindeutige Site-Bindung."); + } + + EndpointCatalogEntry entry; string protocol; string host; int port; - if (!TryResolve(address, candidate.AdapterName, out protocol, out host, out port, out reason)) + string reason; + if (!TryResolve( + address, + candidate.AdapterName, + IsInboundListener(candidate), + out protocol, + out host, + out port, + out reason)) { - return false; + if (address.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + || address.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase)) + { + return Expected(reason); + } + + return Unresolved(reason); } entry = new EndpointCatalogEntry @@ -64,7 +108,12 @@ namespace BizTalkCheckmkPulse Enabled = true, AutoDiscovered = true }; - return true; + return new EndpointResolution + { + Status = EndpointResolutionStatus.Probeable, + Entry = entry, + Reason = string.Empty + }; } /// @@ -72,52 +121,13 @@ namespace BizTalkCheckmkPulse /// 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"); + return Analyze(candidate).Status == EndpointResolutionStatus.Unresolved; } private static bool TryResolve( string address, string adapterName, + bool allowWildcardListener, out string protocol, out string host, out int port, @@ -128,6 +138,12 @@ namespace BizTalkCheckmkPulse port = 0; reason = string.Empty; + if (allowWildcardListener + && TryResolveWildcardListener(address, out protocol, out host, out port, out reason)) + { + return true; + } + if (LooksLikeEmailRecipients(address)) { reason = "SMTP-Empfaengerliste ist kein pruefbarer Netzwerk-Endpunkt."; @@ -210,14 +226,14 @@ namespace BizTalkCheckmkPulse if (Contains(adapterName, "SFTP")) { - host = address.Trim('/'); + host = ExtractBareAdapterHost(address); port = 22; return Validate(host, port, out reason); } if (Contains(adapterName, "FTP")) { - host = address.Trim('/'); + host = ExtractBareAdapterHost(address); port = 21; return Validate(host, port, out reason); } @@ -248,10 +264,91 @@ namespace BizTalkCheckmkPulse case "imap": return 143; case "imaps": return 993; case "msmq": return 1801; + case "net.tcp": return 808; + case "mssql": return 1433; + // Ohne Pfad ist der Hostteil ein tnsnames.ora-Alias und kein sicher pruefbarer DNS-Host. + case "oracledb": return uri.AbsolutePath.Trim('/').Length > 0 ? 1521 : 0; default: return uri.Port > 0 ? uri.Port : 0; } } + /// + /// Reduziert lokale HTTP-/WCF-Wildcard-Listener auf einen nebenwirkungsfreien Loopback-Porttest. + /// + private static bool TryResolveWildcardListener( + string address, + out string protocol, + out string host, + out int port, + out string reason) + { + protocol = "TCP"; + host = string.Empty; + port = 0; + reason = string.Empty; + var match = Regex.Match( + address, + @"^(?https?|net\.tcp)://(?:\+|\*)(?::(?\d{1,5}))?(?:[/\\].*)?$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success) + { + return false; + } + + var scheme = match.Groups["scheme"].Value.ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(match.Groups["port"].Value) + && !int.TryParse(match.Groups["port"].Value, out port)) + { + port = 0; + } + + if (port <= 0) + { + port = scheme == "https" ? 443 : scheme == "net.tcp" ? 808 : 80; + } + + host = "127.0.0.1"; + return Validate(host, port, out reason); + } + + private static string ExtractBareAdapterHost(string address) + { + var value = (address ?? string.Empty).Trim().Trim('/'); + var separator = value.IndexOfAny(new[] { '/', '\\' }); + return separator < 0 ? value : value.Substring(0, separator); + } + + /// + /// Erkennt Adapter, deren relative Receive-Adresse eine lokale Listenerbindung statt eines Remoteziels beschreibt. + /// + private static bool IsInboundListener(EndpointCandidate candidate) + { + return string.Equals(candidate.ArtifactType, "ReceiveLocation", StringComparison.OrdinalIgnoreCase) + && (Contains(candidate.AdapterName, "HTTP") + || Contains(candidate.AdapterName, "SOAP") + || Contains(candidate.AdapterName, "WCF")); + } + + private static EndpointResolution Expected(string reason) + { + return new EndpointResolution + { + Status = EndpointResolutionStatus.ExpectedNonProbeable, + Reason = reason + }; + } + + private static EndpointResolution Unresolved(string reason) + { + return new EndpointResolution + { + Status = EndpointResolutionStatus.Unresolved, + Reason = string.IsNullOrWhiteSpace(reason) + ? "Transportadresse konnte nicht sicher auf Host und Port reduziert werden." + : reason + }; + } + private static bool HasExplicitPort(string address) { var authorityEnd = address.IndexOfAny(new[] { '/', '?' }, address.IndexOf("://", StringComparison.Ordinal) + 3); diff --git a/src/BizTalkCheckmkPulse/EndpointCatalogStore.cs b/src/BizTalkCheckmkPulse/EndpointCatalogStore.cs index 0cbe4da..8bcb369 100644 --- a/src/BizTalkCheckmkPulse/EndpointCatalogStore.cs +++ b/src/BizTalkCheckmkPulse/EndpointCatalogStore.cs @@ -90,14 +90,14 @@ namespace BizTalkCheckmkPulse EnvironmentName = catalogEnvironment, SynchronizedUtc = synchronizedUtc, ActiveCandidates = ReadInt(root, "activeCandidates", 0, 100000), - UnsupportedCandidates = ReadInt(root, "unsupportedCandidates", 0, 100000) + UnresolvedCandidates = ReadInt(root, "unsupportedCandidates", 0, 100000) }; foreach (var node in root.Elements("Endpoint")) { if (catalog.Entries.Count >= _maxEntries) { - throw new InvalidDataException("Endpoint catalog exceeds EndpointMaxCount."); + throw new InvalidDataException("Endpoint catalog exceeds the configured entry limit."); } var entry = new EndpointCatalogEntry @@ -135,7 +135,7 @@ namespace BizTalkCheckmkPulse if (catalog.Entries.Count > _maxEntries) { - throw new InvalidDataException("Endpoint catalog exceeds EndpointMaxCount."); + throw new InvalidDataException("Endpoint catalog exceeds the configured entry limit."); } foreach (var entry in catalog.Entries) @@ -150,7 +150,8 @@ namespace BizTalkCheckmkPulse 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)); + // Der XML-Attributsname bleibt fuer vorhandene Katalogdateien der Version 1 stabil. + new XAttribute("unsupportedCandidates", catalog.UnresolvedCandidates)); foreach (var entry in catalog.Entries .OrderBy(x => x.ApplicationName, StringComparer.OrdinalIgnoreCase) .ThenBy(x => x.ArtifactType, StringComparer.OrdinalIgnoreCase) diff --git a/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs b/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs index 66a056e..c401b0d 100644 --- a/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs +++ b/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs @@ -44,7 +44,7 @@ namespace BizTalkCheckmkPulse var store = new EndpointCatalogStore( _options.EndpointCatalogPath, _options.EndpointCatalogMaxBytes, - _options.EndpointMaxCount); + _options.EndpointCatalogMaxEntries); EndpointCatalog catalog = null; string readFailure = null; try @@ -76,7 +76,7 @@ namespace BizTalkCheckmkPulse "Endpoint catalog synchronized. path=" + _options.EndpointCatalogPath + " active_candidates=" + catalog.ActiveCandidates + " configured=" + catalog.Entries.Count - + " unsupported_external=" + catalog.UnsupportedCandidates); + + " unresolved_candidates=" + catalog.UnresolvedCandidates); } catch (Exception ex) { @@ -104,19 +104,28 @@ namespace BizTalkCheckmkPulse state.CatalogAvailable = true; state.CatalogSynchronizedUtc = catalog.SynchronizedUtc; state.Configured = catalog.Entries.Count; - state.UnsupportedActive = CountUnsupportedExternal(result.EndpointCandidates); + var activeEntries = ResolveActiveEndpoints(catalog, result.EndpointCandidates, state); + var activeTargetCount = activeEntries + .Select(TargetKey) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + if (activeTargetCount > _options.EndpointMaxCount) + { + state.Failure = "Eindeutige aktive Netzwerkziele=" + activeTargetCount + + " ueberschreiten EndpointMaxCount=" + _options.EndpointMaxCount + "."; + return; + } - 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; + + // Detailwarnungen nur beim Katalogabgleich schreiben; der Minutensnapshot + // enthaelt die aktuelle begrenzte Liste bereits und das Tageslog bleibt kompakt. + foreach (var issue in state.RefreshRequired + ? state.ResolutionIssues.Take(20) + : Enumerable.Empty()) + { + _logger.Warning("Endpoint target unresolved. " + issue); + } try { @@ -155,26 +164,26 @@ namespace BizTalkCheckmkPulse 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 discovered = new List(); + var unresolved = 0; + foreach (var candidate in active) + { + var resolution = EndpointAddressParser.Analyze(candidate); + if (resolution.Status == EndpointResolutionStatus.Probeable) + { + discovered.Add(resolution.Entry); + } + else if (resolution.Status == EndpointResolutionStatus.Unresolved + && !manualKeys.Contains(candidate.Key)) + { + unresolved++; + } + } + var merged = manual .Concat(discovered.Where(x => !manualKeys.Contains(x.Key))) .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) @@ -187,7 +196,8 @@ namespace BizTalkCheckmkPulse EnvironmentName = existing == null ? string.Empty : existing.EnvironmentName, SynchronizedUtc = synchronizedUtc.ToUniversalTime(), ActiveCandidates = active.Length, - UnsupportedCandidates = unsupported + // Attributsname bleibt fuer die Rueckwaertskompatibilitaet des Katalogformats bestehen. + UnresolvedCandidates = unresolved }; catalog.Entries.AddRange(merged); return catalog; @@ -200,29 +210,107 @@ namespace BizTalkCheckmkPulse { var catalog = SynchronizeCatalog(existing, candidates, synchronizedUtc); catalog.EnvironmentName = _options.EnvironmentName ?? string.Empty; - if (catalog.Entries.Count > _options.EndpointMaxCount) + if (catalog.Entries.Count > _options.EndpointCatalogMaxEntries) { - throw new InvalidDataException("Probeable endpoints exceed EndpointMaxCount."); + throw new InvalidDataException("Endpoint catalog exceeds EndpointCatalogMaxEntries."); } return catalog; } - private static int CountUnsupportedExternal(IEnumerable candidates) + /// + /// Ermittelt aus aktuellen WMI-Kandidaten und manuellen Overrides die in diesem Lauf zu pruefenden Ziele. + /// Automatisch erkannte Katalogeintraege werden bewusst nicht als veraltete Laufzeitquelle verwendet. + /// + internal static EndpointCatalogEntry[] ResolveActiveEndpoints( + EndpointCatalog catalog, + IEnumerable candidates, + EndpointConnectivityState state) { - var count = 0; - foreach (var candidate in candidates.Where(x => x.Active)) + if (catalog == null) { - EndpointCatalogEntry ignored; - string reason; - if (!EndpointAddressParser.TryCreate(candidate, out ignored, out reason) - && EndpointAddressParser.IsPotentialExternalEndpoint(candidate)) + throw new ArgumentNullException("catalog"); + } + + if (state == null) + { + throw new ArgumentNullException("state"); + } + + var activeCandidates = (candidates ?? Enumerable.Empty()) + .Where(x => x != null && x.Active) + .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(); + var activeKeys = new HashSet(activeCandidates.Select(x => x.Key), StringComparer.OrdinalIgnoreCase); + var manualOverrides = catalog.Entries + .Where(x => x.Enabled && !x.AutoDiscovered) + .Where(x => !string.Equals(x.ArtifactType, "Manual", StringComparison.OrdinalIgnoreCase)) + .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); + var selected = catalog.Entries + .Where(x => x.Enabled && string.Equals(x.ArtifactType, "Manual", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var candidate in activeCandidates) + { + EndpointCatalogEntry manual; + if (manualOverrides.TryGetValue(candidate.Key, out manual)) { - count++; + selected.Add(manual); + state.ManualOverridesActive++; + continue; + } + + var resolution = EndpointAddressParser.Analyze(candidate); + if (resolution.Status == EndpointResolutionStatus.Probeable) + { + // Immer das aktuelle WMI-Ziel pruefen; der Wochenkatalog darf keine alte Adresse erzwingen. + selected.Add(resolution.Entry); + } + else if (resolution.Status == EndpointResolutionStatus.ExpectedNonProbeable) + { + state.ExpectedNonProbeableActive++; + } + else + { + state.UnresolvedActive++; + state.ResolutionIssues.Add(DisplayResolutionIssue(candidate, resolution.Reason)); } } - return count; + state.SkippedInactive = catalog.Entries.Count(x => + x.Enabled + && !string.Equals(x.ArtifactType, "Manual", StringComparison.OrdinalIgnoreCase) + && !activeKeys.Contains(x.Key)); + return selected + .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(); + } + + /// + /// Formatiert eine Aufloesungsluecke ohne Transportadresse, Pfad, Querystring oder Zugangsdaten. + /// + private static string DisplayResolutionIssue(EndpointCandidate candidate, string reason) + { + var artifact = string.IsNullOrWhiteSpace(candidate.ApplicationName) + || string.Equals(candidate.ApplicationName, "(unknown)", StringComparison.OrdinalIgnoreCase) + ? candidate.ArtifactName + : candidate.ApplicationName + "\\" + candidate.ArtifactName; + return "endpoint=" + CompactText(candidate.ArtifactType) + ":" + CompactText(artifact) + + "[" + CompactText(candidate.TransportRole) + "]" + + " adapter=" + CompactText(candidate.AdapterName) + + " reason=" + CompactText(reason); + } + + private static string CompactText(string value) + { + return (value ?? string.Empty) + .Replace('\r', ' ') + .Replace('\n', ' ') + .Replace('|', '/') + .Trim(); } private async Task> ProbeAllAsync(EndpointCatalogEntry[] endpoints) diff --git a/src/BizTalkCheckmkPulse/Models.cs b/src/BizTalkCheckmkPulse/Models.cs index a45753d..87f40ab 100644 --- a/src/BizTalkCheckmkPulse/Models.cs +++ b/src/BizTalkCheckmkPulse/Models.cs @@ -269,6 +269,26 @@ namespace BizTalkCheckmkPulse } } + /// + /// Ergebnis der geheimnisfreien Auswertung einer BizTalk-Transportadresse. + /// + internal enum EndpointResolutionStatus + { + Probeable, + ExpectedNonProbeable, + Unresolved + } + + /// + /// Klassifiziert einen Kandidaten und enthaelt nur bei sicherer Aufloesung ein Socket-Ziel. + /// + internal sealed class EndpointResolution + { + public EndpointResolutionStatus Status { get; set; } + public EndpointCatalogEntry Entry { get; set; } + public string Reason { get; set; } + } + /// /// Ein geheimnisfreier, lokal persistierbarer Netzwerk-Endpunkt. /// @@ -314,7 +334,7 @@ namespace BizTalkCheckmkPulse public string MachineName { get; set; } public string EnvironmentName { get; set; } public int ActiveCandidates { get; set; } - public int UnsupportedCandidates { get; set; } + public int UnresolvedCandidates { get; set; } public List Entries { get; private set; } } @@ -337,6 +357,7 @@ namespace BizTalkCheckmkPulse public EndpointConnectivityState() { Results = new List(); + ResolutionIssues = new List(); } public bool Disabled { get; set; } @@ -348,10 +369,13 @@ namespace BizTalkCheckmkPulse public int Configured { get; set; } public int Active { get; set; } public int SkippedInactive { get; set; } - public int UnsupportedActive { get; set; } + public int UnresolvedActive { get; set; } + public int ExpectedNonProbeableActive { get; set; } + public int ManualOverridesActive { get; set; } public int UniqueTargets { get; set; } public long ProbeDurationMilliseconds { get; set; } public string Failure { get; set; } public List Results { get; private set; } + public List ResolutionIssues { get; private set; } } } diff --git a/src/BizTalkCheckmkPulse/MonitoringOptions.cs b/src/BizTalkCheckmkPulse/MonitoringOptions.cs index bcedc01..5bc6dbd 100644 --- a/src/BizTalkCheckmkPulse/MonitoringOptions.cs +++ b/src/BizTalkCheckmkPulse/MonitoringOptions.cs @@ -41,6 +41,7 @@ namespace BizTalkCheckmkPulse public bool ProbeEndpointConnectivity { get; set; } public string EndpointCatalogPath { get; set; } public int EndpointCatalogMaxBytes { get; set; } + public int EndpointCatalogMaxEntries { get; set; } public int EndpointDiscoveryIntervalHours { get; set; } public int EndpointProbeTimeoutMilliseconds { get; set; } public int EndpointProbeMaxConcurrency { get; set; } @@ -83,6 +84,7 @@ namespace BizTalkCheckmkPulse ProbeEndpointConnectivity = true; EndpointCatalogPath = Path.Combine(commonData, "BizTalkCheckmkPulse", "data", "endpoints.xml"); EndpointCatalogMaxBytes = 1048576; + EndpointCatalogMaxEntries = 1000; EndpointDiscoveryIntervalHours = 168; EndpointProbeTimeoutMilliseconds = 3000; EndpointProbeMaxConcurrency = 16; @@ -141,6 +143,7 @@ namespace BizTalkCheckmkPulse 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.EndpointCatalogMaxEntries = ReadInt(settings, "EndpointCatalogMaxEntries", options.EndpointCatalogMaxEntries, 1, 100000); 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); diff --git a/src/BizTalkCheckmkPulse/Program.cs b/src/BizTalkCheckmkPulse/Program.cs index 621cfd2..3934587 100644 --- a/src/BizTalkCheckmkPulse/Program.cs +++ b/src/BizTalkCheckmkPulse/Program.cs @@ -102,6 +102,12 @@ namespace BizTalkCheckmkPulse + result.EndpointConnectivity.Active + " endpoints_failed=" + result.EndpointConnectivity.Results.Count(x => !x.Available) + + " endpoints_unresolved=" + + result.EndpointConnectivity.UnresolvedActive + + " endpoints_expected_non_socket=" + + result.EndpointConnectivity.ExpectedNonProbeableActive + + " endpoints_manual_overrides=" + + result.EndpointConnectivity.ManualOverridesActive + " endpoints_unique_targets=" + result.EndpointConnectivity.UniqueTargets + " endpoint_probe_ms=" diff --git a/tests/BizTalkCheckmkPulse.Tests/Program.cs b/tests/BizTalkCheckmkPulse.Tests/Program.cs index f32eb91..ac0b23d 100644 --- a/tests/BizTalkCheckmkPulse.Tests/Program.cs +++ b/tests/BizTalkCheckmkPulse.Tests/Program.cs @@ -29,9 +29,13 @@ namespace BizTalkCheckmkPulse.Tests Run("SendPortAllowlistSeparatesExpectedState", SendPortAllowlistSeparatesExpectedState); Run("ArtifactSummaryIsBounded", ArtifactSummaryIsBounded); Run("EndpointAddressesResolveWithoutSecrets", EndpointAddressesResolveWithoutSecrets); + Run("AdapterSpecificEndpointAddressesResolve", AdapterSpecificEndpointAddressesResolve); + Run("EndpointClassificationSeparatesExpectedAndUnresolved", EndpointClassificationSeparatesExpectedAndUnresolved); Run("EndpointCatalogPreservesManualOverrides", EndpointCatalogPreservesManualOverrides); + Run("EndpointRuntimeUsesCurrentAddressAndManualOverrides", EndpointRuntimeUsesCurrentAddressAndManualOverrides); Run("EndpointCatalogRoundTrip", EndpointCatalogRoundTrip); Run("EndpointOutputListsOnlyUnavailableTargets", EndpointOutputListsOnlyUnavailableTargets); + Run("EndpointOutputExplainsOnlyRealResolutionGaps", EndpointOutputExplainsOnlyRealResolutionGaps); Run("EndpointProbeBudgetFitsMinuteInterval", EndpointProbeBudgetFitsMinuteInterval); Run("InstallerUpdatePreservesExistingSettings", InstallerUpdatePreservesExistingSettings); Run("SnapshotRoundTripPreservesLines", SnapshotRoundTripPreservesLines); @@ -368,9 +372,94 @@ namespace BizTalkCheckmkPulse.Tests candidate.Address = "/Orders/Receive.svc"; candidate.AdapterName = "WCF-CustomIsolated"; + candidate.ArtifactType = "ReceiveLocation"; + candidate.TransportRole = "Inbound"; Assert(!EndpointAddressParser.IsPotentialExternalEndpoint(candidate), "relative local receive address must not cause UNKNOWN"); } + private static void AdapterSpecificEndpointAddressesResolve() + { + EndpointCatalogEntry endpoint; + string reason; + var candidate = new EndpointCandidate + { + ArtifactType = "SendPort", + ApplicationName = "Orders", + ArtifactName = "SP SQL", + TransportRole = "Primary", + AdapterName = "WCF-Custom", + Address = "mssql://sql01/INSTANCE/Orders", + Active = true + }; + + Assert(EndpointAddressParser.TryCreate(candidate, out endpoint, out reason), "WCF-SQL URI should resolve: " + reason); + AssertEqual("sql01", endpoint.Host, "WCF-SQL host"); + AssertEqual(1433, endpoint.Port, "WCF-SQL default port"); + + candidate.Address = "mssql://sql01:15433/INSTANCE/Orders"; + Assert(EndpointAddressParser.TryCreate(candidate, out endpoint, out reason), "WCF-SQL explicit port should resolve: " + reason); + AssertEqual(15433, endpoint.Port, "WCF-SQL explicit port"); + + candidate.Address = "net.tcp://service01/Orders"; + candidate.AdapterName = "WCF-NetTcp"; + Assert(EndpointAddressParser.TryCreate(candidate, out endpoint, out reason), "net.tcp URI should resolve: " + reason); + AssertEqual(808, endpoint.Port, "net.tcp default port"); + + candidate.Address = "oracledb://oracle01/ORDERS/Dedicated"; + candidate.AdapterName = "WCF-OracleDB"; + Assert(EndpointAddressParser.TryCreate(candidate, out endpoint, out reason), "direct Oracle DB URI should resolve: " + reason); + AssertEqual("oracle01", endpoint.Host, "Oracle DB host"); + AssertEqual(1521, endpoint.Port, "Oracle DB default port"); + + candidate.Address = "oracledb://ORDERS_TNS"; + Assert(!EndpointAddressParser.TryCreate(candidate, out endpoint, out reason), "tnsnames alias must not be guessed as DNS host"); + + candidate.ArtifactType = "ReceiveLocation"; + candidate.TransportRole = "Inbound"; + candidate.Address = "http://+:8080/Orders"; + candidate.AdapterName = "WCF-WebHttp"; + Assert(EndpointAddressParser.TryCreate(candidate, out endpoint, out reason), "wildcard listener should resolve: " + reason); + AssertEqual("127.0.0.1", endpoint.Host, "wildcard listener loopback host"); + AssertEqual(8080, endpoint.Port, "wildcard listener port"); + } + + private static void EndpointClassificationSeparatesExpectedAndUnresolved() + { + var candidate = new EndpointCandidate + { + ArtifactType = "ReceiveLocation", + ApplicationName = "Orders", + ArtifactName = "RL HTTP", + TransportRole = "Inbound", + AdapterName = "WCF-CustomIsolated", + Address = "/Orders/Receive.svc", + Active = true + }; + AssertEqual( + EndpointResolutionStatus.ExpectedNonProbeable, + EndpointAddressParser.Analyze(candidate).Status, + "relative listener classification"); + + candidate.ArtifactType = "SendPort"; + candidate.TransportRole = "Primary"; + AssertEqual( + EndpointResolutionStatus.Unresolved, + EndpointAddressParser.Analyze(candidate).Status, + "relative outbound address classification"); + + candidate.Address = "http://+:8080/Orders"; + AssertEqual( + EndpointResolutionStatus.Unresolved, + EndpointAddressParser.Analyze(candidate).Status, + "outbound wildcard must not become a loopback target"); + + candidate.Dynamic = true; + AssertEqual( + EndpointResolutionStatus.ExpectedNonProbeable, + EndpointAddressParser.Analyze(candidate).Status, + "dynamic send port classification"); + } + private static void EndpointCatalogPreservesManualOverrides() { var candidate = new EndpointCandidate @@ -417,6 +506,42 @@ namespace BizTalkCheckmkPulse.Tests Assert(synchronized.Entries.All(x => x.ArtifactName != "RL Disabled"), "inactive receive location must not be added"); } + private static void EndpointRuntimeUsesCurrentAddressAndManualOverrides() + { + var candidate = new EndpointCandidate + { + ArtifactType = "SendPort", + ApplicationName = "Orders", + ArtifactName = "SP Orders", + TransportRole = "Primary", + AdapterName = "WCF-Custom", + Address = "https://new.example.test/orders", + Active = true + }; + var catalog = new EndpointCatalog(); + var stale = TestEndpoint("SP Orders", "old.example.test"); + stale.Key = candidate.Key; + catalog.Entries.Add(stale); + var state = new EndpointConnectivityState(); + var selected = EndpointConnectivityProbe.ResolveActiveEndpoints(catalog, new[] { candidate }, state); + AssertEqual(1, selected.Length, "current endpoint selection count"); + AssertEqual("new.example.test", selected[0].Host, "current WMI address must replace stale catalog target"); + + candidate.Address = "adapter-specific-target-without-port"; + var manual = TestEndpoint("SP Orders", "manual.example.test"); + manual.Key = candidate.Key; + manual.AutoDiscovered = false; + manual.Port = 7443; + catalog.Entries.Clear(); + catalog.Entries.Add(manual); + state = new EndpointConnectivityState(); + selected = EndpointConnectivityProbe.ResolveActiveEndpoints(catalog, new[] { candidate }, state); + AssertEqual(1, selected.Length, "manual endpoint selection count"); + AssertEqual("manual.example.test", selected[0].Host, "manual override host"); + AssertEqual(0, state.UnresolvedActive, "manual override must close resolution gap"); + AssertEqual(1, state.ManualOverridesActive, "manual override metric"); + } + private static void EndpointOutputListsOnlyUnavailableTargets() { var result = new ProbeResult(); @@ -445,6 +570,27 @@ namespace BizTalkCheckmkPulse.Tests Assert(line.IndexOf("up.example.test", StringComparison.Ordinal) < 0, "reachable host must not clutter output"); } + private static void EndpointOutputExplainsOnlyRealResolutionGaps() + { + var result = new ProbeResult(); + result.EndpointConnectivity.RuntimeStateAvailable = true; + result.EndpointConnectivity.CatalogAvailable = true; + result.EndpointConnectivity.RefreshSucceeded = true; + result.EndpointConnectivity.ExpectedNonProbeableActive = 4; + var line = FindServiceLine(result, "Endpoint Reachability"); + Assert(line.StartsWith("0 \"BizTalk Endpoint Reachability\"", StringComparison.Ordinal), "expected non-socket endpoints must remain OK"); + Assert(line.Contains("biztalk_endpoints_expected_non_socket=4"), "expected non-socket metric missing"); + Assert(line.IndexOf("unsupported", StringComparison.OrdinalIgnoreCase) < 0, "legacy unsupported wording must not be emitted"); + + result.EndpointConnectivity.UnresolvedActive = 1; + result.EndpointConnectivity.ResolutionIssues.Add( + "endpoint=SendPort:Orders\\SP Custom[Primary] adapter=WCF-Custom reason=Schema ohne Port"); + line = FindServiceLine(result, "Endpoint Reachability"); + Assert(line.StartsWith("3 \"BizTalk Endpoint Reachability\"", StringComparison.Ordinal), "real resolution gap must be UNKNOWN"); + Assert(line.Contains("unresolved_endpoints="), "resolution issue list missing"); + Assert(line.Contains("SP Custom"), "resolution issue artifact missing"); + } + private static void EndpointCatalogRoundTrip() { var directory = Path.Combine(Path.GetTempPath(), "BizTalkCheckmkPulse.CatalogTests." + Guid.NewGuid().ToString("N")); @@ -457,7 +603,7 @@ namespace BizTalkCheckmkPulse.Tests EnvironmentName = "ACC", SynchronizedUtc = DateTime.UtcNow, ActiveCandidates = 150, - UnsupportedCandidates = 0 + UnresolvedCandidates = 7 }; catalog.Entries.Add(TestEndpoint("Orders", "api.example.test")); var store = new EndpointCatalogStore(path, 1048576, 100); @@ -467,6 +613,7 @@ namespace BizTalkCheckmkPulse.Tests 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"); + AssertEqual(7, loaded.UnresolvedCandidates, "catalog unresolved candidate count"); Assert(File.ReadAllText(path).IndexOf("top-secret", StringComparison.OrdinalIgnoreCase) < 0, "catalog must not contain URI secrets"); } finally @@ -526,6 +673,7 @@ namespace BizTalkCheckmkPulse.Tests + "" + "" + "" + + "" + "" + "", new UTF8Encoding(false)); @@ -548,6 +696,7 @@ namespace BizTalkCheckmkPulse.Tests AssertEqual("ACC", effectiveEnvironment, "preserved installer environment"); Assert(merged.Contains("key=\"EndpointProbeMaxConcurrency\" value=\"7\""), "existing operational value must be preserved"); Assert(merged.Contains("key=\"EndpointMaxCount\" value=\"100\""), "superseded old default must migrate to new bounded default"); + Assert(merged.Contains("key=\"EndpointCatalogMaxEntries\" value=\"1000\""), "new catalog entry limit must be added"); Assert(merged.Contains("key=\"NewSetting\" value=\"new-default\""), "new source setting must be added"); Assert(merged.IndexOf("RemovedLegacySetting", StringComparison.Ordinal) < 0, "removed legacy key must not be resurrected"); @@ -713,6 +862,14 @@ namespace BizTalkCheckmkPulse.Tests } } + private static void AssertEqual(EndpointResolutionStatus expected, EndpointResolutionStatus actual, string label) + { + if (expected != actual) + { + throw new InvalidOperationException(label + ": expected " + expected + ", actual " + actual); + } + } + private static void AssertEqual(string expected, string actual, string label) { if (!string.Equals(expected, actual, StringComparison.Ordinal))