using System.Diagnostics; using System.Text.Json; using System.Threading.Channels; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Predictalytics.Hosting; using Serilog; using Serilog.Events; namespace Predictalytics.Hosting.Admin; /// /// Registriert alle API-Endpunkte für die Fernadministration der Software. /// Diese Endpunkte werden NUR auf dem separaten Admin-Port gemappt und sind /// im öffentlichen Analyse-Swagger nicht sichtbar. /// public static class AdminApiEndpoints { private static readonly DateTime StartTime = DateTime.UtcNow; public static void MapAdminApi(this IEndpointRouteBuilder routes, PredictalyticsHost host) { var adminGroup = routes.MapGroup("/api/admin"); // ───────────────────────────────────────────────────────────────────── // Authentifizierung (Token-Validierung) // ───────────────────────────────────────────────────────────────────── adminGroup.MapPost("/auth/verify", (HttpContext context, TokenVerifyRequest? body) => { var expected = host.Options.AdminToken?.Trim() ?? ""; if (string.IsNullOrEmpty(expected)) { return Results.Json(new { valid = false, message = "Auf dem Server ist kein Admin-Token konfiguriert." }, statusCode: 401); } string? candidate = null; if (context.Request.Headers.TryGetValue(AdminAuthFilter.HeaderName, out var headerVal)) { candidate = headerVal.ToString(); } else if (!string.IsNullOrWhiteSpace(body?.Token)) { candidate = body.Token; } else if (context.Request.Query.TryGetValue("token", out var qv)) { candidate = qv.ToString(); } if (string.IsNullOrWhiteSpace(candidate) || !CryptographicTokenEquals(candidate, expected)) { return Results.Json(new { valid = false, message = "Ungültiger Admin-Token." }, statusCode: 401); } return Results.Ok(new { valid = true, version = DcConfig.AppVersion, host = host.Options.AdminServerHost, port = host.Options.AdminServerPort }); }); // Alle nachfolgenden Routen verlangen zwingend den gültigen Admin-Token var secureGroup = adminGroup.AddEndpointFilter(new AdminAuthFilter(() => host.Options.AdminToken)); // ───────────────────────────────────────────────────────────────────── // Status & Übersicht // ───────────────────────────────────────────────────────────────────── secureGroup.MapGet("/status", async (CancellationToken ct) => { var dbSize = await host.GetDatabaseSizeMbAsync(ct); var memoryMb = Math.Round(GC.GetTotalMemory(false) / 1024.0 / 1024.0, 1); var uptime = DateTime.UtcNow - StartTime; return Results.Ok(new { workersRunning = host.WorkersRunning, webServerRunning = host.WebServerRunning, webServerUrl = host.Options.WebserverUrl, webServerPort = host.Options.WebserverPort, webServerHost = host.Options.WebserverHost, adminServerRunning = host.AdminServerRunning, adminServerUrl = host.Options.AdminServerUrl, adminServerPort = host.Options.AdminServerPort, adminServerHost = host.Options.AdminServerHost, isBusy = host.IsBusy, busyOperation = host.BusyOperation, dbSizeMb = dbSize, appVersion = DcConfig.AppVersion, gitCommit = DcConfig.GitCommitShort, memoryMb = memoryMb, uptimeSeconds = Math.Round(uptime.TotalSeconds, 0), uptimeText = FormatUptime(uptime) }); }); // ───────────────────────────────────────────────────────────────────── // Steuerung (Worker & Analyse-Webserver) // ───────────────────────────────────────────────────────────────────── secureGroup.MapPost("/workers/start", () => { if (host.WorkersRunning) return Results.Ok(new { ok = true, message = "Worker laufen bereits.", workersRunning = true }); host.StartWorkers(); Log.Information("Admin WebGUI: Hintergrund-Worker gestartet."); return Results.Ok(new { ok = true, message = "Hintergrund-Worker gestartet.", workersRunning = true }); }); secureGroup.MapPost("/workers/stop", () => { if (!host.WorkersRunning) return Results.Ok(new { ok = true, message = "Worker sind bereits gestoppt.", workersRunning = false }); host.StopWorkers(); Log.Information("Admin WebGUI: Hintergrund-Worker gestoppt."); return Results.Ok(new { ok = true, message = "Hintergrund-Worker gestoppt.", workersRunning = false }); }); secureGroup.MapPost("/webserver/start", async () => { if (host.WebServerRunning) return Results.Ok(new { ok = true, message = "Analyse-Webserver läuft bereits.", webServerRunning = true }); try { await host.StartWebServerAsync(); Log.Information("Admin WebGUI: Analyse-Webserver gestartet auf {Url}", host.Options.WebserverUrl); return Results.Ok(new { ok = true, message = "Analyse-Webserver gestartet.", webServerRunning = true, url = host.Options.WebserverUrl }); } catch (Exception ex) { Log.Error(ex, "Admin WebGUI: Starten des Analyse-Webservers fehlgeschlagen"); return Results.BadRequest(new { ok = false, error = ex.Message }); } }); secureGroup.MapPost("/webserver/stop", async () => { if (!host.WebServerRunning) return Results.Ok(new { ok = true, message = "Analyse-Webserver ist bereits gestoppt.", webServerRunning = false }); await host.StopWebServerAsync(); Log.Information("Admin WebGUI: Analyse-Webserver gestoppt."); return Results.Ok(new { ok = true, message = "Analyse-Webserver gestoppt.", webServerRunning = false }); }); // ───────────────────────────────────────────────────────────────────── // Wartungsaktionen // ───────────────────────────────────────────────────────────────────── secureGroup.MapPost("/maintenance/sync-markets", async (CancellationToken ct) => { if (host.WorkersRunning) return Results.Conflict(new { error = "Markt-Sync kann nicht laufen, während die Worker aktiv sind. Bitte zuerst Worker stoppen." }); try { await host.RunGuardedMaintenanceAsync("Manueller Markt-Sync", async token => { await host.RunSingleMarketSyncAsync(token); return true; }, ct); return Results.Ok(new { ok = true, message = "Manueller Markt-Sync erfolgreich abgeschlossen." }); } catch (Exception ex) { return Results.BadRequest(new { ok = false, error = ex.Message }); } }); secureGroup.MapPost("/maintenance/update-database", async (CancellationToken ct) => { if (host.WorkersRunning) return Results.Conflict(new { error = "Datenbank-Update kann nicht laufen, während die Worker aktiv sind." }); try { await host.RunGuardedMaintenanceAsync("Datenbank-Update", async _ => { await host.UpdateDatabaseAsync(); return true; }, ct); return Results.Ok(new { ok = true, message = "Datenbank erfolgreich aktualisiert." }); } catch (Exception ex) { return Results.BadRequest(new { ok = false, error = ex.Message }); } }); secureGroup.MapPost("/maintenance/recalculate-traders", async (ConfirmActionRequest? body, CancellationToken ct) => { if (body?.Confirm != true) { return Results.BadRequest(new { error = "Bestätigung erforderlich. Bitte { 'confirm': true } mitsenden." }); } if (host.WorkersRunning) return Results.Conflict(new { error = "Neuberechnung kann nicht laufen, während die Worker aktiv sind." }); try { var summary = await host.RunGuardedMaintenanceAsync("Alle Trader neu berechnen", token => host.RunRecalculateAllTradersAsync(token), ct); return Results.Ok(new { ok = true, message = "Trader-Neuberechnung abgeschlossen.", summary }); } catch (Exception ex) { return Results.BadRequest(new { ok = false, error = ex.Message }); } }); // ───────────────────────────────────────────────────────────────────── // Logs & Terminal // ───────────────────────────────────────────────────────────────────── secureGroup.MapGet("/logs", (int? count, string? minLevel) => { var limit = Math.Clamp(count ?? 200, 10, 1000); LogEventLevel? parsedLevel = null; if (!string.IsNullOrWhiteSpace(minLevel) && Enum.TryParse(minLevel, true, out var l)) { parsedLevel = l; } var entries = InMemoryLogBuffer.Instance.GetRecent(limit, parsedLevel); return Results.Ok(entries); }); // SSE (Server-Sent Events) Stream für Live-Terminal secureGroup.MapGet("/logs/stream", async (HttpContext ctx, CancellationToken ct) => { ctx.Response.Headers.ContentType = "text/event-stream"; ctx.Response.Headers.CacheControl = "no-cache"; ctx.Response.Headers.Connection = "keep-alive"; var channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleWriter = false, SingleReader = true }); void OnLog(AdminLogEntry entry) => channel.Writer.TryWrite(entry); InMemoryLogBuffer.Instance.LogEmitted += OnLog; try { // Initiale Initialisierungsmeldung await ctx.Response.WriteAsync("event: connected\ndata: {}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); // Zuerst die letzten 50 Zeilen senden var recent = InMemoryLogBuffer.Instance.GetRecent(50); foreach (var entry in recent) { var json = JsonSerializer.Serialize(entry); await ctx.Response.WriteAsync($"data: {json}\n\n", ct); } await ctx.Response.Body.FlushAsync(ct); // Live weiter streamen while (!ct.IsCancellationRequested) { var entry = await channel.Reader.ReadAsync(ct); var json = JsonSerializer.Serialize(entry); await ctx.Response.WriteAsync($"data: {json}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); } } catch (OperationCanceledException) { } finally { InMemoryLogBuffer.Instance.LogEmitted -= OnLog; channel.Writer.TryComplete(); } }); // Log-Dateien auflisten secureGroup.MapGet("/logs/files", () => { var logDir = LoggingSetup.DefaultLogDirectory; if (!Directory.Exists(logDir)) return Results.Ok(Array.Empty()); var files = Directory.EnumerateFiles(logDir, "*.log", SearchOption.AllDirectories) .Select(path => { var fileInfo = new FileInfo(path); var relativePath = Path.GetRelativePath(logDir, path).Replace('\\', '/'); var category = Path.GetDirectoryName(relativePath)?.Replace('\\', '/') ?? "root"; return new { name = Path.GetFileName(path), relativePath = relativePath, category = string.IsNullOrEmpty(category) ? "root" : category, sizeBytes = fileInfo.Length, sizeKb = Math.Round(fileInfo.Length / 1024.0, 1), modifiedUtc = fileInfo.LastWriteTimeUtc }; }) .OrderByDescending(f => f.modifiedUtc) .ToList(); return Results.Ok(files); }); // Log-Datei herunterladen secureGroup.MapGet("/logs/files/download", (string file) => { if (string.IsNullOrWhiteSpace(file)) return Results.BadRequest("Dateiname erforderlich."); var logDir = Path.GetFullPath(LoggingSetup.DefaultLogDirectory); var safePath = Path.GetFullPath(Path.Combine(logDir, file.TrimStart('/', '\\'))); if (!safePath.StartsWith(logDir, StringComparison.OrdinalIgnoreCase) || !File.Exists(safePath)) { return Results.NotFound("Datei nicht gefunden oder ungültiger Pfad."); } return Results.File(safePath, "text/plain", Path.GetFileName(safePath)); }); // ───────────────────────────────────────────────────────────────────── // Einstellungen (Settings) // ───────────────────────────────────────────────────────────────────── secureGroup.MapGet("/settings", () => { var opts = host.Options; return Results.Ok(new { webserver = new { port = opts.WebserverPort, host = opts.WebserverHost, apiControlToken = string.IsNullOrEmpty(opts.ApiControlToken) ? "" : "••••••••", hasApiControlToken = !string.IsNullOrEmpty(opts.ApiControlToken), dbConnectionDebug = opts.DbConnectionDebug }, adminServer = new { enabled = opts.AdminServerEnabled, port = opts.AdminServerPort, host = opts.AdminServerHost, token = opts.AdminToken, url = opts.AdminServerUrl }, database = new { server = opts.DbServer, database = opts.DbName, user = opts.DbUser, hasPassword = !string.IsNullOrEmpty(opts.DbPassword), password = string.IsNullOrEmpty(opts.DbPassword) ? "" : "••••••••", sslMode = opts.DbSslMode.ToString() }, deploymentCenter = new { serverUrl = opts.DcServerUrl, token = string.IsNullOrEmpty(opts.DcToken) ? "" : "••••••••", hasToken = !string.IsNullOrEmpty(opts.DcToken), heartbeatEnabled = opts.DcHeartbeatEnabled, source = opts.DcSource, instance = opts.DcInstance, intervalSeconds = opts.DcHeartbeatIntervalSeconds, errorReportingEnabled = opts.DcErrorReportingEnabled, updateCheckEnabled = opts.DcUpdateCheckEnabled, updateChannel = opts.DcUpdateChannel }, egress = new { channelsText = opts.EgressChannelsText } }); }); secureGroup.MapPost("/settings", (UpdateSettingsRequest body) => { var opts = host.Options; if (body.Webserver != null) { if (body.Webserver.Port.HasValue && body.Webserver.Port > 0) opts.WebserverPort = body.Webserver.Port.Value; if (!string.IsNullOrWhiteSpace(body.Webserver.Host)) opts.WebserverHost = body.Webserver.Host.Trim(); if (body.Webserver.ApiControlToken != null && body.Webserver.ApiControlToken != "••••••••") opts.ApiControlToken = body.Webserver.ApiControlToken.Trim(); if (body.Webserver.DbConnectionDebug.HasValue) opts.DbConnectionDebug = body.Webserver.DbConnectionDebug.Value; } if (body.AdminServer != null) { if (body.AdminServer.Enabled.HasValue) opts.AdminServerEnabled = body.AdminServer.Enabled.Value; if (body.AdminServer.Port.HasValue && body.AdminServer.Port > 0) opts.AdminServerPort = body.AdminServer.Port.Value; if (!string.IsNullOrWhiteSpace(body.AdminServer.Host)) opts.AdminServerHost = body.AdminServer.Host.Trim(); if (!string.IsNullOrWhiteSpace(body.AdminServer.Token)) opts.AdminToken = body.AdminServer.Token.Trim(); } if (body.Database != null) { if (!string.IsNullOrWhiteSpace(body.Database.Server)) opts.DbServer = body.Database.Server.Trim(); if (!string.IsNullOrWhiteSpace(body.Database.Database)) opts.DbName = body.Database.Database.Trim(); if (!string.IsNullOrWhiteSpace(body.Database.User)) opts.DbUser = body.Database.User.Trim(); if (body.Database.Password != null && body.Database.Password != "••••••••") opts.DbPassword = body.Database.Password.Trim(); if (!string.IsNullOrWhiteSpace(body.Database.SslMode) && Enum.TryParse(body.Database.SslMode, true, out var ssl)) { opts.DbSslMode = ssl; } } if (body.DeploymentCenter != null) { if (body.DeploymentCenter.Token != null && body.DeploymentCenter.Token != "••••••••") opts.DcToken = body.DeploymentCenter.Token.Trim(); if (body.DeploymentCenter.HeartbeatEnabled.HasValue) opts.DcHeartbeatEnabled = body.DeploymentCenter.HeartbeatEnabled.Value; if (!string.IsNullOrWhiteSpace(body.DeploymentCenter.Source)) opts.DcSource = body.DeploymentCenter.Source.Trim(); if (!string.IsNullOrWhiteSpace(body.DeploymentCenter.Instance)) opts.DcInstance = body.DeploymentCenter.Instance.Trim(); if (body.DeploymentCenter.IntervalSeconds.HasValue && body.DeploymentCenter.IntervalSeconds >= 15) opts.DcHeartbeatIntervalSeconds = body.DeploymentCenter.IntervalSeconds.Value; if (body.DeploymentCenter.ErrorReportingEnabled.HasValue) opts.DcErrorReportingEnabled = body.DeploymentCenter.ErrorReportingEnabled.Value; if (body.DeploymentCenter.UpdateCheckEnabled.HasValue) opts.DcUpdateCheckEnabled = body.DeploymentCenter.UpdateCheckEnabled.Value; if (!string.IsNullOrWhiteSpace(body.DeploymentCenter.UpdateChannel)) opts.DcUpdateChannel = body.DeploymentCenter.UpdateChannel.Trim(); } if (body.Egress?.ChannelsText != null) { opts.EgressChannelsText = body.Egress.ChannelsText; } opts.Save(); DcErrorReporter.Configure(opts.DcToken, opts.DcErrorReportingEnabled); Log.Information("Admin WebGUI: Einstellungen gespeichert."); return Results.Ok(new { ok = true, message = "Einstellungen erfolgreich gespeichert." }); }); secureGroup.MapPost("/settings/generate-token", () => { var newToken = PredictalyticsOptions.GenerateSecureToken(); return Results.Ok(new { token = newToken }); }); // ───────────────────────────────────────────────────────────────────── // Deployment Center & Lizenz // ───────────────────────────────────────────────────────────────────── secureGroup.MapGet("/license", async () => { var hwInfo = LicenseGuard.GetHardwareInfo(); var check = await LicenseGuard.TryUseCachedAsync(); return Results.Ok(new { productSlug = DcConfig.ProductSlug, version = DcConfig.AppVersion, gitCommit = DcConfig.GitCommitShort, serverUrl = DcConfig.BaseUrl, hardwareId = hwInfo.HardwareId, hwidSource = hwInfo.HwidSource, status = check.LastResult?.Status ?? "unbekannt", isUsable = check.IsUsable, isCached = check.LastResult?.IsCached ?? false, message = check.LastResult?.Message ?? "", cacheExpiresAt = check.LastResult?.CacheExpiresAt }); }); secureGroup.MapPost("/updates/check", async () => { var check = await LicenseGuard.TryUseCachedAsync(); var licenseKey = check.Session?.LicenseKey; var result = await DcUpdateService.CheckAsync(host.Options.DcUpdateChannel, licenseKey); return Results.Ok(new { currentVersion = DcConfig.AppVersion, updateAvailable = result.UpdateAvailable, latestVersion = result.LatestRelease?.Version ?? DcConfig.AppVersion, isCritical = result.IsCritical, changelog = result.LatestRelease?.Changelog, unauthorized = result.Unauthorized, message = result.Message }); }); } private static bool CryptographicTokenEquals(string candidate, string expected) { var candidateBytes = System.Text.Encoding.UTF8.GetBytes(candidate.Trim()); var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expected.Trim()); return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(candidateBytes, expectedBytes); } private static string FormatUptime(TimeSpan span) { if (span.TotalDays >= 1) return $"{(int)span.TotalDays}d {span.Hours}h {span.Minutes}m"; if (span.TotalHours >= 1) return $"{span.Hours}h {span.Minutes}m {span.Seconds}s"; return $"{span.Minutes}m {span.Seconds}s"; } } // Request Models public sealed record TokenVerifyRequest(string? Token); public sealed record ConfirmActionRequest(bool? Confirm); public sealed class UpdateSettingsRequest { public WebserverSettingsUpdate? Webserver { get; set; } public AdminServerSettingsUpdate? AdminServer { get; set; } public DatabaseSettingsUpdate? Database { get; set; } public DcSettingsUpdate? DeploymentCenter { get; set; } public EgressSettingsUpdate? Egress { get; set; } } public sealed class WebserverSettingsUpdate { public int? Port { get; set; } public string? Host { get; set; } public string? ApiControlToken { get; set; } public bool? DbConnectionDebug { get; set; } } public sealed class AdminServerSettingsUpdate { public bool? Enabled { get; set; } public int? Port { get; set; } public string? Host { get; set; } public string? Token { get; set; } } public sealed class DatabaseSettingsUpdate { public string? Server { get; set; } public string? Database { get; set; } public string? User { get; set; } public string? Password { get; set; } public string? SslMode { get; set; } } public sealed class DcSettingsUpdate { public string? Token { get; set; } public bool? HeartbeatEnabled { get; set; } public string? Source { get; set; } public string? Instance { get; set; } public int? IntervalSeconds { get; set; } public bool? ErrorReportingEnabled { get; set; } public bool? UpdateCheckEnabled { get; set; } public string? UpdateChannel { get; set; } } public sealed class EgressSettingsUpdate { public string? ChannelsText { get; set; } }