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:
@@ -41,6 +41,9 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<!-- Die Host-Zusammenstellung liegt in IBKRTrader.Hosting und wird mit dem kopflosen
|
||||||
|
Daemon geteilt; von dort kommen Core und Module transitiv mit. -->
|
||||||
|
<ProjectReference Include="src\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
|
||||||
<ProjectReference Include="src\IBKRTrader.Core\IBKRTrader.Core.csproj" />
|
<ProjectReference Include="src\IBKRTrader.Core\IBKRTrader.Core.csproj" />
|
||||||
<ProjectReference Include="src\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
|
<ProjectReference Include="src\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
|
||||||
<ProjectReference Include="src\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
|
<ProjectReference Include="src\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Project Path="IBKRTrader.App.csproj" />
|
<Project Path="IBKRTrader.App.csproj" />
|
||||||
<Project Path="src/IBKRTrader.Core/IBKRTrader.Core.csproj" />
|
<Project Path="src/IBKRTrader.Core/IBKRTrader.Core.csproj" />
|
||||||
|
<Project Path="src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj" />
|
||||||
|
<Project Path="src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj" />
|
||||||
<Project Path="src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj" />
|
<Project Path="src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj" />
|
||||||
<Project Path="src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj" />
|
<Project Path="src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj" />
|
||||||
<Project Path="src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj" />
|
<Project Path="src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj" />
|
||||||
|
|||||||
+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.Logging;
|
||||||
using IBKRTrader.Core.Modularity;
|
using IBKRTrader.Core.Modularity;
|
||||||
using IBKRTrader.Core.Persistence;
|
|
||||||
using IBKRTrader.Core.Persistence.Ef;
|
|
||||||
using IBKRTrader.Core.Security;
|
|
||||||
using IBKRTrader.Core.Settings;
|
using IBKRTrader.Core.Settings;
|
||||||
using IBKRTrader.Core.Time;
|
|
||||||
using IBKRTrader.Core.Trading;
|
using IBKRTrader.Core.Trading;
|
||||||
using IBKRTrader.Core.Trading.Ibkr;
|
|
||||||
using IBKRTrader.Core.Workers;
|
using IBKRTrader.Core.Workers;
|
||||||
using IBKRTrader.Core.Workers.BuiltIn;
|
using IBKRTrader.Hosting;
|
||||||
using IBKRTrader.Modules.Accounting;
|
|
||||||
using IBKRTrader.Modules.CongressTrading;
|
|
||||||
using IBKRTrader.Modules.Supervisor;
|
|
||||||
using IBKRTrader.UI;
|
using IBKRTrader.UI;
|
||||||
using IBKRTrader.UI.Views;
|
using IBKRTrader.UI.Views;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -25,6 +12,14 @@ using Microsoft.Extensions.Hosting;
|
|||||||
|
|
||||||
namespace IBKRTrader;
|
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
|
internal static class Program
|
||||||
{
|
{
|
||||||
public static IHost? AppHost { get; private set; }
|
public static IHost? AppHost { get; private set; }
|
||||||
@@ -39,47 +34,12 @@ internal static class Program
|
|||||||
return;
|
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();
|
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()
|
AppHostBuilder.RunStartupChecks(AppHost.Services);
|
||||||
.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>());
|
|
||||||
|
|
||||||
// Host starten → alle Worker/Services (IHostedService) laufen an.
|
// Host starten → alle Worker/Services (IHostedService) laufen an.
|
||||||
AppHost.Start();
|
AppHost.Start();
|
||||||
@@ -96,80 +56,15 @@ internal static class Program
|
|||||||
AppHost.StopAsync().GetAwaiter().GetResult();
|
AppHost.StopAsync().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Registriert alle Core-Services im DI-Container.</summary>
|
/// <summary>Nur die Dienste, die es ohne Oberfläche nicht gibt.</summary>
|
||||||
private static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration)
|
private static void RegisterShellServices(IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// EF-Core-Persistenz (Connection aus appsettings.Local.json).
|
services.AddSingleton<ShellUiHost>();
|
||||||
services.AddCorePersistence(new DatabaseOptions
|
services.AddSingleton<IModuleUiHost>(sp => sp.GetRequiredService<ShellUiHost>());
|
||||||
{
|
services.AddSingleton<LauncherForm>();
|
||||||
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
|
/// <summary>Registriert die Core-Views (Dashboard, Workers, Logs, Settings) bei der Shell.</summary>
|
||||||
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>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Registriert die Core-Views (Logs, Settings, Workers) bei der Shell.</summary>
|
|
||||||
private static void RegisterCoreViews(IModuleUiHost uiHost, IServiceProvider sp)
|
private static void RegisterCoreViews(IModuleUiHost uiHost, IServiceProvider sp)
|
||||||
{
|
{
|
||||||
uiHost.RegisterView(new ModuleView
|
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>
|
/// <summary>
|
||||||
/// Headless-Smoke-Test: baut den Host, registriert Views + Modul-UI und konstruiert jede
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
private static int RunSmokeUi()
|
private static int RunSmokeUi()
|
||||||
{
|
{
|
||||||
ApplicationConfiguration.Initialize();
|
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()
|
AppHostBuilder.RunStartupChecks(host.Services);
|
||||||
.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>());
|
|
||||||
|
|
||||||
var uiHost = host.Services.GetRequiredService<ShellUiHost>();
|
var uiHost = host.Services.GetRequiredService<ShellUiHost>();
|
||||||
RegisterCoreViews(uiHost, host.Services);
|
RegisterCoreViews(uiHost, host.Services);
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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 IBKRTrader.Core.Time;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ public class LoggingService
|
|||||||
private readonly object _jsonlLock = new();
|
private readonly object _jsonlLock = new();
|
||||||
|
|
||||||
private static readonly string LogBaseDir =
|
private static readonly string LogBaseDir =
|
||||||
Path.Combine(AppContext.BaseDirectory, "Logs");
|
AppPaths.Logs;
|
||||||
|
|
||||||
// ─── Konfiguration ────────────────────────────────────────────────────────
|
// ─── Konfiguration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using IBKRTrader.Core.Configuration;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ namespace IBKRTrader.Core.Settings;
|
|||||||
public class SettingsService
|
public class SettingsService
|
||||||
{
|
{
|
||||||
private static readonly string SettingsPath =
|
private static readonly string SettingsPath =
|
||||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json");
|
AppPaths.ConfigFile("settings.json");
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using IBKRTrader.Core.Configuration;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using IBKRTrader.Core.Time;
|
using IBKRTrader.Core.Time;
|
||||||
using IBKRTrader.Core.Persistence.Ef;
|
using IBKRTrader.Core.Persistence.Ef;
|
||||||
@@ -34,7 +35,7 @@ public class BackupWorker : WorkerBase
|
|||||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var timestamp = AppTimeZone.Now.ToString("yyyy-MM-dd_HH-mm");
|
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 dbDir = Path.Combine(backupRoot, "DB");
|
||||||
var logsDir = Path.Combine(backupRoot, "Logs");
|
var logsDir = Path.Combine(backupRoot, "Logs");
|
||||||
|
|
||||||
@@ -166,7 +167,7 @@ public class BackupWorker : WorkerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var srcLogs = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
var srcLogs = AppPaths.Logs;
|
||||||
if (!Directory.Exists(srcLogs)) return;
|
if (!Directory.Exists(srcLogs)) return;
|
||||||
|
|
||||||
var destDir = Path.Combine(logsBackupDir, timestamp);
|
var destDir = Path.Combine(logsBackupDir, timestamp);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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})";
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"Database": {
|
||||||
|
"MySqlConnectionString": ""
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -36,7 +37,7 @@ public sealed class OpenRouterClient : IChatCompletionClient
|
|||||||
{
|
{
|
||||||
string? key = Environment.GetEnvironmentVariable("IBKRTRADER_OPENROUTER_KEY");
|
string? key = Environment.GetEnvironmentVariable("IBKRTRADER_OPENROUTER_KEY");
|
||||||
if (!string.IsNullOrWhiteSpace(key)) return key.Trim();
|
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;
|
return File.Exists(file) ? File.ReadAllText(file).Trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using IBKRTrader.Core.Configuration;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using IBKRTrader.Core.Analytics;
|
using IBKRTrader.Core.Analytics;
|
||||||
using IBKRTrader.Core.Logging;
|
using IBKRTrader.Core.Logging;
|
||||||
@@ -27,7 +28,7 @@ public static class SupervisorTools
|
|||||||
ISupervisorCounterfactualRepository? counterfactuals = null)
|
ISupervisorCounterfactualRepository? counterfactuals = null)
|
||||||
{
|
{
|
||||||
var reg = new SupervisorToolRegistry();
|
var reg = new SupervisorToolRegistry();
|
||||||
string logsDir = Path.Combine(AppContext.BaseDirectory, "Logs");
|
string logsDir = AppPaths.Logs;
|
||||||
|
|
||||||
reg.Register(new SupervisorTool(
|
reg.Register(new SupervisorTool(
|
||||||
"query_decisions",
|
"query_decisions",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using IBKRTrader.Core.Configuration;
|
||||||
using IBKRTrader.Core.Analytics;
|
using IBKRTrader.Core.Analytics;
|
||||||
using IBKRTrader.Core.Logging;
|
using IBKRTrader.Core.Logging;
|
||||||
using IBKRTrader.Core.Persistence;
|
using IBKRTrader.Core.Persistence;
|
||||||
@@ -26,7 +27,7 @@ public sealed class DossierService
|
|||||||
_journal = journal;
|
_journal = journal;
|
||||||
_orderEvents = orderEvents;
|
_orderEvents = orderEvents;
|
||||||
_trades = trades;
|
_trades = trades;
|
||||||
_logsDirectory = Path.Combine(AppContext.BaseDirectory, "Logs");
|
_logsDirectory = AppPaths.Logs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Jüngste Signale (gruppiert über das Entscheidungsjournal), neueste zuerst.</summary>
|
/// <summary>Jüngste Signale (gruppiert über das Entscheidungsjournal), neueste zuerst.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using IBKRTrader.Core.Configuration;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Tests.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Die Ablageorte müssen auf beiden Plattformen bestimmbar und beschreibbar sein. Bisher lag alles
|
||||||
|
/// neben der Binärdatei – unter <c>/opt</c> hat der Dienstbenutzer dort keinen Schreibzugriff, der
|
||||||
|
/// Dienst wäre beim ersten Logeintrag gescheitert.
|
||||||
|
/// </summary>
|
||||||
|
[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=");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user