using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Core;
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 Watchdog des Deploymentcenters
/// (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.
///
/// **Umstellung 14.08.2026:** Ersetzt den eigenständigen Watchdog-Dienst (watchdog.mhdf.de) und
/// LicenseLabrador durch das Deploymentcenter (siehe
/// docs/archiv/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, Schnitt D-1). Endpunkte
/// sind jetzt /api/watchdog/v1/ping und /api/watchdog/v1/event, Auth-Header
/// Authorization: Bearer statt X-Watchdog-Key.
/// und zeigen ab jetzt auf das Deploymentcenter statt
/// auf den alten Einzeldienst – UI, Maskierung und Live-Reload bleiben unverändert, es ist
/// dieselbe erprobte Mechanik (21+ Tests), nur mit neuem Gegenüber.
///
/// **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 Deploymentcenter-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;
///
/// D-1: Selbst ermittelte Gesundheitsprüfungen (z.B. VPN verbunden, DB erreichbar), die der
/// Deploymentcenter-Watchdog unter "checks" auswertet – ein als "ok" gemeldeter Heartbeat mit
/// fehlschlagender Prüfung wird dort automatisch auf "warning" herabgestuft. Der Service
/// interpretiert die Namen nicht selbst, er sammelt nur ein. Composition Root (Program.cs)
/// registriert die konkreten Prüfungen.
///
private readonly Dictionary> _checks = new();
private readonly object _checksLock = new();
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();
///
/// D-1: Registriert eine benannte Gesundheitsprüfung, die bei jedem Heartbeat ausgewertet
/// wird. Eine zweite Registrierung unter demselben Namen ersetzt die vorherige. Der Aufruf
/// selbst darf nicht werfen – ein Fehler in einer Prüfung führt sonst nur dazu, dass diese
/// eine Prüfung als "nicht ok" gemeldet wird, nie dazu, dass der Heartbeat ausfällt.
///
public void RegisterCheck(string name, Func<(bool ok, string? message)> check)
{
lock (_checksLock) { _checks[name] = check; }
}
/// Entfernt eine zuvor registrierte Prüfung (z.B. wenn ein Dienst abgeschaltet wird).
public void UnregisterCheck(string name)
{
lock (_checksLock) { _checks.Remove(name); }
}
/// Wertet alle registrierten Prüfungen aus. Liefert null, wenn keine registriert sind.
private Dictionary? EvaluateChecks()
{
Dictionary> snapshot;
lock (_checksLock)
{
if (_checks.Count == 0) return null;
snapshot = new Dictionary>(_checks);
}
var result = new Dictionary();
foreach (var (name, check) in snapshot)
{
try
{
var (ok, message) = check();
result[name] = message == null
? new { ok }
: new { ok, message = Sanitize(message) };
}
catch (Exception ex)
{
// Eine werfende Prüfung darf den Heartbeat nicht zum Absturz bringen – sie
// meldet sich selbst als fehlgeschlagen.
result[name] = new { ok = false, message = $"Prüfung fehlgeschlagen: {ex.Message}" };
}
}
return result;
}
///
/// 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, PingPath, 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{BuildInfo.Version})", 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/D-1: Meldet dem Watchdog beim Herunterfahren ein geordnetes Ende. Zwei Wege, weil sie
/// unterschiedliche Dinge tun: der Heartbeat mit status:"stopped" ist das, was der
/// Evaluator auswertet (er lässt einen "stopped"-Monitor in Ruhe, statt ihn nach dem
/// Ausbleiben weiterer Heartbeats als "down" zu alarmieren); das Event "stopped_graceful"
/// landet zusätzlich im Ereignisprotokoll. Best effort mit kurzem Timeout – blockiert das
/// Beenden nicht nennenswert.
///
public override async Task StopAsync(CancellationToken cancellationToken)
{
if (_startedEventSent && _settings.WatchdogEnabled)
{
try
{
// 5 s statt 3 s: zwei Aufrufe teilen sich das Budget, keiner soll dadurch
// praktisch ohne Zeit dastehen. host.StopAsync erlaubt insgesamt bis zu 30 s.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await SendHeartbeatAsync("stopped", "PolyTrader wird geordnet beendet.", cts.Token)
.ConfigureAwait(false);
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, PingPath, 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",
os = RuntimeInformation.OSDescription,
// D-1: aus BuildInfo statt aus der Assembly-Version - stimmt jetzt mit
// Directory.Build.props überein statt stets 1.0.0.0 zu tragen.
version = BuildInfo.Version,
checks = EvaluateChecks()
};
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri)
{
Content = JsonContent.Create(payload)
};
// D-1: Authorization: Bearer statt X-Watchdog-Key - das Deploymentcenter kennt den
// alten Header nicht.
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", 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/watchdog/v1/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, EventPath, 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.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", 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)
{
// D-3: Musterprüfung selbst liegt jetzt in SecretRedactor - gemeinsam mit dem
// Fehler-Reporter genutzt, der dieselben Muster aber ohne Zeilen-/Längenlimit braucht
// (ein Stacktrace ist von Natur aus mehrzeilig und soll lesbar bleiben).
string s = SecretRedactor.RemoveSecrets(message);
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);
/// D-1: Pfad des Heartbeat-Endpunkts im Deploymentcenter (vorher "/api/heartbeat").
internal const string PingPath = "watchdog/v1/ping";
/// D-1: Pfad des Event-Endpunkts im Deploymentcenter (vorher "/api/event").
internal const string EventPath = "watchdog/v1/event";
///
/// 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 relativePath, 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/{relativePath}");
return true;
}
}
}