Files
PolyTraderSharp/src/PolyTrader.Core/Services/WatchdogHeartbeatService.cs
T
RichardandClaude Opus 5 6218a04fe4 Eine Roadmap statt fuenfzehn Plandokumente; Altbestand ins Archiv
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>
2026-08-23 18:56:50 +02:00

522 lines
25 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.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;
}
}
}