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>
437 lines
20 KiB
C#
437 lines
20 KiB
C#
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";
|
||
}
|
||
}
|