Watchdog-Heartbeat + LicenseLabrador-Lizenzpruefung eingebunden

Slices W-A/W-B/W-C und L-A aus UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.

Watchdog (Dead-Man's-Switch, externer Server):
- WatchdogHeartbeatService als BackgroundService + DI-Singleton; jeder Sendeversuch
  gekapselt, ein Ausfall des Watchdogs beeintraechtigt PolyTrader nie.
- Eigene Implementierung statt Test-Client des Fremdprojekts: TLS-Pruefung bleibt
  aktiv, http:// nur fuer localhost (Agent-Token nicht im Klartext ins Netz).
- Status aus dem App-Log abgeleitet (Error mit 5-Minuten-Sticky-Fenster, entprellt),
  Lifecycle-Events started/stopping.
- Konfiguration in ServerSettings; Agent-Token [Browsable(false)] mit maskierter
  Eingabe + Statusanzeige, bei gesetztem Master-Key verschluesselt (enc:v1:).

Lizenz (LicenseLabrador, Ed25519):
- LicenseGate.RunStartupGate prueft beim Start; bei ungueltiger Lizenz wird die
  Modulliste leer gebaut, sodass nur die Core-Shell (Terminal/Einstellungen)
  startet. Bewusst kein Environment.Exit - ein Trading-Bot darf nicht mitten im
  Lauf hart sterben. TamperSuspected gilt als nicht nutzbar.
- LicenseDialog (partial + .Designer.cs) fuer Start- und Verwalten-Modus, mit
  Hardware-ID zum Kopieren; Smoke-UI konstruiert beide Modi headless.
- Master-Key wird jetzt VOR dem Host-Build geladen, da auch der Lizenzschluessel
  entschluesselt werden muss; derselbe TerminalLogger wird als Singleton
  weitergereicht, damit die Startmeldungen im Terminal-Fenster erscheinen.

Der Lizenz-SDK-Client wird per Cross-Repo-Projektreferenz auf
..\..\LicenseLabrador eingebunden, damit SDK-Fixes ohne Kopie einfliessen.

484 Zeilen Tests fuer den Heartbeat; Suite gruen (438 Tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-04 09:04:14 +02:00
co-authored by Claude Opus 5
parent bb103a578a
commit ca750a06f5
11 changed files with 2122 additions and 8 deletions
+176
View File
@@ -0,0 +1,176 @@
using System;
using LicenseLabrador.Client;
using PolyTrader.Core.Security;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
namespace PolyTraderSharp.Licensing
{
/// <summary>
/// Lizenz-Torwächter für den Programmstart (B.4). Prüft beim Start die LicenseLabrador-Lizenz und
/// entscheidet, ob PolyTrader voll (mit allen Modulen) oder eingeschränkt (nur Core-Shell, damit
/// Terminal und Einstellungen zum Eintragen einer Lizenz erreichbar bleiben) startet.
///
/// **Grundsatz:** kein harter Abbruch (<c>Environment.Exit</c>) bei ungültiger Lizenz. Ein
/// Trading-Bot mit offenen Positionen darf nicht mitten im Lauf hart abgeschossen werden im
/// Zweifel startet die Shell ohne die Trading-/Analyse-Module weiter. Der Gate wirft nie; jeder
/// Fehler in der Prüfung führt zum eingeschränkten Modus, nicht zum Absturz.
///
/// Die eigentliche Kryptografie (Ed25519-Signaturprüfung, Nonce, Offline-Kulanz, Hardware-Bindung)
/// liegt im <see cref="LicenseClient"/> des SDK; dieser Gate ist nur die Anwendungslogik drumherum.
/// </summary>
public static class LicenseGate
{
/// <summary>
/// Öffentlicher Ed25519-Schlüssel des Lizenzservers (license.mhdf.de), Base64. Bewusst fest
/// eingebettet: nur mit diesem Schlüssel lässt sich eine Server-Antwort verifizieren ein
/// Angreifer kann ohne den (nur serverseitigen) privaten Schlüssel kein „valid" fälschen.
/// </summary>
private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U=";
/// <summary>Produkt-Slug im LicenseLabrador-Admin.</summary>
private const string ProductSlug = "pt";
/// <summary>
/// Lizenzserver-Endpunkt. Ausschließlich HTTPS über http:// gingen Lizenzschlüssel und
/// Hardware-ID im Klartext übers Netz (die signierte Antwort bliebe zwar fälschungssicher,
/// der Schlüssel würde aber geleakt).
/// </summary>
private static readonly string[] Endpoints = { "https://license.mhdf.de" };
/// <summary>Offline-Kulanz, falls der Server das Feld nicht liefert (7 Tage).</summary>
private const int OfflineGraceHoursFallback = 168;
public static LicenseConfig BuildConfig() => new LicenseConfig
{
ProductSlug = ProductSlug,
PublicKeyBase64 = PublicKeyBase64,
Endpoints = Endpoints,
OfflineGraceHoursFallback = OfflineGraceHoursFallback
};
/// <summary>
/// Führt die Startprüfung durch. Rückgabe <c>true</c> = App darf voll (mit Modulen) starten.
/// Zeigt bei ungültiger Lizenz einen modalen Dialog (Schlüssel eingeben, Hardware-ID anzeigen,
/// eingeschränkt starten). Wirft nie.
/// </summary>
public static bool RunStartupGate(ServerSettings settings, TerminalLogger logger, string settingsPath)
{
LicenseClient client;
try
{
client = new LicenseClient(BuildConfig());
}
catch (Exception ex)
{
logger.Error($"❌ Lizenzprüfung nicht initialisierbar: {ex.Message}. Start im eingeschränkten Modus.");
return false;
}
string storedKey = "";
try
{
// Erlaubt einen mit dem Master-Key verschlüsselten Schlüssel (enc:v1:…); Klartext
// wird unverändert durchgereicht.
storedKey = SecretProtection.Unprotect(settings.LicenseKey);
}
catch (Exception ex)
{
logger.Warning($"⚠️ Gespeicherter Lizenzschlüssel nicht lesbar ({ex.Message}) behandle als 'keine Lizenz'.");
}
LicenseResult result = EvaluateBlocking(client, storedKey);
if (result.IsUsable)
{
LogUsable(result, logger);
return true;
}
logger.Warning($"⚠️ Keine gültige Lizenz: {Describe(result)}. Öffne Lizenzdialog.");
LicenseResult? dialogResult;
string? dialogKey;
try
{
using var dlg = new PolyTraderSharp.Ui.LicenseDialog(client, storedKey, result);
dlg.ShowDialog();
dialogResult = dlg.ValidatedResult;
dialogKey = dlg.ValidatedKey;
}
catch (Exception ex)
{
logger.Error($"❌ Lizenzdialog fehlgeschlagen: {ex.Message}. Start im eingeschränkten Modus.");
return false;
}
if (dialogResult != null && dialogResult.IsUsable && !string.IsNullOrWhiteSpace(dialogKey))
{
try
{
// Bei gesetztem Master-Key verschlüsselt speichern; sonst Klartext (Passthrough).
settings.LicenseKey = SecretProtection.Protect(dialogKey!.Trim());
settings.Save(settingsPath);
logger.Info("🔑 Lizenz validiert und gespeichert.");
}
catch (Exception ex)
{
logger.Error($"Lizenz gültig, aber Speichern des Schlüssels fehlgeschlagen: {ex.Message}.");
}
LogUsable(dialogResult, logger);
return true;
}
logger.Warning(
"⚠️ Start im EINGESCHRÄNKTEN Modus keine Module aktiv, nur Terminal und Einstellungen. " +
"Lizenz in den Einstellungen setzen und PolyTrader neu starten.");
return false;
}
/// <summary>
/// Validiert synchron (blockierend). Das SDK arbeitet durchgängig mit ConfigureAwait(false),
/// daher ist das Blockieren vor der Message-Loop unbedenklich. Ein leerer Schlüssel ergibt
/// planmäßig <see cref="LicenseState.NoLicense"/>.
/// </summary>
internal static LicenseResult EvaluateBlocking(LicenseClient client, string? key)
{
return client.ValidateAsync(key ?? string.Empty).GetAwaiter().GetResult();
}
private static void LogUsable(LicenseResult result, TerminalLogger logger)
{
switch (result.State)
{
case LicenseState.Valid:
logger.Info("🔑 Lizenz gültig (online geprüft).");
break;
case LicenseState.ValidOffline:
logger.Warning($"🔑 Lizenz im Offline-Kulanzmodus gültig bis {result.GraceUntil:g} " +
"(Server nicht erreichbar).");
break;
case LicenseState.ValidLocalFile:
logger.Info("🔑 Lizenz über lokale Offline-Lizenzdatei gültig.");
break;
default:
logger.Info($"🔑 Lizenz nutzbar ({result.State}).");
break;
}
}
/// <summary>Kurzbeschreibung eines nicht nutzbaren Ergebnisses für das Log/den Dialog.</summary>
internal static string Describe(LicenseResult result)
{
return result.State switch
{
LicenseState.NoLicense => "kein oder ungültiger Schlüssel / Server nicht erreichbar",
LicenseState.NotFound => "Schlüssel dem Server unbekannt",
LicenseState.Revoked => "Lizenz wurde widerrufen",
LicenseState.Expired => "Lizenz abgelaufen",
LicenseState.ActivationLimit => "maximale Aktivierungen erreicht",
LicenseState.TamperSuspected => "Manipulation erkannt (Signatur/Uhr) Prüfung verweigert",
_ => result.State.ToString()
};
}
}
}