- 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>
411 lines
19 KiB
C#
411 lines
19 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|