Arbeitsstand gesichert: Deploymentcenter-Integration (D-0 bis D-5) und Einfenster-Shell

Sicherungscommit vor dem Aufraeumen des Repos, damit nachvollziehbar bleibt,
welcher Stand vor der Bereinigung galt. Build gruen, 476 Tests gruen.

Zwei Straenge, die sich ueber .csproj, Program.cs und appsettings.json
ueberschneiden und darum gemeinsam abgelegt werden:

Deploymentcenter-Integration (P3c, Plan D-0 bis D-5 code-seitig fertig):
- Deploymentcenter.Client 2.5.0 als lokales Paket, Source-Mapping erweitert
- DeploymentcenterOptions, DeploymentcenterErrorReporter, LicenseGate/LicenseCli
- WatchdogHeartbeatService auf die Deploymentcenter-API umgestellt
  (version, os, checks, metrics, status stopped)
- Security: MasterKeyResolver, SecretRedactor, FilePermissions
- Directory.Build.props mit zentraler Version 0.1.0 (Packager-Versionsdisziplin)
- deploy/: Packager-Vorlage und systemd-Unit; echte Zugangsdaten bleiben
  ueber .gitignore aussen vor
- setup.json fuer die Erstinstallation
- UMSETZUNGSPLAN-Deploymentcenter-Integration.md; ANALYSE-Linux-Portierung.md
  verweist auf den neuen Plan

Einfenster-Shell (UI-Redesign):
- ShellWindow + ShellNavModel als Seitenleisten-Shell
- WindowMenuBar und WindowMenuModel entfallen
- Modul- und Kernfenster auf die Shell-Einbettung angepasst

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-22 10:42:22 +02:00
co-authored by Claude Opus 5
parent ac5e16f0a6
commit dd8da3fd3f
58 changed files with 3932 additions and 650 deletions
@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Security;
namespace PolyTraderSharp.Services
{
/// <summary>
/// Meldet ungefangene Fehler an den Fehler-Stream des Deploymentcenters (D-3,
/// <c>POST /api/errors/v1/report</c>). Fängt drei Quellen ab: <see cref="AppDomain.UnhandledException"/>
/// (der Prozess stirbt gerade <c>level: "fatal"</c>), <see cref="TaskScheduler.UnobservedTaskException"/>
/// (ein Hintergrund-Task ist fehlgeschlagen, ohne dass jemand hinsah <c>"warning"</c>) und
/// <see cref="TerminalLogger.OnLogMessage"/> bei <see cref="LogLevel.Error"/> (<c>"error"</c>).
///
/// **Grundregel wie beim Watchdog:** Ein nicht erreichbares Deploymentcenter darf PolyTrader nie
/// beeinträchtigen. Zusätzlich hier besonders wichtig: **keine Rückkopplung.** Der Reporter selbst
/// darf einen Sendefehler NIEMALS über <see cref="TerminalLogger.Error"/> melden das würde exakt
/// den Log-Kanal auslösen, den er selbst abhört, und sich damit endlos selbst melden. Eigene
/// Fehlermeldungen laufen deshalb ausschließlich über <see cref="TerminalLogger.Warning"/>.
///
/// Fehlermeldungen enthalten möglicherweise Wallet-Adressen, Marktkennungen oder Beträge; der
/// Deploymentcenter-Server liegt beim Hoster. Nachricht und Stacktrace laufen deshalb vor dem
/// Versand durch <see cref="SecretRedactor"/>.
/// </summary>
public sealed class DeploymentcenterErrorReporter : BackgroundService
{
/// <summary>
/// Eigene, deutlich strengere Grenze als das serverseitige Limit (60/Minute/IP) ein
/// durchdrehender Fehlerpfad soll lokal gedrosselt werden, bevor er den Server überhaupt
/// erreicht oder mit anderen Diensten auf derselben IP kollidiert.
/// </summary>
private const int MaxReportsPerMinute = 20;
private const int MaxStackTraceLength = 4000;
private readonly TerminalLogger _logger;
private readonly DeploymentcenterOptions _options;
private readonly HttpClient _http;
private readonly object _rateLock = new();
private readonly Queue<DateTime> _recentSendsUtc = new();
private bool _throttleWarningLogged;
public DeploymentcenterErrorReporter(
TerminalLogger logger,
DeploymentcenterOptions options,
HttpClient? httpClient = null)
{
_logger = logger;
_options = options;
_http = httpClient ?? new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
})
{
Timeout = TimeSpan.FromSeconds(10)
};
_logger.OnLogMessage += OnAppLog;
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
/// <summary>Rein ereignisgesteuert kein eigener Takt nötig, nur Auf- und Abbau der Abonnements.</summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Normales Herunterfahren.
}
}
public override void Dispose()
{
_logger.OnLogMessage -= OnAppLog;
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
base.Dispose();
}
internal void OnAppLog(object? sender, LogMessageEventArgs e)
{
if (e.Level != LogLevel.Error) return;
// Fire-and-forget: OnLogMessage ist synchron, darf den loggenden Thread nicht blockieren.
// ReportAsync fängt jede eigene Ausnahme selbst ab (No-Throw-Vertrag) hier entsteht also
// keine unbeobachtete Task-Exception, die wiederum den UnobservedTaskException-Zweig
// triggern und sich selbst melden würde.
_ = ReportAsync("ApplicationError", e.Message, stackTrace: null, level: "error");
}
internal void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
string message = (e.ExceptionObject as Exception)?.Message ?? e.ExceptionObject?.ToString() ?? "Unbekannter Fehler";
string? stack = (e.ExceptionObject as Exception)?.StackTrace;
string type = (e.ExceptionObject as Exception)?.GetType().FullName ?? "UnknownException";
// Der Prozess beendet sich gleich (IsTerminating in aller Regel true) - synchron und mit
// kurzer Frist senden, statt auf einen Fire-and-forget-Task zu setzen, der nie mehr laufen
// könnte.
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
ReportAsync(type, message, stack, "fatal", cts.Token).GetAwaiter().GetResult();
}
catch { /* Der Prozess stirbt ohnehin - hier ist nichts mehr zu retten. */ }
}
internal void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
var ex = e.Exception?.Flatten().InnerException ?? (Exception?)e.Exception;
_ = ReportAsync(ex?.GetType().FullName ?? "UnobservedTaskException",
ex?.Message ?? "Unbeobachtete Task-Exception", ex?.StackTrace, "warning");
// Verhindert, dass .NET den Prozess wegen der unbeobachteten Exception beendet - wir haben
// sie ja gerade beobachtet und gemeldet.
e.SetObserved();
}
/// <summary>
/// Meldet einen Fehler. Wirft nie, meldet eigene Fehlschläge nur über
/// <see cref="TerminalLogger.Warning"/> (siehe Klassendokumentation zur Rückkopplung).
/// </summary>
public async Task<bool> ReportAsync(
string exceptionType, string message, string? stackTrace, string level, CancellationToken ct = default)
{
try
{
if (!_options.ErrorReportingEnabled) return false;
if (string.IsNullOrWhiteSpace(_options.BaseUrl) || string.IsNullOrWhiteSpace(_options.Token))
return false;
if (!TryBuildEndpoint(_options.BaseUrl, out Uri? uri, out string urlProblem))
{
WarnOnce($"⚠️ Fehler-Reporting nicht sendebereit: {urlProblem}");
return false;
}
if (!TryReserveRateSlot())
{
if (!_throttleWarningLogged)
{
_logger.Warning("⚠️ Fehler-Reporting lokal gedrosselt (mehr als " +
$"{MaxReportsPerMinute} Meldungen/Minute) weitere Meldungen werden " +
"verworfen, bis sich die Rate beruhigt. Details bleiben im Terminal-Log.");
_throttleWarningLogged = true;
}
return false;
}
string token;
try { token = SecretProtection.Unprotect(_options.Token); }
catch (Exception ex) { WarnOnce($"⚠️ Fehler-Reporting-Token nicht lesbar: {ex.Message}"); return false; }
var payload = new
{
project_slug = _options.ProjectSlug,
exception = exceptionType,
message = WatchdogHeartbeatService.Sanitize(message),
stack_trace = SanitizeStackTrace(stackTrace),
level,
build = BuildInfo.Version,
environment = Environment.GetEnvironmentVariable("POLYTRADER_ENVIRONMENT") ?? "production"
};
using var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = JsonContent.Create(payload) };
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
WarnOnce($"⚠️ Fehler-Reporting fehlgeschlagen: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
return false;
}
return true;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex)
{
// Letztes Sicherheitsnetz - siehe Klassendokumentation: NIE über .Error loggen.
WarnOnce($"⚠️ Fehler-Reporting unerwartet fehlgeschlagen: {ex.Message}");
return false;
}
}
/// <summary>Entprellt Warnungen wie der Watchdog: der erste Fehlschlag wird gemeldet, nicht jeder einzelne.</summary>
private bool _lastSendFailed;
private void WarnOnce(string message)
{
if (_lastSendFailed) return;
_logger.Warning(message);
_lastSendFailed = true;
}
/// <summary>Gleitendes Ein-Minuten-Fenster, ohne Hintergrund-Timer.</summary>
private bool TryReserveRateSlot()
{
lock (_rateLock)
{
DateTime now = DateTime.UtcNow;
while (_recentSendsUtc.Count > 0 && (now - _recentSendsUtc.Peek()) > TimeSpan.FromMinutes(1))
_recentSendsUtc.Dequeue();
if (_recentSendsUtc.Count >= MaxReportsPerMinute) return false;
_recentSendsUtc.Enqueue(now);
_throttleWarningLogged = false; // erneutes Überschreiten wieder genau einmal melden
return true;
}
}
/// <summary>
/// Stacktraces dürfen (anders als die kurze Statuszeile im Heartbeat) mehrzeilig bleiben
/// sie sind sonst wertlos. Nur die bekannten Secret-Muster werden entfernt, dazu eine
/// großzügige Längenbegrenzung gegen ausufernde Payloads.
/// </summary>
private static string? SanitizeStackTrace(string? stackTrace)
{
if (string.IsNullOrEmpty(stackTrace)) return null;
string s = SecretRedactor.RemoveSecrets(stackTrace);
return s.Length > MaxStackTraceLength ? s.Substring(0, MaxStackTraceLength - 1) + "…" : s;
}
private static bool TryBuildEndpoint(string baseUrl, out Uri? uri, out string problem)
{
uri = null;
problem = string.Empty;
if (!Uri.TryCreate(baseUrl?.Trim().TrimEnd('/'), UriKind.Absolute, out Uri? parsed))
{
problem = $"Deploymentcenter-URL ist keine gültige absolute URL: '{baseUrl}'.";
return false;
}
bool isHttps = parsed.Scheme == Uri.UriSchemeHttps;
bool isLocalHttp = parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback;
if (!isHttps && !isLocalHttp)
{
problem = $"Deploymentcenter-URL muss HTTPS verwenden (http:// nur für localhost): '{baseUrl}'.";
return false;
}
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/errors/v1/report");
return true;
}
}
}