using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; using PolyTrader.Core.Security; using PolyTraderSharp.Models; namespace PolyTrader.Web.Configuration { internal enum SettingKind { Bool, Text, Int, Choice, ReadOnly } /// Ein Feld der Server-Einstellungen, wie das WebUI es zeigt und – wenn freigegeben – ändert. internal sealed class SettingDescriptor { public required string Key { get; init; } public required string Category { get; init; } public required string Label { get; init; } public required string Description { get; init; } public required SettingKind Kind { get; init; } public required Func Get { get; init; } /// Erst nach einem Neustart wirksam. Belegt am Code, nicht am Feldnamen geraten. public bool RequiresRestart { get; init; } public string[]? Options { get; init; } public int? Min { get; init; } public int? Max { get; init; } /// Prüft einen Wert aus dem WebUI. Ohne Prüfung ist das Feld nicht bearbeitbar. public Func? Parse { get; init; } public Action? Assign { get; init; } public bool Editable => Parse != null && Assign != null; } /// /// Welche Server-Einstellungen das WebUI zeigt und ändern darf (#561). /// /// Eine Freigabeliste, keine Reflection. Der Desktop zeigt per Reflection alles, was /// ServerSettings hat. Übernähme das WebUI das, stünde jedes künftige Feld automatisch im Netz, /// auch ein Geheimnis. Hier erscheint ein neues Feld erst, wenn es jemand bewusst einträgt. /// /// Entscheidung Richard, 13.09.2026: Geheimnisse ausschließlich im Desktop. Dazu zählen hier auch /// zwei Felder, die selbst kein Geheimnis sind, aber eines preisgeben oder fremden Code starten /// könnten. Siehe . /// internal static class ServerSettingsCatalog { /// Mullvad-Standort: "cz", "cz-prg", "cz-prg-wg-101". Siehe , Feld VpnLocation. private static readonly Regex VpnLocationPattern = new("^[a-z]{2}(-[a-z0-9]{2,16}){0,2}$", RegexOptions.CultureInvariant); private static readonly Regex IdentifierPattern = new("^[A-Za-z0-9._-]{1,64}$", RegexOptions.CultureInvariant); /// /// Felder, die das WebUI nie ändert – mit Grund. Ein Änderungsversuch wird mit 403 abgelehnt und /// protokolliert; alle anderen unbekannten Felder mit 400. /// public static readonly IReadOnlyDictionary DesktopOnly = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["MullvadAccount"] = "Geheimnis", ["PolygonRpcUrl"] = "enthält den Alchemy-API-Key", ["WatchdogToken"] = "Geheimnis", ["LicenseKey"] = "Geheimnis", ["WatchdogUrl"] = "an diese Adresse schickt PolyTrader den Watchdog-Token – wer sie ändert, bekommt ihn", ["MullvadCliPath"] = "dieses Programm wird ausgeführt" }; public static readonly IReadOnlyList Fields = new List { // ---- Anzeige ------------------------------------------------------------------ new() { Key = nameof(ServerSettings.Theme), Category = "Anzeige", Label = "Farbschema (Desktop)", Description = "Hell, Dunkel oder wie das Betriebssystem. Betrifft die Desktop-Oberfläche; " + "sie übernimmt es beim nächsten Start.", Kind = SettingKind.Choice, Options = Enum.GetNames(), RequiresRestart = true, Get = s => s.Theme.ToString(), Parse = v => ParseChoice(v, Enum.GetNames()), Assign = (s, v) => s.Theme = Enum.Parse((string)v) }, new() { Key = nameof(ServerSettings.ApplicationTimeZoneId), Category = "Anzeige", Label = "Zeitzone", Description = "IANA-Schreibweise, z. B. Europe/Berlin. Leer = Zeitzone des Systems. Verschiebt " + "Tagesgrenzen in Logs und Auswertungen und wird nur beim Start gesetzt.", Kind = SettingKind.Text, RequiresRestart = true, Get = s => s.ApplicationTimeZoneId, Parse = ParseTimeZone, Assign = (s, v) => s.ApplicationTimeZoneId = (string)v }, // ---- Mullvad VPN -------------------------------------------------------------- new() { Key = nameof(ServerSettings.VpnEnabled), Category = "Mullvad VPN", Label = "VPN-Rotation aktiv", Description = "Automatische VPN-Verbindung und -Rotation über das Mullvad-Programm.", Kind = SettingKind.Bool, Get = s => s.VpnEnabled, Parse = ParseBool, Assign = (s, v) => s.VpnEnabled = (bool)v }, new() { Key = nameof(ServerSettings.VpnLocation), Category = "Mullvad VPN", Label = "VPN-Standort", Description = "Mullvad-Standortkennung, z. B. cz oder cz-prg. Leer = keine Vorgabe.", Kind = SettingKind.Text, Get = s => s.VpnLocation, // Der Wert landet in der Kommandozeile des Mullvad-Programms ("relay set location "), // als EINE Zeichenkette. Ohne diese Pruefung liessen sich zusaetzliche Argumente anhaengen. Parse = v => ParseText(v, VpnLocationPattern, allowEmpty: true, "nur Kleinbuchstaben, Ziffern und Bindestriche, z. B. cz oder cz-prg"), Assign = (s, v) => s.VpnLocation = (string)v }, new() { Key = nameof(ServerSettings.MullvadCliPath), Category = "Mullvad VPN", Label = "Pfad des Mullvad-Programms", Description = "Nur im Desktop änderbar: Dieses Programm wird ausgeführt.", Kind = SettingKind.ReadOnly, Get = s => s.MullvadCliPath }, // ---- Blockchain Listener -------------------------------------------------------- new() { Key = nameof(ServerSettings.EnableBlockchainListener), Category = "Blockchain Listener", Label = "Alchemy-WSS-Listener aktiv", Description = "Schnellere On-Chain-Erkennung von Signalen über Alchemy. Wird nur beim Start " + "des Dienstes ausgewertet.", Kind = SettingKind.Bool, RequiresRestart = true, Get = s => s.EnableBlockchainListener, Parse = ParseBool, Assign = (s, v) => s.EnableBlockchainListener = (bool)v }, // ---- Polymarket WebSockets ------------------------------------------------------ new() { Key = nameof(ServerSettings.UsePolymarketWebsockets), Category = "Polymarket WebSockets", Label = "Polymarket-WSS aktiv", Description = "Live-Kurse und Auto-Redeem über den Polymarket-WebSocket. Der Dienst prüft das " + "in jeder Runde.", Kind = SettingKind.Bool, Get = s => s.UsePolymarketWebsockets, Parse = ParseBool, Assign = (s, v) => s.UsePolymarketWebsockets = (bool)v }, // ---- Watchdog ------------------------------------------------------------------ new() { Key = nameof(ServerSettings.WatchdogEnabled), Category = "Watchdog Monitoring", Label = "Heartbeat senden", Description = "Heartbeat an den Watchdog des Deploymentcenters. Ein nicht erreichbarer Watchdog " + "beeinträchtigt PolyTrader nie.", Kind = SettingKind.Bool, Get = s => s.WatchdogEnabled, Parse = ParseBool, Assign = (s, v) => s.WatchdogEnabled = (bool)v }, new() { Key = nameof(ServerSettings.WatchdogUrl), Category = "Watchdog Monitoring", Label = "Deploymentcenter-URL", Description = "Nur im Desktop änderbar: An diese Adresse geht der Watchdog-Token.", Kind = SettingKind.ReadOnly, Get = s => s.WatchdogUrl }, new() { Key = nameof(ServerSettings.WatchdogSource), Category = "Watchdog Monitoring", Label = "Source", Description = "Dienst-Kennung im Watchdog, z. B. polytrader.", Kind = SettingKind.Text, Get = s => s.WatchdogSource, Parse = v => ParseText(v, IdentifierPattern, allowEmpty: false, "1–64 Zeichen: Buchstaben, Ziffern, . _ -"), Assign = (s, v) => s.WatchdogSource = (string)v }, new() { Key = nameof(ServerSettings.WatchdogInstance), Category = "Watchdog Monitoring", Label = "Instance", Description = "Instanz-Kennung, falls mehrere PolyTrader laufen, z. B. prod-1.", Kind = SettingKind.Text, Get = s => s.WatchdogInstance, Parse = v => ParseText(v, IdentifierPattern, allowEmpty: false, "1–64 Zeichen: Buchstaben, Ziffern, . _ -"), Assign = (s, v) => s.WatchdogInstance = (string)v }, new() { Key = nameof(ServerSettings.WatchdogIntervalSeconds), Category = "Watchdog Monitoring", Label = "Heartbeat-Intervall (Sekunden)", Description = "Sende-Takt. Der Watchdog leitet daraus seine Frist ab. Empfehlung 60 s.", Kind = SettingKind.Int, Min = 10, Max = 3600, Get = s => s.WatchdogIntervalSeconds, Parse = v => ParseInt(v, 10, 3600), Assign = (s, v) => s.WatchdogIntervalSeconds = (int)v } }; public static SettingDescriptor? Find(string key) => Fields.FirstOrDefault(f => string.Equals(f.Key, key, StringComparison.OrdinalIgnoreCase)); /// /// Ob die Geheimnisse gesetzt sind – nie ihr Wert. OpenRouter- und Master-Key werden dafür nicht /// gelesen, nur ihr Vorhandensein geprüft. /// public static IEnumerable<(string Label, string Status)> SecretStatus(ServerSettings s) { yield return ("Mullvad-Account", IsSet(s.MullvadAccount)); yield return ("Polygon-RPC-URL mit Alchemy-Key", IsSet(s.PolygonRpcUrl)); yield return ("Watchdog-Token", s.WatchdogTokenStatus); yield return ("Lizenzschlüssel", s.LicenseKeyStatus); bool openRouter = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY")) || File.Exists(Path.Combine(AppContext.BaseDirectory, "openrouter.key")); yield return ("OpenRouter-Key (Supervisor)", openRouter ? "gesetzt" : "nicht gesetzt"); yield return ("Master-Key", SecretProtection.IsConfigured ? "aktiv – Kontodaten liegen verschlüsselt" : "nicht aktiv – Kontodaten liegen unverschlüsselt"); } private static string IsSet(string? value) => string.IsNullOrWhiteSpace(value) ? "nicht gesetzt" : "gesetzt"; // ---- Prüfungen ---------------------------------------------------------------------- private static (object?, string?) ParseBool(JsonElement v) => v.ValueKind switch { JsonValueKind.True => (true, null), JsonValueKind.False => (false, null), _ => (null, "erwartet an oder aus") }; private static (object?, string?) ParseInt(JsonElement v, int min, int max) { if (v.ValueKind != JsonValueKind.Number || !v.TryGetInt32(out int i)) return (null, "erwartet eine ganze Zahl"); return i < min || i > max ? (null, $"erlaubt sind {min} bis {max}") : (i, null); } private static (object?, string?) ParseText(JsonElement v, Regex pattern, bool allowEmpty, string rule) { if (v.ValueKind != JsonValueKind.String) return (null, "erwartet Text"); string text = v.GetString()!.Trim(); if (text.Length == 0) return allowEmpty ? (text, null) : (null, "darf nicht leer sein"); return pattern.IsMatch(text) ? (text, null) : (null, rule); } private static (object?, string?) ParseChoice(JsonElement v, string[] options) { if (v.ValueKind != JsonValueKind.String) return (null, "erwartet eine der Auswahlen"); string? match = options.FirstOrDefault(o => string.Equals(o, v.GetString(), StringComparison.OrdinalIgnoreCase)); return match != null ? (match, null) : (null, "erlaubt: " + string.Join(", ", options)); } private static (object?, string?) ParseTimeZone(JsonElement v) { if (v.ValueKind != JsonValueKind.String) return (null, "erwartet Text"); string id = v.GetString()!.Trim(); if (id.Length == 0) return (id, null); return TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? (id, null) : (null, $"„{id}“ ist keine bekannte Zeitzone"); } } }