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:
@@ -0,0 +1,60 @@
|
||||
namespace PolyTrader.Core.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Konfiguration der Deploymentcenter-Integration (Lizenz, Watchdog, Fehler-Reporting,
|
||||
/// Updates), gebunden an die "Deploymentcenter"-Sektion in <c>appsettings.json</c>.
|
||||
///
|
||||
/// Ersetzt die bisherigen Einzeldienste Watchdog (watchdog.mhdf.de) und LicenseLabrador
|
||||
/// (license.mhdf.de) durch einen gemeinsamen Gegenüber. Siehe
|
||||
/// docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md.
|
||||
///
|
||||
/// <c>Token</c> und <c>LicenseKey</c> sind Geheimnisse und gehören NICHT in die versionierte
|
||||
/// <c>appsettings.json</c> - dort stehen nur die leeren Platzhalter mit Kommentar, die echten
|
||||
/// Werte kommen aus der gitignorierten <c>appsettings.Local.json</c> (wie schon
|
||||
/// <see cref="DatabaseOptions.MySqlConnectionString"/>). Beide dürfen zusätzlich über
|
||||
/// <c>PolyTrader.Core.Security.SecretProtection</c> verschlüsselt sein (<c>enc:v1:...</c>);
|
||||
/// Klartext wird unverändert durchgereicht.
|
||||
/// </summary>
|
||||
public class DeploymentcenterOptions
|
||||
{
|
||||
public const string SectionName = "Deploymentcenter";
|
||||
|
||||
/// <summary>Basis-URL des Deploymentcenters, z.B. https://dc.mhdf.de. Nur https:// (oder
|
||||
/// http:// auf localhost) wird von den aufrufenden Diensten akzeptiert.</summary>
|
||||
public string BaseUrl { get; set; } = "";
|
||||
|
||||
/// <summary>Produkt-Slug im Deploymentcenter-Admin (Entscheidung 14.08.2026: "polytrader").</summary>
|
||||
public string ProjectSlug { get; set; } = "polytrader";
|
||||
|
||||
/// <summary>Auslieferungskanal: prod, beta oder dev.</summary>
|
||||
public string Channel { get; set; } = "prod";
|
||||
|
||||
/// <summary>
|
||||
/// Anwendungstoken dieser Installation (Scopes watchdog:ping, bugtracker:report).
|
||||
/// Wird bei der Erstinstallation über setup.json/source:"provision" gezogen; kann
|
||||
/// alternativ von Hand gesetzt werden. Niemals ein Master-Token hier eintragen.
|
||||
/// </summary>
|
||||
public string Token { get; set; } = "";
|
||||
|
||||
/// <summary>Lizenzschlüssel dieser Installation.</summary>
|
||||
public string LicenseKey { get; set; } = "";
|
||||
|
||||
/// <summary>Eindeutiger Name dieser Installation im Watchdog-Dashboard.</summary>
|
||||
public string WatchdogSource { get; set; } = "polytrader";
|
||||
|
||||
/// <summary>Mehrere Instanzen derselben Quelle unterscheiden (z.B. "prod-1").</summary>
|
||||
public string WatchdogInstance { get; set; } = "prod-1";
|
||||
|
||||
/// <summary>Sende-Takt des Heartbeats in Sekunden.</summary>
|
||||
public int WatchdogIntervalSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>Heartbeat an- oder abschalten, ohne den restlichen Block zu leeren.</summary>
|
||||
public bool WatchdogEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>Ungefangene Fehler an /api/errors/v1/report melden.</summary>
|
||||
public bool ErrorReportingEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>Beim Start auf ein neues Release prüfen (installiert nie automatisch).</summary>
|
||||
public bool UpdateCheckEnabled { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -79,16 +79,16 @@ namespace PolyTraderSharp.Models
|
||||
|
||||
[Category("Watchdog Monitoring")]
|
||||
[DisplayName("Watchdog Enabled")]
|
||||
[Description("Wenn true, sendet PolyTrader im festen Takt einen Heartbeat an den externen " +
|
||||
"Watchdog-Server. Bleiben die Heartbeats aus, alarmiert der Watchdog (Dead-Man's-Switch). " +
|
||||
"Ein nicht erreichbarer Watchdog beeinträchtigt PolyTrader NIE.")]
|
||||
[Description("Wenn true, sendet PolyTrader im festen Takt einen Heartbeat an den Watchdog " +
|
||||
"des Deploymentcenters. Bleiben die Heartbeats aus, alarmiert der Watchdog " +
|
||||
"(Dead-Man's-Switch). Ein nicht erreichbarer Watchdog beeinträchtigt PolyTrader NIE.")]
|
||||
public bool WatchdogEnabled { get; set; } = false;
|
||||
|
||||
[Category("Watchdog Monitoring")]
|
||||
[DisplayName("Watchdog URL")]
|
||||
[Description("Basis-URL des Watchdog-Servers, z.B. https://watchdog.example.de. " +
|
||||
"HTTPS ist Pflicht – http:// wird nur für localhost akzeptiert, damit der Agent-Token " +
|
||||
"nicht im Klartext über das Netz geht.")]
|
||||
[DisplayName("Deploymentcenter-URL")]
|
||||
[Description("Basis-URL des Deploymentcenters, z.B. https://dc.mhdf.de (ersetzt den früheren " +
|
||||
"eigenständigen Watchdog-Server). HTTPS ist Pflicht – http:// wird nur für localhost " +
|
||||
"akzeptiert, damit der Agent-Token nicht im Klartext über das Netz geht.")]
|
||||
public string WatchdogUrl { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,21 +4,26 @@ using System.Collections.Generic;
|
||||
namespace PolyTrader.Core.Modularity
|
||||
{
|
||||
/// <summary>
|
||||
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die Shell zeigt je View
|
||||
/// höchstens eine Instanz und holt ein bereits offenes Fenster wieder in den Vordergrund.
|
||||
/// Eine vom Core oder einem Modul beigesteuerte Ansicht. Die Shell zeigt je View höchstens eine
|
||||
/// Instanz und holt eine bereits erzeugte wieder nach vorn.
|
||||
///
|
||||
/// <para><b>Bewusst toolkit-neutral:</b> <see cref="CreateView"/> liefert ein <see cref="object"/>,
|
||||
/// keinen konkreten Fenstertyp. Dadurch bleibt der Core plattformunabhängig (<c>net8.0</c>) und
|
||||
/// trägt keine WinForms-Abhängigkeit mehr. Die jeweilige Shell kennt ihr Toolkit und castet:
|
||||
/// die WinForms-Shell auf <c>System.Windows.Forms.Form</c>, die künftige Avalonia-Shell auf
|
||||
/// <c>Avalonia.Controls.Window</c>. Module liefern das Fenster, die Shell hängt es auf.</para>
|
||||
/// keinen konkreten Anzeigetyp. Dadurch bleibt der Core plattformunabhängig und trägt keine
|
||||
/// WinForms-Abhängigkeit mehr. Die jeweilige Shell kennt ihr Toolkit und castet: die frühere
|
||||
/// WinForms-Shell auf <c>System.Windows.Forms.Form</c>, die Avalonia-Shell auf
|
||||
/// <c>Avalonia.Controls.Control</c>.</para>
|
||||
///
|
||||
/// <para><b>Anzeigeform:</b> Die Avalonia-Shell ist seit dem UI-Umbau eine <b>Einfenster-Shell</b> –
|
||||
/// eine View ist dort eine Seite (<c>UserControl</c>) im Inhaltsbereich des einzigen Fensters,
|
||||
/// kein eigenes Fenster mehr. Der Vertrag hier bleibt davon unberührt; nur die Begriffe „offen"
|
||||
/// und „Vordergrund" bedeuten jetzt „erzeugt" bzw. „angezeigt".</para>
|
||||
/// </summary>
|
||||
public sealed class ModuleView
|
||||
{
|
||||
/// <summary>Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View).</summary>
|
||||
/// <summary>Stabile ID für Einzelinstanz-Handling (nur eine Instanz je View).</summary>
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>Titel (Fallback-Fenstertitel, falls das Fenster keinen eigenen setzt).</summary>
|
||||
/// <summary>Titel der Ansicht – in der Avalonia-Shell die Beschriftung in Seitenleiste und Kopfzeile.</summary>
|
||||
public string Title { get; init; } = "Fenster";
|
||||
|
||||
/// <summary>Optionale Gruppierung (z.B. "Core", "CopyTrading").</summary>
|
||||
@@ -37,7 +42,7 @@ namespace PolyTrader.Core.Modularity
|
||||
public string? IconKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). Rückgabetyp ist
|
||||
/// Erzeugt die anzuzeigende Ansicht. Rückgabetyp ist
|
||||
/// <see cref="object"/> – siehe Klassen-Doku zur Toolkit-Neutralität.
|
||||
/// </summary>
|
||||
public Func<object> CreateView { get; init; } = () => new object();
|
||||
@@ -45,9 +50,9 @@ namespace PolyTrader.Core.Modularity
|
||||
|
||||
/// <summary>
|
||||
/// Wird der Shell beim Start übergeben; Core und Module registrieren hier ihre Ansichten.
|
||||
/// Die Shell (Launcher) öffnet die Fenster auf Anforderung und verwaltet den Offen-Status.
|
||||
/// Über die Navigations-Mitglieder kann JEDES Fenster (auch Modul-Fenster, die nur den Core
|
||||
/// kennen) das gemeinsame „Fenster"-Menü bauen.
|
||||
/// Die Shell zeigt eine Ansicht auf Anforderung an und verwaltet ihren Zustand. Über die
|
||||
/// Navigations-Mitglieder kann JEDE Ansicht (auch Modul-Ansichten, die nur den Core kennen)
|
||||
/// zu einer anderen wechseln oder das Herunterfahren einleiten.
|
||||
/// </summary>
|
||||
public interface IModuleUiHost
|
||||
{
|
||||
@@ -56,23 +61,23 @@ namespace PolyTrader.Core.Modularity
|
||||
/// <summary>Alle registrierten Ansichten (Core + Module).</summary>
|
||||
IReadOnlyList<ModuleView> Views { get; }
|
||||
|
||||
/// <summary>Ist das Fenster dieser View gerade offen?</summary>
|
||||
/// <summary>Wurde diese View bereits erzeugt (in der Mehrfenster-Fassung: „Fenster ist offen")?</summary>
|
||||
bool IsOpen(string viewId);
|
||||
|
||||
/// <summary>Öffnet die View bzw. holt ein bereits offenes Fenster in den Vordergrund (maximiert).</summary>
|
||||
/// <summary>Zeigt die View an bzw. holt eine bereits erzeugte wieder nach vorn.</summary>
|
||||
void OpenView(string viewId);
|
||||
|
||||
/// <summary>Holt das Hauptfenster (Launcher) in den Vordergrund.</summary>
|
||||
/// <summary>Zurück zur Startansicht.</summary>
|
||||
void ActivateMain();
|
||||
|
||||
/// <summary>
|
||||
/// Leitet das sichere Herunterfahren ein (Bestätigungsdialog mit Timer). Wird vom „Beenden"-
|
||||
/// Eintrag JEDES Fensters aufgerufen – auch aus Modul-Fenstern, die die App nicht kennen.
|
||||
/// Die konkrete Shell zeigt den Dialog und fährt bei Bestätigung geordnet herunter.
|
||||
/// Leitet das sichere Herunterfahren ein (Bestätigungsdialog mit Timer). Aus jeder Ansicht
|
||||
/// aufrufbar – auch aus Modul-Ansichten, die die App nicht kennen. Die konkrete Shell zeigt
|
||||
/// den Dialog und fährt bei Bestätigung geordnet herunter.
|
||||
/// </summary>
|
||||
void RequestShutdown();
|
||||
|
||||
/// <summary>Feuert, wenn sich der Offen-Status irgendeiner View ändert.</summary>
|
||||
/// <summary>Feuert, wenn eine andere View angezeigt oder erstmals erzeugt wird.</summary>
|
||||
event Action? OpenStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Nethereum.Web3" Version="6.1.0" />
|
||||
<!-- Deploymentcenter-Integration (Lizenz, Watchdog, Fehler-Reporting, Updates) - siehe
|
||||
docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md. Lokales Paket in
|
||||
lib/nuget, keine Projektreferenz ins Nachbar-Repo (PolyTrader muss ohne das
|
||||
Schwester-Repo bauen, siehe lib/nuget/README.md). Erzeugt beim Bauen zusätzlich die
|
||||
Klasse PolyTrader.Core.BuildInfo (Version, Git-Commit, Build-Datum) über
|
||||
Deploymentcenter.BuildInfo.targets, das im Paket unter build/ liegt. -->
|
||||
<PackageReference Include="Deploymentcenter.Client" Version="2.5.0" />
|
||||
<!-- Sicherheit: Nethereum 6.1.0 laesst transitiv Newtonsoft.Json [11.0.2, 14.0.0) zu und
|
||||
wuerde ohne Pinnung auf 11.0.2 aufloesen - das hat eine bekannte Luecke hoher Schwere
|
||||
(GHSA-5crp-9r3c-p9vr). Die .NET-10-Paketpruefung meldet das jetzt auch fuer transitive
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace PolyTrader.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Beschränkt eine Schlüsseldatei auf den Besitzer. Unter Linux/macOS erzeugt
|
||||
/// <see cref="File.WriteAllText(string, string)"/> mit der üblichen umask sonst <c>644</c> —
|
||||
/// world-readable, und genau dort liegen Master-Key, Watchdog-Token oder OpenRouter-Key im
|
||||
/// Klartext. Unter Windows greift die NTFS-Vererbung des Profils, dort ist nichts zu tun.
|
||||
/// </summary>
|
||||
public static class FilePermissions
|
||||
{
|
||||
public static void RestrictToOwner(string path, Action<string>? onWarning = null)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onWarning?.Invoke(
|
||||
$"Dateirechte für {Path.GetFileName(path)} konnten nicht gesetzt werden: {ex.Message}. Bitte manuell prüfen (chmod 600).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace PolyTrader.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Löst den AES-Master-Key auf, mit dem <see cref="SecretProtection"/> Secrets at-rest
|
||||
/// verschlüsselt (D-5/D-10 aus dem Deploymentcenter-Umsetzungsplan).
|
||||
///
|
||||
/// Reihenfolge: Umgebungsvariable, dann eine vorhandene Schlüsseldatei, sonst wird ein neuer
|
||||
/// erzeugt und geschrieben — das ist der Regelfall direkt nach <c>update-agent --action install</c>
|
||||
/// auf einem frischen System, wo niemand von Hand einen Schlüssel eintippt.
|
||||
///
|
||||
/// **Die eine Regel, die nie verletzt werden darf:** Erzeugt wird AUSSCHLIESSLICH, wenn weder
|
||||
/// Variable noch Datei existieren. Ein versehentliches Überschreiben würde jedes bereits
|
||||
/// verschlüsselte Secret in der Datenbank unwiederbringlich unlesbar machen — der einzige
|
||||
/// wirklich unumkehrbare Fehler in diesem gesamten Bereich. Ein zweiter Aufruf (jeder weitere
|
||||
/// Programmstart) liest deshalb ausnahmslos die vorhandene Datei, statt neu zu erzeugen.
|
||||
/// </summary>
|
||||
public static class MasterKeyResolver
|
||||
{
|
||||
/// <param name="keyFilePath">Pfad der Schlüsseldatei (z.B. neben der Anwendung).</param>
|
||||
/// <param name="readEnvironmentVariable">Liest z.B. POLYTRADER_MASTER_KEY. Injizierbar für Tests.</param>
|
||||
/// <param name="onGenerated">Aufgerufen, wenn ein neuer Schlüssel erzeugt wurde (Pfad als Parameter).</param>
|
||||
/// <param name="onWarning">Aufgerufen bei einem nicht fatalen Problem (z.B. Dateirechte, Erzeugung fehlgeschlagen).</param>
|
||||
public static string? Resolve(
|
||||
string keyFilePath,
|
||||
Func<string?> readEnvironmentVariable,
|
||||
Action<string>? onGenerated = null,
|
||||
Action<string>? onWarning = null)
|
||||
{
|
||||
string? masterKey = readEnvironmentVariable();
|
||||
if (!string.IsNullOrWhiteSpace(masterKey))
|
||||
{
|
||||
return masterKey;
|
||||
}
|
||||
|
||||
if (File.Exists(keyFilePath))
|
||||
{
|
||||
return File.ReadAllText(keyFilePath).Trim();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] keyBytes = RandomNumberGenerator.GetBytes(32); // 256-Bit-Schlüssel
|
||||
string generated = Convert.ToBase64String(keyBytes);
|
||||
|
||||
File.WriteAllText(keyFilePath, generated);
|
||||
FilePermissions.RestrictToOwner(keyFilePath, onWarning);
|
||||
|
||||
onGenerated?.Invoke(keyFilePath);
|
||||
return generated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Erzeugung darf den Start nie verhindern - ohne Master-Key laufen Secrets eben
|
||||
// unverschlüsselt weiter (wie bisher ohne POLYTRADER_MASTER_KEY), statt abzustürzen.
|
||||
onWarning?.Invoke($"Master-Key konnte nicht automatisch erzeugt werden: {ex.Message}.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PolyTrader.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Entfernt bekannte Secret-Muster aus Text, der an extern gehostete Dienste (Deploymentcenter:
|
||||
/// Watchdog, Fehler-Reporting) geht. Nur die Musterprüfung selbst — Längenbegrenzung und
|
||||
/// Zeilenbehandlung entscheidet jeder Aufrufer selbst (eine kurze Statuszeile und ein mehrzeiliger
|
||||
/// Stacktrace brauchen unterschiedliche Regeln).
|
||||
/// </summary>
|
||||
public static class SecretRedactor
|
||||
{
|
||||
/// <summary>Entfernt Wallet-Adressen/Keys (0x-Hex) und verschlüsselte Secrets (enc:v1:…).</summary>
|
||||
public static string RemoveSecrets(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return string.Empty;
|
||||
string s = Regex.Replace(text, "0x[0-9a-fA-F]{8,}", "0x…");
|
||||
s = Regex.Replace(s, @"enc:v1:[A-Za-z0-9+/=]+", "enc:v1:…");
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user