using System; using System.Net.Http; using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using PolyTrader.Core.Security; using PolyTraderSharp.Models; namespace PolyTraderSharp.Services { /// Ergebnis eines Sendeversuchs – für die UI-Rückmeldung des Test-Buttons. public sealed record WatchdogSendResult(bool Success, string Detail); /// /// Sendet im festen Takt einen Heartbeat an den externen Watchdog-Server (Dead-Man's-Switch). /// Bleiben die Heartbeats aus, alarmiert der Watchdog – er läuft bewusst auf einer anderen /// Maschine und bemerkt damit auch einen harten Server-Crash. /// /// **Grundregel:** Ein Ausfall des Watchdogs darf PolyTrader NIEMALS beeinträchtigen. Deshalb /// ist jeder Sendeversuch vollständig gekapselt; aus darf keine /// Ausnahme entweichen (ein werfender BackgroundService würde den Host beenden). /// /// Bewusst eine eigene Implementierung statt des mitgelieferten Watchdog-Testclients: dieser /// deaktiviert die TLS-Zertifikatsprüfung pauschal. Hier gilt die Standardprüfung, und /// http:// ist nur für localhost erlaubt, damit der Agent-Token nicht im Klartext /// über das Netz geht. /// public class WatchdogHeartbeatService : BackgroundService { /// Untergrenze des Sende-Takts – schützt den Watchdog vor einem Client-Flood. private const int MinIntervalSeconds = 10; /// /// Takt, in dem die Konfiguration neu ausgewertet wird. Klein genug, dass Änderungen aus /// dem Settings-Fenster praktisch sofort greifen, und ohne messbare Last. /// private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1); /// /// Nachwirkzeit eines Fehlers: Nach einem meldet der Heartbeat /// so lange status=error, bis dieses Fenster ohne neuen Fehler verstrichen ist. Das /// entprellt einzelne Fehler (kein Flackern error→ok→error) und meldet die Erholung genau /// einmal, sobald wieder Ruhe ist. /// private static readonly TimeSpan ErrorStickyWindow = TimeSpan.FromMinutes(5); private const string DefaultSettingsPath = "server_settings.xml"; private readonly TerminalLogger _logger; private readonly HttpClient _http; /// Quelle der Einstellungen – in Tests ersetzbar, im Betrieb die gitignorierte XML-Datei. private readonly Func _settingsLoader; private ServerSettings _settings; /// Zustand der letzten Übertragung – für entprosseltes Logging (kein Log-Spam im Minutentakt). private bool _lastSendFailed; private int _consecutiveFailures; // ===== B.2: Fehlerstatus aus dem Log ===== private readonly object _statusLock = new(); private DateTime _lastAppErrorUtc = DateTime.MinValue; private string? _lastAppErrorMessage; private bool _startedEventSent; public WatchdogHeartbeatService( TerminalLogger logger, HttpClient? httpClient = null, Func? settingsLoader = null) { _logger = logger; _settingsLoader = settingsLoader ?? (() => ServerSettings.Load(DefaultSettingsPath)); _settings = _settingsLoader(); _http = httpClient ?? new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }) { Timeout = TimeSpan.FromSeconds(10) }; // B.2: schwere Fehler aus dem App-Log mitschneiden, um sie im Heartbeat zu spiegeln. _logger.OnLogMessage += OnAppLog; } /// /// Schneidet -Meldungen mit, damit der nächste Heartbeat /// status=error melden kann. Läuft synchron auf dem loggenden Thread – daher bewusst /// minimal (nur Zeitstempel + gekürzte, entschärfte Meldung merken). /// private void OnAppLog(object? sender, LogMessageEventArgs e) { if (e.Level != LogLevel.Error) return; lock (_statusLock) { _lastAppErrorUtc = DateTime.UtcNow; _lastAppErrorMessage = Sanitize(e.Message); } } /// Lädt die Einstellungen neu (nach dem Speichern im Settings-Fenster). public void ReloadSettings() => _settings = _settingsLoader(); /// /// Prüft die aktuelle Konfiguration. null = sendebereit, sonst ein kurzer, /// anzeigbarer Grund. Wird auch vom Test-Button der Settings-View genutzt. /// public string? GetConfigurationProblem() { if (string.IsNullOrWhiteSpace(_settings.WatchdogUrl)) return "Keine Watchdog-URL konfiguriert."; if (string.IsNullOrWhiteSpace(_settings.WatchdogToken)) return "Kein Agent-Token konfiguriert."; if (string.IsNullOrWhiteSpace(_settings.WatchdogSource)) return "Keine Source konfiguriert."; if (!TryBuildEndpoint(_settings.WatchdogUrl, "heartbeat", out _, out string urlProblem)) return urlProblem; return null; } /// /// Haupt-Schleife. Bewusst kein mit fest verdrahtetem Takt: /// die Konfiguration wird bei jedem Durchlauf neu ausgewertet, damit Ein-/Ausschalten, /// Takt-, URL- und Token-Änderungen aus dem Settings-Fenster **ohne Neustart** greifen. /// Der Sendezeitpunkt wird über eine Fälligkeit gesteuert, der Takt bleibt also exakt. /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { DateTime nextDueUtc = DateTime.MinValue; // sofort fällig bool wasActive = false; string? loggedProblem = null; try { while (!stoppingToken.IsCancellationRequested) { if (!_settings.WatchdogEnabled) { if (wasActive) { _logger.Info("Watchdog-Heartbeat deaktiviert – es werden keine Heartbeats mehr gesendet."); wasActive = false; } loggedProblem = null; await Task.Delay(PollInterval, stoppingToken).ConfigureAwait(false); continue; } string? problem = GetConfigurationProblem(); if (problem != null) { // Fehlkonfiguration ist ein Betriebsfehler, kein Grund die App zu behindern. // Jede Ursache wird genau einmal gemeldet, nicht bei jedem Durchlauf. if (loggedProblem != problem) { _logger.Warning($"⚠️ Watchdog-Heartbeat aktiviert, aber nicht sendebereit: {problem} " + "Es werden KEINE Heartbeats gesendet – der Watchdog wird PolyTrader als 'down' melden."); loggedProblem = problem; } wasActive = false; await Task.Delay(PollInterval, stoppingToken).ConfigureAwait(false); continue; } if (!wasActive) { _logger.Info($"Watchdog-Heartbeat aktiv: sendet alle {EffectiveIntervalSeconds()}s an " + $"{_settings.WatchdogUrl} (source={_settings.WatchdogSource}, instance={_settings.WatchdogInstance})."); wasActive = true; loggedProblem = null; nextDueUtc = DateTime.MinValue; // nach dem Aktivieren sofort senden // B.3: Lifecycle – „started" genau einmal senden (der Watchdog protokolliert // den Programmstart; danach übernimmt die Staleness-Erkennung). if (!_startedEventSent) { await SendEventAsync("started", "info", $"PolyTrader gestartet (v{AppVersion})", stoppingToken) .ConfigureAwait(false); _startedEventSent = true; } } if (DateTime.UtcNow >= nextDueUtc) { // B.2: aktuellen Gesundheitsstatus aus dem Log ableiten (ok/error). (string status, string? message) = DeriveStatus(); await SendAndTrackAsync(status, message, stoppingToken).ConfigureAwait(false); nextDueUtc = DateTime.UtcNow.AddSeconds(EffectiveIntervalSeconds()); } await Task.Delay(PollInterval, stoppingToken).ConfigureAwait(false); } } catch (OperationCanceledException) { // Normales Herunterfahren. } catch (Exception ex) { // Letztes Sicherheitsnetz: der Heartbeat darf den Host nicht mitnehmen. _logger.Error($"❌ Watchdog-Heartbeat unerwartet beendet: {ex.Message}. " + "PolyTrader läuft weiter, wird vom Watchdog aber als 'down' gemeldet."); } } /// /// B.3: Meldet dem Watchdog beim Herunterfahren ein geordnetes Ende („stopped_graceful"), /// damit dieser NICHT fälschlich einen Absturz alarmiert. Best effort mit kurzem Timeout – /// blockiert das Beenden nicht nennenswert. /// public override async Task StopAsync(CancellationToken cancellationToken) { if (_startedEventSent && _settings.WatchdogEnabled) { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); await SendEventAsync("stopped_graceful", "info", "PolyTrader wird geordnet beendet.", cts.Token) .ConfigureAwait(false); } catch { /* Herunterfahren nie blockieren */ } } await base.StopAsync(cancellationToken).ConfigureAwait(false); } public override void Dispose() { _logger.OnLogMessage -= OnAppLog; base.Dispose(); } /// /// Sendet einen Heartbeat. Kapselt jeden Fehler und liefert ihn als Ergebnis zurück – /// wirft nie. Öffentlich, damit der Test-Button und (später) die Statusmeldung darauf aufsetzen. /// public async Task SendHeartbeatAsync( string status = "ok", string? message = null, CancellationToken ct = default) { string? problem = GetConfigurationProblem(); if (problem != null) return new WatchdogSendResult(false, problem); if (!TryBuildEndpoint(_settings.WatchdogUrl, "heartbeat", out Uri? uri, out string urlProblem)) return new WatchdogSendResult(false, urlProblem); string token; try { // Erlaubt einen mit dem Master-Key verschlüsselten Token (enc:v1:…); // Klartext wird unverändert durchgereicht. token = SecretProtection.Unprotect(_settings.WatchdogToken); } catch (Exception ex) { return new WatchdogSendResult(false, $"Agent-Token nicht lesbar: {ex.Message}"); } var payload = new { source = _settings.WatchdogSource, instance = string.IsNullOrWhiteSpace(_settings.WatchdogInstance) ? "default" : _settings.WatchdogInstance, type = "heartbeat", status, message, // Feldname bewusst "interval": die Watchdog-API liest ausschließlich // "interval"/"expected_interval_sec" und ignoriert "intervalSeconds" still. interval = EffectiveIntervalSeconds(), group = "Applications", metadata = new { version = AppVersion } }; try { using var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = JsonContent.Create(payload) }; request.Headers.Add("X-Watchdog-Key", token); using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); if (response.IsSuccessStatusCode) return new WatchdogSendResult(true, $"HTTP {(int)response.StatusCode}"); return new WatchdogSendResult(false, $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; // geordnetes Herunterfahren nicht als Fehler behandeln } catch (Exception ex) { return new WatchdogSendResult(false, ex.Message); } } /// Sendet und protokolliert nur Zustandswechsel – kein Log-Eintrag pro Tick. private async Task SendAndTrackAsync(string status, string? message, CancellationToken ct) { WatchdogSendResult result; try { result = await SendHeartbeatAsync(status, message, ct).ConfigureAwait(false); } catch (OperationCanceledException) { return; } if (result.Success) { if (_lastSendFailed) { _logger.Info($"✅ Watchdog wieder erreichbar (nach {_consecutiveFailures} fehlgeschlagenen Versuchen)."); } _lastSendFailed = false; _consecutiveFailures = 0; return; } _consecutiveFailures++; if (!_lastSendFailed) { // Nur der erste Fehler wird gemeldet; danach Ruhe bis zur Erholung. _logger.Warning($"⚠️ Watchdog-Heartbeat fehlgeschlagen: {result.Detail}. " + "PolyTrader arbeitet normal weiter; weitere Fehlversuche werden nicht einzeln geloggt."); } _lastSendFailed = true; } /// /// B.3: Sendet ein Lifecycle-Event an /api/event (z. B. „started", „stopped_graceful"). /// Best effort – wirft nie und blockiert das Herunterfahren nicht. /// public async Task SendEventAsync(string kind, string severity, string? message, CancellationToken ct = default) { string? problem = GetConfigurationProblem(); if (problem != null) return new WatchdogSendResult(false, problem); if (!TryBuildEndpoint(_settings.WatchdogUrl, "event", out Uri? uri, out string urlProblem)) return new WatchdogSendResult(false, urlProblem); string token; try { token = SecretProtection.Unprotect(_settings.WatchdogToken); } catch (Exception ex) { return new WatchdogSendResult(false, $"Agent-Token nicht lesbar: {ex.Message}"); } var payload = new { source = _settings.WatchdogSource, instance = string.IsNullOrWhiteSpace(_settings.WatchdogInstance) ? "default" : _settings.WatchdogInstance, kind, severity, message }; try { using var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = JsonContent.Create(payload) }; request.Headers.Add("X-Watchdog-Key", token); using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); return response.IsSuccessStatusCode ? new WatchdogSendResult(true, $"HTTP {(int)response.StatusCode}") : new WatchdogSendResult(false, $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { return new WatchdogSendResult(false, ex.Message); } } /// /// B.2: Leitet den zu meldenden Gesundheitsstatus aus dem App-Log ab. Ein /// hält den Status für auf error (mit der letzten, entschärften /// Fehlermeldung); danach wieder ok. Bewusst keine Warnungs-Eskalation: App-Warnungen sind zu /// häufig, um daraus einen Monitor-Alarm zu rechtfertigen. /// internal (string status, string? message) DeriveStatus() { lock (_statusLock) { if (_lastAppErrorMessage != null && (DateTime.UtcNow - _lastAppErrorUtc) <= ErrorStickyWindow) return ("error", _lastAppErrorMessage); } return ("ok", null); } /// /// Entschärft eine Log-Meldung vor dem Versand an den (extern gehosteten) Watchdog: entfernt /// Wallet-Adressen/Keys (0x-Hex) und verschlüsselte Secrets (enc:v1:…) und kürzt auf 200 Zeichen. /// Der Watchdog soll nur „etwas ist kaputt" erfahren, keine sensiblen Nutzdaten. /// internal static string Sanitize(string? message) { if (string.IsNullOrEmpty(message)) return string.Empty; string s = System.Text.RegularExpressions.Regex.Replace(message, "0x[0-9a-fA-F]{8,}", "0x…"); s = System.Text.RegularExpressions.Regex.Replace(s, @"enc:v1:[A-Za-z0-9+/=]+", "enc:v1:…"); s = s.Replace("\r", " ").Replace("\n", " ").Trim(); return s.Length > 200 ? s.Substring(0, 197) + "…" : s; } /// Sende-Takt, nach unten begrenzt. private int EffectiveIntervalSeconds() => Math.Max(MinIntervalSeconds, _settings.WatchdogIntervalSeconds); /// /// Baut die Ziel-URL und erzwingt TLS. http:// ist ausschließlich für localhost /// erlaubt (lokales Testen); sonst würde der Agent-Token im Klartext übertragen. /// internal static bool TryBuildEndpoint(string baseUrl, string action, out Uri? uri, out string problem) { uri = null; problem = string.Empty; if (!Uri.TryCreate(baseUrl?.Trim().TrimEnd('/'), UriKind.Absolute, out Uri? parsed)) { problem = $"Watchdog-URL ist keine gültige absolute URL: '{baseUrl}'."; return false; } bool isHttps = parsed.Scheme == Uri.UriSchemeHttps; bool isLocalHttp = parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback; if (!isHttps && !isLocalHttp) { problem = $"Watchdog-URL muss HTTPS verwenden (http:// nur für localhost): '{baseUrl}'. " + "Über http:// ginge der Agent-Token im Klartext über das Netz."; return false; } uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/{action}"); return true; } /// Version der laufenden Assembly – landet als Metadatum im Heartbeat. private static string AppVersion => System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown"; } }