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 @@
+
+
+
+
+
+
+
+