WebUI: Server-Einstellungen ohne Geheimnisse bearbeiten
Umsetzung von #561. Entscheidung Richard, 13.09.2026: Alles, was keine Geheimnisse beruehrt, soll ueber das WebUI gehen; Geheimnisse ausschliesslich im Desktop. Eine ausdrueckliche Freigabeliste (ServerSettingsCatalog) statt Reflection. Der Desktop zeigt per Reflection alles, was ServerSettings hat. Uebernaehme das WebUI das, stuende jedes kuenftige Feld automatisch im Netz, auch ein Geheimnis. Bearbeitbar, je Feld mit Pruefung: - Farbschema und Zeitzone: nach Neustart - VPN an/aus und VPN-Standort: sofort - Blockchain-Listener: nach Neustart - Polymarket-WSS: sofort - Watchdog an/aus, Source, Instance und Intervall: sofort "Sofort" und "nach Neustart" sind am Code belegt, nicht am Feldnamen geraten. Zeitzone und Blockchain-Listener werden nur beim Start gelesen, das Farbschema haelt der ThemeManager. Nur im Desktop, Aenderungsversuch 403 und protokolliert: - Geheimnisse: Mullvad-Account, Polygon-RPC-URL (enthaelt den Alchemy-Key), Watchdog-Token, Lizenzschluessel - Watchdog-URL: an diese Adresse geht der Watchdog-Token, wer sie aendert, bekommt ihn - Mullvad-CLI-Pfad: dieses Programm wird ausgefuehrt Von den Geheimnissen zeigt das WebUI nur, ob sie gesetzt sind. OpenRouter- und Master-Key werden dafuer nicht gelesen. Gefunden dabei: MullvadVpnService baut die Kommandozeile als eine Zeichenkette ("relay set location <Wert>"). Der VPN-Standort nimmt deshalb nur Mullvad- Kennungen an (cz, cz-prg). "cz account login 999" wird abgelehnt, ein Test haelt das fest. Source und Instance gehen als JSON ans Deploymentcenter und sind auf Kennungszeichen begrenzt. POST prueft alle Werte zuerst und schreibt dann alles oder nichts, ueber ServerSettingsFile. Danach zieht er den laufenden Stand nach: das Singleton, MullvadVpnService.ReloadSettings und WatchdogHeartbeatService.ReloadSettings. Desktop - bisher schrieb er eine WebUI-Aenderung still zurueck: Die Einstellungen wurden einmal beim Oeffnen geladen, und "Speichern", "Watchdog-Token setzen" und "Lizenzschluessel setzen" schrieben jeweils das ganze alte Objekt zurueck. Jetzt: - Speichern vergleicht innerhalb der Dateisperre den Dateistand vom Laden und verweigert, wenn sich die Datei inzwischen geaendert hat. - Token und Lizenzschluessel schreiben nur noch ihr eigenes Feld. - Eine unlesbare Datei sperrt das Speichern, statt Vorgaben zu zeigen, die beim Speichern die Schluessel geloescht haetten. Die WebUI-Ansicht zeigt je Feld den Wirkungsvermerk und schickt nur, was geaendert wurde. Der Leak-Test legt seine Kanarienwerte jetzt auch in die Einstellungsdatei, sonst liefe die neue Route gegen eine leere Datei. Nicht durch Unit-Tests abgedeckt: die Desktop-Aenderung an SettingsWindow (Oberflaechenklasse). Sie wird mit der Sichtpruefung auf der VM abgenommen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<SettingsEditor>("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<SettingsEditor>("editorServer")!.Show(_settings);
|
||||
Status("Einstellungen geladen.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_settingsReadable = false;
|
||||
this.FindControl<SettingsEditor>("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;
|
||||
|
||||
/// <summary>
|
||||
/// Ü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.
|
||||
/// </summary>
|
||||
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<System.ComponentModel.BrowsableAttribute>(p)?.Browsable == false) continue;
|
||||
if (System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Xml.Serialization.XmlIgnoreAttribute>(p) != null) continue;
|
||||
p.SetValue(to, p.GetValue(from));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void WriteSingleField(Action<ServerSettings> 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<SettingsEditor>("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<SettingsEditor>("editorServer")!.Show(_settings);
|
||||
|
||||
if (_settings.LicenseKey.Length == 0)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
/// <summary>Ein Feld der Server-Einstellungen, wie das WebUI es zeigt und – wenn freigegeben – ändert.</summary>
|
||||
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<ServerSettings, object?> Get { get; init; }
|
||||
|
||||
/// <summary>Erst nach einem Neustart wirksam. Belegt am Code, nicht am Feldnamen geraten.</summary>
|
||||
public bool RequiresRestart { get; init; }
|
||||
|
||||
public string[]? Options { get; init; }
|
||||
public int? Min { get; init; }
|
||||
public int? Max { get; init; }
|
||||
|
||||
/// <summary>Prüft einen Wert aus dem WebUI. Ohne Prüfung ist das Feld nicht bearbeitbar.</summary>
|
||||
public Func<JsonElement, (object? Value, string? Error)>? Parse { get; init; }
|
||||
public Action<ServerSettings, object>? Assign { get; init; }
|
||||
|
||||
public bool Editable => Parse != null && Assign != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Welche Server-Einstellungen das WebUI zeigt und ändern darf (#561).
|
||||
///
|
||||
/// <para><b>Eine Freigabeliste, keine Reflection.</b> Der Desktop zeigt per Reflection alles, was
|
||||
/// <c>ServerSettings</c> 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.</para>
|
||||
///
|
||||
/// <para>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 <see cref="DesktopOnly"/>.</para>
|
||||
/// </summary>
|
||||
internal static class ServerSettingsCatalog
|
||||
{
|
||||
/// <summary>Mullvad-Standort: "cz", "cz-prg", "cz-prg-wg-101". Siehe <see cref="Fields"/>, Feld VpnLocation.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Felder, die das WebUI nie ändert – mit Grund. Ein Änderungsversuch wird mit 403 abgelehnt und
|
||||
/// protokolliert; alle anderen unbekannten Felder mit 400.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> DesktopOnly =
|
||||
new Dictionary<string, string>(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<SettingDescriptor> Fields = new List<SettingDescriptor>
|
||||
{
|
||||
// ---- 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<AppThemeVariant>(), RequiresRestart = true,
|
||||
Get = s => s.Theme.ToString(),
|
||||
Parse = v => ParseChoice(v, Enum.GetNames<AppThemeVariant>()),
|
||||
Assign = (s, v) => s.Theme = Enum.Parse<AppThemeVariant>((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 <Wert>"),
|
||||
// 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));
|
||||
|
||||
/// <summary>
|
||||
/// Ob die Geheimnisse gesetzt sind – nie ihr Wert. OpenRouter- und Master-Key werden dafür nicht
|
||||
/// gelesen, nur ihr Vorhandensein geprüft.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-Einstellungen ohne Geheimnisse (#561). Was angezeigt und geändert werden darf, legt
|
||||
/// <see cref="ServerSettingsCatalog"/> fest.
|
||||
/// </summary>
|
||||
public static class ServerSettingsEndpoints
|
||||
{
|
||||
public sealed record UpdateServerSettingsRequest(Dictionary<string, JsonElement>? Values);
|
||||
|
||||
public static IEndpointRouteBuilder MapServerSettingsEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/settings/server");
|
||||
|
||||
group.MapGet("/", (IServiceProvider sp) =>
|
||||
{
|
||||
var file = sp.GetService<ServerSettingsFile>();
|
||||
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<string>();
|
||||
|
||||
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<ServerSettingsFile>();
|
||||
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<ServerSettings>();
|
||||
if (live != null)
|
||||
foreach (var (f, v) in parsed) f.Assign!(live, v);
|
||||
sp.GetService<MullvadVpnService>()?.ReloadSettings();
|
||||
sp.GetService<WatchdogHeartbeatService>()?.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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1182,6 +1182,29 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
|
||||
<dd id="cfgTokenStatus">…</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Server-Einstellungen (#561): eine Freigabeliste ohne Geheimnisse. Felder, ueber die ein
|
||||
Schluessel abfliessen oder ein fremdes Programm laufen koennte, stehen nur zur Ansicht. -->
|
||||
<div class="card settings-form" style="margin-top: 16px;">
|
||||
<div class="card-title"><span>Server-Einstellungen</span></div>
|
||||
<div class="form-desc" style="margin-bottom: 14px;">
|
||||
Nur Einstellungen ohne Geheimnisse. Felder, über die ein Schlüssel abfließen oder ein fremdes
|
||||
Programm laufen könnte, stehen hier nur zur Ansicht.
|
||||
</div>
|
||||
<div id="serverSettingsSections"><div class="form-desc">Lade …</div></div>
|
||||
<div style="display: flex; gap: 12px; align-items: center; margin-top: 18px; flex-wrap: wrap;">
|
||||
<button class="btn btn-primary" onclick="saveServerSettings()">Änderungen speichern</button>
|
||||
<div id="serverSettingsMsg" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-form" style="margin-top: 16px;">
|
||||
<div class="card-title"><span>Geheimnisse</span></div>
|
||||
<div class="form-desc" style="margin-bottom: 14px;">
|
||||
Werden ausschließlich im Desktop verwaltet. Hier steht nur, ob sie gesetzt sind.
|
||||
</div>
|
||||
<dl class="settings-readonly" id="serverSecretsList"></dl>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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 => `
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">${escapeHtml(sec.title)}</div>
|
||||
${sec.fields.map(renderServerField).join('')}
|
||||
</div>`).join('');
|
||||
secrets.innerHTML = data.secrets
|
||||
.map(x => `<dt>${escapeHtml(x.label)}</dt><dd>${escapeHtml(x.status)}</dd>`)
|
||||
.join('');
|
||||
} catch (e) {
|
||||
host.innerHTML = `<div class="rate-warning">Server-Einstellungen nicht ladbar: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderServerField(f) {
|
||||
const id = 'srv_' + f.key;
|
||||
const key = escapeHtml(f.key);
|
||||
const badge = !f.editable
|
||||
? '<span class="effect-badge">nur im Desktop</span>'
|
||||
: `<span class="effect-badge ${f.requiresRestart ? 'restart' : 'live'}">${f.requiresRestart ? 'nach Neustart' : 'sofort'}</span>`;
|
||||
|
||||
let control;
|
||||
if (!f.editable) {
|
||||
control = `<span class="setting-readonly">${escapeHtml(String(f.value ?? '')) || '—'}</span>`;
|
||||
} else if (f.kind === 'bool') {
|
||||
control = `<input type="checkbox" id="${id}" data-key="${key}" ${f.value ? 'checked' : ''}>`;
|
||||
} else if (f.kind === 'int') {
|
||||
control = `<input type="number" class="form-input" id="${id}" data-key="${key}" value="${escapeHtml(String(f.value))}"` +
|
||||
`${f.min != null ? ` min="${f.min}"` : ''}${f.max != null ? ` max="${f.max}"` : ''}>`;
|
||||
} else if (f.kind === 'choice') {
|
||||
control = `<select class="form-input form-select" id="${id}" data-key="${key}">` +
|
||||
(f.options || []).map(o => `<option value="${escapeHtml(o)}" ${o === f.value ? 'selected' : ''}>${escapeHtml(o)}</option>`).join('') +
|
||||
'</select>';
|
||||
} else {
|
||||
control = `<input type="text" class="form-input" id="${id}" data-key="${key}" value="${escapeHtml(String(f.value ?? ''))}">`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="setting-field">
|
||||
<label for="${id}">${escapeHtml(f.label)} ${badge}</label>
|
||||
<div>${control}</div>
|
||||
<div class="form-desc">${escapeHtml(f.description)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user