Der Status des Projekts stand verstreut in elf Umsetzungsplaenen, drei Konzepten, der Linux-Analyse und dem Projektstand - teils widersprechend, teils wochenlang veraltet. Ab jetzt gibt es genau eine Statusquelle. docs/ROADMAP.md (neu): - Alle Vorhaben in vier Stufen A bis D, plus technische Schuld und Verlauf. Die Stufen sind eine Reihenfolge, keine Termine: jede schafft die Voraussetzung fuer die naechste. - Statuszeichen: erledigt / offen / blockiert (mit Ursache) / bewusst zurueckgestellt / Idee, nicht beschlossen. Damit ist das, was wir NICHT bauen wollen, sichtbar vorgehalten statt unauffindbar in einem Plan zu schlummern. - Inhaltlich getragen, nicht nur verlinkt: je Vorhaben Ziel, Phasen, Akzeptanzkriterien, offene Entscheidungen und Leitplanken aus den Quelldokumenten. - Sichtbar gemacht, was vorher zwischen den Dokumenten verborgen lag: CopyTrading Phase 1 ist der Engpass der gesamten Roadmap (MarketMaking und BundleArbitrage haben harte Voraussetzungen darauf), und die Sniper-Metriken aus Phase 3.2 sind ein Spezialfall des StrategieDrift-Fingerprints - zusammen bauen statt doppelt. Archiv (docs/archiv/): - 15 Dokumente verschoben (11 Umsetzungsplaene, 3 Konzepte, ANALYSE-Linux-Portierung). Sie bleiben die Bauanleitungen mit Code-Bezuegen, Risikotabellen und Begruendungen - eingefroren ist nur ihr Status. - archiv/README.md ordnet jedes Dokument seinem Roadmap-Punkt zu. Verweise nachgezogen - der eigentliche Aufwand: - 25 Markdown-Links repariert. 15 davon verschiebungsbedingt (eine Ebene tiefer), der Rest war schon vorher falsch: die Ideensammlung verlinkte Quellcode relativ zum Repo-Wurzelverzeichnis statt zu docs/. - 12 Dateien ausserhalb von docs/ verwiesen in Kommentaren auf die Plaene (csproj, props, setup.json, sechs Quelldateien) - alle auf archiv/ umgebogen. - Verweise auf Dateien, die der Fruehjahrsputz geloescht hat (Ui/, Program.cs, WindowMenuBar), zu Klartext entschaerft statt tote Links zu lassen. - Gegenprobe: 85 Links geprueft, 0 kaputt. Build gruen, 476 Tests gruen. PROJEKTSTAND.md entdoppelt: Abschnitt "Offen" verweist jetzt auf die Roadmap. Arbeitsteilung ist damit klar - Projektstand sagt was IST, Roadmap was KOMMT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
522 lines
25 KiB
C#
522 lines
25 KiB
C#
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
|
||
{
|
||
/// <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 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 <c>/api/watchdog/v1/ping</c> und <c>/api/watchdog/v1/event</c>, Auth-Header
|
||
/// <c>Authorization: Bearer</c> statt <c>X-Watchdog-Key</c>. <see cref="ServerSettings.WatchdogUrl"/>
|
||
/// und <see cref="ServerSettings.WatchdogToken"/> 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 <see cref="ExecuteAsync"/> 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
|
||
/// <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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private readonly Dictionary<string, Func<(bool ok, string? message)>> _checks = new();
|
||
private readonly object _checksLock = new();
|
||
|
||
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>
|
||
/// 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.
|
||
/// </summary>
|
||
public void RegisterCheck(string name, Func<(bool ok, string? message)> check)
|
||
{
|
||
lock (_checksLock) { _checks[name] = check; }
|
||
}
|
||
|
||
/// <summary>Entfernt eine zuvor registrierte Prüfung (z.B. wenn ein Dienst abgeschaltet wird).</summary>
|
||
public void UnregisterCheck(string name)
|
||
{
|
||
lock (_checksLock) { _checks.Remove(name); }
|
||
}
|
||
|
||
/// <summary>Wertet alle registrierten Prüfungen aus. Liefert <c>null</c>, wenn keine registriert sind.</summary>
|
||
private Dictionary<string, object>? EvaluateChecks()
|
||
{
|
||
Dictionary<string, Func<(bool ok, string? message)>> snapshot;
|
||
lock (_checksLock)
|
||
{
|
||
if (_checks.Count == 0) return null;
|
||
snapshot = new Dictionary<string, Func<(bool ok, string? message)>>(_checks);
|
||
}
|
||
|
||
var result = new Dictionary<string, object>();
|
||
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;
|
||
}
|
||
|
||
/// <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, PingPath, 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{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.");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// B.3/D-1: Meldet dem Watchdog beim Herunterfahren ein geordnetes Ende. Zwei Wege, weil sie
|
||
/// unterschiedliche Dinge tun: der Heartbeat mit <c>status:"stopped"</c> 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.
|
||
/// </summary>
|
||
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();
|
||
}
|
||
|
||
/// <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, 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 <Version> ü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);
|
||
}
|
||
}
|
||
|
||
/// <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/watchdog/v1/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, 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); }
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
// 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;
|
||
}
|
||
|
||
/// <summary>Sende-Takt, nach unten begrenzt.</summary>
|
||
private int EffectiveIntervalSeconds() =>
|
||
Math.Max(MinIntervalSeconds, _settings.WatchdogIntervalSeconds);
|
||
|
||
/// <summary>D-1: Pfad des Heartbeat-Endpunkts im Deploymentcenter (vorher "/api/heartbeat").</summary>
|
||
internal const string PingPath = "watchdog/v1/ping";
|
||
|
||
/// <summary>D-1: Pfad des Event-Endpunkts im Deploymentcenter (vorher "/api/event").</summary>
|
||
internal const string EventPath = "watchdog/v1/event";
|
||
|
||
/// <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 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;
|
||
}
|
||
}
|
||
}
|