Files
PolyTraderSharp/src/PolyTrader.Web/Endpoints/ServerSettingsEndpoints.cs
T
RichardandClaude Opus 5 c1e13f5441 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>
2026-09-13 14:12:00 +02:00

134 lines
5.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
};
}
}