From 9c7e19149cfb1cc617ae43630d231d3cfc7f8196 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 6 Aug 2026 23:09:33 +0200 Subject: [PATCH] L2: Kopfloser Linux-Dienst - der Trading-Kern laeuft ohne Oberflaeche Meilenstein: Trading, Worker, Accounting, Supervisor, REST und MCP laufen als systemd-Dienst auf Linux. Nur noch IBKRTrader.App ist Windows-gebunden. Neu IBKRTrader.Hosting: die komplette Host-Zusammenstellung, aus der WinForms-Program.cs herausgezogen. Bewusst ein eigenes Projekt - laege sie weiter in der Shell, muesste sie fuer den Daemon dupliziert werden, und beide wuerden auseinanderlaufen. Ein Modul, das nur in einer Variante registriert ist, faellt sonst erst im Betrieb auf. RunStartupChecks buendelt die Startschritte in fester Reihenfolge (Zeitzone vor dem ersten Logeintrag, Master-Key vor jeder Entschluesselung). Neu IBKRTrader.Daemon: kopfloser Einstiegspunkt mit SIGTERM-Behandlung, --check (Trockenlauf: Host bauen, Startpruefungen, keine Boersenverbindung - fuer Deployment und CI) und --db-version. AppPaths: Logs, Backups, settings.json und master.key lagen neben der Binaerdatei. Unter /opt hat der Dienstbenutzer dort keinen Schreibzugriff - der Dienst waere beim ersten Logeintrag gescheitert. Aufloesung jetzt: Umgebungsvariable, sonst Binaerverzeichnis WENN beschreibbar (haelt Windows und die Entwicklung unveraendert), sonst FHS. Der Schreibtest ist ein echter Schreibversuch - Attributpruefung traegt nicht, weil unter Linux Besitzer/Gruppe/Modus und unter Windows die ACL entscheiden. Rechtepruefung fuer master.key/openrouter.key auf Unix: Windows-ACLs uebertragen sich beim Kopieren auf einen Linux-Host nicht, eine key-Datei mit 644 waere dort fuer jeden lokalen Nutzer lesbar. Nur eine Warnung. InvariantGlobalization ausdruecklich auf false. Der naheliegende Griff zum schlanken Image waere hier ein Eigentor gewesen und haette genau die zwei Punkte aus L1b lautlos rueckgaengig gemacht: ohne ICU faellt die Aufloesung von Windows-Zeitzonen-IDs aus (Rueckfall auf UTC im Container) und CultureInfo.GetCultureInfo("de-DE") liefert die invariante Kultur, womit die PDF-Betraege wieder das Format wechseln. systemd: Unit mit Type=notify plus UseSystemd() im Host - ohne sd_notify haette systemctl start bis zum Timeout gehangen. Unit mit Absicherung (ProtectSystem=strict, ReadWritePaths), deploy/README.md mit Voraussetzungen, Verzeichnissen, Zeitzonen-Festlegung und dem, was der Daemon NICHT loest (IB Gateway headless bleibt eine eigene Baustelle). Verifiziert: 193 Tests gruen (+5), Build 0 Fehler/0 Warnungen, --smoke-ui konstruiert alle 7 Fenster, --check laeuft durch, und publish -r linux-x64 liefert 11 MB mit ELF-Launcher, ohne eine einzige Windows-Abhaengigkeit. Co-Authored-By: Claude Opus 5 --- IBKRTrader.App.csproj | 3 + IBKRTrader.slnx | 2 + Program.cs | 238 ++--------------- deploy/README.md | 115 ++++++++ deploy/ibkrtrader.service | 64 +++++ src/IBKRTrader.Core/Configuration/AppPaths.cs | 95 +++++++ src/IBKRTrader.Core/Logging/LoggingService.cs | 3 +- .../Settings/SettingsService.cs | 3 +- .../Workers/BuiltIn/BackupWorker.cs | 5 +- src/IBKRTrader.Daemon/DbVersion.cs | 29 ++ .../IBKRTrader.Daemon.csproj | 38 +++ src/IBKRTrader.Daemon/Program.cs | 79 ++++++ src/IBKRTrader.Daemon/appsettings.json | 11 + src/IBKRTrader.Hosting/AppHostBuilder.cs | 252 ++++++++++++++++++ .../IBKRTrader.Hosting.csproj | 33 +++ .../Agent/OpenRouterClient.cs | 3 +- .../Agent/SupervisorTools.cs | 3 +- .../Services/DossierService.cs | 3 +- .../Configuration/AppPathsTests.cs | 62 +++++ 19 files changed, 819 insertions(+), 222 deletions(-) create mode 100644 deploy/README.md create mode 100644 deploy/ibkrtrader.service create mode 100644 src/IBKRTrader.Core/Configuration/AppPaths.cs create mode 100644 src/IBKRTrader.Daemon/DbVersion.cs create mode 100644 src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj create mode 100644 src/IBKRTrader.Daemon/Program.cs create mode 100644 src/IBKRTrader.Daemon/appsettings.json create mode 100644 src/IBKRTrader.Hosting/AppHostBuilder.cs create mode 100644 src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj create mode 100644 tests/IBKRTrader.Tests/Configuration/AppPathsTests.cs diff --git a/IBKRTrader.App.csproj b/IBKRTrader.App.csproj index 894b7b6..172ccd5 100644 --- a/IBKRTrader.App.csproj +++ b/IBKRTrader.App.csproj @@ -41,6 +41,9 @@ + + diff --git a/IBKRTrader.slnx b/IBKRTrader.slnx index 8a7cc48..4b57690 100644 --- a/IBKRTrader.slnx +++ b/IBKRTrader.slnx @@ -1,6 +1,8 @@ + + diff --git a/Program.cs b/Program.cs index 767e816..c710707 100644 --- a/Program.cs +++ b/Program.cs @@ -1,22 +1,9 @@ -using IBKRTrader.Core.AI; -using IBKRTrader.Core.Budget; -using IBKRTrader.Core.Configuration; -using IBKRTrader.Core.DependencyInjection; -using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Modularity; -using IBKRTrader.Core.Persistence; -using IBKRTrader.Core.Persistence.Ef; -using IBKRTrader.Core.Security; using IBKRTrader.Core.Settings; -using IBKRTrader.Core.Time; using IBKRTrader.Core.Trading; -using IBKRTrader.Core.Trading.Ibkr; using IBKRTrader.Core.Workers; -using IBKRTrader.Core.Workers.BuiltIn; -using IBKRTrader.Modules.Accounting; -using IBKRTrader.Modules.CongressTrading; -using IBKRTrader.Modules.Supervisor; +using IBKRTrader.Hosting; using IBKRTrader.UI; using IBKRTrader.UI.Views; using Microsoft.Extensions.Configuration; @@ -25,6 +12,14 @@ using Microsoft.Extensions.Hosting; namespace IBKRTrader; +/// +/// Einstiegspunkt der WinForms-Shell (Windows). +/// +/// Der Host selbst kommt aus und ist plattformneutral – hier +/// wird er nur um Fensterverwaltung und Launcher ergänzt. Derselbe Host trägt den kopflosen +/// Linux-Dienst IBKRTrader.Daemon; so kann keine der beiden Varianten eine Registrierung +/// vergessen. +/// internal static class Program { public static IHost? AppHost { get; private set; } @@ -39,47 +34,12 @@ internal static class Program return; } - // Diagnose: gibt die MySQL/MariaDB-Serverversion aus (für das EF-ServerVersion-Pinning). - if (args.Length > 0 && string.Equals(args[0], "--db-version", StringComparison.OrdinalIgnoreCase)) - { - RunDbVersion(); - return; - } - ApplicationConfiguration.Initialize(); - var modules = new List { new CongressTradingModule(), new AccountingModule(), new SupervisorModule() }; + var modules = AppHostBuilder.CreateModules(); + AppHost = AppHostBuilder.Build(modules, RegisterShellServices); - AppHost = Host.CreateDefaultBuilder() - .UseContentRoot(AppContext.BaseDirectory) - .ConfigureAppConfiguration((_, config) => - config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false)) - .ConfigureServices((context, services) => - { - RegisterCoreServices(services, context.Configuration); - foreach (var module in modules) - { - services.AddSingleton(module); - module.RegisterServices(services, context.Configuration); - } - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(); - }) - .Build(); - - // Betriebszeitzone VOR dem ersten Logeintrag setzen – sie bestimmt die Tagesgrenzen der - // Logdateien. Wird sie später gesetzt, landen die ersten Zeilen in der falschen Datei. - var startupLog = AppHost.Services.GetRequiredService(); - ConfigureAppTimeZone(AppHost.Services, startupLog); - - // Sicherheit: Master-Key laden (VOR jeder Entschlüsselung) und DB-TLS prüfen. - ConfigureSecretProtection(startupLog); - WarnIfDbTlsNotEnforced(AppHost.Services, startupLog); - - // Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz (vor dem Start). - AppHost.Services.GetRequiredService() - .SetEngine(AppHost.Services.GetRequiredService()); + AppHostBuilder.RunStartupChecks(AppHost.Services); // Host starten → alle Worker/Services (IHostedService) laufen an. AppHost.Start(); @@ -96,80 +56,15 @@ internal static class Program AppHost.StopAsync().GetAwaiter().GetResult(); } - /// Registriert alle Core-Services im DI-Container. - private static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration) + /// Nur die Dienste, die es ohne Oberfläche nicht gibt. + private static void RegisterShellServices(IServiceCollection services, IConfiguration configuration) { - // EF-Core-Persistenz (Connection aus appsettings.Local.json). - services.AddCorePersistence(new DatabaseOptions - { - MySqlConnectionString = configuration["Database:MySqlConnectionString"] ?? string.Empty - }); - - // Settings zuerst laden (eine Quelle, als Singleton weitergereicht). - var settingsService = new SettingsService(); - settingsService.Load(); - services.AddSingleton(settingsService); - - services.AddSingleton(); - - services.AddSingleton(); // core_settings via EF - - services.AddSingleton(); - services.AddSingleton(); - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Datenfundament für Analyse/Forensik (Supervisor): Entscheidungsjournal + Order-Events. - services.AddSingleton(); - services.AddSingleton(); - - // Trading-Kern - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - // Echter TWS-Broker nur, wenn ausdrücklich aktiviert – sonst der NullBroker, der nie handelt. - // Beide Rollen (Handel + lesender Bestandsabgleich) bedient dieselbe Instanz. - if (settingsService.Settings.IBKR.UseTwsApi) - { - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); - } - else - { - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); - } - - // Core-Worker/Services - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable (nur UI/Registry). - services.AddSingleton(sp => sp.GetRequiredService()); - 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()); - services.AddHostedService(sp => sp.GetRequiredService()); - services.AddHostedService(sp => sp.GetRequiredService()); - services.AddHostedService(sp => sp.GetRequiredService()); - services.AddHostedService(sp => sp.GetRequiredService()); - - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); } - /// Registriert die Core-Views (Logs, Settings, Workers) bei der Shell. + /// Registriert die Core-Views (Dashboard, Workers, Logs, Settings) bei der Shell. private static void RegisterCoreViews(IModuleUiHost uiHost, IServiceProvider sp) { uiHost.RegisterView(new ModuleView @@ -199,107 +94,20 @@ internal static class Program }); } - /// - /// Setzt die Betriebszeitzone dieser Instanz aus den Einstellungen. Sie gilt für Anzeige, - /// Logdatei-Tagesgrenzen, Berichtszeiten und Buchungsperioden; gespeichert wird weiterhin UTC. - /// - /// Wir betreiben Instanzen in EU und US. Ohne diese Festlegung hinge die Ortszeit an der - /// Zeitzone des Rechners – auf einem UTC-Container also woanders als auf dem Windows-Desktop, - /// lautlos und ohne Fehlermeldung. Die Zeitzone wird vor den ersten Trades gesetzt und danach - /// nicht mehr gewechselt. - /// - private static void ConfigureAppTimeZone(IServiceProvider services, LoggingService logger) - { - var configured = services.GetRequiredService().Settings.Trading.ApplicationTimeZoneId; - AppTimeZone.Configure(configured, msg => logger.Warn("Core", msg)); - logger.Info("Core", $"Betriebszeitzone: {AppTimeZone.CurrentId} (Persistenz bleibt UTC)."); - } - - /// - /// Lädt den Master-Key (env IBKRTRADER_MASTER_KEY, sonst gitignorierte master.key) und aktiviert die - /// at-rest-Verschlüsselung. Ohne Key läuft die App mit Klartext – mit deutlicher Warnung. - /// - private static void ConfigureSecretProtection(LoggingService logger) - { - var masterKey = Environment.GetEnvironmentVariable("IBKRTRADER_MASTER_KEY"); - if (string.IsNullOrWhiteSpace(masterKey)) - { - var keyFile = Path.Combine(AppContext.BaseDirectory, "master.key"); - if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim(); - } - SecretProtection.Configure(masterKey); - - if (SecretProtection.IsConfigured) - logger.Info("Core", "🔐 Secret-Verschlüsselung aktiv – sensible Daten werden at-rest verschlüsselt (AES-256-GCM)."); - else - logger.Warn("Core", "⚠️ SICHERHEIT: Kein IBKRTRADER_MASTER_KEY gesetzt – sensible Daten würden UNVERSCHLÜSSELT gespeichert. " + - "Master-Key setzen (env IBKRTRADER_MASTER_KEY oder Datei master.key)."); - } - - /// Warnt, wenn der DB-Connection-String keine TLS-Option (SslMode) enthält. Der String wird NICHT geloggt. - private static void WarnIfDbTlsNotEnforced(IServiceProvider services, LoggingService logger) - { - try - { - var conn = services.GetService()?["Database:MySqlConnectionString"] ?? string.Empty; - if (string.IsNullOrEmpty(conn)) return; - if (conn.IndexOf("sslmode", StringComparison.OrdinalIgnoreCase) < 0) - logger.Warn("Core", "⚠️ SICHERHEIT: DB-Verbindung ohne SslMode – Transportverschlüsselung nicht erzwungen. " + - "Im Connection-String 'SslMode=Required' setzen."); - } - catch { /* best-effort, darf den Start nie stören */ } - } - - /// Diagnose: öffnet die DB (aus settings.json) und gibt die Serverversion aus. Kein UI. - private static void RunDbVersion() - { - var settings = new SettingsService(); - settings.Load(); - var conn = settings.Settings.Database.BuildConnectionString(); - try - { - using var c = new MySqlConnector.MySqlConnection(conn); - c.Open(); - Console.WriteLine($"ServerVersion: {c.ServerVersion}"); - } - catch (Exception ex) - { - Console.WriteLine($"FEHLER: {ex.GetType().Name}: {ex.Message}"); - } - } - /// /// Headless-Smoke-Test: baut den Host, registriert Views + Modul-UI und konstruiert jede /// registrierte View sowie den Launcher – ohne Message-Loop. Gibt die Fehleranzahl zurück. + /// Der Host wird bewusst NICHT gestartet: sonst liefen Worker und Broker-Verbindungen an, + /// was für eine reine Konstruktionsprüfung unerwünscht ist. /// private static int RunSmokeUi() { ApplicationConfiguration.Initialize(); - var modules = new List { new CongressTradingModule(), new AccountingModule(), new SupervisorModule() }; + var modules = AppHostBuilder.CreateModules(); + using var host = AppHostBuilder.Build(modules, RegisterShellServices); - using var host = Host.CreateDefaultBuilder() - .UseContentRoot(AppContext.BaseDirectory) - .ConfigureAppConfiguration((_, config) => - config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false)) - .ConfigureServices((context, services) => - { - RegisterCoreServices(services, context.Configuration); - foreach (var module in modules) - { - services.AddSingleton(module); - module.RegisterServices(services, context.Configuration); - } - services.AddSingleton(); - services.AddSingleton(s => s.GetRequiredService()); - services.AddSingleton(); - }) - .Build(); - - ConfigureAppTimeZone(host.Services, host.Services.GetRequiredService()); - - host.Services.GetRequiredService() - .SetEngine(host.Services.GetRequiredService()); + AppHostBuilder.RunStartupChecks(host.Services); var uiHost = host.Services.GetRequiredService(); RegisterCoreViews(uiHost, host.Services); diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..dee5162 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,115 @@ +# Kopfloser Betrieb auf Linux + +Der Dienst `IBKRTrader.Daemon` fährt Trading-Kern, Worker, Accounting, Supervisor, REST-API und +MCP-Light **ohne Oberfläche**. Er nutzt denselben Host-Aufbau wie die Desktop-Shell +(`IBKRTrader.Hosting`), damit beide Varianten nicht auseinanderlaufen. + +## Veröffentlichen + +```bash +dotnet publish src/IBKRTrader.Daemon -c Release -r linux-x64 --self-contained false -o out/ +``` + +Ergebnis: ~11 MB, ein ELF-Launcher `IBKRTrader.Daemon`, **keine Windows-Abhängigkeiten**. +Mit `--self-contained true` entfällt die Runtime-Installation auf dem Zielhost (dann ~80 MB). + +## Voraussetzungen auf dem Host + +| Paket | Wofür | Pflicht? | +|---|---|---| +| `dotnet-runtime-10.0` | Laufzeit | ja (außer bei `--self-contained`) | +| `libicu` | Kulturen und Zeitzonen-ID-Umrechnung | **ja – siehe Warnung unten** | +| `fonts-dejavu-core` | PDF-Export des Accounting-Moduls | nur für den PDF-Export | +| `mariadb-client` | `mariadb-dump` für den BackupWorker | nur fürs DB-Backup | + +> **ICU ist nicht optional.** Ohne ICU (bzw. mit `InvariantGlobalization=true`) passieren zwei +> Dinge – beide **lautlos**, ohne Fehlermeldung: +> 1. `AppTimeZone` kann Windows-Zeitzonen-IDs nicht mehr auflösen und fällt auf die +> Systemzeitzone zurück, im Container also meist UTC. Genau die Verschiebung von +> Buchungszeitstempeln, die wir beseitigt haben. +> 2. Der PDF-Export formatiert Beträge fest gegen `de-DE`. Ohne ICU liefert +> `CultureInfo.GetCultureInfo("de-DE")` die invariante Kultur – aus `1.234,56` wird +> `1,234.56`, in einem Dokument, das als prüfbare Aufstellung gilt. +> +> Der Daemon setzt deshalb ausdrücklich `InvariantGlobalization=false`. + +## Verzeichnisse + +Die Anwendung schreibt in drei Verzeichnisse. Aufgelöst wird in dieser Reihenfolge: + +1. Umgebungsvariable — `IBKRTRADER_CONFIG_DIR`, `IBKRTRADER_DATA_DIR`, `IBKRTRADER_LOG_DIR` +2. Neben der Binärdatei, **wenn dort geschrieben werden darf** (Entwicklung, portable Installation) +3. Sonst FHS — `/etc/ibkrtrader`, `/var/lib/ibkrtrader`, `/var/log/ibkrtrader` + +| Verzeichnis | Inhalt | +|---|---| +| Config | `settings.json`, `master.key`, `openrouter.key` | +| Data | `Backups/` | +| Logs | Textlog je Modul, JSONL je Tag | + +Beim Start steht die tatsächliche Ablage im Log (`Ablage: config=…, data=…, logs=…`). + +## Einrichtung + +```bash +sudo useradd --system --no-create-home --shell /usr/sbin/nologin ibkrtrader +sudo mkdir -p /opt/ibkrtrader /etc/ibkrtrader /var/lib/ibkrtrader /var/log/ibkrtrader +sudo chown -R ibkrtrader:ibkrtrader /var/lib/ibkrtrader /var/log/ibkrtrader /etc/ibkrtrader +sudo chmod 750 /etc/ibkrtrader +``` + +Master-Key ablegen (**nicht** in die Unit-Datei – die ist für alle lesbar): + +```bash +sudo install -o ibkrtrader -g ibkrtrader -m 600 /dev/null /etc/ibkrtrader/master.key +``` + +Der Dienst warnt beim Start, wenn `master.key` oder `openrouter.key` für Gruppe oder andere +zugänglich sind — Windows-ACLs übertragen sich beim Kopieren auf einen Linux-Host nicht. + +## Dienst einrichten + +```bash +sudo cp deploy/ibkrtrader.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now ibkrtrader +journalctl -u ibkrtrader -f +``` + +## Vor dem ersten Start prüfen + +```bash +/opt/ibkrtrader/IBKRTrader.Daemon --check +``` + +Baut den Host, fährt alle Startprüfungen (Zeitzone, Ablageorte, Master-Key, DB-TLS) und beendet +sich — **ohne** Worker zu starten oder eine Verbindung zur Börse aufzubauen. Geeignet für +Deployment-Skripte und CI. + +```bash +/opt/ibkrtrader/IBKRTrader.Daemon --db-version +``` + +Gibt die MariaDB-Serverversion aus (für das EF-`ServerVersion`-Pinning). + +## Betriebszeitzone + +`Trading.ApplicationTimeZoneId` in `settings.json`, IANA-Schreibweise: + +| Instanz | Wert | +|---|---| +| EU | `Europe/Berlin` | +| US | `America/New_York` | + +**Vor den ersten Trades festlegen und danach nicht mehr ändern.** Ein Wechsel verschiebt +rückwirkend die Tagesgrenzen von Logs, Berichten und Buchungsperioden. Gespeichert wird immer +UTC — nur so bleiben die Daten beider Instanzen vergleichbar. + +## Was der Daemon *nicht* löst + +Das **IB Gateway** ist eine Java-Anwendung mit Oberfläche. Für den Dauerbetrieb ohne Bildschirm +braucht es IBC plus Xvfb, dazu die Behandlung des täglichen Neustarts und des 2FA-Handlings — +eine eigene Baustelle, unabhängig von diesem Dienst. Alternative: das Gateway bleibt auf dem +Windows-Rechner, der Linux-Dienst verbindet sich über Port 4002. Dann muss in der +TWS-Konfiguration die erlaubte Client-IP eingetragen sein; der TWS-API-Verkehr ist +**unverschlüsselt** und gehört nicht über ein unvertrautes Netz. diff --git a/deploy/ibkrtrader.service b/deploy/ibkrtrader.service new file mode 100644 index 0000000..53b2a04 --- /dev/null +++ b/deploy/ibkrtrader.service @@ -0,0 +1,64 @@ +# +# systemd-Unit für den kopflosen IBKRTrader-Dienst. +# +# sudo cp deploy/ibkrtrader.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now ibkrtrader +# journalctl -u ibkrtrader -f +# +# Voraussetzungen auf dem Host: +# - .NET-10-Runtime (dotnet-runtime-10.0) +# - libicu → PFLICHT. Ohne ICU fällt die Auflösung von Windows-Zeitzonen-IDs aus und die +# feste de-DE-Formatierung im PDF-Export kippt auf invariant. Beides würde +# lautlos falsche Ausgaben erzeugen, nicht etwa einen Fehler. +# - fonts-dejavu-core → nur für den PDF-Export des Accounting-Moduls. +# - mariadb-client → nur für den BackupWorker (mariadb-dump). +# +[Unit] +Description=IBKRTrader (kopfloser Handelsdienst) +Documentation=file:///opt/ibkrtrader/docs/konzepte/KONZEPT-Linux-Portierung.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +NotifyAccess=all + +User=ibkrtrader +Group=ibkrtrader + +WorkingDirectory=/opt/ibkrtrader +ExecStart=/opt/ibkrtrader/IBKRTrader.Daemon + +# Ablageorte. Ohne diese Variablen weicht die Anwendung selbst auf die FHS-Pfade aus, sobald +# /opt/ibkrtrader nicht beschreibbar ist – ausdrücklich gesetzt ist es aber nachvollziehbarer. +Environment=IBKRTRADER_CONFIG_DIR=/etc/ibkrtrader +Environment=IBKRTRADER_DATA_DIR=/var/lib/ibkrtrader +Environment=IBKRTRADER_LOG_DIR=/var/log/ibkrtrader + +# Der Master-Key gehört NICHT in diese Datei (sie ist für alle lesbar). Entweder als Datei +# /etc/ibkrtrader/master.key mit chmod 600, oder über eine EnvironmentFile mit 600: +# EnvironmentFile=/etc/ibkrtrader/secrets.env +Environment=DOTNET_EnableDiagnostics=0 + +# Geordnetes Herunterfahren: SIGTERM, dann Zeit für offene Broker-Anfragen und den Modul-Stopp. +KillSignal=SIGTERM +TimeoutStopSec=60 +Restart=on-failure +RestartSec=15 + +# Absicherung. Der Dienst braucht nur seine drei Verzeichnisse beschreibbar. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/ibkrtrader /var/log/ibkrtrader +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=true +LockPersonality=true + +[Install] +WantedBy=multi-user.target diff --git a/src/IBKRTrader.Core/Configuration/AppPaths.cs b/src/IBKRTrader.Core/Configuration/AppPaths.cs new file mode 100644 index 0000000..817be88 --- /dev/null +++ b/src/IBKRTrader.Core/Configuration/AppPaths.cs @@ -0,0 +1,95 @@ +namespace IBKRTrader.Core.Configuration; + +/// +/// Die Verzeichnisse, in die die Anwendung schreibt. +/// +/// Warum das nötig wurde: Bisher lag alles neben der Binärdatei – Logs/, +/// Backups/, settings.json, master.key. Unter Windows ist das üblich. Auf +/// Linux liegt eine Anwendung typischerweise unter /opt oder /usr/local, und dort +/// hat der Dienstbenutzer keinen Schreibzugriff. Der Dienst wäre beim ersten Logeintrag +/// gescheitert. +/// +/// Auflösung, in dieser Reihenfolge: +/// +/// Umgebungsvariable (IBKRTRADER_CONFIG_DIR, _DATA_DIR, _LOG_DIR) – +/// 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. +/// +/// +/// Die Prüfung läuft einmal beim ersten Zugriff; die Verzeichnisse werden dabei angelegt. +/// +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")); + + /// Konfiguration und Schlüsselmaterial: settings.json, master.key, openrouter.key. + public static string Config => _config.Value; + + /// Veränderliche Daten: Backups/. + public static string Data => _data.Value; + + /// Logdateien (Textlog je Modul und JSONL). + public static string Logs => _logs.Value; + + /// Vollständiger Pfad einer Konfigurationsdatei. + public static string ConfigFile(string fileName) => Path.Combine(Config, fileName); + + /// Vollständiger Pfad unterhalb des Datenverzeichnisses. + public static string DataPath(string relative) => Path.Combine(Data, relative); + + /// Kurzfassung für den Startlog – damit im Betrieb sichtbar ist, wohin geschrieben wird. + 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. + /// + /// 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) + { + var fromEnv = Environment.GetEnvironmentVariable(envVar); + if (!string.IsNullOrWhiteSpace(fromEnv)) + return Ensure(fromEnv.Trim()); + + var local = localSubDir is null + ? AppContext.BaseDirectory + : Path.Combine(AppContext.BaseDirectory, localSubDir); + + if (IsWritable(AppContext.BaseDirectory)) + return Ensure(local); + + return Ensure(fhsFallback); + } + + /// + /// Prüft den Schreibzugriff, indem tatsächlich geschrieben wird. Eine Rechteprüfung über + /// Attribute trägt nicht: unter Linux entscheiden Besitzer, Gruppe und Modus, unter Windows + /// die ACL – ein Schreibversuch ist die einzige verlässliche Antwort. + /// + private static bool IsWritable(string dir) + { + try + { + var probe = Path.Combine(dir, $".write-probe-{Guid.NewGuid():N}"); + using (File.Create(probe, 1, FileOptions.DeleteOnClose)) { } + return true; + } + catch (UnauthorizedAccessException) { return false; } + catch (IOException) { return false; } + } + + private static string Ensure(string dir) + { + Directory.CreateDirectory(dir); + return dir; + } +} diff --git a/src/IBKRTrader.Core/Logging/LoggingService.cs b/src/IBKRTrader.Core/Logging/LoggingService.cs index 2489aa1..e854982 100644 --- a/src/IBKRTrader.Core/Logging/LoggingService.cs +++ b/src/IBKRTrader.Core/Logging/LoggingService.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Configuration; using IBKRTrader.Core.Time; using System.Runtime.CompilerServices; @@ -21,7 +22,7 @@ public class LoggingService private readonly object _jsonlLock = new(); private static readonly string LogBaseDir = - Path.Combine(AppContext.BaseDirectory, "Logs"); + AppPaths.Logs; // ─── Konfiguration ──────────────────────────────────────────────────────── diff --git a/src/IBKRTrader.Core/Settings/SettingsService.cs b/src/IBKRTrader.Core/Settings/SettingsService.cs index c789a41..c90bab2 100644 --- a/src/IBKRTrader.Core/Settings/SettingsService.cs +++ b/src/IBKRTrader.Core/Settings/SettingsService.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Configuration; using System.Text.Json; using System.Text.Json.Serialization; @@ -10,7 +11,7 @@ namespace IBKRTrader.Core.Settings; public class SettingsService { private static readonly string SettingsPath = - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json"); + AppPaths.ConfigFile("settings.json"); private static readonly JsonSerializerOptions JsonOptions = new() { diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs index 5c2fb4c..9ae3a9c 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Configuration; using System.Globalization; using IBKRTrader.Core.Time; using IBKRTrader.Core.Persistence.Ef; @@ -34,7 +35,7 @@ public class BackupWorker : WorkerBase protected override async Task ExecuteAsync(CancellationToken ct) { var timestamp = AppTimeZone.Now.ToString("yyyy-MM-dd_HH-mm"); - var backupRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Backups"); + var backupRoot = AppPaths.DataPath("Backups"); var dbDir = Path.Combine(backupRoot, "DB"); var logsDir = Path.Combine(backupRoot, "Logs"); @@ -166,7 +167,7 @@ public class BackupWorker : WorkerBase { try { - var srcLogs = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); + var srcLogs = AppPaths.Logs; if (!Directory.Exists(srcLogs)) return; var destDir = Path.Combine(logsBackupDir, timestamp); diff --git a/src/IBKRTrader.Daemon/DbVersion.cs b/src/IBKRTrader.Daemon/DbVersion.cs new file mode 100644 index 0000000..9b008c3 --- /dev/null +++ b/src/IBKRTrader.Daemon/DbVersion.cs @@ -0,0 +1,29 @@ +using IBKRTrader.Core.Settings; + +namespace IBKRTrader.Daemon; + +/// +/// Diagnose: öffnet die Datenbank aus settings.json und gibt die Serverversion aus. +/// Gebraucht für das EF-ServerVersion-Pinning; läuft ohne Host und ohne Oberfläche. +/// +internal static class DbVersion +{ + public static int Print() + { + var settings = new SettingsService(); + settings.Load(); + + try + { + using var c = new MySqlConnector.MySqlConnection(settings.Settings.Database.BuildConnectionString()); + c.Open(); + Console.WriteLine($"ServerVersion: {c.ServerVersion}"); + return 0; + } + catch (Exception ex) + { + Console.WriteLine($"FEHLER: {ex.GetType().Name}: {ex.Message}"); + return 1; + } + } +} diff --git a/src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj b/src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj new file mode 100644 index 0000000..0ad47fe --- /dev/null +++ b/src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj @@ -0,0 +1,38 @@ + + + + + Exe + net10.0 + enable + enable + IBKRTrader.Daemon + IBKRTrader.Daemon + en + + false + + + + + + + + + + + + + + + + diff --git a/src/IBKRTrader.Daemon/Program.cs b/src/IBKRTrader.Daemon/Program.cs new file mode 100644 index 0000000..ffc15c6 --- /dev/null +++ b/src/IBKRTrader.Daemon/Program.cs @@ -0,0 +1,79 @@ +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Modularity; +using IBKRTrader.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace IBKRTrader.Daemon; + +/// +/// Kopfloser Einstiegspunkt: Trading-Kern, Worker, Accounting, Supervisor, REST-API und MCP-Light +/// laufen ohne Oberfläche. Das ist die Betriebsform auf Linux (systemd). +/// +/// Der Host kommt unverändert aus – dieselbe Zusammenstellung, +/// die auch die Desktop-Shell startet. Hier fehlt lediglich alles, was ein Fenster braucht. +/// +internal static class Program +{ + private static async Task Main(string[] args) + { + // Diagnose: Serverversion ausgeben (für das EF-ServerVersion-Pinning). Kein Host nötig. + if (HasFlag(args, "--db-version")) + return DbVersion.Print(); + + // 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. + var checkOnly = HasFlag(args, "--check"); + + var modules = AppHostBuilder.CreateModules(); + using var host = AppHostBuilder.Build(modules); + + var logger = host.Services.GetRequiredService(); + AppHostBuilder.RunStartupChecks(host.Services); + + if (checkOnly) + { + logger.Info("Core", "Prüflauf (--check) erfolgreich: Host baubar, Startprüfungen bestanden."); + Console.WriteLine("=== Prüflauf OK ==="); + return 0; + } + + logger.Info("Core", "=== IBKRTrader (kopflos) startet ==="); + logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version} | {RuntimeDescription()}"); + + await host.StartAsync().ConfigureAwait(false); + + // Module nach dem Host starten – so laufen sie nie gegen einen leeren Zustand an. + foreach (var module in modules) + { + try { await module.StartAsync(default).ConfigureAwait(false); } + catch (Exception ex) { logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); } + } + + logger.Info("Core", "IBKRTrader bereit. Beenden mit Strg+C bzw. SIGTERM."); + + // WaitForShutdown behandelt SIGTERM und SIGINT – genau das, was systemd beim Stoppen sendet. + await host.WaitForShutdownAsync().ConfigureAwait(false); + + await StopModulesAsync(modules, logger).ConfigureAwait(false); + logger.Info("Core", "IBKRTrader beendet."); + return 0; + } + + private static async Task StopModulesAsync(IReadOnlyList modules, LoggingService logger) + { + foreach (var module in modules) + { + try { await module.StopAsync(default).ConfigureAwait(false); } + catch (Exception ex) { logger.Warn(module.Name, $"{module.Name}: Stopp fehlgeschlagen: {ex.Message}"); } + } + } + + private static bool HasFlag(string[] args, string flag) => + args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase)); + + private static string RuntimeDescription() => + $"{System.Runtime.InteropServices.RuntimeInformation.OSDescription.Trim()} " + + $"({System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})"; +} diff --git a/src/IBKRTrader.Daemon/appsettings.json b/src/IBKRTrader.Daemon/appsettings.json new file mode 100644 index 0000000..d09a23c --- /dev/null +++ b/src/IBKRTrader.Daemon/appsettings.json @@ -0,0 +1,11 @@ +{ + "Database": { + "MySqlConnectionString": "" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Warning" + } + } +} diff --git a/src/IBKRTrader.Hosting/AppHostBuilder.cs b/src/IBKRTrader.Hosting/AppHostBuilder.cs new file mode 100644 index 0000000..0bd4225 --- /dev/null +++ b/src/IBKRTrader.Hosting/AppHostBuilder.cs @@ -0,0 +1,252 @@ +using IBKRTrader.Core.AI; +using IBKRTrader.Core.Budget; +using IBKRTrader.Core.Configuration; +using IBKRTrader.Core.DependencyInjection; +using IBKRTrader.Core.IBKR; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Modularity; +using IBKRTrader.Core.Persistence; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Security; +using IBKRTrader.Core.Settings; +using IBKRTrader.Core.Time; +using IBKRTrader.Core.Trading; +using IBKRTrader.Core.Trading.Ibkr; +using IBKRTrader.Core.Workers; +using IBKRTrader.Core.Workers.BuiltIn; +using IBKRTrader.Modules.Accounting; +using IBKRTrader.Modules.CongressTrading; +using IBKRTrader.Modules.Supervisor; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace IBKRTrader.Hosting; + +/// +/// Baut den Anwendungs-Host: Konfiguration, Persistenz, Core-Dienste, Worker und Module – +/// ohne jeden Bezug zur Oberfläche. +/// +/// Bewusst ein eigenes Projekt: derselbe Host trägt den kopflosen Linux-Dienst +/// (IBKRTrader.Daemon) und die Desktop-Shell. Läge die Zusammenstellung wie bisher in der +/// WinForms-Program.cs, müsste sie für den Daemon dupliziert werden – und beide würden mit +/// der Zeit auseinanderlaufen. Ein Modul, das nur in einer Variante registriert ist, wäre ein +/// Fehler, den man erst im Betrieb bemerkt. +/// +public static class AppHostBuilder +{ + /// Die in dieser Ausbaustufe aktiven Module. + public static IReadOnlyList CreateModules() => + [ + new CongressTradingModule(), + new AccountingModule(), + new SupervisorModule() + ]; + + /// + /// Stellt den Host zusammen. erlaubt der jeweiligen Shell, + /// ihre eigenen Dienste (Fensterverwaltung, Launcher) zu ergänzen – der Daemon übergibt nichts. + /// + public static IHost Build(IReadOnlyList modules, + Action? configureExtra = null) => + Host.CreateDefaultBuilder() + .UseContentRoot(AppContext.BaseDirectory) + // Meldet systemd die Betriebsbereitschaft (sd_notify) und behandelt SIGTERM sauber. + // Ohne das würde eine Unit mit Type=notify beim Start bis zum Timeout hängen. + // Läuft der Prozess nicht unter systemd, ist der Aufruf wirkungslos – die + // Windows-Shell nutzt denselben Host-Aufbau und merkt davon nichts. + .UseSystemd() + .ConfigureAppConfiguration((_, config) => + config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false)) + .ConfigureServices((context, services) => + { + RegisterCoreServices(services, context.Configuration); + foreach (var module in modules) + { + services.AddSingleton(module); + module.RegisterServices(services, context.Configuration); + } + configureExtra?.Invoke(services, context.Configuration); + }) + .Build(); + + /// + /// Die Startschritte, die vor dem Anlaufen der Dienste passieren müssen – in dieser Reihenfolge. + /// Gemeinsam für Daemon und Shell, damit keine Variante einen davon vergisst. + /// + public static void RunStartupChecks(IServiceProvider services) + { + var logger = services.GetRequiredService(); + + // 1. Betriebszeitzone VOR dem ersten Logeintrag – sie bestimmt die Tagesgrenzen der Logdateien. + ConfigureAppTimeZone(services, logger); + + // 2. Ablageorte melden. Auf Linux können sie je nach Installation vom Binärverzeichnis + // abweichen (FHS) – im Betrieb muss sichtbar sein, wohin geschrieben wird. + logger.Info("Core", $"Ablage: {AppPaths.Describe()}"); + + // 3. Master-Key VOR jeder Entschlüsselung. + ConfigureSecretProtection(logger); + WarnIfKeyFileIsWorldReadable(logger); + + // 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()); + } + + /// Registriert alle Core-Services im DI-Container. + private static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration) + { + // EF-Core-Persistenz (Connection aus appsettings.Local.json). + services.AddCorePersistence(new DatabaseOptions + { + MySqlConnectionString = configuration["Database:MySqlConnectionString"] ?? string.Empty + }); + + // Settings zuerst laden (eine Quelle, als Singleton weitergereicht). + var settingsService = new SettingsService(); + settingsService.Load(); + services.AddSingleton(settingsService); + + services.AddSingleton(); + services.AddSingleton(); // core_settings via EF + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Datenfundament für Analyse/Forensik (Supervisor): Entscheidungsjournal + Order-Events. + services.AddSingleton(); + services.AddSingleton(); + + // Trading-Kern + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Echter TWS-Broker nur, wenn ausdrücklich aktiviert – sonst der NullBroker, der nie handelt. + // Beide Rollen (Handel + lesender Bestandsabgleich) bedient dieselbe Instanz. + if (settingsService.Settings.IBKR.UseTwsApi) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + } + else + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + // Core-Worker/Services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable. + services.AddSingleton(sp => sp.GetRequiredService()); + 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()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + + services.AddSingleton(); + } + + /// + /// Setzt die Betriebszeitzone dieser Instanz aus den Einstellungen. Sie gilt für Anzeige, + /// Logdatei-Tagesgrenzen, Berichtszeiten und Buchungsperioden; gespeichert wird weiterhin UTC. + /// Wir betreiben Instanzen in EU und US – ohne diese Festlegung hinge die Ortszeit an der + /// Zeitzone des Rechners, auf einem UTC-Container also woanders als auf dem Desktop. + /// + private static void ConfigureAppTimeZone(IServiceProvider services, LoggingService logger) + { + var configured = services.GetRequiredService().Settings.Trading.ApplicationTimeZoneId; + AppTimeZone.Configure(configured, msg => logger.Warn("Core", msg)); + logger.Info("Core", $"Betriebszeitzone: {AppTimeZone.CurrentId} (Persistenz bleibt UTC)."); + } + + /// + /// Lädt den Master-Key (env IBKRTRADER_MASTER_KEY, sonst gitignorierte master.key) und aktiviert + /// die at-rest-Verschlüsselung. Ohne Key läuft die Anwendung mit Klartext – mit deutlicher Warnung. + /// + private static void ConfigureSecretProtection(LoggingService logger) + { + var masterKey = Environment.GetEnvironmentVariable("IBKRTRADER_MASTER_KEY"); + if (string.IsNullOrWhiteSpace(masterKey)) + { + var keyFile = AppPaths.ConfigFile("master.key"); + if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim(); + } + SecretProtection.Configure(masterKey); + + if (SecretProtection.IsConfigured) + logger.Info("Core", "🔐 Secret-Verschlüsselung aktiv – sensible Daten werden at-rest verschlüsselt (AES-256-GCM)."); + else + logger.Warn("Core", "⚠️ SICHERHEIT: Kein IBKRTRADER_MASTER_KEY gesetzt – sensible Daten würden UNVERSCHLÜSSELT gespeichert. " + + "Master-Key setzen (env IBKRTRADER_MASTER_KEY oder Datei master.key)."); + } + + /// + /// Warnt, wenn Schlüsseldateien für andere Nutzer lesbar sind. + /// + /// Nur auf Unix sinnvoll: Windows-ACLs übertragen sich nicht beim Kopieren einer Datei + /// auf einen Linux-Host, und eine master.key mit Standardrechten (644) ist dort für + /// jeden lokalen Nutzer lesbar. Nur eine Warnung – die Rechte gehören dem Betreiber. + /// + private static void WarnIfKeyFileIsWorldReadable(LoggingService logger) + { + if (OperatingSystem.IsWindows()) return; + + foreach (var name in new[] { "master.key", "openrouter.key" }) + { + var path = AppPaths.ConfigFile(name); + if (!File.Exists(path)) continue; + + try + { + var mode = File.GetUnixFileMode(path); + const UnixFileMode others = UnixFileMode.GroupRead | UnixFileMode.GroupWrite + | UnixFileMode.OtherRead | UnixFileMode.OtherWrite; + if ((mode & others) != 0) + logger.Warn("Core", $"⚠️ SICHERHEIT: {name} ist auch für Gruppe/andere zugänglich " + + $"({mode}). Empfohlen: chmod 600 \"{path}\"."); + } + catch (Exception ex) + { + logger.Warn("Core", $"Rechte von {name} nicht prüfbar: {ex.Message}"); + } + } + } + + /// Warnt, wenn der DB-Connection-String keine TLS-Option (SslMode) enthält. Der String wird NICHT geloggt. + private static void WarnIfDbTlsNotEnforced(IServiceProvider services, LoggingService logger) + { + try + { + var conn = services.GetService()?["Database:MySqlConnectionString"] ?? string.Empty; + if (string.IsNullOrEmpty(conn)) return; + if (conn.IndexOf("sslmode", StringComparison.OrdinalIgnoreCase) < 0) + logger.Warn("Core", "⚠️ SICHERHEIT: DB-Verbindung ohne SslMode – Transportverschlüsselung nicht erzwungen. " + + "Im Connection-String 'SslMode=Required' setzen."); + } + catch { /* best-effort, darf den Start nie stören */ } + } +} diff --git a/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj b/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj new file mode 100644 index 0000000..6644819 --- /dev/null +++ b/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj @@ -0,0 +1,33 @@ + + + + + net10.0 + enable + enable + en + + + + + + + + + + + + + + + + + + <_Parameter1>IBKRTrader.Tests + + + + diff --git a/src/IBKRTrader.Modules.Supervisor/Agent/OpenRouterClient.cs b/src/IBKRTrader.Modules.Supervisor/Agent/OpenRouterClient.cs index 988a098..2d6c901 100644 --- a/src/IBKRTrader.Modules.Supervisor/Agent/OpenRouterClient.cs +++ b/src/IBKRTrader.Modules.Supervisor/Agent/OpenRouterClient.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Configuration; using System.Net.Http; using System.Text; using System.Text.Json; @@ -36,7 +37,7 @@ public sealed class OpenRouterClient : IChatCompletionClient { string? key = Environment.GetEnvironmentVariable("IBKRTRADER_OPENROUTER_KEY"); if (!string.IsNullOrWhiteSpace(key)) return key.Trim(); - string file = Path.Combine(AppContext.BaseDirectory, "openrouter.key"); + string file = AppPaths.ConfigFile("openrouter.key"); return File.Exists(file) ? File.ReadAllText(file).Trim() : null; } diff --git a/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorTools.cs b/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorTools.cs index 10e15ff..a6b4a44 100644 --- a/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorTools.cs +++ b/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorTools.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Configuration; using System.Text.Json; using IBKRTrader.Core.Analytics; using IBKRTrader.Core.Logging; @@ -27,7 +28,7 @@ public static class SupervisorTools ISupervisorCounterfactualRepository? counterfactuals = null) { var reg = new SupervisorToolRegistry(); - string logsDir = Path.Combine(AppContext.BaseDirectory, "Logs"); + string logsDir = AppPaths.Logs; reg.Register(new SupervisorTool( "query_decisions", diff --git a/src/IBKRTrader.Modules.Supervisor/Services/DossierService.cs b/src/IBKRTrader.Modules.Supervisor/Services/DossierService.cs index 5a4a810..157d953 100644 --- a/src/IBKRTrader.Modules.Supervisor/Services/DossierService.cs +++ b/src/IBKRTrader.Modules.Supervisor/Services/DossierService.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Configuration; using IBKRTrader.Core.Analytics; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Persistence; @@ -26,7 +27,7 @@ public sealed class DossierService _journal = journal; _orderEvents = orderEvents; _trades = trades; - _logsDirectory = Path.Combine(AppContext.BaseDirectory, "Logs"); + _logsDirectory = AppPaths.Logs; } /// Jüngste Signale (gruppiert über das Entscheidungsjournal), neueste zuerst. diff --git a/tests/IBKRTrader.Tests/Configuration/AppPathsTests.cs b/tests/IBKRTrader.Tests/Configuration/AppPathsTests.cs new file mode 100644 index 0000000..0b0ccee --- /dev/null +++ b/tests/IBKRTrader.Tests/Configuration/AppPathsTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using IBKRTrader.Core.Configuration; + +namespace IBKRTrader.Tests.Configuration; + +/// +/// Die Ablageorte müssen auf beiden Plattformen bestimmbar und beschreibbar sein. Bisher lag alles +/// neben der Binärdatei – unter /opt hat der Dienstbenutzer dort keinen Schreibzugriff, der +/// Dienst wäre beim ersten Logeintrag gescheitert. +/// +[Trait("cat", "unit")] +public class AppPathsTests +{ + [Fact] + public void AlleVerzeichnisse_SindErmittelbarUndExistieren() + { + // Der Zugriff legt sie an – ein Dienst darf nicht daran scheitern, dass ein Verzeichnis fehlt. + foreach (var dir in new[] { AppPaths.Config, AppPaths.Data, AppPaths.Logs }) + { + dir.Should().NotBeNullOrWhiteSpace(); + Directory.Exists(dir).Should().BeTrue($"AppPaths muss \"{dir}\" anlegen"); + } + } + + [Fact] + public void Verzeichnisse_SindTatsaechlichBeschreibbar() + { + // Die Auflösung prüft den Schreibzugriff durch einen echten Schreibversuch. Dieser Test + // haelt fest, dass das Ergebnis stimmt – eine reine Attributpruefung waere hier wertlos, + // weil unter Linux Besitzer/Gruppe/Modus und unter Windows die ACL entscheiden. + foreach (var dir in new[] { AppPaths.Config, AppPaths.Data, AppPaths.Logs }) + { + var probe = Path.Combine(dir, $"test-{Guid.NewGuid():N}.tmp"); + File.WriteAllText(probe, "x"); + File.Delete(probe); + } + } + + [Fact] + public void ConfigFile_LiegtImKonfigurationsverzeichnis() + { + var path = AppPaths.ConfigFile("master.key"); + + Path.GetDirectoryName(path).Should().Be(AppPaths.Config.TrimEnd(Path.DirectorySeparatorChar)); + Path.GetFileName(path).Should().Be("master.key"); + } + + [Fact] + public void DataPath_LiegtImDatenverzeichnis() + { + AppPaths.DataPath("Backups").Should().StartWith(AppPaths.Data); + } + + [Fact] + public void Describe_NenntAlleDreiOrte() + { + // Wird beim Start geloggt – im Betrieb muss sichtbar sein, wohin geschrieben wird. + var text = AppPaths.Describe(); + + text.Should().Contain("config=").And.Contain("data=").And.Contain("logs="); + } +}