Arbeitsstand gesichert: Deploymentcenter-Integration (D-0 bis D-5) und Einfenster-Shell

Sicherungscommit vor dem Aufraeumen des Repos, damit nachvollziehbar bleibt,
welcher Stand vor der Bereinigung galt. Build gruen, 476 Tests gruen.

Zwei Straenge, die sich ueber .csproj, Program.cs und appsettings.json
ueberschneiden und darum gemeinsam abgelegt werden:

Deploymentcenter-Integration (P3c, Plan D-0 bis D-5 code-seitig fertig):
- Deploymentcenter.Client 2.5.0 als lokales Paket, Source-Mapping erweitert
- DeploymentcenterOptions, DeploymentcenterErrorReporter, LicenseGate/LicenseCli
- WatchdogHeartbeatService auf die Deploymentcenter-API umgestellt
  (version, os, checks, metrics, status stopped)
- Security: MasterKeyResolver, SecretRedactor, FilePermissions
- Directory.Build.props mit zentraler Version 0.1.0 (Packager-Versionsdisziplin)
- deploy/: Packager-Vorlage und systemd-Unit; echte Zugangsdaten bleiben
  ueber .gitignore aussen vor
- setup.json fuer die Erstinstallation
- UMSETZUNGSPLAN-Deploymentcenter-Integration.md; ANALYSE-Linux-Portierung.md
  verweist auf den neuen Plan

Einfenster-Shell (UI-Redesign):
- ShellWindow + ShellNavModel als Seitenleisten-Shell
- WindowMenuBar und WindowMenuModel entfallen
- Modul- und Kernfenster auf die Shell-Einbettung angepasst

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-22 10:42:22 +02:00
co-authored by Claude Opus 5
parent ac5e16f0a6
commit dd8da3fd3f
58 changed files with 3932 additions and 650 deletions
@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Security;
namespace PolyTraderSharp.Services
{
/// <summary>
/// Meldet ungefangene Fehler an den Fehler-Stream des Deploymentcenters (D-3,
/// <c>POST /api/errors/v1/report</c>). Fängt drei Quellen ab: <see cref="AppDomain.UnhandledException"/>
/// (der Prozess stirbt gerade <c>level: "fatal"</c>), <see cref="TaskScheduler.UnobservedTaskException"/>
/// (ein Hintergrund-Task ist fehlgeschlagen, ohne dass jemand hinsah <c>"warning"</c>) und
/// <see cref="TerminalLogger.OnLogMessage"/> bei <see cref="LogLevel.Error"/> (<c>"error"</c>).
///
/// **Grundregel wie beim Watchdog:** Ein nicht erreichbares Deploymentcenter darf PolyTrader nie
/// beeinträchtigen. Zusätzlich hier besonders wichtig: **keine Rückkopplung.** Der Reporter selbst
/// darf einen Sendefehler NIEMALS über <see cref="TerminalLogger.Error"/> melden das würde exakt
/// den Log-Kanal auslösen, den er selbst abhört, und sich damit endlos selbst melden. Eigene
/// Fehlermeldungen laufen deshalb ausschließlich über <see cref="TerminalLogger.Warning"/>.
///
/// Fehlermeldungen enthalten möglicherweise Wallet-Adressen, Marktkennungen oder Beträge; der
/// Deploymentcenter-Server liegt beim Hoster. Nachricht und Stacktrace laufen deshalb vor dem
/// Versand durch <see cref="SecretRedactor"/>.
/// </summary>
public sealed class DeploymentcenterErrorReporter : BackgroundService
{
/// <summary>
/// Eigene, deutlich strengere Grenze als das serverseitige Limit (60/Minute/IP) ein
/// durchdrehender Fehlerpfad soll lokal gedrosselt werden, bevor er den Server überhaupt
/// erreicht oder mit anderen Diensten auf derselben IP kollidiert.
/// </summary>
private const int MaxReportsPerMinute = 20;
private const int MaxStackTraceLength = 4000;
private readonly TerminalLogger _logger;
private readonly DeploymentcenterOptions _options;
private readonly HttpClient _http;
private readonly object _rateLock = new();
private readonly Queue<DateTime> _recentSendsUtc = new();
private bool _throttleWarningLogged;
public DeploymentcenterErrorReporter(
TerminalLogger logger,
DeploymentcenterOptions options,
HttpClient? httpClient = null)
{
_logger = logger;
_options = options;
_http = httpClient ?? new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
})
{
Timeout = TimeSpan.FromSeconds(10)
};
_logger.OnLogMessage += OnAppLog;
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
/// <summary>Rein ereignisgesteuert kein eigener Takt nötig, nur Auf- und Abbau der Abonnements.</summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Normales Herunterfahren.
}
}
public override void Dispose()
{
_logger.OnLogMessage -= OnAppLog;
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
base.Dispose();
}
internal void OnAppLog(object? sender, LogMessageEventArgs e)
{
if (e.Level != LogLevel.Error) return;
// Fire-and-forget: OnLogMessage ist synchron, darf den loggenden Thread nicht blockieren.
// ReportAsync fängt jede eigene Ausnahme selbst ab (No-Throw-Vertrag) hier entsteht also
// keine unbeobachtete Task-Exception, die wiederum den UnobservedTaskException-Zweig
// triggern und sich selbst melden würde.
_ = ReportAsync("ApplicationError", e.Message, stackTrace: null, level: "error");
}
internal void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
string message = (e.ExceptionObject as Exception)?.Message ?? e.ExceptionObject?.ToString() ?? "Unbekannter Fehler";
string? stack = (e.ExceptionObject as Exception)?.StackTrace;
string type = (e.ExceptionObject as Exception)?.GetType().FullName ?? "UnknownException";
// Der Prozess beendet sich gleich (IsTerminating in aller Regel true) - synchron und mit
// kurzer Frist senden, statt auf einen Fire-and-forget-Task zu setzen, der nie mehr laufen
// könnte.
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
ReportAsync(type, message, stack, "fatal", cts.Token).GetAwaiter().GetResult();
}
catch { /* Der Prozess stirbt ohnehin - hier ist nichts mehr zu retten. */ }
}
internal void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
var ex = e.Exception?.Flatten().InnerException ?? (Exception?)e.Exception;
_ = ReportAsync(ex?.GetType().FullName ?? "UnobservedTaskException",
ex?.Message ?? "Unbeobachtete Task-Exception", ex?.StackTrace, "warning");
// Verhindert, dass .NET den Prozess wegen der unbeobachteten Exception beendet - wir haben
// sie ja gerade beobachtet und gemeldet.
e.SetObserved();
}
/// <summary>
/// Meldet einen Fehler. Wirft nie, meldet eigene Fehlschläge nur über
/// <see cref="TerminalLogger.Warning"/> (siehe Klassendokumentation zur Rückkopplung).
/// </summary>
public async Task<bool> ReportAsync(
string exceptionType, string message, string? stackTrace, string level, CancellationToken ct = default)
{
try
{
if (!_options.ErrorReportingEnabled) return false;
if (string.IsNullOrWhiteSpace(_options.BaseUrl) || string.IsNullOrWhiteSpace(_options.Token))
return false;
if (!TryBuildEndpoint(_options.BaseUrl, out Uri? uri, out string urlProblem))
{
WarnOnce($"⚠️ Fehler-Reporting nicht sendebereit: {urlProblem}");
return false;
}
if (!TryReserveRateSlot())
{
if (!_throttleWarningLogged)
{
_logger.Warning("⚠️ Fehler-Reporting lokal gedrosselt (mehr als " +
$"{MaxReportsPerMinute} Meldungen/Minute) weitere Meldungen werden " +
"verworfen, bis sich die Rate beruhigt. Details bleiben im Terminal-Log.");
_throttleWarningLogged = true;
}
return false;
}
string token;
try { token = SecretProtection.Unprotect(_options.Token); }
catch (Exception ex) { WarnOnce($"⚠️ Fehler-Reporting-Token nicht lesbar: {ex.Message}"); return false; }
var payload = new
{
project_slug = _options.ProjectSlug,
exception = exceptionType,
message = WatchdogHeartbeatService.Sanitize(message),
stack_trace = SanitizeStackTrace(stackTrace),
level,
build = BuildInfo.Version,
environment = Environment.GetEnvironmentVariable("POLYTRADER_ENVIRONMENT") ?? "production"
};
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);
if (!response.IsSuccessStatusCode)
{
WarnOnce($"⚠️ Fehler-Reporting fehlgeschlagen: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
return false;
}
return true;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex)
{
// Letztes Sicherheitsnetz - siehe Klassendokumentation: NIE über .Error loggen.
WarnOnce($"⚠️ Fehler-Reporting unerwartet fehlgeschlagen: {ex.Message}");
return false;
}
}
/// <summary>Entprellt Warnungen wie der Watchdog: der erste Fehlschlag wird gemeldet, nicht jeder einzelne.</summary>
private bool _lastSendFailed;
private void WarnOnce(string message)
{
if (_lastSendFailed) return;
_logger.Warning(message);
_lastSendFailed = true;
}
/// <summary>Gleitendes Ein-Minuten-Fenster, ohne Hintergrund-Timer.</summary>
private bool TryReserveRateSlot()
{
lock (_rateLock)
{
DateTime now = DateTime.UtcNow;
while (_recentSendsUtc.Count > 0 && (now - _recentSendsUtc.Peek()) > TimeSpan.FromMinutes(1))
_recentSendsUtc.Dequeue();
if (_recentSendsUtc.Count >= MaxReportsPerMinute) return false;
_recentSendsUtc.Enqueue(now);
_throttleWarningLogged = false; // erneutes Überschreiten wieder genau einmal melden
return true;
}
}
/// <summary>
/// Stacktraces dürfen (anders als die kurze Statuszeile im Heartbeat) mehrzeilig bleiben
/// sie sind sonst wertlos. Nur die bekannten Secret-Muster werden entfernt, dazu eine
/// großzügige Längenbegrenzung gegen ausufernde Payloads.
/// </summary>
private static string? SanitizeStackTrace(string? stackTrace)
{
if (string.IsNullOrEmpty(stackTrace)) return null;
string s = SecretRedactor.RemoveSecrets(stackTrace);
return s.Length > MaxStackTraceLength ? s.Substring(0, MaxStackTraceLength - 1) + "…" : s;
}
private static bool TryBuildEndpoint(string baseUrl, out Uri? uri, out string problem)
{
uri = null;
problem = string.Empty;
if (!Uri.TryCreate(baseUrl?.Trim().TrimEnd('/'), UriKind.Absolute, out Uri? parsed))
{
problem = $"Deploymentcenter-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 = $"Deploymentcenter-URL muss HTTPS verwenden (http:// nur für localhost): '{baseUrl}'.";
return false;
}
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/errors/v1/report");
return true;
}
}
}
@@ -1,8 +1,11 @@
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;
@@ -13,16 +16,25 @@ namespace PolyTraderSharp.Services
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.
/// 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/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 Watchdog-Testclients: dieser
/// deaktiviert die TLS-Zertifikatsprüfung pauschal. Hier gilt die Standardprüfung, und
/// 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>
@@ -65,6 +77,16 @@ namespace PolyTraderSharp.Services
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,
@@ -103,6 +125,53 @@ namespace PolyTraderSharp.Services
/// <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.
@@ -115,7 +184,7 @@ namespace PolyTraderSharp.Services
return "Kein Agent-Token konfiguriert.";
if (string.IsNullOrWhiteSpace(_settings.WatchdogSource))
return "Keine Source konfiguriert.";
if (!TryBuildEndpoint(_settings.WatchdogUrl, "heartbeat", out _, out string urlProblem))
if (!TryBuildEndpoint(_settings.WatchdogUrl, PingPath, out _, out string urlProblem))
return urlProblem;
return null;
}
@@ -176,7 +245,7 @@ namespace PolyTraderSharp.Services
// den Programmstart; danach übernimmt die Staleness-Erkennung).
if (!_startedEventSent)
{
await SendEventAsync("started", "info", $"PolyTrader gestartet (v{AppVersion})", stoppingToken)
await SendEventAsync("started", "info", $"PolyTrader gestartet (v{BuildInfo.Version})", stoppingToken)
.ConfigureAwait(false);
_startedEventSent = true;
}
@@ -206,9 +275,12 @@ namespace PolyTraderSharp.Services
}
/// <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.
/// 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)
{
@@ -216,7 +288,11 @@ namespace PolyTraderSharp.Services
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
// 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);
}
@@ -242,7 +318,7 @@ namespace PolyTraderSharp.Services
string? problem = GetConfigurationProblem();
if (problem != null) return new WatchdogSendResult(false, problem);
if (!TryBuildEndpoint(_settings.WatchdogUrl, "heartbeat", out Uri? uri, out string urlProblem))
if (!TryBuildEndpoint(_settings.WatchdogUrl, PingPath, out Uri? uri, out string urlProblem))
return new WatchdogSendResult(false, urlProblem);
string token;
@@ -268,7 +344,11 @@ namespace PolyTraderSharp.Services
// "interval"/"expected_interval_sec" und ignoriert "intervalSeconds" still.
interval = EffectiveIntervalSeconds(),
group = "Applications",
metadata = new { version = AppVersion }
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
@@ -277,7 +357,9 @@ namespace PolyTraderSharp.Services
{
Content = JsonContent.Create(payload)
};
request.Headers.Add("X-Watchdog-Key", token);
// 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)
@@ -330,15 +412,15 @@ namespace PolyTraderSharp.Services
}
/// <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.
/// 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, "event", out Uri? uri, out string urlProblem))
if (!TryBuildEndpoint(_settings.WatchdogUrl, EventPath, out Uri? uri, out string urlProblem))
return new WatchdogSendResult(false, urlProblem);
string token;
@@ -357,7 +439,7 @@ namespace PolyTraderSharp.Services
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = JsonContent.Create(payload) };
request.Headers.Add("X-Watchdog-Key", token);
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}")
@@ -390,9 +472,10 @@ namespace PolyTraderSharp.Services
/// </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:…");
// 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;
}
@@ -401,11 +484,17 @@ namespace PolyTraderSharp.Services
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 action, out Uri? uri, out string problem)
internal static bool TryBuildEndpoint(string baseUrl, string relativePath, out Uri? uri, out string problem)
{
uri = null;
problem = string.Empty;
@@ -425,12 +514,8 @@ namespace PolyTraderSharp.Services
return false;
}
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/{action}");
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/{relativePath}");
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";
}
}