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>
This commit is contained in:
Richard
2026-08-04 09:04:14 +02:00
co-authored by Claude Opus 5
parent bb103a578a
commit ca750a06f5
11 changed files with 2122 additions and 8 deletions
@@ -89,6 +89,81 @@ namespace PolyTraderSharp.Models
public bool UsePolymarketWebsockets { get; set; } = false;
[Category("Watchdog Monitoring")]
[DisplayName("Watchdog Enabled")]
[Description("Wenn true, sendet PolyTrader im festen Takt einen Heartbeat an den externen " +
"Watchdog-Server. Bleiben die Heartbeats aus, alarmiert der Watchdog (Dead-Man's-Switch). " +
"Ein nicht erreichbarer Watchdog beeinträchtigt PolyTrader NIE.")]
public bool WatchdogEnabled { get; set; } = false;
[Category("Watchdog Monitoring")]
[DisplayName("Watchdog URL")]
[Description("Basis-URL des Watchdog-Servers, z.B. https://watchdog.example.de. " +
"HTTPS ist Pflicht http:// wird nur für localhost akzeptiert, damit der Agent-Token " +
"nicht im Klartext über das Netz geht.")]
public string WatchdogUrl { get; set; } = "";
/// <summary>
/// Agent-Token für den Header <c>X-Watchdog-Key</c>. Bewusst NICHT im PropertyGrid sichtbar:
/// Setzen erfolgt über den Button „Watchdog-Token setzen …" mit maskierter Eingabe, damit das
/// Secret nicht offen im Fenster steht und bei gesetztem Master-Key verschlüsselt
/// (<c>enc:v1:…</c>) in der gitignorierten server_settings.xml landet.
/// </summary>
[Browsable(false)]
public string WatchdogToken { get; set; } = "";
[Category("Watchdog Monitoring")]
[DisplayName("Agent-Token")]
[Description("Status des Agent-Tokens. Setzen/Ändern über den Button „Watchdog-Token setzen …" +
"\" in der Werkzeugleiste (maskierte Eingabe).")]
[ReadOnly(true)]
[XmlIgnore]
public string WatchdogTokenStatus =>
string.IsNullOrWhiteSpace(WatchdogToken)
? "nicht gesetzt"
: (WatchdogToken.StartsWith("enc:v1:", System.StringComparison.Ordinal)
? "gesetzt (verschlüsselt)"
: "gesetzt (Klartext)");
[Category("Watchdog Monitoring")]
[DisplayName("Source")]
[Description("Dienst-Kennung im Watchdog (muss dem dort angelegten Monitor entsprechen), z.B. \"polytrader\".")]
public string WatchdogSource { get; set; } = "polytrader";
[Category("Watchdog Monitoring")]
[DisplayName("Instance")]
[Description("Instanz-Kennung, falls mehrere PolyTrader-Instanzen laufen, z.B. \"prod-1\".")]
public string WatchdogInstance { get; set; } = "prod-1";
[Category("Watchdog Monitoring")]
[DisplayName("Heartbeat-Intervall (Sekunden)")]
[Description("Sende-Takt des Heartbeats. Der Watchdog leitet daraus seine Staleness-Frist ab " +
"(Intervall × Faktor + Grace). Minimum 10 s, Empfehlung 60 s.")]
public int WatchdogIntervalSeconds { get; set; } = 60;
/// <summary>
/// Lizenzschlüssel (LicenseLabrador). Wie der Watchdog-Token bewusst NICHT im PropertyGrid
/// sichtbar; gepflegt über den Button „Lizenzschlüssel setzen …" bzw. den Startdialog. Bei
/// gesetztem Master-Key verschlüsselt (<c>enc:v1:…</c>) in der gitignorierten server_settings.xml.
/// </summary>
[Browsable(false)]
public string LicenseKey { get; set; } = "";
[Category("Lizenz")]
[DisplayName("Lizenzschlüssel")]
[Description("Status des Lizenzschlüssels. Setzen/Ändern über den Button „Lizenzschlüssel setzen …\" " +
"in der Werkzeugleiste bzw. beim Programmstart, falls keine gültige Lizenz vorliegt.")]
[ReadOnly(true)]
[XmlIgnore]
public string LicenseKeyStatus =>
string.IsNullOrWhiteSpace(LicenseKey)
? "nicht gesetzt"
: (LicenseKey.StartsWith("enc:v1:", System.StringComparison.Ordinal)
? "gesetzt (verschlüsselt)"
: "gesetzt (Klartext)");
public static ServerSettings Load(string path)
{
if (!File.Exists(path))
@@ -0,0 +1,436 @@
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";
}
}