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;
}
}