Files
PolyTraderSharp/src/PolyTrader.App.Avalonia/Views/SettingsWindow.axaml.cs
T

530 lines
24 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.ObjectModel;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Deploymentcenter.Client;
using PolyTrader.App.Avalonia.Controls;
using PolyTrader.Core;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Security;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using PolyTrader.Web.Configuration;
namespace PolyTrader.App.Avalonia.Views
{
/// <summary>
/// Einstellungen: allgemeine Server-Settings und die Polymarket-Accounts. Beide Bereiche nutzen
/// den <see cref="SettingsEditor"/> anstelle der früheren PropertyGrids.
/// Layout vollständig in SettingsWindow.axaml.
/// </summary>
public partial class SettingsWindow : UserControl
{
private const string SettingsPath = "server_settings.xml";
private readonly ObservableCollection<AccountState> _accounts = new();
private ServerSettings _settings = new();
private IAccountRepository? _accountRepo;
private TradingState? _state;
private WatchdogHeartbeatService? _watchdog;
private MullvadVpnService? _vpn;
private TerminalLogger? _logger;
private DeploymentcenterOptions? _dcOptions;
private WebUiOptions _webOptions = new();
public SettingsWindow() => AvaloniaXamlLoader.Load(this);
public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state,
TerminalLogger logger, WatchdogHeartbeatService? watchdog = null,
MullvadVpnService? vpn = null, DeploymentcenterOptions? dcOptions = null,
WebUiOptions? webOptions = null) : this()
{
_accountRepo = accountRepo;
_state = state;
_logger = logger;
_watchdog = watchdog;
_vpn = vpn;
_dcOptions = dcOptions;
_webOptions = webOptions ?? Program.LoadWebUiOptions();
var grid = this.FindControl<DataGrid>("gridAccounts")!;
grid.ItemsSource = _accounts;
grid.SelectionChanged += (_, _) =>
this.FindControl<SettingsEditor>("editorAccount")!.Show(grid.SelectedItem);
this.FindControl<Button>("btnSave")!.Click += (_, _) => SaveServerSettings();
this.FindControl<Button>("btnReload")!.Click += (_, _) => ReloadServerSettings();
this.FindControl<Button>("btnGenMasterKey")!.Click += async (_, _) => await GenerateMasterKeyAsync();
this.FindControl<Button>("btnOpenRouterKey")!.Click += async (_, _) => await SetOpenRouterKeyAsync();
this.FindControl<Button>("btnWatchdogToken")!.Click += async (_, _) => await SetWatchdogTokenAsync();
this.FindControl<Button>("btnTestWatchdog")!.Click += async (_, _) => await TestWatchdogAsync();
this.FindControl<Button>("btnLicenseKey")!.Click += async (_, _) => await SetLicenseKeyAsync();
this.FindControl<Button>("btnCheckLicense")!.Click += async (_, _) => await CheckLicenseAsync();
this.FindControl<Button>("btnAccNew")!.Click += (_, _) => AddAccount();
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await DeleteAccountAsync();
this.FindControl<Button>("btnAccSave")!.Click += (_, _) => SaveSelectedAccount();
this.FindControl<Button>("btnWebUiGenToken")!.Click += (_, _) => GenerateWebUiToken();
this.FindControl<Button>("btnWebUiToggleShowToken")!.Click += (_, _) => ToggleWebUiTokenVisibility();
this.FindControl<Button>("btnWebUiCopyToken")!.Click += async (_, _) => await CopyWebUiTokenAsync();
this.FindControl<Button>("btnSaveWebUi")!.Click += (_, _) => SaveWebUiSettings();
ReloadServerSettings();
LoadAccounts();
LoadWebUiSettings();
UpdateMasterKeyButtonState();
}
private void Status(string text) => this.FindControl<TextBlock>("lblStatus")!.Text = text;
// ===== Server-Settings =====
private void ReloadServerSettings()
{
_settings = ServerSettings.Load(SettingsPath);
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
Status("Einstellungen geladen.");
}
private void SaveServerSettings()
{
try
{
_settings.Save(SettingsPath);
_vpn?.ReloadSettings();
_watchdog?.ReloadSettings();
_logger?.Info("Server-Einstellungen gespeichert und Dienste neu geladen.");
Status($"Gespeichert um {DateTime.Now:HH:mm:ss}.");
}
catch (Exception ex)
{
Status($"Speichern fehlgeschlagen: {ex.Message}");
}
}
// ===== Polymarket Accounts =====
private void LoadAccounts()
{
if (_state == null) return;
_accounts.Clear();
foreach (var a in _state.Accounts.Values.OrderBy(a => a.AccountId)) _accounts.Add(a);
var grid = this.FindControl<DataGrid>("gridAccounts")!;
if (_accounts.Count > 0) grid.SelectedIndex = 0;
}
private void AddAccount()
{
if (_state == null || _accountRepo == null) return;
int newId = _state.Accounts.Count > 0 ? _state.Accounts.Keys.Max() + 1 : 1;
var acc = new AccountState { AccountId = newId, Name = "Neuer Account" };
_state.Accounts[acc.AccountId] = acc;
_accountRepo.Upsert(acc);
_accounts.Add(acc);
this.FindControl<DataGrid>("gridAccounts")!.SelectedItem = acc;
Status($"Account #{newId} angelegt.");
}
private async System.Threading.Tasks.Task DeleteAccountAsync()
{
if (_state == null || _accountRepo == null) return;
if (this.FindControl<DataGrid>("gridAccounts")!.SelectedItem is not AccountState acc) return;
bool ok = await DialogWindow.Confirm(this, "Löschen bestätigen",
$"Account „{acc.Name}\" (ID {acc.AccountId}) wirklich löschen?", "Löschen");
if (!ok) return;
_state.Accounts.TryRemove(acc.AccountId, out _);
_accountRepo.Delete(acc.AccountId);
_accounts.Remove(acc);
this.FindControl<SettingsEditor>("editorAccount")!.Show(null);
Status($"Account #{acc.AccountId} gelöscht.");
}
private void SaveSelectedAccount()
{
if (this.FindControl<DataGrid>("gridAccounts")!.SelectedItem is not AccountState acc) return;
_accountRepo?.Upsert(acc);
_state?.Accounts.AddOrUpdate(acc.AccountId, acc, (_, _) => acc);
Status($"Account #{acc.AccountId} gespeichert um {DateTime.Now:HH:mm:ss}.");
}
// ===== Master-Key (at-rest-Verschlüsselung) =====
private static string MasterKeyFilePath => Path.Combine(AppContext.BaseDirectory, "master.key");
private static bool MasterKeyExists() =>
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY"))
|| File.Exists(MasterKeyFilePath);
/// <summary>Schaltfläche nur aktiv, solange KEIN Master-Key existiert (Überschreiben = Lockout-Gefahr).</summary>
private void UpdateMasterKeyButtonState() =>
this.FindControl<Button>("btnGenMasterKey")!.IsEnabled = !MasterKeyExists();
private async System.Threading.Tasks.Task GenerateMasterKeyAsync()
{
// Sicherheitsnetz gegen Race/Doppelklick: einen bestehenden Key NIEMALS überschreiben.
if (MasterKeyExists())
{
await DialogWindow.Info(this, "Master-Key vorhanden",
"Es existiert bereits ein Master-Key – Erzeugung abgebrochen. Ein Überschreiben würde den " +
"Zugriff auf bereits verschlüsselte Wallet-Keys unwiederbringlich zerstören.");
UpdateMasterKeyButtonState();
return;
}
try
{
byte[] keyBytes = RandomNumberGenerator.GetBytes(32); // 256-Bit-Schlüssel
File.WriteAllText(MasterKeyFilePath, Convert.ToBase64String(keyBytes));
RestrictToOwner(MasterKeyFilePath);
_logger?.Info("🔐 Master-Key erzeugt und in master.key gespeichert. At-rest-Verschlüsselung wird beim nächsten Start aktiv.");
await DialogWindow.Info(this, "Master-Key erzeugt",
"Ein zufälliger 32-Byte-Master-Key wurde erzeugt und in der Datei „master.key\" " +
"(App-Ordner, gitignored) gespeichert.\n\n" +
"WICHTIG:\n" +
"• Sichere diese Datei SOFORT separat (Passwort-Manager / Offline-Backup).\n" +
"• Master-Key-Verlust = KEIN Zugriff mehr auf die verschlüsselten Wallet-Keys!\n" +
"• Die Verschlüsselung der Account-Credentials wird beim nächsten Programmstart aktiv.");
}
catch (Exception ex)
{
await DialogWindow.Info(this, "Fehler", $"Fehler beim Erzeugen des Master-Keys: {ex.Message}");
}
UpdateMasterKeyButtonState();
}
/// <summary>
/// Beschränkt eine Schlüsseldatei auf den Besitzer. Auf Linux legt <see cref="File.WriteAllText"/>
/// mit der üblichen umask sonst <c>644</c> an – die Datei wäre für alle lesbar. Unter Windows
/// greift die NTFS-Vererbung, dort ist nichts zu tun.
/// </summary>
private void RestrictToOwner(string path) =>
FilePermissions.RestrictToOwner(path, msg => _logger?.Warning($"⚠️ {msg}"));
// ===== OpenRouter-API-Key (Supervisor) =====
private async System.Threading.Tasks.Task SetOpenRouterKeyAsync()
{
string keyFile = Path.Combine(AppContext.BaseDirectory, "openrouter.key");
bool exists = File.Exists(keyFile) ||
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY"));
string? key = await DialogWindow.Prompt(this, "OpenRouter-API-Key",
"OpenRouter-API-Key für den Supervisor eingeben.\n" +
"Wird in der gitignorierten Datei „openrouter.key\" gespeichert (leer = entfernen).\n" +
(exists ? "Aktuell ist bereits ein Key hinterlegt." : "Aktuell ist KEIN Key gesetzt."));
if (key == null) return; // Abbruch
try
{
if (string.IsNullOrWhiteSpace(key))
{
if (File.Exists(keyFile)) File.Delete(keyFile);
_logger?.Info("OpenRouter-Key aus openrouter.key entfernt.");
Status("OpenRouter-Key entfernt.");
return;
}
File.WriteAllText(keyFile, key.Trim());
RestrictToOwner(keyFile);
_logger?.Info("OpenRouter-Key gespeichert (openrouter.key).");
Status("OpenRouter-Key gespeichert – wirkt bei der nächsten Supervisor-Anfrage.");
}
catch (Exception ex)
{
await DialogWindow.Info(this, "Fehler", $"Fehler beim Speichern des OpenRouter-Keys: {ex.Message}");
}
}
// ===== Watchdog =====
private async System.Threading.Tasks.Task SetWatchdogTokenAsync()
{
bool exists = !string.IsNullOrWhiteSpace(_settings.WatchdogToken);
string? token = await DialogWindow.Prompt(this, "Watchdog Agent-Token",
"Agent-Token aus dem Watchdog-Admin eingeben.\n" +
"Wird in der gitignorierten server_settings.xml gespeichert (leer = entfernen).\n" +
(exists ? "Aktuell ist ein Token gesetzt." : "Aktuell ist KEIN Token gesetzt."));
if (token == null) return; // Abbruch
try
{
// 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.Empty
: SecretProtection.Protect(token.Trim());
_settings.Save(SettingsPath);
_watchdog?.ReloadSettings();
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
if (_settings.WatchdogToken.Length == 0)
{
_logger?.Info("Watchdog-Agent-Token entfernt.");
Status("Watchdog-Token entfernt.");
return;
}
bool encrypted = SecretProtection.IsEncrypted(_settings.WatchdogToken);
_logger?.Info($"Watchdog-Agent-Token gespeichert ({(encrypted ? "verschlüsselt" : "Klartext")}).");
Status(encrypted
? "Watchdog-Token gespeichert (verschlüsselt) und sofort aktiv."
: "Watchdog-Token gespeichert – ACHTUNG: ohne Master-Key im Klartext. Mit „Master-Key erzeugen\" ändern.");
}
catch (Exception ex)
{
await DialogWindow.Info(this, "Fehler", $"Fehler beim Speichern des Watchdog-Tokens: {ex.Message}");
}
}
/// <summary>
/// Sendet einen Heartbeat mit den GESPEICHERTEN Einstellungen. Ungespeicherte Änderungen
/// wirken bewusst nicht – sonst würde ein erfolgreicher Test etwas bestätigen, das im
/// laufenden Betrieb gar nicht gilt.
/// </summary>
private async System.Threading.Tasks.Task TestWatchdogAsync()
{
if (_watchdog == null) return;
var btn = this.FindControl<Button>("btnTestWatchdog")!;
btn.IsEnabled = false;
try
{
Status("Sende Test-Heartbeat …");
var result = await _watchdog.SendHeartbeatAsync("ok", "Test-Heartbeat aus dem PolyTrader-Settings-Fenster");
if (result.Success)
{
_logger?.Info($"✅ Watchdog-Test-Heartbeat erfolgreich gesendet ({result.Detail}).");
Status($"Test-Heartbeat erfolgreich ({result.Detail}). Der Monitor sollte jetzt auf „up\" stehen.");
}
else
{
_logger?.Warning($"⚠️ Watchdog-Test-Heartbeat fehlgeschlagen: {result.Detail}");
await DialogWindow.Info(this, "Watchdog",
$"Test-Heartbeat fehlgeschlagen:\n\n{result.Detail}\n\n" +
"Bitte URL, Agent-Token und Source prüfen – und die Einstellungen vorher speichern.");
Status("Test-Heartbeat fehlgeschlagen.");
}
}
finally
{
btn.IsEnabled = true;
}
}
// ===== Lizenz (D-2) =====
private async System.Threading.Tasks.Task SetLicenseKeyAsync()
{
bool exists = !string.IsNullOrWhiteSpace(_settings.LicenseKey);
string? key = await DialogWindow.Prompt(this, "Deploymentcenter-Lizenzschlüssel",
"Lizenzschlüssel eingeben.\n" +
"Wird in der gitignorierten server_settings.xml gespeichert (leer = entfernen).\n" +
"Wirkt erst beim nächsten Start von PolyTrader (keine Laufzeit-Revalidierung).\n" +
(exists ? "Aktuell ist ein Schlüssel gesetzt." : "Aktuell ist KEIN Schlüssel gesetzt."));
if (key == null) return; // Abbruch
try
{
_settings.LicenseKey = string.IsNullOrWhiteSpace(key)
? string.Empty
: SecretProtection.Protect(key.Trim());
_settings.Save(SettingsPath);
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
if (_settings.LicenseKey.Length == 0)
{
_logger?.Info("Lizenzschlüssel entfernt.");
Status("Lizenzschlüssel entfernt – PolyTrader startet beim nächsten Mal im eingeschränkten Modus.");
return;
}
bool encrypted = SecretProtection.IsEncrypted(_settings.LicenseKey);
_logger?.Info($"Lizenzschlüssel gespeichert ({(encrypted ? "verschlüsselt" : "Klartext")}).");
Status((encrypted
? "Lizenzschlüssel gespeichert (verschlüsselt). "
: "Lizenzschlüssel gespeichert – ACHTUNG: ohne Master-Key im Klartext. ") +
"Mit „Lizenz jetzt prüfen\" testen, wirkt für Module erst beim nächsten Start.");
}
catch (Exception ex)
{
await DialogWindow.Info(this, "Fehler", $"Fehler beim Speichern des Lizenzschlüssels: {ex.Message}");
}
}
/// <summary>
/// Prüft den GESPEICHERTEN Schlüssel gegen das Deploymentcenter und zeigt Urteil und
/// Hardware-ID (zum Kopieren, falls Richard sie für eine Aktivierung im Admin braucht).
/// Ungespeicherte Änderungen wirken bewusst nicht, aus demselben Grund wie beim Watchdog-Test.
/// </summary>
private async System.Threading.Tasks.Task CheckLicenseAsync()
{
if (_dcOptions == null) return;
var btn = this.FindControl<Button>("btnCheckLicense")!;
btn.IsEnabled = false;
try
{
if (string.IsNullOrWhiteSpace(_dcOptions.BaseUrl))
{
await DialogWindow.Info(this, "Lizenz", "Keine Deploymentcenter-URL konfiguriert (appsettings.json).");
return;
}
string key;
try { key = SecretProtection.Unprotect(_settings.LicenseKey); }
catch (Exception ex)
{
await DialogWindow.Info(this, "Lizenz", $"Gespeicherter Schlüssel nicht lesbar: {ex.Message}");
return;
}
var hw = HardwareId.GetHardwareId(_dcOptions.ProjectSlug);
if (string.IsNullOrWhiteSpace(key))
{
await DialogWindow.Info(this, "Lizenz",
$"Kein Lizenzschlüssel gesetzt.\n\nHardware-ID dieser Maschine:\n{hw.HardwareId}");
Status("Kein Lizenzschlüssel gesetzt.");
return;
}
Status("Prüfe Lizenz …");
LicenseClient.DefaultAppVersion = BuildInfo.Version;
var client = new LicenseClient();
var result = await client.ValidateAsync(_dcOptions.ProjectSlug, key, _dcOptions.BaseUrl, BuildInfo.Version);
string detail = $"Status: {result.Status}\n{result.Message}\n\nHardware-ID dieser Maschine:\n{hw.HardwareId}";
if (result.IsValid)
{
_logger?.Info($"🔑 Lizenz geprüft: gültig ({result.Status}).");
Status($"Lizenz gültig ({result.Status}).");
await DialogWindow.Info(this, "Lizenz gültig", detail);
}
else
{
_logger?.Warning($"⚠️ Lizenz geprüft: nicht gültig ({result.Status}).");
Status($"Lizenz NICHT gültig ({result.Status}).");
await DialogWindow.Info(this, "Lizenz nicht gültig", detail);
}
}
catch (Exception ex)
{
await DialogWindow.Info(this, "Fehler", $"Lizenzprüfung fehlgeschlagen: {ex.Message}");
}
finally
{
btn.IsEnabled = true;
}
}
// ===== WebUI-Einstellungen =====
private void LoadWebUiSettings()
{
_webOptions = Program.LoadWebUiOptions();
var chkEnabled = this.FindControl<CheckBox>("chkWebUiEnabled")!;
var numPort = this.FindControl<NumericUpDown>("numWebUiPort")!;
var chkLocal = this.FindControl<CheckBox>("chkWebUiLocalOnly")!;
var txtToken = this.FindControl<TextBox>("txtWebUiToken")!;
var lblStatus = this.FindControl<TextBlock>("lblWebUiStatus")!;
var lblUrl = this.FindControl<TextBlock>("lblWebUiUrl")!;
chkEnabled.IsChecked = _webOptions.Enabled;
numPort.Value = _webOptions.Port;
chkLocal.IsChecked = _webOptions.LocalOnly;
txtToken.Text = _webOptions.AuthToken ?? string.Empty;
if (_webOptions.Enabled)
{
string ip = _webOptions.LocalOnly ? "127.0.0.1" : "0.0.0.0";
lblStatus.Text = $"Aktiv (lauscht auf {ip}:{_webOptions.Port})";
lblUrl.Text = $"http://{(_webOptions.LocalOnly ? "localhost" : "0.0.0.0")}:{_webOptions.Port}";
}
else
{
lblStatus.Text = "Deaktiviert (WebUi:Enabled = false)";
lblUrl.Text = "—";
}
}
private void GenerateWebUiToken()
{
string newToken = WebUiConfigManager.GenerateSecureToken();
var txtToken = this.FindControl<TextBox>("txtWebUiToken")!;
txtToken.Text = newToken;
_webOptions.AuthToken = newToken;
WebUiConfigManager.SaveToLocalSettings(_webOptions);
_logger?.Info("🔐 WebUI: Neues Zugangs-Token im Einstellungsfenster generiert und gespeichert.");
SetWebUiSaveStatus("Neues Token generiert & gespeichert.");
}
private void ToggleWebUiTokenVisibility()
{
var txt = this.FindControl<TextBox>("txtWebUiToken")!;
var btn = this.FindControl<Button>("btnWebUiToggleShowToken")!;
if (txt.PasswordChar == '●')
{
txt.PasswordChar = '\0';
btn.Content = "Ausblenden";
}
else
{
txt.PasswordChar = '●';
btn.Content = "Anzeigen";
}
}
private async System.Threading.Tasks.Task CopyWebUiTokenAsync()
{
var txt = this.FindControl<TextBox>("txtWebUiToken")!;
if (string.IsNullOrEmpty(txt.Text)) return;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.Clipboard != null)
{
await topLevel.Clipboard.SetTextAsync(txt.Text);
SetWebUiSaveStatus("Token in Zwischenablage kopiert.");
}
}
private void SaveWebUiSettings()
{
var chkEnabled = this.FindControl<CheckBox>("chkWebUiEnabled")!;
var numPort = this.FindControl<NumericUpDown>("numWebUiPort")!;
var chkLocal = this.FindControl<CheckBox>("chkWebUiLocalOnly")!;
var txtToken = this.FindControl<TextBox>("txtWebUiToken")!;
_webOptions.Enabled = chkEnabled.IsChecked ?? true;
_webOptions.Port = (int)(numPort.Value ?? 5001);
_webOptions.LocalOnly = chkLocal.IsChecked ?? true;
_webOptions.AuthToken = txtToken.Text?.Trim() ?? string.Empty;
WebUiConfigManager.SaveToLocalSettings(_webOptions);
_logger?.Info($"WebUI-Einstellungen in appsettings.Local.json gespeichert (Port {_webOptions.Port}, LocalOnly: {_webOptions.LocalOnly}).");
SetWebUiSaveStatus("Gespeichert in appsettings.Local.json (Port-/Netzwerkänderungen greifen nach Neustart).");
LoadWebUiSettings();
}
private void SetWebUiSaveStatus(string msg)
{
var lbl = this.FindControl<TextBlock>("lblWebUiSaveStatus")!;
lbl.Text = $"✓ {msg}";
}
}
}