diff --git a/Licensing/LicenseGate.cs b/Licensing/LicenseGate.cs new file mode 100644 index 0000000..7c42418 --- /dev/null +++ b/Licensing/LicenseGate.cs @@ -0,0 +1,176 @@ +using System; +using LicenseLabrador.Client; +using PolyTrader.Core.Security; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; + +namespace PolyTraderSharp.Licensing +{ + /// + /// 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 (Environment.Exit) 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 des SDK; dieser Gate ist nur die Anwendungslogik drumherum. + /// + public static class LicenseGate + { + /// + /// Ö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. + /// + private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U="; + + /// Produkt-Slug im LicenseLabrador-Admin. + private const string ProductSlug = "pt"; + + /// + /// 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). + /// + private static readonly string[] Endpoints = { "https://license.mhdf.de" }; + + /// Offline-Kulanz, falls der Server das Feld nicht liefert (7 Tage). + private const int OfflineGraceHoursFallback = 168; + + public static LicenseConfig BuildConfig() => new LicenseConfig + { + ProductSlug = ProductSlug, + PublicKeyBase64 = PublicKeyBase64, + Endpoints = Endpoints, + OfflineGraceHoursFallback = OfflineGraceHoursFallback + }; + + /// + /// Führt die Startprüfung durch. Rückgabe true = 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. + /// + 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; + } + + /// + /// 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 . + /// + 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; + } + } + + /// Kurzbeschreibung eines nicht nutzbaren Ergebnisses für das Log/den Dialog. + 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() + }; + } + } +} diff --git a/PolyTrader.App.csproj b/PolyTrader.App.csproj index f7a560e..49021e8 100644 --- a/PolyTrader.App.csproj +++ b/PolyTrader.App.csproj @@ -91,6 +91,9 @@ + + \ No newline at end of file diff --git a/Program.cs b/Program.cs index 6c52dc6..3b3c833 100644 --- a/Program.cs +++ b/Program.cs @@ -69,6 +69,14 @@ internal static class Program // erst geladen werden. Dieselbe Instanz wird als Singleton weitergereicht (eine Quelle). 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). var allModules = new System.Collections.Generic.List { @@ -79,7 +87,15 @@ internal static class Program }; var disabledModules = new System.Collections.Generic.HashSet( 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(); AppHost = Host.CreateDefaultBuilder() // Config immer neben der EXE suchen (nicht im Arbeitsverzeichnis), damit die App @@ -126,11 +142,17 @@ internal static class Program } }); }); - services.AddSingleton(); + // 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(); services.AddSingleton(); services.AddSingleton(); + // 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())); + // MUSS als erster HostedService laufen: hydriert den State, bevor die // Trading-Services gegen einen leeren State anlaufen. services.AddHostedService(); @@ -145,13 +167,12 @@ internal static class Program services.AddHostedService(); services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService()); services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService()); + services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService()); services.AddSingleton(); services.AddTransient(); }).Build(); - // F1 (Sicherheit): Master-Key VOR jeder Credential-Entschlüsselung (Hydration) laden. - // Quelle: Umgebungsvariable POLYTRADER_MASTER_KEY, sonst gitignorierte master.key im App-Ordner. - ConfigureSecretProtection(AppHost.Services.GetRequiredService()); + // (Master-Key wurde bereits VOR dem Host-Build geladen – siehe ConfigureSecretProtection(bootLog) oben.) // F5 (Sicherheit): warnen, wenn die (remote) DB-Verbindung keine TLS-Erzwingung hat. WarnIfDbTlsNotEnforced(AppHost.Services); @@ -212,7 +233,8 @@ internal static class Program viewServices.GetRequiredService(), viewServices.GetRequiredService(), viewServices.GetRequiredService(), - viewServices.GetRequiredService()); + viewServices.GetRequiredService(), + viewServices.GetRequiredService()); return view; } }); @@ -533,6 +555,19 @@ internal static class Program 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 ==="); return failures; } diff --git a/Ui/LicenseDialog.Designer.cs b/Ui/LicenseDialog.Designer.cs new file mode 100644 index 0000000..137d946 --- /dev/null +++ b/Ui/LicenseDialog.Designer.cs @@ -0,0 +1,157 @@ +namespace PolyTraderSharp.Ui +{ + partial class LicenseDialog + { + /// Erforderliche Designer-Variable. + 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; + } +} diff --git a/Ui/LicenseDialog.cs b/Ui/LicenseDialog.cs new file mode 100644 index 0000000..33dd3d6 --- /dev/null +++ b/Ui/LicenseDialog.cs @@ -0,0 +1,129 @@ +using System; +using System.Drawing; +using System.Windows.Forms; +using LicenseLabrador.Client; +using PolyTraderSharp.Licensing; + +namespace PolyTraderSharp.Ui +{ + /// + /// 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 + /// und gesetzt; der Aufrufer (LicenseGate) speichert den Schlüssel. + /// Ohne gültige Lizenz kann der Nutzer eingeschränkt (nur Core-Shell) weiterstarten. + /// + public partial class LicenseDialog : Form + { + private readonly LicenseClient _client; + + /// Nur gesetzt, wenn im Dialog erfolgreich validiert wurde. + public LicenseResult? ValidatedResult { get; private set; } + + /// Der erfolgreich validierte Schlüssel (für die verschlüsselte Ablage). + public string? ValidatedKey { get; private set; } + + /// + /// 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". + /// + 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 */ } + } + } +} diff --git a/Ui/Views/SettingsView.Designer.cs b/Ui/Views/SettingsView.Designer.cs index 8c9d456..536640b 100644 --- a/Ui/Views/SettingsView.Designer.cs +++ b/Ui/Views/SettingsView.Designer.cs @@ -26,6 +26,9 @@ namespace PolyTraderSharp.Ui.Views btn_loadsettings = new ToolStripButton(); btnGenMasterKey = new ToolStripButton(); btnSetOpenRouterKey = new ToolStripButton(); + btnSetWatchdogToken = new ToolStripButton(); + btnTestWatchdog = new ToolStripButton(); + btnSetLicenseKey = new ToolStripButton(); tabPage2 = new TabPage(); pgAccount = new PropertyGrid(); dgvAccounts = new DataGridView(); @@ -74,7 +77,7 @@ namespace PolyTraderSharp.Ui.Views // toolStrip2 // 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.Name = "toolStrip2"; toolStrip2.Size = new Size(1171, 34); @@ -112,6 +115,30 @@ namespace PolyTraderSharp.Ui.Views btnSetOpenRouterKey.Size = new Size(220, 29); btnSetOpenRouterKey.Text = "OpenRouter-Key setzen …"; 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 // @@ -207,6 +234,9 @@ namespace PolyTraderSharp.Ui.Views private ToolStripButton btn_loadsettings; private ToolStripButton btnGenMasterKey; private ToolStripButton btnSetOpenRouterKey; + private ToolStripButton btnSetWatchdogToken; + private ToolStripButton btnTestWatchdog; + private ToolStripButton btnSetLicenseKey; private ToolStrip toolStripAccounts; private ToolStripButton btnAccNew; private ToolStripButton btnAccDelete; diff --git a/Ui/Views/SettingsView.cs b/Ui/Views/SettingsView.cs index 0424ec9..ed5ce02 100644 --- a/Ui/Views/SettingsView.cs +++ b/Ui/Views/SettingsView.cs @@ -3,8 +3,10 @@ using System.ComponentModel; using System.IO; using System.Linq; using System.Security.Cryptography; +using System.Threading.Tasks; using System.Windows.Forms; using PolyTrader.Core.Persistence; +using PolyTrader.Core.Security; using PolyTraderSharp.Models; using PolyTraderSharp.Services; @@ -23,6 +25,7 @@ namespace PolyTraderSharp.Ui.Views private ServerSettings _settings = new(); private ThreemaService? _threema; private MullvadVpnService? _vpn; + private WatchdogHeartbeatService? _watchdog; private TerminalLogger? _logger; private IAccountRepository? _accountRepo; @@ -37,6 +40,9 @@ namespace PolyTraderSharp.Ui.Views btn_loadsettings.Click += (_, _) => Reload(); btnGenMasterKey.Click += (_, _) => GenerateMasterKey(); btnSetOpenRouterKey.Click += (_, _) => SetOpenRouterKey(); + btnSetWatchdogToken.Click += (_, _) => SetWatchdogToken(); + btnTestWatchdog.Click += async (_, _) => await TestWatchdogAsync(); + btnSetLicenseKey.Click += (_, _) => SetLicenseKey(); UpdateMasterKeyButtonState(); btnAccNew.Click += (_, _) => AddAccount(); @@ -52,13 +58,14 @@ namespace PolyTraderSharp.Ui.Views } public void Initialize(ThreemaService threema, MullvadVpnService vpn, TerminalLogger logger, - IAccountRepository accountRepo, TradingState state) + IAccountRepository accountRepo, TradingState state, WatchdogHeartbeatService? watchdog = null) { _threema = threema; _vpn = vpn; _logger = logger; _accountRepo = accountRepo; _state = state; + _watchdog = watchdog; Reload(); LoadAccounts(); @@ -79,6 +86,7 @@ namespace PolyTraderSharp.Ui.Views _settings.Save(SettingsPath); _threema?.ReloadSettings(); _vpn?.ReloadSettings(); + _watchdog?.ReloadSettings(); _logger?.Info("Server-Einstellungen gespeichert und Services neu geladen."); MessageBox.Show("Server-Einstellungen gespeichert.", "Erfolg", MessageBoxButtons.OK, MessageBoxIcon.Information); @@ -135,6 +143,158 @@ namespace PolyTraderSharp.Ui.Views dgvAccounts.Refresh(); } + // ===== Watchdog ===== + + /// + /// 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. + /// + 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); + } + } + + /// + /// 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. + /// + 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) ===== + + /// + /// Ö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. + /// + 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) ===== /// Pfad der Master-Key-Datei – identisch zu Program.cs (App-Ordner, gitignored). diff --git a/docs/umsetzungsplaene/UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.md b/docs/umsetzungsplaene/UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.md new file mode 100644 index 0000000..4452f3d --- /dev/null +++ b/docs/umsetzungsplaene/UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.md @@ -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 ``-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`), 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();` + +**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. diff --git a/src/PolyTrader.Core/Models/ServerSettings.cs b/src/PolyTrader.Core/Models/ServerSettings.cs index 03351b0..e0ce3a9 100644 --- a/src/PolyTrader.Core/Models/ServerSettings.cs +++ b/src/PolyTrader.Core/Models/ServerSettings.cs @@ -89,6 +89,81 @@ namespace PolyTraderSharp.Models 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; } = ""; + + /// + /// Agent-Token für den Header X-Watchdog-Key. 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 + /// (enc:v1:…) in der gitignorierten server_settings.xml landet. + /// + [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; + + + /// + /// 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 (enc:v1:…) in der gitignorierten server_settings.xml. + /// + [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) { if (!File.Exists(path)) diff --git a/src/PolyTrader.Core/Services/WatchdogHeartbeatService.cs b/src/PolyTrader.Core/Services/WatchdogHeartbeatService.cs new file mode 100644 index 0000000..4cd5529 --- /dev/null +++ b/src/PolyTrader.Core/Services/WatchdogHeartbeatService.cs @@ -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 +{ + /// Ergebnis eines Sendeversuchs – für die UI-Rückmeldung des Test-Buttons. + public sealed record WatchdogSendResult(bool Success, string Detail); + + /// + /// 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 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 + /// http:// ist nur für localhost erlaubt, damit der Agent-Token nicht im Klartext + /// über das Netz geht. + /// + public class WatchdogHeartbeatService : BackgroundService + { + /// Untergrenze des Sende-Takts – schützt den Watchdog vor einem Client-Flood. + private const int MinIntervalSeconds = 10; + + /// + /// Takt, in dem die Konfiguration neu ausgewertet wird. Klein genug, dass Änderungen aus + /// dem Settings-Fenster praktisch sofort greifen, und ohne messbare Last. + /// + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1); + + /// + /// Nachwirkzeit eines Fehlers: Nach einem meldet der Heartbeat + /// so lange status=error, 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. + /// + private static readonly TimeSpan ErrorStickyWindow = TimeSpan.FromMinutes(5); + + private const string DefaultSettingsPath = "server_settings.xml"; + + private readonly TerminalLogger _logger; + private readonly HttpClient _http; + + /// Quelle der Einstellungen – in Tests ersetzbar, im Betrieb die gitignorierte XML-Datei. + private readonly Func _settingsLoader; + + private ServerSettings _settings; + + /// Zustand der letzten Übertragung – für entprosseltes Logging (kein Log-Spam im Minutentakt). + 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? 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; + } + + /// + /// Schneidet -Meldungen mit, damit der nächste Heartbeat + /// status=error melden kann. Läuft synchron auf dem loggenden Thread – daher bewusst + /// minimal (nur Zeitstempel + gekürzte, entschärfte Meldung merken). + /// + private void OnAppLog(object? sender, LogMessageEventArgs e) + { + if (e.Level != LogLevel.Error) return; + lock (_statusLock) + { + _lastAppErrorUtc = DateTime.UtcNow; + _lastAppErrorMessage = Sanitize(e.Message); + } + } + + /// Lädt die Einstellungen neu (nach dem Speichern im Settings-Fenster). + public void ReloadSettings() => _settings = _settingsLoader(); + + /// + /// Prüft die aktuelle Konfiguration. null = sendebereit, sonst ein kurzer, + /// anzeigbarer Grund. Wird auch vom Test-Button der Settings-View genutzt. + /// + 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; + } + + /// + /// Haupt-Schleife. Bewusst kein 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. + /// + 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."); + } + } + + /// + /// 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. + /// + 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(); + } + + /// + /// 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. + /// + public async Task 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); + } + } + + /// Sendet und protokolliert nur Zustandswechsel – kein Log-Eintrag pro Tick. + 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; + } + + /// + /// B.3: Sendet ein Lifecycle-Event an /api/event (z. B. „started", „stopped_graceful"). + /// Best effort – wirft nie und blockiert das Herunterfahren nicht. + /// + public async Task 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); } + } + + /// + /// B.2: Leitet den zu meldenden Gesundheitsstatus aus dem App-Log ab. Ein + /// hält den Status für auf error (mit der letzten, entschärften + /// Fehlermeldung); danach wieder ok. Bewusst keine Warnungs-Eskalation: App-Warnungen sind zu + /// häufig, um daraus einen Monitor-Alarm zu rechtfertigen. + /// + internal (string status, string? message) DeriveStatus() + { + lock (_statusLock) + { + if (_lastAppErrorMessage != null && (DateTime.UtcNow - _lastAppErrorUtc) <= ErrorStickyWindow) + return ("error", _lastAppErrorMessage); + } + return ("ok", null); + } + + /// + /// 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. + /// + 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; + } + + /// Sende-Takt, nach unten begrenzt. + private int EffectiveIntervalSeconds() => + Math.Max(MinIntervalSeconds, _settings.WatchdogIntervalSeconds); + + /// + /// Baut die Ziel-URL und erzwingt TLS. http:// ist ausschließlich für localhost + /// erlaubt (lokales Testen); sonst würde der Agent-Token im Klartext übertragen. + /// + 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; + } + + /// Version der laufenden Assembly – landet als Metadatum im Heartbeat. + private static string AppVersion => + System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown"; + } +} diff --git a/tests/PolyTrader.Tests/WatchdogHeartbeatTests.cs b/tests/PolyTrader.Tests/WatchdogHeartbeatTests.cs new file mode 100644 index 0000000..bb3f320 --- /dev/null +++ b/tests/PolyTrader.Tests/WatchdogHeartbeatTests.cs @@ -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 +{ + /// + /// 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. + /// + 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 ----- + + /// Wartet begrenzt darauf, dass eine Anfrage mit passendem Body abgesetzt wurde. + private static async Task 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)); + } + + /// Wartet begrenzt darauf, dass der Dienst eine Anfrage abgesetzt hat. + private static async Task 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 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? responder = null) + { + handler = new FakeHandler(responder ?? (_ => new HttpResponseMessage(HttpStatusCode.OK))); + return new WatchdogHeartbeatService(new TerminalLogger(), new HttpClient(handler), () => settings); + } + + /// Fängt die Anfrage ab, statt sie zu senden. + private sealed class FakeHandler : HttpMessageHandler + { + private readonly Func _responder; + + public FakeHandler(Func responder) => _responder = responder; + + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastBody { get; private set; } + + private readonly object _gate = new(); + private readonly List _bodies = new(); + + /// Thread-sicherer Prädikat-Check über alle bisher gesendeten Bodies. + public bool AnyBody(Func 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 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); + } + } + } +}