Files
PolyTraderSharp/Ui/Views/SettingsView.cs
T
RichardandClaude Opus 5 ca750a06f5 Watchdog-Heartbeat + LicenseLabrador-Lizenzpruefung eingebunden
Slices W-A/W-B/W-C und L-A aus UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.

Watchdog (Dead-Man's-Switch, externer Server):
- WatchdogHeartbeatService als BackgroundService + DI-Singleton; jeder Sendeversuch
  gekapselt, ein Ausfall des Watchdogs beeintraechtigt PolyTrader nie.
- Eigene Implementierung statt Test-Client des Fremdprojekts: TLS-Pruefung bleibt
  aktiv, http:// nur fuer localhost (Agent-Token nicht im Klartext ins Netz).
- Status aus dem App-Log abgeleitet (Error mit 5-Minuten-Sticky-Fenster, entprellt),
  Lifecycle-Events started/stopping.
- Konfiguration in ServerSettings; Agent-Token [Browsable(false)] mit maskierter
  Eingabe + Statusanzeige, bei gesetztem Master-Key verschluesselt (enc:v1:).

Lizenz (LicenseLabrador, Ed25519):
- LicenseGate.RunStartupGate prueft beim Start; bei ungueltiger Lizenz wird die
  Modulliste leer gebaut, sodass nur die Core-Shell (Terminal/Einstellungen)
  startet. Bewusst kein Environment.Exit - ein Trading-Bot darf nicht mitten im
  Lauf hart sterben. TamperSuspected gilt als nicht nutzbar.
- LicenseDialog (partial + .Designer.cs) fuer Start- und Verwalten-Modus, mit
  Hardware-ID zum Kopieren; Smoke-UI konstruiert beide Modi headless.
- Master-Key wird jetzt VOR dem Host-Build geladen, da auch der Lizenzschluessel
  entschluesselt werden muss; derselbe TerminalLogger wird als Singleton
  weitergereicht, damit die Startmeldungen im Terminal-Fenster erscheinen.

Der Lizenz-SDK-Client wird per Cross-Repo-Projektreferenz auf
..\..\LicenseLabrador eingebunden, damit SDK-Fixes ohne Kopie einfliessen.

484 Zeilen Tests fuer den Heartbeat; Suite gruen (438 Tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:04:14 +02:00

414 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 ThreemaService? _threema;
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(ThreemaService threema, MullvadVpnService vpn, TerminalLogger logger,
IAccountRepository accountRepo, TradingState state, WatchdogHeartbeatService? watchdog = null)
{
_threema = threema;
_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);
_threema?.ReloadSettings();
_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;
}
}
}