- Panel over HTTPS on the game port (TLS handshake detected like HTTP); self-signed certificate in data/panel_cert.pfx (fingerprint in the log) or an own PFX (PanelCertificatePath); remote HTTP is redirected to HTTPS (not locally / behind a proxy); cookie Secure over HTTPS; PanelHttps=false turns it off - Panel login: also locked per user name (16 failures / 15 min), failure lists bounded, user names in the log without control characters - panel_users.json, panel_sessions.json, server.json, certificate: owner-only on Linux - Players: names/chat without control or direction characters (no fake log lines), no '<' '>' in names; '* ' system-style chat, msco.* messages (time/weather) and MSCO_INSTALL only from admins; NaN/absurd money changes dropped (could break the shared money for good) - Extra headers: Permissions-Policy, Cross-Origin-Opener-Policy - 11 new tests (165 pass) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1023 lines
48 KiB
C#
1023 lines
48 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; }
|
|
/// <summary>Interner HTTPS-Port (gleicher Port wie das Spiel: TLS-Verbindungen landen hier), 0 = kein HTTPS.</summary>
|
|
public int InternalTlsPort { get; private set; }
|
|
/// <summary>SHA-256-Fingerabdruck des HTTPS-Zertifikats (zum Vergleichen im Browser), null = kein HTTPS.</summary>
|
|
public string CertificateFingerprint { 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;
|
|
// HTTPS: auf dem Spiel-Port immer (eigener interner Port), bei eigenem Panel-Port nur mit PanelHttps
|
|
System.Security.Cryptography.X509Certificates.X509Certificate2 cert = null;
|
|
bool ownCert = !string.IsNullOrWhiteSpace(config.PanelCertificatePath);
|
|
if (SharesGamePort || (config.PanelHttps && ownCert))
|
|
{
|
|
try { cert = PanelTls.Load(config, dataDir, GameServer.Log); }
|
|
catch (Exception e) { GameServer.Log("Web panel: HTTPS not available (" + e.Message + ")"); }
|
|
}
|
|
CertificateFingerprint = cert != null ? PanelTls.Fingerprint(cert) : null;
|
|
InternalTlsPort = 0;
|
|
if (SharesGamePort)
|
|
{
|
|
bind = IPAddress.Loopback;
|
|
port = InternalPort = FreeLoopbackPort();
|
|
if (cert != null) InternalTlsPort = FreeLoopbackPort();
|
|
}
|
|
else
|
|
{
|
|
bind = ParseBind(config.PanelBindAddress);
|
|
port = config.PanelPort;
|
|
}
|
|
// Eigener Panel-Port: nur mit eigenem Zertifikat HTTPS (meist steht dort ein Reverse-Proxy, der HTTP erwartet)
|
|
bool ownPortHttps = !SharesGamePort && config.PanelHttps && ownCert && cert != null;
|
|
|
|
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { ContentRootPath = AppContext.BaseDirectory });
|
|
builder.Logging.ClearProviders();
|
|
builder.WebHost.ConfigureKestrel(k =>
|
|
{
|
|
k.Listen(bind, port, o => { if (ownPortHttps) o.UseHttps(cert); });
|
|
if (InternalTlsPort > 0) k.Listen(IPAddress.Loopback, InternalTlsPort, o => o.UseHttps(cert));
|
|
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);
|
|
}
|
|
|
|
// Von außen per HTTP auf dem Spiel-Port: auf HTTPS umleiten, damit Passwort und Sitzung verschlüsselt sind
|
|
// (lokal/über einen eigenen Proxy nicht - der Proxy macht HTTPS selbst)
|
|
app.Use(async (ctx, next) =>
|
|
{
|
|
var remote = ctx.Connection.RemoteIpAddress;
|
|
if (InternalTlsPort > 0 && config.PanelHttps && !config.PanelTrustProxy && !ctx.Request.IsHttps
|
|
&& remote != null && !IPAddress.IsLoopback(remote))
|
|
{
|
|
string host = ctx.Request.Host.HasValue ? ctx.Request.Host.Value : "localhost";
|
|
ctx.Response.StatusCode = HttpMethods.IsGet(ctx.Request.Method) || HttpMethods.IsHead(ctx.Request.Method) ? 301 : 308;
|
|
ctx.Response.Headers.Location = "https://" + host + ctx.Request.PathBase + ctx.Request.Path + ctx.Request.QueryString;
|
|
return;
|
|
}
|
|
await next();
|
|
});
|
|
|
|
// Sicherheits-Header
|
|
app.Use(async (ctx, next) =>
|
|
{
|
|
var h = ctx.Response.Headers;
|
|
h["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()";
|
|
h["Cross-Origin-Opener-Policy"] = "same-origin";
|
|
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();
|
|
string scheme = InternalTlsPort > 0 || ownPortHttps ? "https" : "http";
|
|
if (SharesGamePort)
|
|
GameServer.Log($"Web panel: {scheme}://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
|
|
else
|
|
GameServer.Log($"Web panel: {scheme}://{(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, bool tls = false)
|
|
{
|
|
var real = (browser.Client.RemoteEndPoint as IPEndPoint)?.Address;
|
|
using var panel = new TcpClient { NoDelay = true };
|
|
int localPort = 0;
|
|
try
|
|
{
|
|
if (tls && InternalTlsPort == 0) return; // kein Zertifikat
|
|
await panel.ConnectAsync(IPAddress.Loopback, tls ? InternalTlsPort : 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);
|
|
string name = Str(body, "username").Trim();
|
|
// Auch pro Benutzer sperren: sonst ließe sich ein Passwort über viele IPs verteilt durchprobieren
|
|
string userKey = "user:" + name.ToLowerInvariant();
|
|
if (RateLimited(userKey, MaxLoginFailures * 2, TimeSpan.FromMinutes(15)))
|
|
return Error(429, "Too many failed logins for this user - wait 15 minutes");
|
|
var user = users.Verify(name, Str(body, "password"));
|
|
if (user == null)
|
|
{
|
|
RecordFailure(ctx);
|
|
RecordFailure(userKey);
|
|
GameServer.Log($"[panel] Failed login for '{LogSafe(name)}' from {Ip(ctx)}");
|
|
return Error(401, "Wrong username or password");
|
|
}
|
|
failures.TryRemove(Ip(ctx), out _);
|
|
failures.TryRemove(userKey, 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));
|
|
});
|
|
|
|
// --- Mods (data/mods): bekommen die Spieler beim Beitreten vom Server
|
|
app.MapGet("/api/mods", async (HttpContext ctx) =>
|
|
{
|
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
|
return await Run(s => s.PanelMods(), () => ModStore.Load(dataDir, null)
|
|
.Select(m => new { id = m.Id, name = m.Name, version = m.Version, sha256 = m.Sha256, size = m.Size }).ToList());
|
|
});
|
|
|
|
app.MapPost("/api/mods/upload", async (HttpContext ctx) =>
|
|
{
|
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
|
var sizeFeature = ctx.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
|
|
if (sizeFeature != null && !sizeFeature.IsReadOnly) sizeFeature.MaxRequestBodySize = ModStore.MaxModBytes + 1024;
|
|
byte[] data;
|
|
try
|
|
{
|
|
using var ms = new MemoryStream();
|
|
await ctx.Request.Body.CopyToAsync(ms);
|
|
data = ms.ToArray();
|
|
}
|
|
catch (Exception e) { return Error(400, "Upload failed: " + e.Message); }
|
|
string name = ctx.Request.Query["name"].ToString();
|
|
string error = ModStore.Add(dataDir, name, data);
|
|
if (error != null) return Error(400, error);
|
|
GameServer.Log($"[panel] {By(user)} uploaded the mod {Path.GetFileName(name)}");
|
|
return await RunText(s => s.ReloadMods(), () => "Mod uploaded - players get it when the server runs");
|
|
});
|
|
|
|
// Mod-Liste des Keymasters (msco.mbm.wtf/mods) durchsuchen und als Server-Mod übernehmen
|
|
app.MapGet("/api/mods/catalog", async (HttpContext ctx) =>
|
|
{
|
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
|
List<ModCatalog.Entry> list;
|
|
try { list = await ModCatalog.ListAsync(); }
|
|
catch (Exception e) { return Error(502, "The MSCO mod list is not reachable (" + e.Message + ")"); }
|
|
var installed = ModStore.Load(dataDir, null).ToDictionary(m => m.Id, StringComparer.OrdinalIgnoreCase);
|
|
return Results.Json(list.Select(m => new
|
|
{
|
|
id = m.Id, name = m.Name, description = m.Description, author = m.Author, version = m.Version,
|
|
size = m.Size, changelog = m.Changelog, downloads = m.Downloads, updated = m.UpdatedUtc,
|
|
installed = installed.ContainsKey(m.Id),
|
|
upToDate = installed.TryGetValue(m.Id, out var have) && string.Equals(have.Sha256, m.Sha256, StringComparison.OrdinalIgnoreCase),
|
|
}));
|
|
});
|
|
|
|
app.MapPost("/api/mods/catalog/{id}/install", async (HttpContext ctx, string id) =>
|
|
{
|
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
|
string error = await ModCatalog.InstallAsync(dataDir, id);
|
|
if (error != null) return Error(400, error);
|
|
GameServer.Log($"[panel] {By(user)} added the mod {id} from the MSCO mod list");
|
|
return await RunText(s => s.ReloadMods(), () => "Mod added - players get it when the server runs");
|
|
});
|
|
|
|
app.MapPost("/api/mods/{id}/remove", async (HttpContext ctx, string id) =>
|
|
{
|
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
|
if (!ModStore.Remove(dataDir, id)) return Error(404, "Mod not found");
|
|
GameServer.Log($"[panel] {By(user)} removed the mod {id}");
|
|
return await RunText(s => s.ReloadMods(), () => "Mod removed");
|
|
});
|
|
|
|
app.MapPost("/api/mods/reload", async (HttpContext ctx) =>
|
|
{
|
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
|
return await RunText(s => s.ReloadMods(), () => "The server is stopped - mods are loaded when it starts");
|
|
});
|
|
|
|
// --- 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));
|
|
PanelTls.Restrict(tmp);
|
|
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) => RecordFailure(Ip(ctx));
|
|
|
|
void RecordFailure(string key)
|
|
{
|
|
if (failures.Count > 20_000) failures.Clear(); // Speicher begrenzen (viele verschiedene IPs/Namen)
|
|
var list = failures.GetOrAdd(key, _ => new List<DateTime>());
|
|
lock (list)
|
|
{
|
|
list.Add(DateTime.UtcNow);
|
|
if (list.Count > 100) list.RemoveRange(0, list.Count - 100);
|
|
}
|
|
}
|
|
|
|
bool RateLimited(string key, int max, TimeSpan window)
|
|
{
|
|
if (!failures.TryGetValue(key, out var list)) return false;
|
|
lock (list)
|
|
{
|
|
list.RemoveAll(t => DateTime.UtcNow - t > window);
|
|
return list.Count >= max;
|
|
}
|
|
}
|
|
|
|
/// <summary>Text aus einer Anfrage fürs Log: ohne Zeilenumbrüche/Steuerzeichen (keine gefälschten Log-Zeilen), gekürzt.</summary>
|
|
static string LogSafe(string s)
|
|
{
|
|
if (string.IsNullOrEmpty(s)) return "";
|
|
var chars = s.Take(64).Select(c => char.IsControl(c) ? '?' : c).ToArray();
|
|
return new string(chars);
|
|
}
|
|
|
|
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() ?? "" : "";
|
|
}
|