Files
PolyTraderSharp/Ui/Views/SettingsView.cs
T
RichardandClaude Opus 5 fd6d62618f Kultur-Bug behoben + Threema entfernt (INotificationSink)
- TraderMonitorService las API-Preise kulturabhaengig: unter de-DE wurde aus
  "0.53" der Wert 53 (Faktor-100-Fehler im Einstandspreis). Nutzt jetzt den
  bereits vorhandenen invarianten Helper ParseDecimal.
- Gleiche Fehlerklasse in PolymarketClobClient (6x) und MasterTraderAnalyticsJob
  vorsorglich auf InvariantCulture gestellt.
- Neuer Regressionstest ApiNumberParsingTests (10 Faelle unter erzwungener de-DE-Kultur).
- Threema komplett entfernt (Entscheidung Richard): ThreemaService, vendorte
  Bibliothek libs/Threema-MsgApi-Net-Core, ServerSettings-Block, DI-Verdrahtung.
- Ersetzt durch neutrale INotificationSink (No-Throw-Vertrag) + LogNotificationSink
  als Uebergang; RocketChat/Telegram folgen spaeter.
- Entfernt nebenbei libsodium 1.0.16, die einzige Registry-Nutzung im Build,
  den HttpListener-Webhook und System.Web.HttpUtility (alles Linux-Hindernisse).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:01:13 +02:00

411 lines
19 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.ComponentModel;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using System.Windows.Forms;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Security;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
namespace PolyTraderSharp.Ui.Views
{
/// <summary>
/// Core-Settings-Fenster: allgemeine Server-Einstellungen (PropertyGrid) und die
/// allgemeine Verwaltung der Polymarket-Accounts (Wallets, API-Keys …).
/// Die copytrading-spezifischen Detail-Limits werden NICHT hier, sondern im
/// Copytrading-Modul-View bearbeitet (AccountState ist bewusst general-only).
/// </summary>
public partial class SettingsView : Form
{
private const string SettingsPath = "server_settings.xml";
private ServerSettings _settings = new();
private MullvadVpnService? _vpn;
private WatchdogHeartbeatService? _watchdog;
private TerminalLogger? _logger;
private IAccountRepository? _accountRepo;
private TradingState? _state;
private BindingList<AccountState> _accounts = new();
public SettingsView()
{
InitializeComponent();
btn_save.Click += (_, _) => Save();
btn_loadsettings.Click += (_, _) => Reload();
btnGenMasterKey.Click += (_, _) => GenerateMasterKey();
btnSetOpenRouterKey.Click += (_, _) => SetOpenRouterKey();
btnSetWatchdogToken.Click += (_, _) => SetWatchdogToken();
btnTestWatchdog.Click += async (_, _) => await TestWatchdogAsync();
btnSetLicenseKey.Click += (_, _) => SetLicenseKey();
UpdateMasterKeyButtonState();
btnAccNew.Click += (_, _) => AddAccount();
btnAccDelete.Click += (_, _) => DeleteAccount();
dgvAccounts.SelectionChanged += (_, _) =>
{
pgAccount.SelectedObject = dgvAccounts.CurrentRow?.DataBoundItem as AccountState;
};
pgAccount.PropertyValueChanged += (_, _) =>
{
if (pgAccount.SelectedObject is AccountState acc) SaveAccount(acc);
};
}
public void Initialize(MullvadVpnService vpn, TerminalLogger logger,
IAccountRepository accountRepo, TradingState state, WatchdogHeartbeatService? watchdog = null)
{
_vpn = vpn;
_logger = logger;
_accountRepo = accountRepo;
_state = state;
_watchdog = watchdog;
Reload();
LoadAccounts();
}
// ===== Server-Settings =====
private void Reload()
{
_settings = ServerSettings.Load(SettingsPath);
propertyGrid.SelectedObject = _settings;
}
private void Save()
{
try
{
_settings.Save(SettingsPath);
_vpn?.ReloadSettings();
_watchdog?.ReloadSettings();
_logger?.Info("Server-Einstellungen gespeichert und Services neu geladen.");
MessageBox.Show("Server-Einstellungen gespeichert.", "Erfolg",
MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Fehler beim Speichern: {ex.Message}", "Fehler",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
// ===== Polymarket Accounts (allgemeine Einstellungen) =====
private void LoadAccounts()
{
if (_state == null) return;
_accounts = new BindingList<AccountState>(_state.Accounts.Values.OrderBy(a => a.AccountId).ToList());
dgvAccounts.DataSource = _accounts;
pgAccount.SelectedObject = dgvAccounts.CurrentRow?.DataBoundItem as AccountState;
}
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);
dgvAccounts.CurrentCell = dgvAccounts.Rows[dgvAccounts.Rows.Count - 1].Cells[0];
}
private void DeleteAccount()
{
if (_state == null || _accountRepo == null) return;
if (dgvAccounts.CurrentRow?.DataBoundItem is not AccountState acc) return;
if (MessageBox.Show($"Account '{acc.Name}' (ID {acc.AccountId}) wirklich löschen?",
"Löschen bestätigen", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes)
return;
_state.Accounts.TryRemove(acc.AccountId, out _);
_accountRepo.Delete(acc.AccountId);
_accounts.Remove(acc);
}
private void SaveAccount(AccountState acc)
{
_accountRepo?.Upsert(acc);
_state?.Accounts.AddOrUpdate(acc.AccountId, acc, (_, _) => acc);
dgvAccounts.Refresh();
}
// ===== Watchdog =====
/// <summary>
/// Setzt/entfernt den Watchdog-Agent-Token über eine maskierte Eingabe damit das Secret
/// nicht offen im PropertyGrid steht und die server_settings.xml nicht per Hand bearbeitet
/// werden muss. Ist ein Master-Key gesetzt, wird der Token verschlüsselt abgelegt.
/// Wird sofort gespeichert, damit der Test-Button und der laufende Dienst ihn direkt nutzen.
/// </summary>
private void SetWatchdogToken()
{
bool exists = !string.IsNullOrWhiteSpace(_settings.WatchdogToken);
string? token = PromptForSecret("Watchdog Agent-Token",
"Agent-Token aus dem Watchdog-Admin eingeben (Header X-Watchdog-Key).\n" +
"Wird in der gitignorierten server_settings.xml gespeichert (leer = entfernen).\n" +
(exists ? $"Aktuell: {_settings.WatchdogTokenStatus}." : "Aktuell ist KEIN Token gesetzt."));
if (token == null) return; // Abbruch
try
{
if (string.IsNullOrWhiteSpace(token))
{
_settings.WatchdogToken = "";
_settings.Save(SettingsPath);
_watchdog?.ReloadSettings();
propertyGrid.Refresh();
_logger?.Info("Watchdog-Agent-Token entfernt.");
MessageBox.Show("Watchdog-Token entfernt.", "Watchdog",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
// Protect() gibt ohne Master-Key den Klartext unverändert zurück kein stiller
// Sicherheitsverlust, der Nutzer wird darauf hingewiesen.
_settings.WatchdogToken = SecretProtection.Protect(token.Trim());
_settings.Save(SettingsPath);
_watchdog?.ReloadSettings();
propertyGrid.Refresh();
bool encrypted = SecretProtection.IsEncrypted(_settings.WatchdogToken);
_logger?.Info($"Watchdog-Agent-Token gespeichert ({(encrypted ? "verschlüsselt" : "Klartext")}).");
MessageBox.Show(
"Watchdog-Token gespeichert und sofort aktiv.\n\n" +
(encrypted
? "Der Token liegt mit dem Master-Key verschlüsselt in server_settings.xml."
: "Hinweis: Es ist kein Master-Key gesetzt der Token liegt im Klartext in der " +
"(gitignorierten) server_settings.xml. Mit „Master-Key erzeugen\" kannst du das ändern."),
"Watchdog", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Fehler beim Speichern des Watchdog-Tokens: {ex.Message}",
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
/// <summary>
/// Sendet einen Heartbeat mit den GESPEICHERTEN Einstellungen und meldet das Ergebnis.
/// Ungespeicherte Änderungen im PropertyGrid wirken bewusst nicht sonst würde ein
/// erfolgreicher Test eine Konfiguration bestätigen, die so nicht auf der Platte liegt.
/// </summary>
private async Task TestWatchdogAsync()
{
if (_watchdog == null)
{
MessageBox.Show("Der Watchdog-Dienst ist nicht verfügbar.", "Watchdog",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
btnTestWatchdog.Enabled = false;
try
{
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}).");
MessageBox.Show(
$"Test-Heartbeat erfolgreich gesendet ({result.Detail}).\n\n" +
"Der Monitor sollte im Watchdog-Dashboard jetzt auf 'up' stehen.",
"Watchdog", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
else
{
_logger?.Warning($"⚠️ Watchdog-Test-Heartbeat fehlgeschlagen: {result.Detail}");
MessageBox.Show(
$"Test-Heartbeat fehlgeschlagen:\n\n{result.Detail}\n\n" +
"Bitte URL, Agent-Token und Source prüfen und die Einstellungen vorher speichern.",
"Watchdog", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
finally
{
btnTestWatchdog.Enabled = true;
}
}
// ===== Lizenz (LicenseLabrador) =====
/// <summary>
/// Öffnet denselben Lizenzdialog wie der Programmstart (Hardware-ID + Server-Validierung),
/// nur im Verwalten-Modus. Ein erfolgreich validierter Schlüssel wird bei gesetztem
/// Master-Key verschlüsselt in server_settings.xml gespeichert und gilt ab dem nächsten Start.
/// </summary>
private void SetLicenseKey()
{
string current = string.Empty;
try { current = SecretProtection.Unprotect(_settings.LicenseKey); }
catch { /* nicht lesbar → als leer behandeln */ }
LicenseLabrador.Client.LicenseClient client;
try
{
client = new LicenseLabrador.Client.LicenseClient(PolyTraderSharp.Licensing.LicenseGate.BuildConfig());
}
catch (Exception ex)
{
MessageBox.Show($"Lizenzprüfung nicht verfügbar: {ex.Message}", "Lizenz",
MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
using var dlg = new LicenseDialog(client, current, initialResult: null, startupContext: false);
dlg.ShowDialog(this);
if (dlg.ValidatedResult == null || !dlg.ValidatedResult.IsUsable || string.IsNullOrWhiteSpace(dlg.ValidatedKey))
return; // abgebrochen oder nicht validiert nichts speichern
try
{
_settings.LicenseKey = SecretProtection.Protect(dlg.ValidatedKey!.Trim());
_settings.Save(SettingsPath);
propertyGrid.Refresh();
bool encrypted = SecretProtection.IsEncrypted(_settings.LicenseKey);
_logger?.Info($"Lizenz validiert und gespeichert ({(encrypted ? "verschlüsselt" : "Klartext")}).");
MessageBox.Show(
"Lizenz validiert und gespeichert.\n\n" +
(encrypted
? "Der Schlüssel liegt mit dem Master-Key verschlüsselt in server_settings.xml."
: "Hinweis: Es ist kein Master-Key gesetzt der Schlüssel liegt im Klartext in der " +
"(gitignorierten) server_settings.xml."),
"Lizenz", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Fehler beim Speichern des Lizenzschlüssels: {ex.Message}",
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
// ===== Master-Key (at-rest-Verschlüsselung, F1) =====
/// <summary>Pfad der Master-Key-Datei identisch zu Program.cs (App-Ordner, gitignored).</summary>
private static string MasterKeyFilePath => Path.Combine(AppContext.BaseDirectory, "master.key");
/// <summary>Existiert bereits ein Master-Key (Umgebungsvariable ODER Datei)?</summary>
private static bool MasterKeyExists() =>
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY"))
|| File.Exists(MasterKeyFilePath);
/// <summary>Button nur aktiv, solange KEIN Master-Key existiert (Überschreiben = Lockout-Gefahr).</summary>
private void UpdateMasterKeyButtonState() => btnGenMasterKey.Enabled = !MasterKeyExists();
private void GenerateMasterKey()
{
// Sicherheitsnetz gegen Race/Doppelklick: einen bestehenden Key NIEMALS überschreiben.
if (MasterKeyExists())
{
MessageBox.Show(
"Es existiert bereits ein Master-Key Erzeugung abgebrochen. Ein Überschreiben würde den " +
"Zugriff auf bereits verschlüsselte Wallet-Keys unwiederbringlich zerstören.",
"Master-Key vorhanden", MessageBoxButtons.OK, MessageBoxIcon.Warning);
UpdateMasterKeyButtonState();
return;
}
try
{
byte[] keyBytes = RandomNumberGenerator.GetBytes(32); // 256-Bit-Schlüssel
File.WriteAllText(MasterKeyFilePath, Convert.ToBase64String(keyBytes));
_logger?.Info("🔐 Master-Key erzeugt und in master.key gespeichert. At-rest-Verschlüsselung wird beim nächsten Start aktiv.");
MessageBox.Show(
"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 und sicher (z. B. 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.",
"Master-Key erzeugt", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Fehler beim Erzeugen des Master-Keys: {ex.Message}",
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
UpdateMasterKeyButtonState(); // nach Erzeugung deaktivieren
}
// ===== OpenRouter-API-Key (Supervisor) =====
/// <summary>
/// Setzt/entfernt den OpenRouter-API-Key des Supervisors. Ablage in der gitignorierten Datei
/// openrouter.key (genau das liest der OpenRouterClient) kein Secret im Klartext in
/// server_settings.xml. Der Nutzer gibt den Key selbst maskiert ein.
/// </summary>
private void SetOpenRouterKey()
{
string keyFile = Path.Combine(AppContext.BaseDirectory, "openrouter.key");
bool exists = File.Exists(keyFile) ||
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY"));
string? key = PromptForSecret("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.");
MessageBox.Show("OpenRouter-Key entfernt.", "OpenRouter",
MessageBoxButtons.OK, MessageBoxIcon.Information);
}
else
{
File.WriteAllText(keyFile, key.Trim());
_logger?.Info("OpenRouter-Key gespeichert (openrouter.key).");
MessageBox.Show("OpenRouter-Key gespeichert. Wirkt beim nächsten Programmstart bzw. der nächsten Supervisor-Anfrage.\n\n" +
"Tipp: bei OpenRouter ein Spend-Limit für diesen Key setzen (separater Key für den Supervisor empfohlen).",
"OpenRouter", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
catch (Exception ex)
{
MessageBox.Show($"Fehler beim Speichern des OpenRouter-Keys: {ex.Message}",
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
/// <summary>Modaler Mini-Dialog mit maskierter Eingabe. Rückgabe null = Abbruch.</summary>
private string? PromptForSecret(string title, string prompt)
{
using var form = new Form
{
Text = title,
Width = 480,
Height = 210,
FormBorderStyle = FormBorderStyle.FixedDialog,
StartPosition = FormStartPosition.CenterParent,
MinimizeBox = false,
MaximizeBox = false
};
var lbl = new Label { Text = prompt, Left = 12, Top = 12, Width = 445, Height = 70, AutoSize = false };
var tb = new TextBox { Left = 12, Top = 92, Width = 445, UseSystemPasswordChar = true };
var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, Left = 296, Top = 128, Width = 75 };
var cancel = new Button { Text = "Abbrechen", DialogResult = DialogResult.Cancel, Left = 380, Top = 128, Width = 77 };
form.Controls.AddRange(new Control[] { lbl, tb, ok, cancel });
form.AcceptButton = ok;
form.CancelButton = cancel;
return form.ShowDialog(this) == DialogResult.OK ? tb.Text : null;
}
}
}