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:
+23
-215
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Einstiegspunkt der WinForms-Shell (Windows).
|
||||
///
|
||||
/// <para>Der Host selbst kommt aus <see cref="AppHostBuilder"/> und ist plattformneutral – hier
|
||||
/// wird er nur um Fensterverwaltung und Launcher ergänzt. Derselbe Host trägt den kopflosen
|
||||
/// Linux-Dienst <c>IBKRTrader.Daemon</c>; so kann keine der beiden Varianten eine Registrierung
|
||||
/// vergessen.</para>
|
||||
/// </summary>
|
||||
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<IModule> { 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<ShellUiHost>();
|
||||
services.AddSingleton<IModuleUiHost>(sp => sp.GetRequiredService<ShellUiHost>());
|
||||
services.AddSingleton<LauncherForm>();
|
||||
})
|
||||
.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<LoggingService>();
|
||||
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<WebApiService>()
|
||||
.SetEngine(AppHost.Services.GetRequiredService<WorkerEngine>());
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Registriert alle Core-Services im DI-Container.</summary>
|
||||
private static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration)
|
||||
/// <summary>Nur die Dienste, die es ohne Oberfläche nicht gibt.</summary>
|
||||
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<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> (nur UI/Registry).
|
||||
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>();
|
||||
services.AddSingleton<ShellUiHost>();
|
||||
services.AddSingleton<IModuleUiHost>(sp => sp.GetRequiredService<ShellUiHost>());
|
||||
services.AddSingleton<LauncherForm>();
|
||||
}
|
||||
|
||||
/// <summary>Registriert die Core-Views (Logs, Settings, Workers) bei der Shell.</summary>
|
||||
/// <summary>Registriert die Core-Views (Dashboard, Workers, Logs, Settings) bei der Shell.</summary>
|
||||
private static void RegisterCoreViews(IModuleUiHost uiHost, IServiceProvider sp)
|
||||
{
|
||||
uiHost.RegisterView(new ModuleView
|
||||
@@ -199,107 +94,20 @@ internal static class Program
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt die Betriebszeitzone dieser Instanz aus den Einstellungen. Sie gilt für Anzeige,
|
||||
/// Logdatei-Tagesgrenzen, Berichtszeiten und Buchungsperioden; gespeichert wird weiterhin UTC.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </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 App 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 = 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).");
|
||||
}
|
||||
|
||||
/// <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 */ }
|
||||
}
|
||||
|
||||
/// <summary>Diagnose: öffnet die DB (aus settings.json) und gibt die Serverversion aus. Kein UI.</summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static int RunSmokeUi()
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
var modules = new List<IModule> { 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<ShellUiHost>();
|
||||
services.AddSingleton<IModuleUiHost>(s => s.GetRequiredService<ShellUiHost>());
|
||||
services.AddSingleton<LauncherForm>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
ConfigureAppTimeZone(host.Services, host.Services.GetRequiredService<LoggingService>());
|
||||
|
||||
host.Services.GetRequiredService<WebApiService>()
|
||||
.SetEngine(host.Services.GetRequiredService<WorkerEngine>());
|
||||
AppHostBuilder.RunStartupChecks(host.Services);
|
||||
|
||||
var uiHost = host.Services.GetRequiredService<ShellUiHost>();
|
||||
RegisterCoreViews(uiHost, host.Services);
|
||||
|
||||
Reference in New Issue
Block a user