diff --git a/docs/BETRIEB-Watchdog-Lizenz.md b/docs/BETRIEB-Watchdog-Lizenz.md new file mode 100644 index 0000000..edccc80 --- /dev/null +++ b/docs/BETRIEB-Watchdog-Lizenz.md @@ -0,0 +1,74 @@ +# Betrieb: Watchdog-Überwachung & LicenseLabrador-Lizenzierung + +Beide Integrationen sitzen im `Predictalytics.WinFormsHost` (dem Produktiv-Host) und +binden zwei eigenständige Schwester-Projekte an: + +| Projekt | Pfad | Rolle | +|---|---|---| +| Watchdog | `J:\Softwareprojekte\WatchDog` | PHP/MySQL-Server auf `watchdog.mhdf.de`, empfängt Heartbeats | +| LicenseLabrador | `J:\Softwareprojekte\LicenseLabrador` | PHP-Lizenzserver auf `license.mhdf.de` + C#-SDK | + +--- + +## 1. Watchdog (Dead-Man's-Switch) + +`Services/WatchdogHeartbeatService.cs` sendet alle *n* Sekunden einen +`POST /api/heartbeat` an den Watchdog. Bleiben die Heartbeats aus — weil die App +abgestürzt ist oder die ganze Maschine weg ist — schlägt der Watchdog Alarm. +Beim regulären Schließen geht ein `POST /api/event` mit `kind=stopping` raus, +damit ein geplantes Beenden nicht als Crash alarmiert wird. + +**Wichtig:** Ein Ausfall des Watchdogs darf Predictalytics nie beeinträchtigen. +Alle Aufrufe sind best effort; der erste Fehlschlag wird als Warnung geloggt, +Folgefehler nur noch auf Debug-Level (keine Log-Flut). + +### Konfiguration (PropertyGrid im Host, Kategorie „Watchdog") + +| Feld | Default | Bedeutung | +|---|---|---| +| `Enabled` | `true` | Heartbeats an/aus | +| `Server URL` | `https://watchdog.mhdf.de` | Basis-URL | +| `API Key` | *(leer)* | `X-Watchdog-Key` — Shared Key **oder** Agent-Token. Ohne Key passiert nichts. | +| `Source` | `predictalytics` | Monitor-Name im Dashboard (Auto-Registrierung beim ersten Beat) | +| `Instance` | `default` | falls mehrere Instanzen laufen | +| `Interval (Sekunden)` | `60` | Sende-Takt; Alarm nach ca. `Intervall × 1,5 + 30 s` | + +Der Key landet in der `settings.json` neben der Exe (nicht im Git). + +### PolyTrader-Maschine mit überwachen + +Der Heartbeat aus Predictalytics deckt nur *diesen* Prozess ab. Damit auch die +Maschine überwacht wird, auf der PolyTrader läuft, gehört dort zusätzlich der +OS-Agent hin: `WatchDog\agents\windows\watchdog-agent.ps1` als Scheduled Task +(inkl. Shutdown-Hook), bzw. `agents/linux/watchdog-agent.sh` per systemd-Timer. + +--- + +## 2. LicenseLabrador (Kopierschutz) + +`Services/LicenseGuard.cs` prüft beim Start, ob eine nutzbare Lizenz vorliegt +(`Program.Main` bricht sonst ab, bevor die MainForm überhaupt entsteht). +Ohne gültige Lizenz erscheint `Services/LicenseDialog.cs` zur Key-Eingabe. + +Produkt-Slug, Endpoint und der Ed25519-Public-Key sind **bewusst einkompiliert** +und nicht konfigurierbar — ein einstellbarer Endpoint würde erlauben, die App auf +einen gefälschten Lizenzserver zu zeigen. + +- **Produkt-Slug:** `predictalytics` +- **Offline-Gnadenfrist:** 168 h (7 Tage) — danach ist Serverkontakt nötig +- **Revalidierung zur Laufzeit:** alle 12 h; bei Widerruf/Ablauf beendet sich die App +- **Härtung:** `VerifyChecksum` (HMAC über State + Key + Hardware-ID) gegen Memory-Patches; + Nonce-Reflexion und Signaturprüfung übernimmt das SDK + +### Einmalige Server-Schritte + +1. Im Admin-Backend von `license.mhdf.de` das Produkt mit Slug **`predictalytics`** anlegen + (ohne den Eintrag liefert der Server `not_found`). +2. Lizenzschlüssel ausstellen und beim ersten Start im Dialog eingeben. +3. Prüfen, dass der einkompilierte Public Key in `LicenseGuard.PublicKeyBase64` + dem `signing.pub` des Servers entspricht. + +### Deaktivierung bei PC-Wechsel + +`LicenseClient.DeactivateAsync()` gibt die Aktivierung wieder frei. Aktuell nicht +in der UI verdrahtet — bei Bedarf als Menüpunkt ergänzen. diff --git a/src/Predictalytics.WinFormsHost/AppSettings.cs b/src/Predictalytics.WinFormsHost/AppSettings.cs index cd442bf..8961568 100644 --- a/src/Predictalytics.WinFormsHost/AppSettings.cs +++ b/src/Predictalytics.WinFormsHost/AppSettings.cs @@ -27,6 +27,42 @@ public class AppSettings [Editor("System.ComponentModel.Design.MultilineStringEditor, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(System.Drawing.Design.UITypeEditor))] public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; } + [Category("Watchdog")] + [DisplayName("Enabled")] + [Description("Sendet periodische Heartbeats an den externen Watchdog-Server (Dead-Man's-Switch). Benötigt einen API Key.")] + [DefaultValue(true)] + public bool WatchdogEnabled { get; set; } = true; + + [Category("Watchdog")] + [DisplayName("Server URL")] + [Description("Basis-URL des Watchdog-Servers.")] + [DefaultValue("https://watchdog.mhdf.de")] + public string WatchdogUrl { get; set; } = "https://watchdog.mhdf.de"; + + [Category("Watchdog")] + [DisplayName("API Key")] + [Description("Shared Key oder Agent-Token des Watchdog-Servers (X-Watchdog-Key). Ohne Key werden keine Heartbeats gesendet.")] + [PasswordPropertyText(true)] + public string WatchdogApiKey { get; set; } = ""; + + [Category("Watchdog")] + [DisplayName("Source")] + [Description("Eindeutiger Monitor-Name dieses Dienstes im Watchdog-Dashboard.")] + [DefaultValue("predictalytics")] + public string WatchdogSource { get; set; } = "predictalytics"; + + [Category("Watchdog")] + [DisplayName("Instance")] + [Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")] + [DefaultValue("default")] + public string WatchdogInstance { get; set; } = "default"; + + [Category("Watchdog")] + [DisplayName("Interval (Sekunden)")] + [Description("Sende-Takt der Heartbeats. Der Watchdog alarmiert, wenn ~1,5× dieses Intervall + 30 s ohne Heartbeat vergehen.")] + [DefaultValue(60)] + public int WatchdogIntervalSeconds { get; set; } = 60; + private string _dbServer = "localhost"; private string _dbName = ""; private string _dbUser = ""; diff --git a/src/Predictalytics.WinFormsHost/MainForm.cs b/src/Predictalytics.WinFormsHost/MainForm.cs index 2f62794..1730de0 100644 --- a/src/Predictalytics.WinFormsHost/MainForm.cs +++ b/src/Predictalytics.WinFormsHost/MainForm.cs @@ -11,6 +11,7 @@ public partial class MainForm : Form private bool _workerRunning; private bool _webServerRunning; private AppSettings _settings = null!; + private WatchdogHeartbeatService? _watchdog; /// Exposes the terminal RichTextBox for the Serilog sink. public RichTextBox Terminal => rtb_terminal; @@ -40,6 +41,7 @@ public partial class MainForm : Form _webServer.DbConnectionDebug = _settings.DbConnectionDebug; _webServer.EgressChannelsText = _settings.EgressChannelsText; } + RestartWatchdog(); }; _webServer = new EmbeddedWebServer(); @@ -69,6 +71,39 @@ public partial class MainForm : Form var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 }; dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync(); dbSizeTimer.Start(); + + RestartWatchdog(); + } + + /// + /// (Re-)creates the Watchdog heartbeat sender from the current settings. + /// Called at startup and whenever settings change. + /// + private void RestartWatchdog() + { + _watchdog?.Dispose(); + _watchdog = null; + + if (!_settings.WatchdogEnabled) return; + + if (string.IsNullOrWhiteSpace(_settings.WatchdogApiKey) || string.IsNullOrWhiteSpace(_settings.WatchdogUrl)) + { + Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen — bitte in den Settings eintragen."); + return; + } + + _watchdog = new WatchdogHeartbeatService( + _settings.WatchdogUrl, + _settings.WatchdogApiKey, + _settings.WatchdogSource, + _settings.WatchdogInstance, + _settings.WatchdogIntervalSeconds, + metadataProvider: () => new + { + workersRunning = _workerRunning, + webserverRunning = _webServerRunning + }); + _watchdog.Start(); } private async void Btn_serverstart_Click(object? sender, EventArgs e) @@ -142,6 +177,9 @@ public partial class MainForm : Form protected override void OnFormClosing(FormClosingEventArgs e) { + _watchdog?.NotifyStopping(); + _watchdog?.Dispose(); + _watchdog = null; _workerCts?.Cancel(); _webServer?.StopWebServerAsync().GetAwaiter().GetResult(); base.OnFormClosing(e); diff --git a/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj b/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj index 63cc61f..adbcb34 100644 --- a/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj +++ b/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj @@ -31,6 +31,8 @@ + + diff --git a/src/Predictalytics.WinFormsHost/Program.cs b/src/Predictalytics.WinFormsHost/Program.cs index 5e9c925..b561731 100644 --- a/src/Predictalytics.WinFormsHost/Program.cs +++ b/src/Predictalytics.WinFormsHost/Program.cs @@ -1,4 +1,5 @@ using Predictalytics.Infrastructure.Logging; +using Predictalytics.WinFormsHost.Services; using Serilog; using Serilog.Events; @@ -11,6 +12,13 @@ internal static class Program { ApplicationConfiguration.Initialize(); + // ─── License gate: no usable license, no app ─── + var licenseClient = LicenseGuard.EnsureLicensed(); + if (licenseClient == null) + { + return; + } + var mainForm = new MainForm(); var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm); @@ -145,6 +153,10 @@ internal static class Program Log.Warning("══════════════════════════════════════════════════════"); mainForm.Initialize(); + + // While running: re-check the license every 12 h (revocation/expiry/offline grace). + using var licenseTimer = LicenseGuard.StartPeriodicRevalidation(licenseClient); + System.Windows.Forms.Application.Run(mainForm); Log.Information("Application shutting down."); diff --git a/src/Predictalytics.WinFormsHost/Services/LicenseDialog.cs b/src/Predictalytics.WinFormsHost/Services/LicenseDialog.cs new file mode 100644 index 0000000..0d0defa --- /dev/null +++ b/src/Predictalytics.WinFormsHost/Services/LicenseDialog.cs @@ -0,0 +1,118 @@ +using LicenseLabrador.Client; + +namespace Predictalytics.WinFormsHost.Services; + +/// +/// Modal dialog shown at startup when no usable license is present. +/// Lets the user enter/activate a license key; closes with OK only after a +/// successful, checksum-verified validation. +/// +public sealed class LicenseDialog : Form +{ + private readonly LicenseClient _client; + private readonly Label _lblStatus; + private readonly TextBox _txtKey; + private readonly Button _btnActivate; + private readonly Button _btnExit; + + public LicenseResult? Result { get; private set; } + + public LicenseDialog(LicenseClient client, LicenseResult? lastResult) + { + _client = client; + + Text = "Predictalytics — Lizenzaktivierung"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterScreen; + ClientSize = new Size(460, 190); + + var lblInfo = new Label + { + Text = "Diese Installation benötigt eine gültige Lizenz.\nBitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):", + Location = new Point(12, 12), + Size = new Size(436, 34) + }; + + _txtKey = new TextBox + { + Location = new Point(12, 52), + Size = new Size(436, 26), + Font = new Font("Consolas", 11f), + CharacterCasing = CharacterCasing.Upper + }; + + _lblStatus = new Label + { + Location = new Point(12, 84), + Size = new Size(436, 50), + ForeColor = Color.Firebrick, + Text = FormatInitialStatus(lastResult) + }; + + _btnActivate = new Button + { + Text = "Aktivieren", + Location = new Point(252, 146), + Size = new Size(96, 30) + }; + _btnActivate.Click += async (_, _) => await ActivateAsync(); + + _btnExit = new Button + { + Text = "Beenden", + Location = new Point(354, 146), + Size = new Size(94, 30), + DialogResult = DialogResult.Cancel + }; + + AcceptButton = _btnActivate; + CancelButton = _btnExit; + Controls.AddRange(new Control[] { lblInfo, _txtKey, _lblStatus, _btnActivate, _btnExit }); + } + + private static string FormatInitialStatus(LicenseResult? lastResult) + { + if (lastResult == null || lastResult.State == LicenseState.NoLicense) return ""; + return $"Letzte Prüfung: {lastResult.State} — {lastResult.Message}"; + } + + private async Task ActivateAsync() + { + var key = _txtKey.Text.Trim(); + if (string.IsNullOrWhiteSpace(key)) + { + _lblStatus.Text = "Bitte einen Lizenzschlüssel eingeben."; + return; + } + + _btnActivate.Enabled = false; + _lblStatus.ForeColor = Color.DimGray; + _lblStatus.Text = "Prüfe Lizenz am Server..."; + + try + { + var result = await _client.ValidateAsync(key); + if (result.IsUsable && _client.VerifyChecksum(result)) + { + Result = result; + DialogResult = DialogResult.OK; + Close(); + return; + } + + _lblStatus.ForeColor = Color.Firebrick; + _lblStatus.Text = $"Lizenz nicht nutzbar ({result.State}):\n{result.Message}"; + } + catch (Exception ex) + { + _lblStatus.ForeColor = Color.Firebrick; + _lblStatus.Text = $"Fehler bei der Prüfung: {ex.Message}"; + } + finally + { + _btnActivate.Enabled = true; + } + } +} diff --git a/src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs b/src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs new file mode 100644 index 0000000..a70750e --- /dev/null +++ b/src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs @@ -0,0 +1,98 @@ +using LicenseLabrador.Client; +using Serilog; + +namespace Predictalytics.WinFormsHost.Services; + +/// +/// Startup license gate backed by the LicenseLabrador server. +/// Endpoint, product slug and the Ed25519 public key are deliberately compiled in +/// (not user configuration): a configurable endpoint/key would let anyone point the +/// app at a fake license server. +/// +public static class LicenseGuard +{ + private const string ProductSlug = "predictalytics"; + private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U="; + private const string BasicAuthUser = "Labrador"; + private const string BasicAuthPassword = "Labrador02763!"; + private static readonly string[] Endpoints = { "http://license.mhdf.de/public/api/v1" }; + + /// Re-check interval while the app is running (12 h). + public const int RevalidationIntervalMs = 12 * 60 * 60 * 1000; + + public static LicenseClient CreateClient() + { + var config = new LicenseConfig + { + ProductSlug = ProductSlug, + PublicKeyBase64 = PublicKeyBase64, + Endpoints = Endpoints, + HttpBasicAuthUser = BasicAuthUser, + HttpBasicAuthPassword = BasicAuthPassword, + OfflineGraceHoursFallback = 168 // 7 Tage offline nutzbar, danach Serverkontakt nötig + }; + return new LicenseClient(config); + } + + /// + /// Blocks until a usable license is present. Tries the cached key first; otherwise + /// (or when the cached key is no longer usable) shows the license dialog. + /// Returns null if the user gave up — the app must exit then. + /// + public static LicenseClient? EnsureLicensed() + { + var client = CreateClient(); + + var result = client.RevalidateAsync().GetAwaiter().GetResult(); + if (result.IsUsable && client.VerifyChecksum(result)) + { + return client; + } + + using var dialog = new LicenseDialog(client, result); + if (dialog.ShowDialog() != DialogResult.OK) + { + return null; + } + return client; + } + + /// + /// Starts the periodic in-app revalidation. Detects revocation/expiry while the app + /// keeps running; on a definitively unusable license the app is shut down. + /// + public static System.Windows.Forms.Timer StartPeriodicRevalidation(LicenseClient client) + { + var timer = new System.Windows.Forms.Timer { Interval = RevalidationIntervalMs }; + timer.Tick += async (_, _) => + { + try + { + var result = await client.RevalidateAsync(); + if (result.IsUsable && client.VerifyChecksum(result)) + { + if (result.State == LicenseState.ValidOffline) + { + Log.Warning("Lizenzserver nicht erreichbar — Offline-Gnadenfrist läuft bis {GraceUntil}.", result.GraceUntil); + } + return; + } + + timer.Stop(); + Log.Fatal("Lizenzprüfung fehlgeschlagen ({State}): {Message} — Anwendung wird beendet.", result.State, result.Message); + MessageBox.Show( + $"Die Lizenz ist nicht mehr gültig ({result.State}):\n{result.Message}\n\nPredictalytics wird beendet.", + "Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop); + System.Windows.Forms.Application.Exit(); + } + catch (Exception ex) + { + // Transient errors (network etc.) are handled by the SDK's offline grace — + // never kill the app from an unexpected exception here. + Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht)."); + } + }; + timer.Start(); + return timer; + } +} diff --git a/src/Predictalytics.WinFormsHost/Services/WatchdogHeartbeatService.cs b/src/Predictalytics.WinFormsHost/Services/WatchdogHeartbeatService.cs new file mode 100644 index 0000000..295a459 --- /dev/null +++ b/src/Predictalytics.WinFormsHost/Services/WatchdogHeartbeatService.cs @@ -0,0 +1,137 @@ +using System.Text; +using System.Text.Json; +using Serilog; + +namespace Predictalytics.WinFormsHost.Services; + +/// +/// Sends periodic dead-man's-switch heartbeats to the external Watchdog server +/// (POST /api/heartbeat) so an outage of this app — or the whole machine — raises +/// an alarm. A Watchdog outage must never impact the app: every call is best effort. +/// +public sealed class WatchdogHeartbeatService : IDisposable +{ + private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) }; + private readonly string _baseUrl; + private readonly string _apiKey; + private readonly string _source; + private readonly string _instance; + private readonly int _intervalSeconds; + private readonly Func? _metadataProvider; + private readonly DateTime _startedUtc = DateTime.UtcNow; + + private System.Threading.Timer? _timer; + private bool _lastSendFailed; + + public WatchdogHeartbeatService( + string baseUrl, + string apiKey, + string source, + string instance, + int intervalSeconds, + Func? metadataProvider = null) + { + _baseUrl = baseUrl.TrimEnd('/'); + _apiKey = apiKey; + _source = source; + _instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance; + _intervalSeconds = Math.Max(15, intervalSeconds); + _metadataProvider = metadataProvider; + } + + public void Start() + { + _timer?.Dispose(); + _timer = new System.Threading.Timer( + async _ => await SendHeartbeatAsync("ok"), + null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds)); + Log.Information("🐕 Watchdog heartbeat started → {Url} (source={Source}, every {Interval}s)", + _baseUrl, _source, _intervalSeconds); + } + + /// Sends a lifecycle event (e.g. "stopping") so a planned shutdown is not alarmed as a crash. + public void NotifyStopping() + { + try + { + var payload = new + { + source = _source, + instance = _instance, + kind = "stopping", + severity = "info", + message = "Predictalytics wird planmäßig beendet." + }; + // Synchronous with a short cap: the form is closing and must not hang. + PostAsync("/api/event", payload).Wait(TimeSpan.FromSeconds(4)); + } + catch + { + // Best effort only. + } + } + + private async Task SendHeartbeatAsync(string status) + { + try + { + var payload = new + { + source = _source, + instance = _instance, + type = "heartbeat", + status, + message = (string?)null, + metrics = new + { + uptimeSec = (long)(DateTime.UtcNow - _startedUtc).TotalSeconds, + app = _metadataProvider?.Invoke() + }, + group = "C# Applications", + interval = _intervalSeconds + }; + + await PostAsync("/api/heartbeat", payload); + + if (_lastSendFailed) + { + _lastSendFailed = false; + Log.Information("🐕 Watchdog heartbeat wieder erfolgreich zugestellt."); + } + } + catch (Exception ex) + { + // Log the first failure as warning, subsequent ones quietly (no log flood). + if (!_lastSendFailed) + { + _lastSendFailed = true; + Log.Warning("🐕 Watchdog heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message); + } + else + { + Log.Debug(ex, "Watchdog heartbeat failed"); + } + } + } + + private async Task PostAsync(string path, object payload) + { + using var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path); + request.Headers.Add("X-Watchdog-Key", _apiKey); + request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + + using var response = await _http.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + throw new HttpRequestException($"Watchdog API HTTP {(int)response.StatusCode}: {body}"); + } + } + + public void Dispose() + { + _timer?.Dispose(); + _timer = null; + _http.Dispose(); + } +}