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:
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user