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
@@ -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)