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