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:
Richard
2026-09-13 14:12:00 +02:00
co-authored by Claude Opus 5
parent 68d8d6f8d2
commit c1e13f5441
9 changed files with 863 additions and 8 deletions
@@ -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");
}
}
}