Files
PolyTraderSharp/src/PolyTrader.App.Avalonia/Program.cs
T

566 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PolyTrader.App.Avalonia.Shell;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.DependencyInjection;
using PolyTrader.Core.Modularity;
using PolyTrader.Core.Notifications;
using PolyTrader.Core.Security;
using PolyTrader.Modules.Accounting;
using PolyTrader.Modules.CopyTrading;
using PolyTrader.Modules.ResolutionFarming;
using PolyTrader.Modules.Supervisor;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using PolyTrader.Web.Configuration;
using PolyTrader.Web.Hosting;
namespace PolyTrader.App.Avalonia
{
/// <summary>
/// Einstiegspunkt der plattformneutralen PolyTrader-Anwendung.
///
/// <para>Der Aufbau ist bewusst zweigeteilt: <see cref="BuildHost"/> stellt den Generic Host mit
/// Persistenz, Diensten und Modulen zusammen – ohne jeden Bezug zur Oberfläche. Erst
/// <see cref="Main"/> hängt Avalonia daran. Damit lässt sich derselbe Host später ohne Umbau
/// für den kopflosen Linux-Betrieb starten (Schalter <c>--headless</c>, Stufe L2).</para>
/// </summary>
internal static class Program
{
private const string ServerSettingsPath = "server_settings.xml";
/// <summary>
/// Rueckgabewert bei Konfigurationsfehlern. 78 ist EX_CONFIG aus den BSD-sysexits und
/// damit ein etablierter Wert fuer "Konfiguration unbrauchbar". Wichtig fuer den
/// Dienstbetrieb: <c>deploy/polytrader.service</c> traegt ihn in
/// <c>RestartPreventExitStatus</c> ein, damit systemd bei einem Konfigurationsfehler
/// NICHT im Zehn-Sekunden-Takt neu startet - ein fehlender Connection-String repariert
/// sich durch Neustarts nicht.
/// </summary>
private const int ExitCodeConfigurationError = 78;
[STAThread]
public static int Main(string[] args)
{
bool headless = args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase));
bool smoke = args.Any(a => string.Equals(a, "--smoke-ui", StringComparison.OrdinalIgnoreCase));
// D-2: Lizenz-CLI-Schalter (--license-status/--license-set-key/--license-deactivate)
// laufen VOR dem Host-Aufbau und beenden sich selbst - kein Sinn, dafür Trading-Dienste
// hochzufahren.
if (Licensing.LicenseCli.TryHandle(args, out int cliExitCode))
return cliExitCode;
var dcOptions = LoadDeploymentcenterOptions();
// Ohne Datenbankverbindung kann die Anwendung nichts Sinnvolles tun - jedes Repository
// haengt daran. Bis zum 26.08.2026 lief sie trotzdem los und starb erst tief im
// DI-Aufbau mit einer ArgumentException samt 50 Zeilen Stacktrace ("The string argument
// 'connectionString' cannot be empty"). Auf einem Server ist das doppelt schlecht:
// Der Betreiber sieht nicht, was fehlt, und systemd startet den Dienst wegen
// Restart=on-failure endlos neu. Deshalb hier frueh und verstaendlich abbrechen.
var connectionString = LoadDatabaseConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
{
var err = Console.Error;
err.WriteLine("FEHLER: Keine Datenbankverbindung konfiguriert.");
err.WriteLine();
err.WriteLine("Erwartet wird 'Database:MySqlConnectionString' in appsettings.Local.json");
err.WriteLine($"neben der Programmdatei ({AppContext.BaseDirectory}).");
err.WriteLine();
err.WriteLine("Beispiel:");
err.WriteLine("{");
err.WriteLine(" \"Database\": {");
err.WriteLine(" \"MySqlConnectionString\": \"Server=HOST;Port=3306;Database=polytrader;User ID=BENUTZER;Password=PASSWORT;\"");
err.WriteLine(" }");
err.WriteLine("}");
err.WriteLine();
err.WriteLine("Die Datei ist bewusst nicht versioniert (Zugangsdaten). Das Schema legt");
err.WriteLine("'dotnet ef database update' an - die Anwendung migriert nicht selbst.");
return ExitCodeConfigurationError;
}
// Konstruktionsprüfung (--smoke-ui) testet Fensteraufbau, keine Lizenzlogik - dort
// bewusst ALLE Module registrieren, sonst würde die Prüfung auf einem Build-Server
// (nie lizenziert) still einen Großteil ihrer Abdeckung verlieren.
var host = BuildHost(dcOptions, enforceLicense: !smoke, out var bootLog);
// Konstruktionspruefung der Fenster: Host NICHT starten. Sonst laufen Trading-Engine,
// WSS-Clients und Jobs gegen die echten Boersen-Endpunkte an - fuer einen reinen
// UI-Test unerwuenscht (und auf einem Build-Server schlicht falsch).
if (smoke)
{
try { return RunSmokeUi(host, bootLog); }
finally { host.Dispose(); }
}
host.Start();
try
{
if (headless)
{
bootLog.Info("Kopfloser Betrieb: keine Oberfläche, Trading-Dienste laufen. Beenden mit Strg+C.");
host.WaitForShutdown();
return 0;
}
App.Services = host.Services;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return 0;
}
finally
{
// Geordnetes Herunterfahren der Module – erst nach dem Ende der Oberfläche.
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
host.Dispose();
}
}
/// <summary>Von Avalonia (auch vom XAML-Previewer) erwartete Fabrikmethode.</summary>
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
/// <summary>
/// Lädt NUR den Deploymentcenter-Konfigurationsblock, bevor der Generic Host existiert –
/// die Lizenzprüfung (D-2) muss VOR der Modul-Liste laufen, also bevor
/// <see cref="Host.CreateDefaultBuilder"/> seine eigene <see cref="IConfiguration"/> baut.
/// Liest dieselben Dateien wie der Host später (appsettings.json + appsettings.Local.json),
/// nur eigenständig und minimal. Von <see cref="Licensing.LicenseCli"/> mitbenutzt.
/// </summary>
internal static DeploymentcenterOptions LoadDeploymentcenterOptions()
{
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Local.json", optional: true)
.Build();
var options = new DeploymentcenterOptions();
config.GetSection(DeploymentcenterOptions.SectionName).Bind(options);
return options;
}
/// <summary>
/// Liest den Connection-String, bevor der Generic Host existiert - fuer die fruehe
/// Konfigurationspruefung in <see cref="Main"/>. Liest dieselben Dateien und denselben
/// Schluessel wie der Host spaeter, damit beide nicht auseinanderlaufen koennen.
/// </summary>
internal static string LoadDatabaseConnectionString()
{
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Local.json", optional: true)
.Build();
return config["Database:MySqlConnectionString"] ?? string.Empty;
}
/// <summary>
/// Lädt die WebUI-Konfiguration (Port, LocalOnly, AuthToken) aus den Konfigurationsdateien.
/// </summary>
internal static WebUiOptions LoadWebUiOptions()
{
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Local.json", optional: true)
.Build();
var options = new WebUiOptions();
config.GetSection(WebUiOptions.SectionName).Bind(options);
return options;
}
/// <summary>
/// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die
/// aktiven Module. Kennt die Oberfläche nicht.
/// </summary>
/// <param name="enforceLicense">
/// D-2: Wenn <c>false</c> (nur <c>--smoke-ui</c>), werden immer alle Module registriert –
/// die Konstruktionsprüfung testet Fensteraufbau, keine Lizenzlogik.
/// </param>
private static IHost BuildHost(DeploymentcenterOptions dcOptions, bool enforceLicense, out TerminalLogger bootLog)
{
var serverSettings = ServerSettings.Load(ServerSettingsPath);
var logger = new TerminalLogger();
bootLog = logger;
// Anzeige-Kultur festnageln: Auf Linux richtet sich die Kultur sonst nach LANG/LC_ALL,
// das unter systemd oft gar nicht gesetzt ist - dann faellt .NET auf Invariant zurueck
// und aus "1.234,56 USDC" wird "1,234.56 USDC". Maschinen-I/O (API, Logs, CSV) laeuft
// unabhaengig davon ohnehin invariant.
var uiCulture = new System.Globalization.CultureInfo("de-DE");
System.Globalization.CultureInfo.DefaultThreadCurrentCulture = uiCulture;
System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = uiCulture;
ConfigureSecretProtection(logger);
// Selbstheilende Migration (B-12): ein noch im Klartext abgelegter Lizenzschluessel
// wird beim ersten Start mit Master-Key verschluesselt nachgezogen. server_settings.xml
// traegt neben ihm auch den Watchdog-Token - die Konvention "Secrets nur verschluesselt
// at-rest" soll dort genauso gelten wie in der Datenbank.
if (SecretProtection.IsConfigured
&& !string.IsNullOrWhiteSpace(serverSettings.LicenseKey)
&& !SecretProtection.IsEncrypted(serverSettings.LicenseKey))
{
try
{
serverSettings.LicenseKey = SecretProtection.Protect(serverSettings.LicenseKey);
serverSettings.Save(ServerSettingsPath);
logger.Info("🔐 Lizenzschlüssel lag im Klartext vor und wurde verschlüsselt nachgezogen.");
}
catch (Exception ex)
{
logger.Warning($"⚠️ Lizenzschlüssel konnte nicht verschlüsselt nachgezogen werden: {ex.Message}");
}
}
// Anzeige-Zeitzone einmalig setzen (Logs, Auswertungen). Ein unbekannter Wert ist nicht
// fatal - es wird auf die Systemzeitzone ausgewichen und gewarnt.
AppTimeZone.Configure(serverSettings.ApplicationTimeZoneId, m => logger.Warning(m));
logger.Info($"Anzeige-Zeitzone: {AppTimeZone.CurrentId}");
var allModules = new List<IPolyTraderModule>
{
new CopyTradingModule(),
new ResolutionFarmingModule(),
new SupervisorModule(),
new AccountingModule()
};
var disabled = new HashSet<string>(serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase);
// D-2: ohne gültige Lizenz startet nur die Core-Shell (Terminal, Einstellungen) - alle
// Trading-/Analyse-Module bleiben unregistriert. Kein Environment.Exit (Entscheidung
// 31.07.2026): der Schlüssel lässt sich im Einstellungsfenster nachtragen.
bool licensed = !enforceLicense || Licensing.LicenseGate.CheckUsable(dcOptions, serverSettings, logger);
if (!licensed)
{
foreach (var m in allModules) disabled.Add(m.Name);
}
var modules = allModules.Where(m => !disabled.Contains(m.Name)).ToList();
var webOptions = LoadWebUiOptions();
if (!enforceLicense)
{
// Bei Konstruktionsprüfung (--smoke-ui) kein Kestrel starten
webOptions.Enabled = false;
}
if (webOptions.Enabled)
{
string bind = webOptions.LocalOnly ? "127.0.0.1 (nur lokal)" : "0.0.0.0 (Netzwerk/VPN)";
logger.Info($"🌐 WebGUI aktiv auf Port {webOptions.Port} [{bind}].");
if (string.IsNullOrWhiteSpace(webOptions.AuthToken))
logger.Warning("⚠️ WebGUI: Kein Zugangs-Token gesetzt – Zugriff ist vollständig gesperrt.");
}
else
{
logger.Info("🌐 WebGUI deaktiviert (WebUi:Enabled = false).");
}
return Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
.ConfigureAppConfiguration((_, config) =>
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
.ConfigureServices((context, services) =>
{
services.AddSingleton(dcOptions);
services.AddSingleton(webOptions);
var databaseOptions = new DatabaseOptions
{
MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty
};
services.Configure<DatabaseOptions>(context.Configuration.GetSection(DatabaseOptions.SectionName));
services.AddCorePersistence(databaseOptions);
services.AddSingleton(serverSettings);
services.AddSingleton(logger);
services.AddSingleton<TradingState>();
services.AddSingleton<JobManager>();
services.AddSingleton<MullvadVpnService>();
services.AddHttpClient();
// Polymarket-Zugriffe teilen sich einen langlebigen Verbindungspool.
services.AddSingleton(sp => new PolymarketApiService(
sp.GetRequiredService<TerminalLogger>(), CreatePolymarketHttpClient()));
services.AddSingleton(sp => new PolymarketClobClient(
sp.GetRequiredService<TerminalLogger>(), CreatePolymarketHttpClient()));
// Benachrichtigungen: neutrale Senke, bis RocketChat/Telegram angebunden sind.
services.AddSingleton<INotificationSink>(
sp => new LogNotificationSink(sp.GetRequiredService<TerminalLogger>()));
services.AddSingleton(sp =>
{
var watchdog = new WatchdogHeartbeatService(sp.GetRequiredService<TerminalLogger>());
// D-1: erster "checks"-Eintrag - beweist die Mechanik mit einem real
// verfügbaren Signal. db/market_feed folgen, sobald die jeweiligen Dienste
// ein Gesundheitssignal nach außen geben (siehe
// docs/archiv/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, D-1).
var vpn = sp.GetRequiredService<MullvadVpnService>();
var serverSettings = sp.GetRequiredService<ServerSettings>();
watchdog.RegisterCheck("vpn", () => serverSettings.VpnEnabled
? (vpn.IsConnected, vpn.IsConnected ? null : "Mullvad VPN nicht verbunden")
: (true, null)); // VPN bewusst aus -> kein Fehlalarm
return watchdog;
});
// MUSS vor den Modulen registriert werden: hydriert die Accounts, bevor die
// Trading-Dienste anlaufen (Reihenfolge = Registrierungsreihenfolge).
services.AddHostedService<CoreStateHydrationService>();
foreach (var module in modules)
{
services.AddSingleton(module);
module.RegisterServices(services, context.Configuration);
}
// D-3: Fehler-Reporting - rein ereignisgesteuert (AppDomain.UnhandledException,
// TaskScheduler.UnobservedTaskException, TerminalLogger-Fehler), kein eigener Takt.
services.AddSingleton(sp => new DeploymentcenterErrorReporter(
sp.GetRequiredService<TerminalLogger>(), sp.GetRequiredService<DeploymentcenterOptions>()));
services.AddHostedService<MarketSyncService>();
services.AddHostedService(sp => sp.GetRequiredService<MullvadVpnService>());
services.AddHostedService(sp => sp.GetRequiredService<WatchdogHeartbeatService>());
services.AddHostedService(sp => sp.GetRequiredService<DeploymentcenterErrorReporter>());
// Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos.
services.AddSingleton<AvaloniaUiHost>();
})
.ConfigurePolyTraderWeb(webOptions)
.Build();
}
/// <summary>
/// Kopflose Konstruktionsprüfung aller registrierten Fenster – Nachfolger des
/// <c>--smoke-ui</c>-Laufs der WinForms-App, der Konstruktionsfehler zuverlässig gefangen hat.
/// Läuft ohne Fenster und ohne Message-Loop.
/// </summary>
private static int RunSmokeUi(IHost host, TerminalLogger logger)
{
// Avalonia muss initialisiert sein, damit Fenster konstruiert werden koennen.
BuildAvaloniaApp().SetupWithoutStarting();
App.Services = host.Services;
ThemeManager.Initialize(host.Services.GetRequiredService<ServerSettings>(), ServerSettingsPath, logger);
var uiHost = host.Services.GetRequiredService<AvaloniaUiHost>();
CoreViews.Register(uiHost, host.Services);
foreach (var module in host.Services.GetServices<IPolyTraderModule>())
module.RegisterUi(uiHost, host.Services);
ModuleViews.Register(uiHost, host.Services);
ViewIcons.AssignDefaults(uiHost);
int failures = 0;
Console.WriteLine("=== Smoke-UI: Seiten-Konstruktion (Avalonia) ===");
foreach (var view in uiHost.Views)
{
try
{
_ = view.CreateView();
Console.WriteLine($"[OK] {view.Id} ({view.Title})");
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] {view.Id}: {ex.GetType().Name}: {ex.Message}");
}
}
// Die Startseite steckt seit der Einfenster-Shell in der Schleife oben (core.start);
// separat geprueft wird nur noch das Shell-Fenster selbst.
try
{
_ = new Views.ShellWindow(uiHost, host.Services);
Console.WriteLine("[OK] ShellWindow konstruiert");
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] ShellWindow: {ex.GetType().Name}: {ex.Message}");
}
try
{
_ = new Views.ShutdownConfirmWindow();
Console.WriteLine("[OK] ShutdownConfirmWindow konstruiert");
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] ShutdownConfirmWindow: {ex.GetType().Name}: {ex.Message}");
}
// Der Einstellungs-Editor wird aus den Attributen des Modells aufgebaut. Ein Fenster kann
// fehlerfrei konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen -
// deshalb hier gegen die tatsaechliche Feldzahl pruefen.
try
{
var sections = ViewModels.SettingsModelBuilder.Build(new ServerSettings());
int fieldCount = sections.Sum(x => x.Fields.Count);
if (sections.Count == 0 || fieldCount == 0)
{
failures++;
Console.WriteLine("[FEHLER] Einstellungs-Editor: keine Felder aus ServerSettings ermittelt " +
"(Category-/DisplayName-Attribute verloren?).");
}
else
{
Console.WriteLine($"[OK] Einstellungs-Editor: {sections.Count} Abschnitte, {fieldCount} Felder " +
$"({string.Join(", ", sections.Select(x => x.Title))})");
}
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] Einstellungs-Editor: {ex.GetType().Name}: {ex.Message}");
}
// Der Editor waehlt den Feldtyp nach dem Eigenschaftstyp. Fehlt ein Typ in dieser
// Zuordnung, faellt er still auf "nur Anzeige" zurueck - die Einstellung ist dann in
// der Oberflaeche nicht mehr aenderbar, ohne dass irgendwo ein Fehler auftaucht.
// Genau so waren saemtliche Dezimal-Einstellungen (Positionsgroessen, Preisgrenzen,
// Kill-Switch-Schwellen) unbemerkt zu reinen Anzeigen geworden. Deshalb hier gegen die
// Modelle mit den meisten Zahlenfeldern pruefen.
foreach (object model in new object[]
{
new PolyTraderSharp.Models.CopyTradingAccountSettings(),
new PolyTrader.Modules.ResolutionFarming.Models.RfSettings(),
new ServerSettings()
})
{
try
{
// Listen (z.B. "Deaktivierte Module") stehen bewusst nur zur Anzeige - die
// pflegt der Modul-Tab des Dashboards. Geprueft werden Einzelwerte.
var notEditable = ViewModels.SettingsModelBuilder.Build(model)
.SelectMany(sec => sec.Fields)
.Where(f => f.IsEditable
&& f is ViewModels.ReadOnlySettingsField
&& !typeof(System.Collections.IEnumerable).IsAssignableFrom(f.ValueType))
.Select(f => f.Label)
.ToList();
if (notEditable.Count > 0)
{
failures++;
Console.WriteLine($"[FEHLER] Einstellungs-Editor ({model.GetType().Name}): " +
$"{notEditable.Count} beschreibbare Felder werden nur angezeigt: " +
string.Join(", ", notEditable));
}
else
{
Console.WriteLine($"[OK] Einstellungs-Editor ({model.GetType().Name}): " +
"alle beschreibbaren Felder sind auch bearbeitbar");
}
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] Einstellungs-Editor ({model.GetType().Name}): {ex.GetType().Name}: {ex.Message}");
}
}
// Farbschema: jeder Token muss in BEIDEN Varianten aufloesen. Ein Tippfehler im
// Schluessel faellt sonst nicht auf - ThemeManager.Brush liefert dann still Grau.
string[] themeKeys =
{
"AppBgBrush", "AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush",
"AppBorderBrush", "AppDividerBrush",
"AppTextBrush", "AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush",
"AppAccentBrush", "AppAccentAltBrush", "AppAccentStrongBrush", "AppAccentSoftBrush",
"AppPositiveBrush", "AppNegativeBrush", "AppWarningBrush",
"AppNotifyWarnBrush", "AppNotifyErrorBrush",
"AppTradeLossBrush", "AppTradeSmallWinBrush", "AppTradeBigWinBrush",
"AppToggleActiveBrush", "AppToggleSellOnlyBrush", "AppToggleInactiveBrush",
"AppChatUserBrush", "AppChatAgentBrush"
};
foreach (var variant in new[] { global::Avalonia.Styling.ThemeVariant.Light,
global::Avalonia.Styling.ThemeVariant.Dark })
{
var missing = themeKeys.Where(k =>
!(global::Avalonia.Application.Current!.TryFindResource(k, variant, out object? v) && v is global::Avalonia.Media.IBrush))
.ToList();
if (missing.Count > 0)
{
failures++;
Console.WriteLine($"[FEHLER] Farbschema „{variant}“: fehlende Farben: {string.Join(", ", missing)}");
}
else
{
Console.WriteLine($"[OK] Farbschema „{variant}“: alle {themeKeys.Length} Farben vorhanden");
}
}
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
return failures == 0 ? 0 : 1;
}
/// <summary>
/// HttpClient für die Polymarket-APIs: langlebiger Verbindungspool und die Header, die die
/// Gegenstelle erwartet (identisch zur bisherigen WinForms-Zusammenstellung).
/// </summary>
private static System.Net.Http.HttpClient CreatePolymarketHttpClient() =>
new(new System.Net.Http.SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 100
})
{
DefaultRequestHeaders = { { "User-Agent", "py_clob_client" }, { "Accept", "*/*" } }
};
/// <summary>
/// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte <c>master.key</c>, sonst wird
/// einer erzeugt – D-5/D-10) und aktiviert die at-rest-Verschlüsselung. Muss VOR jeder
/// Credential-Entschlüsselung laufen.
/// </summary>
private static void ConfigureSecretProtection(TerminalLogger logger) =>
ConfigureSecretProtection(msg => logger.Warning(msg), msg => logger.Info(msg));
/// <summary>
/// Wie oben, nur ohne <see cref="TerminalLogger"/>: die Lizenz-CLI läuft VOR dem
/// Host-Aufbau und hat noch keinen. Bis zum 26.08.2026 fehlte dieser Aufruf dort ganz –
/// <c>--license-set-key</c> legte den Schlüssel deshalb im Klartext ab, obwohl der Code
/// <see cref="SecretProtection.Protect"/> aufrief: ohne Master-Key reicht der Aufruf den
/// Klartext unverändert durch (Befund B-12 des Linux-Sichttests).
/// </summary>
internal static void ConfigureSecretProtection(Action<string> warn, Action<string>? info = null)
{
string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
string? masterKey = MasterKeyResolver.Resolve(
keyFile,
() => Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY"),
onGenerated: _ => warn(
"🔐 Kein Master-Key gefunden – ein neuer wurde erzeugt und in \"master.key\" " +
"gespeichert (App-Ordner, gitignored). WICHTIG: Diese Datei SOFORT separat sichern " +
"(Passwort-Manager/Offline-Backup) – ihr Verlust macht jedes ab jetzt verschlüsselte " +
"Secret unwiederbringlich unlesbar."),
onWarning: msg => warn($"⚠️ {msg}"));
SecretProtection.Configure(masterKey);
if (SecretProtection.IsConfigured)
info?.Invoke("🔐 Secret-Verschlüsselung aktiv – Account-Credentials werden at-rest verschlüsselt (AES-256-GCM).");
else
warn("⚠️ SICHERHEIT: Kein POLYTRADER_MASTER_KEY gesetzt – Account-Credentials liegen UNVERSCHLÜSSELT in der DB.");
}
}
}