diff --git a/src/PolyTrader.App.Avalonia/Views/SettingsWindow.axaml.cs b/src/PolyTrader.App.Avalonia/Views/SettingsWindow.axaml.cs index 95b0ec4..4caff08 100644 --- a/src/PolyTrader.App.Avalonia/Views/SettingsWindow.axaml.cs +++ b/src/PolyTrader.App.Avalonia/Views/SettingsWindow.axaml.cs @@ -39,6 +39,11 @@ namespace PolyTrader.App.Avalonia.Views private DeploymentcenterOptions? _dcOptions; private WebUiOptions _webOptions = new(); + // #561: Stand der Datei beim Laden. Das WebUI schreibt dieselbe Datei; ohne diesen Vergleich + // ueberschriebe "Speichern" eine zwischenzeitliche Aenderung still mit dem alten Stand. + private DateTime? _loadedStamp; + private bool _settingsReadable = true; + public SettingsWindow() => AvaloniaXamlLoader.Load(this); public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state, @@ -89,16 +94,44 @@ namespace PolyTrader.App.Avalonia.Views private void ReloadServerSettings() { - _settings = ServerSettings.Load(SettingsPath); - this.FindControl("editorServer")!.Show(_settings); - Status("Einstellungen geladen."); + // Streng lesen (#561): ServerSettings.Load lieferte bei einer unlesbaren Datei still Vorgaben, + // und "Speichern" haette dann Lizenzschluessel und Token mit leeren Werten ueberschrieben. + try + { + _settings = new ServerSettingsFile(SettingsPath).Read(); + _settingsReadable = true; + _loadedStamp = SettingsStamp(); + this.FindControl("editorServer")!.Show(_settings); + Status("Einstellungen geladen."); + } + catch (Exception ex) + { + _settingsReadable = false; + this.FindControl("editorServer")!.Show(null); + Status($"Einstellungen nicht lesbar – Speichern gesperrt: {ex.Message}"); + } } private void SaveServerSettings() { try { - _settings.Save(SettingsPath); + if (!_settingsReadable) + { + Status("Nicht gespeichert: Die Einstellungsdatei war beim Laden nicht lesbar."); + return; + } + + new ServerSettingsFile(SettingsPath).Update(fresh => + { + // Innerhalb der Dateisperre verglichen - dazwischen kann kein anderer Schreiber kommen. + if (SettingsStamp() != _loadedStamp) + throw new InvalidOperationException( + "Die Datei wurde seit dem Laden geändert, z. B. über das WebUI. Nicht gespeichert – " + + "bitte „Neu laden“ und die Änderung wiederholen."); + CopyEditableFields(_settings, fresh); + }); + _loadedStamp = SettingsStamp(); _vpn?.ReloadSettings(); _watchdog?.ReloadSettings(); _logger?.Info("Server-Einstellungen gespeichert und Dienste neu geladen."); @@ -110,6 +143,42 @@ namespace PolyTrader.App.Avalonia.Views } } + private static DateTime? SettingsStamp() => + File.Exists(SettingsPath) ? File.GetLastWriteTimeUtc(SettingsPath) : null; + + /// + /// Übernimmt die im Editor bearbeitbaren Felder. Ausgeblendete Geheimnisse ([Browsable(false)]: + /// Watchdog-Token, Lizenzschlüssel) bleiben so, wie sie frisch in der Datei stehen – sie haben + /// eigene Setz-Dialoge. + /// + private static void CopyEditableFields(ServerSettings from, ServerSettings to) + { + foreach (var p in typeof(ServerSettings).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)) + { + if (!p.CanRead || !p.CanWrite || p.GetIndexParameters().Length > 0) continue; + if (System.Reflection.CustomAttributeExtensions.GetCustomAttribute(p)?.Browsable == false) continue; + if (System.Reflection.CustomAttributeExtensions.GetCustomAttribute(p) != null) continue; + p.SetValue(to, p.GetValue(from)); + } + } + + /// + /// Schreibt genau ein Feld (#561), statt das ganze beim Öffnen geladene Objekt zurückzuschreiben - + /// das hätte eine zwischenzeitliche WebUI-Änderung still rückgängig gemacht. Der Ladestand rückt nur + /// mit, wenn außer diesem Schreibvorgang niemand die Datei angefasst hat; sonst meldet „Speichern“ + /// die fremde Änderung weiterhin. + /// + private void WriteSingleField(Action change) + { + bool untouched = false; + new ServerSettingsFile(SettingsPath).Update(s => + { + untouched = SettingsStamp() == _loadedStamp; + change(s); + }); + if (untouched) _loadedStamp = SettingsStamp(); + } + // ===== Polymarket Accounts ===== private void LoadAccounts() @@ -266,10 +335,11 @@ namespace PolyTrader.App.Avalonia.Views { // Protect() gibt ohne Master-Key den Klartext unverändert zurück – kein stiller // Sicherheitsverlust, es wird unten darauf hingewiesen. - _settings.WatchdogToken = string.IsNullOrWhiteSpace(token) + string newToken = string.IsNullOrWhiteSpace(token) ? string.Empty : SecretProtection.Protect(token.Trim()); - _settings.Save(SettingsPath); + WriteSingleField(s => s.WatchdogToken = newToken); + _settings.WatchdogToken = newToken; _watchdog?.ReloadSettings(); this.FindControl("editorServer")!.Show(_settings); @@ -341,10 +411,11 @@ namespace PolyTrader.App.Avalonia.Views try { - _settings.LicenseKey = string.IsNullOrWhiteSpace(key) + string newKey = string.IsNullOrWhiteSpace(key) ? string.Empty : SecretProtection.Protect(key.Trim()); - _settings.Save(SettingsPath); + WriteSingleField(s => s.LicenseKey = newKey); + _settings.LicenseKey = newKey; this.FindControl("editorServer")!.Show(_settings); if (_settings.LicenseKey.Length == 0) diff --git a/src/PolyTrader.Web/Configuration/ServerSettingsCatalog.cs b/src/PolyTrader.Web/Configuration/ServerSettingsCatalog.cs new file mode 100644 index 0000000..08f0a9e --- /dev/null +++ b/src/PolyTrader.Web/Configuration/ServerSettingsCatalog.cs @@ -0,0 +1,270 @@ +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"); + } + } +} diff --git a/src/PolyTrader.Web/Endpoints/ServerSettingsEndpoints.cs b/src/PolyTrader.Web/Endpoints/ServerSettingsEndpoints.cs new file mode 100644 index 0000000..c6c5ca1 --- /dev/null +++ b/src/PolyTrader.Web/Endpoints/ServerSettingsEndpoints.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PolyTrader.Core.Configuration; +using PolyTrader.Web.Configuration; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; + +namespace PolyTrader.Web.Endpoints +{ + /// + /// Server-Einstellungen ohne Geheimnisse (#561). Was angezeigt und geändert werden darf, legt + /// fest. + /// + public static class ServerSettingsEndpoints + { + public sealed record UpdateServerSettingsRequest(Dictionary? Values); + + public static IEndpointRouteBuilder MapServerSettingsEndpoints(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/api/settings/server"); + + group.MapGet("/", (IServiceProvider sp) => + { + var file = sp.GetService(); + if (file == null) + return Results.Problem("Die Einstellungsdatei ist nicht verfügbar.", statusCode: StatusCodes.Status503ServiceUnavailable); + + try + { + return Results.Ok(Describe(file.Read())); + } + catch (Exception ex) + { + return Results.Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError); + } + }); + + // Alles oder nichts: Erst werden alle Werte geprüft, dann wird in einem Zug geschrieben. Ein + // halb übernommener Satz Einstellungen wäre schwerer zu verstehen als ein abgelehnter. + group.MapPost("/", (UpdateServerSettingsRequest? req, IServiceProvider sp, TerminalLogger logger) => + { + if (req?.Values == null || req.Values.Count == 0) + return Results.BadRequest(new { error = "Keine Werte übergeben: {\"values\": {\"Feld\": Wert}}." }); + + var parsed = new List<(SettingDescriptor Field, object Value)>(); + var errors = new List(); + + foreach (var (key, raw) in req.Values) + { + if (ServerSettingsCatalog.DesktopOnly.TryGetValue(key, out var reason)) + { + logger.Warning($"🌐 WebUI: Änderung an {key} abgelehnt – nur im Desktop ({reason})."); + return Results.Json(new { error = $"{key} lässt sich nur im Desktop ändern ({reason}). Es wurde nichts geändert." }, + statusCode: StatusCodes.Status403Forbidden); + } + + var field = ServerSettingsCatalog.Find(key); + if (field == null || !field.Editable) + { + errors.Add($"{key}: im WebUI nicht vorhanden"); + continue; + } + + var (value, error) = field.Parse!(raw); + if (error != null) errors.Add($"{field.Label}: {error}"); + else parsed.Add((field, value!)); + } + + if (errors.Count > 0) + return Results.BadRequest(new { error = "Nichts gespeichert. " + string.Join(" · ", errors), errors }); + + var file = sp.GetService(); + ServerSettings? written = null; + var failed = PersistenceGuard.Write(file != null, + () => written = file!.Update(s => { foreach (var (f, v) in parsed) f.Assign!(s, v); }), + logger, "Server-Einstellungen"); + if (failed != null) return failed; + + // Den laufenden Stand nachziehen. Das Singleton lesen WSS-Client, Alchemy-Listener und der + // VPN-Check des Watchdogs; VPN- und Watchdog-Dienst halten eigene, aus der Datei geladene Kopien. + var live = sp.GetService(); + if (live != null) + foreach (var (f, v) in parsed) f.Assign!(live, v); + sp.GetService()?.ReloadSettings(); + sp.GetService()?.ReloadSettings(); + + logger.Info("🌐 WebUI: Server-Einstellungen geändert – " + + string.Join(", ", parsed.Select(p => $"{p.Field.Key}={p.Value}"))); + + return Results.Ok(new + { + success = true, + changed = parsed.Select(p => new { key = p.Field.Key, label = p.Field.Label, requiresRestart = p.Field.RequiresRestart }).ToList(), + restartRequired = parsed.Any(p => p.Field.RequiresRestart), + settings = Describe(written!) + }); + }); + + return routes; + } + + internal static object Describe(ServerSettings s) => new + { + managedIn = "desktop", + sections = ServerSettingsCatalog.Fields + .GroupBy(f => f.Category) + .Select(g => new + { + title = g.Key, + fields = g.Select(f => new + { + key = f.Key, + label = f.Label, + description = f.Description, + kind = f.Kind.ToString().ToLowerInvariant(), + value = f.Get(s), + options = f.Options, + min = f.Min, + max = f.Max, + editable = f.Editable, + requiresRestart = f.RequiresRestart + }).ToList() + }).ToList(), + secrets = ServerSettingsCatalog.SecretStatus(s).Select(x => new { label = x.Label, status = x.Status }).ToList() + }; + } +} diff --git a/src/PolyTrader.Web/Endpoints/SettingsEndpoints.cs b/src/PolyTrader.Web/Endpoints/SettingsEndpoints.cs index c451905..efc6973 100644 --- a/src/PolyTrader.Web/Endpoints/SettingsEndpoints.cs +++ b/src/PolyTrader.Web/Endpoints/SettingsEndpoints.cs @@ -47,6 +47,9 @@ namespace PolyTrader.Web.Endpoints group.MapPost("/webui", RejectDesktopOnly); group.MapPost("/webui/generate-token", RejectDesktopOnly); + // #561: Server-Einstellungen ohne Geheimnisse. + routes.MapServerSettingsEndpoints(); + return routes; } diff --git a/src/PolyTrader.Web/wwwroot/css/app.css b/src/PolyTrader.Web/wwwroot/css/app.css index 8296835..77b94f3 100644 --- a/src/PolyTrader.Web/wwwroot/css/app.css +++ b/src/PolyTrader.Web/wwwroot/css/app.css @@ -1181,3 +1181,76 @@ tr:hover td { font-size: 14px; font-weight: 500; } + +/* Server-Einstellungen (#561) */ +.settings-section { + margin-bottom: 18px; +} + +.settings-section-title { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid var(--border); + padding-bottom: 4px; + margin-bottom: 4px; +} + +.setting-field { + display: grid; + grid-template-columns: minmax(180px, 280px) 1fr; + gap: 4px 20px; + align-items: center; + padding: 8px 0; +} + +.setting-field .form-desc { + grid-column: 2; + margin: 0; +} + +.setting-field .form-input { + max-width: 320px; +} + +.setting-readonly { + font-family: var(--font-mono); + font-size: 12px; + color: var(--muted); + word-break: break-all; +} + +.effect-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 10px; + border: 1px solid var(--border); + color: var(--muted); + font-size: 10.5px; + font-weight: 600; + vertical-align: middle; + white-space: nowrap; +} + +.effect-badge.live { + color: var(--positive); + border-color: var(--positive); +} + +.effect-badge.restart { + color: var(--warning); + border-color: var(--warning); +} + +@media (max-width: 760px) { + .setting-field { + grid-template-columns: 1fr; + } + + .setting-field .form-desc { + grid-column: 1; + } +} diff --git a/src/PolyTrader.Web/wwwroot/index.html b/src/PolyTrader.Web/wwwroot/index.html index 2d61fe1..a2d8be7 100644 --- a/src/PolyTrader.Web/wwwroot/index.html +++ b/src/PolyTrader.Web/wwwroot/index.html @@ -1182,6 +1182,29 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
…
+ + +
+
Server-Einstellungen
+
+ Nur Einstellungen ohne Geheimnisse. Felder, über die ein Schlüssel abfließen oder ein fremdes + Programm laufen könnte, stehen hier nur zur Ansicht. +
+
Lade …
+
+ + +
+
+ +
+
Geheimnisse
+
+ Werden ausschließlich im Desktop verwaltet. Hier steht nur, ob sie gesetzt sind. +
+
+
diff --git a/src/PolyTrader.Web/wwwroot/js/app.js b/src/PolyTrader.Web/wwwroot/js/app.js index cb801d3..2de9c96 100644 --- a/src/PolyTrader.Web/wwwroot/js/app.js +++ b/src/PolyTrader.Web/wwwroot/js/app.js @@ -660,6 +660,7 @@ function clearLogs() { // Settings View async function loadSettings() { + loadServerSettings(); try { const res = await fetch('/api/settings/webui'); if (!res.ok) return; @@ -676,6 +677,109 @@ async function loadSettings() { } catch (e) {} } +// ==================== Server-Einstellungen (#561) ==================== + +let serverSettingsLoaded = {}; + +async function loadServerSettings() { + const host = document.getElementById('serverSettingsSections'); + const secrets = document.getElementById('serverSecretsList'); + if (!host) return; + try { + const res = await fetch('/api/settings/server'); + if (!res.ok) throw new Error(await errorText(res)); + const data = await res.json(); + + serverSettingsLoaded = {}; + data.sections.forEach(sec => sec.fields.forEach(f => { serverSettingsLoaded[f.key] = f.value; })); + + host.innerHTML = data.sections.map(sec => ` +
+
${escapeHtml(sec.title)}
+ ${sec.fields.map(renderServerField).join('')} +
`).join(''); + secrets.innerHTML = data.secrets + .map(x => `
${escapeHtml(x.label)}
${escapeHtml(x.status)}
`) + .join(''); + } catch (e) { + host.innerHTML = `
Server-Einstellungen nicht ladbar: ${escapeHtml(e.message)}
`; + } +} + +function renderServerField(f) { + const id = 'srv_' + f.key; + const key = escapeHtml(f.key); + const badge = !f.editable + ? 'nur im Desktop' + : `${f.requiresRestart ? 'nach Neustart' : 'sofort'}`; + + let control; + if (!f.editable) { + control = `${escapeHtml(String(f.value ?? '')) || '—'}`; + } else if (f.kind === 'bool') { + control = ``; + } else if (f.kind === 'int') { + control = ``; + } else if (f.kind === 'choice') { + control = `'; + } else { + control = ``; + } + + return ` +
+ +
${control}
+
${escapeHtml(f.description)}
+
`; +} + +async function saveServerSettings() { + // Nur geaenderte Felder: Wer Zeitzone und VPN nicht angefasst hat, soll sie auch nicht mitschreiben. + const values = {}; + document.querySelectorAll('#serverSettingsSections [data-key]').forEach(el => { + const key = el.dataset.key; + let v; + if (el.type === 'checkbox') v = el.checked; + else if (el.type === 'number') v = el.value === '' ? null : Number(el.value); + else v = el.value; + if (v !== serverSettingsLoaded[key]) values[key] = v; + }); + + if (Object.keys(values).length === 0) { + showServerSettingsMsg('Keine Änderungen.', false); + return; + } + + try { + const res = await fetch('/api/settings/server', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values }) + }); + if (!res.ok) throw new Error(await errorText(res)); + const data = await res.json(); + const later = data.changed.filter(c => c.requiresRestart).map(c => c.label); + showServerSettingsMsg(`Gespeichert: ${data.changed.map(c => c.label).join(', ')}.` + + (later.length ? ` Erst nach einem Neustart wirksam: ${later.join(', ')}.` : ' Sofort wirksam.'), false); + loadServerSettings(); + } catch (e) { + showServerSettingsMsg(e.message, true); + } +} + +function showServerSettingsMsg(text, isError) { + const msg = document.getElementById('serverSettingsMsg'); + if (!msg) return; + msg.textContent = text; + msg.className = isError ? 'rate-warning' : 'form-desc'; + msg.style.margin = '0'; + msg.hidden = false; +} + // Auth async function logout() { try { diff --git a/tests/PolyTrader.Tests/WebUi/ServerSettingsEndpointTests.cs b/tests/PolyTrader.Tests/WebUi/ServerSettingsEndpointTests.cs new file mode 100644 index 0000000..f216315 --- /dev/null +++ b/tests/PolyTrader.Tests/WebUi/ServerSettingsEndpointTests.cs @@ -0,0 +1,175 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using PolyTraderSharp.Models; +using Xunit; + +namespace PolyTrader.Tests.WebUi +{ + /// + /// Server-Einstellungen über das WebUI (#561): eine Freigabeliste ohne Geheimnisse, geprüfte Werte, + /// alles oder nichts. + /// + public class ServerSettingsEndpointTests + { + private static ServerSettings WithSecrets() => new() + { + LicenseKey = "enc:v1:LIZENZ", + WatchdogToken = "enc:v1:WATCHDOG", + MullvadAccount = "1234567890", + PolygonRpcUrl = "wss://polygon-mainnet.g.alchemy.com/v2/ALCHEMY", + WatchdogUrl = "https://dc.example", + VpnLocation = "cz", + WatchdogIntervalSeconds = 60 + }; + + private static JsonElement Field(JsonElement settings, string key) => + settings.GetProperty("sections").EnumerateArray() + .SelectMany(s => s.GetProperty("fields").EnumerateArray()) + .Single(f => f.GetProperty("key").GetString() == key); + + private static bool HasField(JsonElement settings, string key) => + settings.GetProperty("sections").EnumerateArray() + .SelectMany(s => s.GetProperty("fields").EnumerateArray()) + .Any(f => f.GetProperty("key").GetString() == key); + + [Fact] + public async Task Anzeige_enthaelt_die_Freigabeliste_aber_keine_Geheimnisfelder() + { + await using var web = await WebUiTestHost.StartAsync(); + WithSecrets().Save(web.SettingsPath); + + var settings = await web.GetJsonAsync("/api/settings/server"); + + Assert.True(Field(settings, "VpnEnabled").GetProperty("editable").GetBoolean()); + Assert.False(Field(settings, "WatchdogUrl").GetProperty("editable").GetBoolean()); + Assert.False(Field(settings, "MullvadCliPath").GetProperty("editable").GetBoolean()); + Assert.True(Field(settings, "ApplicationTimeZoneId").GetProperty("requiresRestart").GetBoolean()); + Assert.False(Field(settings, "UsePolymarketWebsockets").GetProperty("requiresRestart").GetBoolean()); + + foreach (var secret in new[] { "LicenseKey", "WatchdogToken", "MullvadAccount", "PolygonRpcUrl" }) + Assert.False(HasField(settings, secret), secret); + + var secrets = settings.GetProperty("secrets").EnumerateArray() + .ToDictionary(x => x.GetProperty("label").GetString()!, x => x.GetProperty("status").GetString()!); + Assert.Equal("gesetzt", secrets["Mullvad-Account"]); + Assert.Equal("gesetzt (verschlüsselt)", secrets["Lizenzschlüssel"]); + } + + [Fact] + public async Task Aendern_schreibt_nur_die_Werte_und_zieht_den_laufenden_Stand_nach() + { + var live = WithSecrets(); + await using var web = await WebUiTestHost.StartAsync(s => s.AddSingleton(live)); + WithSecrets().Save(web.SettingsPath); + + var (status, body) = await web.PostJsonAsync("/api/settings/server", new + { + values = new { VpnEnabled = true, WatchdogIntervalSeconds = 120, VpnLocation = "cz-prg", Theme = "dunkel" } + }); + + Assert.Equal(200, status); + Assert.True(body.GetProperty("restartRequired").GetBoolean()); // Theme wirkt erst nach Neustart + + var written = ServerSettings.Load(web.SettingsPath); + Assert.True(written.VpnEnabled); + Assert.Equal(120, written.WatchdogIntervalSeconds); + Assert.Equal("cz-prg", written.VpnLocation); + Assert.Equal(AppThemeVariant.Dunkel, written.Theme); + Assert.Equal("enc:v1:LIZENZ", written.LicenseKey); + Assert.Equal("enc:v1:WATCHDOG", written.WatchdogToken); + Assert.Equal("1234567890", written.MullvadAccount); + Assert.Equal("wss://polygon-mainnet.g.alchemy.com/v2/ALCHEMY", written.PolygonRpcUrl); + + Assert.True(live.VpnEnabled); // laufender Stand + Assert.Equal(120, live.WatchdogIntervalSeconds); + } + + [Theory] + [InlineData("WatchdogUrl", "https://angreifer.example")] + [InlineData("MullvadCliPath", "/tmp/boese")] + [InlineData("LicenseKey", "neu")] + [InlineData("PolygonRpcUrl", "wss://x")] + public async Task Desktop_Felder_werden_mit_403_abgelehnt_und_nichts_geschrieben(string key, string value) + { + await using var web = await WebUiTestHost.StartAsync(); + WithSecrets().Save(web.SettingsPath); + string before = File.ReadAllText(web.SettingsPath); + + var values = new System.Collections.Generic.Dictionary { [key] = value, ["VpnEnabled"] = true }; + var (status, body) = await web.PostJsonAsync("/api/settings/server", new { values }); + + Assert.Equal(403, status); + Assert.Contains("Desktop", body.GetProperty("error").GetString()); + Assert.Equal(before, File.ReadAllText(web.SettingsPath)); + } + + [Theory] + [InlineData("VpnLocation", "cz account login 999")] // Argumente fuer das Mullvad-Programm einschleusen + [InlineData("VpnLocation", "cz;reboot")] + [InlineData("ApplicationTimeZoneId", "Mars/Olympus")] + [InlineData("WatchdogSource", "polytrader\\\" , \\\"x")] + public async Task Ungueltige_Texte_werden_abgelehnt(string key, string value) + { + await using var web = await WebUiTestHost.StartAsync(); + WithSecrets().Save(web.SettingsPath); + string before = File.ReadAllText(web.SettingsPath); + + var values = new System.Collections.Generic.Dictionary { [key] = value }; + var (status, _) = await web.PostJsonAsync("/api/settings/server", new { values }); + + Assert.Equal(400, status); + Assert.Equal(before, File.ReadAllText(web.SettingsPath)); + } + + [Fact] + public async Task Ein_ungueltiger_Wert_verhindert_auch_die_gueltigen() + { + await using var web = await WebUiTestHost.StartAsync(); + WithSecrets().Save(web.SettingsPath); + + var (status, body) = await web.PostJsonAsync("/api/settings/server", new + { + values = new { VpnEnabled = true, WatchdogIntervalSeconds = 5 } + }); + + Assert.Equal(400, status); + Assert.Contains("Nichts gespeichert", body.GetProperty("error").GetString()); + Assert.False(ServerSettings.Load(web.SettingsPath).VpnEnabled); + } + + [Fact] + public async Task Unbekanntes_Feld_leerer_Rumpf_und_gueltige_Zeitzone() + { + await using var web = await WebUiTestHost.StartAsync(); + + var (unknown, _) = await web.PostJsonAsync("/api/settings/server", new { values = new { GibtEsNicht = 1 } }); + var (empty, _) = await web.PostJsonAsync("/api/settings/server"); + Assert.Equal(400, unknown); + Assert.Equal(400, empty); + Assert.False(File.Exists(web.SettingsPath)); + + var (ok, _) = await web.PostJsonAsync("/api/settings/server", new { values = new { ApplicationTimeZoneId = "Europe/Berlin" } }); + Assert.Equal(200, ok); + Assert.Equal("Europe/Berlin", ServerSettings.Load(web.SettingsPath).ApplicationTimeZoneId); + } + + [Fact] + public async Task Unlesbare_Datei_wird_weder_angezeigt_noch_ueberschrieben() + { + await using var web = await WebUiTestHost.StartAsync(); + const string kaputt = "enc:v1:LIZENZ