using System; using System.IO; using System.Linq; using System.Net.Http; using System.Threading.Channels; using PolyTrader.Core.Persistence; using PolyTrader.Core.Security; using System.Windows.Forms; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using PolyTrader.Core.Configuration; using PolyTrader.Core.DependencyInjection; using PolyTrader.Core.Modularity; using PolyTrader.Modules.CopyTrading; using PolyTrader.Modules.CopyTrading.Persistence; using PolyTrader.Modules.ResolutionFarming; using PolyTrader.Modules.Supervisor; using PolyTrader.Modules.Accounting; using PolyTraderSharp.Models; using PolyTraderSharp.Services; namespace PolyTraderSharp; internal static class Program { public static IHost? AppHost { get; private set; } /// Pfad der (gitignorierten) Server-Settings-Datei – Quelle u.a. für die Modul-Aktivierung. private const string ServerSettingsPath = "server_settings.xml"; [STAThread] private static void Main(string[] args) { // Config-Migration aus mongoexport-JSON (Ordner, Default "MongoDB") -> MySQL. if (args.Length > 0 && string.Equals(args[0], "--migrate-json", StringComparison.OrdinalIgnoreCase)) { var folder = args.Length > 1 ? args[1] : "MongoDB"; RunConfigMigrationFromJson(folder); return; } // Readback-Verifikation der migrierten Config aus MySQL. Kein UI-Start. if (args.Length > 0 && string.Equals(args[0], "--verify-mysql", StringComparison.OrdinalIgnoreCase)) { RunVerifyMySql(); return; } // Headless-Smoke-Test der UI: konstruiert jede View + den Launcher (ohne Message-Loop // und ohne Trading-/WSS-Services). Fängt Laufzeit-Konstruktionsfehler ab. Kein Fenster. if (args.Length > 0 && string.Equals(args[0], "--smoke-ui", StringComparison.OrdinalIgnoreCase)) { Environment.ExitCode = RunSmokeUi(); return; } // Diagnose: gibt die MySQL-Serverversion aus (für das ServerVersion-Pinning). Kein UI. if (args.Length > 0 && string.Equals(args[0], "--db-version", StringComparison.OrdinalIgnoreCase)) { RunDbVersion(); return; } ApplicationConfiguration.Initialize(); // Server-Settings früh laden (VOR der DI-Registrierung), damit deaktivierte Module gar nicht // erst geladen werden. Dieselbe Instanz wird als Singleton weitergereicht (eine Quelle). var serverSettings = PolyTraderSharp.Models.ServerSettings.Load(ServerSettingsPath); // Alle bekannten Module; „modules" enthält nur die aktiven (nicht in DisabledModules). var allModules = new System.Collections.Generic.List { new CopyTradingModule(), new ResolutionFarmingModule(), new SupervisorModule(), new AccountingModule() }; var disabledModules = new System.Collections.Generic.HashSet( serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase); var modules = allModules.Where(m => !disabledModules.Contains(m.Name)).ToList(); AppHost = Host.CreateDefaultBuilder() // Config immer neben der EXE suchen (nicht im Arbeitsverzeichnis), damit die App // auch beim Start aus bin/ oder per Doppelklick ihre appsettings findet. .UseContentRoot(AppContext.BaseDirectory) // Host.CreateDefaultBuilder lädt appsettings.Local.json NICHT (nur appsettings.json // + appsettings.{Environment}.json). Die gitignorierte Local-Datei hält aber die // MySQL-Connection – daher hier explizit ergänzen, sonst bleibt sie leer. .ConfigureAppConfiguration((context, config) => config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false)) .ConfigureServices(delegate(HostBuilderContext context, IServiceCollection services) { var databaseOptions = new DatabaseOptions { MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty }; services.Configure(context.Configuration.GetSection(DatabaseOptions.SectionName)); services.AddCorePersistence(databaseOptions); services.AddSingleton(serverSettings); services.AddSingleton(); services.AddSingleton(delegate(IServiceProvider sp) { TerminalLogger requiredService2 = sp.GetRequiredService(); var httpHandler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2), MaxConnectionsPerServer = 100 }; return new PolymarketApiService(requiredService2, new HttpClient(httpHandler) { DefaultRequestHeaders = { { "User-Agent", "py_clob_client" }, { "Accept", "*/*" } } }); }); services.AddSingleton(delegate(IServiceProvider sp) { TerminalLogger requiredService2 = sp.GetRequiredService(); var httpHandler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2), MaxConnectionsPerServer = 100 }; return new PolymarketClobClient(requiredService2, new HttpClient(httpHandler) { DefaultRequestHeaders = { { "User-Agent", "py_clob_client" }, { "Accept", "*/*" } } }); }); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // MUSS als erster HostedService laufen: hydriert den State, bevor die // Trading-Services gegen einen leeren State anlaufen. services.AddHostedService(); // Module registrieren ihre eigenen Services/Channels/State selbst. foreach (var module in modules) { services.AddSingleton(module); module.RegisterServices(services, context.Configuration); } services.AddHostedService(); services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService()); services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService()); services.AddSingleton(); services.AddTransient(); }).Build(); // F1 (Sicherheit): Master-Key VOR jeder Credential-Entschlüsselung (Hydration) laden. // Quelle: Umgebungsvariable POLYTRADER_MASTER_KEY, sonst gitignorierte master.key im App-Ordner. ConfigureSecretProtection(AppHost.Services.GetRequiredService()); // F5 (Sicherheit): warnen, wenn die (remote) DB-Verbindung keine TLS-Erzwingung hat. WarnIfDbTlsNotEnforced(AppHost.Services); try { // Trade-Nummerierung fortsetzen: höchste bestehende TradeId serverseitig lesen // (M3: kein Full-Table-Load mehr über Find(_=>true).Max()). Nur wenn CopyTrading aktiv ist – // sonst sind dessen Services (CopyTradingState/…) gar nicht registriert. if (modules.Any(m => m is CopyTradingModule)) { var copyState = AppHost.Services.GetRequiredService(); var tradeLog = AppHost.Services.GetRequiredService(); copyState.TotalCopyTrades = tradeLog.GetMaxTradeId(); } } catch (Exception ex) { // M3: NICHT still schlucken. Bliebe der Zähler bei 0, kollidierten neue TradeIds mit // historischen (PK ist ValueGeneratedNever) → stiller Verlust aller Session-Trades. var log = AppHost.Services.GetRequiredService(); log.Error($"❌ KRITISCH: TradeId-Fortsetzung konnte nicht initialisiert werden: {ex.Message}. " + "Trade-Nummerierung ist NICHT sicher – neue Trades könnten mit bestehenden kollidieren. Bitte DB prüfen."); } AppHost.Start(); // F1: einmalige, idempotente Verschlüsselung evtl. vorhandener Klartext-Credentials in der DB. ReencryptAccountCredentials(AppHost.Services); // Shell-Views registrieren (Core-App-Views; Module folgen via module.RegisterUi). var uiHost = AppHost.Services.GetRequiredService(); var viewServices = AppHost.Services; uiHost.RegisterView(new PolyTrader.Core.Modularity.ModuleView { Id = "core.terminal", Title = "Terminal", Group = "Core", Order = 10, CreateForm = () => { var view = new PolyTraderSharp.Ui.Views.TerminalView(); view.Initialize(viewServices.GetRequiredService()); return view; } }); uiHost.RegisterView(new PolyTrader.Core.Modularity.ModuleView { Id = "core.settings", Title = "Server Settings", Group = "Core", Order = 20, CreateForm = () => { var view = new PolyTraderSharp.Ui.Views.SettingsView(); view.Initialize( viewServices.GetRequiredService(), viewServices.GetRequiredService(), viewServices.GetRequiredService(), viewServices.GetRequiredService(), viewServices.GetRequiredService()); return view; } }); uiHost.RegisterView(new PolyTrader.Core.Modularity.ModuleView { Id = "core.jobs", Title = "Server Jobs", Group = "Core", Order = 30, CreateForm = () => { var view = new PolyTraderSharp.Ui.Views.JobsView(); view.Initialize(viewServices.GetRequiredService()); return view; } }); uiHost.RegisterView(new PolyTrader.Core.Modularity.ModuleView { Id = "core.dashboard", Title = "Dashboard", Group = "Core", Order = 5, CreateForm = () => { var view = new PolyTraderSharp.Ui.Views.DashboardView(); var config = viewServices.GetRequiredService(); // Alle Module (auch deaktivierte) fürs Dashboard: läuft-Status = ist in dieser Session geladen. var moduleInfos = allModules .Select(m => new PolyTraderSharp.Ui.Views.ModuleActivationInfo( m.Name, modules.Contains(m), m.GetActivationBlocker(config))) .ToList(); view.Initialize( viewServices.GetRequiredService(), viewServices.GetRequiredService(), moduleInfos, ServerSettingsPath); return view; } }); // Modul-UI registrieren (Module steuern ihre Views selbst bei). foreach (var module in modules) { module.RegisterUi(uiHost, viewServices); } // Menü-Icons zentral zuweisen: Die App kennt alle Fenster-Ressourcen; Core und Module // referenzieren sie nicht. So erscheint jedes Fenster mit Icon in der obersten Menüleiste. AssignMenuIcons(uiHost); var launcher = AppHost.Services.GetRequiredService(); Application.Run(launcher); AppHost.StopAsync().GetAwaiter().GetResult(); } /// /// Weist den registrierten Views (Core + Module) ihr Menü-Icon aus den App-Ressourcen zu – /// gemappt über die stabile View-ID. Bereits gesetzte Icons bleiben erhalten. /// private static void AssignMenuIcons(PolyTraderSharp.Ui.ShellUiHost uiHost) { var map = new System.Collections.Generic.Dictionary { ["core.dashboard"] = Properties.Resources.dashboard, ["core.settings"] = Properties.Resources.setting_tools, ["core.terminal"] = Properties.Resources.error_log, ["core.jobs"] = Properties.Resources.system_time, ["accounting.main"] = Properties.Resources.coins_in_hand, ["copytrading.main"] = Properties.Resources.cross_reference, ["resolutionfarming.main"] = Properties.Resources.file_start_workflow, ["supervisor.main"] = Properties.Resources.emotion_batman, }; foreach (var view in uiHost.Views) if (view.Icon == null && map.TryGetValue(view.Id, out var img)) view.Icon = img; } private static void RunConfigMigrationFromJson(string folder) { var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder() .SetBasePath(System.IO.Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: true) .AddJsonFile("appsettings.Local.json", optional: true) .AddEnvironmentVariables() .Build(); var mySql = config["Database:MySqlConnectionString"] ?? string.Empty; if (string.IsNullOrWhiteSpace(mySql)) { Console.WriteLine("FEHLER: Database:MySqlConnectionString fehlt (appsettings.Local.json)."); return; } if (!System.IO.Path.IsPathRooted(folder)) folder = System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), folder); Services.ConfigMigrator.RunFromJson(folder, mySql); } private static void RunVerifyMySql() { var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder() .SetBasePath(System.IO.Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: true) .AddJsonFile("appsettings.Local.json", optional: true) .AddEnvironmentVariables() .Build(); var mySql = config["Database:MySqlConnectionString"] ?? string.Empty; if (string.IsNullOrWhiteSpace(mySql)) { Console.WriteLine("FEHLER: Database:MySqlConnectionString fehlt (appsettings.Local.json)."); return; } Services.ConfigMigrator.VerifyMySql(mySql); } private static void RunDbVersion() { var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder() .SetBasePath(System.IO.Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: true) .AddJsonFile("appsettings.Local.json", optional: true) .AddEnvironmentVariables() .Build(); var conn = config["Database:MySqlConnectionString"] ?? string.Empty; if (string.IsNullOrWhiteSpace(conn)) { Console.WriteLine("FEHLER: Database:MySqlConnectionString fehlt (appsettings.Local.json)."); return; } 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}"); } } /// /// F1: Lädt den Master-Key (POLYTRADER_MASTER_KEY, sonst gitignorierte master.key) und aktiviert die /// at-rest-Verschlüsselung. Ohne Key läuft die App wie bisher mit Klartext – mit deutlicher Warnung /// (kein stiller Sicherheitsverlust). Muss VOR jeder Credential-Entschlüsselung laufen. /// private static void ConfigureSecretProtection(TerminalLogger logger) { string? masterKey = Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY"); if (string.IsNullOrWhiteSpace(masterKey)) { string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key"); if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim(); } SecretProtection.Configure(masterKey); if (SecretProtection.IsConfigured) logger.Info("🔐 Secret-Verschlüsselung aktiv – Account-Credentials werden at-rest verschlüsselt (AES-256-GCM)."); else logger.Warning("⚠️ SICHERHEIT: Kein POLYTRADER_MASTER_KEY gesetzt – Account-Credentials liegen UNVERSCHLÜSSELT in der DB. " + "Master-Key setzen (env POLYTRADER_MASTER_KEY oder master.key), siehe docs/sicherheit."); } /// /// F5: Warnt, wenn der DB-Connection-String keine TLS-Erzwingung (SslMode) enthält. Der String /// selbst wird NICHT geloggt (enthält das Passwort) – nur das Fehlen der TLS-Option. /// private static void WarnIfDbTlsNotEnforced(IServiceProvider services) { try { var config = services.GetService(); string conn = config?["Database:MySqlConnectionString"] ?? string.Empty; if (string.IsNullOrEmpty(conn)) return; if (conn.IndexOf("sslmode", StringComparison.OrdinalIgnoreCase) < 0) { services.GetRequiredService().Warning( "⚠️ SICHERHEIT: DB-Verbindung ohne SslMode – Transportverschlüsselung zur (remote) MySQL nicht erzwungen. " + "Im Connection-String 'SslMode=Required' (oder VerifyFull) setzen, siehe docs/sicherheit."); } } catch { /* Warnung ist best-effort; darf den Start nie stören */ } } /// /// F1: Verschlüsselt einmalig/idempotent evtl. vorhandene Klartext-Credentials in der DB (Alt-Bestand /// wird beim Re-Save durch den EF-Converter verschlüsselt). Nur wenn ein Master-Key gesetzt ist. /// Fehler blockieren den Start nicht (Log); künftige Saves verschlüsseln ohnehin. /// private static void ReencryptAccountCredentials(IServiceProvider services) { if (!SecretProtection.IsConfigured) return; var logger = services.GetRequiredService(); try { var accountRepo = services.GetRequiredService(); var accounts = accountRepo.GetAll(); // Converter entschlüsselt/passt Klartext durch foreach (var acc in accounts) accountRepo.Upsert(acc); // Re-Save → Converter verschlüsselt logger.Info($"🔐 Account-Credentials at-rest gesichert ({accounts.Count} Account(s))."); } catch (Exception ex) { logger.Error($"Re-Encryption der Account-Credentials fehlgeschlagen: {ex.Message}. " + "Stimmt der Master-Key mit den bereits verschlüsselten Daten überein?"); } } /// /// Headless-Smoke-Test: baut einen minimalen Host (Persistenz + State + Modul-Registrierung /// + Shell/Launcher), hydriert den State aus MySQL und konstruiert jede registrierte View /// sowie den Launcher – ohne Message-Loop und ohne Trading-/WSS-Services zu starten. /// Gibt 0 zurück, wenn alles fehlerfrei konstruiert, sonst die Fehleranzahl. /// private static int RunSmokeUi() { ApplicationConfiguration.Initialize(); var modules = new System.Collections.Generic.List { new CopyTradingModule(), new ResolutionFarmingModule(), new SupervisorModule(), new AccountingModule() }; using var host = Host.CreateDefaultBuilder() .UseContentRoot(AppContext.BaseDirectory) .ConfigureAppConfiguration((context, config) => config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false)) .ConfigureServices((context, services) => { var databaseOptions = new DatabaseOptions { MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty }; services.Configure(context.Configuration.GetSection(DatabaseOptions.SectionName)); services.AddCorePersistence(databaseOptions); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); foreach (var module in modules) { services.AddSingleton(module); module.RegisterServices(services, context.Configuration); } services.AddSingleton(); services.AddTransient(); }).Build(); // F1: Master-Key laden, damit die Hydration verschlüsselte Credentials entschlüsseln kann. ConfigureSecretProtection(host.Services.GetRequiredService()); // State aus MySQL laden (Accounts/Trader/Settings), ohne die BackgroundServices zu starten. try { host.Services.GetRequiredService() .StartAsync(default).GetAwaiter().GetResult(); } catch (Exception ex) { Console.WriteLine($"[WARN] Hydration übersprungen: {ex.Message}"); } var uiHost = host.Services.GetRequiredService(); foreach (var module in modules) module.RegisterUi(uiHost, host.Services); int failures = 0; Console.WriteLine("=== Smoke-UI: View-Konstruktion ==="); foreach (var view in uiHost.Views) { try { using var form = view.CreateForm(); Console.WriteLine($"[OK] {view.Id} ({view.Title})"); } catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] {view.Id}: {ex.GetType().Name}: {ex.Message}"); } } try { using var launcher = host.Services.GetRequiredService(); Console.WriteLine("[OK] LauncherForm konstruiert"); } catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] LauncherForm: {ex.GetType().Name}: {ex.Message}"); } // Core-Dashboard direkt konstruieren (im Smoke nicht via uiHost registriert) - prueft das ScottPlot-Rendering headless. try { using var dash = new PolyTraderSharp.Ui.Views.DashboardView(); dash.Initialize(host.Services.GetRequiredService(), host.Services.GetRequiredService()); Console.WriteLine("[OK] core.dashboard konstruiert (inkl. Charts)"); } catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] core.dashboard: {ex.GetType().Name}: {ex.Message}"); } // Core-Views mit hand-erstellten Grid-Spalten direkt konstruieren (nicht im uiHost registriert): // faengt VS-Re-Serialisierungs-Regressionen (fallengelassene DataGridView-Spalten) ab. foreach (var (id, make) in new (string, Func)[] { ("core.jobs", () => new PolyTraderSharp.Ui.Views.JobsView()), ("core.terminal", () => new PolyTraderSharp.Ui.Views.TerminalView()), ("core.settings", () => new PolyTraderSharp.Ui.Views.SettingsView()), }) { try { using var f = make(); Console.WriteLine($"[OK] {id} konstruiert"); } catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] {id}: {ex.GetType().Name}: {ex.Message}"); } } Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ==="); return failures; } }