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