using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; using System.Reflection; using System.Security.Cryptography; using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Logging; namespace MSCO.Server; /// /// Web-Panel (wie txAdmin): Anmelden, Spielerliste, Kick/Ban/Admin, Banns, Live-Konsole mit Eingabe, Chat, /// Welt zurücksetzen, Backups, Benutzerverwaltung. /// /// Port: Standard ist derselbe Port wie der Gameserver - der Server erkennt an den ersten Bytes, ob ein Browser /// (HTTP) oder das Spiel verbindet, und reicht Browser-Verbindungen an das Panel weiter (das intern nur auf /// 127.0.0.1 lauscht). Mit PanelPort in server.json läuft es stattdessen auf einem eigenen Port. /// /// Hinter einem Reverse-Proxy: PanelTrustProxy = true übernimmt X-Forwarded-For/-Proto/-Prefix, /// PanelBasePath (z.B. "/panel") für den Betrieb unter einem Unterpfad. /// public sealed class AdminPanel { const string CookieName = "msco_panel"; const string CsrfHeader = "X-MSCO-Panel"; static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(12); static readonly TimeSpan ActionTimeout = TimeSpan.FromSeconds(8); const int MaxLoginFailures = 8; static readonly TimeSpan LoginWindow = TimeSpan.FromMinutes(5); sealed class Session { public string User; public DateTime Expires; } readonly ServerHost host; // läuft immer - der Gameserver darunter kann aus sein readonly string dataDir; ServerConfig config; readonly PanelUsers users; readonly ConcurrentDictionary sessions = new(); readonly ConcurrentDictionary> failures = new(); // Weitergereichte Verbindungen (gleicher Port): lokaler Port der Weiterleitung -> echte Adresse des Browsers readonly ConcurrentDictionary relayed = new(); WebApplication app; /// Einmal-Code für das erste Konto (null, sobald es einen Benutzer gibt). public string SetupCode { get; private set; } /// Interner Port, wenn das Panel den Spiel-Port mitbenutzt. public int InternalPort { get; private set; } public bool SharesGamePort { get; private set; } AdminPanel(ServerHost host, ServerConfig config, string dataDir) { this.host = host; this.dataDir = dataDir; this.config = config; users = new PanelUsers(Path.Combine(dataDir, "panel_users.json")); } // ---------------------------------------------------------------- Start public static AdminPanel Start(ServerHost host, ServerConfig config, string dataDir) { var panel = new AdminPanel(host, config, dataDir); try { panel.StartWeb(); } catch (Exception e) { GameServer.Log("Web panel could not start: " + e.Message); return null; } return panel; } void StartWeb() { SharesGamePort = config.PanelPort <= 0 || config.PanelPort == config.Port; IPAddress bind; int port; if (SharesGamePort) { bind = IPAddress.Loopback; port = InternalPort = FreeLoopbackPort(); } else { bind = ParseBind(config.PanelBindAddress); port = config.PanelPort; } var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { ContentRootPath = AppContext.BaseDirectory }); builder.Logging.ClearProviders(); builder.WebHost.ConfigureKestrel(k => { k.Listen(bind, port); k.Limits.MaxRequestBodySize = 64 * 1024; k.AddServerHeader = false; }); app = builder.Build(); // Weitergereichte Verbindung: echte Browser-Adresse wiederherstellen (sonst wäre jeder 127.0.0.1) app.Use(async (ctx, next) => { var remote = ctx.Connection.RemoteIpAddress; if (remote != null && IPAddress.IsLoopback(remote) && relayed.TryGetValue(ctx.Connection.RemotePort, out var real)) ctx.Connection.RemoteIpAddress = real; await next(); }); if (config.PanelTrustProxy) { var forwarded = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost }; forwarded.KnownIPNetworks.Clear(); // jedem Proxy vertrauen (PanelTrustProxy ist eine bewusste Entscheidung) forwarded.KnownProxies.Clear(); app.UseForwardedHeaders(forwarded); // Proxy unter Unterpfad (z.B. Traefik/nginx mit X-Forwarded-Prefix) app.Use(async (ctx, next) => { string prefix = ctx.Request.Headers["X-Forwarded-Prefix"]; if (!string.IsNullOrEmpty(prefix) && prefix.StartsWith('/')) ctx.Request.PathBase = prefix.TrimEnd('/'); await next(); }); } string basePath = (config.PanelBasePath ?? "").Trim().TrimEnd('/'); if (basePath.Length > 0) { if (!basePath.StartsWith('/')) basePath = "/" + basePath; app.UsePathBase(basePath); } // Sicherheits-Header app.Use(async (ctx, next) => { var h = ctx.Response.Headers; h["X-Content-Type-Options"] = "nosniff"; h["X-Frame-Options"] = "DENY"; h["Referrer-Policy"] = "no-referrer"; h["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: https://avatars.steamstatic.com; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"; if (ctx.Request.Path.StartsWithSegments("/api")) h["Cache-Control"] = "no-store"; await next(); }); MapFiles(); MapApi(); app.StartAsync().GetAwaiter().GetResult(); if (users.Empty) CreateSetupCode(); if (SharesGamePort) GameServer.Log($"Web panel: http://:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)"); else GameServer.Log($"Web panel: http://{(bind.Equals(IPAddress.Any) ? "" : bind.ToString())}:{port}{basePath}/"); } /// Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu). public void UpdateConfig(ServerConfig newConfig) => config = newConfig; public void Stop() { try { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); app?.StopAsync(timeout.Token).GetAwaiter().GetResult(); } catch { } } void CreateSetupCode() { const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; var code = new char[10]; for (int i = 0; i < code.Length; i++) code[i] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)]; SetupCode = new string(code, 0, 5) + "-" + new string(code, 5, 5); GameServer.Log("============================================================"); GameServer.Log(" WEB PANEL SETUP - open the panel in your browser and enter"); GameServer.Log(" this code to create the owner account: " + SetupCode); GameServer.Log("============================================================"); } /// Konsolenbefehl "panel": Adresse und (falls noch nötig) den Einrichtungs-Code zeigen. public void PrintInfo() { GameServer.Log(SharesGamePort ? $"Web panel on the game port {config.Port}" : $"Web panel on port {config.PanelPort}"); if (SetupCode != null) GameServer.Log("Setup code for the first account: " + SetupCode); else GameServer.Log($"{users.All().Count} panel user(s): " + string.Join(", ", users.All().Select(u => $"{u.Name} ({u.Role})"))); } static int FreeLoopbackPort() { var probe = new TcpListener(IPAddress.Loopback, 0); probe.Start(); int port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return port; } static IPAddress ParseBind(string address) { if (string.IsNullOrWhiteSpace(address) || address == "*" || address == "0.0.0.0") return IPAddress.Any; if (address == "localhost") return IPAddress.Loopback; return IPAddress.TryParse(address, out var ip) ? ip : IPAddress.Any; } // ---------------------------------------------------------------- Gleicher Port: HTTP erkennen und weiterreichen /// Beginnen die Bytes wie eine HTTP-Anfrage? (Spiel-Pakete beginnen mit einer kleinen Längenangabe.) public static bool LooksLikeHttp(byte[] b, int n) { if (n < 4) return false; for (int i = 0; i < 3; i++) if (b[i] < 'A' || b[i] > 'Z') return false; return (b[3] >= 'A' && b[3] <= 'Z') || b[3] == ' '; // "GET ", "PUT ", POST, HEAD, DELE, OPTI, PATC ... } /// Browser-Verbindung vom Spiel-Port an das intern laufende Panel weiterreichen (in beide Richtungen). public async Task RelayAsync(TcpClient browser) { var real = (browser.Client.RemoteEndPoint as IPEndPoint)?.Address; using var panel = new TcpClient { NoDelay = true }; int localPort = 0; try { await panel.ConnectAsync(IPAddress.Loopback, InternalPort); localPort = ((IPEndPoint)panel.Client.LocalEndPoint).Port; if (real != null) relayed[localPort] = real.IsIPv4MappedToIPv6 ? real.MapToIPv4() : real; using var a = browser.GetStream(); using var b = panel.GetStream(); var up = a.CopyToAsync(b); var down = b.CopyToAsync(a); await Task.WhenAny(up, down); } catch { } finally { if (localPort != 0) relayed.TryRemove(localPort, out _); try { browser.Close(); } catch { } } } // ---------------------------------------------------------------- Dateien (Oberfläche) void MapFiles() { byte[] html = Resource("panel.index.html"), js = Resource("panel.app.js"), i18n = Resource("panel.i18n.js"), css = Resource("panel.app.css"), logo = Resource("panel.logo.png"); app.MapGet("/", (HttpContext ctx) => { // /panel ohne Schrägstrich -> /panel/ (sonst zeigen die relativen Adressen der Seite ins Leere) if (ctx.Request.PathBase.HasValue && !ctx.Request.Path.HasValue) return Results.Redirect(ctx.Request.PathBase + "/"); return Results.Bytes(html, "text/html; charset=utf-8"); }); app.MapGet("/app.js", () => Results.Bytes(js, "text/javascript; charset=utf-8")); app.MapGet("/i18n.js", () => Results.Bytes(i18n, "text/javascript; charset=utf-8")); app.MapGet("/app.css", () => Results.Bytes(css, "text/css; charset=utf-8")); if (logo != null) app.MapGet("/logo.png", () => Results.Bytes(logo, "image/png")); } static byte[] Resource(string name) { using var s = Assembly.GetExecutingAssembly().GetManifestResourceStream(name); if (s == null) return null; using var ms = new MemoryStream(); s.CopyTo(ms); return ms.ToArray(); } // ---------------------------------------------------------------- API void MapApi() { // --- Anmeldung app.MapGet("/api/session", (HttpContext ctx) => { var user = CurrentUser(ctx); return Results.Json(new { needsSetup = users.Empty, serverName = config.Name, user = user == null ? null : new { name = user.Name, role = user.Role.ToString() } }); }); app.MapPost("/api/setup", async (HttpContext ctx) => { if (!CsrfOk(ctx)) return Forbidden("Missing header"); if (!users.Empty || SetupCode == null) return Error(400, "The panel is already set up"); if (RateLimited(ctx)) return Error(429, "Too many attempts - wait a few minutes"); var body = await Body(ctx); string code = Str(body, "code").Trim().ToUpperInvariant(); if (!CryptographicOperations.FixedTimeEquals(System.Text.Encoding.UTF8.GetBytes(code), System.Text.Encoding.UTF8.GetBytes(SetupCode))) { RecordFailure(ctx); return Error(403, "Wrong setup code (see the server console / docker logs)"); } string name = Str(body, "username").Trim(), password = Str(body, "password"); string error = users.Add(name, password, PanelRole.Owner); if (error != null) return Error(400, error); SetupCode = null; GameServer.Log($"[panel] Owner account '{name}' created ({Ip(ctx)})"); StartSession(ctx, name); return Results.Json(new { ok = true }); }); app.MapPost("/api/login", async (HttpContext ctx) => { if (!CsrfOk(ctx)) return Forbidden("Missing header"); if (RateLimited(ctx)) return Error(429, "Too many failed logins - wait a few minutes"); var body = await Body(ctx); var user = users.Verify(Str(body, "username").Trim(), Str(body, "password")); if (user == null) { RecordFailure(ctx); GameServer.Log($"[panel] Failed login for '{Str(body, "username").Trim()}' from {Ip(ctx)}"); return Error(401, "Wrong username or password"); } failures.TryRemove(Ip(ctx), out _); StartSession(ctx, user.Name); GameServer.Log($"[panel] {user.Name} logged in ({Ip(ctx)})"); return Results.Json(new { ok = true }); }); app.MapPost("/api/logout", (HttpContext ctx) => { if (!CsrfOk(ctx)) return Forbidden("Missing header"); string token = ctx.Request.Cookies[CookieName]; if (token != null) sessions.TryRemove(token, out _); ctx.Response.Cookies.Delete(CookieName, CookieOptions(ctx)); return Results.Json(new { ok = true }); }); app.MapPost("/api/me/password", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied; var body = await Body(ctx); if (users.Verify(user.Name, Str(body, "current")) == null) return Error(403, "Current password is wrong"); string error = users.SetPassword(user.Name, Str(body, "password")); if (error != null) return Error(400, error); GameServer.Log($"[panel] {user.Name} changed their password"); return Ok("Password changed"); }); // --- Server app.MapGet("/api/status", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied; return await Run(s => s.PanelStatus(), () => host.OfflineStatus()); }); app.MapGet("/api/players", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied; bool withAddress = user.Role >= PanelRole.Admin; // IP-Adressen nur für Admins return await Run(s => s.PanelPlayers(withAddress), () => Array.Empty()); }); app.MapPost("/api/players/{id:int}/{action}", async (HttpContext ctx, int id, string action) => { var need = action == "kick" ? PanelRole.Moderator : PanelRole.Admin; var user = Require(ctx, need, out var denied); if (user == null) return denied; var body = await Body(ctx); string by = By(user); string reason = Str(body, "reason"); return action switch { "kick" => await RunText(s => s.PanelKick(id, false, reason, by)), "ban" => await RunText(s => s.PanelKick(id, true, reason, by)), "op" => await RunText(s => s.PanelSetAdmin(id, true, by)), "deop" => await RunText(s => s.PanelSetAdmin(id, false, by)), _ => Error(404, "Unknown action") }; }); app.MapPost("/api/say", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied; var body = await Body(ctx); string text = Str(body, "text"), by = By(user); return await RunText(s => s.PanelSay(text, by)); }); // --- Banns app.MapGet("/api/bans", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied; return await Run(s => s.PanelBans(), () => config.BannedSteamIds.ToList()); }); app.MapPost("/api/bans/add", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; var body = await Body(ctx); string id = Str(body, "id"), reason = Str(body, "reason"), by = By(user); return await RunText(s => s.PanelBanId(id, reason, by), () => OfflineBan(id, true, by)); }); app.MapPost("/api/bans/remove", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; var body = await Body(ctx); string id = Str(body, "id"), by = By(user); return await RunText(s => s.PanelUnban(id, by), () => OfflineBan(id, false, by)); }); // --- Ingame-Admins (dürfen im Spiel /admin benutzen) app.MapGet("/api/admins", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied; return await Run(s => s.PanelAdmins(), () => new { steamVerification = !string.IsNullOrWhiteSpace(config.SteamWebApiKey), admins = config.Admins.Select(id => new { steamId = id, name = (string)null, online = false }).ToList() }); }); app.MapPost("/api/admins/{action}", async (HttpContext ctx, string action) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; var body = await Body(ctx); string id = Str(body, "id").Trim(), by = By(user); if (id.Length != 17 || !id.All(char.IsDigit)) return Error(400, "Enter a SteamID64 (17 digits)"); if (action != "add" && action != "remove") return Error(404, "Unknown action"); bool add = action == "add"; return await RunText(s => s.RunCommandCaptured((add ? "op " : "deop ") + id, by), () => { config.Admins.Remove(id); if (add) config.Admins.Add(id); try { config.Save(host.ConfigPath); } catch (Exception e) { return "Could not save server.json: " + e.Message; } GameServer.Log($"SteamID {id} is {(add ? "now an admin" : "no longer an admin")} ({by})"); return $"SteamID {id} is {(add ? "now an admin" : "no longer an admin")}"; }); }); // --- Konsole app.MapGet("/api/console", (HttpContext ctx) => { var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied; long.TryParse(ctx.Request.Query["after"], out long after); var lines = LogBuffer.Since(after, 1000); return Results.Json(new { last = lines.Count > 0 ? lines[^1].Seq : Math.Max(after, 0), lines = lines.Select(l => new { seq = l.Seq, time = l.Time, text = l.Text }) }); }); app.MapPost("/api/console", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; var body = await Body(ctx); string command = Str(body, "command").Trim(); if (command.Length == 0 || command.Length > 500) return Error(400, "Enter a command"); string by = By(user); if (host.State != HostState.Running || host.Server == null) { // Server aus: Host-Befehle (start, status, panel ...) - Ausgabe aus dem Log einsammeln long from = LogBuffer.LastSeq; GameServer.Log($"> {command} ({by})"); host.Command(command); await Task.Delay(400); return Ok(string.Join("\n", LogBuffer.Since(from, 200).Skip(1).Select(l => l.Text))); } return await RunText(s => s.RunCommandCaptured(command, by)); }); // --- Welt / Backups app.MapGet("/api/backups", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; return await Run(s => s.PanelBackups(), () => SaveStore.BackupDirs(dataDir).Select(d => Path.GetFileName(d)).ToList()); }); app.MapPost("/api/world/{action}", async (HttpContext ctx, string action) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; var body = await Body(ctx); string name = Str(body, "name"), by = By(user); return await RunText(s => s.PanelWorld(action, name, by), () => OfflineWorld(action, name, by)); }); // --- Server steuern (Start/Stopp/Neustart) app.MapPost("/api/server/{action}", async (HttpContext ctx, string action) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; string by = By(user); await Task.CompletedTask; return action switch { "start" => Ok(host.RequestStart(by)), "stop" => Ok(host.RequestStop(by)), "restart" => Ok(host.RequestRestart(by)), _ => Error(404, "Unknown action") }; }); // --- Einstellungen (server.json) app.MapGet("/api/config", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; return await Run(s => s.PanelConfig(), () => ConfigEditor.Build(config)); }); app.MapPost("/api/config", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; var body = await Body(ctx); bool owner = user.Role >= PanelRole.Owner; string by = By(user); return await RunText(s => s.PanelUpdateConfig(body, owner, by), () => ConfigEditor.Apply(config, host.ConfigPath, body, owner, by)); }); // --- Spielstand als ZIP app.MapGet("/api/save/download", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; await Task.CompletedTask; if (!SaveStore.HasSave(dataDir)) return Error(404, "There is no save game on the server yet"); GameServer.Log($"[panel] {user.Name} downloaded the save game"); return Results.File(SaveStore.ExportZip(dataDir), "application/zip", $"msco-save-{DateTime.Now:yyyyMMdd-HHmmss}.zip"); }); app.MapPost("/api/save/upload", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; // Größere Grenze nur für den Spielstand var sizeFeature = ctx.Features.Get(); if (sizeFeature != null && !sizeFeature.IsReadOnly) sizeFeature.MaxRequestBodySize = 64L * 1024 * 1024; byte[] zip; try { using var ms = new MemoryStream(); await ctx.Request.Body.CopyToAsync(ms); zip = ms.ToArray(); } catch (Exception e) { return Error(400, "Upload failed: " + e.Message); } if (zip.Length == 0) return Error(400, "Choose a ZIP, RAR or 7z file"); string by = By(user); var server = host.Server; string result; if (server == null || host.State != HostState.Running) result = SaveStore.ImportZipOffline(dataDir, zip, by); else { var task = server.Invoke(() => server.ImportSaveZip(zip, by)); if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy"); result = await task; } return result.StartsWith("Save game uploaded") ? Ok(result) : Error(400, result); }); // --- Benutzer (nur Owner) app.MapGet("/api/users", (HttpContext ctx) => { var user = Require(ctx, PanelRole.Owner, out var denied); if (user == null) return denied; return Results.Json(users.All().Select(u => new { name = u.Name, role = u.Role.ToString(), created = u.CreatedUtc.ToString("o"), lastLogin = u.LastLoginUtc?.ToString("o") })); }); app.MapPost("/api/users", async (HttpContext ctx) => { var user = Require(ctx, PanelRole.Owner, out var denied); if (user == null) return denied; var body = await Body(ctx); if (!Enum.TryParse(Str(body, "role"), true, out PanelRole role)) return Error(400, "Unknown role"); string name = Str(body, "name").Trim(); string error = users.Add(name, Str(body, "password"), role); if (error != null) return Error(400, error); GameServer.Log($"[panel] {user.Name} added user '{name}' ({role})"); return Ok($"User {name} created"); }); app.MapPost("/api/users/{name}/{action}", async (HttpContext ctx, string name, string action) => { var user = Require(ctx, PanelRole.Owner, out var denied); if (user == null) return denied; var body = await Body(ctx); string error; switch (action) { case "role": if (!Enum.TryParse(Str(body, "role"), true, out PanelRole role)) return Error(400, "Unknown role"); error = users.SetRole(name, role); if (error == null) GameServer.Log($"[panel] {user.Name} changed the role of '{name}' to {role}"); break; case "password": error = users.SetPassword(name, Str(body, "password")); if (error == null) { EndSessionsOf(name); GameServer.Log($"[panel] {user.Name} reset the password of '{name}'"); } break; case "delete": if (string.Equals(name, user.Name, StringComparison.OrdinalIgnoreCase)) return Error(400, "You cannot delete yourself"); error = users.Remove(name); if (error == null) { EndSessionsOf(name); GameServer.Log($"[panel] {user.Name} deleted user '{name}'"); } break; default: return Error(404, "Unknown action"); } return error != null ? Error(400, error) : Ok("Done"); }); } // ---------------------------------------------------------------- Hilfen static string By(PanelUsers.User user) => user.Name + " (panel)"; const string NotRunning = "The game server is not running - start it on the dashboard"; /// In der Server-Schleife ausführen; ist der Server aus, whenStopped (oder Fehler 409). async Task Run(Func func, Func whenStopped = null) { var server = host.Server; if (server == null || host.State != HostState.Running) return whenStopped != null ? Results.Json(whenStopped()) : Error(409, NotRunning); var task = server.Invoke(() => func(server)); if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return whenStopped != null ? Results.Json(whenStopped()) : Error(503, "The server is busy or stopping"); return Results.Json(await task); } async Task RunText(Func func, Func whenStopped = null) { var server = host.Server; if (server == null || host.State != HostState.Running) return whenStopped != null ? Ok(whenStopped()) : Error(409, NotRunning); var task = server.Invoke(() => func(server)); if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy or stopping"); return Ok(await task); } /// Server aus: Bann direkt in server.json setzen bzw. aufheben. string OfflineBan(string id, bool ban, string by) { id = (id ?? "").Trim(); if (ban) { bool valid = (id.Length == 17 && id.All(char.IsDigit)) || (id.StartsWith("name:") && id.Length > 5 && id.Length < 60); if (!valid) return "Enter a SteamID64 (17 digits) or name:playername"; if (config.BannedSteamIds.Contains(id)) return id + " is already banned"; config.BannedSteamIds.Add(id); } else if (!config.BannedSteamIds.Remove(id)) return id + " is not banned"; try { config.Save(host.ConfigPath); } catch (Exception e) { return "Could not save server.json: " + e.Message; } GameServer.Log($"{id} was {(ban ? "banned" : "unbanned")} by {by}"); return id + (ban ? " is banned" : " is no longer banned"); } /// Server aus: Spielstand-Aktionen direkt auf den Dateien. string OfflineWorld(string action, string name, string by) { switch (action) { case "backup": if (!SaveStore.HasSave(dataDir)) return "There is no save game to back up"; SaveStore.Backup(dataDir); GameServer.Log($"Save game backed up by {by}"); return "Backup created"; case "reset": return SaveStore.ResetOffline(dataDir, by); case "restore": return SaveStore.RestoreOffline(dataDir, name, by); case "resetpoint": return SaveStore.SetResetPoint(dataDir) ?? "The current save game is now the reset point"; case "reloadsave": return "The server is stopped - it loads the save game when you start it"; case "save": return "The server is stopped - nothing to save"; default: return "Unknown action"; } } static IResult Ok(string message) => Results.Json(new { ok = true, message }); static IResult Error(int status, string message) => Results.Json(new { error = message }, statusCode: status); static IResult Forbidden(string message) => Error(403, message); /// /// Nur Anfragen aus der eigenen Oberfläche: sie schicken einen eigenen Header, den eine fremde Webseite ohne /// CORS-Freigabe nicht setzen kann (Schutz vor CSRF). Zusätzlich ist das Cookie SameSite=Strict. /// static bool CsrfOk(HttpContext ctx) => ctx.Request.Headers[CsrfHeader] == "1"; PanelUsers.User CurrentUser(HttpContext ctx) { string token = ctx.Request.Cookies[CookieName]; if (token == null || !sessions.TryGetValue(token, out var session)) return null; if (session.Expires < DateTime.UtcNow) { sessions.TryRemove(token, out _); return null; } var user = users.Find(session.User); if (user == null) { sessions.TryRemove(token, out _); return null; } session.Expires = DateTime.UtcNow + SessionLifetime; // gleitend verlängern return user; } PanelUsers.User Require(HttpContext ctx, PanelRole role, out IResult denied) { denied = null; if (!HttpMethods.IsGet(ctx.Request.Method) && !CsrfOk(ctx)) { denied = Forbidden("Missing header"); return null; } var user = CurrentUser(ctx); if (user == null) { denied = Error(401, "Please log in"); return null; } if (user.Role < role) { denied = Forbidden("Your role (" + user.Role + ") is not allowed to do this"); return null; } return user; } void StartSession(HttpContext ctx, string name) { string token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); sessions[token] = new Session { User = name, Expires = DateTime.UtcNow + SessionLifetime }; ctx.Response.Cookies.Append(CookieName, token, CookieOptions(ctx)); // Alte Sitzungen aufräumen foreach (var kv in sessions) if (kv.Value.Expires < DateTime.UtcNow) sessions.TryRemove(kv.Key, out _); } void EndSessionsOf(string name) { foreach (var kv in sessions) if (string.Equals(kv.Value.User, name, StringComparison.OrdinalIgnoreCase)) sessions.TryRemove(kv.Key, out _); } static CookieOptions CookieOptions(HttpContext ctx) => new() { HttpOnly = true, SameSite = SameSiteMode.Strict, Secure = ctx.Request.IsHttps, Path = ctx.Request.PathBase.HasValue ? ctx.Request.PathBase.Value : "/", MaxAge = SessionLifetime }; static string Ip(HttpContext ctx) => ctx.Connection.RemoteIpAddress?.ToString() ?? "?"; bool RateLimited(HttpContext ctx) { if (!failures.TryGetValue(Ip(ctx), out var list)) return false; lock (list) { list.RemoveAll(t => DateTime.UtcNow - t > LoginWindow); return list.Count >= MaxLoginFailures; } } void RecordFailure(HttpContext ctx) { var list = failures.GetOrAdd(Ip(ctx), _ => new List()); lock (list) list.Add(DateTime.UtcNow); } static async Task Body(HttpContext ctx) { try { if (ctx.Request.ContentLength is null or 0) return default; return await JsonSerializer.DeserializeAsync(ctx.Request.Body); } catch { return default; } } static string Str(JsonElement body, string name) => body.ValueKind == JsonValueKind.Object && body.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : ""; }