Files
Predictalytics/src/Predictalytics.Hosting/Admin/AdminApiEndpoints.cs
T
Richard 1131d257cf Eigenständiges token-geschütztes Verwaltungs-WebGUI (Admin WebGUI) hinzugefügt
- Unabhängige Kestrel-Instanz auf Port 5001 (vollständig getrennt vom Analyse-Webserver)
- Admin API Endpunkte ohne Swagger-Exposition
- Token-Gatekeeper mit sofortigem Login-Overlay bei unautorisiertem Zugriff
- Live-Terminal via SSE-Log-Streaming und Log-Download-Übersicht
- Steuerung von Workern, Analyse-Webserver, Wartungsaktionen und Einstellungen
- Nahtlose Integration in Avalonia Desktop-Shell und Linux Headless-Modus
- Unit-Tests für Admin-Filter, Optionen und Log-Puffer
2026-09-07 21:09:03 +02:00

578 lines
26 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<LogEventLevel>(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<AdminLogEntry>(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<object>());
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<MySqlConnector.MySqlSslMode>(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; }
}