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="); + } +}