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:
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user