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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 23:09:33 +02:00
co-authored by Claude Opus 5
parent 8b9b993d1d
commit 9c7e19149c
19 changed files with 819 additions and 222 deletions
@@ -0,0 +1,95 @@
namespace IBKRTrader.Core.Configuration;
/// <summary>
/// Die Verzeichnisse, in die die Anwendung schreibt.
///
/// <para><b>Warum das nötig wurde:</b> Bisher lag alles neben der Binärdatei – <c>Logs/</c>,
/// <c>Backups/</c>, <c>settings.json</c>, <c>master.key</c>. Unter Windows ist das üblich. Auf
/// Linux liegt eine Anwendung typischerweise unter <c>/opt</c> oder <c>/usr/local</c>, und dort
/// hat der Dienstbenutzer <b>keinen Schreibzugriff</b>. Der Dienst wäre beim ersten Logeintrag
/// gescheitert.</para>
///
/// <para><b>Auflösung, in dieser Reihenfolge:</b></para>
/// <list type="number">
/// <item>Umgebungsvariable (<c>IBKRTRADER_CONFIG_DIR</c>, <c>_DATA_DIR</c>, <c>_LOG_DIR</c>) –
/// hat immer Vorrang, damit ein Betreiber die Ablage frei bestimmen kann.</item>
/// <item>Das Verzeichnis der Binärdatei, <b>wenn dort geschrieben werden darf</b>. Das hält das
/// bisherige Verhalten unter Windows und beim Entwickeln unter Linux unverändert.</item>
/// <item>Sonst die FHS-Konvention: <c>/etc/ibkrtrader</c>, <c>/var/lib/ibkrtrader</c>,
/// <c>/var/log/ibkrtrader</c>.</item>
/// </list>
///
/// <para>Die Prüfung läuft einmal beim ersten Zugriff; die Verzeichnisse werden dabei angelegt.</para>
/// </summary>
public static class AppPaths
{
private const string AppFolder = "ibkrtrader";
private static readonly Lazy<string> _config = new(() => Resolve("IBKRTRADER_CONFIG_DIR", $"/etc/{AppFolder}"));
private static readonly Lazy<string> _data = new(() => Resolve("IBKRTRADER_DATA_DIR", $"/var/lib/{AppFolder}"));
private static readonly Lazy<string> _logs = new(() => Resolve("IBKRTRADER_LOG_DIR", $"/var/log/{AppFolder}", "Logs"));
/// <summary>Konfiguration und Schlüsselmaterial: <c>settings.json</c>, <c>master.key</c>, <c>openrouter.key</c>.</summary>
public static string Config => _config.Value;
/// <summary>Veränderliche Daten: <c>Backups/</c>.</summary>
public static string Data => _data.Value;
/// <summary>Logdateien (Textlog je Modul und JSONL).</summary>
public static string Logs => _logs.Value;
/// <summary>Vollständiger Pfad einer Konfigurationsdatei.</summary>
public static string ConfigFile(string fileName) => Path.Combine(Config, fileName);
/// <summary>Vollständiger Pfad unterhalb des Datenverzeichnisses.</summary>
public static string DataPath(string relative) => Path.Combine(Data, relative);
/// <summary>Kurzfassung für den Startlog – damit im Betrieb sichtbar ist, wohin geschrieben wird.</summary>
public static string Describe() => $"config={Config}, data={Data}, logs={Logs}";
/// <param name="envVar">Umgebungsvariable, die alles überstimmt.</param>
/// <param name="fhsFallback">FHS-Pfad, wenn neben der Binärdatei nicht geschrieben werden darf.</param>
/// <param name="localSubDir">
/// Unterverzeichnis im Binärverzeichnis. Für Logs ist das <c>Logs/</c>; Konfiguration und Daten
/// lagen bisher direkt daneben und bleiben dort, damit bestehende Installationen unverändert
/// weiterlaufen.
/// </param>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -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 ────────────────────────────────────────────────────────
@@ -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()
{
@@ -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);
+29
View File
@@ -0,0 +1,29 @@
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Daemon;
/// <summary>
/// Diagnose: öffnet die Datenbank aus <c>settings.json</c> und gibt die Serverversion aus.
/// Gebraucht für das EF-<c>ServerVersion</c>-Pinning; läuft ohne Host und ohne Oberfläche.
/// </summary>
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;
}
}
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Kopfloser Betrieb: Trading-Kern, Worker, Accounting, Supervisor, REST und MCP ohne
Oberfläche. Das ist der Einstiegspunkt für den Linux-Dienst (systemd). -->
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>IBKRTrader.Daemon</AssemblyName>
<RootNamespace>IBKRTrader.Daemon</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<!-- BEWUSST NICHT InvariantGlobalization: das Image braucht ICU (libicu / icu-data-full).
Zwei Dinge hängen daran und würden sonst lautlos falsch werden:
1. AppTimeZone löst Windows-Zeitzonen-IDs über TimeZoneInfo.TryConvertWindowsIdToIanaId
auf. Ohne ICU schlägt das fehl – eine Instanz mit einer Altkonfiguration
("W. Europe Standard Time") würde auf die Systemzeitzone zurückfallen, also je nach
Container auf UTC. Genau die Verschiebung, die wir in L1b 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" würde
wieder "1,234.56" – in einem Dokument, das als prüfbare Aufstellung gilt. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+79
View File
@@ -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;
/// <summary>
/// Kopfloser Einstiegspunkt: Trading-Kern, Worker, Accounting, Supervisor, REST-API und MCP-Light
/// laufen ohne Oberfläche. Das ist die Betriebsform auf Linux (systemd).
///
/// <para>Der Host kommt unverändert aus <see cref="AppHostBuilder"/> – dieselbe Zusammenstellung,
/// die auch die Desktop-Shell startet. Hier fehlt lediglich alles, was ein Fenster braucht.</para>
/// </summary>
internal static class Program
{
private static async Task<int> 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<LoggingService>();
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<IModule> 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})";
}
+11
View File
@@ -0,0 +1,11 @@
{
"Database": {
"MySqlConnectionString": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Warning"
}
}
}
+252
View File
@@ -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;
/// <summary>
/// Baut den Anwendungs-Host: Konfiguration, Persistenz, Core-Dienste, Worker und Module –
/// <b>ohne jeden Bezug zur Oberfläche</b>.
///
/// <para>Bewusst ein eigenes Projekt: derselbe Host trägt den kopflosen Linux-Dienst
/// (<c>IBKRTrader.Daemon</c>) und die Desktop-Shell. Läge die Zusammenstellung wie bisher in der
/// WinForms-<c>Program.cs</c>, 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.</para>
/// </summary>
public static class AppHostBuilder
{
/// <summary>Die in dieser Ausbaustufe aktiven Module.</summary>
public static IReadOnlyList<IModule> CreateModules() =>
[
new CongressTradingModule(),
new AccountingModule(),
new SupervisorModule()
];
/// <summary>
/// Stellt den Host zusammen. <paramref name="configureExtra"/> erlaubt der jeweiligen Shell,
/// ihre eigenen Dienste (Fensterverwaltung, Launcher) zu ergänzen – der Daemon übergibt nichts.
/// </summary>
public static IHost Build(IReadOnlyList<IModule> modules,
Action<IServiceCollection, IConfiguration>? 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();
/// <summary>
/// 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.
/// </summary>
public static void RunStartupChecks(IServiceProvider services)
{
var logger = services.GetRequiredService<LoggingService>();
// 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<WebApiService>()
.SetEngine(services.GetRequiredService<WorkerEngine>());
}
/// <summary>Registriert alle Core-Services im DI-Container.</summary>
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<LoggingService>();
services.AddSingleton<CoreSettingsService>(); // core_settings via EF
services.AddSingleton<IBKRGatewayService>();
services.AddSingleton<IBKRMarketDataRepository>();
services.AddSingleton<BudgetService>();
services.AddSingleton<TradeHistoryService>();
services.AddSingleton<AIModelService>();
// Datenfundament für Analyse/Forensik (Supervisor): Entscheidungsjournal + Order-Events.
services.AddSingleton<IDecisionJournal, EfDecisionJournal>();
services.AddSingleton<IOrderEventLog, EfOrderEventLog>();
// Trading-Kern
services.AddSingleton<DashboardService>();
services.AddSingleton<IRiskService, RiskService>();
services.AddSingleton<IPortfolioService, PortfolioService>();
services.AddSingleton<IExecutionService, ExecutionService>();
// 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<IbkrBrokerClient>();
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<IbkrBrokerClient>());
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<IbkrBrokerClient>());
}
else
{
services.AddSingleton<NullBrokerClient>();
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<NullBrokerClient>());
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<NullBrokerClient>());
}
// Core-Worker/Services
services.AddSingleton<BackupWorker>();
services.AddSingleton<WebserverService>();
services.AddSingleton<WebApiService>();
services.AddSingleton<IBKRInstrumentSyncWorker>();
services.AddSingleton<IBKRPriceHistoryWorker>();
// Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable<IWorker>.
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebserverService>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
// Lebenszyklus über den Generic Host (jeder Worker ist ein IHostedService).
services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>());
services.AddHostedService(sp => sp.GetRequiredService<WebserverService>());
services.AddHostedService(sp => sp.GetRequiredService<WebApiService>());
services.AddHostedService(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
services.AddHostedService(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
services.AddSingleton<WorkerEngine>();
}
/// <summary>
/// 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.
/// </summary>
private static void ConfigureAppTimeZone(IServiceProvider services, LoggingService logger)
{
var configured = services.GetRequiredService<SettingsService>().Settings.Trading.ApplicationTimeZoneId;
AppTimeZone.Configure(configured, msg => logger.Warn("Core", msg));
logger.Info("Core", $"Betriebszeitzone: {AppTimeZone.CurrentId} (Persistenz bleibt UTC).");
}
/// <summary>
/// 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.
/// </summary>
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).");
}
/// <summary>
/// Warnt, wenn Schlüsseldateien für andere Nutzer lesbar sind.
///
/// <para>Nur auf Unix sinnvoll: Windows-ACLs übertragen sich nicht beim Kopieren einer Datei
/// auf einen Linux-Host, und eine <c>master.key</c> mit Standardrechten (644) ist dort für
/// jeden lokalen Nutzer lesbar. Nur eine Warnung – die Rechte gehören dem Betreiber.</para>
/// </summary>
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}");
}
}
}
/// <summary>Warnt, wenn der DB-Connection-String keine TLS-Option (SslMode) enthält. Der String wird NICHT geloggt.</summary>
private static void WarnIfDbTlsNotEnforced(IServiceProvider services, LoggingService logger)
{
try
{
var conn = services.GetService<IConfiguration>()?["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 */ }
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Plattformneutral: enthält den kompletten Anwendungs-Host OHNE Oberfläche. Wird sowohl vom
kopflosen Linux-Daemon als auch von den Desktop-Shells verwendet, damit beide dieselbe
Zusammenstellung starten und nicht auseinanderlaufen. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<!-- systemd-Integration: sd_notify (Type=notify) und journald-taugliches Logformat.
Ausserhalb von systemd ein No-Op, stoert die Windows-Shell also nicht. -->
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IBKRTrader.Core\IBKRTrader.Core.csproj" />
<ProjectReference Include="..\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
<ProjectReference Include="..\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
<ProjectReference Include="..\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>IBKRTrader.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -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;
}
@@ -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",
@@ -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;
}
/// <summary>Jüngste Signale (gruppiert über das Entscheidungsjournal), neueste zuerst.</summary>