From 168f4699e1ed65b0ca2e03fe86f01ec7de47b091 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 8 Aug 2026 20:56:45 +0200 Subject: [PATCH] Deployment-Center-Integration im WinFormsHost Loest die getrennten Server Watchdog (watchdog.mhdf.de) und LicenseLabrador (license.mhdf.de) durch das Deployment Center (dc.mhdf.de) ab. - DcConfig: einkompilierte Basis-URL und Produkt-Slug, Version aus BuildInfo - DcApiClient: gemeinsamer HTTP-Zugang - DcHeartbeatService: Heartbeat mit Metriken und DB-Health-Check - DcErrorReporter/DcErrorSink: Error- und Fatal-Meldungen an den Fehler-Stream - DcUpdateService: Update-Pruefung gegen den UpdateService - LicenseGuard/LicenseDialog: Lizenzgate ueber /api/license/v1/validate, mit LicenseSession, Hardware-ID v2 und Unterscheidung transienter Fehler WatchdogHeartbeatService entfernt, Betriebsdoku ersetzt. Build: 0 Fehler. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 113 ++++++++ docs/BETRIEB-Deploymentcenter.md | 232 ++++++++++++++++ docs/BETRIEB-Watchdog-Lizenz.md | 82 ------ .../AppSettings.cs | 68 +++-- src/Predictalytics.WinFormsHost/MainForm.cs | 219 ++++++++++++++-- .../Predictalytics.WinFormsHost.csproj | 11 +- src/Predictalytics.WinFormsHost/Program.cs | 39 ++- .../Services/DcApiClient.cs | 96 +++++++ .../Services/DcConfig.cs | 37 +++ .../Services/DcErrorReporter.cs | 247 ++++++++++++++++++ .../Services/DcErrorSink.cs | 39 +++ .../Services/DcHeartbeatService.cs | 217 +++++++++++++++ .../Services/DcUpdateService.cs | 58 ++++ .../Services/LicenseDialog.cs | 64 +++-- .../Services/LicenseGuard.cs | 149 +++++++---- .../Services/WatchdogHeartbeatService.cs | 141 ---------- 16 files changed, 1457 insertions(+), 355 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/BETRIEB-Deploymentcenter.md delete mode 100644 docs/BETRIEB-Watchdog-Lizenz.md create mode 100644 src/Predictalytics.WinFormsHost/Services/DcApiClient.cs create mode 100644 src/Predictalytics.WinFormsHost/Services/DcConfig.cs create mode 100644 src/Predictalytics.WinFormsHost/Services/DcErrorReporter.cs create mode 100644 src/Predictalytics.WinFormsHost/Services/DcErrorSink.cs create mode 100644 src/Predictalytics.WinFormsHost/Services/DcHeartbeatService.cs create mode 100644 src/Predictalytics.WinFormsHost/Services/DcUpdateService.cs delete mode 100644 src/Predictalytics.WinFormsHost/Services/WatchdogHeartbeatService.cs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5d695b3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# Predictalytics + +Betriebsdokumentation zur Anbindung an das Deployment Center (Lizenz, Watchdog, +Updates, Fehler-Stream): [`docs/BETRIEB-Deploymentcenter.md`](docs/BETRIEB-Deploymentcenter.md). + +## Zentraler Bugtracker — Deployment Center + +Erfasse unbehandelte Fehler, geplante Verbesserungen und Ideen im zentralen +Deployment Center. Basis-URL: `https://dc.mhdf.de` + +### Zugang +Token steht in der Umgebungsvariable `DC_TOKEN`. +Header: `Authorization: Bearer $DC_TOKEN` + +Ist `DC_TOKEN` nicht gesetzt, melde nichts und weise stattdessen darauf hin — +die Endpunkte antworten sonst mit `401 unauthorized`. + +Die vollständige Schnittstellenbeschreibung liegt maschinenlesbar unter +`GET /api/openapi.json`, das Handbuch unter `/docs/`. + +### Projekt +Dieses Projekt ist `predictalytics`. +Repo: `http://192.168.178.10:8418/Richard/Predictalytics` +Fällt dir ein Fehler im Deployment Center selbst auf, melde ihn unter +`deploymentcenter`. + +### Etwas melden +`POST /api/bugtracker/v1/report` + +```json +{ + "project_slug": "predictalytics", + "type": "bug", + "title": "Kurze, aussagekräftige Zusammenfassung", + "description": "Unter welchen Bedingungen tritt es auf?", + "error_message": "Exakte Fehlermeldung", + "stack_trace": "Vollständiger Stacktrace", + "severity": "high", + "environment": "production", + "build_version": "v1.0.0", + "repo_url": "http://192.168.178.10:8418/Richard/Predictalytics", + "git_branch": "main", + "commit_sha": "a21536f", + "file_path": "src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs", + "line_no": 152, + "client_ref": "eindeutige-id-dieses-laufs" +} +``` + +**Setze immer `client_ref`** — ein wiederholter Aufruf mit demselben Wert legt +kein Duplikat an. Gib nach Möglichkeit `file_path` und `line_no` an; das spart +dem nächsten Agenten das Parsen des Stacktrace. + +Schweregrade: `idea` (Gedanke für später), `wishlist` (Backlog), +`low`, `medium`, `high`, `critical`. + +### Arbeit übernehmen +Bevor du an einem Item arbeitest, übernimm es — sonst arbeiten zwei Agenten +parallel am selben Problem: + +``` +POST /api/bugtracker/v1/manage?action=next +Body: {"project_slug": "predictalytics", "limit": 1} +``` + +Antwortet der Server mit `409 already_claimed`, nimm das nächste Item. +Die Reservierung läuft nach 30 Minuten ab; `action=claim` auf dieselbe ID +erneuert sie. + +### Fortschritt festhalten +``` +POST /api/bugtracker/v1/manage?action=comment&id= +Body: {"comment": "Was du herausgefunden hast", "action_taken": "investigated"} +``` + +`action_taken`: `investigated`, `fix_proposed`, `pr_opened`, `needs_human`, +`blocked`, `commented`. + +### Abschließen +``` +POST /api/bugtracker/v1/manage?action=resolve&id= +Body: {"resolved_in_build": "v1.0.1", "resolution_notes": "Was geändert wurde"} +``` + +Kommst du nicht weiter, gib das Item zurück statt es blockieren zu lassen: +``` +POST /api/bugtracker/v1/manage?action=release&id= +Body: {"note": "Grund"} +``` + +### Laufzeitfehler +Die Anwendung meldet Error/Fatal selbst über `POST /api/errors/v1/report` +(`Services/DcErrorReporter.cs`) — dort nichts von Hand nachreichen. + +### Release melden +Nach einem Release schließen sich Items mit passendem `resolved_in_build` +automatisch: +``` +POST /api/updateservice/v1/publish +Body: {"product_slug": "predictalytics", "version": "1.0.1", + "download_url": "...", "sha256_hash": "...", "git_commit": "..."} +``` + +Die Version in `src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj` +(``) muss dazu passen — sie ist es, die der Client als installierte +Version meldet. + +### Fehlerbehandlung +Antworten haben die Form `{"status":"error","error":{"code":"…"}}`. +Reagiere auf `code`, nicht auf den Text: +- `401 unauthorized` — Token prüfen, nicht wiederholen +- `409 already_claimed` — nächstes Item nehmen +- `429 rate_limited` — Intervall verdoppeln, später erneut diff --git a/docs/BETRIEB-Deploymentcenter.md b/docs/BETRIEB-Deploymentcenter.md new file mode 100644 index 0000000..2016422 --- /dev/null +++ b/docs/BETRIEB-Deploymentcenter.md @@ -0,0 +1,232 @@ +# Betrieb: Deployment Center + +Das **Deployment Center** (`https://dc.mhdf.de`, Schwester-Repo +`J:\Softwareprojekte\Deploymentcenter`) hat die beiden früheren Einzelserver abgelöst: + +| Vorher | Jetzt | +|---|---| +| WatchDog auf `watchdog.mhdf.de`, `POST /api/heartbeat`, Header `X-Watchdog-Key` | Deployment Center, `POST /api/watchdog/v1/ping`, `Authorization: Bearer` | +| LicenseLabrador auf `license.mhdf.de`, Ed25519-signierte Envelopes | Deployment Center, `POST /api/license/v1/validate`, Hardware-ID v2 | +| — | UpdateService, Fehler-Stream und Bugtracker (neu) | + +Die Anbindung sitzt im `Predictalytics.WinFormsHost` und referenziert das +C#-SDK `Deploymentcenter.Client` aus dem Schwester-Repo: + +``` +..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj +``` + +Das Repo muss also neben dem Predictalytics-Checkout liegen. `LicenseLabrador` +und `WatchDog` werden nicht mehr gebraucht. + +--- + +## 0. Was einmalig zu tun ist + +1. Im Deployment Center unter **Token-Verwaltung → Master-Token erstellen** ein + Token mit den Rechten **`watchdog:ping`** und **`bugtracker:report`** anlegen. +2. Im Host unter **Settings → Deployment Center → API Token** eintragen. + Der alte `X-Watchdog-Key` funktioniert nicht mehr — es ist ein anderer Server. +3. Beim ersten Start erscheint einmalig der Lizenzdialog: der Lizenzschlüssel muss + erneut eingegeben werden, weil die Aktivierung jetzt am Deployment Center hängt. +4. Serverseitig muss der **Evaluator-Cron** laufen — ohne ihn bleibt ein + abgestürzter Dienst dauerhaft grün: + ``` + * * * * * curl -fsS -H "Authorization: Bearer " https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null + ``` + +Der Token landet in der `settings.json` neben der Exe (nicht im Git). + +--- + +## 1. Konfiguration (PropertyGrid, Kategorie „Deployment Center") + +| Feld | Default | Bedeutung | +|---|---|---| +| `Server URL` | `https://dc.mhdf.de` | **schreibgeschützt**, siehe unten | +| `API Token` | *(leer)* | `Authorization: Bearer`. Ohne Token: keine Heartbeats, keine Fehlermeldungen. | +| `Heartbeat aktiv` | `true` | Dead-Man's-Switch an/aus | +| `Monitor Source` | `Predictalytics` | Monitor-Name im Dashboard (Auto-Registrierung beim ersten Ping) | +| `Monitor Instance` | `default` | falls mehrere Instanzen laufen | +| `Heartbeat-Intervall (Sekunden)` | `60` | `warning` nach 2×, `down` nach 4× | +| `Fehler melden` | `true` | Error/Fatal an den Fehler-Stream | +| `Update-Prüfung beim Start` | `true` | prüft nur, installiert nichts von selbst | +| `Update-Kanal` | `prod` | `prod`, `beta` oder `dev` | + +**Warum ist die Server-URL nicht einstellbar?** Sie entscheidet, wohin die +Lizenzprüfung geht und woher Update-Pakete kommen. Ein einstellbarer Endpoint +würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu +zeigen. Sie steht als Konstante in `Services/DcConfig.cs`. + +--- + +## 2. Watchdog (Dead-Man's-Switch) + +`Services/DcHeartbeatService.cs` sendet alle *n* Sekunden einen +`POST /api/watchdog/v1/ping`. Beim regulären Schließen geht ein Ping mit +`status: "stopped"` raus — der Evaluator lässt einen so beendeten Monitor in +Ruhe, bis wieder ein normaler Heartbeat eintrifft. Ohne das folgt wenige Minuten +nach jedem geordneten Beenden ein Fehlalarm. + +**Ein Ausfall des Deployment Centers 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). Wird das Token abgelehnt +(`401`/`403`), stellt der Dienst die Versuche ganz ein — Wiederholen kann daran +nichts ändern. + +### Mitgesendeter Gesundheitszustand + +Ein Heartbeat beweist nur, dass ein Timer läuft. Deshalb schickt die App ihren +selbst ermittelten Zustand mit: + +| `checks` | Bedeutung | +|---|---| +| `db` | `SELECT 1` gegen die konfigurierte MySQL, gedeckelt auf 5 s. Schlägt sie fehl, stuft der Server den Heartbeat auf `warning`. | + +| `metrics` | Bedeutung | +|---|---| +| `uptime_sec` | Laufzeit des Prozesses | +| `workers_running` | 0/1 — Hintergrund-Worker gestartet | +| `webserver_running` | 0/1 — eingebetteter Kestrel gestartet | +| `memory_mb` | verwalteter Heap | +| `db_size_mb` | zuletzt ermittelte Datenbankgröße | + +Zusätzlich geht seit Server 2.1 das Feld `version` mit — im Dashboard ist damit +sichtbar, welcher Build läuft, und ein Ausfall lässt sich einem Rollout zuordnen. + +Gestoppte Worker sind **bewusst kein** fehlgeschlagener Check: das ist ein +gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf `warning` +halten. Der Zustand steckt als Metrik drin und lässt sich dort auswerten. + +Metriken werden serverseitig 14 Tage lang mit Verlauf gehalten +(`GET /api/watchdog/v1/metrics?source=Predictalytics`). + +### Maschine mit überwachen + +Der Heartbeat deckt nur *diesen* Prozess ab. Für die Maschine selbst gehört +zusätzlich ein OS-Agent auf den Host (siehe +`Deploymentcenter/docs/WATCHDOG_INTEGRATION_GUIDE.md`). Läuft beides, sollte im +WebUI unter **WatchDog → System-Hierarchie** die *Übergeordnete Entität* des +Predictalytics-Monitors auf den Host gesetzt werden — dann erzeugt ein +Maschinenausfall eine Meldung statt zwei. + +--- + +## 3. Lizenzierung + +`Services/LicenseGuard.cs` prüft beim Start, ob eine nutzbare Lizenz vorliegt; +`Program.Main` bricht sonst ab, bevor die MainForm entsteht. Ohne gültige Lizenz +erscheint `Services/LicenseDialog.cs` zur Key-Eingabe. + +- **Produkt-Slug:** `predictalytics` +- **Hardware-ID v2:** `2:win:` aus `HKLM\...\Cryptography\MachineGuid`. + Der Rechnername steckt **nicht** im Hash — Umbenennen kostet keinen + Aktivierungsplatz. +- **Offline-Cache:** AES-256-GCM + DPAPI unter `%AppData%\predictalytics\license\state.dat` + (Schema 3 seit SDK 2.1, Schema 2 wird noch gelesen). Dort liegt auch der + Lizenzschlüssel — `LicenseClient.TryGetCachedKey()` holt ihn beim Start, damit + ohne Dialog revalidiert werden kann. +- **Revalidierung zur Laufzeit:** alle 12 h. +- **Gemeldete Version:** `LicenseClient.DefaultAppVersion` wird in `Program.Main` + auf `BuildInfo.Version` gesetzt; ohne das trüge jede Installation in der + Aktivierungsliste dieselbe „1.0.0". + +### Offline-Gnadenfrist + +Seit Server 2.1 ist sie **echt begrenzt**: der Server liefert `cache_ttl_hours` +(je Projekt im WebUI, Standard 168 h = 7 Tage). Vorher galt faktisch das +Lizenz-Ablaufdatum, bei einer Lizenz bis 2040 also unbegrenzt. + +Läuft die Frist in weniger als 48 h ab, warnt der Host beim Start und bei jeder +Revalidierung. Die verbleibende Frist zeigt **Deployment Center → Lizenzstatus +anzeigen**. Falls Predictalytics planmäßig länger offline laufen soll, muss die +TTL im WebUI für das Projekt `predictalytics` heraufgesetzt werden. + +### Wann die App sich beendet — und wann nicht + +Maßgeblich ist `LicenseValidationResult.IsTransient`: + +| | Zustände | Reaktion | +|---|---|---| +| `IsValid` | `valid`, `valid_offline` | weiter | +| `IsTransient` | `server_unavailable`, `cache_expired` | **kein Urteil, nur eine gescheiterte Verbindung** — weiterlaufen, beim nächsten Durchlauf erneut versuchen | +| sonst | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | Meldung und Beenden | + +`cache_expired` beendet die laufende Sitzung also nicht — der **nächste Start** +bleibt aber am Lizenzdialog hängen, weil `IsValid` dann falsch ist. Der Host +loggt diesen Fall deshalb als Error, nicht bloß als Warnung. + +### Deaktivierung bei PC-Wechsel + +`POST /api/license/v1/deactivate` verlangt den `shared_key` aus der +Server-Konfiguration. Der gehört **nicht** in die ausgelieferte Anwendung, +deshalb ist die Funktion hier nicht verdrahtet. Übliche Route: im WebUI unter +**Lizenzen → Hardware-Liste → „Freigeben"**. Die Hardware-ID zeigt der Host unter +**Deployment Center → Lizenzstatus anzeigen**. + +--- + +## 4. Fehler-Stream + +`Services/DcErrorReporter.cs` meldet Laufzeitfehler an +`POST /api/errors/v1/report`. Zwei Wege führen dorthin: + +1. **Globale Handler** — `Application.ThreadException` (UI-Thread), + `AppDomain.UnhandledException` (Level `fatal`, wird blockierend gesendet, weil + der Prozess gleich weg ist) und `TaskScheduler.UnobservedTaskException`. +2. **Serilog-Sink** (`Services/DcErrorSink.cs`) — jedes `Log.Error`/`Log.Fatal`, + auch aus Worker und Kestrel. + +Lokale Bremse, damit das Rate-Limit (60/min und IP) nicht verbrannt wird: +höchstens 20 Meldungen pro Minute, derselbe Fehler höchstens alle 5 Minuten. +Serverseitig werden gleiche Fehler ohnehin gruppiert und hochgezählt; +betriebsbedingtes Rauschen gehört in die **Ignore-Regeln** des WebUI — ein +Treffer zählt weiter, meldet aber nicht. + +> Der Serilog-Filter in `Program.cs` wirft `Duplicate entry`-Ausnahmen schon +> vorher weg; die erreichen den Stream also gar nicht. + +--- + +## 5. Updates + +`Services/DcUpdateService.cs` fragt beim Start +`GET /api/updateservice/v1/check` ab (ohne Token). Gefunden wird nur — installiert +wird nichts von selbst: + +- Kein Update: Info ins Log. +- Update verfügbar: Warnung ins Log, Hinweis in der Statusleiste. +- **Kritisches** Update: zusätzlich ein Dialog. +- Menü **Deployment Center → Nach Updates suchen** prüft jederzeit von Hand. + +Liegt `update-agent.exe` neben der Anwendung, bietet der Dialog an, ihn zu +starten; die Anwendung meldet dann vorher `stopped` und beendet sich. Fehlt der +Agent, weist der Dialog auf die manuelle Installation hin. + +Seit SDK 2.1 liefert auch der API-Zweig vollständige Release-Daten +(Download-Adresse, Prüfsumme, Changelog, Kritikalität) — der Dialog zeigt die +Release Notes also auch dann, wenn die statische `latest.json` fehlt. + +### Versionsstand + +`Predictalytics.WinFormsHost.csproj` importiert `Deploymentcenter.BuildInfo.targets` +und erzeugt daraus zur Übersetzungszeit `Predictalytics.WinFormsHost.BuildInfo` +mit `Version`, `GitCommit`, `GitCommitShort`, `BuildDateUtc`, `Channel` und +`Summary`. Quelle ist `` in derselben csproj — beim Release dort +hochziehen und mit + +``` +pack-and-deploy --project predictalytics --version --channel prod +``` + +veröffentlichen. Dieselbe Version geht an die Lizenz-Aktivierungsliste, an den +Heartbeat und als `build` an den Fehler-Stream; `GitCommitShort` reist im +`context` jeder Fehlermeldung mit. + +--- + +## 6. Bugtracker + +Der Agenten-Workflow ist in [`../CLAUDE.md`](../CLAUDE.md) beschrieben. +Projekt-Slug: `predictalytics`. Items, deren `resolved_in_build` einer +veröffentlichten Version entspricht, schließen sich beim Publish von selbst. diff --git a/docs/BETRIEB-Watchdog-Lizenz.md b/docs/BETRIEB-Watchdog-Lizenz.md deleted file mode 100644 index 1d131a1..0000000 --- a/docs/BETRIEB-Watchdog-Lizenz.md +++ /dev/null @@ -1,82 +0,0 @@ -# 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=stopped_graceful` -raus, damit ein geplantes Beenden nicht als Crash alarmiert wird. - -> **Nicht auf `kind=stopping` ändern.** Der Watchdog akzeptiert den Wert im -> Router, aber `event_log.kind` ist ein ENUM ohne `stopping`. Der Server setzt -> dann zwar noch den Zustand auf `stopped`, scheitert aber am Insert und liefert -> HTTP 500 — das Event fehlt in der Historie. Verifiziert am 2026-07-30. - -**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 - -### Status - -Produkt `predictalytics` und Lizenzschlüssel sind angelegt; die Aktivierung wurde -am 2026-07-30 gegen den Produktivserver verifiziert (`Valid`, gültig bis -2040-12-31, Checksum-Prüfung bestanden). Der einkompilierte Public Key passt zum -`signing.pub` des Servers — sonst käme `TamperSuspected` statt `Valid`. - -Die Aktivierung ist an die Hardware-ID der jeweiligen Maschine gebunden. Auf -einem neuen Rechner erscheint einmalig der Dialog; der Cache liegt danach unter -`%AppData%\predictalytics\license`. - -### 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 fe25583..b10600a 100644 --- a/src/Predictalytics.WinFormsHost/AppSettings.cs +++ b/src/Predictalytics.WinFormsHost/AppSettings.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Serialization; namespace Predictalytics.WinFormsHost; @@ -27,41 +28,60 @@ 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")] + [Category("Deployment Center")] [DisplayName("Server URL")] - [Description("Basis-URL des Watchdog-Servers.")] - [DefaultValue("https://watchdog.mhdf.de")] - public string WatchdogUrl { get; set; } = "https://watchdog.mhdf.de"; + [Description("Basis-URL des Deployment Centers. Fest einkompiliert — ein einstellbarer Endpoint würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu zeigen.")] + [ReadOnly(true)] + [JsonIgnore] + public string DcServerUrl => Services.DcConfig.BaseUrl; - [Category("Watchdog")] - [DisplayName("API Key")] - [Description("Shared Key oder Agent-Token des Watchdog-Servers (X-Watchdog-Key). Ohne Key werden keine Heartbeats gesendet.")] + [Category("Deployment Center")] + [DisplayName("API Token")] + [Description("Token des Deployment Centers (Authorization: Bearer). Benötigte Rechte: 'watchdog:ping' für Heartbeats, 'bugtracker:report' für das Fehler-Reporting. Ohne Token werden weder Heartbeats noch Fehler gemeldet.")] [PasswordPropertyText(true)] - public string WatchdogApiKey { get; set; } = ""; + public string DcToken { get; set; } = ""; - [Category("Watchdog")] - [DisplayName("Source")] + [Category("Deployment Center")] + [DisplayName("Heartbeat aktiv")] + [Description("Sendet periodische Heartbeats an den Watchdog des Deployment Centers (Dead-Man's-Switch). Benötigt ein Token.")] + [DefaultValue(true)] + public bool DcHeartbeatEnabled { get; set; } = true; + + [Category("Deployment Center")] + [DisplayName("Monitor Source")] [Description("Eindeutiger Monitor-Name dieses Dienstes im Watchdog-Dashboard.")] [DefaultValue("Predictalytics")] - public string WatchdogSource { get; set; } = "Predictalytics"; + public string DcSource { get; set; } = "Predictalytics"; - [Category("Watchdog")] - [DisplayName("Instance")] + [Category("Deployment Center")] + [DisplayName("Monitor Instance")] [Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")] [DefaultValue("default")] - public string WatchdogInstance { get; set; } = "default"; + public string DcInstance { 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.")] + [Category("Deployment Center")] + [DisplayName("Heartbeat-Intervall (Sekunden)")] + [Description("Sende-Takt der Heartbeats. Der Evaluator stuft nach dem Doppelten auf 'warning' und nach dem Vierfachen auf 'down'.")] [DefaultValue(60)] - public int WatchdogIntervalSeconds { get; set; } = 60; + public int DcHeartbeatIntervalSeconds { get; set; } = 60; + + [Category("Deployment Center")] + [DisplayName("Fehler melden")] + [Description("Meldet Laufzeitfehler (Error/Fatal) an den Fehler-Stream des Deployment Centers. Gleiche Fehler werden dort gruppiert und hochgezählt.")] + [DefaultValue(true)] + public bool DcErrorReportingEnabled { get; set; } = true; + + [Category("Deployment Center")] + [DisplayName("Update-Prüfung beim Start")] + [Description("Prüft beim Start, ob im gewählten Kanal ein neueres Release vorliegt. Installiert wird nichts automatisch.")] + [DefaultValue(true)] + public bool DcUpdateCheckEnabled { get; set; } = true; + + [Category("Deployment Center")] + [DisplayName("Update-Kanal")] + [Description("prod, beta oder dev.")] + [DefaultValue("prod")] + public string DcUpdateChannel { get; set; } = "prod"; private string _dbServer = "localhost"; private string _dbName = ""; diff --git a/src/Predictalytics.WinFormsHost/MainForm.cs b/src/Predictalytics.WinFormsHost/MainForm.cs index 1730de0..d5dd777 100644 --- a/src/Predictalytics.WinFormsHost/MainForm.cs +++ b/src/Predictalytics.WinFormsHost/MainForm.cs @@ -11,7 +11,8 @@ public partial class MainForm : Form private bool _workerRunning; private bool _webServerRunning; private AppSettings _settings = null!; - private WatchdogHeartbeatService? _watchdog; + private DcHeartbeatService? _heartbeat; + private double? _lastDbSizeMb; /// Exposes the terminal RichTextBox for the Serilog sink. public RichTextBox Terminal => rtb_terminal; @@ -29,9 +30,9 @@ public partial class MainForm : Form /// /// Called after Serilog is configured. Initializes the embedded web server. /// - public void Initialize() + public void Initialize(AppSettings settings) { - _settings = AppSettings.Load(); + _settings = settings; pg_settings.SelectedObject = _settings; pg_settings.PropertyValueChanged += (s, e) => { _settings.Save(); @@ -41,7 +42,8 @@ public partial class MainForm : Form _webServer.DbConnectionDebug = _settings.DbConnectionDebug; _webServer.EgressChannelsText = _settings.EgressChannelsText; } - RestartWatchdog(); + DcErrorReporter.Configure(_settings.DcToken, _settings.DcErrorReportingEnabled); + RestartHeartbeat(); }; _webServer = new EmbeddedWebServer(); @@ -52,11 +54,12 @@ public partial class MainForm : Form // Build Version (Date of compilation/file creation) try { var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime; - label_buildVersion.Text = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}"; + label_buildVersion.Text = $"v{DcConfig.AppVersion} — Build: {buildDate:yyyy-MM-dd HH:mm:ss}"; } catch { - label_buildVersion.Text = "Build: Unknown"; + label_buildVersion.Text = $"v{DcConfig.AppVersion}"; } + BuildDeploymentcenterMenu(); UpdateStatusBar(); // Wire up button events @@ -72,38 +75,197 @@ public partial class MainForm : Form dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync(); dbSizeTimer.Start(); - RestartWatchdog(); + RestartHeartbeat(); + + if (_settings.DcUpdateCheckEnabled) + { + _ = CheckForUpdatesAsync(silent: true); + } } /// - /// (Re-)creates the Watchdog heartbeat sender from the current settings. + /// (Re-)creates the Deployment Center heartbeat sender from the current settings. /// Called at startup and whenever settings change. /// - private void RestartWatchdog() + private void RestartHeartbeat() { - _watchdog?.Dispose(); - _watchdog = null; + _heartbeat?.Dispose(); + _heartbeat = null; - if (!_settings.WatchdogEnabled) return; + if (!_settings.DcHeartbeatEnabled) return; - if (string.IsNullOrWhiteSpace(_settings.WatchdogApiKey) || string.IsNullOrWhiteSpace(_settings.WatchdogUrl)) + if (string.IsNullOrWhiteSpace(_settings.DcToken)) { - Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen — bitte in den Settings eintragen."); + Log.Information("🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token — bitte in den Settings eintragen."); return; } - _watchdog = new WatchdogHeartbeatService( - _settings.WatchdogUrl, - _settings.WatchdogApiKey, - _settings.WatchdogSource, - _settings.WatchdogInstance, - _settings.WatchdogIntervalSeconds, - metadataProvider: () => new + _heartbeat = new DcHeartbeatService( + _settings.DcToken, + _settings.DcSource, + _settings.DcInstance, + _settings.DcHeartbeatIntervalSeconds, + CollectHeartbeatSnapshotAsync); + _heartbeat.Start(); + } + + /// + /// Assembles what this app knows about its own health. A heartbeat alone only proves that + /// a timer runs — the DB check is what shows whether the app can actually do its work. + /// + private async Task CollectHeartbeatSnapshotAsync(CancellationToken ct) + { + var snapshot = new DcHeartbeatSnapshot + { + Message = _workerRunning ? "Worker laufen" : "Worker gestoppt" + }; + + // Deliberately no check for "workers stopped": that is a legitimate state chosen by + // the operator and would otherwise keep the monitor permanently on warning. + snapshot.Metrics["workers_running"] = _workerRunning ? 1 : 0; + snapshot.Metrics["webserver_running"] = _webServerRunning ? 1 : 0; + snapshot.Metrics["memory_mb"] = Math.Round(GC.GetTotalMemory(forceFullCollection: false) / 1024d / 1024d, 1); + if (_lastDbSizeMb is { } dbSize) snapshot.Metrics["db_size_mb"] = Math.Round(dbSize, 2); + + var db = await ProbeDatabaseAsync(ct); + if (db is not null) snapshot.Checks["db"] = db; + + return snapshot; + } + + /// SELECT 1 against the configured MySQL, capped so it cannot stall the heartbeat. + private async Task ProbeDatabaseAsync(CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_settings.DbName)) return null; + + var started = System.Diagnostics.Stopwatch.StartNew(); + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + + await using var conn = new MySqlConnector.MySqlConnection(_settings.ConnectionString); + await conn.OpenAsync(timeout.Token); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT 1"; + await cmd.ExecuteScalarAsync(timeout.Token); + + return new DcCheck(true, $"{started.ElapsedMilliseconds} ms", started.ElapsedMilliseconds); + } + catch (Exception ex) + { + return new DcCheck(false, ex.Message); + } + } + + /// Adds the Deployment Center entries to the menu bar (not part of the designer). + private void BuildDeploymentcenterMenu() + { + var menu = new ToolStripMenuItem("Deployment Center"); + + var updateItem = new ToolStripMenuItem("Nach Updates suchen"); + updateItem.Click += async (_, _) => await CheckForUpdatesAsync(silent: false); + + var licenseItem = new ToolStripMenuItem("Lizenzstatus anzeigen"); + licenseItem.Click += (_, _) => ShowLicenseStatus(); + + menu.DropDownItems.Add(updateItem); + menu.DropDownItems.Add(licenseItem); + menuStrip1.Items.Add(menu); + } + + /// + /// Asks the UpdateService for a newer release. Silent at startup (log + status bar); + /// only a critical release interrupts the user. + /// + private async Task CheckForUpdatesAsync(bool silent) + { + try + { + var result = await DcUpdateService.CheckAsync(_settings.DcUpdateChannel); + + if (result.Error is not null) { - workersRunning = _workerRunning, - webserverRunning = _webServerRunning - }); - _watchdog.Start(); + Log.Warning("Update-Prüfung fehlgeschlagen: {Message}", result.Message); + if (!silent) + { + MessageBox.Show($"Update-Prüfung fehlgeschlagen:\n{result.Message}", + "Deployment Center", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + return; + } + + if (!result.UpdateAvailable) + { + Log.Information("Update-Prüfung: v{Version} ist aktuell (Kanal {Channel}).", + DcConfig.AppVersion, _settings.DcUpdateChannel); + if (!silent) + { + MessageBox.Show($"Predictalytics v{DcConfig.AppVersion} ist aktuell.", + "Deployment Center", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + return; + } + + var latest = result.LatestRelease?.Version ?? "?"; + Log.Warning("⬆ Update verfügbar: v{Latest} (installiert: v{Current}, Kanal {Channel}){Critical}", + latest, DcConfig.AppVersion, _settings.DcUpdateChannel, result.IsCritical ? " — KRITISCH" : ""); + label_buildVersion.Text = $"v{DcConfig.AppVersion} — Update v{latest} verfügbar"; + + if (silent && !result.IsCritical) return; + + var notes = result.LatestRelease?.Changelog; + var agentPresent = DcUpdateService.FindUpdateAgent() is not null; + var text = $"Neues Release v{latest} verfügbar (installiert: v{DcConfig.AppVersion}).\n" + + (result.IsCritical ? "\nDieses Update ist als kritisch markiert.\n" : "") + + (string.IsNullOrWhiteSpace(notes) ? "" : $"\n{notes}\n") + + (agentPresent + ? "\nJetzt installieren? Predictalytics wird dazu beendet." + : $"\nDer Update-Agent liegt nicht neben der Anwendung — bitte manuell einspielen."); + + if (!agentPresent) + { + MessageBox.Show(text, "Deployment Center", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + if (MessageBox.Show(text, "Deployment Center", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) + { + return; + } + + // The agent replaces the running installation, so announce the shutdown first — + // otherwise the monitor reports a crash a few minutes later. + _heartbeat?.NotifyStopping(); + DcUpdateService.LaunchAgent(_settings.DcUpdateChannel); + } + catch (Exception ex) + { + Log.Warning(ex, "Update-Prüfung fehlgeschlagen"); + } + } + + private void ShowLicenseStatus() + { + var hardware = LicenseGuard.GetHardwareInfo(); + var result = Program.LicenseSession?.LastResult; + + var grace = result?.CacheExpiresAt is { } expiresAt && expiresAt > 0 + ? $"\nOffline-Gnadenfrist bis: {DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime:yyyy-MM-dd HH:mm} UTC" + : ""; + + MessageBox.Show( + $"Produkt: {DcConfig.ProductSlug}\n" + + $"Version: {DcConfig.AppVersion} ({DcConfig.GitCommitShort})\n" + + $"Server: {DcConfig.BaseUrl}\n" + + $"Hardware-ID: {hardware.HardwareId}\n" + + $"Quelle: {hardware.HwidSource}\n\n" + + $"Letzte Prüfung: {result?.Status ?? "unbekannt"}" + + (result?.IsCached == true ? " (aus Offline-Cache)" : "") + + $"\n{result?.Message}{grace}\n\n" + + "Aktivierung für diesen Rechner freigeben: im Deployment Center unter " + + "Lizenzen → Hardware-Liste → „Freigeben\".", + "Lizenzstatus", MessageBoxButtons.OK, MessageBoxIcon.Information); } private async void Btn_serverstart_Click(object? sender, EventArgs e) @@ -177,9 +339,9 @@ public partial class MainForm : Form protected override void OnFormClosing(FormClosingEventArgs e) { - _watchdog?.NotifyStopping(); - _watchdog?.Dispose(); - _watchdog = null; + _heartbeat?.NotifyStopping(); + _heartbeat?.Dispose(); + _heartbeat = null; _workerCts?.Cancel(); _webServer?.StopWebServerAsync().GetAwaiter().GetResult(); base.OnFormClosing(e); @@ -332,6 +494,7 @@ public partial class MainForm : Form if (result != DBNull.Value && result != null) { var sizeMb = Convert.ToDouble(result); + _lastDbSizeMb = sizeMb; this.Invoke(() => label_dbSize.Text = $"DB Size: {sizeMb:F2} MB"); } } diff --git a/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj b/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj index adbcb34..f543a2f 100644 --- a/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj +++ b/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj @@ -9,6 +9,8 @@ SystemAware true app.manifest + + 1.0.0 @@ -31,10 +33,15 @@ - - + + + + + diff --git a/src/Predictalytics.WinFormsHost/Program.cs b/src/Predictalytics.WinFormsHost/Program.cs index b561731..e6ab020 100644 --- a/src/Predictalytics.WinFormsHost/Program.cs +++ b/src/Predictalytics.WinFormsHost/Program.cs @@ -7,17 +7,17 @@ namespace Predictalytics.WinFormsHost; internal static class Program { + /// The license this run is based on — read by the "Lizenzstatus" menu entry. + internal static LicenseSession? LicenseSession { get; private set; } + [STAThread] static void Main() { ApplicationConfiguration.Initialize(); - // ─── License gate: no usable license, no app ─── - var licenseClient = LicenseGuard.EnsureLicensed(); - if (licenseClient == null) - { - return; - } + // Settings first: the error reporting needs the token before the license gate runs, + // otherwise a failing activation would never show up in the error stream. + var settings = AppSettings.Load(); var mainForm = new MainForm(); var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm); @@ -48,6 +48,9 @@ internal static class Program // ── RichTextBox Terminal ── .WriteTo.Sink(new RichTextBoxSink(rtbWriteAction), restrictedToMinimumLevel: LogEventLevel.Warning) + // ── Deployment Center error stream (Error/Fatal) ── + .WriteTo.Sink(new DcErrorSink(), restrictedToMinimumLevel: LogEventLevel.Error) + // ══════════════════════════════════════════════ // FILE SINKS — By Level // ══════════════════════════════════════════════ @@ -147,19 +150,37 @@ internal static class Program .CreateLogger(); + // ─── Deployment Center: error stream + global exception handlers ─── + DcErrorReporter.Configure(settings.DcToken, settings.DcErrorReportingEnabled); + DcErrorReporter.InstallGlobalHandlers(); + + // Without this every activation would show up as "1.0.0" in the license list. + Deploymentcenter.Client.LicenseClient.DefaultAppVersion = DcConfig.AppVersion; + Log.Warning("══════════════════════════════════════════════════════"); - Log.Warning(" 🚀 Predictalytics v1.0 — Data retrieval started!"); + Log.Warning(" 🚀 Predictalytics v{Version} — Data retrieval started!", DcConfig.AppVersion); Log.Warning(" 📊 First platform report in 5 minutes."); Log.Warning("══════════════════════════════════════════════════════"); - mainForm.Initialize(); + // ─── License gate: no usable license, no app ─── + LicenseSession = LicenseGuard.EnsureLicensed(); + if (LicenseSession == null) + { + Log.Information("Keine nutzbare Lizenz — Anwendung wird beendet."); + DcErrorReporter.Shutdown(); + Log.CloseAndFlush(); + return; + } + + mainForm.Initialize(settings); // While running: re-check the license every 12 h (revocation/expiry/offline grace). - using var licenseTimer = LicenseGuard.StartPeriodicRevalidation(licenseClient); + using var licenseTimer = LicenseGuard.StartPeriodicRevalidation(LicenseSession); System.Windows.Forms.Application.Run(mainForm); Log.Information("Application shutting down."); + DcErrorReporter.Shutdown(); Log.CloseAndFlush(); } } diff --git a/src/Predictalytics.WinFormsHost/Services/DcApiClient.cs b/src/Predictalytics.WinFormsHost/Services/DcApiClient.cs new file mode 100644 index 0000000..b5e8dd0 --- /dev/null +++ b/src/Predictalytics.WinFormsHost/Services/DcApiClient.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Predictalytics.WinFormsHost.Services; + +/// +/// Failed Deployment Center call. is the stable, machine readable +/// error code from the API envelope ("unauthorized", "rate_limited", ...) — react to it, +/// not to the message text. +/// +public sealed class DcApiException : Exception +{ + public DcApiException(HttpStatusCode statusCode, string? code, string body) + : base($"Deployment Center HTTP {(int)statusCode}{(code is null ? "" : $" ({code})")}: {Shorten(body)}") + { + StatusCode = statusCode; + Code = code; + } + + public HttpStatusCode StatusCode { get; } + public string? Code { get; } + + /// True for errors that repeating the same call cannot fix (wrong/missing token). + public bool IsPermanent => + StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden || + Code is "unauthorized" or "project_forbidden"; + + private static string Shorten(string body) => + body.Length <= 300 ? body : body[..300] + "…"; +} + +/// +/// Minimal JSON client for the Deployment Center API. Used by the heartbeat and the error +/// stream; the license and update modules bring their own client (Deploymentcenter.Client). +/// +public sealed class DcApiClient : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly HttpClient _http; + private readonly string _token; + + public DcApiClient(string token, TimeSpan? timeout = null) + { + _token = token ?? ""; + _http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(10) }; + } + + public async Task PostJsonAsync(string path, object payload, CancellationToken ct = default) + { + using var request = new HttpRequestMessage(HttpMethod.Post, DcConfig.BaseUrl + path); + if (!string.IsNullOrWhiteSpace(_token)) + { + request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_token}"); + } + request.Content = new StringContent( + JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"); + + using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new DcApiException(response.StatusCode, ExtractErrorCode(body), body); + } + return body; + } + + /// Reads error.code out of {"status":"error","error":{"code":"…"}}. + private static string? ExtractErrorCode(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.ValueKind == JsonValueKind.Object && + doc.RootElement.TryGetProperty("error", out var error) && + error.ValueKind == JsonValueKind.Object && + error.TryGetProperty("code", out var code)) + { + return code.GetString(); + } + } + catch (JsonException) + { + // Not every error path answers with the envelope (e.g. a proxy returning HTML). + } + return null; + } + + public void Dispose() => _http.Dispose(); +} diff --git a/src/Predictalytics.WinFormsHost/Services/DcConfig.cs b/src/Predictalytics.WinFormsHost/Services/DcConfig.cs new file mode 100644 index 0000000..9f29228 --- /dev/null +++ b/src/Predictalytics.WinFormsHost/Services/DcConfig.cs @@ -0,0 +1,37 @@ +namespace Predictalytics.WinFormsHost.Services; + +/// +/// Compile-time settings for the Deployment Center (https://dc.mhdf.de), which replaced +/// the separate Watchdog and LicenseLabrador servers. +/// +/// The base URL is deliberately NOT user configuration: it decides where the license check +/// goes and where update packages are downloaded from. A configurable endpoint would let +/// anyone point the app at a fake license or update server. +/// +public static class DcConfig +{ + public const string BaseUrl = "https://dc.mhdf.de"; + + /// Slug in dc_projects — license, UpdateService, error stream and bugtracker share it. + public const string ProductSlug = "predictalytics"; + + /// + /// Reported to the license activation list, the monitor and the error stream. + /// Generated by Deploymentcenter.BuildInfo.targets from <Version> in the csproj — + /// bump it there when releasing, it is what the UpdateService compares against. + /// + public static string AppVersion => BuildInfo.Version; + + /// Commit this build came from — travels with error reports. + public static string GitCommitShort => BuildInfo.GitCommitShort; + + /// Dashboard grouping of the Watchdog monitor. + public const string MonitorGroup = "Applications"; + + /// Value for the "environment" field of the error stream. +#if DEBUG + public const string Environment = "development"; +#else + public const string Environment = "production"; +#endif +} diff --git a/src/Predictalytics.WinFormsHost/Services/DcErrorReporter.cs b/src/Predictalytics.WinFormsHost/Services/DcErrorReporter.cs new file mode 100644 index 0000000..83d2fc6 --- /dev/null +++ b/src/Predictalytics.WinFormsHost/Services/DcErrorReporter.cs @@ -0,0 +1,247 @@ +using System.Diagnostics; +using Serilog; + +namespace Predictalytics.WinFormsHost.Services; + +/// +/// Forwards runtime errors to the Deployment Center error stream +/// (POST /api/errors/v1/report). The server groups identical errors, counts them up and +/// silences known-harmless ones via ignore rules — so this reports rather than filters. +/// +/// Static on purpose: the Serilog sink and the global exception handlers are wired up +/// before the settings are known, and both have to reach the same rate limiter. +/// +public static class DcErrorReporter +{ + /// Server limit is 60 reports per minute and IP — stay well below it. + private const int MaxReportsPerMinute = 20; + + /// The same error is only reported again after this interval (the server counts it up anyway). + private static readonly TimeSpan RepeatSuppression = TimeSpan.FromMinutes(5); + + private static readonly object Sync = new(); + private static readonly Dictionary RecentSignatures = new(); + + private static DcApiClient? _api; + private static bool _enabled; + private static bool _tokenRejected; + private static DateTime _windowStartUtc = DateTime.UtcNow; + private static int _sentInWindow; + + public static bool IsEnabled => _enabled && _api is not null && !_tokenRejected; + + /// (Re-)configures the reporter. An empty token switches it off. + public static void Configure(string token, bool enabled) + { + lock (Sync) + { + _api?.Dispose(); + _api = null; + _tokenRejected = false; + + if (!enabled || string.IsNullOrWhiteSpace(token)) + { + _enabled = false; + return; + } + + _api = new DcApiClient(token); + _enabled = true; + } + } + + public static void Shutdown() + { + lock (Sync) + { + _enabled = false; + _api?.Dispose(); + _api = null; + } + } + + /// + /// Installs the global handlers. Without them an unhandled exception ends the process + /// without a trace in the error stream — exactly the case the stream exists for. + /// + public static void InstallGlobalHandlers() + { + // Fully qualified: "Application" alone binds to the Predictalytics.Application namespace. + System.Windows.Forms.Application.ThreadException += (_, e) => + { + Log.Error(e.Exception, "Unbehandelte Ausnahme im UI-Thread"); + Report(e.Exception, "error"); + MessageBox.Show( + $"Ein unerwarteter Fehler ist aufgetreten:\n\n{e.Exception.Message}\n\n" + + "Der Fehler wurde an das Deployment Center gemeldet.", + "Predictalytics", MessageBoxButtons.OK, MessageBoxIcon.Error); + }; + + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + if (e.ExceptionObject is not Exception ex) return; + // Report before logging: the Serilog sink would report the same exception first + // and the duplicate suppression would then swallow the blocking call — the + // process would die before anything reached the server. + Report(ex, "fatal", blocking: true); + Log.Fatal(ex, "Unbehandelte Ausnahme — Prozess wird beendet"); + }; + + TaskScheduler.UnobservedTaskException += (_, e) => + { + Log.Warning(e.Exception, "Unbeobachtete Task-Ausnahme"); + Report(e.Exception, "error"); + e.SetObserved(); + }; + } + + public static void Report(Exception exception, string level = "error", bool blocking = false) + { + if (!IsEnabled) return; + + var inner = Unwrap(exception); + var (file, line) = ResolveOrigin(inner); + + Send( + exceptionType: inner.GetType().FullName ?? inner.GetType().Name, + message: inner.Message, + stackTrace: exception.ToString(), + level: level, + file: file, + line: line, + blocking: blocking); + } + + /// Reports a logged error that carries no exception (Log.Error("..." )). + public static void ReportMessage(string exceptionType, string message, string? stackTrace, string level) + { + if (!IsEnabled) return; + Send(exceptionType, message, stackTrace, level, null, null, blocking: false); + } + + private static void Send( + string exceptionType, string message, string? stackTrace, string level, + string? file, int? line, bool blocking) + { + DcApiClient api; + lock (Sync) + { + if (_api is null || !_enabled || _tokenRejected) return; + if (!PassesRateLimit(exceptionType, message)) return; + api = _api; + } + + var payload = new + { + project_slug = DcConfig.ProductSlug, + exception = exceptionType, + message = Truncate(message, 2000), + stack_trace = Truncate(stackTrace, 8000), + level, + build = DcConfig.AppVersion, + environment = DcConfig.Environment, + file, + line, + // Tells apart reports coming from several installations of the same build, + // and pins the report to an exact commit. + context = new { host = System.Environment.MachineName, commit = DcConfig.GitCommitShort } + }; + + var task = PostAsync(api, payload); + if (blocking) + { + task.Wait(TimeSpan.FromSeconds(5)); + } + } + + private static async Task PostAsync(DcApiClient api, object payload) + { + try + { + await api.PostJsonAsync("/api/errors/v1/report", payload).ConfigureAwait(false); + } + catch (DcApiException ex) when (ex.IsPermanent) + { + lock (Sync) { _tokenRejected = true; } + // Debug level on purpose: a warning here would be logged, land in the sink and + // come straight back as the next report. + Log.Debug("Deployment Center error stream rejected the token ({Code}); reporting disabled.", ex.Code); + } + catch (Exception ex) + { + Log.Debug(ex, "Deployment Center error report failed"); + } + } + + /// Local budget: the server counts duplicates itself, we only avoid burning the rate limit. + private static bool PassesRateLimit(string exceptionType, string message) + { + var now = DateTime.UtcNow; + + if (now - _windowStartUtc > TimeSpan.FromMinutes(1)) + { + _windowStartUtc = now; + _sentInWindow = 0; + } + if (_sentInWindow >= MaxReportsPerMinute) return false; + + var signature = exceptionType + "|" + Truncate(message, 200); + if (RecentSignatures.TryGetValue(signature, out var last) && now - last < RepeatSuppression) + { + return false; + } + + if (RecentSignatures.Count > 500) RecentSignatures.Clear(); + RecentSignatures[signature] = now; + _sentInWindow++; + return true; + } + + /// + /// AggregateException and TargetInvocationException say nothing about the actual defect; + /// grouping on them would throw unrelated errors into one bucket. + /// + private static Exception Unwrap(Exception exception) + { + while (exception is AggregateException { InnerExceptions.Count: 1 } aggregate) + { + exception = aggregate.InnerExceptions[0]; + } + return exception; + } + + /// Reads file and line from the first stack frame that has debug info (PDB present). + private static (string? File, int? Line) ResolveOrigin(Exception exception) + { + try + { + var trace = new StackTrace(exception, fNeedFileInfo: true); + foreach (var frame in trace.GetFrames()) + { + var file = frame.GetFileName(); + if (string.IsNullOrEmpty(file)) continue; + var lineNo = frame.GetFileLineNumber(); + return (ToRepoRelative(file!), lineNo > 0 ? lineNo : (int?)null); + } + } + catch + { + // Origin is a nicety, never a reason to drop the report. + } + return (null, null); + } + + /// Turns C:\build\...\src\Foo\Bar.cs into src/Foo/Bar.cs so the path matches the repo. + private static string ToRepoRelative(string path) + { + var normalized = path.Replace('\\', '/'); + var marker = normalized.LastIndexOf("/src/", StringComparison.OrdinalIgnoreCase); + return marker >= 0 ? normalized[(marker + 1)..] : normalized; + } + + private static string? Truncate(string? value, int max) + { + if (string.IsNullOrEmpty(value)) return value; + return value!.Length <= max ? value : value[..max] + "…"; + } +} diff --git a/src/Predictalytics.WinFormsHost/Services/DcErrorSink.cs b/src/Predictalytics.WinFormsHost/Services/DcErrorSink.cs new file mode 100644 index 0000000..96a1bb4 --- /dev/null +++ b/src/Predictalytics.WinFormsHost/Services/DcErrorSink.cs @@ -0,0 +1,39 @@ +using Serilog.Core; +using Serilog.Events; + +namespace Predictalytics.WinFormsHost.Services; + +/// +/// Serilog sink that forwards Error and Fatal events to the Deployment Center error stream. +/// +/// It is registered while the logger is being built, long before the settings are known — +/// is asked at emit time whether reporting is switched on, so +/// toggling the setting takes effect without rebuilding the logger. +/// +public sealed class DcErrorSink : ILogEventSink +{ + public void Emit(LogEvent logEvent) + { + if (logEvent.Level < LogEventLevel.Error) return; + if (!DcErrorReporter.IsEnabled) return; + + var level = logEvent.Level == LogEventLevel.Fatal ? "fatal" : "error"; + + if (logEvent.Exception is not null) + { + DcErrorReporter.Report(logEvent.Exception, level); + return; + } + + // No exception attached: the rendered message is all the identity this error has. + var source = logEvent.Properties.TryGetValue("SourceContext", out var ctx) + ? ctx.ToString().Trim('"') + : "Predictalytics"; + + DcErrorReporter.ReportMessage( + exceptionType: source, + message: logEvent.RenderMessage(), + stackTrace: null, + level: level); + } +} diff --git a/src/Predictalytics.WinFormsHost/Services/DcHeartbeatService.cs b/src/Predictalytics.WinFormsHost/Services/DcHeartbeatService.cs new file mode 100644 index 0000000..8f8bfe0 --- /dev/null +++ b/src/Predictalytics.WinFormsHost/Services/DcHeartbeatService.cs @@ -0,0 +1,217 @@ +using System.Runtime.InteropServices; +using System.Text.Json.Serialization; +using Serilog; + +namespace Predictalytics.WinFormsHost.Services; + +/// Single self-assessed health check sent along with a heartbeat. +public sealed class DcCheck +{ + public DcCheck(bool ok, string? message = null, double? value = null) + { + Ok = ok; + Message = message; + Value = value; + } + + [JsonPropertyName("ok")] public bool Ok { get; } + [JsonPropertyName("message")] public string? Message { get; } + [JsonPropertyName("value")] public double? Value { get; } +} + +/// What the application reports about itself in one heartbeat. +public sealed class DcHeartbeatSnapshot +{ + /// ok | warning | error — "stopped"/"maintenance" are sent by the service itself. + public string Status { get; set; } = "ok"; + + public string? Message { get; set; } + + /// A failing check downgrades an "ok" heartbeat to "warning" on the server. + public Dictionary Checks { get; } = new(); + + /// Numeric values; the server keeps 14 days of history per metric. + public Dictionary Metrics { get; } = new(); +} + +/// +/// Sends periodic heartbeats to the Deployment Center Watchdog +/// (POST /api/watchdog/v1/ping) so an outage of this app — or of the whole machine — +/// raises an alarm. A Deployment Center outage must never impact the app: every call +/// is best effort. +/// +/// The server evaluates by interval: no heartbeat for more than 2× the interval means +/// "warning", more than 4× means "down". +/// +public sealed class DcHeartbeatService : IDisposable +{ + private readonly DcApiClient _api; + private readonly string _source; + private readonly string _instance; + private readonly int _intervalSeconds; + private readonly Func>? _snapshotProvider; + private readonly DateTime _startedUtc = DateTime.UtcNow; + + // Guards against overlapping sends when a call takes longer than the interval. + private readonly SemaphoreSlim _sendGate = new(1, 1); + + private System.Threading.Timer? _timer; + private bool _lastSendFailed; + private bool _tokenRejected; + private bool _stoppingNotified; + private volatile bool _disposed; + + public DcHeartbeatService( + string token, + string source, + string instance, + int intervalSeconds, + Func>? snapshotProvider = null) + { + _api = new DcApiClient(token); + _source = string.IsNullOrWhiteSpace(source) ? "Predictalytics" : source; + _instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance; + _intervalSeconds = Math.Max(15, intervalSeconds); + _snapshotProvider = snapshotProvider; + } + + public void Start() + { + _timer?.Dispose(); + _timer = new System.Threading.Timer( + async _ => await SendHeartbeatAsync().ConfigureAwait(false), + null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds)); + Log.Information("🐕 Deployment Center Heartbeat gestartet → {Url} (source={Source}, alle {Interval}s)", + DcConfig.BaseUrl, _source, _intervalSeconds); + } + + /// + /// Announces a planned shutdown as status "stopped". The evaluator leaves such a monitor + /// alone until a normal heartbeat arrives again — without it, every orderly shutdown + /// produces a false alarm a few minutes later. + /// + public void NotifyStopping() + { + if (_stoppingNotified) return; + _stoppingNotified = true; + + try + { + _timer?.Dispose(); + _timer = null; + + var payload = BuildPayload("stopped", "Predictalytics wird planmäßig beendet.", null, null); + // Synchronous with a short cap: the form is closing and must not hang. + _api.PostJsonAsync("/api/watchdog/v1/ping", payload).Wait(TimeSpan.FromSeconds(4)); + } + catch + { + // Best effort only. + } + } + + /// + /// Invoked from a timer callback, i.e. as async void — an exception escaping here would + /// take the whole process down. Nothing in this method may throw. + /// + private async Task SendHeartbeatAsync() + { + if (_disposed || _tokenRejected) return; + if (!await _sendGate.WaitAsync(0).ConfigureAwait(false)) return; + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_intervalSeconds)); + + DcHeartbeatSnapshot snapshot; + try + { + snapshot = _snapshotProvider is null + ? new DcHeartbeatSnapshot() + : await _snapshotProvider(cts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + // Collecting the state must never keep the heartbeat from going out — + // that would turn a diagnostic hiccup into a false "down". + snapshot = new DcHeartbeatSnapshot { Status = "warning", Message = $"Statusermittlung fehlgeschlagen: {ex.Message}" }; + } + + snapshot.Metrics["uptime_sec"] = Math.Round((DateTime.UtcNow - _startedUtc).TotalSeconds); + + var payload = BuildPayload(snapshot.Status, snapshot.Message, snapshot.Checks, snapshot.Metrics); + await _api.PostJsonAsync("/api/watchdog/v1/ping", payload, cts.Token).ConfigureAwait(false); + + if (_lastSendFailed) + { + _lastSendFailed = false; + Log.Information("🐕 Deployment Center Heartbeat wieder erfolgreich zugestellt."); + } + } + catch (DcApiException ex) when (ex.IsPermanent) + { + // Retrying cannot help — a rejected token would otherwise log forever. + _tokenRejected = true; + Log.Warning("🐕 Deployment Center weist das Token zurück ({Code}) — Heartbeats werden eingestellt. " + + "Bitte in den Settings ein Token mit dem Recht 'watchdog:ping' eintragen.", ex.Code ?? "unauthorized"); + } + catch (Exception ex) + { + // Log the first failure as warning, subsequent ones quietly (no log flood). + if (!_lastSendFailed) + { + _lastSendFailed = true; + Log.Warning("🐕 Deployment Center Heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message); + } + else + { + Log.Debug(ex, "Deployment Center heartbeat failed"); + } + } + finally + { + _sendGate.Release(); + } + } + + public void Dispose() + { + _disposed = true; + _timer?.Dispose(); + _timer = null; + + // Give a send in flight a moment to finish before the HttpClient goes away. Called + // from the UI thread (settings change, form closing), so the wait stays short — if it + // expires, the pending call just fails and gets logged like any other network error. + if (_sendGate.Wait(TimeSpan.FromMilliseconds(250))) + { + _sendGate.Release(); + } + _api.Dispose(); + + // _sendGate is deliberately not disposed: a late Release() on a disposed semaphore + // would throw on the timer thread for no gain — SemaphoreSlim without a wait handle + // holds no unmanaged resources. + } + + private object BuildPayload( + string status, + string? message, + Dictionary? checks, + Dictionary? metrics) => new + { + source = _source, + instance = _instance, + type = "heartbeat", + status, + interval = _intervalSeconds, + message, + group = DcConfig.MonitorGroup, + os = $"{RuntimeInformation.OSDescription} / .NET {System.Environment.Version}", + // Since 2.1 the monitor can show which build is running — that is what ties + // "this monitor went down" to "we rolled out 1.4.3 an hour ago". + version = DcConfig.AppVersion, + checks = checks is { Count: > 0 } ? checks : null, + metrics = metrics is { Count: > 0 } ? metrics : null + }; +} diff --git a/src/Predictalytics.WinFormsHost/Services/DcUpdateService.cs b/src/Predictalytics.WinFormsHost/Services/DcUpdateService.cs new file mode 100644 index 0000000..ad480fe --- /dev/null +++ b/src/Predictalytics.WinFormsHost/Services/DcUpdateService.cs @@ -0,0 +1,58 @@ +using Deploymentcenter.Client; +using Serilog; + +namespace Predictalytics.WinFormsHost.Services; + +/// +/// Checks the Deployment Center UpdateService for a newer release +/// (GET /api/updateservice/v1/check, no token needed). +/// +/// The check itself only reads metadata. Installing is done by the standalone +/// update-agent, which replaces the running installation and therefore has to be +/// started explicitly by the user. +/// +public static class DcUpdateService +{ + private const string AgentFileName = "update-agent.exe"; + + public static async Task CheckAsync(string channel, CancellationToken ct = default) + { + var client = new UpdateClient(); + return await client.CheckForUpdateAsync( + baseUrl: DcConfig.BaseUrl, + projectId: DcConfig.ProductSlug, + currentVersion: DcConfig.AppVersion, + channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel, + cancellationToken: ct).ConfigureAwait(false); + } + + /// Path of the update agent next to the exe, or null if it was not deployed. + public static string? FindUpdateAgent() + { + var path = Path.Combine(AppContext.BaseDirectory, AgentFileName); + return File.Exists(path) ? path : null; + } + + /// + /// Hands control to the update agent and ends this process. Returns false if the agent + /// is not present — then the update has to be installed by hand. + /// + public static bool LaunchAgent(string channel) + { + var agent = FindUpdateAgent(); + if (agent is null) + { + Log.Warning("Update-Agent ({Agent}) liegt nicht neben der Anwendung — Update bitte manuell einspielen.", AgentFileName); + return false; + } + + Log.Information("Starte Update-Agent {Agent} (Kanal {Channel}) und beende die Anwendung...", agent, channel); + return UpdateClient.LaunchUpdateAgent( + agentPath: agent, + projectId: DcConfig.ProductSlug, + channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel, + action: "update", + version: "latest", + exitCurrentApp: true); + } +} diff --git a/src/Predictalytics.WinFormsHost/Services/LicenseDialog.cs b/src/Predictalytics.WinFormsHost/Services/LicenseDialog.cs index 0d0defa..94f0694 100644 --- a/src/Predictalytics.WinFormsHost/Services/LicenseDialog.cs +++ b/src/Predictalytics.WinFormsHost/Services/LicenseDialog.cs @@ -1,11 +1,11 @@ -using LicenseLabrador.Client; +using Deploymentcenter.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. +/// Lets the user enter/activate a license key; closes with OK only after the Deployment +/// Center confirmed the activation for this machine. /// public sealed class LicenseDialog : Form { @@ -15,9 +15,10 @@ public sealed class LicenseDialog : Form private readonly Button _btnActivate; private readonly Button _btnExit; - public LicenseResult? Result { get; private set; } + /// Set once the activation succeeded. + public LicenseSession? Session { get; private set; } - public LicenseDialog(LicenseClient client, LicenseResult? lastResult) + public LicenseDialog(LicenseClient client, HardwareIdResult hardware, LicenseValidationResult? lastResult) { _client = client; @@ -26,27 +27,38 @@ public sealed class LicenseDialog : Form MaximizeBox = false; MinimizeBox = false; StartPosition = FormStartPosition.CenterScreen; - ClientSize = new Size(460, 190); + ClientSize = new Size(560, 230); 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) + Size = new Size(536, 34) }; _txtKey = new TextBox { Location = new Point(12, 52), - Size = new Size(436, 26), + Size = new Size(536, 26), Font = new Font("Consolas", 11f), CharacterCasing = CharacterCasing.Upper }; + // The hardware ID is what the activation is bound to — without it, support cannot + // tell which slot to release when a machine is replaced. + var lblHardware = new Label + { + Text = $"Hardware-ID: {hardware.HardwareId} (Quelle: {hardware.HwidSource})", + Location = new Point(12, 84), + Size = new Size(536, 20), + ForeColor = Color.DimGray, + AutoEllipsis = true + }; + _lblStatus = new Label { - Location = new Point(12, 84), - Size = new Size(436, 50), + Location = new Point(12, 110), + Size = new Size(536, 62), ForeColor = Color.Firebrick, Text = FormatInitialStatus(lastResult) }; @@ -54,7 +66,7 @@ public sealed class LicenseDialog : Form _btnActivate = new Button { Text = "Aktivieren", - Location = new Point(252, 146), + Location = new Point(352, 186), Size = new Size(96, 30) }; _btnActivate.Click += async (_, _) => await ActivateAsync(); @@ -62,20 +74,29 @@ public sealed class LicenseDialog : Form _btnExit = new Button { Text = "Beenden", - Location = new Point(354, 146), + Location = new Point(454, 186), Size = new Size(94, 30), DialogResult = DialogResult.Cancel }; AcceptButton = _btnActivate; CancelButton = _btnExit; - Controls.AddRange(new Control[] { lblInfo, _txtKey, _lblStatus, _btnActivate, _btnExit }); + Controls.AddRange(new Control[] { lblInfo, _txtKey, lblHardware, _lblStatus, _btnActivate, _btnExit }); } - private static string FormatInitialStatus(LicenseResult? lastResult) + private static string FormatInitialStatus(LicenseValidationResult? lastResult) { - if (lastResult == null || lastResult.State == LicenseState.NoLicense) return ""; - return $"Letzte Prüfung: {lastResult.State} — {lastResult.Message}"; + if (lastResult is null) return ""; + + // IsTransient means the server gave no verdict at all — telling the user their + // license is bad would be wrong, the connection is. + if (lastResult.IsTransient) + { + return "Das Deployment Center ist derzeit nicht erreichbar und es liegt keine " + + "gültige Offline-Prüfung mehr vor. Bitte Verbindung prüfen und erneut versuchen.\n" + + lastResult.Message; + } + return $"Letzte Prüfung: {lastResult.Status} — {lastResult.Message}"; } private async Task ActivateAsync() @@ -83,27 +104,28 @@ public sealed class LicenseDialog : Form var key = _txtKey.Text.Trim(); if (string.IsNullOrWhiteSpace(key)) { + _lblStatus.ForeColor = Color.Firebrick; _lblStatus.Text = "Bitte einen Lizenzschlüssel eingeben."; return; } _btnActivate.Enabled = false; _lblStatus.ForeColor = Color.DimGray; - _lblStatus.Text = "Prüfe Lizenz am Server..."; + _lblStatus.Text = "Prüfe Lizenz am Deployment Center..."; try { - var result = await _client.ValidateAsync(key); - if (result.IsUsable && _client.VerifyChecksum(result)) + var result = await _client.ValidateAsync(DcConfig.ProductSlug, key, DcConfig.BaseUrl); + if (result.IsValid) { - Result = result; + Session = new LicenseSession(_client, key, result); DialogResult = DialogResult.OK; Close(); return; } _lblStatus.ForeColor = Color.Firebrick; - _lblStatus.Text = $"Lizenz nicht nutzbar ({result.State}):\n{result.Message}"; + _lblStatus.Text = $"Lizenz nicht nutzbar ({result.Status}):\n{result.Message}"; } catch (Exception ex) { diff --git a/src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs b/src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs index d463c34..97ecd9e 100644 --- a/src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs +++ b/src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs @@ -1,102 +1,155 @@ -using LicenseLabrador.Client; +using Deploymentcenter.Client; using Serilog; namespace Predictalytics.WinFormsHost.Services; +/// An activated license: the key belonging to this machine plus the last result. +public sealed class LicenseSession +{ + public LicenseSession(LicenseClient client, string licenseKey, LicenseValidationResult result) + { + Client = client; + LicenseKey = licenseKey; + LastResult = result; + } + + public LicenseClient Client { get; } + public string LicenseKey { get; } + public LicenseValidationResult LastResult { get; internal set; } +} + /// -/// 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. +/// Startup license gate backed by the Deployment Center (POST /api/license/v1/validate). +/// +/// Endpoint and product slug are deliberately compiled in (see ): +/// a configurable endpoint 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!"; - // HTTPS ist Pflicht, nicht Kosmetik: license.mhdf.de leitet http→https um, und .NET - // macht bei einem Redirect aus dem POST ein GET. Der Server antwortet darauf mit 405, - // das SDK wertet das als "unerreichbar" und meldet irrefuehrend NoLicense. - // Ausserdem gingen die BasicAuth-Credentials sonst im Klartext ueber die Leitung. - private static readonly string[] Endpoints = { "https://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); - } + public static LicenseClient CreateClient() => new(); + + /// Hardware ID v2 of this machine — shown in the dialog and needed for support. + public static HardwareIdResult GetHardwareInfo() => HardwareId.GetHardwareId(DcConfig.ProductSlug); /// - /// 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. + /// Blocks until a usable license is present. Tries the key from the encrypted local cache + /// first; otherwise (or when that 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() + public static LicenseSession? EnsureLicensed() { var client = CreateClient(); + var hardware = GetHardwareInfo(); - var result = client.RevalidateAsync().GetAwaiter().GetResult(); - if (result.IsUsable && client.VerifyChecksum(result)) + // The client takes the key as a parameter on every call; the key of the last + // successful activation lives in the encrypted local cache. + var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug); + + LicenseValidationResult? lastResult = null; + if (!string.IsNullOrWhiteSpace(cachedKey)) { - return client; + lastResult = client.ValidateAsync(DcConfig.ProductSlug, cachedKey!, DcConfig.BaseUrl) + .GetAwaiter().GetResult(); + if (lastResult.IsValid) + { + Log.Information("🔑 Lizenz geprüft: {Status}{Cached} (HWID {Hwid}, Quelle {Source})", + lastResult.Status, lastResult.IsCached ? " — aus Offline-Cache" : "", + hardware.HardwareId, hardware.HwidSource); + WarnIfGraceRunningOut(lastResult); + return new LicenseSession(client, cachedKey!, lastResult); + } + + Log.Warning("🔑 Gespeicherter Lizenzschlüssel nicht nutzbar ({Status}): {Message}", + lastResult.Status, lastResult.Message); } - using var dialog = new LicenseDialog(client, result); - if (dialog.ShowDialog() != DialogResult.OK) + using var dialog = new LicenseDialog(client, hardware, lastResult); + if (dialog.ShowDialog() != DialogResult.OK || dialog.Session is null) { return null; } - return client; + return dialog.Session; } /// - /// Starts the periodic in-app revalidation. Detects revocation/expiry while the app - /// keeps running; on a definitively unusable license the app is shut down. + /// Starts the periodic in-app revalidation. Detects revocation/expiry while the app keeps + /// running; only a definitive negative shuts the app down — a server outage must not. /// - public static System.Windows.Forms.Timer StartPeriodicRevalidation(LicenseClient client) + public static System.Windows.Forms.Timer StartPeriodicRevalidation(LicenseSession session) { var timer = new System.Windows.Forms.Timer { Interval = RevalidationIntervalMs }; timer.Tick += async (_, _) => { try { - var result = await client.RevalidateAsync(); - if (result.IsUsable && client.VerifyChecksum(result)) + var result = await session.Client.ValidateAsync( + DcConfig.ProductSlug, session.LicenseKey, DcConfig.BaseUrl); + session.LastResult = result; + + if (result.IsValid) { - if (result.State == LicenseState.ValidOffline) + if (result.IsCached) { - Log.Warning("Lizenzserver nicht erreichbar — Offline-Gnadenfrist läuft bis {GraceUntil}.", result.GraceUntil); + Log.Warning("Lizenzserver nicht erreichbar — Prüfung erfolgte aus dem Offline-Cache."); + } + WarnIfGraceRunningOut(result); + return; + } + + if (result.IsTransient) + { + // server_unavailable / cache_expired: no verdict, only a failed connection. + // A running installation must not be shut down for that — but an exhausted + // grace period is worth more than a warning: the next start will stop at + // the license dialog. + if (result.Status.Equals("cache_expired", StringComparison.OrdinalIgnoreCase)) + { + Log.Error("Offline-Gnadenfrist abgelaufen und das Deployment Center ist nicht erreichbar. " + + "Diese Sitzung läuft weiter, der nächste Start verlangt aber eine Online-Prüfung: {Message}", + result.Message); + } + else + { + Log.Warning("Lizenz-Revalidierung vorläufig fehlgeschlagen ({Status}): {Message} — wird erneut versucht.", + result.Status, result.Message); } return; } timer.Stop(); - Log.Fatal("Lizenzprüfung fehlgeschlagen ({State}): {Message} — Anwendung wird beendet.", result.State, result.Message); + Log.Fatal("Lizenzprüfung fehlgeschlagen ({Status}): {Message} — Anwendung wird beendet.", + result.Status, result.Message); MessageBox.Show( - $"Die Lizenz ist nicht mehr gültig ({result.State}):\n{result.Message}\n\nPredictalytics wird beendet.", + $"Die Lizenz ist nicht mehr gültig ({result.Status}):\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. + // Never kill the app from an unexpected exception here. Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht)."); } }; timer.Start(); return timer; } + + /// + /// Since 2.1 the offline grace period is really bounded (cache_ttl_hours, default 168 h). + /// A machine that is offline on purpose should learn that before it runs out, not after. + /// + private static void WarnIfGraceRunningOut(LicenseValidationResult result) + { + if (result.CacheExpiresAt is not { } expiresAt || expiresAt <= 0) return; + + var remaining = DateTimeOffset.FromUnixTimeSeconds(expiresAt) - DateTimeOffset.UtcNow; + if (remaining > TimeSpan.FromHours(48)) return; + + Log.Warning("🔑 Offline-Gnadenfrist endet in {Hours:F0} h ({Until:yyyy-MM-dd HH:mm} UTC) — " + + "bis dahin muss das Deployment Center einmal erreichbar sein.", + Math.Max(0, remaining.TotalHours), DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime); + } } diff --git a/src/Predictalytics.WinFormsHost/Services/WatchdogHeartbeatService.cs b/src/Predictalytics.WinFormsHost/Services/WatchdogHeartbeatService.cs deleted file mode 100644 index 0305f57..0000000 --- a/src/Predictalytics.WinFormsHost/Services/WatchdogHeartbeatService.cs +++ /dev/null @@ -1,141 +0,0 @@ -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); - } - - /// - /// Reports a planned shutdown so it is not alarmed as a crash. - /// Must be "stopped_graceful": the server's event_log.kind ENUM has no "stopping" - /// value, and that variant fails the insert with HTTP 500 after the state update. - /// - public void NotifyStopping() - { - try - { - var payload = new - { - source = _source, - instance = _instance, - kind = "stopped_graceful", - 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(); - } -}