Files
MSCO-Dedicated-Server/AdminPanel.cs
T
MrMahoandClaude Opus 5.5 835d3ce998 Panel: unticking 'web panel enabled' + restart panel really turns it off (2.9.5)
The restart used to refuse when the panel was disabled, so it stayed
reachable. Now it stops the panel (also the relay on the game port);
'panel on|off' in the console turns it on/off and saves server.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:07:18 +02:00

884 lines
40 KiB
C#

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;
/// <summary>
/// Web-Panel: 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.
/// </summary>
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<string, Session> sessions = new();
readonly ConcurrentDictionary<string, List<DateTime>> failures = new();
// Weitergereichte Verbindungen (gleicher Port): lokaler Port der Weiterleitung -> echte Adresse des Browsers
readonly ConcurrentDictionary<int, IPAddress> relayed = new();
WebApplication app;
/// <summary>Einmal-Code für das erste Konto (null, sobald es einen Benutzer gibt).</summary>
public string SetupCode { get; private set; }
/// <summary>Interner Port, wenn das Panel den Spiel-Port mitbenutzt.</summary>
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"));
LoadSessions();
}
// ---------------------------------------------------------------- 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 (Reverse-Proxy 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 && SetupCode == null) CreateSetupCode();
if (SharesGamePort)
GameServer.Log($"Web panel: http://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
else
GameServer.Log($"Web panel: http://{(bind.Equals(IPAddress.Any) ? "<server>" : bind.ToString())}:{port}{basePath}/");
}
/// <summary>Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu).</summary>
public void UpdateConfig(ServerConfig newConfig) => config = newConfig;
/// <summary>
/// Mit neuen Einstellungen (Port, Unterpfad, Proxy ...) neu starten - Anmeldungen bleiben erhalten. Klappt es nicht
/// (z.B. Port belegt), läuft das Panel mit den bisherigen Einstellungen weiter.
/// </summary>
public bool RestartWeb(ServerConfig newConfig)
{
var previous = config;
Stop();
config = newConfig;
try
{
StartWeb();
GameServer.Log("Web panel restarted with the new settings");
return true;
}
catch (Exception e)
{
GameServer.Log("Web panel could not start with the new settings (" + e.Message + ") - back to the previous settings");
config = previous;
try { StartWeb(); } catch (Exception e2) { GameServer.Log("Web panel could not start: " + e2.Message); }
return false;
}
}
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("============================================================");
}
/// <summary>Konsolenbefehl "panel": Adresse und (falls noch nötig) den Einrichtungs-Code zeigen.</summary>
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
/// <summary>Beginnen die Bytes wie eine HTTP-Anfrage? (Spiel-Pakete beginnen mit einer kleinen Längenangabe.)</summary>
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 ...
}
/// <summary>Browser-Verbindung vom Spiel-Port an das intern laufende Panel weiterreichen (in beide Richtungen).</summary>
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(TokenKey(token), out _)) SaveSessions();
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<object>());
});
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)),
"restart-panel" => PanelRestart(ctx, by),
_ => Error(404, "Unknown action")
};
});
// --- Updates (prüfen, installieren, Fortschritt)
app.MapGet("/api/update", (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
return Results.Json(host.Updater.Status());
});
app.MapPost("/api/update/check", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
await host.Updater.CheckAsync();
return Results.Json(host.Updater.Status());
});
app.MapPost("/api/update/install", (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
string problem = host.Updater.Install(By(user));
return problem != null ? Error(409, problem) : Ok("Update started");
});
// --- 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<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
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");
});
}
/// <summary>Panel neu starten (z.B. nach Port-Änderung). Antwortet zuerst - der Neustart beendet diese Verbindung.</summary>
IResult PanelRestart(HttpContext ctx, string by)
{
var fresh = host.LoadConfigForRestart();
if (fresh == null) return Error(500, "Could not read server.json");
if (!fresh.PanelEnabled)
{
// "Web-Panel an" abgehakt: das Panel wird wirklich ausgeschaltet
GameServer.Log($"[panel] {by} turns the web panel off");
_ = Task.Run(async () => { await Task.Delay(700); host.RestartPanel(fresh, by); });
return Results.Json(new { ok = true, disabled = true,
message = "The web panel is turned off - turn it on again with 'panel on' in the server console or PanelEnabled in server.json" });
}
bool shares = fresh.PanelPort <= 0 || fresh.PanelPort == fresh.Port;
int port = shares ? fresh.Port : fresh.PanelPort;
string basePath = (fresh.PanelBasePath ?? "").Trim().TrimEnd('/');
if (basePath.Length > 0 && !basePath.StartsWith('/')) basePath = "/" + basePath;
// Neue Adresse für den Browser (hinter einem Proxy bleibt sie gleich)
string url = fresh.PanelTrustProxy ? null : $"{ctx.Request.Scheme}://{ctx.Request.Host.Host}:{port}{basePath}/";
GameServer.Log($"[panel] {by} restarts the web panel");
_ = Task.Run(async () =>
{
await Task.Delay(700); // Antwort erst beim Browser ankommen lassen
host.RestartPanel(fresh, by);
});
return Results.Json(new { ok = true, message = "The panel restarts - reloading in a few seconds", url });
}
// ---------------------------------------------------------------- Hilfen
static string By(PanelUsers.User user) => user.Name + " (panel)";
const string NotRunning = "The game server is not running - start it on the dashboard";
/// <summary>In der Server-Schleife ausführen; ist der Server aus, whenStopped (oder Fehler 409).</summary>
async Task<IResult> Run<T>(Func<GameServer, T> func, Func<object> 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<IResult> RunText(Func<GameServer, string> func, Func<string> 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);
}
/// <summary>Server aus: Bann direkt in server.json setzen bzw. aufheben.</summary>
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");
}
/// <summary>Server aus: Spielstand-Aktionen direkt auf den Dateien.</summary>
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 "newgame": return SaveStore.NewGameOffline(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);
/// <summary>
/// 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.
/// </summary>
static bool CsrfOk(HttpContext ctx) => ctx.Request.Headers[CsrfHeader] == "1";
PanelUsers.User CurrentUser(HttpContext ctx)
{
string token = ctx.Request.Cookies[CookieName];
if (token == null) return null;
string key = TokenKey(token);
if (!sessions.TryGetValue(key, out var session)) return null;
if (session.Expires < DateTime.UtcNow) { sessions.TryRemove(key, out _); SaveSessions(); return null; }
var user = users.Find(session.User);
if (user == null) { sessions.TryRemove(key, out _); SaveSessions(); return null; }
session.Expires = DateTime.UtcNow + SessionLifetime; // gleitend verlängern
if (DateTime.UtcNow - lastSessionSave > TimeSpan.FromMinutes(5)) SaveSessions();
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[TokenKey(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 _);
SaveSessions();
}
void EndSessionsOf(string name)
{
foreach (var kv in sessions)
if (string.Equals(kv.Value.User, name, StringComparison.OrdinalIgnoreCase)) sessions.TryRemove(kv.Key, out _);
SaveSessions();
}
// Sitzungen überstehen einen Neustart (z.B. nach einem Update). Gespeichert wird nur ein Hash des Cookies.
static readonly JsonSerializerOptions SessionJson = new() { IncludeFields = true };
readonly object sessionFileLock = new();
DateTime lastSessionSave = DateTime.MinValue;
string SessionsPath => Path.Combine(dataDir, "panel_sessions.json");
static string TokenKey(string token) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
void LoadSessions()
{
try
{
if (!File.Exists(SessionsPath)) return;
var saved = JsonSerializer.Deserialize<Dictionary<string, Session>>(File.ReadAllText(SessionsPath), SessionJson);
foreach (var kv in saved ?? new())
if (kv.Value?.User != null && kv.Value.Expires > DateTime.UtcNow) sessions[kv.Key] = kv.Value;
}
catch { } // kaputte Datei: alle melden sich neu an
}
void SaveSessions()
{
lock (sessionFileLock)
{
lastSessionSave = DateTime.UtcNow;
try
{
string tmp = SessionsPath + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(sessions.ToDictionary(kv => kv.Key, kv => kv.Value), SessionJson));
File.Move(tmp, SessionsPath, true);
}
catch { }
}
}
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<DateTime>());
lock (list) list.Add(DateTime.UtcNow);
}
static async Task<JsonElement> Body(HttpContext ctx)
{
try
{
if (ctx.Request.ContentLength is null or 0) return default;
return await JsonSerializer.DeserializeAsync<JsonElement>(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() ?? "" : "";
}