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; /// /// Initialisiert die Endpoint-Pruefung mit Laufzeitgrenzen und Dateiprotokoll. /// /// Validierte Monitoring-Konfiguration. /// Gemeinsames Provider-Dateiprotokoll. public EndpointConnectivityProbe(MonitoringOptions options, FileLogger logger) { _options = options; _logger = logger; } /// /// Synchronisiert den Katalog und ergaenzt das Gesamtergebnis um Socket-Probes. /// /// Bereits mit aktuellen BizTalk-Artefakten gefuelltes Ergebnis. 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.EndpointCatalogMaxEntries); 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 = _options.ForceEndpointCatalogRefresh || 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 + " unresolved_candidates=" + catalog.UnresolvedCandidates); } 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; 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; } state.Active = 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 { var probeStopwatch = Stopwatch.StartNew(); var probeResults = ProbeAllAsync(activeEntries).GetAwaiter().GetResult(); probeStopwatch.Stop(); state.ProbeDurationMilliseconds = probeStopwatch.ElapsedMilliseconds; state.UniqueTargets = activeEntries .Select(TargetKey) .Distinct(StringComparer.OrdinalIgnoreCase) .Count(); foreach (var probeResult in probeResults) { state.Results.Add(probeResult); if (!probeResult.Available && probeResult.Endpoint.BestEffort) { // DATABASE-/WCF-SAP-Ziele sind bewusst Diagnose-Probes: Ein // Fehlschlag darf weder Tageslog noch Checkmk mit Alarmen fluten. state.BestEffortIgnoredFailures++; } else 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); } } /// /// Fuehrt manuelle Overrides mit den aktuell automatisch erkannten Zielen zusammen. /// /// Vorhandener Katalog oder . /// Aktuelle BizTalk-Send-/Receive-Transporte. /// UTC-Zeitpunkt des vollstaendigen Abgleichs. /// Neuer, geheimnisfreier Endpoint-Katalog. internal static EndpointCatalog SynchronizeCatalog( EndpointCatalog existing, IEnumerable candidates, DateTime synchronizedUtc) { var active = (candidates ?? Enumerable.Empty()) .Where(x => x != null && x.Active) .ToArray(); 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) .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, // Attributsname bleibt fuer die Rueckwaertskompatibilitaet des Katalogformats bestehen. UnresolvedCandidates = unresolved }; 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.EndpointCatalogMaxEntries) { throw new InvalidDataException("Endpoint catalog exceeds EndpointCatalogMaxEntries."); } return catalog; } /// /// 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. /// /// Gueltiger lokaler Endpoint-Katalog. /// Aktuelle aktive und inaktive BizTalk-Transporte. /// Zu aktualisierende Laufzeitzaehler und Aufloesungsdiagnosen. /// Aktuell aktive, deduplizierte Katalogeintraege. internal static EndpointCatalogEntry[] ResolveActiveEndpoints( EndpointCatalog catalog, IEnumerable candidates, EndpointConnectivityState state) { if (catalog == null) { 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)) { 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)); } } state.SkippedInactive = catalog.Entries.Count(x => x.Enabled && !string.Equals(x.ArtifactType, "Manual", StringComparison.OrdinalIgnoreCase) && !activeKeys.Contains(x.Key)); var resolved = selected .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .Select(x => x.First()) .ToArray(); state.BestEffortActive = resolved.Count(x => x.BestEffort); return resolved; } /// /// 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) { var unique = endpoints .GroupBy(TargetKey, StringComparer.OrdinalIgnoreCase) .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 = TargetKey(endpoint); var outcome = outcomes[key]; return new EndpointProbeResult { Endpoint = endpoint, Available = outcome.Available, DurationMilliseconds = outcome.DurationMilliseconds, Failure = outcome.Failure }; }).ToArray(); } /// /// Berechnet die theoretische Obergrenze fuer vollstaendig timeoutende Zielgruppen. /// /// Anzahl eindeutiger Socket-Ziele. /// Maximale parallele Probes. /// Timeout je Probe in Millisekunden. /// Theoretische Worst-Case-Dauer in Millisekunden. internal static long CalculateWorstCaseProbeMilliseconds(int targetCount, int concurrency, int timeoutMilliseconds) { if (targetCount <= 0) { return 0; } if (concurrency <= 0 || timeoutMilliseconds <= 0) { throw new ArgumentOutOfRangeException("concurrency"); } return ((targetCount + concurrency - 1L) / concurrency) * timeoutMilliseconds; } private static string TargetKey(EndpointCatalogEntry endpoint) { return endpoint.Protocol.ToUpperInvariant() + "|" + endpoint.Host.ToUpperInvariant() + "|" + endpoint.Port; } 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; } /// /// Formatiert ein geheimnisfreies Socket-Ziel fuer Checkmk und Dateilog. /// /// Zu formatierender Katalogeintrag. /// Artefakt, Host, Port und Protokoll ohne URI-Geheimnisse. 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 }; } } } }