Files
PolyTraderSharp/src/PolyTrader.Core/Services/WatchdogHeartbeatService.cs
T
RichardandClaude Opus 5 ca750a06f5 Watchdog-Heartbeat + LicenseLabrador-Lizenzpruefung eingebunden
Slices W-A/W-B/W-C und L-A aus UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.

Watchdog (Dead-Man's-Switch, externer Server):
- WatchdogHeartbeatService als BackgroundService + DI-Singleton; jeder Sendeversuch
  gekapselt, ein Ausfall des Watchdogs beeintraechtigt PolyTrader nie.
- Eigene Implementierung statt Test-Client des Fremdprojekts: TLS-Pruefung bleibt
  aktiv, http:// nur fuer localhost (Agent-Token nicht im Klartext ins Netz).
- Status aus dem App-Log abgeleitet (Error mit 5-Minuten-Sticky-Fenster, entprellt),
  Lifecycle-Events started/stopping.
- Konfiguration in ServerSettings; Agent-Token [Browsable(false)] mit maskierter
  Eingabe + Statusanzeige, bei gesetztem Master-Key verschluesselt (enc:v1:).

Lizenz (LicenseLabrador, Ed25519):
- LicenseGate.RunStartupGate prueft beim Start; bei ungueltiger Lizenz wird die
  Modulliste leer gebaut, sodass nur die Core-Shell (Terminal/Einstellungen)
  startet. Bewusst kein Environment.Exit - ein Trading-Bot darf nicht mitten im
  Lauf hart sterben. TamperSuspected gilt als nicht nutzbar.
- LicenseDialog (partial + .Designer.cs) fuer Start- und Verwalten-Modus, mit
  Hardware-ID zum Kopieren; Smoke-UI konstruiert beide Modi headless.
- Master-Key wird jetzt VOR dem Host-Build geladen, da auch der Lizenzschluessel
  entschluesselt werden muss; derselbe TerminalLogger wird als Singleton
  weitergereicht, damit die Startmeldungen im Terminal-Fenster erscheinen.

Der Lizenz-SDK-Client wird per Cross-Repo-Projektreferenz auf
..\..\LicenseLabrador eingebunden, damit SDK-Fixes ohne Kopie einfliessen.

484 Zeilen Tests fuer den Heartbeat; Suite gruen (438 Tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:04:14 +02:00

437 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>Ergebnis eines Sendeversuchs für die UI-Rückmeldung des Test-Buttons.</summary>
public sealed record WatchdogSendResult(bool Success, string Detail);
/// <summary>
/// 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 <see cref="ExecuteAsync"/> 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
/// <c>http://</c> ist nur für localhost erlaubt, damit der Agent-Token nicht im Klartext
/// über das Netz geht.
/// </summary>
public class WatchdogHeartbeatService : BackgroundService
{
/// <summary>Untergrenze des Sende-Takts schützt den Watchdog vor einem Client-Flood.</summary>
private const int MinIntervalSeconds = 10;
/// <summary>
/// Takt, in dem die Konfiguration neu ausgewertet wird. Klein genug, dass Änderungen aus
/// dem Settings-Fenster praktisch sofort greifen, und ohne messbare Last.
/// </summary>
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
/// <summary>
/// Nachwirkzeit eines Fehlers: Nach einem <see cref="LogLevel.Error"/> meldet der Heartbeat
/// so lange <c>status=error</c>, 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.
/// </summary>
private static readonly TimeSpan ErrorStickyWindow = TimeSpan.FromMinutes(5);
private const string DefaultSettingsPath = "server_settings.xml";
private readonly TerminalLogger _logger;
private readonly HttpClient _http;
/// <summary>Quelle der Einstellungen in Tests ersetzbar, im Betrieb die gitignorierte XML-Datei.</summary>
private readonly Func<ServerSettings> _settingsLoader;
private ServerSettings _settings;
/// <summary>Zustand der letzten Übertragung für entprosseltes Logging (kein Log-Spam im Minutentakt).</summary>
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<ServerSettings>? 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;
}
/// <summary>
/// Schneidet <see cref="LogLevel.Error"/>-Meldungen mit, damit der nächste Heartbeat
/// <c>status=error</c> melden kann. Läuft synchron auf dem loggenden Thread daher bewusst
/// minimal (nur Zeitstempel + gekürzte, entschärfte Meldung merken).
/// </summary>
private void OnAppLog(object? sender, LogMessageEventArgs e)
{
if (e.Level != LogLevel.Error) return;
lock (_statusLock)
{
_lastAppErrorUtc = DateTime.UtcNow;
_lastAppErrorMessage = Sanitize(e.Message);
}
}
/// <summary>Lädt die Einstellungen neu (nach dem Speichern im Settings-Fenster).</summary>
public void ReloadSettings() => _settings = _settingsLoader();
/// <summary>
/// Prüft die aktuelle Konfiguration. <c>null</c> = sendebereit, sonst ein kurzer,
/// anzeigbarer Grund. Wird auch vom Test-Button der Settings-View genutzt.
/// </summary>
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;
}
/// <summary>
/// Haupt-Schleife. Bewusst kein <see cref="PeriodicTimer"/> 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.
/// </summary>
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.");
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
public async Task<WatchdogSendResult> 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);
}
}
/// <summary>Sendet und protokolliert nur Zustandswechsel kein Log-Eintrag pro Tick.</summary>
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;
}
/// <summary>
/// B.3: Sendet ein Lifecycle-Event an <c>/api/event</c> (z. B. „started", „stopped_graceful").
/// Best effort wirft nie und blockiert das Herunterfahren nicht.
/// </summary>
public async Task<WatchdogSendResult> 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); }
}
/// <summary>
/// B.2: Leitet den zu meldenden Gesundheitsstatus aus dem App-Log ab. Ein <see cref="LogLevel.Error"/>
/// hält den Status für <see cref="ErrorStickyWindow"/> auf <c>error</c> (mit der letzten, entschärften
/// Fehlermeldung); danach wieder <c>ok</c>. Bewusst keine Warnungs-Eskalation: App-Warnungen sind zu
/// häufig, um daraus einen Monitor-Alarm zu rechtfertigen.
/// </summary>
internal (string status, string? message) DeriveStatus()
{
lock (_statusLock)
{
if (_lastAppErrorMessage != null && (DateTime.UtcNow - _lastAppErrorUtc) <= ErrorStickyWindow)
return ("error", _lastAppErrorMessage);
}
return ("ok", null);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Sende-Takt, nach unten begrenzt.</summary>
private int EffectiveIntervalSeconds() =>
Math.Max(MinIntervalSeconds, _settings.WatchdogIntervalSeconds);
/// <summary>
/// Baut die Ziel-URL und erzwingt TLS. <c>http://</c> ist ausschließlich für localhost
/// erlaubt (lokales Testen); sonst würde der Agent-Token im Klartext übertragen.
/// </summary>
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;
}
/// <summary>Version der laufenden Assembly landet als Metadatum im Heartbeat.</summary>
private static string AppVersion =>
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown";
}
}