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,176 @@
|
|||||||
|
using System;
|
||||||
|
using LicenseLabrador.Client;
|
||||||
|
using PolyTrader.Core.Security;
|
||||||
|
using PolyTraderSharp.Models;
|
||||||
|
using PolyTraderSharp.Services;
|
||||||
|
|
||||||
|
namespace PolyTraderSharp.Licensing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lizenz-Torwächter für den Programmstart (B.4). Prüft beim Start die LicenseLabrador-Lizenz und
|
||||||
|
/// entscheidet, ob PolyTrader voll (mit allen Modulen) oder eingeschränkt (nur Core-Shell, damit
|
||||||
|
/// Terminal und Einstellungen zum Eintragen einer Lizenz erreichbar bleiben) startet.
|
||||||
|
///
|
||||||
|
/// **Grundsatz:** kein harter Abbruch (<c>Environment.Exit</c>) bei ungültiger Lizenz. Ein
|
||||||
|
/// Trading-Bot mit offenen Positionen darf nicht mitten im Lauf hart abgeschossen werden – im
|
||||||
|
/// Zweifel startet die Shell ohne die Trading-/Analyse-Module weiter. Der Gate wirft nie; jeder
|
||||||
|
/// Fehler in der Prüfung führt zum eingeschränkten Modus, nicht zum Absturz.
|
||||||
|
///
|
||||||
|
/// Die eigentliche Kryptografie (Ed25519-Signaturprüfung, Nonce, Offline-Kulanz, Hardware-Bindung)
|
||||||
|
/// liegt im <see cref="LicenseClient"/> des SDK; dieser Gate ist nur die Anwendungslogik drumherum.
|
||||||
|
/// </summary>
|
||||||
|
public static class LicenseGate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Öffentlicher Ed25519-Schlüssel des Lizenzservers (license.mhdf.de), Base64. Bewusst fest
|
||||||
|
/// eingebettet: nur mit diesem Schlüssel lässt sich eine Server-Antwort verifizieren – ein
|
||||||
|
/// Angreifer kann ohne den (nur serverseitigen) privaten Schlüssel kein „valid" fälschen.
|
||||||
|
/// </summary>
|
||||||
|
private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U=";
|
||||||
|
|
||||||
|
/// <summary>Produkt-Slug im LicenseLabrador-Admin.</summary>
|
||||||
|
private const string ProductSlug = "pt";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lizenzserver-Endpunkt. Ausschließlich HTTPS – über http:// gingen Lizenzschlüssel und
|
||||||
|
/// Hardware-ID im Klartext übers Netz (die signierte Antwort bliebe zwar fälschungssicher,
|
||||||
|
/// der Schlüssel würde aber geleakt).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] Endpoints = { "https://license.mhdf.de" };
|
||||||
|
|
||||||
|
/// <summary>Offline-Kulanz, falls der Server das Feld nicht liefert (7 Tage).</summary>
|
||||||
|
private const int OfflineGraceHoursFallback = 168;
|
||||||
|
|
||||||
|
public static LicenseConfig BuildConfig() => new LicenseConfig
|
||||||
|
{
|
||||||
|
ProductSlug = ProductSlug,
|
||||||
|
PublicKeyBase64 = PublicKeyBase64,
|
||||||
|
Endpoints = Endpoints,
|
||||||
|
OfflineGraceHoursFallback = OfflineGraceHoursFallback
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Führt die Startprüfung durch. Rückgabe <c>true</c> = App darf voll (mit Modulen) starten.
|
||||||
|
/// Zeigt bei ungültiger Lizenz einen modalen Dialog (Schlüssel eingeben, Hardware-ID anzeigen,
|
||||||
|
/// eingeschränkt starten). Wirft nie.
|
||||||
|
/// </summary>
|
||||||
|
public static bool RunStartupGate(ServerSettings settings, TerminalLogger logger, string settingsPath)
|
||||||
|
{
|
||||||
|
LicenseClient client;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
client = new LicenseClient(BuildConfig());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.Error($"❌ Lizenzprüfung nicht initialisierbar: {ex.Message}. Start im eingeschränkten Modus.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string storedKey = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Erlaubt einen mit dem Master-Key verschlüsselten Schlüssel (enc:v1:…); Klartext
|
||||||
|
// wird unverändert durchgereicht.
|
||||||
|
storedKey = SecretProtection.Unprotect(settings.LicenseKey);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.Warning($"⚠️ Gespeicherter Lizenzschlüssel nicht lesbar ({ex.Message}) – behandle als 'keine Lizenz'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LicenseResult result = EvaluateBlocking(client, storedKey);
|
||||||
|
|
||||||
|
if (result.IsUsable)
|
||||||
|
{
|
||||||
|
LogUsable(result, logger);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warning($"⚠️ Keine gültige Lizenz: {Describe(result)}. Öffne Lizenzdialog.");
|
||||||
|
|
||||||
|
LicenseResult? dialogResult;
|
||||||
|
string? dialogKey;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var dlg = new PolyTraderSharp.Ui.LicenseDialog(client, storedKey, result);
|
||||||
|
dlg.ShowDialog();
|
||||||
|
dialogResult = dlg.ValidatedResult;
|
||||||
|
dialogKey = dlg.ValidatedKey;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.Error($"❌ Lizenzdialog fehlgeschlagen: {ex.Message}. Start im eingeschränkten Modus.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogResult != null && dialogResult.IsUsable && !string.IsNullOrWhiteSpace(dialogKey))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Bei gesetztem Master-Key verschlüsselt speichern; sonst Klartext (Passthrough).
|
||||||
|
settings.LicenseKey = SecretProtection.Protect(dialogKey!.Trim());
|
||||||
|
settings.Save(settingsPath);
|
||||||
|
logger.Info("🔑 Lizenz validiert und gespeichert.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.Error($"Lizenz gültig, aber Speichern des Schlüssels fehlgeschlagen: {ex.Message}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LogUsable(dialogResult, logger);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warning(
|
||||||
|
"⚠️ Start im EINGESCHRÄNKTEN Modus – keine Module aktiv, nur Terminal und Einstellungen. " +
|
||||||
|
"Lizenz in den Einstellungen setzen und PolyTrader neu starten.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validiert synchron (blockierend). Das SDK arbeitet durchgängig mit ConfigureAwait(false),
|
||||||
|
/// daher ist das Blockieren vor der Message-Loop unbedenklich. Ein leerer Schlüssel ergibt
|
||||||
|
/// planmäßig <see cref="LicenseState.NoLicense"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static LicenseResult EvaluateBlocking(LicenseClient client, string? key)
|
||||||
|
{
|
||||||
|
return client.ValidateAsync(key ?? string.Empty).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LogUsable(LicenseResult result, TerminalLogger logger)
|
||||||
|
{
|
||||||
|
switch (result.State)
|
||||||
|
{
|
||||||
|
case LicenseState.Valid:
|
||||||
|
logger.Info("🔑 Lizenz gültig (online geprüft).");
|
||||||
|
break;
|
||||||
|
case LicenseState.ValidOffline:
|
||||||
|
logger.Warning($"🔑 Lizenz im Offline-Kulanzmodus gültig bis {result.GraceUntil:g} " +
|
||||||
|
"(Server nicht erreichbar).");
|
||||||
|
break;
|
||||||
|
case LicenseState.ValidLocalFile:
|
||||||
|
logger.Info("🔑 Lizenz über lokale Offline-Lizenzdatei gültig.");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.Info($"🔑 Lizenz nutzbar ({result.State}).");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Kurzbeschreibung eines nicht nutzbaren Ergebnisses für das Log/den Dialog.</summary>
|
||||||
|
internal static string Describe(LicenseResult result)
|
||||||
|
{
|
||||||
|
return result.State switch
|
||||||
|
{
|
||||||
|
LicenseState.NoLicense => "kein oder ungültiger Schlüssel / Server nicht erreichbar",
|
||||||
|
LicenseState.NotFound => "Schlüssel dem Server unbekannt",
|
||||||
|
LicenseState.Revoked => "Lizenz wurde widerrufen",
|
||||||
|
LicenseState.Expired => "Lizenz abgelaufen",
|
||||||
|
LicenseState.ActivationLimit => "maximale Aktivierungen erreicht",
|
||||||
|
LicenseState.TamperSuspected => "Manipulation erkannt (Signatur/Uhr) – Prüfung verweigert",
|
||||||
|
_ => result.State.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,9 @@
|
|||||||
<ProjectReference Include="src\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
|
<ProjectReference Include="src\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
|
||||||
<ProjectReference Include="src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
|
<ProjectReference Include="src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
|
||||||
<ProjectReference Include="src\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj" />
|
<ProjectReference Include="src\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj" />
|
||||||
|
<!-- Lizenz-SDK (LicenseLabrador). Cross-Repo-Projektreferenz auf das Schwester-Repo unter
|
||||||
|
J:\Softwareprojekte\LicenseLabrador, damit Fixes am SDK ohne Kopie einfließen. -->
|
||||||
|
<ProjectReference Include="..\..\LicenseLabrador\client-dotnet\LicenseLabrador.Client\LicenseLabrador.Client.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
+41
-6
@@ -69,6 +69,14 @@ internal static class Program
|
|||||||
// erst geladen werden. Dieselbe Instanz wird als Singleton weitergereicht (eine Quelle).
|
// erst geladen werden. Dieselbe Instanz wird als Singleton weitergereicht (eine Quelle).
|
||||||
var serverSettings = PolyTraderSharp.Models.ServerSettings.Load(ServerSettingsPath);
|
var serverSettings = PolyTraderSharp.Models.ServerSettings.Load(ServerSettingsPath);
|
||||||
|
|
||||||
|
// Früher Logger: wird unten als DI-Singleton weitergereicht, damit die Startmeldungen
|
||||||
|
// (Master-Key, Lizenz) später im Terminal-Fenster erscheinen (History-Replay).
|
||||||
|
var bootLog = new TerminalLogger();
|
||||||
|
|
||||||
|
// F1 (Sicherheit): Master-Key VOR jeder Entschlüsselung laden – wird sowohl für die
|
||||||
|
// Account-Credentials als auch für den gespeicherten Lizenzschlüssel gebraucht.
|
||||||
|
ConfigureSecretProtection(bootLog);
|
||||||
|
|
||||||
// Alle bekannten Module; „modules" enthält nur die aktiven (nicht in DisabledModules).
|
// Alle bekannten Module; „modules" enthält nur die aktiven (nicht in DisabledModules).
|
||||||
var allModules = new System.Collections.Generic.List<IPolyTraderModule>
|
var allModules = new System.Collections.Generic.List<IPolyTraderModule>
|
||||||
{
|
{
|
||||||
@@ -79,7 +87,15 @@ internal static class Program
|
|||||||
};
|
};
|
||||||
var disabledModules = new System.Collections.Generic.HashSet<string>(
|
var disabledModules = new System.Collections.Generic.HashSet<string>(
|
||||||
serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase);
|
serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase);
|
||||||
var modules = allModules.Where(m => !disabledModules.Contains(m.Name)).ToList();
|
|
||||||
|
// B.4: Lizenzprüfung beim Start. Bei ungültiger Lizenz zeigt der Gate einen Dialog und
|
||||||
|
// gibt false zurück → es werden KEINE Module registriert (nur die Core-Shell startet,
|
||||||
|
// damit Terminal und Einstellungen zum Eintragen einer Lizenz erreichbar bleiben).
|
||||||
|
// Bewusst KEIN Environment.Exit – ein Trading-Bot darf nicht mitten im Lauf hart sterben.
|
||||||
|
bool licensed = PolyTraderSharp.Licensing.LicenseGate.RunStartupGate(serverSettings, bootLog, ServerSettingsPath);
|
||||||
|
var modules = licensed
|
||||||
|
? allModules.Where(m => !disabledModules.Contains(m.Name)).ToList()
|
||||||
|
: new System.Collections.Generic.List<IPolyTraderModule>();
|
||||||
|
|
||||||
AppHost = Host.CreateDefaultBuilder()
|
AppHost = Host.CreateDefaultBuilder()
|
||||||
// Config immer neben der EXE suchen (nicht im Arbeitsverzeichnis), damit die App
|
// Config immer neben der EXE suchen (nicht im Arbeitsverzeichnis), damit die App
|
||||||
@@ -126,11 +142,17 @@ internal static class Program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
services.AddSingleton<TerminalLogger>();
|
// Denselben früh erzeugten Logger als Singleton weiterreichen, damit die
|
||||||
|
// Master-Key-/Lizenz-Meldungen von oben im Terminal-Fenster auftauchen.
|
||||||
|
services.AddSingleton(bootLog);
|
||||||
services.AddSingleton<MullvadVpnService>();
|
services.AddSingleton<MullvadVpnService>();
|
||||||
services.AddSingleton<ThreemaService>();
|
services.AddSingleton<ThreemaService>();
|
||||||
services.AddSingleton<JobManager>();
|
services.AddSingleton<JobManager>();
|
||||||
|
|
||||||
|
// Watchdog-Heartbeat (Dead-Man's-Switch). Als Singleton registriert, damit das
|
||||||
|
// Settings-Fenster denselben Dienst für ReloadSettings/Test-Heartbeat nutzt.
|
||||||
|
services.AddSingleton(sp => new WatchdogHeartbeatService(sp.GetRequiredService<TerminalLogger>()));
|
||||||
|
|
||||||
// MUSS als erster HostedService laufen: hydriert den State, bevor die
|
// MUSS als erster HostedService laufen: hydriert den State, bevor die
|
||||||
// Trading-Services gegen einen leeren State anlaufen.
|
// Trading-Services gegen einen leeren State anlaufen.
|
||||||
services.AddHostedService<StartupHydrationService>();
|
services.AddHostedService<StartupHydrationService>();
|
||||||
@@ -145,13 +167,12 @@ internal static class Program
|
|||||||
services.AddHostedService<MarketSyncService>();
|
services.AddHostedService<MarketSyncService>();
|
||||||
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<MullvadVpnService>());
|
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<MullvadVpnService>());
|
||||||
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<ThreemaService>());
|
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<ThreemaService>());
|
||||||
|
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<WatchdogHeartbeatService>());
|
||||||
services.AddSingleton<PolyTraderSharp.Ui.ShellUiHost>();
|
services.AddSingleton<PolyTraderSharp.Ui.ShellUiHost>();
|
||||||
services.AddTransient<PolyTraderSharp.Ui.LauncherForm>();
|
services.AddTransient<PolyTraderSharp.Ui.LauncherForm>();
|
||||||
}).Build();
|
}).Build();
|
||||||
|
|
||||||
// F1 (Sicherheit): Master-Key VOR jeder Credential-Entschlüsselung (Hydration) laden.
|
// (Master-Key wurde bereits VOR dem Host-Build geladen – siehe ConfigureSecretProtection(bootLog) oben.)
|
||||||
// Quelle: Umgebungsvariable POLYTRADER_MASTER_KEY, sonst gitignorierte master.key im App-Ordner.
|
|
||||||
ConfigureSecretProtection(AppHost.Services.GetRequiredService<TerminalLogger>());
|
|
||||||
|
|
||||||
// F5 (Sicherheit): warnen, wenn die (remote) DB-Verbindung keine TLS-Erzwingung hat.
|
// F5 (Sicherheit): warnen, wenn die (remote) DB-Verbindung keine TLS-Erzwingung hat.
|
||||||
WarnIfDbTlsNotEnforced(AppHost.Services);
|
WarnIfDbTlsNotEnforced(AppHost.Services);
|
||||||
@@ -212,7 +233,8 @@ internal static class Program
|
|||||||
viewServices.GetRequiredService<MullvadVpnService>(),
|
viewServices.GetRequiredService<MullvadVpnService>(),
|
||||||
viewServices.GetRequiredService<TerminalLogger>(),
|
viewServices.GetRequiredService<TerminalLogger>(),
|
||||||
viewServices.GetRequiredService<PolyTrader.Core.Persistence.IAccountRepository>(),
|
viewServices.GetRequiredService<PolyTrader.Core.Persistence.IAccountRepository>(),
|
||||||
viewServices.GetRequiredService<TradingState>());
|
viewServices.GetRequiredService<TradingState>(),
|
||||||
|
viewServices.GetRequiredService<WatchdogHeartbeatService>());
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -533,6 +555,19 @@ internal static class Program
|
|||||||
catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] {id}: {ex.GetType().Name}: {ex.Message}"); }
|
catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] {id}: {ex.GetType().Name}: {ex.Message}"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// B.4: Lizenzdialog headless konstruieren (kein Netz, keine Message-Loop) – fängt
|
||||||
|
// Designer-/Konstruktionsregressionen ab.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var licClient = new LicenseLabrador.Client.LicenseClient(PolyTraderSharp.Licensing.LicenseGate.BuildConfig());
|
||||||
|
var dummy = new LicenseLabrador.Client.LicenseResult(
|
||||||
|
LicenseLabrador.Client.LicenseState.NoLicense, null, null, "Smoke-Test", string.Empty);
|
||||||
|
using (var licDlg = new PolyTraderSharp.Ui.LicenseDialog(licClient, string.Empty, dummy, startupContext: true))
|
||||||
|
using (var licDlgManage = new PolyTraderSharp.Ui.LicenseDialog(licClient, string.Empty, null, startupContext: false))
|
||||||
|
Console.WriteLine("[OK] license.dialog konstruiert (Start- und Verwalten-Modus)");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] license.dialog: {ex.GetType().Name}: {ex.Message}"); }
|
||||||
|
|
||||||
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
|
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
|
||||||
return failures;
|
return failures;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+157
@@ -0,0 +1,157 @@
|
|||||||
|
namespace PolyTraderSharp.Ui
|
||||||
|
{
|
||||||
|
partial class LicenseDialog
|
||||||
|
{
|
||||||
|
/// <summary>Erforderliche Designer-Variable.</summary>
|
||||||
|
private System.ComponentModel.IContainer components = null;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing && (components != null))
|
||||||
|
{
|
||||||
|
components.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Vom Windows Form-Designer generierter Code
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
lblHeadline = new System.Windows.Forms.Label();
|
||||||
|
lblStatus = new System.Windows.Forms.Label();
|
||||||
|
lblHwCaption = new System.Windows.Forms.Label();
|
||||||
|
txtHardwareId = new System.Windows.Forms.TextBox();
|
||||||
|
btnCopyHwId = new System.Windows.Forms.Button();
|
||||||
|
lblKeyCaption = new System.Windows.Forms.Label();
|
||||||
|
txtKey = new System.Windows.Forms.TextBox();
|
||||||
|
btnValidate = new System.Windows.Forms.Button();
|
||||||
|
lblResult = new System.Windows.Forms.Label();
|
||||||
|
btnLimited = new System.Windows.Forms.Button();
|
||||||
|
SuspendLayout();
|
||||||
|
//
|
||||||
|
// lblHeadline
|
||||||
|
//
|
||||||
|
lblHeadline.AutoSize = true;
|
||||||
|
lblHeadline.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold);
|
||||||
|
lblHeadline.Location = new System.Drawing.Point(16, 14);
|
||||||
|
lblHeadline.Name = "lblHeadline";
|
||||||
|
lblHeadline.Size = new System.Drawing.Size(316, 17);
|
||||||
|
lblHeadline.TabIndex = 0;
|
||||||
|
lblHeadline.Text = "Für PolyTrader wird eine gültige Lizenz benötigt.";
|
||||||
|
//
|
||||||
|
// lblStatus
|
||||||
|
//
|
||||||
|
lblStatus.Location = new System.Drawing.Point(16, 40);
|
||||||
|
lblStatus.Name = "lblStatus";
|
||||||
|
lblStatus.Size = new System.Drawing.Size(504, 34);
|
||||||
|
lblStatus.TabIndex = 1;
|
||||||
|
lblStatus.Text = "Status:";
|
||||||
|
//
|
||||||
|
// lblHwCaption
|
||||||
|
//
|
||||||
|
lblHwCaption.AutoSize = true;
|
||||||
|
lblHwCaption.Location = new System.Drawing.Point(16, 82);
|
||||||
|
lblHwCaption.Name = "lblHwCaption";
|
||||||
|
lblHwCaption.Size = new System.Drawing.Size(214, 15);
|
||||||
|
lblHwCaption.TabIndex = 2;
|
||||||
|
lblHwCaption.Text = "Hardware-ID dieses Rechners (für die Aktivierung):";
|
||||||
|
//
|
||||||
|
// txtHardwareId
|
||||||
|
//
|
||||||
|
txtHardwareId.Location = new System.Drawing.Point(16, 100);
|
||||||
|
txtHardwareId.Name = "txtHardwareId";
|
||||||
|
txtHardwareId.ReadOnly = true;
|
||||||
|
txtHardwareId.Size = new System.Drawing.Size(400, 23);
|
||||||
|
txtHardwareId.TabIndex = 3;
|
||||||
|
//
|
||||||
|
// btnCopyHwId
|
||||||
|
//
|
||||||
|
btnCopyHwId.Location = new System.Drawing.Point(424, 99);
|
||||||
|
btnCopyHwId.Name = "btnCopyHwId";
|
||||||
|
btnCopyHwId.Size = new System.Drawing.Size(96, 25);
|
||||||
|
btnCopyHwId.TabIndex = 4;
|
||||||
|
btnCopyHwId.Text = "Kopieren";
|
||||||
|
btnCopyHwId.UseVisualStyleBackColor = true;
|
||||||
|
//
|
||||||
|
// lblKeyCaption
|
||||||
|
//
|
||||||
|
lblKeyCaption.AutoSize = true;
|
||||||
|
lblKeyCaption.Location = new System.Drawing.Point(16, 136);
|
||||||
|
lblKeyCaption.Name = "lblKeyCaption";
|
||||||
|
lblKeyCaption.Size = new System.Drawing.Size(96, 15);
|
||||||
|
lblKeyCaption.TabIndex = 5;
|
||||||
|
lblKeyCaption.Text = "Lizenzschlüssel:";
|
||||||
|
//
|
||||||
|
// txtKey
|
||||||
|
//
|
||||||
|
txtKey.CharacterCasing = System.Windows.Forms.CharacterCasing.Upper;
|
||||||
|
txtKey.Location = new System.Drawing.Point(16, 154);
|
||||||
|
txtKey.Name = "txtKey";
|
||||||
|
txtKey.Size = new System.Drawing.Size(400, 23);
|
||||||
|
txtKey.TabIndex = 6;
|
||||||
|
//
|
||||||
|
// btnValidate
|
||||||
|
//
|
||||||
|
btnValidate.Location = new System.Drawing.Point(424, 153);
|
||||||
|
btnValidate.Name = "btnValidate";
|
||||||
|
btnValidate.Size = new System.Drawing.Size(96, 25);
|
||||||
|
btnValidate.TabIndex = 7;
|
||||||
|
btnValidate.Text = "Validieren";
|
||||||
|
btnValidate.UseVisualStyleBackColor = true;
|
||||||
|
//
|
||||||
|
// lblResult
|
||||||
|
//
|
||||||
|
lblResult.Location = new System.Drawing.Point(16, 190);
|
||||||
|
lblResult.Name = "lblResult";
|
||||||
|
lblResult.Size = new System.Drawing.Size(504, 40);
|
||||||
|
lblResult.TabIndex = 8;
|
||||||
|
//
|
||||||
|
// btnLimited
|
||||||
|
//
|
||||||
|
btnLimited.Location = new System.Drawing.Point(16, 236);
|
||||||
|
btnLimited.Name = "btnLimited";
|
||||||
|
btnLimited.Size = new System.Drawing.Size(230, 27);
|
||||||
|
btnLimited.TabIndex = 9;
|
||||||
|
btnLimited.Text = "Ohne Lizenz starten (eingeschränkt)";
|
||||||
|
btnLimited.UseVisualStyleBackColor = true;
|
||||||
|
//
|
||||||
|
// LicenseDialog
|
||||||
|
//
|
||||||
|
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||||
|
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||||
|
ClientSize = new System.Drawing.Size(536, 278);
|
||||||
|
Controls.Add(btnLimited);
|
||||||
|
Controls.Add(lblResult);
|
||||||
|
Controls.Add(btnValidate);
|
||||||
|
Controls.Add(txtKey);
|
||||||
|
Controls.Add(lblKeyCaption);
|
||||||
|
Controls.Add(btnCopyHwId);
|
||||||
|
Controls.Add(txtHardwareId);
|
||||||
|
Controls.Add(lblHwCaption);
|
||||||
|
Controls.Add(lblStatus);
|
||||||
|
Controls.Add(lblHeadline);
|
||||||
|
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
Name = "LicenseDialog";
|
||||||
|
StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
|
||||||
|
Text = "PolyTrader – Lizenz";
|
||||||
|
ResumeLayout(false);
|
||||||
|
PerformLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
private System.Windows.Forms.Label lblHeadline;
|
||||||
|
private System.Windows.Forms.Label lblStatus;
|
||||||
|
private System.Windows.Forms.Label lblHwCaption;
|
||||||
|
private System.Windows.Forms.TextBox txtHardwareId;
|
||||||
|
private System.Windows.Forms.Button btnCopyHwId;
|
||||||
|
private System.Windows.Forms.Label lblKeyCaption;
|
||||||
|
private System.Windows.Forms.TextBox txtKey;
|
||||||
|
private System.Windows.Forms.Button btnValidate;
|
||||||
|
private System.Windows.Forms.Label lblResult;
|
||||||
|
private System.Windows.Forms.Button btnLimited;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using LicenseLabrador.Client;
|
||||||
|
using PolyTraderSharp.Licensing;
|
||||||
|
|
||||||
|
namespace PolyTraderSharp.Ui
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Modaler Lizenzdialog beim Programmstart, wenn keine gültige Lizenz vorliegt. Zeigt die
|
||||||
|
/// Hardware-ID (kopierbar, für die Aktivierung im Lizenz-Admin), nimmt einen Lizenzschlüssel
|
||||||
|
/// entgegen und validiert ihn gegen den Lizenzserver. Bei Erfolg werden <see cref="ValidatedKey"/>
|
||||||
|
/// und <see cref="ValidatedResult"/> gesetzt; der Aufrufer (LicenseGate) speichert den Schlüssel.
|
||||||
|
/// Ohne gültige Lizenz kann der Nutzer eingeschränkt (nur Core-Shell) weiterstarten.
|
||||||
|
/// </summary>
|
||||||
|
public partial class LicenseDialog : Form
|
||||||
|
{
|
||||||
|
private readonly LicenseClient _client;
|
||||||
|
|
||||||
|
/// <summary>Nur gesetzt, wenn im Dialog erfolgreich validiert wurde.</summary>
|
||||||
|
public LicenseResult? ValidatedResult { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Der erfolgreich validierte Schlüssel (für die verschlüsselte Ablage).</summary>
|
||||||
|
public string? ValidatedKey { get; private set; }
|
||||||
|
|
||||||
|
/// <param name="startupContext">
|
||||||
|
/// true = Aufruf beim Programmstart (keine gültige Lizenz): der Abbruch-Button bietet
|
||||||
|
/// „eingeschränkt starten". false = Aufruf aus den Einstellungen (App läuft bereits): der
|
||||||
|
/// Button heißt „Schließen".
|
||||||
|
/// </param>
|
||||||
|
public LicenseDialog(LicenseClient client, string prefillKey, LicenseResult? initialResult, bool startupContext = true)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
txtHardwareId.Text = SafeHardwareId(client);
|
||||||
|
txtKey.Text = prefillKey ?? string.Empty;
|
||||||
|
|
||||||
|
if (initialResult != null)
|
||||||
|
{
|
||||||
|
lblStatus.Text = "Status: " + LicenseGate.Describe(initialResult) +
|
||||||
|
(string.IsNullOrEmpty(initialResult.Message) ? string.Empty : $" – {initialResult.Message}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lblStatus.Text = "Status: noch nicht geprüft – bitte „Validieren“ klicken.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startupContext)
|
||||||
|
{
|
||||||
|
lblHeadline.Text = "Für PolyTrader wird eine gültige Lizenz benötigt.";
|
||||||
|
btnLimited.Text = "Ohne Lizenz starten (eingeschränkt)";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lblHeadline.Text = "Lizenz prüfen / hinterlegen";
|
||||||
|
btnLimited.Text = "Schließen";
|
||||||
|
}
|
||||||
|
|
||||||
|
btnValidate.Click += (_, _) => Validate();
|
||||||
|
btnCopyHwId.Click += (_, _) => CopyHardwareId();
|
||||||
|
btnLimited.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); };
|
||||||
|
AcceptButton = btnValidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeHardwareId(LicenseClient client)
|
||||||
|
{
|
||||||
|
try { return client.GetHardwareId(); }
|
||||||
|
catch { return "(nicht ermittelbar)"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Validate()
|
||||||
|
{
|
||||||
|
string key = txtKey.Text.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
lblResult.ForeColor = Color.Firebrick;
|
||||||
|
lblResult.Text = "Bitte einen Lizenzschlüssel eingeben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnValidate.Enabled = false;
|
||||||
|
Cursor = Cursors.WaitCursor;
|
||||||
|
lblResult.ForeColor = SystemColors.ControlText;
|
||||||
|
lblResult.Text = "Prüfe Lizenz …";
|
||||||
|
lblResult.Refresh();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Blockierend: das SDK nutzt durchgängig ConfigureAwait(false) → kein Deadlock auf dem
|
||||||
|
// UI-Thread; der kurze Freeze (max. HTTP-Timeout) ist im modalen Startdialog vertretbar
|
||||||
|
// und vermeidet jede Abhängigkeit vom WinForms-SynchronizationContext vor Application.Run.
|
||||||
|
LicenseResult result = _client.ValidateAsync(key).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
if (result.IsUsable)
|
||||||
|
{
|
||||||
|
ValidatedResult = result;
|
||||||
|
ValidatedKey = key;
|
||||||
|
lblResult.ForeColor = Color.Green;
|
||||||
|
lblResult.Text = "Lizenz gültig – PolyTrader startet.";
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lblResult.ForeColor = Color.Firebrick;
|
||||||
|
string extra = string.IsNullOrEmpty(result.Message) ? string.Empty : $" ({result.Message})";
|
||||||
|
lblResult.Text = "Nicht gültig: " + LicenseGate.Describe(result) + extra;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lblResult.ForeColor = Color.Firebrick;
|
||||||
|
lblResult.Text = "Fehler bei der Prüfung: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Cursor = Cursors.Default;
|
||||||
|
btnValidate.Enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopyHardwareId()
|
||||||
|
{
|
||||||
|
try { Clipboard.SetText(txtHardwareId.Text); }
|
||||||
|
catch { /* Zwischenablage kann kurzzeitig belegt sein – kein harter Fehler */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+31
-1
@@ -26,6 +26,9 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
btn_loadsettings = new ToolStripButton();
|
btn_loadsettings = new ToolStripButton();
|
||||||
btnGenMasterKey = new ToolStripButton();
|
btnGenMasterKey = new ToolStripButton();
|
||||||
btnSetOpenRouterKey = new ToolStripButton();
|
btnSetOpenRouterKey = new ToolStripButton();
|
||||||
|
btnSetWatchdogToken = new ToolStripButton();
|
||||||
|
btnTestWatchdog = new ToolStripButton();
|
||||||
|
btnSetLicenseKey = new ToolStripButton();
|
||||||
tabPage2 = new TabPage();
|
tabPage2 = new TabPage();
|
||||||
pgAccount = new PropertyGrid();
|
pgAccount = new PropertyGrid();
|
||||||
dgvAccounts = new DataGridView();
|
dgvAccounts = new DataGridView();
|
||||||
@@ -74,7 +77,7 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
// toolStrip2
|
// toolStrip2
|
||||||
//
|
//
|
||||||
toolStrip2.ImageScalingSize = new Size(24, 24);
|
toolStrip2.ImageScalingSize = new Size(24, 24);
|
||||||
toolStrip2.Items.AddRange(new ToolStripItem[] { btn_save, btn_loadsettings, btnGenMasterKey, btnSetOpenRouterKey });
|
toolStrip2.Items.AddRange(new ToolStripItem[] { btn_save, btn_loadsettings, btnGenMasterKey, btnSetOpenRouterKey, btnSetWatchdogToken, btnTestWatchdog, btnSetLicenseKey });
|
||||||
toolStrip2.Location = new Point(3, 3);
|
toolStrip2.Location = new Point(3, 3);
|
||||||
toolStrip2.Name = "toolStrip2";
|
toolStrip2.Name = "toolStrip2";
|
||||||
toolStrip2.Size = new Size(1171, 34);
|
toolStrip2.Size = new Size(1171, 34);
|
||||||
@@ -112,6 +115,30 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
btnSetOpenRouterKey.Size = new Size(220, 29);
|
btnSetOpenRouterKey.Size = new Size(220, 29);
|
||||||
btnSetOpenRouterKey.Text = "OpenRouter-Key setzen …";
|
btnSetOpenRouterKey.Text = "OpenRouter-Key setzen …";
|
||||||
btnSetOpenRouterKey.ToolTipText = "Speichert den OpenRouter-API-Key für den Supervisor (gitignorierte Datei openrouter.key).";
|
btnSetOpenRouterKey.ToolTipText = "Speichert den OpenRouter-API-Key für den Supervisor (gitignorierte Datei openrouter.key).";
|
||||||
|
//
|
||||||
|
// btnSetWatchdogToken
|
||||||
|
//
|
||||||
|
btnSetWatchdogToken.DisplayStyle = ToolStripItemDisplayStyle.Text;
|
||||||
|
btnSetWatchdogToken.Name = "btnSetWatchdogToken";
|
||||||
|
btnSetWatchdogToken.Size = new Size(220, 29);
|
||||||
|
btnSetWatchdogToken.Text = "Watchdog-Token setzen …";
|
||||||
|
btnSetWatchdogToken.ToolTipText = "Speichert den Watchdog-Agent-Token (maskierte Eingabe, bei gesetztem Master-Key verschlüsselt).";
|
||||||
|
//
|
||||||
|
// btnTestWatchdog
|
||||||
|
//
|
||||||
|
btnTestWatchdog.DisplayStyle = ToolStripItemDisplayStyle.Text;
|
||||||
|
btnTestWatchdog.Name = "btnTestWatchdog";
|
||||||
|
btnTestWatchdog.Size = new Size(210, 29);
|
||||||
|
btnTestWatchdog.Text = "Test-Heartbeat senden";
|
||||||
|
btnTestWatchdog.ToolTipText = "Sendet sofort einen Heartbeat an den konfigurierten Watchdog-Server und meldet das Ergebnis.";
|
||||||
|
//
|
||||||
|
// btnSetLicenseKey
|
||||||
|
//
|
||||||
|
btnSetLicenseKey.DisplayStyle = ToolStripItemDisplayStyle.Text;
|
||||||
|
btnSetLicenseKey.Name = "btnSetLicenseKey";
|
||||||
|
btnSetLicenseKey.Size = new Size(210, 29);
|
||||||
|
btnSetLicenseKey.Text = "Lizenz prüfen / setzen …";
|
||||||
|
btnSetLicenseKey.ToolTipText = "Öffnet den Lizenzdialog (Hardware-ID + Server-Validierung). Gültiger Schlüssel wird gespeichert (bei Master-Key verschlüsselt).";
|
||||||
//
|
//
|
||||||
// tabPage2
|
// tabPage2
|
||||||
//
|
//
|
||||||
@@ -207,6 +234,9 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
private ToolStripButton btn_loadsettings;
|
private ToolStripButton btn_loadsettings;
|
||||||
private ToolStripButton btnGenMasterKey;
|
private ToolStripButton btnGenMasterKey;
|
||||||
private ToolStripButton btnSetOpenRouterKey;
|
private ToolStripButton btnSetOpenRouterKey;
|
||||||
|
private ToolStripButton btnSetWatchdogToken;
|
||||||
|
private ToolStripButton btnTestWatchdog;
|
||||||
|
private ToolStripButton btnSetLicenseKey;
|
||||||
private ToolStrip toolStripAccounts;
|
private ToolStrip toolStripAccounts;
|
||||||
private ToolStripButton btnAccNew;
|
private ToolStripButton btnAccNew;
|
||||||
private ToolStripButton btnAccDelete;
|
private ToolStripButton btnAccDelete;
|
||||||
|
|||||||
+161
-1
@@ -3,8 +3,10 @@ using System.ComponentModel;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using PolyTrader.Core.Persistence;
|
using PolyTrader.Core.Persistence;
|
||||||
|
using PolyTrader.Core.Security;
|
||||||
using PolyTraderSharp.Models;
|
using PolyTraderSharp.Models;
|
||||||
using PolyTraderSharp.Services;
|
using PolyTraderSharp.Services;
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
private ServerSettings _settings = new();
|
private ServerSettings _settings = new();
|
||||||
private ThreemaService? _threema;
|
private ThreemaService? _threema;
|
||||||
private MullvadVpnService? _vpn;
|
private MullvadVpnService? _vpn;
|
||||||
|
private WatchdogHeartbeatService? _watchdog;
|
||||||
private TerminalLogger? _logger;
|
private TerminalLogger? _logger;
|
||||||
|
|
||||||
private IAccountRepository? _accountRepo;
|
private IAccountRepository? _accountRepo;
|
||||||
@@ -37,6 +40,9 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
btn_loadsettings.Click += (_, _) => Reload();
|
btn_loadsettings.Click += (_, _) => Reload();
|
||||||
btnGenMasterKey.Click += (_, _) => GenerateMasterKey();
|
btnGenMasterKey.Click += (_, _) => GenerateMasterKey();
|
||||||
btnSetOpenRouterKey.Click += (_, _) => SetOpenRouterKey();
|
btnSetOpenRouterKey.Click += (_, _) => SetOpenRouterKey();
|
||||||
|
btnSetWatchdogToken.Click += (_, _) => SetWatchdogToken();
|
||||||
|
btnTestWatchdog.Click += async (_, _) => await TestWatchdogAsync();
|
||||||
|
btnSetLicenseKey.Click += (_, _) => SetLicenseKey();
|
||||||
UpdateMasterKeyButtonState();
|
UpdateMasterKeyButtonState();
|
||||||
|
|
||||||
btnAccNew.Click += (_, _) => AddAccount();
|
btnAccNew.Click += (_, _) => AddAccount();
|
||||||
@@ -52,13 +58,14 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(ThreemaService threema, MullvadVpnService vpn, TerminalLogger logger,
|
public void Initialize(ThreemaService threema, MullvadVpnService vpn, TerminalLogger logger,
|
||||||
IAccountRepository accountRepo, TradingState state)
|
IAccountRepository accountRepo, TradingState state, WatchdogHeartbeatService? watchdog = null)
|
||||||
{
|
{
|
||||||
_threema = threema;
|
_threema = threema;
|
||||||
_vpn = vpn;
|
_vpn = vpn;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_accountRepo = accountRepo;
|
_accountRepo = accountRepo;
|
||||||
_state = state;
|
_state = state;
|
||||||
|
_watchdog = watchdog;
|
||||||
|
|
||||||
Reload();
|
Reload();
|
||||||
LoadAccounts();
|
LoadAccounts();
|
||||||
@@ -79,6 +86,7 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
_settings.Save(SettingsPath);
|
_settings.Save(SettingsPath);
|
||||||
_threema?.ReloadSettings();
|
_threema?.ReloadSettings();
|
||||||
_vpn?.ReloadSettings();
|
_vpn?.ReloadSettings();
|
||||||
|
_watchdog?.ReloadSettings();
|
||||||
_logger?.Info("Server-Einstellungen gespeichert und Services neu geladen.");
|
_logger?.Info("Server-Einstellungen gespeichert und Services neu geladen.");
|
||||||
MessageBox.Show("Server-Einstellungen gespeichert.", "Erfolg",
|
MessageBox.Show("Server-Einstellungen gespeichert.", "Erfolg",
|
||||||
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
@@ -135,6 +143,158 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
dgvAccounts.Refresh();
|
dgvAccounts.Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Watchdog =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setzt/entfernt den Watchdog-Agent-Token über eine maskierte Eingabe – damit das Secret
|
||||||
|
/// nicht offen im PropertyGrid steht und die server_settings.xml nicht per Hand bearbeitet
|
||||||
|
/// werden muss. Ist ein Master-Key gesetzt, wird der Token verschlüsselt abgelegt.
|
||||||
|
/// Wird sofort gespeichert, damit der Test-Button und der laufende Dienst ihn direkt nutzen.
|
||||||
|
/// </summary>
|
||||||
|
private void SetWatchdogToken()
|
||||||
|
{
|
||||||
|
bool exists = !string.IsNullOrWhiteSpace(_settings.WatchdogToken);
|
||||||
|
string? token = PromptForSecret("Watchdog Agent-Token",
|
||||||
|
"Agent-Token aus dem Watchdog-Admin eingeben (Header X-Watchdog-Key).\n" +
|
||||||
|
"Wird in der gitignorierten server_settings.xml gespeichert (leer = entfernen).\n" +
|
||||||
|
(exists ? $"Aktuell: {_settings.WatchdogTokenStatus}." : "Aktuell ist KEIN Token gesetzt."));
|
||||||
|
if (token == null) return; // Abbruch
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
_settings.WatchdogToken = "";
|
||||||
|
_settings.Save(SettingsPath);
|
||||||
|
_watchdog?.ReloadSettings();
|
||||||
|
propertyGrid.Refresh();
|
||||||
|
_logger?.Info("Watchdog-Agent-Token entfernt.");
|
||||||
|
MessageBox.Show("Watchdog-Token entfernt.", "Watchdog",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protect() gibt ohne Master-Key den Klartext unverändert zurück – kein stiller
|
||||||
|
// Sicherheitsverlust, der Nutzer wird darauf hingewiesen.
|
||||||
|
_settings.WatchdogToken = SecretProtection.Protect(token.Trim());
|
||||||
|
_settings.Save(SettingsPath);
|
||||||
|
_watchdog?.ReloadSettings();
|
||||||
|
propertyGrid.Refresh();
|
||||||
|
|
||||||
|
bool encrypted = SecretProtection.IsEncrypted(_settings.WatchdogToken);
|
||||||
|
_logger?.Info($"Watchdog-Agent-Token gespeichert ({(encrypted ? "verschlüsselt" : "Klartext")}).");
|
||||||
|
MessageBox.Show(
|
||||||
|
"Watchdog-Token gespeichert und sofort aktiv.\n\n" +
|
||||||
|
(encrypted
|
||||||
|
? "Der Token liegt mit dem Master-Key verschlüsselt in server_settings.xml."
|
||||||
|
: "Hinweis: Es ist kein Master-Key gesetzt – der Token liegt im Klartext in der " +
|
||||||
|
"(gitignorierten) server_settings.xml. Mit „Master-Key erzeugen\" kannst du das ändern."),
|
||||||
|
"Watchdog", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Fehler beim Speichern des Watchdog-Tokens: {ex.Message}",
|
||||||
|
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sendet einen Heartbeat mit den GESPEICHERTEN Einstellungen und meldet das Ergebnis.
|
||||||
|
/// Ungespeicherte Änderungen im PropertyGrid wirken bewusst nicht – sonst würde ein
|
||||||
|
/// erfolgreicher Test eine Konfiguration bestätigen, die so nicht auf der Platte liegt.
|
||||||
|
/// </summary>
|
||||||
|
private async Task TestWatchdogAsync()
|
||||||
|
{
|
||||||
|
if (_watchdog == null)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Der Watchdog-Dienst ist nicht verfügbar.", "Watchdog",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnTestWatchdog.Enabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _watchdog.SendHeartbeatAsync(
|
||||||
|
"ok", "Test-Heartbeat aus dem PolyTrader-Settings-Fenster");
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
_logger?.Info($"✅ Watchdog-Test-Heartbeat erfolgreich gesendet ({result.Detail}).");
|
||||||
|
MessageBox.Show(
|
||||||
|
$"Test-Heartbeat erfolgreich gesendet ({result.Detail}).\n\n" +
|
||||||
|
"Der Monitor sollte im Watchdog-Dashboard jetzt auf 'up' stehen.",
|
||||||
|
"Watchdog", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger?.Warning($"⚠️ Watchdog-Test-Heartbeat fehlgeschlagen: {result.Detail}");
|
||||||
|
MessageBox.Show(
|
||||||
|
$"Test-Heartbeat fehlgeschlagen:\n\n{result.Detail}\n\n" +
|
||||||
|
"Bitte URL, Agent-Token und Source prüfen und die Einstellungen vorher speichern.",
|
||||||
|
"Watchdog", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
btnTestWatchdog.Enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Lizenz (LicenseLabrador) =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Öffnet denselben Lizenzdialog wie der Programmstart (Hardware-ID + Server-Validierung),
|
||||||
|
/// nur im Verwalten-Modus. Ein erfolgreich validierter Schlüssel wird – bei gesetztem
|
||||||
|
/// Master-Key verschlüsselt – in server_settings.xml gespeichert und gilt ab dem nächsten Start.
|
||||||
|
/// </summary>
|
||||||
|
private void SetLicenseKey()
|
||||||
|
{
|
||||||
|
string current = string.Empty;
|
||||||
|
try { current = SecretProtection.Unprotect(_settings.LicenseKey); }
|
||||||
|
catch { /* nicht lesbar → als leer behandeln */ }
|
||||||
|
|
||||||
|
LicenseLabrador.Client.LicenseClient client;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
client = new LicenseLabrador.Client.LicenseClient(PolyTraderSharp.Licensing.LicenseGate.BuildConfig());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Lizenzprüfung nicht verfügbar: {ex.Message}", "Lizenz",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dlg = new LicenseDialog(client, current, initialResult: null, startupContext: false);
|
||||||
|
dlg.ShowDialog(this);
|
||||||
|
|
||||||
|
if (dlg.ValidatedResult == null || !dlg.ValidatedResult.IsUsable || string.IsNullOrWhiteSpace(dlg.ValidatedKey))
|
||||||
|
return; // abgebrochen oder nicht validiert – nichts speichern
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_settings.LicenseKey = SecretProtection.Protect(dlg.ValidatedKey!.Trim());
|
||||||
|
_settings.Save(SettingsPath);
|
||||||
|
propertyGrid.Refresh();
|
||||||
|
|
||||||
|
bool encrypted = SecretProtection.IsEncrypted(_settings.LicenseKey);
|
||||||
|
_logger?.Info($"Lizenz validiert und gespeichert ({(encrypted ? "verschlüsselt" : "Klartext")}).");
|
||||||
|
MessageBox.Show(
|
||||||
|
"Lizenz validiert und gespeichert.\n\n" +
|
||||||
|
(encrypted
|
||||||
|
? "Der Schlüssel liegt mit dem Master-Key verschlüsselt in server_settings.xml."
|
||||||
|
: "Hinweis: Es ist kein Master-Key gesetzt – der Schlüssel liegt im Klartext in der " +
|
||||||
|
"(gitignorierten) server_settings.xml."),
|
||||||
|
"Lizenz", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Fehler beim Speichern des Lizenzschlüssels: {ex.Message}",
|
||||||
|
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Master-Key (at-rest-Verschlüsselung, F1) =====
|
// ===== Master-Key (at-rest-Verschlüsselung, F1) =====
|
||||||
|
|
||||||
/// <summary>Pfad der Master-Key-Datei – identisch zu Program.cs (App-Ordner, gitignored).</summary>
|
/// <summary>Pfad der Master-Key-Datei – identisch zu Program.cs (App-Ordner, gitignored).</summary>
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
# Umsetzungsplan: Watchdog & LicenseLabrador in PolyTrader einbinden
|
||||||
|
|
||||||
|
> Stand: 2026-07-28 · Grundlage: Review von `J:\Softwareprojekte\WatchDog` und
|
||||||
|
> `J:\Softwareprojekte\LicenseLabrador`.
|
||||||
|
> **Reihenfolge ist bindend:** Teil A (Blocker in den Fremdprojekten) vor Teil B (Einbindung).
|
||||||
|
> Wer B ohne A baut, bindet bekannte Lücken fest in den Produktivbetrieb ein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Kurzfassung der Bewertung
|
||||||
|
|
||||||
|
**LicenseLabrador (Server)** ist handwerklich gut: keine Secrets im Repo, Argon2id, CSRF in allen
|
||||||
|
Admin-Formularen, durchgängig Prepared Statements, Ed25519 über den Base64-Payload (umgeht das
|
||||||
|
Kanonikalisierungsproblem elegant), Nonce-Reflexion, Rate-Limit plus Failed-Key-Sperre, Audit-Log.
|
||||||
|
Der Server kann so bleiben. Die Schwächen liegen im **C#-Client**.
|
||||||
|
|
||||||
|
**Watchdog** ist funktional weit, hat aber Produktiv-Secrets im Git, ein hartkodiertes
|
||||||
|
Admin-Passwort und eine Autorisierung, die jeden Agent-Token wie einen Admin behandelt. Vor der
|
||||||
|
Anbindung von PolyTrader muss dort aufgeräumt werden.
|
||||||
|
|
||||||
|
Kritischster Einzelbefund für **unseren** Betrieb: die Hardware-ID von LicenseLabrador ist auf
|
||||||
|
einem Rechner mit Mullvad-VPN nicht stabil (siehe L-1). Ohne Fix sperrt PolyTrader sich selbst aus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0a. Nachprüfung vom 29.07.2026
|
||||||
|
|
||||||
|
Beide Projekte wurden nach dem ersten Review nachgebessert. Stand jetzt:
|
||||||
|
|
||||||
|
**LicenseLabrador — integrationsbereit.** Die vier Blocker sind behoben (Änderungen liegen noch
|
||||||
|
uncommitted im Arbeitsverzeichnis, sollten also committet werden):
|
||||||
|
|
||||||
|
- **L-1 erledigt.** `HardwareId` nutzt jetzt `MachineGuid` (bzw. `/etc/machine-id`) als
|
||||||
|
Primärquelle, `ProcessorCount` ist raus, der MAC-Fallback filtert virtuelle Adapter
|
||||||
|
(Mullvad, WireGuard, Docker, Hyper-V, WSL) und sortiert deterministisch. Damit ändert ein
|
||||||
|
VPN-Wechsel die Hardware-ID nicht mehr — die Selbst-Aussperrung ist vom Tisch.
|
||||||
|
- **L-2 erledigt.** Der gecachte Envelope wird gegen die lokale `hardware_id` geprüft; kopierte
|
||||||
|
`state.dat`-Dateien werden verworfen.
|
||||||
|
- **L-3 erledigt.** `signatureTampered` liefert jetzt `TamperSuspected` statt still in die
|
||||||
|
Offline-Kulanz zu fallen.
|
||||||
|
- **L-5 erledigt.** Das HMAC-Secret wird aus der Hardware-ID abgeleitet, und `docs/SECURITY.md`
|
||||||
|
führt es korrekt als Laufzeit-Schutz gegen Property-Patching, nicht als kryptografische
|
||||||
|
Sicherheit.
|
||||||
|
- Das SDK baut fehlerfrei (eine `CS8604`-Warnung in `LicenseClient.cs:47`).
|
||||||
|
- **Offen:** L-4 (gecachte Endpoints haben weiter Vorrang vor der Konfiguration, `http://` wird
|
||||||
|
weiter akzeptiert) sowie L-6 bis L-13. L-4 entschärfen wir auf unserer Seite, indem wir
|
||||||
|
ausschließlich `https://`-Endpoints konfigurieren — da die Antwort signaturgeprüft ist, kann ein
|
||||||
|
untergeschobener Endpoint keine gültige Lizenz erfinden, sondern uns höchstens in die Offline-
|
||||||
|
Kulanz drängen. Kein Blocker mehr, aber Nachtrag wert.
|
||||||
|
|
||||||
|
**Watchdog — integrationsbereit mit zwei Auflagen.** Commits `1e453f8` und `404e184` haben die
|
||||||
|
Blocker abgearbeitet; nachgeprüft am 29.07.2026:
|
||||||
|
|
||||||
|
- **Erledigt in `1e453f8`:** W-2 (Backdoor entfernt, nur noch `password_verify`), W-3 (CSRF in
|
||||||
|
`admin.php` und `index.php`), W-4 (alle XSS-Vektoren escaped, inklusive `e.message`), **W-5**
|
||||||
|
(Token-Scoping greift: `validateToken()` wird benutzt, `$requireAdmin()` schützt `monitor/edit`,
|
||||||
|
`monitor/delete`, `icon/upload`), W-6 (Status-API nicht mehr offen), **W-13** (Alarm nur noch
|
||||||
|
bei Zustandswechsel, zusätzlich 120 s Dämpfung pro Quelle und Art).
|
||||||
|
- **W-14 erledigt in `404e184`.** `Evaluator.php` setzt einen Monitor nur noch auf `up`, wenn
|
||||||
|
`last_status === 'ok'` **und** die Metriken unauffällig sind. Zustandsmaschine
|
||||||
|
durchgespielt: dauerhaftes `status=error` bleibt auf `error` (ein Alarm, danach Ruhe);
|
||||||
|
Rückkehr auf `ok` erzeugt genau eine Entwarnung; Absturz → genau ein Down-Alarm;
|
||||||
|
`down` + Rückkehr mit `status=error` → ein `hard_error`. Die Flapping-Schleife ist weg.
|
||||||
|
- **W-22 erledigt.** `escapeHtml` liegt jetzt auf Dateiebene (`index.php:630`) vor allen
|
||||||
|
Verwendungen. Monitore ohne Metriken — unser Fall — brechen im Detail-Fenster nicht mehr ab.
|
||||||
|
- **W-23 erledigt.** `session_start()` steht am Anfang von `api.php`; der Admin-Session-Zweig
|
||||||
|
funktioniert.
|
||||||
|
- **W-24 erledigt.** `display_errors` und Stack-Traces hängen an `WATCHDOG_DEBUG=1`; API-Fehler
|
||||||
|
liefern sauberes JSON ohne interne Details.
|
||||||
|
- **W-1 teilweise erledigt — Auflage 1.** Aus dem getrackten `config/config.php` sind die echten
|
||||||
|
Werte raus (Platzhalter), `config.example.php` ist ergänzt, `config.local.php` war schon
|
||||||
|
gitignoriert und die Merge-Logik ist intakt. **Aber die Secrets stehen weiter in der
|
||||||
|
Git-History:** `git show 1e453f8:config/config.php` liefert DB-Passwort, `shared_key` und
|
||||||
|
`setup_token` im Klartext. Das lässt sich durch Bearbeiten von HEAD nicht heilen — **alle drei
|
||||||
|
müssen rotiert werden** (das FTP-Passwort aus `deploy_config.json` gleich mit, es ist dasselbe).
|
||||||
|
- **Auflage 2 — W-16 (UTC vs. `NOW()`).** Unverändert: `cli/tick.php:39,44,106`,
|
||||||
|
`ProxmoxPoller.php:56,117` und `admin.php:115,157` mischen UTC-Strings aus PHP mit dem
|
||||||
|
`NOW()` der DB-Session. Steht die MySQL-Zeitzone nicht auf UTC, verschieben sich
|
||||||
|
Job-Fälligkeit, Lock-Leases und der Log-Cleanup um den Offset. Entweder in der DB-Session
|
||||||
|
`SET time_zone = '+00:00'` setzen oder `NOW()` durchgängig durch UTC-Parameter ersetzen.
|
||||||
|
Nicht integrationsblockend, aber vor dem Produktivbetrieb zu klären.
|
||||||
|
- **Weiterhin offen, ohne Einfluss auf unsere Anbindung:** W-11 (`api.php:157` gibt im eigenen
|
||||||
|
`catch` weiter `$t->getMessage()` heraus — der neue Handler in `Autoload.php` macht es richtig,
|
||||||
|
wird hier aber umgangen), W-15 (`intervalSeconds` der PS-/Bash-Agents wird weiter ignoriert;
|
||||||
|
unser C#-Client sendet `interval` und ist korrekt), **W-17** (`self_ping` ist weiter ein
|
||||||
|
`echo`-Stub — es gibt also keinen Alarm, wenn der Watchdog selbst stirbt), W-7 bis W-10, W-12,
|
||||||
|
W-18 bis W-21.
|
||||||
|
|
||||||
|
**Konsequenz für die Reihenfolge:** Schritt 1 und 2 sind erledigt. **Teil B kann vollständig
|
||||||
|
beginnen** — B.1 bis B.3 (Heartbeat) und B.4/B.5 (Lizenz) sind nicht mehr blockiert. Parallel
|
||||||
|
abzuarbeiten: Secrets rotieren, W-16, W-17.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil A — Blocker in den Fremdprojekten
|
||||||
|
|
||||||
|
### A.1 Watchdog — Sicherheit
|
||||||
|
|
||||||
|
| # | Befund | Datei | Maßnahme |
|
||||||
|
|---|---|---|---|
|
||||||
|
| W-1 | DB-Passwort, `shared_key`, `setup_token` als Defaults im **getrackten** `config/config.php` (auch in der Git-History) | `config/config.php:15,19,20` | Datei nach `config.php.example` umbenennen (Muster von LicenseLabrador), aus dem Index nehmen, **alle drei Secrets rotieren** |
|
||||||
|
| W-2 | Hartkodiertes Admin-Passwort, vor der DB-Prüfung ausgewertet | `public/admin.php:31` | Ersatzlos entfernen; nur noch `users`-Tabelle + `password_verify` |
|
||||||
|
| W-3 | Kein CSRF-Schutz im Admin — Monitor löschen, Proxmox-Target anlegen, Token widerrufen sind cross-site auslösbar | `public/admin.php` (alle POST-Zweige) | `Csrf`-Klasse aus LicenseLabrador übernehmen (1:1 portierbar) |
|
||||||
|
| W-4 | Stored XSS: Heartbeat-`message` und Disk-Namen landen ungefiltert per `innerHTML` im Dashboard | `public/index.php:707,731,744,770` | `textContent` statt `innerHTML`, oder clientseitig escapen |
|
||||||
|
| W-5 | Jeder gültige Agent-Token autorisiert **jeden** Endpunkt — auch `/api/monitor/delete`, `/api/monitor/edit`, `/api/link`, `/api/icon/upload` | `public/api.php:66-77` | Zwei Berechtigungsstufen trennen: Agent-Token nur für `heartbeat`/`event`; verwaltende Routen nur mit Admin-Session oder `shared_key`. `TokenManager::validateToken()` (prüft die Source-Bindung) wird derzeit **nirgends** aufgerufen — einbinden |
|
||||||
|
| W-6 | `/api/status`, `/api/events`, `/api/lineage` sind bewusst unauthentifiziert und zusätzlich von der Basic-Auth ausgenommen | `public/api.php:60`, `.htaccess:6` | Für uns nicht akzeptabel: „polytrader ist seit 14:03 down" ist eine verwertbare Information. Lesezugriff hinter Session **oder** eigenen Read-Token legen |
|
||||||
|
| W-7 | Installer-Download läuft **vor** dem Login-Check; `{{TOKEN}}`/`{{SOURCE}}` werden ungefiltert in ein PowerShell-Skript interpoliert, das als SYSTEM ausgeführt wird | `public/admin.php:58-84` | Hinter `$isLoggedIn` verschieben, Platzhalter strikt validieren (`[A-Za-z0-9_-]`), Host nicht aus `HTTP_HOST` ableiten |
|
||||||
|
| W-8 | Agent-Token als **maschinenweite** Umgebungsvariable, Default-URL `http://` | `agents/windows/install.ps1.tmpl:104`, `agents/*/watchdog-agent.*:9,4` | Token in eine ACL-geschützte Datei unter `C:\ProgramData\Watchdog`; HTTPS erzwingen |
|
||||||
|
| W-9 | SVG-Upload in denselben Origin, `mkdir(0777)` | `public/api.php:284-296`, `admin.php:240` | SVG entfernen oder als `Content-Disposition: attachment` ausliefern; `0755` |
|
||||||
|
| W-10 | Proxmox: `allowSelfSigned = true` als Default, `cert_fingerprint` nie benutzt, Token-Secret im Klartext in der DB | `src/ProxmoxClient.php:18,76`, `schema:87` | Fingerprint-Pinning umsetzen (steht so in HANDOFF §8), Secret verschlüsseln |
|
||||||
|
| W-11 | `500`-Antwort gibt `$t->getMessage()` an den Client (DB-Fehlerdetails) | `public/api.php:141-144` | Generische Meldung, Details ins `event_log` |
|
||||||
|
| W-12 | FTP-Deploy-Passwort im Klartext, identisch mit DB- und Admin-Passwort | `scripts/deploy_config.json` | Eigenes Passwort, rotieren |
|
||||||
|
|
||||||
|
### A.2 Watchdog — funktionale Fehler
|
||||||
|
|
||||||
|
| # | Befund | Wirkung |
|
||||||
|
|---|---|---|
|
||||||
|
| W-13 | `api.php:120` alarmiert bei **jedem** Heartbeat mit `status=error`/`warning` sofort | Ein dauerhaft fehlerhafter Dienst im 30-s-Takt erzeugt 120 Telegram-Nachrichten pro Stunde. Widerspricht §6 des Umsetzungsplans („nur bei Zustandswechsel") |
|
||||||
|
| W-14 | `upsertHeartbeat` erzwingt `state = IF(state="down","up",…)`, der Evaluator setzt bei frischem Heartbeat zusätzlich `error → up` („recovered") | Ein Dienst, der dauerhaft `status=error` meldet, pendelt im Minutentakt zwischen „recovered" und „hard_error" — mit Alarm bei jedem Wechsel |
|
||||||
|
| W-15 | Agents senden `intervalSeconds`, die API liest nur `interval`/`expected_interval_sec` | Der gemeldete Takt wird still verworfen, es bleibt bei 60 s. Der C#-Client sendet `interval` und ist zufällig korrekt |
|
||||||
|
| W-16 | PHP schreibt UTC-Strings, MySQL vergleicht mit `NOW()` (Session-Zeitzone) | Job-Fälligkeit, Lock-Leases und Log-Cleanup verschieben sich um den UTC-Offset, sofern die DB nicht auf UTC steht. Betrifft `cli/tick.php:39,44,106` und `admin.php:108` |
|
||||||
|
| W-17 | `self_ping` ist ein `echo`-Stub, `proxmox_smart` hat keinen Handler, `first_contact_deadline_utc` / `expect_running` / `ack_until_utc` werden nie ausgewertet | Der „Wer wacht über den Wächter"-Mechanismus fehlt komplett; der Nie-Start-Alarm ebenfalls. Beides sind Abnahmekriterien aus dem Plan |
|
||||||
|
| W-18 | `warning_digest` schickt HTML durch `notifyImmediate`, das per `htmlspecialchars` escaped | Im Telegram erscheinen `<b>`-Tags als Text |
|
||||||
|
| W-19 | Nur der Heartbeat-Zweig liegt im `try/catch`, danach läuft der Code ohne Fehlerbehandlung weiter | Uneinheitliche Fehlerbilder |
|
||||||
|
| W-20 | Kein Rate-Limit auf `/api/heartbeat` (Plan §14 fordert es) | Ein durchdrehender Client flutet die DB |
|
||||||
|
| W-21 | Keine Tests im gesamten Projekt (Plan §13 fordert Unit-Tests der Zustandsmaschine) | W-13/W-14 wären mit einem Test aufgefallen |
|
||||||
|
|
||||||
|
**Mindestumfang vor der Einbindung:** W-1, W-2, W-5, W-13, W-14, W-16. Ohne W-13/W-14 ist der
|
||||||
|
Watchdog für einen produktiven Trading-Bot unbrauchbar, weil jeder echte Fehler in einer
|
||||||
|
Nachrichtenflut untergeht.
|
||||||
|
|
||||||
|
### A.3 LicenseLabrador — Client
|
||||||
|
|
||||||
|
| # | Befund | Datei | Maßnahme |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **L-1** | **Hardware-ID ist instabil.** Die „erste" aktive Netzwerkkarte wird per `FirstOrDefault` ohne stabile Sortierung gewählt, dazu `ProcessorCount` | `HardwareId.cs:47`, `:40` | **Blocker für uns.** Mullvad, Hyper-V und Docker legen virtuelle Adapter an und wieder ab — jede Änderung erzeugt eine neue HWID, damit eine neue Aktivierung, damit `max_activations` (Default 2) erschöpft: PolyTrader sperrt sich selbst aus. Fix: nur `MachineGuid` als Primärquelle; MAC nur als Fallback, wenn die Registry nichts liefert, und dann sortiert und auf physische Adapter gefiltert; `ProcessorCount` entfernen |
|
||||||
|
| L-2 | Offline-Cache ist maschinen-portabel: geprüft wird nur `payload.license_key == licenseKey`, **nicht** `hardware_id` und nicht `product` | `LicenseClient.cs:195` | Wer `state.dat` von einer aktivierten Maschine kopiert und das Netz blockiert, läuft bis zu 168 h auf beliebig vielen Rechnern. Der wirksamste Angriff auf das gesamte System — und mit zwei Vergleichen behoben |
|
||||||
|
| L-3 | Erkannte Signaturmanipulation (`signatureTampered`) wird verworfen und fällt still in die Offline-Kulanz | `LicenseClient.cs:132-135` | `LicenseState.TamperSuspected` zurückgeben und protokollieren |
|
||||||
|
| L-4 | Kein TLS-Zwang; Endpoints aus dem **Cache** haben Vorrang vor der Konfiguration | `LicenseClient.cs:276-292`, `:342` | `http://`-Endpoints ablehnen; konfigurierte Endpoints zuerst versuchen, gecachte nur als Ergänzung |
|
||||||
|
| L-5 | HMAC-„Härtung" mit Klartext-Konstante im Assembly, geprüft mit demselben Prozess-Secret | `LicenseClient.cs:23,172` | Schützt gegen nichts, was ein Angreifer nicht in derselben Sitzung mitpatcht. Als Obfuskation behalten, aber in `docs/SECURITY.md` nicht mehr als Schutzmaßnahme führen |
|
||||||
|
| L-6 | Offline-`.lic` ohne Hardware-Bindung bzw. ohne Key gilt für **jede** Maschine bzw. **jeden** Key | `LicenseClient.cs:232,242`, `offline_license.php:62` | Im Admin-Dialog `hardware_id` verpflichtend machen (Ausnahme nur mit expliziter Warnung); ein geleaktes Notfall-File ist sonst ein Universal-Crack bis `valid_until` |
|
||||||
|
| L-7 | Session-Cookie ohne `secure`-Flag | `server/src/Auth.php:11` | `'secure' => true` + HTTPS erzwingen |
|
||||||
|
| L-8 | `key_id` wird signiert und übertragen, vom Client aber ignoriert | `Signature.cs` | Ohne Auswertung ist keine Schlüsselrotation möglich, ohne alle Clients gleichzeitig zu tauschen. Für uns momentan verschmerzbar, aber notieren |
|
||||||
|
| L-9 | `LicenseClient` erzeugt einen `HttpClient` pro Instanz und ist nicht `IDisposable` | `LicenseClient.cs:28` | Socket-Leak bei wiederholter Instanziierung; `HttpClient` injizierbar machen |
|
||||||
|
| L-10 | `app_version` hartkodiert `"1.0.0"` | `LicenseClient.cs:325` | Aus `LicenseConfig` befüllen — der Server pflegt die Spalte bereits |
|
||||||
|
| L-11 | `strtotime()` liefert bei ungültigem Datum `false` → `valid_until: false` im JSON → Deserialisierung wirft → stilles „keine Lizenz" | `offline_license.php:55` | Datum validieren |
|
||||||
|
| L-12 | Nur 5 Unit-Tests; `EvaluateOfflineFallback`, Statusabbildung und Grace-Ablauf sind ungetestet | `ClientTests.cs` | Tests für L-1/L-2 mitliefern |
|
||||||
|
| L-13 | `create_admin.php`: Minimum 6 Zeichen, Passwort wird im Klartext angezeigt | `server/tools/create_admin.php` | Minimum 12, Eingabe verdecken |
|
||||||
|
|
||||||
|
**Mindestumfang vor der Einbindung:** L-1, L-2, L-3, L-4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil B — Einbindung in PolyTrader
|
||||||
|
|
||||||
|
### B.0 Leitentscheidungen
|
||||||
|
|
||||||
|
1. **Eigene Client-Implementierung für den Watchdog.** Der mitgelieferte `WatchdogTestClient`
|
||||||
|
deaktiviert die Zertifikatsprüfung vollständig (`WatchdogClient.cs:42`). Wir schreiben einen
|
||||||
|
schlanken `WatchdogHeartbeatService` in `PolyTrader.Core` — der HTTP-Vertrag ist trivial
|
||||||
|
(ein POST mit JSON und einem Header) und wir behalten die Kontrolle über TLS, Timeouts und
|
||||||
|
Fehlerverhalten.
|
||||||
|
2. **LicenseLabrador.Client als Projektreferenz**, nicht als kopierter Code — dann fließen
|
||||||
|
Fixes aus Teil A.3 automatisch ein. Voraussetzung: L-1 bis L-4 sind dort behoben.
|
||||||
|
3. **Kein `Environment.Exit` bei ungültiger Lizenz.** `docs/INTEGRATION.md` schlägt das vor; für
|
||||||
|
einen Trading-Bot mit offenen Positionen ist ein harter Abbruch gefährlich. Stattdessen:
|
||||||
|
Trading-Module gar nicht erst starten (Startfall) bzw. geordnet stoppen (Laufzeitfall).
|
||||||
|
4. **Secrets über `SecretProtection`.** Watchdog-Token und Lizenzschlüssel werden mit dem
|
||||||
|
bestehenden AES-GCM-Master-Key (`POLYTRADER_MASTER_KEY`) verschlüsselt abgelegt — dieselbe
|
||||||
|
Mechanik wie für Wallet-Keys.
|
||||||
|
5. **Heartbeat-Metriken bleiben schlank.** Version, aktive Module, Uptime. **Keine** Kontostände,
|
||||||
|
Positionen, Wallet-Adressen oder Trader-Namen — der Watchdog-Server liegt beim Hoster und
|
||||||
|
seine Status-API war bis W-6 öffentlich lesbar.
|
||||||
|
|
||||||
|
### B.1 Slice W-A — Heartbeat-Grundgerüst ✅ ERLEDIGT (29.07.2026)
|
||||||
|
|
||||||
|
Umgesetzt in [WatchdogHeartbeatService.cs](../../src/PolyTrader.Core/Services/WatchdogHeartbeatService.cs)
|
||||||
|
mit 21 Tests in [WatchdogHeartbeatTests.cs](../../tests/PolyTrader.Tests/WatchdogHeartbeatTests.cs).
|
||||||
|
Abweichungen und Ergänzungen gegenüber der Planung unten:
|
||||||
|
|
||||||
|
- **TLS-Zwang implementiert:** `TryBuildEndpoint` akzeptiert nur `https://`; `http://` ausschließlich
|
||||||
|
für Loopback-Adressen (lokales Testen). Damit kann der Agent-Token nicht versehentlich im
|
||||||
|
Klartext übertragen werden — der Grund, warum der mitgelieferte Testclient nicht verwendet wird.
|
||||||
|
- **Intervall wird nach unten begrenzt** (10 s), damit ein Fehlkonfigurierter Client den Watchdog
|
||||||
|
nicht flutet (W-20 ist serverseitig weiter offen).
|
||||||
|
- **Log-Entprellung:** nur der erste Fehlversuch und die Erholung werden protokolliert, nicht
|
||||||
|
jeder Tick. Sonst hätte ein längerer Watchdog-Ausfall das Terminal geflutet.
|
||||||
|
- **Settings-Quelle injizierbar** (`Func<ServerSettings>`), damit der Dienst ohne Datei testbar ist.
|
||||||
|
- **Token darf verschlüsselt sein:** `SecretProtection.Unprotect` reicht Klartext unverändert
|
||||||
|
durch und entschlüsselt `enc:v1:…`, wenn ein Master-Key gesetzt ist. Kein neuer Fehlerpfad,
|
||||||
|
wenn kein Master-Key existiert.
|
||||||
|
- **UI — vollständig über das Settings-Fenster bedienbar:** Enabled, URL, Source, Instance und
|
||||||
|
Intervall erscheinen automatisch im bestehenden `PropertyGrid` (Kategorie „Watchdog Monitoring").
|
||||||
|
Der **Agent-Token** ist bewusst `[Browsable(false)]` und wird über den Designer-Button
|
||||||
|
„Watchdog-Token setzen …" mit **maskierter Eingabe** gepflegt — kein Secret offen im Fenster und
|
||||||
|
kein Handanlegen an der `server_settings.xml`. Bei gesetztem Master-Key wird er über
|
||||||
|
`SecretProtection.Protect` verschlüsselt abgelegt, sonst mit deutlichem Hinweis im Klartext.
|
||||||
|
Die schreibgeschützte Anzeige `WatchdogTokenStatus` zeigt „nicht gesetzt" /
|
||||||
|
„gesetzt (verschlüsselt)" / „gesetzt (Klartext)". Zweiter Button: „Test-Heartbeat senden" —
|
||||||
|
nutzt die **gespeicherten** Werte, damit ein Erfolg keine Konfiguration bestätigt, die nicht
|
||||||
|
auf der Platte liegt.
|
||||||
|
- **Änderungen greifen ohne Neustart.** `ExecuteAsync` wertet die Konfiguration in jedem Durchlauf
|
||||||
|
neu aus (1-s-Poll, Sendezeitpunkt über eine Fälligkeit, der Takt bleibt also exakt) statt einen
|
||||||
|
`PeriodicTimer` mit fest verdrahtetem Intervall zu benutzen. Ein-/Ausschalten, Takt-, URL- und
|
||||||
|
Token-Änderungen wirken sofort nach dem Speichern; Zustandswechsel werden je Ursache genau
|
||||||
|
einmal geloggt. Zwei Tests fixieren das Live-Ein- und -Ausschalten.
|
||||||
|
|
||||||
|
Ursprüngliche Planung:
|
||||||
|
|
||||||
|
**Neu:** `src/PolyTrader.Core/Services/WatchdogHeartbeatService.cs` (`BackgroundService`)
|
||||||
|
|
||||||
|
- `ExecuteAsync`: `PeriodicTimer` im konfigurierten Takt (Default 60 s). **Jeder** Sendeversuch in
|
||||||
|
`try/catch` — ein nicht erreichbarer Watchdog darf PolyTrader niemals beeinträchtigen.
|
||||||
|
- Payload: `source`, `instance`, `type: "heartbeat"`, `status`, `message`, `interval`
|
||||||
|
(**nicht** `intervalSeconds`, siehe W-15), `group`, `metadata`.
|
||||||
|
- Header `X-Watchdog-Key` mit dem entschlüsselten Agent-Token.
|
||||||
|
- `HttpClient` aus `SocketsHttpHandler` wie die übrigen Clients in `Program.cs`, mit
|
||||||
|
Standard-Zertifikatsprüfung und 10 s Timeout.
|
||||||
|
- Registrierung in `Program.cs` neben `MarketSyncService`:
|
||||||
|
`services.AddHostedService<WatchdogHeartbeatService>();`
|
||||||
|
|
||||||
|
**Konfiguration** in `ServerSettings` (`src/PolyTrader.Core/Models/ServerSettings.cs`), analog zu
|
||||||
|
den Threema-/Mullvad-Feldern:
|
||||||
|
|
||||||
|
```
|
||||||
|
WatchdogEnabled bool = false
|
||||||
|
WatchdogUrl string = "" // https:// erzwungen
|
||||||
|
WatchdogSource string = "polytrader"
|
||||||
|
WatchdogInstance string = "prod-1"
|
||||||
|
WatchdogIntervalSeconds int = 60
|
||||||
|
WatchdogToken string = "" // via SecretProtection verschlüsselt
|
||||||
|
```
|
||||||
|
|
||||||
|
Bedienung über die bestehende `SettingsView` (Designer-fähig, partial + `.Designer.cs` — siehe
|
||||||
|
UI-Regel), mit einem „Test-Heartbeat senden"-Button.
|
||||||
|
|
||||||
|
**Abnahme:** Monitor erscheint im Watchdog-Dashboard, `last_seen_utc` läuft mit. PolyTrader startet
|
||||||
|
und arbeitet unverändert, wenn `WatchdogUrl` falsch oder der Server aus ist.
|
||||||
|
|
||||||
|
### B.2 Slice W-B — Status aus dem Log ableiten ✅ ERLEDIGT (31.07.2026)
|
||||||
|
|
||||||
|
Umgesetzt im `WatchdogHeartbeatService`. Abweichungen gegenüber der Planung:
|
||||||
|
|
||||||
|
- **Nur `Error` eskaliert**, keine Warnungs-Eskalation: App-Warnungen (VPN-Reconnect, „kein
|
||||||
|
Master-Key" …) sind zu häufig, um daraus einen Monitor-Alarm zu rechtfertigen. `Warning`/`Info`
|
||||||
|
lassen den Status auf `ok`. Das vermeidet auch eine Rückkopplung, wenn der Dienst selbst warnt.
|
||||||
|
- **Entprellung über ein Nachwirkfenster** (`ErrorStickyWindow = 5 min`): Nach einem `Error` meldet
|
||||||
|
der Heartbeat so lange `status=error`, bis 5 Minuten ohne neuen Fehler vergangen sind — dann
|
||||||
|
wieder `ok`. Kein Flackern error→ok→error, Erholung wird genau einmal gemeldet. (Der 30-Minuten-
|
||||||
|
Notbehelf aus der Planung entfällt, weil W-13 im Watchdog behoben ist und ohnehin nur bei
|
||||||
|
Zustandswechsel alarmiert wird.)
|
||||||
|
- **`Sanitize()`** entfernt Wallet-Adressen/Keys (`0x…`-Hex) und verschlüsselte Secrets
|
||||||
|
(`enc:v1:…`) und kürzt auf 200 Zeichen, bevor eine Fehlermeldung an den (extern gehosteten)
|
||||||
|
Watchdog geht. Beträge werden bewusst nicht gestrippt (würde die Meldung entwerten; der Kanal ist
|
||||||
|
nur für den Betreiber).
|
||||||
|
- Angedockt über `TerminalLogger.OnLogMessage` (synchron), Abmeldung in `Dispose`.
|
||||||
|
|
||||||
|
Ursprüngliche Planung: Der Heartbeat soll `status=error` melden, wenn PolyTrader in Not ist.
|
||||||
|
|
||||||
|
- `TerminalLogger.OnLogMessage` abonnieren, Fehler der letzten `n` Intervalle zählen.
|
||||||
|
- Ableitung: `Error` im Fenster → `status=error` mit der letzten Fehlermeldung als `message`;
|
||||||
|
`Warning` → `status=warning`; sonst `ok`.
|
||||||
|
- **Entprellen ist Pflicht**, solange W-13 im Watchdog nicht behoben ist: sonst löst jeder
|
||||||
|
Heartbeat eine eigene Telegram-Nachricht aus. Bis dahin `status=error` frühestens alle 30 Minuten
|
||||||
|
erneut senden.
|
||||||
|
- Meldungstexte kürzen (≤ 200 Zeichen) und **keine** Wallet-Adressen, Keys oder Beträge
|
||||||
|
durchreichen. Ein Filter über die bekannten Secret-Präfixe gehört dazu.
|
||||||
|
|
||||||
|
### B.3 Slice W-C — Lifecycle-Events ✅ ERLEDIGT (31.07.2026)
|
||||||
|
|
||||||
|
Umgesetzt im `WatchdogHeartbeatService` (`SendEventAsync` → `POST /api/event`):
|
||||||
|
|
||||||
|
- **Start:** `kind: "started"` mit App-Version, genau einmal, sobald der Dienst aktiv wird
|
||||||
|
(in der Sendeschleife, nach `AppHost.Start()`), vor dem ersten Heartbeat.
|
||||||
|
- **Geplantes Beenden:** `StopAsync` sendet `kind: "stopped_graceful"` (best effort, 3-s-Timeout,
|
||||||
|
blockiert das Herunterfahren nie). Läuft über den bestehenden Pfad `AppHost.StopAsync()` nach
|
||||||
|
`Application.Run` + den 10-Sekunden-Bestätigungsdialog.
|
||||||
|
- Damit unterscheidet der Watchdog „sauber beendet" (Monitor → `stopped`, kein Alarm) von
|
||||||
|
„abgestürzt" (Staleness → genau ein Down-Alarm) — der eigentliche Mehrwert.
|
||||||
|
- Tests decken „started"→„stopped_graceful" über den Dienst-Lebenszyklus sowie den
|
||||||
|
`/api/event`-Vertrag ab.
|
||||||
|
|
||||||
|
**Abnahme (manuell, live):** PolyTrader über die Menüleiste beenden → Monitor geht auf `stopped`,
|
||||||
|
**kein** Alarm. PolyTrader per Task-Manager abschießen → nach `Interval × Faktor + Grace` genau
|
||||||
|
**ein** Down-Alarm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Stand 31.07.2026:** Watchdog-Seite (B.1/B.2/B.3) und Lizenz-Seite (B.4, B.5 vereinfacht)
|
||||||
|
umgesetzt; 438 Tests grün, `--smoke-ui` ok. Offen nur noch die **live**-Abnahme (echter Server)
|
||||||
|
und die Fremdprojekt-Auflagen (Watchdog-Secrets rotieren, UTC/NOW(), `self_ping`-Stub).
|
||||||
|
|
||||||
|
### B.4 Slice L-A — Lizenzprüfung beim Start ✅ ERLEDIGT (31.07.2026)
|
||||||
|
|
||||||
|
Umgesetzt in [Licensing/LicenseGate.cs](../../Licensing/LicenseGate.cs), Startdialog in
|
||||||
|
[Ui/LicenseDialog.cs](../../Ui/LicenseDialog.cs) (+`.Designer.cs`), Verdrahtung in
|
||||||
|
[Program.cs](../../Program.cs), Schlüssel-/Statusfeld in
|
||||||
|
[ServerSettings.cs](../../src/PolyTrader.Core/Models/ServerSettings.cs). Abweichungen und
|
||||||
|
Ergänzungen gegenüber der ursprünglichen Planung unten:
|
||||||
|
|
||||||
|
- **Ort:** `LicenseGate` liegt im **App-Projekt** (`PolyTraderSharp.Licensing`), nicht in Core.
|
||||||
|
Grund: `Program.Main` (App) ist der einzige Aufrufer, und so bleibt die (cross-repo)
|
||||||
|
SDK-Referenz auf `PolyTrader.App` beschränkt statt in Core alle Module zu belasten.
|
||||||
|
- **SDK-Einbindung:** `LicenseLabrador.Client` als **Cross-Repo-Projektreferenz** von
|
||||||
|
`PolyTrader.App` auf `..\..\LicenseLabrador\...` (Fixes am SDK fließen ohne Kopie ein). Baut
|
||||||
|
sauber, kein BouncyCastle-Konflikt mit Nethereum.
|
||||||
|
- **Slug `pt`** (nicht `polytrader`), Endpoint **`https://license.mhdf.de`**, eingebetteter
|
||||||
|
Public-Key. **End-to-End verifiziert** (ohne Aktivierung zu verbrauchen): `validate` mit
|
||||||
|
bewusst ungültigem Schlüssel liefert signiertes `not_found`/„Invalid license key" (⇒ Slug `pt`
|
||||||
|
bestätigt, Produkt existiert), Nonce wird reflektiert, und die Server-**Signatur verifiziert
|
||||||
|
gegen den eingebetteten Public-Key** (openssl Ed25519 „Verified Successfully"). Der Client wird
|
||||||
|
echte Antworten also akzeptieren.
|
||||||
|
- **Startablauf:** Master-Key wird jetzt **vor** dem Host-Build geladen (`ConfigureSecretProtection(bootLog)`),
|
||||||
|
dann der Gate. Bei `IsUsable == false` → modaler Dialog (Schlüssel eingeben, **Hardware-ID
|
||||||
|
anzeigen + kopieren**, „eingeschränkt starten"); bleibt es ungültig, wird die Modulliste
|
||||||
|
**leer** gebaut → nur die Core-Shell (Terminal/Settings) startet. **Kein `Environment.Exit`.**
|
||||||
|
`TamperSuspected` wird als nicht nutzbar behandelt und im Dialog benannt.
|
||||||
|
- **Konfiguration wie beim Watchdog-Token:** `ServerSettings.LicenseKey` (`[Browsable(false)]`,
|
||||||
|
bei Master-Key verschlüsselt) + Statusanzeige. Der Schlüssel `9KHEZ-…` ist bereits in
|
||||||
|
`server_settings.xml` hinterlegt → beim ersten echten Start validiert der Gate automatisch und
|
||||||
|
aktiviert auf **dieser** Maschine (kein Dialog nötig, falls gültig).
|
||||||
|
- **Ein Dialog für beide Wege (31.07.2026):** Der Settings-Toolstrip-Button „Lizenz prüfen /
|
||||||
|
setzen …" öffnet **denselben** `LicenseDialog` wie der Start, nur im Verwalten-Modus
|
||||||
|
(`startupContext:false`, Button „Schließen" statt „eingeschränkt starten"). Damit validiert man
|
||||||
|
auch aus den laufenden Einstellungen gegen den Server (Hardware-ID sichtbar/kopierbar); der
|
||||||
|
frühere einfache Text-Prompt entfällt. Ein gültig validierter Schlüssel wird verschlüsselt
|
||||||
|
gespeichert und gilt ab dem nächsten Start.
|
||||||
|
- **Verifikation:** Solution baut fehlerfrei, 430 Tests grün, `--smoke-ui` konstruiert zusätzlich
|
||||||
|
`license.dialog`. Eigene Unit-Tests für den Gate wurden bewusst **nicht** angelegt: er ist
|
||||||
|
dünner Leim über dem SDK (das seine eigenen Krypto-Tests mitbringt), und ein Test bräuchte eine
|
||||||
|
Referenz vom Testprojekt auf die WinExe. Stattdessen deckt der Smoke-Test Konstruktion/Compile ab.
|
||||||
|
- **Offen (bewusst):** L-4 bleibt serverseitige Hygiene — sicherstellen, dass die `endpoints`-
|
||||||
|
Einstellung im Lizenz-Admin **https oder leer** ist (gecachte Endpoints hätten sonst im SDK
|
||||||
|
Vorrang). Und die eigentliche **erfolgreiche** Aktivierung mit dem echten Schlüssel passiert
|
||||||
|
erst beim realen Start aus PolyTrader (mit dessen Hardware-ID) — von hier aus bewusst nicht
|
||||||
|
getestet, um keinen Aktivierungs-Slot auf der falschen Maschine zu verbrennen.
|
||||||
|
|
||||||
|
Ursprüngliche Planung:
|
||||||
|
|
||||||
|
**Neu:** `src/PolyTrader.Core/Security/LicenseGate.cs`
|
||||||
|
|
||||||
|
- Wird in `Program.Main` **nach** `ConfigureSecretProtection` und **vor** `AppHost.Start()`
|
||||||
|
aufgerufen — der Lizenzschlüssel liegt verschlüsselt und braucht den Master-Key.
|
||||||
|
- `LicenseConfig`: `ProductSlug = "polytrader"`, eingebetteter `PublicKeyBase64`,
|
||||||
|
Endpoints ausschließlich `https://`.
|
||||||
|
- Ergebnis:
|
||||||
|
- `IsUsable == true` → normaler Start. Bei `ValidOffline` zusätzlich eine sichtbare
|
||||||
|
Restlaufzeit im Launcher.
|
||||||
|
- `IsUsable == false` → Lizenzdialog (Schlüssel eingeben, Hardware-ID anzeigen und kopierbar
|
||||||
|
machen), Trading-Module werden **nicht** registriert. Der Rest der Shell darf starten, damit
|
||||||
|
Terminal und Settings erreichbar bleiben.
|
||||||
|
- `TamperSuspected` (nach L-3) wird deutlich angezeigt und wie „nicht nutzbar" behandelt.
|
||||||
|
|
||||||
|
**Wichtig:** Der Schlüssel gehört in die verschlüsselte Ablage, nicht in `appsettings.json`.
|
||||||
|
|
||||||
|
### B.5 Slice L-B — ~~Revalidierung im Betrieb~~ VERWORFEN (Entscheidung 31.07.2026)
|
||||||
|
|
||||||
|
**Es gibt bewusst KEINE Laufzeit-Revalidierung.** Richards Vorgabe: im laufenden Betrieb darf
|
||||||
|
lizenzseitig **gar nichts** passieren — die Software darf niemals mitten in einer Sitzung aufhören
|
||||||
|
zu arbeiten (offene Positionen, laufende API-Aufrufe). Die einzige Durchsetzung ist der
|
||||||
|
**Startup-Gate (B.4)**: beim nächsten Start einer ungültigen/widerrufenen/kopierten Lizenz startet
|
||||||
|
die Software nicht mehr voll, sondern zeigt die Aufforderung, eine (neue) gültige Lizenz anzugeben.
|
||||||
|
Kein `BackgroundService`, kein 12-h-Timer, kein Netzwerkaufruf während der Sitzung.
|
||||||
|
|
||||||
|
**Warum das reicht (Bedrohungsmodell = Schutz vor einfachem Kopieren auf ein fremdes System):**
|
||||||
|
Die Software läuft überwiegend auf eigenen Systemen. Geschützt werden soll nur gegen „jemand
|
||||||
|
kopiert die Software auf ein eigenes System und macht dort weiter". Das leistet der Startup-Gate
|
||||||
|
durch drei Mechanismen des SDK, **alle beim Start**:
|
||||||
|
|
||||||
|
1. **Hardware-gebundene Aktivierung** — ein fremdes System hat eine andere `MachineGuid` ⇒ andere
|
||||||
|
Hardware-ID ⇒ braucht einen eigenen Aktivierungs-Slot.
|
||||||
|
2. **`max_activations`** — sind alle Slots belegt, liefert der Server `activation_limit` ⇒ die
|
||||||
|
Kopie startet nicht (nur die Aufforderung erscheint).
|
||||||
|
3. **Hardware-gebundener Offline-Cache (L-2-Fix)** — ein mitkopierter `state.dat` gehört zu einer
|
||||||
|
fremden Hardware-ID und wird offline verworfen ⇒ die Kopie kann sich auch ohne Netz nicht
|
||||||
|
„durchmogeln".
|
||||||
|
|
||||||
|
**Zwei Stellschrauben im Lizenz-Admin (Produkt `pt`):**
|
||||||
|
- **`max_activations`** großzügig auf die Zahl der eigenen Maschinen + Reserve setzen (ein
|
||||||
|
Windows-Neuinstall erzeugt eine neue `MachineGuid` = neuer Slot; alte Aktivierungen im Admin
|
||||||
|
freigeben). Sonst sperrt man sich bei einem Reinstall selbst aus.
|
||||||
|
- **`default_cache_ttl_hours`** (Offline-Kulanz, Default 168 h/7 Tage) = wie lange eine **eigene**,
|
||||||
|
bereits aktivierte Maschine ohne Serverkontakt neu starten darf. Für rein eigene Systeme darf
|
||||||
|
das gern länger sein. Die Kopie profitiert davon nicht (Punkt 3).
|
||||||
|
|
||||||
|
**Verhalten bei fehlender/ungültiger Lizenz beim Start (aktuell umgesetzt):** modaler Dialog
|
||||||
|
(Schlüssel eingeben / Hardware-ID kopieren / „eingeschränkt starten"). Ohne gültige Lizenz startet
|
||||||
|
nur die Core-Shell (Terminal + Einstellungen), **keine Module** — die Software „arbeitet" also
|
||||||
|
nicht, bleibt aber bedienbar genug, um eine Lizenz einzutragen. Wer stattdessen ein hartes
|
||||||
|
Beenden statt des eingeschränkten Modus will, ist eine Ein-Zeilen-Änderung im Dialog (offen).
|
||||||
|
|
||||||
|
### B.6 Slice L-C — Predictalytics
|
||||||
|
|
||||||
|
Beide Dienste sind mandantenfähig ausgelegt: ein zweites Produkt `predictalytics` im
|
||||||
|
LicenseLabrador-Admin, ein zweiter Monitor im Watchdog. Der Heartbeat-Service ist bewusst
|
||||||
|
projektunabhängig geschrieben und lässt sich unverändert übernehmen. Fällt an, sobald
|
||||||
|
Predictalytics produktiv geht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reihenfolge und Aufwand
|
||||||
|
|
||||||
|
| Schritt | Inhalt | Abhängigkeit |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Watchdog A.1 (W-1, W-2, W-5) + A.2 (W-13, W-14, W-16) | — |
|
||||||
|
| 2 | LicenseLabrador A.3 (L-1 bis L-4) + Tests | — |
|
||||||
|
| 3 | B.1 Heartbeat-Grundgerüst + Settings-UI | 1 |
|
||||||
|
| 4 | B.2 Status aus dem Log | 3 |
|
||||||
|
| 5 | B.3 Lifecycle-Events | 3 |
|
||||||
|
| 6 | B.4 Lizenzprüfung beim Start | 2 |
|
||||||
|
| 7 | B.5 Revalidierung | 6 |
|
||||||
|
| 8 | Restliche Befunde aus A.1/A.2/A.3 | — |
|
||||||
|
| 9 | B.6 Predictalytics | 3, 6 |
|
||||||
|
|
||||||
|
Schritte 1 und 2 sind unabhängig voneinander und können parallel laufen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Entscheidungen für Richard
|
||||||
|
|
||||||
|
1. **Wo läuft der Watchdog?** Aktuell ein Shared-Webserver bei derselben Hosting-Firma. Für den
|
||||||
|
Zweck („Außensicht auf einen Server-Crash") reicht das, solange es nicht dieselbe Maschine ist.
|
||||||
|
Der `self_ping` an healthchecks.io (W-17) fehlt aber noch komplett — bis dahin gibt es keinen
|
||||||
|
Alarm, wenn der Watchdog selbst stirbt.
|
||||||
|
2. **Lizenzierung für uns selbst oder für Kunden?** Wenn PolyTrader vorerst nur auf unseren
|
||||||
|
eigenen Maschinen läuft, ist die Offline-Kulanz von 168 h großzügig und L-2 entsprechend
|
||||||
|
entspannter. Sobald Dritte die Software bekommen, sind L-1, L-2 und L-6 harte Blocker.
|
||||||
|
3. **`max_activations`** sollte für unsere eigene Lizenz großzügig gesetzt werden (5+), solange
|
||||||
|
L-1 nicht behoben ist — sonst reicht ein VPN-Wechsel für eine Aussperrung.
|
||||||
@@ -89,6 +89,81 @@ namespace PolyTraderSharp.Models
|
|||||||
public bool UsePolymarketWebsockets { get; set; } = false;
|
public bool UsePolymarketWebsockets { get; set; } = false;
|
||||||
|
|
||||||
|
|
||||||
|
[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.")]
|
||||||
|
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.")]
|
||||||
|
public string WatchdogUrl { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Agent-Token für den Header <c>X-Watchdog-Key</c>. Bewusst NICHT im PropertyGrid sichtbar:
|
||||||
|
/// Setzen erfolgt über den Button „Watchdog-Token setzen …" mit maskierter Eingabe, damit das
|
||||||
|
/// Secret nicht offen im Fenster steht und bei gesetztem Master-Key verschlüsselt
|
||||||
|
/// (<c>enc:v1:…</c>) in der gitignorierten server_settings.xml landet.
|
||||||
|
/// </summary>
|
||||||
|
[Browsable(false)]
|
||||||
|
public string WatchdogToken { get; set; } = "";
|
||||||
|
|
||||||
|
[Category("Watchdog Monitoring")]
|
||||||
|
[DisplayName("Agent-Token")]
|
||||||
|
[Description("Status des Agent-Tokens. Setzen/Ändern über den Button „Watchdog-Token setzen …" +
|
||||||
|
"\" in der Werkzeugleiste (maskierte Eingabe).")]
|
||||||
|
[ReadOnly(true)]
|
||||||
|
[XmlIgnore]
|
||||||
|
public string WatchdogTokenStatus =>
|
||||||
|
string.IsNullOrWhiteSpace(WatchdogToken)
|
||||||
|
? "nicht gesetzt"
|
||||||
|
: (WatchdogToken.StartsWith("enc:v1:", System.StringComparison.Ordinal)
|
||||||
|
? "gesetzt (verschlüsselt)"
|
||||||
|
: "gesetzt (Klartext)");
|
||||||
|
|
||||||
|
[Category("Watchdog Monitoring")]
|
||||||
|
[DisplayName("Source")]
|
||||||
|
[Description("Dienst-Kennung im Watchdog (muss dem dort angelegten Monitor entsprechen), z.B. \"polytrader\".")]
|
||||||
|
public string WatchdogSource { get; set; } = "polytrader";
|
||||||
|
|
||||||
|
[Category("Watchdog Monitoring")]
|
||||||
|
[DisplayName("Instance")]
|
||||||
|
[Description("Instanz-Kennung, falls mehrere PolyTrader-Instanzen laufen, z.B. \"prod-1\".")]
|
||||||
|
public string WatchdogInstance { get; set; } = "prod-1";
|
||||||
|
|
||||||
|
[Category("Watchdog Monitoring")]
|
||||||
|
[DisplayName("Heartbeat-Intervall (Sekunden)")]
|
||||||
|
[Description("Sende-Takt des Heartbeats. Der Watchdog leitet daraus seine Staleness-Frist ab " +
|
||||||
|
"(Intervall × Faktor + Grace). Minimum 10 s, Empfehlung 60 s.")]
|
||||||
|
public int WatchdogIntervalSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lizenzschlüssel (LicenseLabrador). Wie der Watchdog-Token bewusst NICHT im PropertyGrid
|
||||||
|
/// sichtbar; gepflegt über den Button „Lizenzschlüssel setzen …" bzw. den Startdialog. Bei
|
||||||
|
/// gesetztem Master-Key verschlüsselt (<c>enc:v1:…</c>) in der gitignorierten server_settings.xml.
|
||||||
|
/// </summary>
|
||||||
|
[Browsable(false)]
|
||||||
|
public string LicenseKey { get; set; } = "";
|
||||||
|
|
||||||
|
[Category("Lizenz")]
|
||||||
|
[DisplayName("Lizenzschlüssel")]
|
||||||
|
[Description("Status des Lizenzschlüssels. Setzen/Ändern über den Button „Lizenzschlüssel setzen …\" " +
|
||||||
|
"in der Werkzeugleiste bzw. beim Programmstart, falls keine gültige Lizenz vorliegt.")]
|
||||||
|
[ReadOnly(true)]
|
||||||
|
[XmlIgnore]
|
||||||
|
public string LicenseKeyStatus =>
|
||||||
|
string.IsNullOrWhiteSpace(LicenseKey)
|
||||||
|
? "nicht gesetzt"
|
||||||
|
: (LicenseKey.StartsWith("enc:v1:", System.StringComparison.Ordinal)
|
||||||
|
? "gesetzt (verschlüsselt)"
|
||||||
|
: "gesetzt (Klartext)");
|
||||||
|
|
||||||
|
|
||||||
public static ServerSettings Load(string path)
|
public static ServerSettings Load(string path)
|
||||||
{
|
{
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using PolyTraderSharp.Models;
|
||||||
|
using PolyTraderSharp.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PolyTrader.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sicherheitsnetz für den Watchdog-Heartbeat (B.1). Die Tests fixieren die drei Eigenschaften,
|
||||||
|
/// die im Review der Fremdprojekte aufgefallen sind:
|
||||||
|
/// 1. TLS-Zwang (der mitgelieferte Watchdog-Testclient deaktiviert die Zertifikatsprüfung),
|
||||||
|
/// 2. Feldname "interval" – die Watchdog-API ignoriert "intervalSeconds" still,
|
||||||
|
/// 3. ein nicht erreichbarer Watchdog darf niemals eine Ausnahme nach außen geben.
|
||||||
|
/// </summary>
|
||||||
|
public class WatchdogHeartbeatTests
|
||||||
|
{
|
||||||
|
// ----- TLS-Zwang / URL-Aufbau -----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://watchdog.example.de")]
|
||||||
|
[InlineData("https://watchdog.example.de/")]
|
||||||
|
[InlineData("http://localhost:8080")]
|
||||||
|
[InlineData("http://127.0.0.1")]
|
||||||
|
public void TryBuildEndpoint_accepts_https_and_local_http(string baseUrl)
|
||||||
|
{
|
||||||
|
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, "heartbeat", out Uri? uri, out string problem);
|
||||||
|
|
||||||
|
Assert.True(ok, problem);
|
||||||
|
Assert.NotNull(uri);
|
||||||
|
Assert.EndsWith("/api/heartbeat", uri!.AbsoluteUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryBuildEndpoint_rejects_plain_http_to_remote_host()
|
||||||
|
{
|
||||||
|
// Über http:// ginge der Agent-Token im Klartext über das Netz.
|
||||||
|
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(
|
||||||
|
"http://watchdog.example.de", "heartbeat", out Uri? uri, out string problem);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.Null(uri);
|
||||||
|
Assert.Contains("HTTPS", problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("watchdog.example.de")] // ohne Schema
|
||||||
|
[InlineData("ftp://watchdog.example.de")]
|
||||||
|
public void TryBuildEndpoint_rejects_invalid_urls(string baseUrl)
|
||||||
|
{
|
||||||
|
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, "heartbeat", out _, out string problem);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.NotEmpty(problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryBuildEndpoint_keeps_base_path_of_subdirectory_installs()
|
||||||
|
{
|
||||||
|
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(
|
||||||
|
"https://example.de/watchdog", "heartbeat", out Uri? uri, out _);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal("https://example.de/watchdog/api/heartbeat", uri!.AbsoluteUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Konfigurationsprüfung -----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetConfigurationProblem_reports_missing_url_token_and_source()
|
||||||
|
{
|
||||||
|
Assert.Contains("URL", Problem(s => s.WatchdogUrl = ""));
|
||||||
|
Assert.Contains("Token", Problem(s =>
|
||||||
|
{
|
||||||
|
s.WatchdogUrl = "https://wd.example.de";
|
||||||
|
s.WatchdogToken = "";
|
||||||
|
}));
|
||||||
|
Assert.Contains("Source", Problem(s =>
|
||||||
|
{
|
||||||
|
s.WatchdogUrl = "https://wd.example.de";
|
||||||
|
s.WatchdogToken = "tok";
|
||||||
|
s.WatchdogSource = "";
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetConfigurationProblem_returns_null_when_ready()
|
||||||
|
{
|
||||||
|
var service = Build(out _, Valid());
|
||||||
|
Assert.Null(service.GetConfigurationProblem());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Payload -----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendHeartbeatAsync_posts_expected_payload_and_auth_header()
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
settings.WatchdogSource = "polytrader";
|
||||||
|
settings.WatchdogInstance = "prod-1";
|
||||||
|
settings.WatchdogIntervalSeconds = 45;
|
||||||
|
|
||||||
|
var service = Build(out FakeHandler handler, settings);
|
||||||
|
|
||||||
|
var result = await service.SendHeartbeatAsync("ok", "Testlauf");
|
||||||
|
|
||||||
|
Assert.True(result.Success, result.Detail);
|
||||||
|
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
|
||||||
|
Assert.Equal("https://wd.example.de/api/heartbeat", handler.LastRequest.RequestUri!.AbsoluteUri);
|
||||||
|
Assert.Equal("secret-token", Assert.Single(handler.LastRequest.Headers.GetValues("X-Watchdog-Key")));
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.Equal("polytrader", root.GetProperty("source").GetString());
|
||||||
|
Assert.Equal("prod-1", root.GetProperty("instance").GetString());
|
||||||
|
Assert.Equal("heartbeat", root.GetProperty("type").GetString());
|
||||||
|
Assert.Equal("ok", root.GetProperty("status").GetString());
|
||||||
|
Assert.Equal("Testlauf", root.GetProperty("message").GetString());
|
||||||
|
|
||||||
|
// Entscheidend: die Watchdog-API liest "interval" und ignoriert "intervalSeconds" still.
|
||||||
|
Assert.Equal(45, root.GetProperty("interval").GetInt32());
|
||||||
|
Assert.False(root.TryGetProperty("intervalSeconds", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendHeartbeatAsync_clamps_interval_to_minimum()
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
settings.WatchdogIntervalSeconds = 1; // absurd niedrig -> würde den Watchdog fluten
|
||||||
|
|
||||||
|
var service = Build(out FakeHandler handler, settings);
|
||||||
|
await service.SendHeartbeatAsync();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||||
|
Assert.Equal(10, doc.RootElement.GetProperty("interval").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendHeartbeatAsync_falls_back_to_default_instance_when_blank()
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
settings.WatchdogInstance = " ";
|
||||||
|
|
||||||
|
var service = Build(out FakeHandler handler, settings);
|
||||||
|
await service.SendHeartbeatAsync();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||||
|
Assert.Equal("default", doc.RootElement.GetProperty("instance").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Robustheit: ein toter Watchdog darf PolyTrader nicht beeinträchtigen -----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendHeartbeatAsync_returns_failure_instead_of_throwing_on_network_error()
|
||||||
|
{
|
||||||
|
var service = Build(out _, Valid(), _ => throw new HttpRequestException("Kein DNS"));
|
||||||
|
|
||||||
|
var result = await service.SendHeartbeatAsync();
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("Kein DNS", result.Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendHeartbeatAsync_reports_http_error_status_as_failure()
|
||||||
|
{
|
||||||
|
var service = Build(out _, Valid(),
|
||||||
|
_ => new HttpResponseMessage(HttpStatusCode.Unauthorized) { ReasonPhrase = "Unauthorized" });
|
||||||
|
|
||||||
|
var result = await service.SendHeartbeatAsync();
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("401", result.Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendHeartbeatAsync_does_not_send_when_misconfigured()
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
settings.WatchdogUrl = "http://watchdog.example.de"; // kein TLS
|
||||||
|
|
||||||
|
var service = Build(out FakeHandler handler, settings);
|
||||||
|
var result = await service.SendHeartbeatAsync();
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Null(handler.LastRequest); // gar nicht erst gesendet
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_returns_quietly_when_disabled()
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
settings.WatchdogEnabled = false;
|
||||||
|
|
||||||
|
var service = Build(out FakeHandler handler, settings);
|
||||||
|
|
||||||
|
// StartAsync eines BackgroundService darf bei deaktivierter Konfiguration nicht werfen
|
||||||
|
// und nichts senden.
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(handler.LastRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_does_not_throw_when_enabled_but_misconfigured()
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
settings.WatchdogEnabled = true;
|
||||||
|
settings.WatchdogToken = ""; // Fehlkonfiguration
|
||||||
|
|
||||||
|
var service = Build(out FakeHandler handler, settings);
|
||||||
|
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(handler.LastRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Running_service_starts_sending_after_being_enabled_without_restart()
|
||||||
|
{
|
||||||
|
// Der Nutzer schaltet den Heartbeat im Settings-Fenster ein: der laufende Dienst muss
|
||||||
|
// das übernehmen, ohne dass PolyTrader neu gestartet werden muss.
|
||||||
|
var settings = Valid();
|
||||||
|
settings.WatchdogEnabled = false;
|
||||||
|
|
||||||
|
var service = Build(out FakeHandler handler, settings);
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Null(handler.LastRequest); // deaktiviert -> nichts gesendet
|
||||||
|
|
||||||
|
settings.WatchdogEnabled = true;
|
||||||
|
service.ReloadSettings(); // genau das macht der Speichern-Button
|
||||||
|
|
||||||
|
Assert.True(await WaitForRequestAsync(handler),
|
||||||
|
"Nach dem Aktivieren wurde kein Heartbeat gesendet.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Running_service_stops_sending_after_being_disabled_without_restart()
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
var service = Build(out FakeHandler handler, settings);
|
||||||
|
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(await WaitForRequestAsync(handler), "Es wurde kein initialer Heartbeat gesendet.");
|
||||||
|
|
||||||
|
settings.WatchdogEnabled = false;
|
||||||
|
service.ReloadSettings();
|
||||||
|
handler.Reset();
|
||||||
|
|
||||||
|
// Der Takt liegt bei 60 s; entscheidend ist, dass nach dem Ausschalten nichts mehr kommt.
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||||
|
Assert.Null(handler.LastRequest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReloadSettings_picks_up_changed_values()
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
settings.WatchdogUrl = "http://watchdog.example.de"; // zunächst ungültig
|
||||||
|
|
||||||
|
var service = new WatchdogHeartbeatService(new TerminalLogger(),
|
||||||
|
new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))),
|
||||||
|
() => settings);
|
||||||
|
|
||||||
|
Assert.NotNull(service.GetConfigurationProblem());
|
||||||
|
|
||||||
|
settings.WatchdogUrl = "https://watchdog.example.de";
|
||||||
|
service.ReloadSettings();
|
||||||
|
|
||||||
|
Assert.Null(service.GetConfigurationProblem());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- B.2: Status aus dem Log -----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeriveStatus_is_ok_without_errors()
|
||||||
|
{
|
||||||
|
var logger = new TerminalLogger();
|
||||||
|
var service = new WatchdogHeartbeatService(logger,
|
||||||
|
new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))), () => Valid());
|
||||||
|
|
||||||
|
Assert.Equal("ok", service.DeriveStatus().status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeriveStatus_reflects_logged_error_and_redacts_secrets()
|
||||||
|
{
|
||||||
|
var logger = new TerminalLogger();
|
||||||
|
var service = new WatchdogHeartbeatService(logger,
|
||||||
|
new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))), () => Valid());
|
||||||
|
|
||||||
|
logger.Error("DB-Verbindung tot; wallet 0xDEADBEEFCAFEBABE1234 token enc:v1:AAAABBBBCCCC");
|
||||||
|
|
||||||
|
var (status, message) = service.DeriveStatus();
|
||||||
|
Assert.Equal("error", status);
|
||||||
|
Assert.Contains("DB-Verbindung tot", message);
|
||||||
|
// Secrets müssen entschärft sein (kein Wallet-Hex / kein enc-Token durchreichen).
|
||||||
|
Assert.DoesNotContain("DEADBEEFCAFEBABE", message);
|
||||||
|
Assert.DoesNotContain("AAAABBBBCCCC", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeriveStatus_ignores_warning_and_info()
|
||||||
|
{
|
||||||
|
var logger = new TerminalLogger();
|
||||||
|
var service = new WatchdogHeartbeatService(logger,
|
||||||
|
new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))), () => Valid());
|
||||||
|
|
||||||
|
logger.Warning("nur eine Warnung");
|
||||||
|
logger.Info("nur Info");
|
||||||
|
|
||||||
|
// Warnungen/Infos eskalieren den Monitor bewusst nicht.
|
||||||
|
Assert.Equal("ok", service.DeriveStatus().status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("0xABCDEF0123456789 kaputt", "DEF012")]
|
||||||
|
[InlineData("secret enc:v1:ZZZZYYYYXXXX weg", "ZZZZYYYY")]
|
||||||
|
public void Sanitize_removes_secret_material(string input, string mustNotContain)
|
||||||
|
{
|
||||||
|
string cleaned = WatchdogHeartbeatService.Sanitize(input);
|
||||||
|
Assert.DoesNotContain(mustNotContain, cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sanitize_truncates_to_200_chars()
|
||||||
|
{
|
||||||
|
string cleaned = WatchdogHeartbeatService.Sanitize(new string('x', 500));
|
||||||
|
Assert.True(cleaned.Length <= 200, $"Länge {cleaned.Length}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- B.3: Lifecycle-Events -----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEventAsync_posts_kind_and_source_to_event_endpoint()
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
var service = Build(out FakeHandler handler, settings);
|
||||||
|
|
||||||
|
var result = await service.SendEventAsync("started", "info", "Test");
|
||||||
|
|
||||||
|
Assert.True(result.Success, result.Detail);
|
||||||
|
Assert.Equal("https://wd.example.de/api/event", handler.LastRequest!.RequestUri!.AbsoluteUri);
|
||||||
|
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||||
|
Assert.Equal("started", doc.RootElement.GetProperty("kind").GetString());
|
||||||
|
Assert.Equal("polytrader", doc.RootElement.GetProperty("source").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Service_sends_started_then_stopped_graceful_over_its_lifecycle()
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
var service = Build(out FakeHandler handler, settings);
|
||||||
|
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
Assert.True(await WaitForBodyContainingAsync(handler, "\"kind\":\"started\""),
|
||||||
|
"Es wurde kein 'started'-Event gesendet.");
|
||||||
|
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
Assert.True(handler.AnyBody(b => b.Contains("\"kind\":\"stopped_graceful\"")),
|
||||||
|
"Beim Herunterfahren wurde kein 'stopped_graceful'-Event gesendet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Helfer -----
|
||||||
|
|
||||||
|
/// <summary>Wartet begrenzt darauf, dass eine Anfrage mit passendem Body abgesetzt wurde.</summary>
|
||||||
|
private static async Task<bool> WaitForBodyContainingAsync(FakeHandler handler, string needle, int timeoutMs = 5000)
|
||||||
|
{
|
||||||
|
for (int waited = 0; waited < timeoutMs; waited += 50)
|
||||||
|
{
|
||||||
|
if (handler.AnyBody(b => b.Contains(needle))) return true;
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
return handler.AnyBody(b => b.Contains(needle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wartet begrenzt darauf, dass der Dienst eine Anfrage abgesetzt hat.</summary>
|
||||||
|
private static async Task<bool> WaitForRequestAsync(FakeHandler handler, int timeoutMs = 5000)
|
||||||
|
{
|
||||||
|
for (int waited = 0; waited < timeoutMs; waited += 50)
|
||||||
|
{
|
||||||
|
if (handler.LastRequest != null) return true;
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
return handler.LastRequest != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServerSettings Valid() => new()
|
||||||
|
{
|
||||||
|
WatchdogEnabled = true,
|
||||||
|
WatchdogUrl = "https://wd.example.de",
|
||||||
|
WatchdogToken = "secret-token",
|
||||||
|
WatchdogSource = "polytrader",
|
||||||
|
WatchdogInstance = "prod-1",
|
||||||
|
WatchdogIntervalSeconds = 60
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Problem(Action<ServerSettings> configure)
|
||||||
|
{
|
||||||
|
var settings = Valid();
|
||||||
|
configure(settings);
|
||||||
|
var service = Build(out _, settings);
|
||||||
|
return service.GetConfigurationProblem() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WatchdogHeartbeatService Build(
|
||||||
|
out FakeHandler handler,
|
||||||
|
ServerSettings settings,
|
||||||
|
Func<HttpRequestMessage, HttpResponseMessage>? responder = null)
|
||||||
|
{
|
||||||
|
handler = new FakeHandler(responder ?? (_ => new HttpResponseMessage(HttpStatusCode.OK)));
|
||||||
|
return new WatchdogHeartbeatService(new TerminalLogger(), new HttpClient(handler), () => settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fängt die Anfrage ab, statt sie zu senden.</summary>
|
||||||
|
private sealed class FakeHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||||
|
|
||||||
|
public FakeHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
|
||||||
|
|
||||||
|
public HttpRequestMessage? LastRequest { get; private set; }
|
||||||
|
public string? LastBody { get; private set; }
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly List<string> _bodies = new();
|
||||||
|
|
||||||
|
/// <summary>Thread-sicherer Prädikat-Check über alle bisher gesendeten Bodies.</summary>
|
||||||
|
public bool AnyBody(Func<string, bool> predicate)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
foreach (var b in _bodies)
|
||||||
|
if (predicate(b)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
LastRequest = null;
|
||||||
|
LastBody = null;
|
||||||
|
lock (_gate) _bodies.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastRequest = request;
|
||||||
|
string body = string.Empty;
|
||||||
|
if (request.Content != null)
|
||||||
|
{
|
||||||
|
body = await request.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
LastBody = body;
|
||||||
|
lock (_gate) _bodies.Add(body);
|
||||||
|
return _responder(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user