Watchdog-Heartbeat + LicenseLabrador-Lizenzgate im WinFormsHost

Bindet die beiden neuen Betriebsprojekte an:

- WatchdogHeartbeatService: periodischer POST /api/heartbeat an
  watchdog.mhdf.de (Dead-Man's-Switch), stopping-Event beim Beenden,
  Konfiguration ueber AppSettings-Kategorie "Watchdog". Fehler sind
  best effort und beeintraechtigen die App nie.
- LicenseGuard + LicenseDialog: Lizenzpruefung vor dem Start der
  MainForm, 12h-Revalidierung zur Laufzeit, Checksum-Haertung.
  Produkt-Slug/Endpoint/Public-Key sind einkompiliert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-30 12:12:54 +02:00
co-authored by Claude Fable 5
parent 02c5a4d6f4
commit 3a82397651
8 changed files with 515 additions and 0 deletions
+74
View File
@@ -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.
@@ -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))] [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 ?? ""; } 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 _dbServer = "localhost";
private string _dbName = ""; private string _dbName = "";
private string _dbUser = ""; private string _dbUser = "";
@@ -11,6 +11,7 @@ public partial class MainForm : Form
private bool _workerRunning; private bool _workerRunning;
private bool _webServerRunning; private bool _webServerRunning;
private AppSettings _settings = null!; private AppSettings _settings = null!;
private WatchdogHeartbeatService? _watchdog;
/// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary> /// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary>
public RichTextBox Terminal => rtb_terminal; public RichTextBox Terminal => rtb_terminal;
@@ -40,6 +41,7 @@ public partial class MainForm : Form
_webServer.DbConnectionDebug = _settings.DbConnectionDebug; _webServer.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer.EgressChannelsText = _settings.EgressChannelsText; _webServer.EgressChannelsText = _settings.EgressChannelsText;
} }
RestartWatchdog();
}; };
_webServer = new EmbeddedWebServer(); _webServer = new EmbeddedWebServer();
@@ -69,6 +71,39 @@ public partial class MainForm : Form
var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 }; var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 };
dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync(); dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync();
dbSizeTimer.Start(); dbSizeTimer.Start();
RestartWatchdog();
}
/// <summary>
/// (Re-)creates the Watchdog heartbeat sender from the current settings.
/// Called at startup and whenever settings change.
/// </summary>
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) 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) protected override void OnFormClosing(FormClosingEventArgs e)
{ {
_watchdog?.NotifyStopping();
_watchdog?.Dispose();
_watchdog = null;
_workerCts?.Cancel(); _workerCts?.Cancel();
_webServer?.StopWebServerAsync().GetAwaiter().GetResult(); _webServer?.StopWebServerAsync().GetAwaiter().GetResult();
base.OnFormClosing(e); base.OnFormClosing(e);
@@ -31,6 +31,8 @@
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" /> <ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" /> <ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" /> <ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
<!-- Externes Schwester-Repo: J:\Softwareprojekte\LicenseLabrador muss neben dem Predictalytics-Checkout liegen. -->
<ProjectReference Include="..\..\..\..\LicenseLabrador\client-dotnet\LicenseLabrador.Client\LicenseLabrador.Client.csproj" />
</ItemGroup> </ItemGroup>
<Target Name="CleanupLocalization" AfterTargets="Build"> <Target Name="CleanupLocalization" AfterTargets="Build">
@@ -1,4 +1,5 @@
using Predictalytics.Infrastructure.Logging; using Predictalytics.Infrastructure.Logging;
using Predictalytics.WinFormsHost.Services;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
@@ -11,6 +12,13 @@ internal static class Program
{ {
ApplicationConfiguration.Initialize(); ApplicationConfiguration.Initialize();
// ─── License gate: no usable license, no app ───
var licenseClient = LicenseGuard.EnsureLicensed();
if (licenseClient == null)
{
return;
}
var mainForm = new MainForm(); var mainForm = new MainForm();
var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm); var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm);
@@ -145,6 +153,10 @@ internal static class Program
Log.Warning("══════════════════════════════════════════════════════"); Log.Warning("══════════════════════════════════════════════════════");
mainForm.Initialize(); 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); System.Windows.Forms.Application.Run(mainForm);
Log.Information("Application shutting down."); Log.Information("Application shutting down.");
@@ -0,0 +1,118 @@
using LicenseLabrador.Client;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
@@ -0,0 +1,98 @@
using LicenseLabrador.Client;
using Serilog;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// 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.
/// </summary>
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" };
/// <summary>Re-check interval while the app is running (12 h).</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Starts the periodic in-app revalidation. Detects revocation/expiry while the app
/// keeps running; on a definitively unusable license the app is shut down.
/// </summary>
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;
}
}
@@ -0,0 +1,137 @@
using System.Text;
using System.Text.Json;
using Serilog;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// 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.
/// </summary>
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<object?>? _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<object?>? 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);
}
/// <summary>Sends a lifecycle event (e.g. "stopping") so a planned shutdown is not alarmed as a crash.</summary>
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();
}
}