From 0a72ea7e2809e4bd5dd3c407d086dccb029c123b Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 22 Aug 2026 10:44:56 +0200 Subject: [PATCH] Deploymentcenter-Integration (Schritte 0-8 des Konzepts) Bindet Lizenz, Watchdog, Fehler-Stream, UpdateService und Erstinstallation an das Deploymentcenter 2.5.1 an. Einbauort ist IBKRTrader.Hosting - den Host teilen sich Shell und Daemon. Projekt-Befunde aus dem Konzept vorab bereinigt: P1 Echte DB-Zugangsdaten als Vorgabewerte in AppSettings -> Platzhalter. Das alte Passwort steht weiterhin in der Git-Historie und ist als kompromittiert zu behandeln (Rotation ist Nutzer-Aktion). P2 AppPaths fiel unter Windows auf /etc/ibkrtrader zurueck, was .NET zu C:\etc\ibkrtrader aufloest. Jetzt %ProgramData%\IBKRTrader. P3 Globale Ausnahmebehandler (AppDomain / TaskScheduler) - vorher gab es keinen Logeintrag, wenn der Prozess unbehandelt wegbrach. P4 Version einmal zentral in Directory.Build.props statt zweimal hartkodiert. Bewusste Abweichungen vom DC-Leitfaden, beide fuer ein handelndes System: - Lizenz-Urteil fuehrt zum Sperrbetrieb (TradingEnabled=false) statt zu Environment.Exit(1). Keine neuen Einstiege, aber Risiko-, Exit- und Buchhaltungslogik laufen weiter. - exitCurrentApp bleibt immer false; der Aufrufer beendet geordnet. Das SDK haengt als Cross-Repo-ProjectReference am Schwester-Repo Deploymentcenter (Interim, siehe Konzept 2.2). Damit ist P5 offen: die Gitea-CI checkt das Schwester-Repo nicht aus und wird rot, bis der Bezug auf ein NuGet-Paket umgestellt ist. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 + Directory.Build.props | 10 + .../KONZEPT-Deploymentcenter-Integration.md | 652 ++++++++++++++++++ scripts/release.config.json | 22 + scripts/release.ps1 | 233 +++++++ scripts/release.sh | 280 ++++++++ settings.example.json | 11 + setup.json | 27 + src/IBKRTrader.App/Program.cs | 2 +- src/IBKRTrader.Core/Configuration/AppPaths.cs | 32 +- src/IBKRTrader.Core/Settings/AppSettings.cs | 68 +- src/IBKRTrader.Daemon/Program.cs | 50 +- src/IBKRTrader.Hosting/AppHostBuilder.cs | 56 +- .../Deploymentcenter/DcApiClient.cs | 100 +++ .../Deploymentcenter/DcConfig.cs | 30 + .../Deploymentcenter/DcCrashHandlers.cs | 35 + .../Deploymentcenter/DcErrorReporter.cs | 114 +++ .../Deploymentcenter/DcErrorSink.cs | 32 + .../Deploymentcenter/DcHeartbeatWorker.cs | 299 ++++++++ .../Deploymentcenter/DcUpdateService.cs | 80 +++ .../Deploymentcenter/LicenseGuard.cs | 143 ++++ .../IBKRTrader.Hosting.csproj | 16 + 22 files changed, 2281 insertions(+), 15 deletions(-) create mode 100644 Directory.Build.props create mode 100644 docs/konzepte/KONZEPT-Deploymentcenter-Integration.md create mode 100644 scripts/release.config.json create mode 100644 scripts/release.ps1 create mode 100644 scripts/release.sh create mode 100644 setup.json create mode 100644 src/IBKRTrader.Hosting/Deploymentcenter/DcApiClient.cs create mode 100644 src/IBKRTrader.Hosting/Deploymentcenter/DcConfig.cs create mode 100644 src/IBKRTrader.Hosting/Deploymentcenter/DcCrashHandlers.cs create mode 100644 src/IBKRTrader.Hosting/Deploymentcenter/DcErrorReporter.cs create mode 100644 src/IBKRTrader.Hosting/Deploymentcenter/DcErrorSink.cs create mode 100644 src/IBKRTrader.Hosting/Deploymentcenter/DcHeartbeatWorker.cs create mode 100644 src/IBKRTrader.Hosting/Deploymentcenter/DcUpdateService.cs create mode 100644 src/IBKRTrader.Hosting/Deploymentcenter/LicenseGuard.cs diff --git a/.gitignore b/.gitignore index 0d1a652..6825b61 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ Logs/ ## Local Claude / agent config .claude/settings.local.json + +## Deploymentcenter Release-Pipeline (siehe scripts/release.ps1 / release.sh) +.dc-tools/ +artifacts/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..2e7e291 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + + 1.0.0 + + diff --git a/docs/konzepte/KONZEPT-Deploymentcenter-Integration.md b/docs/konzepte/KONZEPT-Deploymentcenter-Integration.md new file mode 100644 index 0000000..df45607 --- /dev/null +++ b/docs/konzepte/KONZEPT-Deploymentcenter-Integration.md @@ -0,0 +1,652 @@ +# KONZEPT: Deploymentcenter-Integration + +> Stand: 2026-08-14 · Deploymentcenter-Version **2.5.1** · Quelle: `J:\Softwareprojekte\Deploymentcenter\docs`, +> `GET /api/updateservice/v1/changelog?since=2.4` +> Zielprojekt: IBKRTrader (App + Daemon), Projekt-Slug **`ibkrtrader`** +> SDK-Zielversion: `Deploymentcenter.Client` **2.5.1** (`DeploymentcenterSdk.Version` mitführen, damit +> künftige Anbindungen über `?since=2.5.1` direkt sehen, was sich seither geändert hat) + +> **Nachtrag 2026-08-14:** Die ursprüngliche Fassung dieses Dokuments (Stand DC 2.4) enthielt sieben +> Befunde/Bedenken in §8, die dem Deploymentcenter-Entwickler mitgeteilt wurden. Alle sieben sind mit +> 2.5.0/2.5.1 behoben — Einzelheiten weiterhin in §8, jetzt mit Fix-Vermerk. Zwei Neuerungen aus +> derselben Runde gehen über die ursprünglichen Befunde hinaus und sind in §5.1, §7.2 und §2.2 +> eingearbeitet: `exitCurrentApp` ist jetzt als harter Kill dokumentiert, und `BuildInfo.targets` liegt +> im NuGet-Paket selbst. + +Das Deploymentcenter (`https://dc.mhdf.de`) bündelt fünf Dienste, die IBKRTrader bisher +alle nicht hat: **Lizenz**, **Watchdog**, **Error-Reporting**, **Bugtracker** und +**UpdateService inkl. Erstinstallation**. Dieses Dokument beschreibt, in welcher +Reihenfolge sie angebunden werden, was dabei am Projekt vorher aufgeräumt werden muss +und wo die vom Deploymentcenter vorgeschlagenen Muster für ein *handelndes* System +nicht ohne Weiteres passen. + +--- + +## 0. Ausgangslage + +| | Zustand heute | +|---|---| +| DC-Anbindung | **keine** — kein Treffer für `Deploymentcenter`, `dc.mhdf.de` oder `DC_TOKEN` im Quelltext | +| Version | Zeichenkette `"1.0.0"` in `App/Program.cs:34` und `Daemon/Program.cs:43`, keine ``, keine `Directory.Build.props` | +| Globale Fehlerbehandlung | **keine** — weder `AppDomain.UnhandledException` noch `TaskScheduler.UnobservedTaskException` | +| Auslieferung | `dotnet publish` von Hand, `deploy/README.md`, systemd-Unit | +| CI | Gitea Actions, Matrix `ubuntu-latest` / `windows-latest`, Checkout **nur** von IBKRTrader | + +Referenzintegration ist **Predictalytics** (`src/Predictalytics.Hosting/Dc*.cs`, +`docs/BETRIEB-Deploymentcenter.md`). Sie liegt auf SDK-Stand 2.2 und ist damit +unvollständig — siehe [§8](#8-befunde-und-offene-punkte), Punkt D2. Übernommen wird +das Muster, nicht der Code eins zu eins. + +Der Einbauort ist **`IBKRTrader.Hosting`**: den Host teilen sich Shell und Daemon +(`AppHostBuilder`), damit gilt jede Anbindung automatisch für beide Betriebsformen. +`IBKRTrader.Core` bleibt frei davon — der Core soll keine Netzabhängigkeit zu einem +Verwaltungsserver bekommen. + +--- + +## 1. Voraussetzungen im Deploymentcenter (vor jedem Code) + +Ohne diese Schritte läuft nichts von dem, was danach kommt: + +1. **Projekt `ibkrtrader` anlegen** (WebUI → Projekte). Slug wird von Lizenz, + UpdateService, Fehler-Stream und Bugtracker gemeinsam benutzt. +2. **Offline-Gnadenfrist festlegen** (`default_cache_ttl_hours`). Vorgabe 168 h. + Für einen Handelsdienst, der auch bei gestörter Internetverbindung weiterlaufen + soll, ist das die entscheidende Stellschraube — siehe [§5](#5-lizenz). +3. **Lizenz für `ibkrtrader` ausstellen.** Ohne sie kommt die Anwendung nach dem + ersten Release nicht mehr an ihre Updates (Zugangsschutz seit 2.4). +4. **Master-Token** mit `watchdog:ping`, `bugtracker:report` — für die Anwendung. + Zweites Token mit `updateservice:publish` — für die Release-Pipeline, gehört + **nicht** in die Anwendung. +5. **Installationskonto** (Rolle `installer`) für die Erstinstallation. +6. **Evaluator-Cron prüfen.** `* * * * * php cli/tick.php --quiet`. Ohne ihn bleibt + ein abgestürzter Trading-Daemon im Dashboard dauerhaft grün — der Watchdog wäre + dekorativ. Derselbe Job erzeugt auch den Release-Zugangsschutz. + +--- + +## 2. Build-Grundlage + +### 2.1 `Directory.Build.props` mit `` + +```xml + + + 1.0.0 + + +``` + +`pack-and-deploy` liest die Version aus der Hauptassembly und **bricht bei einer +Abweichung zu `--version` ab**. Der Grund ist unangenehm genug dafür: Wird `1.0.1` +als `1.0.2` veröffentlicht, aktualisiert die gesamte Installationsbasis, meldet +danach weiterhin `1.0.1`, hält das Release erneut für neu — und aktualisiert bei +jedem Start wieder. + +Gleichzeitig entfallen die beiden hartkodierten `"1.0.0"` in den `Program.cs`; +sie werden durch `BuildInfo.Version` ersetzt. + +### 2.2 SDK-Anbindung — entschieden, in zwei Stufen + +`Deploymentcenter.Client` (`netstandard2.0;net8.0`) wird von `net10.0` problemlos +konsumiert. Seit SDK 2.5.0 liegt `BuildInfo.targets` im Paket selbst +(`build/`-Ordner, von NuGet automatisch importiert) — der frühere `` per +Cross-Repo-Pfad ist damit **nicht mehr nötig**. Das entscheidet die Frage +grundsätzlich zugunsten eines echten NuGet-Bezugs. Geprüft (2026-08-14): Weder +`https://dc.mhdf.de/nuget/` noch die Gitea-Package-Registry unter +`http://192.168.178.10:8418/api/packages/Richard/nuget/` führen aktuell ein +Paket `Deploymentcenter.Client` — es gibt also noch keine Quelle, gegen die eine +`PackageReference` auflösen könnte. + +**Deshalb zweistufig:** + +| Stufe | Weg | Zustand | +|---|---|---| +| **Jetzt (umgesetzt)** | Cross-Repo-`ProjectReference` (Muster Predictalytics), `Deploymentcenter.BuildInfo.targets` weiterhin per `` eingebunden — der pfadbasierte Import funktioniert laut Leitfaden unverändert weiter, auch wenn er nicht mehr nötig wäre | funktioniert sofort, keine neue Infrastruktur, keine Push-Entscheidung nötig | +| **Folgeschritt (offen)** | `dotnet pack` im Deploymentcenter-Repo, Push nach `.../api/packages/Richard/nuget/`, `NuGet.config` um Quelle `gitea` mit `packageSourceMapping` auf `Deploymentcenter.*` ergänzen, `` durch `` ersetzen | braucht einen Push in ein fremdes Repo (Deploymentcenter) und Package-Registry-Zugangsdaten — bewusst nicht Teil dieses Durchlaufs | + +Der Cross-Repo-Bezug bricht die Gitea-CI von IBKRTrader (der Runner checkt nur +dieses eine Repo aus) — siehe **P5**. Das ist mit der Wahl von Stufe 1 bewusst in +Kauf genommen, weil eine funktionierende lokale Integration wichtiger ist als eine +grüne CI, die eine noch nicht existierende Paketquelle voraussetzt. + +### 2.3 `BuildInfo` + +`Deploymentcenter.BuildInfo.targets` in die csproj von `IBKRTrader.Hosting` +importieren. Die Klasse entsteht in `$(RootNamespace)`, also `IBKRTrader.Hosting.BuildInfo` +— erreichbar ohne `using`. Liefert `Version`, `GitCommit`, `GitCommitShort`, +`BuildDateUtc`, `Channel`, `Summary`. + +> **Erledigt mit 2.5.1** (vormals §8 D4): `BuildDateUtc` trug die volle Uhrzeit, +> `WriteOnlyWhenDifferent` griff dadurch nie, und jedes importierende Projekt wurde +> bei jedem Build neu übersetzt. Seit 2.5.1 ist das Feld tagesgenau — höchstens ein +> Neubau pro Tag. Vorbedingung: die Targets-Datei im Schwester-Repo muss auf 2.5.1 +> oder neuer stehen (`git -C ../Deploymentcenter log -1 --oneline -- client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets`). + +--- + +## 3. Konfiguration und gemeinsame Bausteine + +### 3.1 `DcConfig` (kompiliert, nicht einstellbar) + +```csharp +namespace IBKRTrader.Hosting.Deploymentcenter; + +public static class DcConfig +{ + public const string BaseUrl = "https://dc.mhdf.de"; // bewusst KEINE Einstellung + public const string ProductSlug = "ibkrtrader"; + public const string MonitorGroup = "Trading"; + public static string AppVersion => BuildInfo.Version; + public static string GitCommitShort => BuildInfo.GitCommitShort; +} +``` + +Die Basis-URL entscheidet, wohin die Lizenzprüfung geht und woher ausführbarer +Code kommt. Einstellbar wäre sie eine Einladung, die Anwendung auf einen +gefälschten Update-Server zu zeigen. + +### 3.2 Neuer Abschnitt in `AppSettings` + +Ergänzt `src/IBKRTrader.Core/Settings/AppSettings.cs` um +`DeploymentcenterSettings` (gleiches Muster wie `LoggingSettings`, mit +`Category`/`DisplayName`/`Description`, damit es in der Settings-Ansicht auftaucht): + +| Feld | Vorgabe | Bedeutung | +|---|---|---| +| `ApiToken` | *(leer)* | `Authorization: Bearer`. Leer = kein Heartbeat, keine Fehlermeldung. `[PasswordPropertyText]` | +| `HeartbeatEnabled` | `true` | Dead-Man's-Switch | +| `MonitorSource` | *(Hostname)* | Monitorname im Dashboard | +| `MonitorInstance` | `default` | mehrere Instanzen (EU/US) desselben Dienstes | +| `HeartbeatIntervalSeconds` | `60` | `warning` nach 2×, `down` nach 4× | +| `ReportErrors` | `true` | Error/Fatal an den Fehler-Stream | +| `CheckUpdatesOnStart` | `true` | prüft nur, installiert nie von selbst | +| `UpdateChannel` | `prod` | `prod`, `beta`, `dev` | +| `LicenseKey` | *(leer)* | nur Rückfall; Regelfall ist der verschlüsselte Cache | + +`ApiToken` und `LicenseKey` sind schutzbedürftig und laufen über +`SecretProtection` (AES-256-GCM at rest), wie die übrigen Geheimnisse des Projekts. + +### 3.3 `DcApiClient` + +Schlanker JSON-Client für Heartbeat und Fehler-Stream (Lizenz und Update bringen +ihren eigenen mit). Übernimmt von Predictalytics: + +- `DcApiException` mit stabilem `error.code` aus dem Umschlag + `{"status":"error","error":{"code":"…"}}` — reagiert wird auf den Code, nie auf den Text. +- `IsPermanent` für `401`/`403`/`unauthorized`: danach wird nicht weiter versucht. +- Zeitgrenze 10 s. **Ein nicht erreichbares Deploymentcenter darf den Handel + niemals beeinflussen** — jeder Aufruf ist best effort. + +--- + +## 4. Watchdog + +Neuer Worker `DcHeartbeatWorker` in `IBKRTrader.Hosting`, registriert wie die +übrigen (`IWorker` + `AddHostedService`), damit er in der Worker-Ansicht +auftaucht und manuell auslösbar ist. + +`POST /api/watchdog/v1/ping` alle *n* Sekunden mit `source`, `instance`, +`interval`, `os`, `version = BuildInfo.Version`, `group = "Trading"`. + +### 4.1 `checks` — was IBKRTrader wirklich über sich weiß + +Ein Heartbeat beweist nur, dass ein Timer läuft. Der klassische Fehlerfall dieses +Projekts ist ein anderer: der Prozess lebt, aber die TWS-Verbindung ist seit einer +Stunde tot und es kommen keine Kurse mehr. + +| Check | Quelle | `ok` wenn | +|---|---|---| +| `db` | EF/`MySqlConnector`, `SELECT 1`, gedeckelt auf 5 s | Verbindung steht | +| `ibkr_gateway` | `IBKRGatewayService` | verbunden **oder** `UseTwsApi = false` (NullBroker ist ein gewollter Zustand, kein Fehler) | +| `market_data` | `IBKRMarketDataRepository` | letzter Tick jünger als *x* min — nur wenn TWS aktiv | +| `worker_engine` | `WorkerEngine` | kein Worker im Fehlerzustand | + +Bewusst **kein** Check auf „Trading aktiv": `TradingEnabled = false` ist ein +gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf `warning` +halten. Er wandert als Metrik mit. + +### 4.2 `metrics` + +`uptime_sec`, `trading_enabled` (0/1), `open_positions`, `orders_today`, +`account_value`, `workers_running`, `memory_mb`, `decision_journal_lag_sec`. + +Serverseitig 14 Tage Verlauf mit Abweichung zum 7-Tage-Mittel desselben Monitors — +damit lässt sich „heute plötzlich 40 Orders statt 3" erkennen, ohne im +Deploymentcenter Schwellwerte für jedes Projekt zu pflegen. + +### 4.3 Sauberes Beenden + +Beim geordneten Herunterfahren (SIGTERM/systemd, Fenster schließen) geht ein Ping +mit `status: "stopped"` raus. Ohne ihn folgt wenige Minuten nach jedem +planmäßigen Neustart ein Fehlalarm. Der Aufruf gehört in `StopAsync` +**vor** das Beenden der übrigen Dienste und braucht eine eigene, kurze Zeitgrenze +(3 s), damit ein hängender Monitoring-Server das Herunterfahren nicht blockiert. + +### 4.4 Hierarchie + +Läuft der Daemon auf einem eigenen Host, gehört im WebUI unter +**WatchDog → System-Hierarchie** die *Übergeordnete Entität* auf diesen Host +gesetzt. Sonst erzeugt ein Maschinenausfall zwei Meldungen für ein Problem. + +--- + +## 5. Lizenz + +`LicenseGuard` in `IBKRTrader.Hosting`, aufgerufen aus `RunStartupChecks` — +nach der Zeitzone (Logausgaben) und **vor** dem Start der Worker. + +- Produkt-Slug `ibkrtrader`, Hardware-ID v2 (`2:win:…` / `2:lin:…`). +- `LicenseClient.DefaultAppVersion = BuildInfo.Version` einmalig beim Start, + sonst trägt jede Installation in der Aktivierungsliste dieselbe `1.0.0`. +- Revalidierung zur Laufzeit alle 12 h; Warnung, wenn die Gnadenfrist in unter + 48 h abläuft. +- **`LicenseConfig.SetStorageDirectory(AppPaths.ConfigFile("license"))`** — + ausdrücklich setzen. Die Vorgabe des SDK löst unter Linux über `$XDG_CONFIG_HOME` + bzw. `$HOME` auf; ein systemd-Dienst mit `User=ibkrtrader` und ohne Heimatverzeichnis + landet im Rückfall auf `AppContext.BaseDirectory/license`, also unter `/opt` — + dort ist nichts schreibbar, und es gäbe **keinen Offline-Cache**. Mit `AppPaths` + fügt sich die Ablage in die bereits vorhandene Verzeichnisauflösung des Projekts ein. + **Erledigt/entschärft mit 2.5.1** (vormals §8 D4): der Rückfall ohne `$HOME` zeigt + jetzt serverseitig auf `/var/lib/` statt ins Installationsverzeichnis — unser + eigenes explizites Setzen bleibt trotzdem die robustere Wahl und ist unabhängig vom + SDK-Rückfall. + ⚠️ **Breaking Change 2.5.1:** `SetStorageDirectory` hängt jetzt selbst `/license` + an (vorher wurde der Pfad roh benutzt). Der Aufruf wird zu + `LicenseConfig.SetStorageDirectory(AppPaths.Config)` — **ohne** das eigene + `"license"`-Suffix, sonst entstünde `…/license/ibkrtrader/license`. + +### 5.1 Kopflose Schalter + +Konvention aus dem Lizenz-Leitfaden, im Daemon umzusetzen (das SDK bringt keine CLI mit): + +``` +IBKRTrader.Daemon --license-status +IBKRTrader.Daemon --license-set-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX +``` + +`--license-deactivate` **nicht** verdrahten: es verlangt den `shared_key` des +Servers, der gehört nicht in eine ausgelieferte Anwendung. Freigeben läuft über +das WebUI (Hardware-Liste → „Freigeben"). + +> **Geändert mit 2.5.0:** der `update-agent` nimmt den Lizenzschlüssel nicht mehr +> als `--license-key`-Argument entgegen — `ps` zeigt Prozessargumente jedem Nutzer +> der Maschine, dieselbe Begründung wie beim Crontab-Verzicht in UPGRADE §5. Der +> Weg ist jetzt die Umgebungsvariable `DC_LICENSE_KEY` (`LaunchUpdateAgent` setzt +> sie selbst, wenn `licenseKey` übergeben wird — kein eigener Code nötig). Unsere +> `--license-set-key`-Implementierung ruft also `LicenseClient.ValidateAsync` +> direkt auf und schreibt **nicht** `--license-key` in einen eigenen Aufruf des +> Agenten. +> +> Ebenfalls neu: `EnsureLicensedAsync()` auf `LicenseClient` — nimmt den +> zwischengespeicherten Schlüssel, fragt bei Bedarf über `ILicensePrompt` nach, +> prüft, fragt bei Ablehnung erneut. `allowPrompt:false` lehnt ohne Cache ab, statt +> im Daemon auf eine Eingabe zu warten, die nie kommt — **das ist für den Daemon +> die richtige Einstiegsmethode**, nicht das manuelle Zusammensetzen aus +> `TryGetCachedKey` + `ValidateAsync`. + +### 5.2 Reaktion — hier weicht IBKRTrader bewusst vom Leitfaden ab + +Die DC-Doku empfiehlt bei einem endgültigen Urteil `Environment.Exit(1)`. +**Für einen Dienst mit offenen Positionen ist das die falsche Reaktion.** Ein +abgelaufener Vertrag ist kein Grund, ein Portfolio unbeaufsichtigt zu lassen — +Stop-Loss- und Exit-Logik müssen weiterlaufen, sonst richtet die Lizenzdurchsetzung +mehr Schaden an als der unlizenzierte Betrieb. + +Vorschlag — drei Stufen statt zwei: + +| Ergebnis | Zustände | Reaktion | +|---|---|---| +| `IsValid` | `valid`, `valid_offline` | Normalbetrieb | +| `IsTransient` | `server_unavailable`, `cache_expired` | **kein Urteil, nur keine Auskunft.** Warnen, weiterlaufen, beim nächsten Durchlauf erneut fragen | +| Urteil | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | **Sperrbetrieb:** `TradingEnabled` hart auf `false`, keine neuen Einstiege, Risiko-/Exit-Worker und Buchhaltung laufen weiter, Meldung an Watchdog (`status: "warning"`) und Fehler-Stream. Beendet wird erst, wenn keine Position mehr offen ist — oder auf ausdrückliche Anweisung | + +Bei der Desktop-Shell darf es beim Start weiterhin ein Lizenzfenster geben; der +**Daemon** darf niemals auf eine Eingabe warten. + +--- + +## 6. Error-Reporting + +Zwei Wege in `POST /api/errors/v1/report`, beide in `IBKRTrader.Hosting`: + +1. **Senke am `LoggingService`.** `LoggingService.EntryWritten` existiert bereits + und ist genau dafür gedacht (die Oberfläche hängt schon dran). Ein + `DcErrorSink` filtert auf `AppLogLevel.Error` und meldet mit + `level: "error"`, `build: BuildInfo.Version`, `file`/`line` aus dem Stacktrace, + `context` mit `GitCommitShort` und `CorrelationId`/`SignalId`, wenn vorhanden. + Die `SignalId` mitzuschicken ist der eigentliche Gewinn: eine Fehlermeldung + lässt sich damit im Deploymentcenter direkt gegen das Entscheidungsjournal halten. +2. **Globale Handler** — `AppDomain.UnhandledException` (`level: "fatal"`, + **blockierend** gesendet, der Prozess ist gleich weg) und + `TaskScheduler.UnobservedTaskException`. Beide fehlen im Projekt heute + vollständig; sie müssen sowieso her. + +### 6.1 Bremse + +Verpflichtend, sonst verbrennt ein Fehler in einer Handelsschleife das +serverseitige Rate-Limit binnen Sekunden: + +- höchstens 20 Meldungen pro Minute, +- derselbe Fehler höchstens alle 5 Minuten, +- bei `401`/`403` dauerhaft abschalten, +- bei `429` Intervall verdoppeln. + +Betriebsbedingtes Rauschen gehört nicht in diese Bremse, sondern in die +**Ignore-Regeln** des WebUI: dort wird weitergezählt, aber nicht gemeldet — und +eine Alarmschwelle schlägt an, wenn ein bekannter Fehler plötzlich hundertmal so +oft auftritt. + +> **Nicht melden:** alles, was Kontodaten, Positionen oder Schlüsselmaterial im +> Text führen könnte. Der Fehler-Stream ist eine externe Ablage. Vor dem Senden +> wird der Text durch dieselbe Maskierung geschickt, die schon für die Logs gilt. + +--- + +## 7. UpdateService, Erstinstallation und Release-Pipeline + +### 7.1 Prüfen, nicht installieren + +`DcUpdateService.CheckAsync` beim Start (`GET /api/updateservice/v1/check`). +**Mit Zugangsdaten**, sonst gibt es seit 2.4 nur noch `401`: + +```csharp +var key = settings.Deploymentcenter.LicenseKey is { Length: > 0 } k + ? k + : LicenseClient.TryGetCachedKey(DcConfig.ProductSlug); + +var check = await new UpdateClient().CheckForUpdateAsync( + baseUrl: DcConfig.BaseUrl, + projectId: DcConfig.ProductSlug, + currentVersion: BuildInfo.Version, + channel: settings.Deploymentcenter.UpdateChannel, + credentials: ReleaseCredentials.FromLicenseKey(key), + cancellationToken: ct); + +if (check.Unauthorized) { /* Lizenzproblem, KEIN Netzwerkfehler — eigene Meldung */ } +``` + +Gefunden wird nur, installiert wird nichts von selbst. Ein Trading-Dienst, der +sich unangekündigt neu startet, ist keine akzeptable Vorgabe. + +- Kein Update → Info ins Log. +- Update verfügbar → Warnung + Hinweis in der Shell. +- Kritisch → zusätzlich Dialog (Shell) bzw. Watchdog-Event (Daemon). + +### 7.2 Anwenden + +Über `UpdateClient.LaunchUpdateAgent(..., licenseKey: key)`; der Agent bekommt +`--restart`, `--wait-for-pid`, `--platform` von selbst, den Lizenzschlüssel seit +2.5.0 **nicht mehr** als `--license-key`-Argument, sondern über die +Umgebungsvariable `DC_LICENSE_KEY` (siehe §5.1). Vorher geht ein +`status: "stopped"` an den Watchdog. + +**Vorbedingung, die die Anwendung selbst prüfen muss:** kein offener Auftrag, +keine laufende Order. Ein Update, das den Prozess zwischen Order-Absendung und +Statusrückmeldung abräumt, hinterlässt eine Order bei IBKR ohne Gegenstück im +Journal. Das Update wird also erst nach dem Handelsschluss bzw. bei leerem +Auftragsbuch angeboten. + +> **Verschärft mit 2.5.0:** `exitCurrentApp: true` ist jetzt ausdrücklich als +> **harter Kill** dokumentiert — laufende `finally`-Blöcke, `IHostApplicationLifetime` +> und Destruktoren kommen nicht mehr zum Zug. Für IBKRTrader heißt das: Solange +> die Vorbedingung oben nicht sicher geprüft ist (offenes Auftragsbuch, laufende +> Buchungstransaktion), muss `exitCurrentApp: false` gesetzt werden. Der Agent +> wartet über `--wait-for-pid` ohnehin auf das Prozessende; das eigene +> Herunterfahren läuft dann über `host.StopAsync(...)` wie in `App/Program.cs` +> bereits vorhanden — **nicht** über `LaunchUpdateAgent` selbst. Zusätzlich +> `waitTimeoutSeconds` heraufsetzen, falls das geordnete Beenden (EF-Flush, +> Kestrel, Log-Flush, DB-Backup-Worker) länger als die Vorgabe von 60 s dauern +> kann — läuft die Zeit ab, bricht der Agent ohne Änderung ab. + +Für den Linux-Dienst siehe [§8 D3](#8-befunde-und-offene-punkte) — dafür gibt es +im Deploymentcenter derzeit keinen fertigen Weg. + +### 7.3 Was ausgeliefert wird — Ausschließen vs. Schützen + +Das ist die Stelle, an der Updates in anderen Projekten schon Konfigurationen +zerstört haben. Für IBKRTrader gilt: + +| Datei | Liste | Begründung | +|---|---|---| +| `settings.example.json` | *(im Paket)* | Vorlage mit Platzhaltern — Erstinstallation braucht sie | +| `settings.json` | `preservePatterns` | enthält DB-Zugang, DC-Token, Flex-Token. Muss ein Update überleben | +| `appsettings.Local.json` | `preservePatterns` | Verbindungszeichenfolge des Zielsystems | +| `master.key`, `openrouter.key` | `excludePatterns` | **darf nie ins Paket** | +| `Logs/**`, `Backups/**` | `excludePatterns` | Laufzeitdaten | +| `*.pdb`, `*.xml` | `excludePatterns` | Build-Artefakte | +| `update-agent*` | bewusst entscheiden | mitliefern = aktualisiert sich selbst mit | + +`pack-and-deploy` warnt von sich aus bei Dateien, die nach Zugangsdaten aussehen. +Die Warnung ist ernst zu nehmen: Release-Pakete liegen zwar hinter einem +Zugangsschutz, aber jeder lizenzierte Kunde kann sie auspacken. + +### 7.4 `setup.json` + +Beschreibt der Erstinstallation, was abzufragen ist; wird ins Paket kopiert. +Entwurf für IBKRTrader: + +```json +{ + "schema": 1, + "displayName": "IBKRTrader", + "targets": [ + { "id": "app", "file": "settings.json", "location": "install", "format": "json" } + ], + "fields": [ + { "key": "Database:Host", "label": "MySQL-Host", "type": "string" }, + { "key": "Database:Database", "label": "Datenbank", "type": "string" }, + { "key": "Database:User", "label": "Benutzer", "type": "string" }, + { "key": "Database:Password", "label": "Passwort", "type": "secret" }, + { "key": "IBKR:Host", "label": "TWS/Gateway", "default": "127.0.0.1" }, + { "key": "IBKR:Port", "label": "Port (Paper 4002 / Live 4001)", "type": "int", "default": "4002" }, + { "key": "Trading:ApplicationTimeZoneId", "label": "Betriebszeitzone", "default": "Europe/Berlin" }, + { "key": "Deploymentcenter:BaseUrl", "source": "detect:baseurl", "type": "url" }, + { "key": "Deploymentcenter:ApiToken", "label": "Token dieser Installation", + "source": "provision", "scopes": ["watchdog:ping", "bugtracker:report"] }, + { "key": "Deploymentcenter:MonitorSource", "source": "detect:hostname" } + ] +} +``` + +Zwei Punkte dazu: + +- **`location: "install"` ist nur unter Windows richtig** — und auch dort nur, + solange nicht nach `C:\Program Files` installiert wird. Siehe + [§8 P2](#8-befunde-und-offene-punkte); solange `AppPaths` nicht korrigiert ist, + passen `setup.json` und die tatsächliche Leseposition der Anwendung unter Linux + nicht zusammen. **Dieser Punkt blockiert die Erstinstallation und gehört vor §7.4.** +- Der Installer schreibt **Klartext**. `SecretProtection` muss also Klartext + unverändert durchreichen und beim ersten Speichern verschlüsseln — die Weiche + ist zu prüfen und im Test ausdrücklich als *aktiv genutzt* zu beschreiben, damit + sie nicht eines Tages als tot entfernt wird. + +### 7.5 Release-Pipeline + +`scripts/release.ps1` und `scripts/release.sh` sind per `curl` von +`/docs/release-template/` geholt (wie `release.md` empfiehlt, statt aus dem +lokalen Schwester-Repo kopiert — damit ist sichergestellt, dass die kanonische +Fassung im Projekt liegt) und **unverändert** gelassen. Nur +`scripts/release.config.json` ist ausgefüllt: + +```json +{ + "project": "ibkrtrader", + "csproj": "src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj", + "runtimes": ["win-x64", "linux-x64"], + "selfContained": true, + "setupJson": "setup.json" +} +``` + +**Entschieden: nur der Daemon, keine Zwei-Produkte-Aufteilung.** IBKRTrader hat +zwei Einstiegspunkte (`App` = Avalonia-Shell, `Daemon` = kopflos). Die Vorlage +nimmt aber genau **eine** `csproj` für alle Runtimes in der Liste — sie ruft +`dotnet publish -r ` in einer Schleife auf, es gibt keine +Möglichkeit, App für `win-x64` und Daemon für `linux-x64` in **demselben** Lauf +zu veröffentlichen, ohne das (laut Anleitung unveränderte) Skript anzufassen. +Ursprünglich in diesem Dokument vorgeschlagen war genau das ("Windows = App, +Linux = Daemon") — das ist mit der Vorlage so nicht umsetzbar und wurde beim +Umsetzen korrigiert. + +`IBKRTrader.Daemon` ist der bewusste Kandidat: laut README „plattformneutral" +(`net10.0` ohne Plattformbindung), läuft kopflos auf beiden Zielsystemen und +ist ohnehin die für den Serverbetrieb vorgesehene Form (`deploy/README.md`). +Eine automatisierte Auslieferung der Avalonia-Shell (`App`) als +Windows-Desktop-Distribution ist damit **nicht** Teil dieses Durchlaufs — dafür +bräuchte es entweder einen zweiten Produkt-Slug (`ibkrtrader-app`, doppelte +Lizenz-/Update-Pflege) oder ein angepasstes Release-Skript. Zurückgestellt, bis +tatsächlich Bedarf an einer automatisierten Desktop-Auslieferung besteht — bis +dahin bleibt `dotnet run --project src/IBKRTrader.App` bzw. eine manuelle +`dotnet publish` der übliche Weg für die Shell. + +Zugangsdaten kommen aus `DC_FTP_HOST` / `DC_FTP_USER` / `DC_FTP_PASS` / `DC_TOKEN`, +`.dc-tools/` und `artifacts/` sind in der `.gitignore`. + +**`packager.config.json` (`excludePatterns`/`preservePatterns`) ist nicht Teil +dieses Durchlaufs.** Sie liegt "neben dem Werkzeug", also unter `.dc-tools/` — +das entsteht erst, wenn `pack-and-deploy` beim ersten echten Lauf von +`release.ps1`/`release.sh` selbst heruntergeladen wird (Prüfsummen-geprüft). +Ein erster Release-Lauf braucht `DC_FTP_*`/`DC_TOKEN` und ist damit eine +credentialierte, vom Betreiber auszulösende Aktion. **Sicherheitsnetz bis +dahin:** Der Packager bricht seit 2.5.0 ohnehin hart ab, wenn er im +Publish-Verzeichnis nach Zugangsdaten aussehende Dateien findet (Name *und* +Inhalt) — die in §7.3 beschriebenen Dateien (`settings.json`, +`appsettings.Local.json`) sind ohnehin gitignored und landen nur dann im +Publish-Output, wenn sie lokal im Projektverzeichnis liegen (`` in den `.csproj`-Dateien kopiert sie dann mit). Bricht +`release.ps1` mit einem Hinweis auf eine vermutete Zugangsdatei ab, ist das der +Packager, der genau das abfängt — kein Fehlschlag, sondern die Absicherung, die +2.5.0 dafür eingeführt hat. + +--- + +## 8. Befunde und offene Punkte + +> **Status 2026-08-14:** Alle sieben ursprünglich an den Deploymentcenter-Entwickler +> gemeldeten Befunde (D1–D7) sind mit den Versionen 2.5.0/2.5.1 behoben — bestätigt über +> `GET /api/updateservice/v1/changelog?since=2.4`. Der Abschnitt bleibt als Nachweis +> stehen, jeder Punkt trägt jetzt einen Fix-Vermerk. Die Projekt-Befunde (P1–P6) sind +> davon unabhängig und weiterhin offen — sie werden in diesem Durchlauf abgearbeitet. + +### Am Projekt (P) — muss vor der Integration bereinigt werden + +**P1 · Echte Zugangsdaten als Vorgabewerte im Quelltext.** +`src/IBKRTrader.Core/Settings/AppSettings.cs:14-35` trägt Host, Datenbankname, +Benutzer und **Passwort** einer produktiven MariaDB als Vorgabe. Die Datei ist +versioniert, die Werte stehen damit auch in der Git-Historie — `settings.json` +im `.gitignore` hilft dagegen nicht. Das Passwort ist als kompromittiert zu +behandeln und zu wechseln; die Vorgaben gehören auf Platzhalter. Zusätzlich +brisant, weil die Vorlage künftig im Release-Paket ausgeliefert wird. + +> **Verschärft mit 2.5.0:** der Packager **bricht jetzt hart ab**, wenn er +> Zugangsdaten im Publish-Verzeichnis erkennt (Dateiname *und* Inhalt, u. a. +> gefülltes `Password=`) — vorher nur eine Warnung, die auf der Preserve-Liste +> sogar unterdrückt wurde. Damit ist P1 nicht mehr „sollte bereinigt werden", +> sondern **release-blockierend**: Ohne Fix lässt sich `settings.example.json` +> gar nicht mehr veröffentlichen. + +**P2 · `AppPaths` fällt unter Windows auf einen Linux-Pfad zurück.** +`Configuration/AppPaths.cs:57-71`: ist das Binärverzeichnis nicht beschreibbar, +gilt der FHS-Rückfall `/etc/ibkrtrader`. Unter Windows löst .NET das zu +`C:\etc\ibkrtrader` auf. Genau dieser Fall tritt ein, sobald der DC-Installer +seinen Windows-Vorschlag `Programme\` benutzt — dann liegen `settings.json`, +`master.key` und `Logs/` in `C:\etc\`. Nötig: plattformabhängiger Rückfall +(`%ProgramData%\IBKRTrader` bzw. `%APPDATA%`). + +**P3 · Keine globalen Ausnahmebehandler.** Ohne +`AppDomain.UnhandledException` und `TaskScheduler.UnobservedTaskException` gibt es +keine `fatal`-Meldungen — und heute auch keinen Logeintrag, wenn der Prozess +wegbricht. Unabhängig vom Deploymentcenter überfällig. + +**P4 · Version an zwei Stellen hartkodiert.** `App/Program.cs:34` und +`Daemon/Program.cs:43`. Ohne `Directory.Build.props` bricht `pack-and-deploy` beim +ersten Release ab — zu Recht. + +**P5 · CI kennt das Schwester-Repo nicht.** `.gitea/workflows/build.yml` checkt +nur IBKRTrader aus. Eine `ProjectReference` nach `..\..\..\..\Deploymentcenter\…` +macht den Build auf beiden Matrix-Zielen rot. Siehe die Entscheidung in §2.2. + +**P6 · Konfiguration liegt im Installationsverzeichnis.** Unter Windows schreibt +`AppPaths` neben die Binärdatei. Das ist mit `preservePatterns` beherrschbar +(§7.3), muss aber beim ersten Release stimmen — ein Update, das `settings.json` +überschreibt, nimmt einer laufenden Installation Datenbank, Token und Flex-Zugang +gleichzeitig. + +### Am Deploymentcenter (D) — gemeldet, Stand vor 2.5.0 + +**D1 · Der Lizenzschlüssel als Update-Zugang war nirgends vollständig erklärt.** +✅ **Behoben (2.5.1, Doku).** Laut Changelog explizit als „Lückenschluss" genannt: +„Woher die Anwendung den Lizenzschlüssel für den Update-Zugang nimmt +(`TryGetCachedKey`)". Für uns bleibt zusätzlich `EnsureLicensedAsync()` (neu in +2.5.0) relevant — siehe §5.1. + +**D2 · Predictalytics wurde vom Zugangsschutz ausgesperrt.** +✅ **Bestätigt und behoben (2.5.1).** Der Changelog bestätigt den Verdacht wörtlich: +„bei Predictalytics ist genau das passiert". Fix: Zugangsschutz ist jetzt je +Produkt abschaltbar (WebUI → UpdateService → Zugangsschutz) — für Installationsbasen +ohne SDK 2.4 lässt sich das Produkt ausnehmen, mit Schlüssel neu ausliefern, danach +wieder einschalten. **Für IBKRTrader heißt das:** genau diese Falle von Anfang an +vermeiden, indem der erste ausgelieferte Build bereits `licenseKey` mitgibt (§7.5, +§9 Reihenfolge). + +**D3 · Für einen systemd-Dienst gab es keinen dokumentierten Update-Weg.** +✅ **Behoben (2.5.1, Doku).** Laut Changelog ergänzt: „wie ein laufender +systemd-Dienst aktualisiert wird". Bleibt in §7.2/§8-P laufend zu beobachten, ob +die neue Beschreibung mit unserem tatsächlichen Vorgehen (`--target-dir`, +Dienstbenutzer-Rechte) übereinstimmt — bei der Umsetzung von Aufgabe „Update-Prüfung +mit ReleaseCredentials" gegenprüfen. + +**D4 · `LicenseConfig.GetStorageDirectory`/`SetStorageDirectory` war für Dienste +unglücklich.** ✅ **Behoben, mit Breaking Change (2.5.1).** Zwei Korrekturen: +(a) Rückfall ohne `$HOME` zeigt jetzt auf `/var/lib/` statt ins +Installationsverzeichnis. (b) `SetStorageDirectory` hängt jetzt selbst +`/license` an, statt den Pfad roh zu verwenden — **das ändert unseren +Aufruf**, siehe §5 (`AppPaths.Config` statt `AppPaths.ConfigFile("license")`). + +**D5 · `BuildInfo.targets` verhinderte inkrementelle Builds.** +✅ **Behoben (2.5.1).** `BuildDateUtc` ist jetzt tagesgenau statt mit voller Uhrzeit, +`WriteOnlyWhenDifferent` greift wieder. Siehe §2.3. + +**D6 · Die Empfehlung „bei einem Urteil beenden" passte für handelnde Systeme nicht.** +✅ **Behoben (2.5.1, Doku)** — als Lückenschluss genannt: „was statt +`Environment.Exit(1)` zu tun ist, wenn Beenden die gefährlichere Option wäre". +Unser Sperrbetrieb-Ansatz (§5.2) bleibt die konkrete Umsetzung für IBKRTrader. +**Zusätzlich, über D6 hinausgehend:** 2.5.0 dokumentiert `exitCurrentApp: true` +jetzt als harten Kill ohne `finally`/Lifecycle-Hooks — praktisch relevanter für uns +als die Lizenz-Frage allein, siehe §7.2. + +**D7 · Kleinigkeiten** (README-Verweis auf `AGENT_RELEASE_GUIDE.md`, Rate-Limits +nicht durchgängig beziffert). ✅ **Behoben (2.5.1, Doku)** — Changelog nennt +„die Drosselungsgrenzen aller Endpunkte" als ergänzt; `docs/README.md` führt jetzt +`/docs/release.md` und den Changelog-Endpunkt im Index. + +--- + +## 9. Reihenfolge + +| Schritt | Inhalt | Abhängig von | Stand | +|---|---|---|---| +| 0 | **P1** Zugangsdaten wechseln, Vorgaben auf Platzhalter | — | ⬜ dieser Durchlauf | +| 1 | **P2** `AppPaths`-Rückfall plattformabhängig, **P4** `Directory.Build.props` | — | ⬜ dieser Durchlauf | +| 2 | SDK-Bezug Stufe 1: Cross-Repo-`ProjectReference` (§2.2) | — | ⬜ dieser Durchlauf | +| 3 | `DcConfig`, `DeploymentcenterSettings`, `DcApiClient`, `BuildInfo` | 1, 2 | ⬜ dieser Durchlauf | +| 4 | **P3** globale Handler + Fehler-Stream | 3 | ⬜ dieser Durchlauf | +| 5 | Watchdog-Heartbeat inkl. `stopped` | 3 | ⬜ dieser Durchlauf | +| 6 | Lizenz mit Sperrbetrieb statt Abbruch (§5.2), `EnsureLicensedAsync` | 3 | ⬜ dieser Durchlauf | +| 7 | Update-Prüfung **mit** `ReleaseCredentials`, `exitCurrentApp:false` bei offenem Zustand | 6 (braucht den Schlüssel) | ⬜ dieser Durchlauf | +| 8 | `setup.json`, `preserve`/`exclude`, Release-Pipeline (Vorlage per `curl` von `/docs/release-template/`) | 1–7 | ⬜ dieser Durchlauf | +| 9 | Bugtracker-Baustein nach `AGENTS.md` / `.agents/rules` | Projekt-Slug angelegt | ⬜ separat (Projekt `ibkrtrader` muss im WebUI erst angelegt werden) | +| 10 | **Folgeschritt, nicht Teil dieses Durchlaufs:** `Deploymentcenter.Client` als NuGet-Paket in die Gitea-Registry pushen, `` durch `PackageReference` ersetzen, Gitea-CI (**P5**) auf den Paketbezug umstellen | 2 | ⬜ offen — siehe §2.2 | + +Schritt 7 und 8 hängen zusammen: **der erste ausgelieferte Build muss die +Zugangsdaten schon mitbringen.** Für ein Produkt, das noch nie veröffentlicht hat, +entsteht `/releases/ibkrtrader/` mit dem ersten Upload und wird vom nächsten +`tick.php`-Lauf geschützt — es gibt kein Zeitfenster, das SDK danach nachzurüsten. +Wird Schritt 7 übersprungen, fällt die Tür hinter dem ersten Release zu (genau der +Fall, der laut Changelog bei Predictalytics bereits eingetreten ist, siehe D2). + +Schritt 9 (Bugtracker) setzt voraus, dass das Projekt `ibkrtrader` im +Deploymentcenter-WebUI angelegt ist und ein Token mit `bugtracker:report` +vorliegt — beides serverseitige Handarbeit, die dieser Durchlauf nicht auslösen +kann. Wird zurückgestellt. diff --git a/scripts/release.config.json b/scripts/release.config.json new file mode 100644 index 0000000..33fc70f --- /dev/null +++ b/scripts/release.config.json @@ -0,0 +1,22 @@ +{ + "_comment": "Kopie als scripts/release.config.json angelegt und ausgefuellt. Diese Datei enthaelt KEINE Zugangsdaten - die kommen aus Umgebungsvariablen (DC_FTP_HOST, DC_FTP_USER, DC_FTP_PASS, DC_TOKEN) oder aus einer packager.config.json neben dem Werkzeug. release.config.json darf deshalb versioniert werden.", + + "_project_comment": "IBKRTrader.Daemon ist der Release-Kandidat: plattformneutral (net10.0 ohne Plattformbindung, siehe README), laeuft kopflos auf beiden Zielsystemen. IBKRTrader.App (Avalonia-Shell) ist bewusst NICHT Teil dieser automatisierten Pipeline - das Vorlagenskript nimmt genau eine csproj fuer alle Runtimes, und das Skript bleibt laut Anleitung unveraendert. Eine Desktop-Distribution der Shell ist ein separater, spaeterer Schritt.", + "project": "ibkrtrader", + + "csproj": "src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj", + + "_runtimes_comment": "Je Eintrag entsteht ein eigenes Release - ohne Plattformangabe wuerden sie sich gegenseitig ueberschreiben.", + "runtimes": ["win-x64", "linux-x64"], + + "_selfContained_comment": "true nimmt die .NET-Laufzeit ins Paket, damit ein frisch aufgesetztes System kein vorinstalliertes .NET braucht (siehe deploy/README.md zur ICU-Warnung).", + "selfContained": true, + + "publishSingleFile": false, + + "setupJson": "setup.json", + + "mainAssembly": "", + + "baseUrl": "https://dc.mhdf.de" +} diff --git a/scripts/release.ps1 b/scripts/release.ps1 new file mode 100644 index 0000000..1be4f08 --- /dev/null +++ b/scripts/release.ps1 @@ -0,0 +1,233 @@ +<# +.SYNOPSIS + Veroeffentlicht dieses Projekt im Deploymentcenter. + +.DESCRIPTION + Vorlage zum Kopieren nach scripts/release.ps1 des eigenen Projekts. + Anzupassen ist nur der Kopf von release.config.json - dieses Skript + selbst bleibt unveraendert. + + Der Ablauf je Zielplattform: + dotnet publish -r -> pack-and-deploy --platform + + pack-and-deploy uebernimmt dabei Pruefsummen, Dateimanifest, das + Fortschreiben der latest.json und die Anmeldung beim Deploymentcenter. + Das hier nachzubauen waere ein zweiter Ort fuer dieselben Fehler; das + Skript orchestriert nur. + + Fehlt das Werkzeug, wird es geholt und die Pruefsumme geprueft. + +.EXAMPLE + .\scripts\release.ps1 -Version 1.4.3 -Changelog "Behebt den Login-Fehler." + +.EXAMPLE + .\scripts\release.ps1 -Version 1.5.0 -Channel beta -WhatIf +#> + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + # Ohne Angabe wird die Version aus Directory.Build.props bzw. der csproj gelesen. + [string] $Version, + + [ValidateSet('prod', 'beta', 'dev')] + [string] $Channel = 'prod', + + [string] $Changelog, + + # Als kritisches Update kennzeichnen (Rollout priorisieren). + [switch] $Critical, + + [string] $ConfigFile = (Join-Path $PSScriptRoot 'release.config.json') +) + +$ErrorActionPreference = 'Stop' + +# ---------------------------------------------------------------- Konfiguration +if (-not (Test-Path $ConfigFile)) { + throw "Konfiguration fehlt: $ConfigFile`nVorlage kopieren: release.config.example.json -> release.config.json" +} + +$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json + +foreach ($required in @('project', 'csproj', 'runtimes')) { + if (-not $config.$required) { + throw "In $ConfigFile fehlt der Eintrag '$required'." + } +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$csprojRel = $config.csproj +$csproj = Join-Path $repoRoot $csprojRel + +if (-not (Test-Path $csproj)) { + throw "Projektdatei nicht gefunden: $csproj" +} + +$baseUrl = if ($config.baseUrl) { $config.baseUrl.TrimEnd('/') } else { 'https://dc.mhdf.de' } +$toolDir = Join-Path $repoRoot '.dc-tools' + +# ---------------------------------------------------------------------- Version +function Get-ProjectVersion { + # Directory.Build.props zuerst: Steht nur in einem von mehreren + # Projekten, laufen die Angaben frueher oder spaeter auseinander - und + # pack-and-deploy bricht dann zu Recht mit einem Versionskonflikt ab. + foreach ($candidate in @( + (Join-Path $repoRoot 'Directory.Build.props'), + $csproj + )) { + if (-not (Test-Path $candidate)) { continue } + + $match = [regex]::Match((Get-Content $candidate -Raw), '\s*([^<]+?)\s*') + if ($match.Success) { + return $match.Groups[1].Value.Trim() + } + } + + return $null +} + +if (-not $Version) { + $Version = Get-ProjectVersion + if (-not $Version) { + throw "Keine in Directory.Build.props oder $csprojRel gefunden. Bitte -Version angeben." + } + Write-Host "Version aus dem Projekt gelesen: $Version" -ForegroundColor DarkGray +} + +if (-not $Changelog) { + $Changelog = "Release v$Version" +} + +# ------------------------------------------------------------------- Werkzeug +function Get-PackAndDeploy { + $exe = Join-Path $toolDir 'pack-and-deploy.exe' + if (Test-Path $exe) { return $exe } + + Write-Host "pack-and-deploy wird geholt ..." -ForegroundColor Cyan + New-Item -ItemType Directory -Force -Path $toolDir | Out-Null + + $name = 'pack-and-deploy-win-x64.exe' + $temp = Join-Path $toolDir 'download.tmp' + + Invoke-WebRequest -Uri "$baseUrl/installer/$name" -OutFile $temp -UseBasicParsing + + $expectedRaw = (Invoke-WebRequest -Uri "$baseUrl/installer/$name.sha256" -UseBasicParsing).Content + $expected = if ($expectedRaw -is [byte[]]) { + [System.Text.Encoding]::ASCII.GetString($expectedRaw) + } else { [string]$expectedRaw } + $expected = $expected.Trim().ToLower() + + $actual = (Get-FileHash $temp -Algorithm SHA256).Hash.ToLower() + + if ($actual -ne $expected) { + Remove-Item $temp -Force + throw "Pruefsumme von $name stimmt nicht.`n erwartet: $expected`n erhalten: $actual" + } + + Move-Item $temp $exe -Force + try { Unblock-File $exe -ErrorAction SilentlyContinue } catch { } + + Write-Host " Pruefsumme in Ordnung." -ForegroundColor DarkGray + return $exe +} + +$packAndDeploy = Get-PackAndDeploy + +# --------------------------------------------------------------------- Ablauf +Write-Host '' +Write-Host "Projekt : $($config.project)" -ForegroundColor White +Write-Host "Version : $Version" +Write-Host "Kanal : $Channel" +Write-Host "Plattform : $($config.runtimes -join ', ')" +Write-Host '' + +$results = @() + +foreach ($rid in $config.runtimes) { + Write-Host "=== $rid ===" -ForegroundColor Cyan + + $publishDir = Join-Path $repoRoot "artifacts/publish/$rid" + + if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'dotnet publish')) { + # Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden + # sonst mit ins Paket wandern. + if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force } + + $publishArgs = @( + 'publish', $csproj, + '-c', 'Release', + '-r', $rid, + '-o', $publishDir, + '--nologo' + ) + + if ($config.selfContained) { $publishArgs += '--self-contained', 'true' } + else { $publishArgs += '--self-contained', 'false' } + + if ($config.publishSingleFile) { $publishArgs += '-p:PublishSingleFile=true' } + + & dotnet @publishArgs + if ($LASTEXITCODE -ne 0) { throw "dotnet publish fuer $rid ist fehlgeschlagen." } + } + + # setup.json mitliefern, damit der Installer weiss, was einzurichten ist. + # Bewusst kein Ternary-Operator: den gibt es erst ab PowerShell 7, und + # diese Vorlage soll auch mit dem mitgelieferten 5.1 laufen. + $setupRel = if ($config.setupJson) { $config.setupJson } else { 'setup.json' } + $setupJson = Join-Path $repoRoot $setupRel + if (Test-Path $setupJson) { + Copy-Item $setupJson (Join-Path $publishDir 'setup.json') -Force + Write-Host " setup.json mitgenommen" -ForegroundColor DarkGray + } + + if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'pack-and-deploy')) { + $packArgs = @( + '--project', $config.project, + '--version', $Version, + '--channel', $Channel, + '--platform', $rid, + '--publish-dir', $publishDir, + '--changelog', $Changelog + ) + + if ($Critical) { $packArgs += '--critical' } + if ($config.mainAssembly) { $packArgs += '--main-assembly', $config.mainAssembly } + + & $packAndDeploy @packArgs + $code = $LASTEXITCODE + + $results += [pscustomobject]@{ Runtime = $rid; ExitCode = $code } + + # 1 = Konfigurationsfehler oder Versionskonflikt: dann stimmt etwas + # Grundsaetzliches, und die weiteren Plattformen wuerden genauso + # scheitern. + if ($code -eq 1) { throw "pack-and-deploy meldet einen Konfigurations- oder Versionsfehler." } + } + + Write-Host '' +} + +# ------------------------------------------------------------------ Ergebnis +Write-Host '=== Ergebnis ===' -ForegroundColor White + +foreach ($r in $results) { + $text = switch ($r.ExitCode) { + 0 { 'vollstaendig veroeffentlicht' } + 2 { 'TEILWEISE - Upload oder Registrierung fehlgeschlagen' } + default { "unerwarteter Rueckgabewert $($r.ExitCode)" } + } + $color = if ($r.ExitCode -eq 0) { 'Green' } else { 'Yellow' } + Write-Host (" {0,-16} {1}" -f $r.Runtime, $text) -ForegroundColor $color +} + +$failed = @($results | Where-Object { $_.ExitCode -ne 0 }) + +if ($failed.Count -gt 0) { + Write-Host '' + Write-Host 'Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen,' -ForegroundColor Yellow + Write-Host 'ob die bereits hochgeladenen Dateien konsistent sind.' -ForegroundColor Yellow + exit 2 +} + +Write-Host '' +Write-Host "Fertig. $($config.project) $Version ist im Kanal $Channel verfuegbar." -ForegroundColor Green diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..106cb74 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash +# +# Veroeffentlicht dieses Projekt im Deploymentcenter. +# +# Vorlage zum Kopieren nach scripts/release.sh des eigenen Projekts. +# Anzupassen ist nur scripts/release.config.json - dieses Skript selbst +# bleibt unveraendert. +# +# Der Ablauf je Zielplattform: +# dotnet publish -r -> pack-and-deploy --platform +# +# pack-and-deploy uebernimmt Pruefsummen, Dateimanifest, das Fortschreiben der +# latest.json und die Anmeldung beim Deploymentcenter. Das hier nachzubauen +# waere ein zweiter Ort fuer dieselben Fehler; das Skript orchestriert nur. +# +# ./scripts/release.sh --version 1.4.3 --changelog "Behebt den Login-Fehler." +# ./scripts/release.sh --version 1.5.0 --channel beta --dry-run + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CONFIG_FILE="${DC_RELEASE_CONFIG:-$SCRIPT_DIR/release.config.json}" + +VERSION="" +CHANNEL="prod" +CHANGELOG="" +CRITICAL=0 +DRY_RUN=0 + +usage() { + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +} + +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --channel) CHANNEL="$2"; shift 2 ;; + --changelog) CHANGELOG="$2"; shift 2 ;; + --critical) CRITICAL=1; shift ;; + --dry-run|-n) DRY_RUN=1; shift ;; + --help|-h) usage ;; + *) echo "Unbekannte Option: $1" >&2; exit 1 ;; + esac +done + +# ------------------------------------------------------------- Voraussetzungen +command -v dotnet >/dev/null 2>&1 || { + echo "FEHLER: dotnet wird gebraucht, ist aber nicht installiert." >&2 + exit 1 +} + +[ -f "$CONFIG_FILE" ] || { + echo "FEHLER: Konfiguration fehlt: $CONFIG_FILE" >&2 + echo " Vorlage kopieren: release.config.example.json -> release.config.json" >&2 + exit 1 +} + +# JSON lesen - mit jq, sonst mit Python. +# +# Bewusst nicht nur jq: Es ist auf den wenigsten Systemen vorinstalliert, und +# an einer fehlenden Abhaengigkeit soll die Vorlage nicht scheitern. Python +# liegt auf den meisten Entwickler- und CI-Systemen ohnehin bereit. +JSON_READER="" + +if command -v jq >/dev/null 2>&1; then + JSON_READER="jq" +else + # Nicht nur pruefen, ob der Befehl existiert, sondern ob er laeuft: + # Windows legt unter WindowsApps einen python3-Platzhalter ab, der + # gefunden wird, beim Aufruf aber nur auf den Store verweist. + for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1 \ + && "$candidate" -c "import json" >/dev/null 2>&1; then + JSON_READER="$candidate" + break + fi + done +fi + +if [ -z "$JSON_READER" ]; then + echo "FEHLER: Zum Lesen von $CONFIG_FILE wird jq oder ein lauffaehiges Python gebraucht." >&2 + exit 1 +fi + +# Liest einen Skalar. Pfad in jq-Schreibweise, z. B. .project +cfg() { + if [ "$JSON_READER" = "jq" ]; then + jq -r "$1 // empty" "$CONFIG_FILE" + else + "$JSON_READER" -c " +import json,sys +d=json.load(open(sys.argv[1], encoding='utf-8')) +for part in sys.argv[2].lstrip('.').split('.'): + if not isinstance(d, dict): d=None; break + d=d.get(part) +if d is None: print('') +elif isinstance(d, bool): print('true' if d else 'false') +else: print(d) +" "$CONFIG_FILE" "$1" + fi +} + +# Liest ein Feld mit Zeichenketten, eine je Zeile. +cfg_list() { + if [ "$JSON_READER" = "jq" ]; then + jq -r "$1[]?" "$CONFIG_FILE" + else + "$JSON_READER" -c " +import json,sys +d=json.load(open(sys.argv[1], encoding='utf-8')) +for part in sys.argv[2].lstrip('.').split('.'): + d = d.get(part) if isinstance(d, dict) else None +for item in (d or []): print(item) +" "$CONFIG_FILE" "$1" + fi +} + +PROJECT="$(cfg .project)" +CSPROJ_REL="$(cfg .csproj)" +BASE_URL="$(cfg .baseUrl)" +BASE_URL="${BASE_URL:-https://dc.mhdf.de}" +BASE_URL="${BASE_URL%/}" +SETUP_REL="$(cfg .setupJson)" +SETUP_REL="${SETUP_REL:-setup.json}" +MAIN_ASSEMBLY="$(cfg .mainAssembly)" + +[ -n "$PROJECT" ] || { echo "FEHLER: 'project' fehlt in $CONFIG_FILE" >&2; exit 1; } +[ -n "$CSPROJ_REL" ] || { echo "FEHLER: 'csproj' fehlt in $CONFIG_FILE" >&2; exit 1; } + +CSPROJ="$REPO_ROOT/$CSPROJ_REL" +[ -f "$CSPROJ" ] || { echo "FEHLER: Projektdatei nicht gefunden: $CSPROJ" >&2; exit 1; } + +mapfile -t RUNTIMES < <(cfg_list .runtimes) +[ "${#RUNTIMES[@]}" -gt 0 ] || { echo "FEHLER: 'runtimes' ist leer." >&2; exit 1; } + +SELF_CONTAINED="$(cfg .selfContained)" +SINGLE_FILE="$(cfg .publishSingleFile)" + +# -------------------------------------------------------------------- Version +if [ -z "$VERSION" ]; then + # Directory.Build.props zuerst: Steht nur in einem von mehreren + # Projekten, laufen die Angaben auseinander - und pack-and-deploy bricht + # dann zu Recht mit einem Versionskonflikt ab. + for candidate in "$REPO_ROOT/Directory.Build.props" "$CSPROJ"; do + [ -f "$candidate" ] || continue + VERSION="$(sed -n 's:.*\s*\([^<]*\)\s*.*:\1:p' "$candidate" | head -1 | tr -d '[:space:]')" + [ -n "$VERSION" ] && break + done + + [ -n "$VERSION" ] || { + echo "FEHLER: Keine gefunden. Bitte --version angeben." >&2 + exit 1 + } + echo "Version aus dem Projekt gelesen: $VERSION" +fi + +CHANGELOG="${CHANGELOG:-Release v$VERSION}" + +# ------------------------------------------------------------------- Werkzeug +TOOL_DIR="$REPO_ROOT/.dc-tools" +PACK="$TOOL_DIR/pack-and-deploy" + +fetch_tool() { + [ -x "$PACK" ] && return 0 + + case "$(uname -m)" in + x86_64|amd64) rid="linux-x64" ;; + aarch64|arm64) rid="linux-arm64" ;; + *) echo "FEHLER: Nicht unterstuetzte Architektur $(uname -m)" >&2; exit 1 ;; + esac + + echo "pack-and-deploy wird geholt ($rid) ..." + mkdir -p "$TOOL_DIR" + + tmp="$(mktemp)" + curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid" -o "$tmp" + expected="$(curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid.sha256" | tr -d ' \t\r\n')" + + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$tmp" | cut -d' ' -f1)" + else + actual="$(shasum -a 256 "$tmp" | cut -d' ' -f1)" + fi + + if [ "$actual" != "$expected" ]; then + rm -f "$tmp" + echo "FEHLER: Pruefsumme stimmt nicht." >&2 + echo " erwartet: $expected" >&2 + echo " erhalten: $actual" >&2 + exit 1 + fi + + chmod +x "$tmp" + mv "$tmp" "$PACK" + echo " Pruefsumme in Ordnung." +} + +fetch_tool + +# --------------------------------------------------------------------- Ablauf +echo +echo "Projekt : $PROJECT" +echo "Version : $VERSION" +echo "Kanal : $CHANNEL" +echo "Plattform : ${RUNTIMES[*]}" +[ "$DRY_RUN" -eq 1 ] && echo "(Probelauf - es wird nichts hochgeladen)" +echo + +FAILED=0 + +for rid in "${RUNTIMES[@]}"; do + echo "=== $rid ===" + + PUBLISH_DIR="$REPO_ROOT/artifacts/publish/$rid" + + if [ "$DRY_RUN" -eq 0 ]; then + # Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden + # sonst mit ins Paket wandern. + rm -rf "$PUBLISH_DIR" + + publish_args=(publish "$CSPROJ" -c Release -r "$rid" -o "$PUBLISH_DIR" --nologo) + + if [ "$SELF_CONTAINED" = "true" ]; then + publish_args+=(--self-contained true) + else + publish_args+=(--self-contained false) + fi + + [ "$SINGLE_FILE" = "true" ] && publish_args+=(-p:PublishSingleFile=true) + + dotnet "${publish_args[@]}" + + # setup.json mitliefern, damit der Installer weiss, was einzurichten ist. + if [ -f "$REPO_ROOT/$SETUP_REL" ]; then + cp "$REPO_ROOT/$SETUP_REL" "$PUBLISH_DIR/setup.json" + echo " setup.json mitgenommen" + fi + + pack_args=( + --project "$PROJECT" + --version "$VERSION" + --channel "$CHANNEL" + --platform "$rid" + --publish-dir "$PUBLISH_DIR" + --changelog "$CHANGELOG" + ) + + [ "$CRITICAL" -eq 1 ] && pack_args+=(--critical) + [ -n "$MAIN_ASSEMBLY" ] && pack_args+=(--main-assembly "$MAIN_ASSEMBLY") + + set +e + "$PACK" "${pack_args[@]}" + code=$? + set -e + + case "$code" in + 0) echo " -> vollstaendig veroeffentlicht" ;; + 1) + # Konfigurationsfehler oder Versionskonflikt: die weiteren + # Plattformen wuerden genauso scheitern. + echo "FEHLER: Konfigurations- oder Versionsfehler - Abbruch." >&2 + exit 1 + ;; + 2) echo " -> TEILWEISE: Upload oder Registrierung fehlgeschlagen"; FAILED=1 ;; + *) echo " -> unerwarteter Rueckgabewert $code"; FAILED=1 ;; + esac + fi + + echo +done + +if [ "$FAILED" -ne 0 ]; then + echo "Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen," >&2 + echo "ob die bereits hochgeladenen Dateien konsistent sind." >&2 + exit 2 +fi + +echo "Fertig. $PROJECT $VERSION ist im Kanal $CHANNEL verfuegbar." diff --git a/settings.example.json b/settings.example.json index 25d698d..991fc52 100644 --- a/settings.example.json +++ b/settings.example.json @@ -30,5 +30,16 @@ "Enabled": true, "IntervalMinutes": 30 } + }, + "Deploymentcenter": { + "ApiToken": "", + "HeartbeatEnabled": true, + "MonitorSource": "", + "MonitorInstance": "default", + "HeartbeatIntervalSeconds": 60, + "ReportErrors": true, + "CheckUpdatesOnStart": true, + "UpdateChannel": "prod", + "LicenseKey": "" } } diff --git a/setup.json b/setup.json new file mode 100644 index 0000000..f7eb780 --- /dev/null +++ b/setup.json @@ -0,0 +1,27 @@ +{ + "schema": 1, + "displayName": "IBKRTrader", + "description": "Modulares Trading-Framework fuer Interactive Brokers. Fuer den Betrieb werden eine MySQL/MariaDB-Datenbank und ein IBKR-Gateway (TWS/IB Gateway) benoetigt.", + + "targets": [ + { "id": "app", "file": "settings.json", "location": "install", "format": "json" } + ], + + "fields": [ + { "key": "Database:Host", "label": "MySQL-Host", "type": "string", "required": true }, + { "key": "Database:Port", "label": "MySQL-Port", "type": "int", "default": "3306", "required": false }, + { "key": "Database:Database", "label": "Datenbankname", "type": "string", "required": true }, + { "key": "Database:User", "label": "MySQL-Benutzer", "type": "string", "required": true }, + { "key": "Database:Password", "label": "MySQL-Passwort", "type": "secret", "required": true }, + + { "key": "IBKR:Host", "label": "TWS/Gateway-Adresse", "type": "string", "default": "127.0.0.1", "required": false }, + { "key": "IBKR:Port", "label": "TWS/Gateway-Port (Paper 4002 / Live 4001)", "type": "int", "default": "4002", "required": false }, + + { "key": "Trading:ApplicationTimeZoneId", "label": "Betriebszeitzone (IANA, z. B. Europe/Berlin)", "type": "string", "default": "Europe/Berlin", "required": false }, + + { "key": "Deploymentcenter:BaseUrl", "label": "Deploymentcenter", "type": "url", "source": "detect:baseurl" }, + { "key": "Deploymentcenter:MonitorSource", "label": "Name im Watchdog-Dashboard", "source": "detect:hostname" }, + { "key": "Deploymentcenter:ApiToken", "label": "Token dieser Installation", + "source": "provision", "scopes": ["watchdog:ping", "bugtracker:report"] } + ] +} diff --git a/src/IBKRTrader.App/Program.cs b/src/IBKRTrader.App/Program.cs index 93103f9..7341690 100644 --- a/src/IBKRTrader.App/Program.cs +++ b/src/IBKRTrader.App/Program.cs @@ -31,7 +31,7 @@ internal static class Program var logger = host.Services.GetRequiredService(); logger.Info("Core", "=== IBKRTrader startet ==="); - logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}"); + logger.Info("Core", $"Version: {IBKRTrader.Hosting.BuildInfo.Summary} | .NET {Environment.Version}"); host.Start(); StartModules(modules, logger); diff --git a/src/IBKRTrader.Core/Configuration/AppPaths.cs b/src/IBKRTrader.Core/Configuration/AppPaths.cs index 817be88..3b57120 100644 --- a/src/IBKRTrader.Core/Configuration/AppPaths.cs +++ b/src/IBKRTrader.Core/Configuration/AppPaths.cs @@ -15,8 +15,13 @@ namespace IBKRTrader.Core.Configuration; /// hat immer Vorrang, damit ein Betreiber die Ablage frei bestimmen kann. /// Das Verzeichnis der Binärdatei, wenn dort geschrieben werden darf. Das hält das /// bisherige Verhalten unter Windows und beim Entwickeln unter Linux unverändert. -/// Sonst die FHS-Konvention: /etc/ibkrtrader, /var/lib/ibkrtrader, -/// /var/log/ibkrtrader. +/// Sonst plattformabhängig: unter Linux/macOS die FHS-Konvention +/// (/etc/ibkrtrader, /var/lib/ibkrtrader, /var/log/ibkrtrader), unter +/// Windows %ProgramData%\IBKRTrader. Der FHS-Pfad darf hier nicht als +/// plattformübergreifender Rückfall dienen – .NET löst /etc/ibkrtrader unter +/// Windows zu C:\etc\ibkrtrader auf. Genau dieser Fall tritt ein, sobald eine +/// Installation nach Program Files zeigt, wo der Dienst- bzw. Nutzerkonto-Kontext +/// keinen Schreibzugriff neben der Binärdatei hat. /// /// /// Die Prüfung läuft einmal beim ersten Zugriff; die Verzeichnisse werden dabei angelegt. @@ -26,8 +31,8 @@ public static class AppPaths private const string AppFolder = "ibkrtrader"; private static readonly Lazy _config = new(() => Resolve("IBKRTRADER_CONFIG_DIR", $"/etc/{AppFolder}")); - private static readonly Lazy _data = new(() => Resolve("IBKRTRADER_DATA_DIR", $"/var/lib/{AppFolder}")); - private static readonly Lazy _logs = new(() => Resolve("IBKRTRADER_LOG_DIR", $"/var/log/{AppFolder}", "Logs")); + private static readonly Lazy _data = new(() => Resolve("IBKRTRADER_DATA_DIR", $"/var/lib/{AppFolder}", windowsSubDir: "Data")); + private static readonly Lazy _logs = new(() => Resolve("IBKRTRADER_LOG_DIR", $"/var/log/{AppFolder}", "Logs", windowsSubDir: "Logs")); /// Konfiguration und Schlüsselmaterial: settings.json, master.key, openrouter.key. public static string Config => _config.Value; @@ -48,13 +53,18 @@ public static class AppPaths public static string Describe() => $"config={Config}, data={Data}, logs={Logs}"; /// Umgebungsvariable, die alles überstimmt. - /// FHS-Pfad, wenn neben der Binärdatei nicht geschrieben werden darf. + /// FHS-Pfad unter Linux/macOS, wenn neben der Binärdatei nicht geschrieben werden darf. /// /// Unterverzeichnis im Binärverzeichnis. Für Logs ist das Logs/; Konfiguration und Daten /// lagen bisher direkt daneben und bleiben dort, damit bestehende Installationen unverändert /// weiterlaufen. /// - private static string Resolve(string envVar, string fhsFallback, string? localSubDir = null) + /// + /// Unterverzeichnis unter %ProgramData%\IBKRTrader, wenn unter Windows neben der + /// Binärdatei nicht geschrieben werden darf (z. B. Installation nach Program Files). + /// null = direkt in %ProgramData%\IBKRTrader, wie bei der Konfiguration. + /// + private static string Resolve(string envVar, string fhsFallback, string? localSubDir = null, string? windowsSubDir = null) { var fromEnv = Environment.GetEnvironmentVariable(envVar); if (!string.IsNullOrWhiteSpace(fromEnv)) @@ -67,6 +77,16 @@ public static class AppPaths if (IsWritable(AppContext.BaseDirectory)) return Ensure(local); + // Binärverzeichnis nicht beschreibbar - der FHS-Pfad taugt nur unter Linux/macOS als + // Rückfall. Unter Windows löst .NET "/etc/ibkrtrader" zu "C:\etc\ibkrtrader" auf; der + // richtige Rückfall dort ist das maschinenweite Anwendungsdatenverzeichnis. + if (OperatingSystem.IsWindows()) + { + var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + var winBase = Path.Combine(programData, "IBKRTrader"); + return Ensure(windowsSubDir is null ? winBase : Path.Combine(winBase, windowsSubDir)); + } + return Ensure(fhsFallback); } diff --git a/src/IBKRTrader.Core/Settings/AppSettings.cs b/src/IBKRTrader.Core/Settings/AppSettings.cs index 71221bc..07c9c6c 100644 --- a/src/IBKRTrader.Core/Settings/AppSettings.cs +++ b/src/IBKRTrader.Core/Settings/AppSettings.cs @@ -11,7 +11,7 @@ public class DatabaseSettings [Category("Database")] [DisplayName("Host")] [Description("MySQL-Serveradresse (Hostname oder IP)")] - public string Host { get; set; } = "larx.your-database.de"; + public string Host { get; set; } = "your-db-host"; [Category("Database")] [DisplayName("Port")] @@ -21,18 +21,18 @@ public class DatabaseSettings [Category("Database")] [DisplayName("Database")] [Description("Name der MySQL-Datenbank")] - public string Database { get; set; } = "bergisnu_db0"; + public string Database { get; set; } = "your-db-name"; [Category("Database")] [DisplayName("User")] [Description("MySQL-Benutzername")] - public string User { get; set; } = "bergisnu_0"; + public string User { get; set; } = "your-db-user"; [Category("Database")] [DisplayName("Password")] [Description("MySQL-Passwort")] [PasswordPropertyText(true)] - public string Password { get; set; } = "o5/A49+f}:Da"; + public string Password { get; set; } = "CHANGE_ME"; public string BuildConnectionString() => $"Server={Host};Port={Port};Database={Database};" + @@ -299,6 +299,61 @@ public class SupervisorSettings public override string ToString() => Model; } +// ─── Deploymentcenter ──────────────────────────────────────────────────────── + +[TypeConverter(typeof(ExpandableObjectConverter))] +public class DeploymentcenterSettings +{ + [Category("Deploymentcenter")] + [DisplayName("API Token")] + [Description("Authorization: Bearer . Ohne Token: keine Heartbeats, keine Fehlermeldungen.")] + [PasswordPropertyText(true)] + public string ApiToken { get; set; } = ""; + + [Category("Deploymentcenter")] + [DisplayName("Heartbeat aktiv")] + [Description("Dead-Man's-Switch an/aus")] + public bool HeartbeatEnabled { get; set; } = true; + + [Category("Deploymentcenter")] + [DisplayName("Monitor Source")] + [Description("Monitor-Name im Dashboard. Leer = Rechnername (Auto-Registrierung beim ersten Ping)")] + public string MonitorSource { get; set; } = ""; + + [Category("Deploymentcenter")] + [DisplayName("Monitor Instance")] + [Description("Für mehrere Instanzen desselben Dienstes (z. B. EU/US)")] + public string MonitorInstance { get; set; } = "default"; + + [Category("Deploymentcenter")] + [DisplayName("Heartbeat-Intervall (Sekunden)")] + [Description("warning nach 2×, down nach 4× dieses Intervalls")] + public int HeartbeatIntervalSeconds { get; set; } = 60; + + [Category("Deploymentcenter")] + [DisplayName("Fehler melden")] + [Description("Error/Fatal an den Fehler-Stream (/api/errors/v1/report)")] + public bool ReportErrors { get; set; } = true; + + [Category("Deploymentcenter")] + [DisplayName("Update-Prüfung beim Start")] + [Description("Prüft nur, installiert nichts von selbst")] + public bool CheckUpdatesOnStart { get; set; } = true; + + [Category("Deploymentcenter")] + [DisplayName("Update-Kanal")] + [Description("prod, beta oder dev")] + public string UpdateChannel { get; set; } = "prod"; + + [Category("Deploymentcenter")] + [DisplayName("Lizenzschlüssel")] + [Description("Nur Rückfall, falls noch kein Schlüssel im verschlüsselten Cache liegt")] + [PasswordPropertyText(true)] + public string LicenseKey { get; set; } = ""; + + public override string ToString() => HeartbeatEnabled ? "Aktiv" : "Deaktiviert"; +} + // ─── Root ──────────────────────────────────────────────────────────────────── public class AppSettings @@ -347,4 +402,9 @@ public class AppSettings [DisplayName("Supervisor")] [Description("KI-Analyse/Forensik (OpenRouter, read-only)")] public SupervisorSettings Supervisor { get; set; } = new(); + + [Category("Deploymentcenter")] + [DisplayName("Deploymentcenter")] + [Description("Lizenz, Watchdog, UpdateService, Fehler-Stream (https://dc.mhdf.de)")] + public DeploymentcenterSettings Deploymentcenter { get; set; } = new(); } diff --git a/src/IBKRTrader.Daemon/Program.cs b/src/IBKRTrader.Daemon/Program.cs index ffc15c6..f404e98 100644 --- a/src/IBKRTrader.Daemon/Program.cs +++ b/src/IBKRTrader.Daemon/Program.cs @@ -1,6 +1,9 @@ +using Deploymentcenter.Client; +using IBKRTrader.Core.Configuration; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Modularity; using IBKRTrader.Hosting; +using IBKRTrader.Hosting.Deploymentcenter; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -21,6 +24,16 @@ internal static class Program if (HasFlag(args, "--db-version")) return DbVersion.Print(); + // Lizenz-Konvention aus dem Deploymentcenter-Leitfaden: das SDK bringt keine eigene CLI mit, + // diese beiden Schalter sind hier die vom Leitfaden empfohlene Umsetzung. Kein Host nötig - + // beide arbeiten nur mit dem lokalen Lizenz-Cache bzw. einer einzelnen Validierung. + if (HasFlag(args, "--license-status")) + return PrintLicenseStatus(); + + var setKeyIndex = Array.IndexOf(args, "--license-set-key"); + if (setKeyIndex >= 0 && setKeyIndex + 1 < args.Length) + return await SetLicenseKeyAsync(args[setKeyIndex + 1]).ConfigureAwait(false); + // Trockenlauf: Host bauen, Startprüfungen fahren, Dienste NICHT starten. Für Deployment // und CI – prüft Konfiguration, DB-Verbindungszeichenfolge, Master-Key und Ablageorte, // ohne eine einzige Verbindung zur Börse aufzubauen. @@ -40,7 +53,7 @@ internal static class Program } logger.Info("Core", "=== IBKRTrader (kopflos) startet ==="); - logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version} | {RuntimeDescription()}"); + logger.Info("Core", $"Version: {IBKRTrader.Hosting.BuildInfo.Summary} | .NET {Environment.Version} | {RuntimeDescription()}"); await host.StartAsync().ConfigureAwait(false); @@ -73,6 +86,41 @@ internal static class Program private static bool HasFlag(string[] args, string flag) => args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase)); + // ─── Lizenz-CLI (kein Host nötig) ────────────────────────────────────────── + + private static int PrintLicenseStatus() + { + LicenseConfig.SetStorageDirectory(AppPaths.Config); + + var hw = HardwareId.GetHardwareId(DcConfig.ProductSlug); + var state = LicenseClient.TryGetCachedState(DcConfig.ProductSlug); + + Console.WriteLine($"HWID v2: {hw.HardwareId} ({hw.HwidSource})"); + Console.WriteLine($"Schlüssel: {LicenseClient.TryGetCachedKey(DcConfig.ProductSlug) ?? "keiner"}"); + Console.WriteLine($"Cache: {(state == null ? "leer" : state.Status)}"); + + if (state is { CacheExpiresAt: > 0 }) + { + var until = DateTimeOffset.FromUnixTimeSeconds(state.CacheExpiresAt); + Console.WriteLine($"Offline noch bis: {until:u}"); + } + return 0; + } + + private static async Task SetLicenseKeyAsync(string key) + { + LicenseConfig.SetStorageDirectory(AppPaths.Config); + + // ValidateAsync legt den Schluessel bei Erfolg selbst im verschluesselten Cache ab - eine + // eigene Ablage braucht es dafuer nicht. + var client = new LicenseClient(); + var result = await client.ValidateAsync(DcConfig.ProductSlug, key, DcConfig.BaseUrl, DcConfig.AppVersion) + .ConfigureAwait(false); + + Console.WriteLine(result.Message); + return result.IsValid ? 0 : 1; + } + private static string RuntimeDescription() => $"{System.Runtime.InteropServices.RuntimeInformation.OSDescription.Trim()} " + $"({System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})"; diff --git a/src/IBKRTrader.Hosting/AppHostBuilder.cs b/src/IBKRTrader.Hosting/AppHostBuilder.cs index 0bd4225..cb5bfe6 100644 --- a/src/IBKRTrader.Hosting/AppHostBuilder.cs +++ b/src/IBKRTrader.Hosting/AppHostBuilder.cs @@ -14,6 +14,7 @@ using IBKRTrader.Core.Trading; using IBKRTrader.Core.Trading.Ibkr; using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers.BuiltIn; +using IBKRTrader.Hosting.Deploymentcenter; using IBKRTrader.Modules.Accounting; using IBKRTrader.Modules.CongressTrading; using IBKRTrader.Modules.Supervisor; @@ -92,9 +93,45 @@ public static class AppHostBuilder // 4. Transportverschlüsselung der DB prüfen. WarnIfDbTlsNotEnforced(services, logger); - // 5. Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine (vor dem Start). - services.GetRequiredService() - .SetEngine(services.GetRequiredService()); + // 5. Zirkuläre Abhängigkeit auflösen: WebApiService und der Watchdog-Heartbeat brauchen die + // Engine (vor dem Start), sind aber selbst Teil von deren IEnumerable. + var workerEngine = services.GetRequiredService(); + services.GetRequiredService().SetEngine(workerEngine); + services.GetRequiredService().SetWorkerEngine(workerEngine); + + // 6. Deploymentcenter-Fehlermeldung verdrahten. Ohne globale Handler gab es bisher keine + // "fatal"-Meldungen und keinen Logeintrag, wenn der Prozess unbehandelt wegbrach. + var errorReporter = services.GetRequiredService(); + new DcErrorSink(errorReporter).Attach(logger); + DcCrashHandlers.Register(logger, errorReporter); + + // 7. Lizenz pruefen - NACH der Fehlermeldung (ein Sperrbetrieb-Urteil soll sich melden + // koennen) und ohne Eingabeaufforderung: weder Shell noch Daemon duerfen hier auf eine + // Konsoleneingabe warten. Blockierend, weil RunStartupChecks synchron aufgerufen wird und + // vor dem Start der Worker abgeschlossen sein muss. + services.GetRequiredService().CheckAtStartupAsync().GetAwaiter().GetResult(); + + // 8. Update-Pruefung - NACH der Lizenz, weil der Zugang zur Release-Ablage seit 2.4 am + // Lizenzschluessel haengt. Findet nur, installiert nichts von selbst. + var settingsService = services.GetRequiredService(); + if (settingsService.Settings.Deploymentcenter.CheckUpdatesOnStart) + { + try + { + var check = DcUpdateService.CheckAsync(settingsService).GetAwaiter().GetResult(); + if (check.Unauthorized) + logger.Warn("Core", $"Update-Prüfung: Zugangsdaten abgelehnt (Lizenz abgelaufen/widerrufen?) - {check.Message}"); + else if (check.UpdateAvailable) + logger.Warn("Core", $"Update verfügbar: v{check.LatestRelease?.Version}{(check.IsCritical ? " (KRITISCH)" : "")} - {check.Message}"); + else + logger.Info("Core", "Update-Prüfung: kein Update verfügbar."); + } + catch (Exception ex) + { + // Ein nicht erreichbares Deploymentcenter darf den Start niemals verhindern. + logger.Warn("Core", $"Update-Prüfung fehlgeschlagen: {ex.Message}"); + } + } } /// Registriert alle Core-Services im DI-Container. @@ -114,6 +151,16 @@ public static class AppHostBuilder services.AddSingleton(); services.AddSingleton(); // core_settings via EF + // Deploymentcenter: Fehler-Stream. Als DI-Factory registriert (nicht eager erzeugt), damit + // sie sich denselben LoggingService- und SettingsService-Singleton aus demselben Container + // holt, statt einen zweiten Container dafür aufzubauen. + services.AddSingleton(sp => + { + var token = sp.GetRequiredService().Settings.Deploymentcenter.ApiToken; + return new DcErrorReporter(token, msg => sp.GetRequiredService().Warn("Deploymentcenter", msg)); + }); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); @@ -152,6 +199,7 @@ public static class AppHostBuilder services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable. services.AddSingleton(sp => sp.GetRequiredService()); @@ -159,6 +207,7 @@ public static class AppHostBuilder services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); // Lebenszyklus über den Generic Host (jeder Worker ist ein IHostedService). services.AddHostedService(sp => sp.GetRequiredService()); @@ -166,6 +215,7 @@ public static class AppHostBuilder services.AddHostedService(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); services.AddSingleton(); } diff --git a/src/IBKRTrader.Hosting/Deploymentcenter/DcApiClient.cs b/src/IBKRTrader.Hosting/Deploymentcenter/DcApiClient.cs new file mode 100644 index 0000000..2308c2f --- /dev/null +++ b/src/IBKRTrader.Hosting/Deploymentcenter/DcApiClient.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IBKRTrader.Hosting.Deploymentcenter; + +/// +/// Fehlgeschlagener Deploymentcenter-Aufruf. ist der stabile, maschinenlesbare +/// Fehlercode aus dem API-Umschlag ("unauthorized", "rate_limited", ...) - reagiert wird auf den +/// Code, nicht auf den 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; } + + /// Wahr für Fehler, die eine Wiederholung mit denselben Daten nicht beheben kann (Token falsch/fehlt). + 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] + "…"; +} + +/// +/// Schlanker JSON-Client für Watchdog-Heartbeat und Fehler-Stream. Lizenz und UpdateService bringen +/// ihren eigenen Client mit (Deploymentcenter.Client). +/// +/// Ein nicht erreichbares Deploymentcenter darf den Handel niemals beeinflussen - jeder +/// Aufruf ist best effort mit kurzer Zeitgrenze; Fehler werden dem Aufrufer nur als Exception +/// gemeldet, nie geworfen in einem Kontext, der den Trading-Kern mitreißen könnte. +/// +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; + } + + /// Liest error.code aus {"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) + { + // Nicht jeder Fehlerpfad antwortet mit dem Umschlag (z. B. eine Proxy-Fehlerseite). + } + return null; + } + + public void Dispose() => _http.Dispose(); +} diff --git a/src/IBKRTrader.Hosting/Deploymentcenter/DcConfig.cs b/src/IBKRTrader.Hosting/Deploymentcenter/DcConfig.cs new file mode 100644 index 0000000..64a16f6 --- /dev/null +++ b/src/IBKRTrader.Hosting/Deploymentcenter/DcConfig.cs @@ -0,0 +1,30 @@ +namespace IBKRTrader.Hosting.Deploymentcenter; + +/// +/// Fest verdrahtete Eckdaten der Deploymentcenter-Anbindung (https://dc.mhdf.de), das Lizenz, +/// Watchdog, UpdateService, Fehler-Stream und Bugtracker in einem Server bündelt. +/// +/// Die Basis-URL ist bewusst KEINE Einstellung. Sie entscheidet, wohin die +/// Lizenzprüfung geht und woher ausführbarer Code (Update-Pakete) kommt. Ein einstellbarer +/// Endpunkt wäre eine Einladung, die Anwendung auf einen gefälschten Server zu zeigen. +/// +public static class DcConfig +{ + public const string BaseUrl = "https://dc.mhdf.de"; + + /// Slug in dc_projects - Lizenz, UpdateService, Fehler-Stream und Bugtracker teilen ihn. + public const string ProductSlug = "ibkrtrader"; + + /// Gruppierung des Watchdog-Monitors im Dashboard. + public const string MonitorGroup = "Trading"; + + /// + /// Gemeldete Version - Lizenz-Aktivierungsliste, Watchdog-Monitorspalte und Fehler-Stream. + /// Erzeugt von Deploymentcenter.BuildInfo.targets aus <Version> in Directory.Build.props; + /// beim Release dort hochziehen, das ist auch, wogegen der UpdateService vergleicht. + /// + public static string AppVersion => BuildInfo.Version; + + /// Commit dieses Builds - reist im Fehler-Stream mit. + public static string GitCommitShort => BuildInfo.GitCommitShort; +} diff --git a/src/IBKRTrader.Hosting/Deploymentcenter/DcCrashHandlers.cs b/src/IBKRTrader.Hosting/Deploymentcenter/DcCrashHandlers.cs new file mode 100644 index 0000000..36b3840 --- /dev/null +++ b/src/IBKRTrader.Hosting/Deploymentcenter/DcCrashHandlers.cs @@ -0,0 +1,35 @@ +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Hosting.Deploymentcenter; + +/// +/// Globale Ausnahmebehandler - ohne sie gab es keine "fatal"-Meldungen und, vor dieser +/// Integration, nicht einmal einen Logeintrag, wenn der Prozess unbehandelt wegbrach. +/// +public static class DcCrashHandlers +{ + public static void Register(LoggingService logger, DcErrorReporter reporter) + { + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + var ex = e.ExceptionObject as Exception; + logger.Error("Core", $"Unbehandelte Ausnahme - Prozess beendet sich (IsTerminating={e.IsTerminating}).", ex); + + // Blockierend: der Prozess ist gleich weg, ein fire-and-forget-Task käme nie mehr zur + // Ausführung. + reporter.Report( + level: "fatal", + module: "Core", + message: "Unbehandelte Ausnahme - Prozess beendet sich.", + exception: ex, + blocking: true); + }; + + TaskScheduler.UnobservedTaskException += (_, e) => + { + logger.Error("Core", "Unbeobachtete Task-Ausnahme.", e.Exception); + reporter.Report(level: "error", module: "Core", message: "Unbeobachtete Task-Ausnahme.", exception: e.Exception); + e.SetObserved(); + }; + } +} diff --git a/src/IBKRTrader.Hosting/Deploymentcenter/DcErrorReporter.cs b/src/IBKRTrader.Hosting/Deploymentcenter/DcErrorReporter.cs new file mode 100644 index 0000000..4cc7e49 --- /dev/null +++ b/src/IBKRTrader.Hosting/Deploymentcenter/DcErrorReporter.cs @@ -0,0 +1,114 @@ +namespace IBKRTrader.Hosting.Deploymentcenter; + +/// +/// Meldet Laufzeitfehler an POST /api/errors/v1/report. +/// +/// Bremse ist Pflicht, nicht Kür. Ein Fehler in einer Handelsschleife könnte binnen +/// Sekunden das serverseitige Rate-Limit verbrennen. Deshalb: höchstens 20 Meldungen pro Minute +/// insgesamt, derselbe Fehler höchstens alle 5 Minuten, und bei 401/403 wird dauerhaft +/// abgeschaltet - ein abgelehntes Token wird durch Wiederholen nicht gültig. +/// +/// Serverseitig werden gleiche Fehler ohnehin gruppiert und hochgezählt; betriebsbedingtes +/// Rauschen gehört in die Ignore-Regeln des WebUI, nicht in eine noch härtere lokale Bremse. +/// +public sealed class DcErrorReporter +{ +#if DEBUG + private const string Environment_ = "development"; +#else + private const string Environment_ = "production"; +#endif + + private const int MaxPerMinute = 20; + private static readonly TimeSpan SameErrorCooldown = TimeSpan.FromMinutes(5); + + private readonly string _token; + private readonly Action _debugLog; + private readonly object _lock = new(); + private readonly Queue _recentSends = new(); + private readonly Dictionary _lastSentByFingerprint = new(); + private volatile bool _disabled; + + public DcErrorReporter(string token, Action? debugLog = null) + { + _token = (token ?? "").Trim(); + _debugLog = debugLog ?? (_ => { }); + } + + public bool Enabled => !_disabled && !string.IsNullOrWhiteSpace(_token); + + /// + /// Meldet einen Fehler. gilt für den Fall "fatal": der Prozess + /// beendet sich gleich, ein fire-and-forget-Task käme nie mehr zur Ausführung. + /// + public void Report(string level, string module, string message, Exception? exception, + string? filePath = null, int? lineNo = null, string? correlationId = null, + bool blocking = false) + { + if (!Enabled) return; + + var fingerprint = module + "|" + (exception?.GetType().FullName ?? message); + lock (_lock) + { + if (_disabled) return; + + var now = DateTime.UtcNow; + while (_recentSends.Count > 0 && now - _recentSends.Peek() > TimeSpan.FromMinutes(1)) + _recentSends.Dequeue(); + if (_recentSends.Count >= MaxPerMinute) return; + + if (_lastSentByFingerprint.TryGetValue(fingerprint, out var last) && now - last < SameErrorCooldown) + return; + + _recentSends.Enqueue(now); + _lastSentByFingerprint[fingerprint] = now; + } + + var payload = new + { + project_slug = DcConfig.ProductSlug, + exception = exception?.GetType().FullName ?? "LoggedError", + message = exception != null ? $"{message} | {exception.Message}" : message, + stack_trace = exception?.ToString(), + level, + build = DcConfig.AppVersion, + environment = Environment_, + file = filePath, + line = lineNo, + context = new { git_commit = DcConfig.GitCommitShort, correlation_id = correlationId, module } + }; + + var client = new DcApiClient(_token, TimeSpan.FromSeconds(blocking ? 5 : 10)); + var task = SendAsync(client, payload); + + if (blocking) + { + // AppDomain.UnhandledException: der Prozess ist gleich weg, ein fire-and-forget-Task + // käme nie mehr zur Ausführung. Kurze Zeitgrenze, damit das Beenden nicht unnötig hängt. + try { task.Wait(TimeSpan.FromSeconds(6)); } catch { /* best effort */ } + client.Dispose(); + } + else + { + _ = task.ContinueWith(_ => client.Dispose(), TaskScheduler.Default); + } + } + + private async Task SendAsync(DcApiClient client, object payload) + { + try + { + await client.PostJsonAsync("/api/errors/v1/report", payload).ConfigureAwait(false); + } + catch (DcApiException ex) when (ex.IsPermanent) + { + _disabled = true; + _debugLog($"[Deploymentcenter] Fehler-Stream dauerhaft deaktiviert: {ex.Message}"); + } + catch (Exception ex) + { + // Ein nicht erreichbares Deploymentcenter darf den Handel niemals beeinflussen. + _debugLog($"[Deploymentcenter] Fehlermeldung fehlgeschlagen: {ex.Message}"); + } + } +} diff --git a/src/IBKRTrader.Hosting/Deploymentcenter/DcErrorSink.cs b/src/IBKRTrader.Hosting/Deploymentcenter/DcErrorSink.cs new file mode 100644 index 0000000..de2c7c7 --- /dev/null +++ b/src/IBKRTrader.Hosting/Deploymentcenter/DcErrorSink.cs @@ -0,0 +1,32 @@ +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Hosting.Deploymentcenter; + +/// +/// Hängt sich an und meldet Error-Einträge an den +/// Fehler-Stream. Ergänzt die globalen Ausnahmebehandler (): die +/// decken den Absturz des Prozesses ab, dieser Sink jedes behandelte logger.Error(...) aus +/// Workern, Modulen und Kestrel. +/// +/// Bewusst nicht blockierend - ein hängender Sink darf den schreibenden Worker nicht +/// mitreißen. Die Bremse gegen Log-Fluten sitzt in . +/// +public sealed class DcErrorSink +{ + private readonly DcErrorReporter _reporter; + + public DcErrorSink(DcErrorReporter reporter) => _reporter = reporter; + + public void Attach(LoggingService logger) => logger.EntryWritten += OnEntryWritten; + + private void OnEntryWritten(LogEntry entry) + { + if (entry.Level != AppLogLevel.Error) return; + + try + { + _reporter.Report(level: "error", module: entry.Module, message: entry.Message, exception: entry.Exception); + } + catch { /* Fehlermeldung darf das Logging niemals stören */ } + } +} diff --git a/src/IBKRTrader.Hosting/Deploymentcenter/DcHeartbeatWorker.cs b/src/IBKRTrader.Hosting/Deploymentcenter/DcHeartbeatWorker.cs new file mode 100644 index 0000000..9997629 --- /dev/null +++ b/src/IBKRTrader.Hosting/Deploymentcenter/DcHeartbeatWorker.cs @@ -0,0 +1,299 @@ +using IBKRTrader.Core.IBKR; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Modularity; +using IBKRTrader.Core.Persistence; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Settings; +using IBKRTrader.Core.Trading; +using IBKRTrader.Core.Workers; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; + +namespace IBKRTrader.Hosting.Deploymentcenter; + +/// +/// Dead-Man's-Switch: meldet in festem Takt POST /api/watchdog/v1/ping. +/// +/// Bewusst nicht über : die Basisklasse schreibt bei jedem Run +/// zwei Zeilen nach core_worker_log - bei einem 60-Sekunden-Takt wären das über 2800 +/// Schreibvorgänge am Tag für reine Lebenszeichen. Ein eigener, schlanker Loop passt hier besser; +/// wird trotzdem gepflegt, damit der Worker in der Workers-Ansicht auftaucht. +/// +/// Ein Heartbeat beweist nur, dass ein Thread läuft - deshalb schickt er den selbst +/// ermittelten Zustand mit (checks). Ein nicht erreichbares Deploymentcenter darf den Handel +/// niemals beeinflussen: jeder Aufruf ist best effort mit kurzer Zeitgrenze. +/// +public sealed class DcHeartbeatWorker : IWorker, IHostedService +{ + public string Name => "DcHeartbeatWorker"; + public string Module => "Core"; + public WorkerType Type => WorkerType.Service; + public WorkerInfo Info { get; } = new(); + + private readonly LoggingService _logger; + private readonly SettingsService _settings; + private readonly IDbContextFactory _dbf; + private readonly IBKRGatewayService _gateway; + private readonly IPortfolioService _portfolio; + private readonly IOrderEventLog _orderEvents; + private readonly IEnumerable _modules; + private readonly LicenseGuard _licenseGuard; + + private readonly DateTime _startedAtUtc = DateTime.UtcNow; + private readonly SemaphoreSlim _wake = new(0, 1); + private static readonly TimeSpan LicenseRevalidationInterval = TimeSpan.FromHours(12); + private DateTime _lastLicenseRevalidationUtc = DateTime.UtcNow; + + private WorkerEngine? _workerEngine; + private CancellationTokenSource? _cts; + private Task? _loop; + + public DcHeartbeatWorker( + LoggingService logger, SettingsService settings, IDbContextFactory dbf, + IBKRGatewayService gateway, IPortfolioService portfolio, IOrderEventLog orderEvents, + IEnumerable modules, LicenseGuard licenseGuard) + { + _logger = logger; + _settings = settings; + _dbf = dbf; + _gateway = gateway; + _portfolio = portfolio; + _orderEvents = orderEvents; + _modules = modules; + _licenseGuard = licenseGuard; + + Info.WorkerName = Name; + Info.Module = Module; + Info.Type = Type.ToString(); + Info.Active = settings.Settings.Deploymentcenter.HeartbeatEnabled; + Info.Status = WorkerStatus.Idle; + Info.Info = "Bereit"; + } + + /// + /// Zirkuläre Abhängigkeit auflösen (WorkerEngine registriert alle IWorker, auch diesen) - gleiches + /// Muster wie : erst nach dem Bau des Hosts setzbar. + /// + public void SetWorkerEngine(WorkerEngine engine) => _workerEngine = engine; + + // ─── Lifecycle ──────────────────────────────────────────────────────────── + + public Task StartAsync(CancellationToken cancellationToken) + { + if (!Info.Active) + { + Info.Status = WorkerStatus.Stopped; + Info.Info = "Deaktiviert"; + return Task.CompletedTask; + } + + Info.RunEvery = $"{Math.Max(5, _settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds)}s"; + _cts = new CancellationTokenSource(); + _loop = Task.Run(() => RunLoopAsync(_cts.Token), _cts.Token); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_cts == null) return; + await _cts.CancelAsync(); + if (_loop != null) + { + try { await _loop.ConfigureAwait(false); } + catch (OperationCanceledException) { /* erwartet */ } + } + + // Geordnetes Beenden ankündigen - ohne das folgt wenige Minuten später ein Fehlalarm. + // Kurze eigene Zeitgrenze: ein hängender Monitoring-Server darf das Herunterfahren + // nicht blockieren. + await SendAsync("stopped", "Dienst planmäßig beendet", CancellationToken.None, timeout: TimeSpan.FromSeconds(3)) + .ConfigureAwait(false); + + Info.Status = WorkerStatus.Stopped; + Info.Info = "Gestoppt"; + } + + public async Task TriggerAsync() + { + if (_wake.CurrentCount == 0) _wake.Release(); + await Task.CompletedTask; + } + + // ─── Loop ───────────────────────────────────────────────────────────────── + + private async Task RunLoopAsync(CancellationToken ct) + { + _logger.Info(Module, $"{Name}: Heartbeat gestartet (Intervall {_settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds}s)."); + + while (!ct.IsCancellationRequested) + { + await RunOnceAsync(ct).ConfigureAwait(false); + await RevalidateLicenseIfDueAsync(ct).ConfigureAwait(false); + + var interval = TimeSpan.FromSeconds(Math.Max(5, _settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds)); + Info.NextRuntime = DateTime.Now.Add(interval); + + try { await _wake.WaitAsync(interval, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + } + } + + private async Task RunOnceAsync(CancellationToken ct) + { + Info.Status = WorkerStatus.Running; + + var (checks, metrics, allOk) = await GatherStateAsync(ct).ConfigureAwait(false); + var status = allOk ? "ok" : "warning"; + var message = allOk ? "Betrieb normal" : "Eine oder mehrere Prüfungen auffällig"; + + var sent = await SendAsync(status, message, ct, checks: checks, metrics: metrics).ConfigureAwait(false); + + Info.LastRuntime = DateTime.Now; + Info.Status = WorkerStatus.Idle; + Info.Info = sent ? $"{status} – {Info.LastRuntime:HH:mm:ss}" : $"Melden fehlgeschlagen – {Info.LastRuntime:HH:mm:ss}"; + } + + /// + /// Trittbrett für die Lizenz-Revalidierung (alle 12h) - reitet auf dem ohnehin laufenden + /// Heartbeat-Takt statt einen eigenen Timer zu betreiben. + /// + private async Task RevalidateLicenseIfDueAsync(CancellationToken ct) + { + if (DateTime.UtcNow - _lastLicenseRevalidationUtc < LicenseRevalidationInterval) return; + _lastLicenseRevalidationUtc = DateTime.UtcNow; + + try + { + await _licenseGuard.RevalidateAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Warn(Module, $"Lizenz-Revalidierung fehlgeschlagen: {ex.Message}"); + } + } + + // ─── Zustand ermitteln ──────────────────────────────────────────────────── + + /// + /// Ein Heartbeat beweist nur, dass ein Thread läuft - nicht, dass die Anwendung ihre Arbeit + /// tut. Jede Prüfung ist einzeln abgesichert: eine fehlschlagende Prüfung darf die übrigen + /// (und vor allem den Heartbeat selbst) nicht verhindern. + /// + private async Task<(Dictionary checks, Dictionary metrics, bool allOk)> GatherStateAsync(CancellationToken ct) + { + var checks = new Dictionary(); + var metrics = new Dictionary(); + var allOk = true; + + // db + try + { + using var dbCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + dbCts.CancelAfter(TimeSpan.FromSeconds(5)); + await using var db = await _dbf.CreateDbContextAsync(dbCts.Token).ConfigureAwait(false); + await db.Database.ExecuteSqlRawAsync("SELECT 1", dbCts.Token).ConfigureAwait(false); + checks["db"] = new { ok = true }; + } + catch (Exception ex) + { + checks["db"] = new { ok = false, message = Shorten(ex.Message) }; + allOk = false; + } + + // ibkr_gateway - NullBroker (UseTwsApi = false) ist ein gewollter Zustand, kein Fehler. + try + { + var useTws = _settings.Settings.IBKR.UseTwsApi; + var gatewayOk = !useTws || _gateway.IsConnected; + checks["ibkr_gateway"] = useTws + ? new { ok = gatewayOk, message = gatewayOk ? "verbunden" : "nicht verbunden" } + : new { ok = true, message = "TWS-Broker deaktiviert (NullBroker)" }; + if (!gatewayOk) allOk = false; + } + catch (Exception ex) + { + checks["ibkr_gateway"] = new { ok = false, message = Shorten(ex.Message) }; + allOk = false; + } + + // worker_engine - kein Worker im Fehlerzustand + try + { + var infos = _workerEngine?.WorkerInfos; + var failing = infos?.Where(w => w.Status == WorkerStatus.Error).Select(w => w.WorkerName).ToList() ?? []; + checks["worker_engine"] = new { ok = failing.Count == 0, message = failing.Count == 0 ? "alle Worker ok" : string.Join(", ", failing) }; + if (failing.Count > 0) allOk = false; + metrics["workers_running"] = infos?.Count(w => w.Status is WorkerStatus.Idle or WorkerStatus.Running) ?? 0; + } + catch (Exception ex) + { + checks["worker_engine"] = new { ok = false, message = Shorten(ex.Message) }; + allOk = false; + } + + // Metriken - bewusst kein Check auf "Trading aktiv": ein abgeschalteter Handel ist ein + // gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf "warning" halten. + metrics["uptime_sec"] = (long)(DateTime.UtcNow - _startedAtUtc).TotalSeconds; + metrics["trading_enabled"] = _settings.Settings.Trading.TradingEnabled ? 1 : 0; + metrics["memory_mb"] = GC.GetTotalMemory(false) / 1024 / 1024; + + try + { + var todayStartUtc = DateTime.UtcNow.Date; + metrics["orders_today"] = _orderEvents.Query(e => e.Timestamp >= todayStartUtc, limit: 5000).Count; + } + catch { /* best effort - Metrik darf den Heartbeat nicht verhindern */ } + + try + { + var open = 0; + foreach (var module in _modules) + open += (await _portfolio.GetPositionsAsync(module.Name, ct).ConfigureAwait(false)).Count; + metrics["open_positions"] = open; + } + catch { /* best effort */ } + + return (checks, metrics, allOk); + } + + // ─── Senden ─────────────────────────────────────────────────────────────── + + private async Task SendAsync(string status, string message, CancellationToken ct, + Dictionary? checks = null, Dictionary? metrics = null, + TimeSpan? timeout = null) + { + var token = _settings.Settings.Deploymentcenter.ApiToken; + if (string.IsNullOrWhiteSpace(token)) return false; + + var payload = new + { + source = string.IsNullOrWhiteSpace(_settings.Settings.Deploymentcenter.MonitorSource) + ? Environment.MachineName + : _settings.Settings.Deploymentcenter.MonitorSource, + instance = _settings.Settings.Deploymentcenter.MonitorInstance, + status, + interval = _settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds, + message, + group = DcConfig.MonitorGroup, + os = System.Runtime.InteropServices.RuntimeInformation.OSDescription, + version = DcConfig.AppVersion, + checks, + metrics + }; + + using var client = new DcApiClient(token, timeout ?? TimeSpan.FromSeconds(10)); + try + { + await client.PostJsonAsync("/api/watchdog/v1/ping", payload, ct).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + // Ein nicht erreichbarer Monitoring-Server darf die Anwendung niemals stoppen. + _logger.Warn(Module, $"{Name}: Heartbeat fehlgeschlagen: {ex.Message}"); + return false; + } + } + + private static string Shorten(string s) => s.Length <= 200 ? s : s[..200] + "…"; +} diff --git a/src/IBKRTrader.Hosting/Deploymentcenter/DcUpdateService.cs b/src/IBKRTrader.Hosting/Deploymentcenter/DcUpdateService.cs new file mode 100644 index 0000000..0491cec --- /dev/null +++ b/src/IBKRTrader.Hosting/Deploymentcenter/DcUpdateService.cs @@ -0,0 +1,80 @@ +using Deploymentcenter.Client; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; + +namespace IBKRTrader.Hosting.Deploymentcenter; + +/// +/// Fragt den UpdateService ab (GET /api/updateservice/v1/check) und übergibt bei Bedarf an den +/// eigenständigen update-agent. Die Prüfung selbst liest nur Metadaten - installiert wird +/// nichts von selbst. Ein Trading-Dienst, der sich unangekündigt neu startet, ist keine akzeptable +/// Vorgabe. +/// +/// Seit 2.4 mit Zugangsdaten - die Release-Ablage liegt hinter HTTP-Basic-Auth, der +/// Zugang hängt am Lizenzschlüssel (siehe LicenseGuard). Ohne ihn liefert der Server 401 statt +/// Updates. +/// +public static class DcUpdateService +{ + public static Task CheckAsync(SettingsService settings, CancellationToken ct = default) + { + var dc = settings.Settings.Deploymentcenter; + var key = ResolveLicenseKey(dc); + + return new UpdateClient().CheckForUpdateAsync( + baseUrl: DcConfig.BaseUrl, + projectId: DcConfig.ProductSlug, + currentVersion: DcConfig.AppVersion, + channel: string.IsNullOrWhiteSpace(dc.UpdateChannel) ? "prod" : dc.UpdateChannel, + credentials: ReleaseCredentials.FromLicenseKey(key), + cancellationToken: ct); + } + + /// Pfad des Agenten neben der laufenden Anwendung, oder null, wenn er dort nicht liegt. + public static string? FindUpdateAgent() => UpdateClient.ResolveAgentPath(AppContext.BaseDirectory); + + /// + /// Übergibt die Kontrolle an den Update-Agenten. + /// + /// exitCurrentApp ist bewusst immer false. Seit SDK 2.5.0 ist + /// exitCurrentApp: true ausdrücklich als harter Kill dokumentiert - laufende + /// finally-Blöcke, IHostApplicationLifetime und Destruktoren kommen dann nicht + /// mehr zum Zug. Für ein System mit potenziell offenem Auftragsbuch oder einer laufenden + /// Buchungstransaktion ist das die falsche Vorgabe. Der Aufrufer beendet die Anwendung + /// stattdessen selbst geordnet (host.StopAsync(...)), nachdem dieser Aufruf + /// true geliefert hat - der Agent wartet über --wait-for-pid ohnehin auf das + /// Prozessende. + /// + public static bool LaunchAgent(SettingsService settings, LoggingService logger) + { + var dc = settings.Settings.Deploymentcenter; + var agent = FindUpdateAgent(); + if (agent is null) + { + logger.Warn("Core", $"Update-Agent ({UpdateClient.AgentFileName}) liegt nicht neben der Anwendung - Update bitte manuell einspielen."); + return false; + } + + var key = ResolveLicenseKey(dc); + var channel = string.IsNullOrWhiteSpace(dc.UpdateChannel) ? "prod" : dc.UpdateChannel; + + logger.Info("Core", $"Starte Update-Agent {agent} (Kanal {channel})."); + return UpdateClient.LaunchUpdateAgent( + agentPath: agent, + projectId: DcConfig.ProductSlug, + channel: channel, + action: "update", + version: "latest", + currentVersion: DcConfig.AppVersion, + exitCurrentApp: false, + licenseKey: key, + // Vorgabe 60s reicht knapp fuer EF-Flush, Kestrel, DB-Backup-Worker beim geordneten + // Beenden nicht immer - 90s Luft, statt dass der Agent grundlos abbricht. + waitTimeoutSeconds: 90); + } + + private static string? ResolveLicenseKey(DeploymentcenterSettings dc) => + string.IsNullOrWhiteSpace(dc.LicenseKey) + ? LicenseClient.TryGetCachedKey(DcConfig.ProductSlug) + : dc.LicenseKey; +} diff --git a/src/IBKRTrader.Hosting/Deploymentcenter/LicenseGuard.cs b/src/IBKRTrader.Hosting/Deploymentcenter/LicenseGuard.cs new file mode 100644 index 0000000..e9478ab --- /dev/null +++ b/src/IBKRTrader.Hosting/Deploymentcenter/LicenseGuard.cs @@ -0,0 +1,143 @@ +using Deploymentcenter.Client; +using IBKRTrader.Core.Configuration; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; + +namespace IBKRTrader.Hosting.Deploymentcenter; + +/// +/// Meldet -Ereignisse an statt an die +/// Konsole - ConsoleLicensePrompt (die Vorgabe des SDK) passt weder für eine GUI-Anwendung +/// noch für einen Dienst ohne angehängtes Terminal. wird nie +/// aufgerufen, solange mit allowPrompt: false arbeitet - trotzdem +/// definiert, damit die Schnittstelle vollständig implementiert ist. +/// +internal sealed class SilentLicensePrompt : ILicensePrompt +{ + private readonly LoggingService _logger; + public SilentLicensePrompt(LoggingService logger) => _logger = logger; + + public Task RequestLicenseKeyAsync(string productSlug) => Task.FromResult(null); + public void ShowLicenseError(string title, string message) => _logger.Warn("Core", $"[Lizenz] {title}: {message}"); + public void ShowLicenseInfo(string title, string message) => _logger.Info("Core", $"[Lizenz] {title}: {message}"); +} + +/// +/// Prüft die Lizenz beim Start und bei Revalidierung. +/// +/// Weicht bewusst vom Leitfaden ab. Der Deploymentcenter-Leitfaden empfiehlt bei einem +/// endgültigen Urteil (revoked, expired, ...) Environment.Exit(1). Für ein +/// Handelssystem mit offenen Positionen ist ein sofortiges Beenden potenziell schädlicher als der +/// unlizenzierte Weiterbetrieb. Stattdessen: Sperrbetrieb - +/// wird hart auf false gesetzt (das ist bereits der zentrale, von +/// ExecutionService.ExecuteAsync geprüfte Hauptschalter, siehe §5.2 im Integrationskonzept). +/// Keine neuen Einstiege, aber Risiko-, Exit- und Buchhaltungslogik laufen unverändert weiter. +/// +public sealed class LicenseGuard +{ + private readonly LoggingService _logger; + private readonly SettingsService _settings; + private readonly DcErrorReporter _errorReporter; + private LicenseClient? _client; + + public LicenseGuard(LoggingService logger, SettingsService settings, DcErrorReporter errorReporter) + { + _logger = logger; + _settings = settings; + _errorReporter = errorReporter; + } + + /// + /// Erstprüfung beim Start. Läuft ohne Eingabeaufforderung (allowPrompt: false) - weder + /// die Desktop-Shell noch der Daemon dürfen hier auf eine Konsoleneingabe warten, die nie kommt. + /// Ein eigenes Lizenzfenster für die Shell ist als Folgeschritt vorgesehen (siehe Konzept §5.2). + /// + public async Task CheckAtStartupAsync(CancellationToken ct = default) + { + // Breaking Change 2.5.1: SetStorageDirectory haengt jetzt selbst "/license" an - der + // Aufruf bekommt hier bewusst NUR AppPaths.Config, kein zusaetzliches "license"-Unterverzeichnis + // mehr (sonst entstuende ".../license/ibkrtrader/license"). + LicenseConfig.SetStorageDirectory(AppPaths.Config); + LicenseClient.DefaultAppVersion = DcConfig.AppVersion; + + _client = new LicenseClient(prompt: new SilentLicensePrompt(_logger)); + + // Liegt noch kein Schluessel im Cache, aber einer in den Settings (Erstinbetriebnahme oder + // manuell eingetragen): einmal validieren, damit ValidateAsync ihn selbst in den + // verschluesselten Cache legt. Danach reicht TryGetCachedKey. + var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug); + var configuredKey = _settings.Settings.Deploymentcenter.LicenseKey; + if (string.IsNullOrWhiteSpace(cachedKey) && !string.IsNullOrWhiteSpace(configuredKey)) + { + await _client.ValidateAsync(DcConfig.ProductSlug, configuredKey, DcConfig.BaseUrl, DcConfig.AppVersion, ct) + .ConfigureAwait(false); + } + + var result = await _client.EnsureLicensedAsync( + DcConfig.ProductSlug, DcConfig.BaseUrl, allowPrompt: false, appVersion: DcConfig.AppVersion, + cancellationToken: ct).ConfigureAwait(false); + + Apply(result); + } + + /// Erneute Prüfung zur Laufzeit (Empfehlung: alle 12h, aus einem Worker heraus aufzurufen). + public async Task RevalidateAsync(CancellationToken ct = default) + { + if (_client == null) return; + var result = await _client.RevalidateAsync(DcConfig.ProductSlug, DcConfig.BaseUrl, DcConfig.AppVersion, ct) + .ConfigureAwait(false); + Apply(result); + } + + /// Für die kopflosen Schalter (--license-status / --license-set-key) im Daemon. + public LicenseClient Client => _client ??= new LicenseClient(prompt: new SilentLicensePrompt(_logger)); + + private void Apply(LicenseValidationResult result) + { + if (result.IsValid) + { + _logger.Info("Core", $"Lizenz: {result.Status} ({(result.IsCached ? "aus Cache" : "vom Server")})."); + WarnIfGracePeriodExpiringSoon(result); + return; + } + + if (result.IsTransient) + { + // Kein Urteil, nur keine Auskunft (server_unavailable, cache_expired) - weiterlaufen, + // der nächste Durchlauf prüft erneut. + _logger.Warn("Core", $"Lizenzprüfung derzeit nicht möglich: {result.Message}"); + return; + } + + // Ein Urteil (revoked, expired, not_found, activation_limit, suspended, clock_rollback, + // no_license): Sperrbetrieb statt Prozessende. + var trading = _settings.Settings.Trading; + if (trading.TradingEnabled) + { + trading.TradingEnabled = false; + _settings.Save(); + _logger.Error("Core", + $"Lizenz ungültig ({result.Status}): {result.Message}. Sperrbetrieb aktiv - " + + "TradingEnabled auf false gesetzt, keine neuen Einstiege mehr. Bestehende Positionen " + + "und Risiko-/Exit-Logik laufen unverändert weiter."); + } + else + { + _logger.Warn("Core", $"Lizenz weiterhin ungültig ({result.Status}): {result.Message}."); + } + + _errorReporter.Report( + level: "warning", module: "Core", + message: $"Lizenz ungültig ({result.Status}) - Sperrbetrieb aktiv, TradingEnabled=false.", + exception: null); + } + + private void WarnIfGracePeriodExpiringSoon(LicenseValidationResult result) + { + if (result.CacheExpiresAt is not { } expiresUnix) return; + + var expires = DateTimeOffset.FromUnixTimeSeconds(expiresUnix); + if (expires - DateTimeOffset.UtcNow < TimeSpan.FromHours(48)) + _logger.Warn("Core", $"Offline-Gnadenfrist der Lizenz läuft in weniger als 48h ab ({expires:u})."); + } +} diff --git a/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj b/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj index 6644819..3b10178 100644 --- a/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj +++ b/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj @@ -22,6 +22,11 @@ + + @@ -30,4 +35,15 @@ + + + + + + + +