diff --git a/AdminPanel.cs b/AdminPanel.cs new file mode 100644 index 0000000..988a89d --- /dev/null +++ b/AdminPanel.cs @@ -0,0 +1,670 @@ +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; + } + + GameServer server; // nach einem Neustart aus dem Panel ein neuer (Attach) + 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(GameServer server, ServerConfig config, string dataDir) + { + this.server = server; + this.config = config; + users = new PanelUsers(Path.Combine(dataDir, "panel_users.json")); + } + + // ---------------------------------------------------------------- Start + + public static AdminPanel Start(GameServer server, ServerConfig config, string dataDir) + { + var panel = new AdminPanel(server, 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}/"); + } + + /// Neustart im selben Programm: das Panel (und die Anmeldungen) bleiben, der Server ist neu. + public void Attach(GameServer newServer, ServerConfig newConfig) + { + server = newServer; + 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"), 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("/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(() => server.PanelStatus()); + }); + + 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(() => server.PanelPlayers(withAddress)); + }); + + 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(() => server.PanelKick(id, false, reason, by)), + "ban" => await RunText(() => server.PanelKick(id, true, reason, by)), + "op" => await RunText(() => server.PanelSetAdmin(id, true, by)), + "deop" => await RunText(() => server.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(() => server.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(() => server.PanelBans()); + }); + + 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(() => server.PanelBanId(id, reason, 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(() => server.PanelUnban(id, by)); + }); + + // --- 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); + return await RunText(() => server.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(() => server.PanelBackups()); + }); + + 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(() => server.PanelWorld(action, name, by)); + }); + + // --- Server steuern (Start/Stopp/Neustart) + app.MapPost("/api/server/{action}", async (HttpContext ctx, string action) => + { + var user = Require(ctx, action == "shutdown" ? PanelRole.Owner : PanelRole.Admin, out var denied); if (user == null) return denied; + string by = By(user); + return await RunText(() => server.PanelServerAction(action, by)); + }); + + // --- 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(() => server.PanelConfig()); + }); + + 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(() => server.PanelUpdateConfig(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; + var task = server.Invoke(() => server.ExportSaveZip()); + if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy"); + GameServer.Log($"[panel] {user.Name} downloaded the save game"); + return Results.File(await task, "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 file"); + string by = By(user); + return await RunText(() => server.ImportSaveZip(zip, by)); + }); + + // --- 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)"; + + async Task Run(Func func) + { + var task = server.Invoke(func); + if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy or stopping"); + return Results.Json(await task); + } + + async Task RunText(Func func) + { + var task = server.Invoke(func); + if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy or stopping"); + return Ok(await task); + } + + 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() ?? "" : ""; +} diff --git a/Dockerfile b/Dockerfile index 37a8e89..c457cc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,8 @@ WORKDIR /src COPY . . RUN dotnet publish -c Release -o /app -FROM mcr.microsoft.com/dotnet/runtime:10.0 +# aspnet: enthält Kestrel für das Web-Panel +FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app COPY --from=build /app . ENV MSCO_DATA=/data @@ -17,6 +18,7 @@ ENV HOME=/tmp # Befehle an den laufenden Server: docker exec msco-server msco players RUN printf '#!/bin/sh\nexec dotnet /app/MSCO.Server.dll --cmd "$@"\n' > /usr/local/bin/msco && chmod +x /usr/local/bin/msco VOLUME /data +# Spiel UND Web-Panel (Browser werden am selben Port erkannt) EXPOSE 7777/tcp # docker stop schickt SIGTERM -> Server speichert den Weltzustand STOPSIGNAL SIGTERM diff --git a/GameServer.cs b/GameServer.cs index 6ac170e..cdbffc1 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -30,6 +30,7 @@ public sealed class GameServer /// SteamID von Steam bestätigt (Ticket per Web API geprüft). Nur dann gibt es Admin-Rechte. public bool SteamVerified; public bool IsAdmin; + public DateTime JoinedAt; public DateTime DestroyWindowStart; public int DestroyCount; /// @@ -51,6 +52,7 @@ public sealed class GameServer readonly WorldState world; readonly Dictionary clients = new(); readonly ConcurrentQueue commands = new(); + readonly ConcurrentQueue actions = new(); // vom Web-Panel: laufen in der Server-Schleife readonly Stopwatch clock = Stopwatch.StartNew(); TcpListener listener; @@ -187,13 +189,54 @@ public sealed class GameServer Log("Server stopped."); } + /// Web-Panel (gesetzt vom Programm). Teilt es sich den Spiel-Port, werden Browser hier erkannt und weitergereicht. + public AdminPanel Panel { get; set; } + readonly ConcurrentQueue sniffedGameClients = new(); + void AcceptClients() { while (listener.Pending()) { - var c = new Client { Id = nextId++, Conn = new Connection(listener.AcceptTcpClient()), State = ClientState.New }; - clients[c.Id] = c; + var tcp = listener.AcceptTcpClient(); + if (Panel != null && Panel.SharesGamePort) _ = SniffAsync(tcp); + else AddClient(tcp); } + while (sniffedGameClients.TryDequeue(out var tcp)) AddClient(tcp); + } + + void AddClient(TcpClient tcp) + { + var c = new Client { Id = nextId++, Conn = new Connection(tcp), State = ClientState.New }; + clients[c.Id] = c; + } + + /// + /// Gleicher Port für Spiel und Web-Panel: in die ersten Bytes schauen (ohne sie zu verbrauchen). Ein Browser + /// schickt "GET /", "POST" ... - das Spiel beginnt mit einer kleinen Längenangabe (Bytes 2-4 sind 0). + /// Beide Seiten reden zuerst, der Server muss also nichts senden, bevor er es weiß. + /// + async Task SniffAsync(TcpClient tcp) + { + var buffer = new byte[4]; + int n; + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (true) + { + n = await tcp.Client.ReceiveAsync(buffer, SocketFlags.Peek, timeout.Token); + if (n == 0) { tcp.Close(); return; } + if (n >= 4 || buffer[0] < 'A' || buffer[0] > 'Z') break; // kein Buchstabe = sicher das Spiel + await Task.Delay(15, timeout.Token); + } + } + catch + { + try { tcp.Close(); } catch { } + return; + } + if (AdminPanel.LooksLikeHttp(buffer, n)) await Panel.RelayAsync(tcp); + else sniffedGameClients.Enqueue(tcp); } void PollClients() @@ -242,7 +285,7 @@ public sealed class GameServer } // Regelmäßig den aktuellen Spielstand vom Spiel holen - if (config.SaveSyncMinutes > 0 && now >= nextSaveSync) + if (config.SaveSyncMinutes > 0 && !GameStopped && now >= nextSaveSync) { nextSaveSync = now + config.SaveSyncMinutes * 60.0; RequestSave("regular save"); @@ -267,7 +310,7 @@ public sealed class GameServer else if (result.Status == 0) Log("Master server not reachable - server is not listed right now."); else ReportReachability(result); } - if (!offline && now >= nextHeartbeat && heartbeat == null) + if (!offline && !GameStopped && now >= nextHeartbeat && heartbeat == null) { nextHeartbeat = now + HeartbeatSeconds; heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion); @@ -480,6 +523,7 @@ public sealed class GameServer // Andere Version ist erlaubt (der Spieler wurde in der Mod schon gewarnt), nur vermerken if (version != ModVersion) Log($"{c.Name} uses MSCO {version} (server {ModVersion}) - syncing may not work correctly"); if (config.BannedSteamIds.Contains(c.SteamId)) { Reject(c, "You are banned from this server"); return; } + if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; } if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; } c.State = ClientState.Verifying; @@ -490,6 +534,7 @@ public sealed class GameServer { string error = c.Verification.Result; if (error != null) { Reject(c, error); return; } + if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; } if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; } // Nur eine von Steam bestätigte SteamID gilt als Beweis. Ohne SteamWebApiKey kann jeder eine beliebige @@ -503,6 +548,7 @@ public sealed class GameServer } c.State = ClientState.Joined; + c.JoinedAt = DateTime.UtcNow; var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList(); c.Conn.Send(MsgType.Welcome, Wire.Build(w => { @@ -836,15 +882,24 @@ public sealed class GameServer void RunCommands() { - while (commands.TryDequeue(out string line)) + while (actions.TryDequeue(out var action)) + { + try { action(); } + catch (Exception e) { Log("Panel action failed: " + e.Message); } + } + while (commands.TryDequeue(out string line)) ExecuteCommand(line); + } + + void ExecuteCommand(string line) + { { // BOM am Zeilenanfang (z.B. bei umgeleiteter Eingabe) ignorieren string[] args = line.Trim().TrimStart('').Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - if (args.Length == 0) continue; + if (args.Length == 0) return; switch (args[0].ToLowerInvariant()) { case "help": - Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | backup | restore-save | set-reset-point | say | op | deop | admins | save | reset-world | reload-save | stop"); + Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | backup | restore-save | set-reset-point | panel | stop-game | start-game | restart | say | op | deop | admins | save | reset-world | reload-save | stop"); break; case "say": if (args.Length < 2) { Log("say "); break; } @@ -921,6 +976,10 @@ public sealed class GameServer case "save": SaveWorld(); break; + case "panel": + if (Panel != null) Panel.PrintInfo(); + else Log("The web panel is disabled (PanelEnabled in server.json)"); + break; case "reset-world": ResetWorld("Console"); break; @@ -954,6 +1013,17 @@ public sealed class GameServer case "exit": running = false; break; + case "restart": + Log("Restarting (settings, license and save game are loaded again) ..."); + RestartRequested = true; + running = false; + break; + case "stop-game": + Log(StopGame("Console")); + break; + case "start-game": + Log(StartGame("Console")); + break; default: Log("Unknown command, type 'help'"); break; @@ -993,5 +1063,348 @@ public sealed class GameServer } } - public static void Log(string text) => Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {text}"); + public static void Log(string text) + { + string time = DateTime.Now.ToString("HH:mm:ss"); + Console.WriteLine($"[{time}] {text}"); + LogBuffer.Add(time, text); + } + + // ---------------------------------------------------------------- Web-Panel + // Das Panel läuft auf eigenen Threads. Alles, was den Server-Zustand liest oder ändert, wird hier in die + // Server-Schleife gegeben (höchstens ein paar Millisekunden Wartezeit) - so gibt es keine Überschneidungen. + + /// Funktion in der Server-Schleife ausführen und das Ergebnis zurückgeben. + public Task Invoke(Func func) + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + actions.Enqueue(() => + { + try { done.SetResult(func()); } + catch (Exception e) { done.SetException(e); } + }); + return done.Task; + } + + /// Konsolenbefehl ausführen und alles zurückgeben, was er ins Log schreibt. + public string RunCommandCaptured(string line, string by) + { + Log($"> {line} ({by})"); + long from = LogBuffer.LastSeq; + ExecuteCommand(line); + return string.Join("\n", LogBuffer.Since(from, 500).Select(l => l.Text)); + } + + public object PanelStatus() + { + string resetPoint = File.Exists(Path.Combine(originalPath, "defaultES2File.txt")) + ? File.GetLastWriteTimeUtc(Path.Combine(originalPath, "defaultES2File.txt")).ToString("o") : null; + return new + { + name = config.Name, + description = config.Description, + version = ModVersion, + players = JoinedCount, + maxPlayers = config.MaxPlayers, + gameStopped = GameStopped, + uptimeSeconds = (long)clock.Elapsed.TotalSeconds, + port = config.Port, + isPublic = config.Public, + requireSteam = config.RequireSteam, + steamVerification = steam.Enabled, + objects = world.Objects.Count, + actions = world.Events.Count, + boughtItems = world.Appeared.Count, + admins = config.Admins.Count, + bans = config.BannedSteamIds.Count, + backups = SaveBackups().Count, + resetPoint, + saveSyncMinutes = config.SaveSyncMinutes + }; + } + + public object PanelPlayers(bool withAddress) => + clients.Values.Where(c => c.State == ClientState.Joined && !c.Conn.IsClosing).OrderBy(c => c.Id).Select(c => new + { + id = c.Id, + name = c.Name, + steamId = c.SteamId, + admin = c.IsAdmin, + verified = c.SteamVerified, + address = withAddress ? c.Conn.RemoteAddress : null, + joinedAt = c.JoinedAt.ToString("o") + }).ToList(); + + public object PanelBans() => config.BannedSteamIds.ToList(); + + // ---------------------------------------------------------------- Start / Stopp / Neustart + + /// Spiel offline (Panel läuft weiter): Spieler raus, nicht mehr gelistet, niemand kann beitreten. + public bool GameStopped { get; private set; } + /// Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden). + public bool RestartRequested { get; private set; } + + public string StopGame(string by) + { + if (GameStopped) return "The game server is already stopped"; + foreach (var c in clients.Values.Where(x => x.State == ClientState.Joined).ToList()) + Reject(c, $"The server was stopped by {by}"); + GameStopped = true; + saveUpload = null; + waitingForSave.Clear(); + SaveWorld(); + Log($"Game server stopped by {by} - players cannot join until it is started again (the panel keeps running)"); + return "Game server stopped"; + } + + public string StartGame(string by) + { + if (!GameStopped) return "The game server is already running"; + if (!LoadSave()) return "Could not load the save game - see the console"; + GameStopped = false; + HeartbeatSoon(); + Log($"Game server started by {by}"); + return "Game server started"; + } + + public string PanelServerAction(string action, string by) + { + switch (action) + { + case "stop-game": return StopGame(by); + case "start-game": return StartGame(by); + case "restart": + Log($"Restart requested by {by} ..."); + RestartRequested = true; + running = false; + return "Restarting - the panel is back in a few seconds"; + case "shutdown": + Log($"Shutdown requested by {by} - the server and the panel stop"); + running = false; + return "Shutting down"; + default: return "Unknown action"; + } + } + + // ---------------------------------------------------------------- Einstellungen (server.json) im Panel + + public object PanelConfig() => new + { + name = config.Name, + description = config.Description, + maxPlayers = config.MaxPlayers, + isPublic = config.Public, + requireSteam = config.RequireSteam, + publicAddress = config.PublicAddress, + saveSyncMinutes = config.SaveSyncMinutes, + autosaveSeconds = config.AutosaveSeconds, + port = config.Port, + panelEnabled = config.PanelEnabled, + panelPort = config.PanelPort, + panelBindAddress = config.PanelBindAddress, + panelTrustProxy = config.PanelTrustProxy, + panelBasePath = config.PanelBasePath, + licenseKeySet = !string.IsNullOrWhiteSpace(config.LicenseKey), + steamWebApiKeySet = !string.IsNullOrWhiteSpace(config.SteamWebApiKey), + environmentOverrides = config.EnvironmentOverrides + }; + + /// Einstellungen ändern. Schlüssel (Lizenz, Steam Web API) nur als Owner; leer = unverändert. + public string PanelUpdateConfig(System.Text.Json.JsonElement b, bool owner, string by) + { + if (b.ValueKind != System.Text.Json.JsonValueKind.Object) return "Invalid settings"; + var changed = new List(); + bool restartNeeded = false; + + string Text(string name, string current, int max) + { + if (!b.TryGetProperty(name, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String) return current; + string s = v.GetString().Trim(); + if (s.Length > max) s = s[..max]; + if (s != current) changed.Add(name); + return s; + } + int Number(string name, int current, int min, int max) + { + if (!b.TryGetProperty(name, out var v) || !v.TryGetInt32(out int n)) return current; + n = Math.Clamp(n, min, max); + if (n != current) changed.Add(name); + return n; + } + bool Flag(string name, bool current) + { + if (!b.TryGetProperty(name, out var v) || (v.ValueKind != System.Text.Json.JsonValueKind.True && v.ValueKind != System.Text.Json.JsonValueKind.False)) return current; + bool f = v.GetBoolean(); + if (f != current) changed.Add(name); + return f; + } + + string name = Text("name", config.Name, 60); + if (name.Length == 0) return "The server name cannot be empty"; + config.Name = name; + config.Description = Text("description", config.Description, 300); + config.MaxPlayers = Number("maxPlayers", config.MaxPlayers, 1, 64); + config.Public = Flag("isPublic", config.Public); + config.RequireSteam = Flag("requireSteam", config.RequireSteam); + config.PublicAddress = Text("publicAddress", config.PublicAddress, 200); + config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240); + config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600); + + int before = changed.Count; + config.Port = Number("port", config.Port, 1, 65535); + config.PanelEnabled = Flag("panelEnabled", config.PanelEnabled); + config.PanelPort = Number("panelPort", config.PanelPort, 0, 65535); + config.PanelBindAddress = Text("panelBindAddress", config.PanelBindAddress, 60); + config.PanelTrustProxy = Flag("panelTrustProxy", config.PanelTrustProxy); + config.PanelBasePath = Text("panelBasePath", config.PanelBasePath, 60); + if (changed.Count > before) restartNeeded = true; + + foreach (string secret in new[] { "licenseKey", "steamWebApiKey" }) + { + if (!b.TryGetProperty(secret, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String || v.GetString().Trim().Length == 0) continue; + if (!owner) return "Only the owner can change keys"; + string value = v.GetString().Trim(); + if (value.Length > 200) return "Key too long"; + if (secret == "licenseKey") config.LicenseKey = value; else config.SteamWebApiKey = value; + changed.Add(secret); + restartNeeded = true; + } + + if (changed.Count == 0) return "Nothing changed"; + try { config.Save(configPath); } + catch (Exception e) { return "Could not save server.json: " + e.Message; } + HeartbeatSoon(); // Name/Beschreibung/Spielerzahl gleich an die Serverliste + Log($"Settings changed by {by}: {string.Join(", ", changed)}"); + var overridden = changed.Where(c => config.EnvironmentOverrides.Any(e => e.Replace("_", "").Contains(c.ToUpperInvariant().Replace("ISPUBLIC", "PUBLIC")))).ToList(); + return "Settings saved" + (restartNeeded ? " - some changes need a restart" : "") + + (overridden.Count > 0 ? " (note: " + string.Join(", ", overridden) + " is also set by an environment variable, which wins on restart)" : ""); + } + + // ---------------------------------------------------------------- Spielstand als ZIP hoch-/herunterladen + + const long MaxSaveZip = 64L * 1024 * 1024; + + /// + /// Spielstand aus einer ZIP übernehmen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem + /// Ordner in der ZIP) - Pfade aus der ZIP werden nie benutzt. Danach: Backup, laden, neuer Reset-Punkt, Welt leer, + /// alle verbinden neu. + /// + public string ImportSaveZip(byte[] zip, string by) + { + var found = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(zip), System.IO.Compression.ZipArchiveMode.Read); + long total = 0; + foreach (var entry in archive.Entries) + { + string file = Path.GetFileName(entry.FullName.Replace('\\', '/')); + string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase)); + if (known == null) continue; + if (entry.Length > MaxSaveZip || (total += entry.Length) > MaxSaveZip) return "The save game is too large"; + using var s = entry.Open(); + using var ms = new MemoryStream(); + s.CopyTo(ms); + found[known] = ms.ToArray(); + } + } + catch (InvalidDataException) { return "This is not a valid ZIP file"; } + if (!found.ContainsKey("defaultES2File.txt")) + return "No defaultES2File.txt in the ZIP - zip the files from %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car"; + + try + { + BackupSave(); + Directory.CreateDirectory(savePath); + foreach (string name in SaveFiles) + { + string target = Path.Combine(savePath, name); + if (found.TryGetValue(name, out var data)) + { + File.WriteAllBytes(target + ".tmp", data); + File.Move(target + ".tmp", target, true); + } + else if (File.Exists(target)) File.Delete(target); // gehört zum alten Spielstand + } + } + catch (Exception e) { return "Could not write the save game: " + e.Message; } + if (!LoadSave()) return "The uploaded save game could not be loaded - see the console"; + SetResetPoint(); + saveUpload = null; + waitingForSave.Clear(); + world.Reset(); + SaveWorld(); + Log($"New save game uploaded by {by} ({found.Count} files) - it is also the new reset point"); + ReconnectAll($"{by} uploaded a new save game - reconnecting..."); + return $"Save game uploaded ({found.Count} files) - everyone reconnects"; + } + + /// Aktuellen Spielstand als ZIP. + public byte[] ExportSaveZip() + { + using var ms = new MemoryStream(); + using (var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, true)) + { + foreach (string name in SaveFiles) + { + string file = Path.Combine(savePath, name); + if (File.Exists(file)) System.IO.Compression.ZipFileExtensions.CreateEntryFromFile(archive, file, name); + } + } + return ms.ToArray(); + } + + public object PanelBackups() => SaveBackups().Select(d => Path.GetFileName(d)).ToList(); + + public string PanelKick(int id, bool ban, string reason, string by) + { + if (!clients.TryGetValue(id, out var target) || target.State != ClientState.Joined) return "Player not found"; + return KickOrBan(target, ban, reason, by); + } + + /// SteamID sperren, auch wenn der Spieler gerade nicht online ist. + public string PanelBanId(string steamId, string reason, string by) + { + steamId = (steamId ?? "").Trim(); + bool valid = (steamId.Length == 17 && steamId.All(char.IsDigit)) || (steamId.StartsWith("name:") && steamId.Length > 5 && steamId.Length < 60); + if (!valid) return "Enter a SteamID64 (17 digits) or name:playername"; + var online = clients.Values.FirstOrDefault(c => c.State == ClientState.Joined && c.SteamId == steamId); + if (online != null) return KickOrBan(online, true, reason, by); + if (config.BannedSteamIds.Contains(steamId)) return steamId + " is already banned"; + config.BannedSteamIds.Add(steamId); + config.Save(configPath); + Log($"{steamId} was banned by {by}"); + return steamId + " is banned"; + } + + public string PanelUnban(string id, string by) => Unban(id, by); + + public string PanelSay(string text, string by) + { + text = (text ?? "").Trim(); + if (text.Length == 0) return "Empty message"; + if (text.Length > 200) text = text[..200]; + ServerChat(text); + Log($"[chat] Server ({by}): {text}"); + return "Sent"; + } + + public string PanelSetAdmin(int id, bool admin, string by) => RunCommandCaptured((admin ? "op " : "deop ") + id, by); + + public string PanelWorld(string action, string arg, string by) + { + switch (action) + { + case "save": SaveWorld(); return "World state saved"; + case "backup": return BackupNow(by); + case "reset": return ResetWorld(by); + case "restore": return RestoreSave(arg ?? "", by); + case "resetpoint": + if (!SetResetPoint()) return "No save game to use as reset point"; + Log($"{by} set the current save game as the reset point"); + return "The current save game is now the reset point"; + case "reloadsave": return RunCommandCaptured("reload-save", by); + default: return "Unknown action"; + } + } } diff --git a/LogBuffer.cs b/LogBuffer.cs new file mode 100644 index 0000000..958b42e --- /dev/null +++ b/LogBuffer.cs @@ -0,0 +1,33 @@ +namespace MSCO.Server; + +/// Die letzten Log-Zeilen im Speicher - für die Live-Konsole im Web-Panel. +public static class LogBuffer +{ + public readonly record struct Line(long Seq, string Time, string Text); + + const int Capacity = 3000; + static readonly Queue lines = new(); + static readonly object gate = new(); + static long seq; + + public static long LastSeq { get { lock (gate) return seq; } } + + public static void Add(string time, string text) + { + lock (gate) + { + lines.Enqueue(new Line(++seq, time, text)); + while (lines.Count > Capacity) lines.Dequeue(); + } + } + + /// Zeilen nach der angegebenen Nummer (höchstens max, die neuesten). + public static List Since(long after, int max) + { + lock (gate) + { + var result = lines.Where(l => l.Seq > after).ToList(); + return result.Count > max ? result.GetRange(result.Count - max, max) : result; + } + } +} diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj index 1f805c3..797a70e 100644 --- a/MSCO.Server.csproj +++ b/MSCO.Server.csproj @@ -15,6 +15,15 @@ logo.ico + + + + + + + + + diff --git a/Panel/app.css b/Panel/app.css new file mode 100644 index 0000000..8867207 --- /dev/null +++ b/Panel/app.css @@ -0,0 +1,184 @@ +:root { + --bg: #17171b; + --panel: #1e1e23; + --panel-2: #24242a; + --sidebar: #0f0f12; + --border: #2e2e36; + --text: #ececf1; + --muted: #8e8e99; + --accent: #f26b1d; + --accent-hover: #ff823a; + --online: #3dd68c; + --pending: #f2c230; + --danger: #e5484d; + --danger-hover: #f2555a; + --radius: 10px; + --mono: ui-monospace, "Cascadia Mono", "JetBrains Mono", Consolas, monospace; +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--bg); + color: var(--text); + font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; +} +.hidden { display: none !important; } +.muted { color: var(--muted); } +.error { color: var(--danger); margin: 0; } +h1, h2, h3 { margin: 0; font-weight: 700; letter-spacing: -0.01em; } +h1 { font-size: 22px; } +h2 { font-size: 18px; } +h3 { font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ---------------------------------------------------------------- Knöpfe & Felder */ +.btn { + border: 0; border-radius: 8px; padding: 9px 16px; font: inherit; font-weight: 600; cursor: pointer; + background: var(--panel-2); color: var(--text); transition: background .12s, transform .06s; + white-space: nowrap; +} +.btn:hover { background: #2f2f37; } +.btn:active { transform: translateY(1px); } +.btn:disabled { opacity: .5; cursor: default; } +.btn.primary { background: var(--accent); color: #fff; } +.btn.primary:hover { background: var(--accent-hover); } +.btn.danger { background: rgba(229, 72, 77, .14); color: #ff8a8d; } +.btn.danger:hover { background: var(--danger); color: #fff; } +.btn.ghost { background: transparent; color: var(--muted); } +.btn.ghost:hover { background: var(--panel-2); color: var(--text); } +.btn.small { padding: 6px 11px; font-size: 13px; } +.btn.wide { width: 100%; } + +input, select, textarea { + width: 100%; background: var(--panel-2); border: 1px solid var(--border); color: var(--text); + border-radius: 8px; padding: 9px 12px; font: inherit; outline: none; transition: border-color .12s; +} +input:focus, select:focus, textarea:focus { border-color: var(--accent); } +label { display: block; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; } +label input, label select { margin-top: 6px; text-transform: none; letter-spacing: normal; font-size: 14px; font-weight: 400; } + +.pill { + display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; + background: rgba(242, 107, 29, .16); color: var(--accent); letter-spacing: .03em; +} +.pill.gray { background: rgba(255, 255, 255, .08); color: var(--muted); } +.pill.green { background: rgba(61, 214, 140, .14); color: var(--online); } +.pill.red { background: rgba(229, 72, 77, .15); color: #ff8a8d; } + +/* ---------------------------------------------------------------- Anmeldung */ +.auth { min-height: 100%; display: grid; place-items: center; padding: 24px 16px; + background: radial-gradient(ellipse at top, rgba(242, 107, 29, .12), transparent 60%), var(--bg); } +.auth-card { width: 100%; max-width: 380px; background: var(--panel); border: 1px solid var(--border); + border-radius: 14px; padding: 28px; display: grid; gap: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, .4); } +.auth-card h1 { margin-top: 6px; } +.auth-card p { margin: -8px 0 0; } + +.brand { display: flex; align-items: center; gap: 12px; } +.brand img { width: 40px; height: 40px; border-radius: 9px; } +.brand-title { font-weight: 800; font-size: 20px; letter-spacing: .02em; } +.brand-sub { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } + +/* ---------------------------------------------------------------- Layout */ +.layout { display: grid; grid-template-columns: 232px 1fr; min-height: 100%; } +.sidebar { background: var(--sidebar); padding: 20px 14px; display: flex; flex-direction: column; gap: 22px; + position: sticky; top: 0; height: 100vh; } +.sidebar .brand { padding: 0 8px; } +nav { display: grid; gap: 2px; } +nav button { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; background: transparent; + border: 0; color: var(--muted); font: inherit; font-weight: 600; padding: 10px 12px; border-radius: 8px; cursor: pointer; } +nav button:hover { background: #18181d; color: var(--text); } +nav button.active { background: var(--panel-2); color: var(--text); box-shadow: inset 3px 0 0 var(--accent); } +nav .ico { width: 18px; text-align: center; opacity: .9; } +.sidebar-foot { margin-top: auto; display: grid; gap: 8px; padding: 0 8px; } +.me { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 600; overflow: hidden; } +.me span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.content { min-width: 0; display: flex; flex-direction: column; } +.topbar { display: flex; align-items: center; gap: 14px; padding: 18px 28px; border-bottom: 1px solid var(--border); + background: rgba(23, 23, 27, .85); backdrop-filter: blur(8px); position: sticky; top: 0; z-index: 5; } +.server-name { font-weight: 700; font-size: 16px; } +.server-meta { color: var(--muted); font-size: 12px; } +.status-dot { margin-left: auto; width: 10px; height: 10px; border-radius: 50%; background: var(--pending); box-shadow: 0 0 0 4px rgba(242, 194, 48, .15); } +.status-dot.ok { background: var(--online); box-shadow: 0 0 0 4px rgba(61, 214, 140, .15); } +.status-dot.bad { background: var(--danger); box-shadow: 0 0 0 4px rgba(229, 72, 77, .15); } +.menu { display: none; } +.page { padding: 24px 28px 40px; display: grid; gap: 20px; align-content: start; } + +/* ---------------------------------------------------------------- Karten */ +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; } +.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; } +.stat-label { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } +.stat-value { font-size: 26px; font-weight: 800; margin-top: 6px; font-variant-numeric: tabular-nums; } +.stat-sub { color: var(--muted); font-size: 12px; margin-top: 2px; } +.section { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.section-head { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; } +.section-head h2 { margin-right: auto; } +.section-body { padding: 18px; } +.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.row > input { flex: 1; min-width: 180px; } +.empty { color: var(--muted); padding: 26px 18px; text-align: center; } + +/* ---------------------------------------------------------------- Tabellen */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th { text-align: left; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; + font-weight: 700; padding: 10px 18px; border-bottom: 1px solid var(--border); white-space: nowrap; } +td { padding: 11px 18px; border-bottom: 1px solid #26262d; vertical-align: middle; } +tr:last-child td { border-bottom: 0; } +tbody tr:hover { background: rgba(255, 255, 255, .02); } +.actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; } +.id { font-family: var(--mono); color: var(--muted); } +.mono { font-family: var(--mono); font-size: 13px; } +.player-name { font-weight: 600; display: flex; align-items: center; gap: 8px; } + +/* ---------------------------------------------------------------- Konsole */ +.console { background: #0c0c0f; font-family: var(--mono); font-size: 12.5px; line-height: 1.55; height: 60vh; min-height: 300px; + overflow-y: auto; padding: 14px 16px; white-space: pre-wrap; word-break: break-word; } +.console .t { color: #5d5d68; margin-right: 8px; } +.console .l-chat { color: #9fd3ff; } +.console .l-panel { color: #ffb37f; } +.console .l-cmd { color: var(--accent); } +.console .l-warn { color: #ff8a8d; } +.console.small { height: 260px; min-height: 0; } +.form-grid { display: grid; gap: 14px; max-width: 420px; } +.grid-2 { display: grid; gap: 20px; } +.form-grid.wide { max-width: none; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px 20px; } +.label-row { display: inline-flex; align-items: center; gap: 6px; } +.hint { display: block; margin-top: 5px; color: var(--muted); font-size: 12px; text-transform: none; letter-spacing: normal; font-weight: 400; } +.toggle { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding-top: 18px; } +.toggle input { width: 18px; height: 18px; margin: 1px 0 0; accent-color: var(--accent); flex: none; } +input[type=file] { padding: 7px; } +input[type=file]::file-selector-button { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 5px 10px; margin-right: 10px; cursor: pointer; } +.console-input { display: flex; gap: 10px; padding: 12px; border-top: 1px solid var(--border); background: var(--panel); } +.console-input input { font-family: var(--mono); } + +/* ---------------------------------------------------------------- Modal & Hinweise */ +.modal-back { position: fixed; inset: 0; background: rgba(0, 0, 0, .6); display: grid; place-items: center; padding: 16px; z-index: 50; + animation: fade .12s ease-out; } +.modal { width: 100%; max-width: 440px; background: var(--panel); border: 1px solid var(--border); border-radius: 14px; + padding: 22px; display: grid; gap: 14px; box-shadow: 0 24px 70px rgba(0, 0, 0, .5); } +.modal.danger { border-top: 3px solid var(--danger); } +.modal p { margin: 0; color: var(--muted); } +.modal .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } +@keyframes fade { from { opacity: 0; } } + +.toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 60; max-width: min(380px, calc(100vw - 32px)); } +.toast { background: var(--panel-2); border: 1px solid var(--border); border-left: 3px solid var(--online); border-radius: 10px; + padding: 11px 14px; box-shadow: 0 10px 30px rgba(0, 0, 0, .4); animation: slide .2s ease-out; white-space: pre-wrap; } +.toast.err { border-left-color: var(--danger); } +@keyframes slide { from { transform: translateX(20px); opacity: 0; } } + +/* ---------------------------------------------------------------- Handy */ +@media (max-width: 820px) { + .layout { grid-template-columns: 1fr; } + .sidebar { position: fixed; inset: 0 auto 0 0; width: 250px; z-index: 40; transform: translateX(-100%); transition: transform .18s; } + .sidebar.open { transform: none; box-shadow: 20px 0 60px rgba(0, 0, 0, .5); } + .menu { display: inline-block; } + .topbar { padding: 14px 16px; } + .page { padding: 16px 16px 32px; } + th, td { padding: 10px 12px; } + .console { height: 55vh; } +} diff --git a/Panel/app.js b/Panel/app.js new file mode 100644 index 0000000..08fe82f --- /dev/null +++ b/Panel/app.js @@ -0,0 +1,690 @@ +"use strict"; +// MSCO Server Panel. Alle Texte (Spielernamen, Log ...) werden nur als Text eingefügt, nie als HTML. + +const $ = (id) => document.getElementById(id); +const ROLES = { Moderator: 1, Admin: 2, Owner: 3 }; +const state = { user: null, page: "dashboard", status: null, timers: [], consoleLast: 0, history: [], historyIndex: -1 }; + +// ------------------------------------------------------------------ Hilfen + +function h(tag, attrs, ...children) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v === null || v === undefined || v === false) continue; + if (k === "class") el.className = v; + else if (k.startsWith("on")) el.addEventListener(k.slice(2), v); + else el.setAttribute(k, v === true ? "" : v); + } + for (const c of children.flat()) { + if (c === null || c === undefined || c === false) continue; + el.append(c instanceof Node ? c : document.createTextNode(String(c))); + } + return el; +} + +async function api(method, path, body) { + const res = await fetch("api/" + path, { + method, + credentials: "same-origin", + headers: { "Content-Type": "application/json", "X-MSCO-Panel": "1" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + let data = null; + try { data = await res.json(); } catch { /* leer */ } + if (res.status === 401 && state.user) { state.user = null; showAuth(); } + if (!res.ok) throw new Error((data && data.error) || "HTTP " + res.status); + return data; +} + +function toast(text, error) { + const el = h("div", { class: "toast" + (error ? " err" : "") }, text); + $("toasts").append(el); + setTimeout(() => el.remove(), error ? 6000 : 3500); +} + +async function act(promise) { + try { + const r = await promise; + if (r && r.message) toast(r.message); + return r; + } catch (e) { + toast(e.message, true); + return null; + } +} + +function can(role) { return state.user && ROLES[state.user.role] >= ROLES[role]; } + +function duration(seconds) { + const d = Math.floor(seconds / 86400), hr = Math.floor(seconds % 86400 / 3600), m = Math.floor(seconds % 3600 / 60); + return d > 0 ? `${d}d ${hr}h` : hr > 0 ? `${hr}h ${m}m` : `${m}m`; +} + +function since(iso) { + if (!iso) return "–"; + const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); + return s < 60 ? "just now" : duration(s); +} + +function backupDate(name) { + const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(name); + return m ? `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}:${m[6]}` : name; +} + +function every(ms, fn) { + fn(); + state.timers.push(setInterval(fn, ms)); +} + +function clearTimers() { + state.timers.forEach(clearInterval); + state.timers = []; +} + +// ------------------------------------------------------------------ Dialoge + +function modal({ title, text, danger, fields = [], confirm = "OK", cancel = "Cancel" }) { + return new Promise((resolve) => { + const inputs = fields.map((f) => { + const input = f.options + ? h("select", {}, f.options.map((o) => h("option", { value: o, selected: o === f.value }, o))) + : h("input", { type: f.type || "text", placeholder: f.placeholder || "", value: f.value || "", maxlength: f.max || 200, autocomplete: "off" }); + return { f, input, row: h("label", {}, f.label, input) }; + }); + const close = (value) => { back.remove(); resolve(value); }; + const form = h("form", { class: "modal" + (danger ? " danger" : "") }, + h("h2", {}, title), + text ? h("p", {}, text) : null, + inputs.map((i) => i.row), + h("div", { class: "buttons" }, + h("button", { class: "btn ghost", type: "button", onclick: () => close(null) }, cancel), + h("button", { class: "btn " + (danger ? "danger" : "primary"), type: "submit" }, confirm))); + form.addEventListener("submit", (e) => { + e.preventDefault(); + const values = {}; + inputs.forEach((i) => { values[i.f.name] = i.input.value; }); + close(values); + }); + const back = h("div", { class: "modal-back", onclick: (e) => { if (e.target === back) close(null); } }, form); + back.addEventListener("keydown", (e) => { if (e.key === "Escape") close(null); }); + $("modalRoot").append(back); + (inputs[0] ? inputs[0].input : form.querySelector("button[type=submit]")).focus(); + }); +} + +// ------------------------------------------------------------------ Anmeldung + +let setupMode = false; + +function showAuth(needsSetup, serverName) { + clearTimers(); + setupMode = !!needsSetup; + $("app").classList.add("hidden"); + $("auth").classList.remove("hidden"); + if (serverName) $("authServer").textContent = serverName; + $("authTitle").textContent = setupMode ? "Set up the panel" : "Sign in"; + $("authHint").textContent = setupMode + ? "Create the owner account. The setup code is shown in the server console (docker logs msco-server)." + : ""; + $("codeRow").classList.toggle("hidden", !setupMode); + $("password2Row").classList.toggle("hidden", !setupMode); + $("password").setAttribute("autocomplete", setupMode ? "new-password" : "current-password"); + $("authButton").textContent = setupMode ? "Create account" : "Sign in"; + $("authError").classList.add("hidden"); + (setupMode ? $("code") : $("username")).focus(); +} + +$("authForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const err = $("authError"); + err.classList.add("hidden"); + const username = $("username").value.trim(), password = $("password").value; + try { + if (setupMode) { + if (password !== $("password2").value) throw new Error("The passwords do not match"); + await api("POST", "setup", { code: $("code").value, username, password }); + } else { + await api("POST", "login", { username, password }); + } + $("password").value = $("password2").value = ""; + await boot(); + } catch (ex) { + err.textContent = ex.message; + err.classList.remove("hidden"); + } +}); + +$("logout").addEventListener("click", async () => { + await act(api("POST", "logout")); + state.user = null; + boot(); +}); + +$("menuToggle").addEventListener("click", () => document.querySelector(".sidebar").classList.toggle("open")); + +// ------------------------------------------------------------------ Navigation + +const PAGES = [ + { id: "dashboard", label: "Dashboard", icon: "◧", role: "Moderator", render: renderDashboard }, + { id: "players", label: "Players", icon: "☺", role: "Moderator", render: renderPlayers }, + { id: "console", label: "Console", icon: "›_", role: "Moderator", render: renderConsole }, + { id: "bans", label: "Bans", icon: "⊘", role: "Moderator", render: renderBans }, + { id: "world", label: "World & Backups", icon: "⟲", role: "Admin", render: renderWorld }, + { id: "settings", label: "Settings", icon: "☰", role: "Admin", render: renderSettings }, + { id: "users", label: "Panel Users", icon: "⚿", role: "Owner", render: renderUsers }, + { id: "account", label: "My Account", icon: "⚙", role: "Moderator", render: renderAccount }, +]; + +function renderNav() { + const nav = $("nav"); + nav.replaceChildren(...PAGES.filter((p) => can(p.role)).map((p) => + h("button", { type: "button", class: p.id === state.page ? "active" : "", onclick: () => go(p.id) }, + h("span", { class: "ico" }, p.icon), p.label))); +} + +function go(pageId) { + const p = PAGES.find((x) => x.id === pageId && can(x.role)) || PAGES[0]; + state.page = p.id; + try { localStorage.setItem("msco-panel-page", p.id); } catch { /* egal */ } + document.querySelector(".sidebar").classList.remove("open"); + clearTimers(); + every(5000, refreshTopbar); + renderNav(); + const page = $("page"); + page.replaceChildren(); + p.render(page); +} + +async function refreshTopbar() { + try { + const s = await api("GET", "status"); + state.status = s; + $("serverName").textContent = s.name; + $("serverMeta").textContent = s.gameStopped + ? `game server stopped · v${s.version}` + : `${s.players}/${s.maxPlayers} players · v${s.version} · up ${duration(s.uptimeSeconds)}`; + $("statusDot").className = "status-dot " + (s.gameStopped ? "" : "ok"); + $("statusDot").title = s.gameStopped ? "Game server stopped" : "Online"; + document.title = `${s.name} · MSCO Panel`; + } catch { + $("statusDot").className = "status-dot bad"; + } +} + +// ------------------------------------------------------------------ Dashboard + +function renderDashboard(page) { + const cards = h("div", { class: "cards" }); + const control = h("div", { class: "section-body row" }); + if (can("Admin")) page.append(h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Server control"), h("span", { class: "muted", id: "gameState" }, "")), + control)); + const recent = h("div", { class: "console small" }); + page.append( + cards, + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Recent activity"), + h("button", { class: "btn small", type: "button", onclick: () => go("console") }, "Open console")), + recent)); + + const card = (label, value, sub) => h("div", { class: "card" }, + h("div", { class: "stat-label" }, label), h("div", { class: "stat-value" }, value), sub ? h("div", { class: "stat-sub" }, sub) : null); + + every(4000, async () => { + try { + const s = await api("GET", "status"); + if (can("Admin")) renderControl(control, s); + cards.replaceChildren( + card("Players", `${s.players} / ${s.maxPlayers}`, s.isPublic ? "listed in the server browser" : "not listed (private)"), + card("Uptime", duration(s.uptimeSeconds), "version " + s.version), + card("World", s.objects.toLocaleString(), `moved objects · ${s.actions} part actions · ${s.boughtItems} bought items`), + card("Save game", s.saveSyncMinutes > 0 ? `every ${s.saveSyncMinutes} min` : "on join", + s.resetPoint ? "reset point from " + new Date(s.resetPoint).toLocaleString() : "no reset point yet"), + card("Backups", s.backups, "save game backups"), + card("Admins / Bans", `${s.admins} / ${s.bans}`, s.steamVerification ? "Steam verification on" : "Steam verification OFF (no Web API key)")); + } catch (e) { cards.replaceChildren(h("div", { class: "card" }, e.message)); } + }); + every(3000, async () => { + try { + const r = await api("GET", "console?after=0"); + recent.replaceChildren(...r.lines.slice(-40).map(consoleLine)); + recent.scrollTop = recent.scrollHeight; + } catch { /* Topbar zeigt den Fehler */ } + }); +} + +async function serverAction(action, confirmText) { + if (confirmText) { + const ok = await modal(confirmText); + if (!ok) return; + } + await act(api("POST", "server/" + action)); + setTimeout(refreshTopbar, 800); +} + +function renderControl(el, s) { + const state = $("gameState"); + if (state) state.textContent = s.gameStopped ? "game server is stopped - players cannot join" : "game server is running"; + el.replaceChildren( + s.gameStopped + ? h("button", { class: "btn primary", type: "button", onclick: () => serverAction("start-game") }, "▶ Start") + : h("button", { class: "btn", type: "button", onclick: () => serverAction("stop-game", { title: "Stop the game server?", + text: "All players are kicked and nobody can join until you start it again. The panel keeps running.", danger: true, confirm: "Stop" }) }, "■ Stop"), + h("button", { class: "btn", type: "button", onclick: () => serverAction("restart", { title: "Restart the server?", + text: "Settings (server.json), license and save game are loaded again. Players reconnect automatically. You stay logged in.", confirm: "Restart" }) }, "⟳ Restart"), + can("Owner") ? h("button", { class: "btn danger", type: "button", onclick: () => serverAction("shutdown", { title: "Shut down completely?", + text: "The game server AND this panel stop. Start it again on the machine (e.g. docker compose up -d).", danger: true, confirm: "Shut down" }) }, "⏻ Shut down") : null); +} + +// ------------------------------------------------------------------ Einstellungen + +function renderSettings(page) { + const box = h("div", { class: "grid-2" }); + page.append(box); + load(); + + async function load() { + let c; + try { c = await api("GET", "config"); } catch (e) { toast(e.message, true); return; } + const env = c.environmentOverrides || []; + const fields = {}; + const envNote = (name) => env.some((e) => e.replace(/_/g, "").includes(name.toUpperCase().replace("ISPUBLIC", "PUBLIC"))) + ? h("span", { class: "pill gray", title: "Also set by an environment variable (.env) - that value wins on restart" }, "ENV") : null; + const text = (name, label, value, opts = {}) => { + const input = h("input", { value: value ?? "", maxlength: opts.max || 100, placeholder: opts.placeholder || "", type: opts.type || "text", autocomplete: "off" }); + fields[name] = () => input.value; + return h("label", {}, h("span", { class: "label-row" }, label, envNote(name), opts.restart ? h("span", { class: "pill gray" }, "restart") : null), input, + opts.hint ? h("small", { class: "hint" }, opts.hint) : null); + }; + const number = (name, label, value, opts = {}) => { + const input = h("input", { type: "number", value, min: opts.min, max: opts.max }); + fields[name] = () => parseInt(input.value, 10); + return h("label", {}, h("span", { class: "label-row" }, label, envNote(name), opts.restart ? h("span", { class: "pill gray" }, "restart") : null), input, + opts.hint ? h("small", { class: "hint" }, opts.hint) : null); + }; + const toggle = (name, label, value, hint, restart) => { + const input = h("input", { type: "checkbox", checked: value }); + fields[name] = () => input.checked; + return h("label", { class: "toggle" }, input, h("span", {}, + h("span", { class: "label-row" }, label, envNote(name), restart ? h("span", { class: "pill gray" }, "restart") : null), + hint ? h("small", { class: "hint" }, hint) : null)); + }; + const secret = (name, label, isSet) => { + const input = h("input", { type: "password", placeholder: isSet ? "•••••••• (set - leave empty to keep)" : "not set", autocomplete: "new-password", disabled: !can("Owner") }); + fields[name] = () => input.value; + return h("label", {}, h("span", { class: "label-row" }, label, envNote(name), h("span", { class: "pill gray" }, "restart")), input); + }; + + const save = async () => { + const body = {}; + for (const [k, get] of Object.entries(fields)) { + const v = get(); + if (typeof v === "number" && isNaN(v)) continue; + body[k] = v; + } + if (await act(api("POST", "config", body))) load(); + }; + + box.replaceChildren( + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "General"), h("span", { class: "muted" }, "applies immediately")), + h("div", { class: "section-body form-grid wide" }, + text("name", "Server name", c.name, { max: 60 }), + text("description", "Description", c.description, { max: 300 }), + number("maxPlayers", "Max players", c.maxPlayers, { min: 1, max: 64 }), + text("publicAddress", "Public address", c.publicAddress, { max: 200, placeholder: "automatic", hint: "IP or domain players connect to (empty = automatic)" }), + toggle("isPublic", "Listed in the server browser", c.isPublic), + toggle("requireSteam", "Steam required", c.requireSteam, "Off = players without Steam can join too"))), + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Saving")), + h("div", { class: "section-body form-grid wide" }, + number("saveSyncMinutes", "Take the save game from a player every … minutes", c.saveSyncMinutes, { min: 0, max: 240, hint: "0 = only when someone joins" }), + number("autosaveSeconds", "Write the world state every … seconds", c.autosaveSeconds, { min: 10, max: 3600 }))), + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Network & panel"), h("span", { class: "muted" }, "needs a restart")), + h("div", { class: "section-body form-grid wide" }, + number("port", "Game port", c.port, { min: 1, max: 65535, restart: true }), + number("panelPort", "Panel port", c.panelPort, { min: 0, max: 65535, restart: true, hint: "0 = same port as the game (recommended)" }), + text("panelBindAddress", "Panel bind address", c.panelBindAddress, { max: 60, restart: true, hint: "only with an own panel port - 127.0.0.1 = only via a local reverse proxy" }), + text("panelBasePath", "Panel sub path", c.panelBasePath, { max: 60, restart: true, placeholder: "/", hint: "e.g. /panel when the proxy serves it there" }), + toggle("panelTrustProxy", "Behind a reverse proxy", c.panelTrustProxy, "Trust X-Forwarded-For / -Proto / -Prefix", true), + toggle("panelEnabled", "Web panel enabled", c.panelEnabled, "Turning it off locks you out of this panel after the next program start", true))), + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Keys"), h("span", { class: "muted" }, can("Owner") ? "only owners can change keys" : "owner only")), + h("div", { class: "section-body form-grid wide" }, + secret("licenseKey", "License key", c.licenseKeySet), + secret("steamWebApiKey", "Steam Web API key", c.steamWebApiKeySet))), + h("div", { class: "row" }, h("button", { class: "btn primary", type: "button", onclick: save }, "Save settings"), + h("button", { class: "btn", type: "button", onclick: () => serverAction("restart", { title: "Restart the server now?", + text: "Loads the saved settings. Players reconnect automatically.", confirm: "Restart" }) }, "Restart to apply"))); + } +} + +// ------------------------------------------------------------------ Spieler + +function renderPlayers(page) { + const tbody = h("tbody"); + const count = h("span", { class: "pill gray" }, "…"); + const say = h("input", { placeholder: "Message to all players…", maxlength: 200 }); + const sendSay = async () => { + if (!say.value.trim()) return; + if (await act(api("POST", "say", { text: say.value }))) say.value = ""; + }; + say.addEventListener("keydown", (e) => { if (e.key === "Enter") sendSay(); }); + + page.append( + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Players"), count), + h("div", { class: "table-wrap" }, + h("table", {}, + h("thead", {}, h("tr", {}, + h("th", {}, "ID"), h("th", {}, "Name"), h("th", {}, "SteamID"), + can("Admin") ? h("th", {}, "Address") : null, h("th", {}, "Online"), h("th", {}, ""))), + tbody))), + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Server message")), + h("div", { class: "section-body row" }, say, h("button", { class: "btn primary", type: "button", onclick: sendSay }, "Send")))); + + every(3000, async () => { + try { + const players = await api("GET", "players"); + count.textContent = players.length + (players.length === 1 ? " online" : " online"); + if (players.length === 0) { + tbody.replaceChildren(h("tr", {}, h("td", { colspan: 6, class: "empty" }, "Nobody is online right now."))); + return; + } + tbody.replaceChildren(...players.map(playerRow)); + } catch (e) { toast(e.message, true); } + }); +} + +function playerRow(p) { + const steam = /^\d{17}$/.test(p.steamId) + ? h("a", { href: "https://steamcommunity.com/profiles/" + p.steamId, target: "_blank", rel: "noopener noreferrer", class: "mono" }, p.steamId) + : h("span", { class: "mono muted" }, p.steamId); + const actions = h("div", { class: "actions" }, + h("button", { class: "btn small", type: "button", onclick: () => kick(p, false) }, "Kick"), + can("Admin") ? h("button", { class: "btn small danger", type: "button", onclick: () => kick(p, true) }, "Ban") : null, + can("Admin") && p.verified + ? h("button", { class: "btn small ghost", type: "button", onclick: () => setAdmin(p, !p.admin) }, p.admin ? "Remove admin" : "Make admin") + : null); + return h("tr", {}, + h("td", { class: "id" }, "#" + p.id), + h("td", {}, h("span", { class: "player-name" }, p.name, + p.admin ? h("span", { class: "pill" }, "ADMIN") : null, + p.verified ? null : h("span", { class: "pill gray", title: "SteamID not verified by Steam" }, "unverified"))), + h("td", {}, steam), + can("Admin") ? h("td", { class: "mono muted" }, p.address || "") : null, + h("td", { class: "muted" }, since(p.joinedAt)), + h("td", {}, actions)); +} + +async function kick(p, ban) { + const v = await modal({ + title: (ban ? "Ban " : "Kick ") + p.name, + text: ban ? "The player is kicked and cannot join again until you unban them." : "The player sees the reason and can join again.", + danger: true, + fields: [{ name: "reason", label: "Reason (shown to the player)", placeholder: "optional", max: 100 }], + confirm: ban ? "Ban" : "Kick", + }); + if (v) await act(api("POST", `players/${p.id}/${ban ? "ban" : "kick"}`, { reason: v.reason })); +} + +async function setAdmin(p, admin) { + const ok = await modal({ title: (admin ? "Make " : "Remove admin: ") + p.name + (admin ? " an admin?" : ""), + text: admin ? "In-game admins can kick, ban, spawn items and reset the world." : "", confirm: admin ? "Make admin" : "Remove" }); + if (ok) await act(api("POST", `players/${p.id}/${admin ? "op" : "deop"}`)); +} + +// ------------------------------------------------------------------ Konsole + +function consoleLine(l) { + const t = l.text; + const cls = t.startsWith("[chat]") ? "l-chat" : t.startsWith("[panel]") ? "l-panel" : t.startsWith(">") ? "l-cmd" + : /error|failed|could not|cannot|warning|not reachable/i.test(t) ? "l-warn" : ""; + return h("div", { class: cls }, h("span", { class: "t" }, l.time), t); +} + +function renderConsole(page) { + const out = h("div", { class: "console" }); + const input = h("input", { placeholder: can("Admin") ? "Command… (help for a list)" : "Only admins can enter commands", disabled: !can("Admin"), autocomplete: "off", spellcheck: "false" }); + const send = async () => { + const command = input.value.trim(); + if (!command) return; + state.history.push(command); + state.historyIndex = state.history.length; + input.value = ""; + await act(api("POST", "console", { command }).then(() => null)); // Ausgabe erscheint im Log + poll(); + }; + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") send(); + else if (e.key === "ArrowUp" && state.history.length) { + state.historyIndex = Math.max(0, state.historyIndex - 1); + input.value = state.history[state.historyIndex] || ""; + e.preventDefault(); + } else if (e.key === "ArrowDown" && state.history.length) { + state.historyIndex = Math.min(state.history.length, state.historyIndex + 1); + input.value = state.history[state.historyIndex] || ""; + e.preventDefault(); + } + }); + + page.append(h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Live console"), h("span", { class: "muted" }, "chat, joins, admin actions and command output")), + out, + h("div", { class: "console-input" }, input, h("button", { class: "btn primary", type: "button", onclick: send, disabled: !can("Admin") }, "Run")))); + + let last = 0; + async function poll() { + try { + const r = await api("GET", "console?after=" + last); + if (!r.lines.length) return; + const stick = out.scrollTop + out.clientHeight >= out.scrollHeight - 30; + out.append(...r.lines.map(consoleLine)); + while (out.childElementCount > 2000) out.firstChild.remove(); + last = r.last; + if (stick) out.scrollTop = out.scrollHeight; + } catch { /* Topbar zeigt den Fehler */ } + } + every(1000, poll); + if (can("Admin")) input.focus(); +} + +// ------------------------------------------------------------------ Banns + +function renderBans(page) { + const list = h("tbody"); + const id = h("input", { placeholder: "SteamID64 (17 digits) or name:playername", maxlength: 60 }); + page.append( + can("Admin") ? h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Ban a SteamID"), h("span", { class: "muted" }, "also works when the player is offline")), + h("div", { class: "section-body row" }, id, + h("button", { class: "btn danger", type: "button", onclick: async () => { + if (!id.value.trim()) return; + if (await act(api("POST", "bans/add", { id: id.value.trim() }))) { id.value = ""; load(); } + } }, "Ban"))) : null, + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Banned players")), + h("div", { class: "table-wrap" }, h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "Identity"), h("th", {}, ""))), list)))); + + async function load() { + try { + const bans = await api("GET", "bans"); + if (!bans.length) { list.replaceChildren(h("tr", {}, h("td", { colspan: 2, class: "empty" }, "Nobody is banned."))); return; } + list.replaceChildren(...bans.map((b) => h("tr", {}, + h("td", {}, /^\d{17}$/.test(b) + ? h("a", { href: "https://steamcommunity.com/profiles/" + b, target: "_blank", rel: "noopener noreferrer", class: "mono" }, b) + : h("span", { class: "mono" }, b.replace(/^name:/, "") + " (no Steam)")), + h("td", {}, can("Admin") ? h("div", { class: "actions" }, h("button", { class: "btn small", type: "button", onclick: async () => { + if (await act(api("POST", "bans/remove", { id: b }))) load(); + } }, "Unban")) : null)))); + } catch (e) { toast(e.message, true); } + } + every(10000, load); +} + +// ------------------------------------------------------------------ Welt & Backups + +function renderWorld(page) { + const backups = h("tbody"); + const zipInput = h("input", { type: "file", accept: ".zip,application/zip" }); + const uploadZip = async () => { + const file = zipInput.files && zipInput.files[0]; + if (!file) { toast("Choose a ZIP file first", true); return; } + const ok = await modal({ title: "Upload " + file.name + "?", danger: true, confirm: "Upload", + text: "The current save game is backed up, then replaced. All changes in the world are reset and everyone reconnects." }); + if (!ok) return; + try { + const res = await fetch("api/save/upload", { method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/zip", "X-MSCO-Panel": "1" }, body: file }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error((data && data.error) || "HTTP " + res.status); + toast(data.message); + zipInput.value = ""; + loadBackups(); + } catch (e) { toast(e.message, true); } + }; + const worldAction = async (action, confirmText, name) => { + if (confirmText) { + const ok = await modal({ title: confirmText.title, text: confirmText.text, danger: confirmText.danger, confirm: confirmText.button }); + if (!ok) return; + } + await act(api("POST", "world/" + action, name ? { name } : {})); + loadBackups(); + }; + + page.append( + h("div", { class: "cards" }, + h("div", { class: "card" }, + h("div", { class: "stat-label" }, "Reset world"), + h("p", { class: "muted" }, "Everything goes back to the reset point (the save game as it was uploaded to the server). The current state is backed up first. Everyone reconnects."), + h("button", { class: "btn danger", type: "button", onclick: () => worldAction("reset", { title: "Reset the whole world?", text: "Moved objects, bought items, doors, vehicle values and player positions are reset to the reset point. The current state is kept as a backup.", danger: true, button: "Reset world" }) }, "Reset world")), + h("div", { class: "card" }, + h("div", { class: "stat-label" }, "Backup"), + h("p", { class: "muted" }, "Save a copy of the current server save game. The last 5 backups are kept."), + h("button", { class: "btn primary", type: "button", onclick: () => worldAction("backup") }, "Backup now")), + h("div", { class: "card" }, + h("div", { class: "stat-label" }, "Reset point"), + h("p", { class: "muted" }, "Use the current save game as the new reset point for “Reset world”."), + h("button", { class: "btn", type: "button", onclick: () => worldAction("resetpoint", { title: "Set a new reset point?", text: "“Reset world” will go back to the save game as it is right now.", button: "Set reset point" }) }, "Set reset point")), + h("div", { class: "card" }, + h("div", { class: "stat-label" }, "Upload save game (ZIP)"), + h("p", { class: "muted" }, "ZIP the files from AppData\\LocalLow\\Amistech\\My Summer Car. They replace the server save and become the reset point. Everyone reconnects."), + h("div", { class: "row" }, zipInput, + h("button", { class: "btn primary", type: "button", onclick: uploadZip }, "Upload"), + h("a", { class: "btn ghost", href: "api/save/download" }, "Download current"))), + h("div", { class: "card" }, + h("div", { class: "stat-label" }, "Save game files"), + h("p", { class: "muted" }, "Copied new save files into data/save? Load them (they also become the reset point)."), + h("div", { class: "row" }, + h("button", { class: "btn", type: "button", onclick: () => worldAction("reloadsave", { title: "Load the save game from data/save?", text: "Everyone reconnects and gets this save game.", button: "Load" }) }, "Reload save"), + h("button", { class: "btn ghost", type: "button", onclick: () => worldAction("save") }, "Save world state")))), + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Save game backups"), h("span", { class: "muted" }, "newest first")), + h("div", { class: "table-wrap" }, h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "Created"), h("th", {}, "Folder"), h("th", {}, ""))), backups)))); + + async function loadBackups() { + try { + const list = await api("GET", "backups"); + if (!list.length) { backups.replaceChildren(h("tr", {}, h("td", { colspan: 3, class: "empty" }, "No backups yet."))); return; } + backups.replaceChildren(...list.map((name) => h("tr", {}, + h("td", {}, backupDate(name)), h("td", { class: "mono muted" }, name), + h("td", {}, h("div", { class: "actions" }, h("button", { class: "btn small", type: "button", onclick: () => + worldAction("restore", { title: "Restore " + backupDate(name) + "?", text: "The server goes back to this save game (the current one is backed up first). Everyone reconnects.", danger: true, button: "Restore" }, name) }, "Restore")))))); + } catch (e) { toast(e.message, true); } + } + every(15000, loadBackups); +} + +// ------------------------------------------------------------------ Benutzer + +function renderUsers(page) { + const tbody = h("tbody"); + page.append(h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Panel users"), + h("button", { class: "btn primary small", type: "button", onclick: addUser }, "+ Add user")), + h("div", { class: "table-wrap" }, h("table", {}, + h("thead", {}, h("tr", {}, h("th", {}, "Name"), h("th", {}, "Role"), h("th", {}, "Last login"), h("th", {}, ""))), tbody))), + h("div", { class: "section" }, h("div", { class: "section-body muted" }, + h("b", {}, "Moderator"), ": players, kick, server messages, read console & bans. ", + h("b", {}, "Admin"), ": everything on the server (ban, console commands, world reset, backups). ", + h("b", {}, "Owner"), ": additionally manages panel users."))); + + async function load() { + try { + const users = await api("GET", "users"); + tbody.replaceChildren(...users.map((u) => h("tr", {}, + h("td", {}, h("span", { class: "player-name" }, u.name, u.name.toLowerCase() === state.user.name.toLowerCase() ? h("span", { class: "pill gray" }, "you") : null)), + h("td", {}, h("span", { class: "pill" + (u.role === "Owner" ? "" : u.role === "Admin" ? " green" : " gray") }, u.role)), + h("td", { class: "muted" }, u.lastLogin ? since(u.lastLogin) + " ago" : "never"), + h("td", {}, h("div", { class: "actions" }, + h("button", { class: "btn small ghost", type: "button", onclick: () => changeRole(u) }, "Role"), + h("button", { class: "btn small ghost", type: "button", onclick: () => resetPassword(u) }, "Password"), + u.name.toLowerCase() === state.user.name.toLowerCase() ? null + : h("button", { class: "btn small danger", type: "button", onclick: () => removeUser(u) }, "Delete")))))); + } catch (e) { toast(e.message, true); } + } + + async function addUser() { + const v = await modal({ title: "Add panel user", fields: [ + { name: "name", label: "Username", max: 32 }, + { name: "password", label: "Password (min. 8 characters)", type: "password" }, + { name: "role", label: "Role", options: ["Moderator", "Admin", "Owner"], value: "Moderator" }], confirm: "Create" }); + if (v && await act(api("POST", "users", v))) load(); + } + async function changeRole(u) { + const v = await modal({ title: "Role of " + u.name, fields: [{ name: "role", label: "Role", options: ["Moderator", "Admin", "Owner"], value: u.role }], confirm: "Save" }); + if (v && await act(api("POST", `users/${encodeURIComponent(u.name)}/role`, v))) load(); + } + async function resetPassword(u) { + const v = await modal({ title: "New password for " + u.name, text: "They are logged out everywhere.", fields: [{ name: "password", label: "New password", type: "password" }], confirm: "Set password" }); + if (v && await act(api("POST", `users/${encodeURIComponent(u.name)}/password`, v))) load(); + } + async function removeUser(u) { + const ok = await modal({ title: "Delete " + u.name + "?", text: "They can no longer log in to the panel.", danger: true, confirm: "Delete" }); + if (ok && await act(api("POST", `users/${encodeURIComponent(u.name)}/delete`))) load(); + } + load(); +} + +// ------------------------------------------------------------------ Konto + +function renderAccount(page) { + const current = h("input", { type: "password", autocomplete: "current-password" }); + const next = h("input", { type: "password", autocomplete: "new-password" }); + const repeat = h("input", { type: "password", autocomplete: "new-password" }); + const form = h("form", { class: "section-body form-grid" }, + h("label", {}, "Current password", current), + h("label", {}, "New password (min. 8 characters)", next), + h("label", {}, "Repeat new password", repeat), + h("div", {}, h("button", { class: "btn primary", type: "submit" }, "Change password"))); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + if (next.value !== repeat.value) { toast("The new passwords do not match", true); return; } + if (await act(api("POST", "me/password", { current: current.value, password: next.value }))) current.value = next.value = repeat.value = ""; + }); + page.append(h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "My account"), + h("span", { class: "muted" }, `${state.user.name} · ${state.user.role}`)), form)); +} + +// ------------------------------------------------------------------ Start + +async function boot() { + let session; + try { session = await api("GET", "session"); } + catch (e) { document.body.replaceChildren(h("div", { class: "empty" }, "Panel not reachable: " + e.message)); return; } + if (!session.user) { showAuth(session.needsSetup, session.serverName); return; } + state.user = session.user; + $("auth").classList.add("hidden"); + $("app").classList.remove("hidden"); + $("meName").textContent = session.user.name; + $("meRole").textContent = session.user.role; + $("serverName").textContent = session.serverName; + let page = "dashboard"; + try { page = localStorage.getItem("msco-panel-page") || page; } catch { /* egal */ } + go(page); +} + +boot(); diff --git a/Panel/index.html b/Panel/index.html new file mode 100644 index 0000000..8dd17ca --- /dev/null +++ b/Panel/index.html @@ -0,0 +1,73 @@ + + + + + + MSCO Server Panel + + + + + + +
+
+
+ +
+
MSCO
+
Server Panel
+
+
+

Sign in

+

+ + + + + + +
+
+ + + + +
+
+ + diff --git a/Panel/logo.png b/Panel/logo.png new file mode 100644 index 0000000..bec43aa Binary files /dev/null and b/Panel/logo.png differ diff --git a/PanelUsers.cs b/PanelUsers.cs new file mode 100644 index 0000000..a036b0f --- /dev/null +++ b/PanelUsers.cs @@ -0,0 +1,153 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace MSCO.Server; + +/// Rollen im Web-Panel: Moderator (Spieler/Chat), Admin (alles am Server), Owner (zusätzlich Benutzer). +public enum PanelRole { Moderator = 1, Admin = 2, Owner = 3 } + +/// +/// Benutzer des Web-Panels in data/panel_users.json. Passwörter nur als PBKDF2-SHA256-Hash mit eigenem Salz. +/// +public sealed class PanelUsers +{ + public sealed class User + { + public string Name { get; set; } + public PanelRole Role { get; set; } + public string Salt { get; set; } + public string Hash { get; set; } + public int Iterations { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime? LastLoginUtc { get; set; } + } + + sealed class FileData { public List Users { get; set; } = new(); } + + const int Iterations = 210_000; + static readonly Regex ValidName = new("^[A-Za-z0-9_.-]{3,32}$", RegexOptions.Compiled); + static readonly JsonSerializerOptions Options = new() { WriteIndented = true }; + + readonly string path; + readonly object gate = new(); + List users; + + public PanelUsers(string path) + { + this.path = path; + users = File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllText(path), Options)?.Users ?? new() : new(); + } + + public bool Empty { get { lock (gate) return users.Count == 0; } } + + public List All() { lock (gate) return users.ToList(); } + + public User Find(string name) + { + lock (gate) return users.FirstOrDefault(u => string.Equals(u.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + public static string CheckName(string name) => + name != null && ValidName.IsMatch(name) ? null : "Username: 3-32 characters (letters, numbers, _ . -)"; + + public static string CheckPassword(string password) => + password != null && password.Length >= 8 && password.Length <= 200 ? null : "Password: at least 8 characters"; + + /// Neuen Benutzer anlegen. Gibt einen Fehler zurück oder null. + public string Add(string name, string password, PanelRole role) + { + string error = CheckName(name) ?? CheckPassword(password); + if (error != null) return error; + lock (gate) + { + if (Find(name) != null) return "A user with this name already exists"; + var user = new User { Name = name, Role = role, CreatedUtc = DateTime.UtcNow }; + SetPasswordHash(user, password); + users.Add(user); + Save(); + } + return null; + } + + /// Benutzer prüfen. Gibt ihn bei richtigem Passwort zurück, sonst null. + public User Verify(string name, string password) + { + User user = Find(name ?? ""); + if (user == null || password == null) + { + // Gleich lange rechnen wie bei einem echten Benutzer (kein Rückschluss, ob es den Namen gibt) + Rfc2898DeriveBytes.Pbkdf2(password ?? "", new byte[16], Iterations, HashAlgorithmName.SHA256, 32); + return null; + } + byte[] hash = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), Convert.FromBase64String(user.Salt), + user.Iterations, HashAlgorithmName.SHA256, 32); + if (!CryptographicOperations.FixedTimeEquals(hash, Convert.FromBase64String(user.Hash))) return null; + lock (gate) + { + user.LastLoginUtc = DateTime.UtcNow; + Save(); + } + return user; + } + + public string SetPassword(string name, string password) + { + string error = CheckPassword(password); + if (error != null) return error; + lock (gate) + { + User user = Find(name); + if (user == null) return "User not found"; + SetPasswordHash(user, password); + Save(); + } + return null; + } + + public string SetRole(string name, PanelRole role) + { + lock (gate) + { + User user = Find(name); + if (user == null) return "User not found"; + if (user.Role == PanelRole.Owner && role != PanelRole.Owner && users.Count(u => u.Role == PanelRole.Owner) == 1) + return "There must be at least one owner"; + user.Role = role; + Save(); + } + return null; + } + + public string Remove(string name) + { + lock (gate) + { + User user = Find(name); + if (user == null) return "User not found"; + if (user.Role == PanelRole.Owner && users.Count(u => u.Role == PanelRole.Owner) == 1) + return "The last owner cannot be deleted"; + users.Remove(user); + Save(); + } + return null; + } + + static void SetPasswordHash(User user, string password) + { + byte[] salt = RandomNumberGenerator.GetBytes(16); + user.Salt = Convert.ToBase64String(salt); + user.Iterations = Iterations; + user.Hash = Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, 32)); + } + + void Save() + { + string tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(new FileData { Users = users }, Options)); + if (!OperatingSystem.IsWindows()) + try { File.SetUnixFileMode(tmp, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { } + File.Move(tmp, path, true); + } +} diff --git a/Program.cs b/Program.cs index bb47a3b..ef6666f 100644 --- a/Program.cs +++ b/Program.cs @@ -94,41 +94,12 @@ if (Array.IndexOf(args, "--import-save") >= 0) if (Directory.Exists(original)) Directory.Delete(original, true); } -// Lizenzschlüssel beim Keymaster prüfen -if (string.IsNullOrWhiteSpace(config.LicenseKey)) -{ - GameServer.Log("No license key set. Get one at " + MasterServer.Url + "/keys and put it into server.json."); - Pause(); - return 1; -} -// Automatisch mit dem Master (msco.mbm.wtf) verbinden: ist er gerade nicht erreichbar, -// wird es mit wachsendem Abstand (max. 60 s) weiter versucht, statt aufzugeben. -var master = new MasterClient(MasterServer.Url); -MasterClient.KeyInfo key; -for (int attempt = 1; ; attempt++) -{ - GameServer.Log($"Checking license key at {MasterServer.Url} ..."); - key = await master.ValidateKeyAsync(config.LicenseKey); - if (!key.Unreachable) break; - int wait = Math.Min(60, 5 * attempt); - GameServer.Log($"Master server not reachable ({key.Error}) - retrying in {wait} s (Ctrl+C to cancel)"); - await Task.Delay(TimeSpan.FromSeconds(wait)); -} -if (!key.Valid) -{ - GameServer.Log("License key invalid: " + (key.Error ?? "unknown key")); - GameServer.Log("Manage your keys at " + MasterServer.Url + "/keys"); - Pause(); - return 1; -} -GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})"); - -var server = new GameServer(config, dataDir, configPath, key.Owner); -if (!server.LoadSave()) -{ - Pause(); - return 1; -} +// Konsole, "msco " (docker exec) und Signale gehen immer an den gerade laufenden Server - +// nach einem Neustart aus dem Panel ist das ein neuer. Das Web-Panel bleibt dabei bestehen (man bleibt angemeldet). +GameServer server = null; +AdminPanel panel = null; +Action send = line => server?.EnqueueCommand(line); +Action stopRequested = () => { if (server == null) Environment.Exit(0); else server.EnqueueCommand("stop"); }; // Konsolenbefehle in eigenem Thread lesen, ausgeführt werden sie in der Server-Schleife. // Als Dienst (systemd/Docker ohne Terminal) gibt es keine Eingabe - dann einfach ohne Konsole laufen. @@ -137,7 +108,7 @@ var input = new Thread(() => try { string line; - while ((line = Console.ReadLine()) != null) server.EnqueueCommand(line); + while ((line = Console.ReadLine()) != null) send(line); } catch { } }) { IsBackground = true }; @@ -155,7 +126,7 @@ var commandWatcher = new Thread(() => if (!File.Exists(commandFile)) continue; File.Move(commandFile, processing, true); foreach (string line in File.ReadAllLines(processing)) - if (line.Trim().Length > 0) { GameServer.Log("> " + line.Trim()); server.EnqueueCommand(line.Trim()); } + if (line.Trim().Length > 0) { GameServer.Log("> " + line.Trim()); send(line.Trim()); } File.Delete(processing); } catch { } @@ -164,11 +135,70 @@ var commandWatcher = new Thread(() => commandWatcher.Start(); // Sauber beenden (Weltzustand speichern) bei Strg+C, systemctl stop / docker stop (SIGTERM) und SIGQUIT -Console.CancelKeyPress += (_, e) => { e.Cancel = true; server.EnqueueCommand("stop"); }; -using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { ctx.Cancel = true; server.EnqueueCommand("stop"); }); -using var sigquit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, ctx => { ctx.Cancel = true; server.EnqueueCommand("stop"); }); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; stopRequested(); }; +using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { ctx.Cancel = true; stopRequested(); }); +using var sigquit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, ctx => { ctx.Cancel = true; stopRequested(); }); -server.Run(); +for (bool restarting = false; ; restarting = true) +{ + if (restarting) + { + // Neustart: server.json neu lesen (im Panel geänderte Einstellungen), Lizenz und Spielstand neu prüfen + try { config = ServerConfig.LoadOrCreate(configPath, out _); } + catch (Exception e) { GameServer.Log("Cannot read " + configPath + " (" + e.Message + ") - keeping the previous settings"); } + config.ApplyEnvironment(); + } + + // Lizenzschlüssel beim Keymaster prüfen + if (string.IsNullOrWhiteSpace(config.LicenseKey)) + { + GameServer.Log("No license key set. Get one at " + MasterServer.Url + "/keys and put it into server.json."); + Pause(); + return 1; + } + // Automatisch mit dem Master (msco.mbm.wtf) verbinden: ist er gerade nicht erreichbar, + // wird es mit wachsendem Abstand (max. 60 s) weiter versucht, statt aufzugeben. + var master = new MasterClient(MasterServer.Url); + MasterClient.KeyInfo key; + for (int attempt = 1; ; attempt++) + { + GameServer.Log($"Checking license key at {MasterServer.Url} ..."); + key = await master.ValidateKeyAsync(config.LicenseKey); + if (!key.Unreachable) break; + int wait = Math.Min(60, 5 * attempt); + GameServer.Log($"Master server not reachable ({key.Error}) - retrying in {wait} s (Ctrl+C to cancel)"); + await Task.Delay(TimeSpan.FromSeconds(wait)); + } + if (!key.Valid) + { + GameServer.Log("License key invalid: " + (key.Error ?? "unknown key")); + GameServer.Log("Manage your keys at " + MasterServer.Url + "/keys"); + Pause(); + return 1; + } + GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})"); + + var next = new GameServer(config, dataDir, configPath, key.Owner); + if (!next.LoadSave()) + { + Pause(); + return 1; + } + // Web-Panel (wie txAdmin) - standardmäßig auf dem Spiel-Port; bei einem Neustart dasselbe Panel weiterverwenden + if (config.PanelEnabled || panel != null) + { + if (panel == null) panel = AdminPanel.Start(next, config, dataDir); + else panel.Attach(next, config); + next.Panel = panel; + } + server = next; + + server.Run(); + if (!server.RestartRequested) break; + server = null; + GameServer.Log("Restarting the server ..."); +} +panel?.Stop(); return 0; string ArgValue(string name) diff --git a/README.md b/README.md index 8c03891..6095ce3 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,50 @@ Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvari `MSCO_NAME`, `MSCO_PORT`, `MSCO_MAX_PLAYERS`, `MSCO_PUBLIC`, `MSCO_REQUIRE_STEAM`, `MSCO_PUBLIC_ADDRESS`, `MSCO_STEAM_WEB_API_KEY`) überschreiben `server.json`. +## Web-Panel (wie txAdmin) + +Der Server bringt ein Web-Panel mit – **auf demselben Port wie das Spiel** (Standard `7777`). Einfach im Browser +`http://:7777/` öffnen; der Server erkennt selbst, ob ein Browser oder das Spiel verbindet. Es muss also +kein weiterer Port freigegeben werden. + +**Erstes Anmelden:** Beim Start ohne Panel-Konto steht im Log ein Einrichtungs-Code: + +```bash +docker logs msco-server | grep -A2 "WEB PANEL SETUP" +``` + +Mit diesem Code legst du im Browser das **Owner**-Konto an (erneut anzeigen: `msco panel`). + +| Bereich | Was geht | +|---|---| +| Dashboard | Spieler, Laufzeit, Weltzustand, Backups – **Start / Stop / Restart / Shut down** | +| Players | Liste mit ID, Name, SteamID, IP · Kick, Ban, Admin geben/nehmen · Nachricht an alle | +| Console | Live-Log (Chat, Beitritte, Admin-Aktionen) und Befehle eingeben (mit Verlauf ↑/↓) | +| Bans | Gebannte ansehen, entbannen, SteamID auch offline bannen | +| World & Backups | Welt zurücksetzen, Backup, Reset-Punkt, Backups wiederherstellen, **Spielstand als ZIP hoch-/herunterladen** | +| Settings | `server.json` bearbeiten (Name, Spielerzahl, Sichtbarkeit, Ports, Panel, Schlüssel …) | +| Panel Users | weitere Benutzer anlegen: **Moderator** (Spieler/Chat), **Admin** (alles am Server), **Owner** (+ Benutzer) | + +- **Stop** nimmt nur das Spiel offline (Spieler raus, nicht gelistet) – das Panel läuft weiter, **Start** holt es + zurück. **Restart** lädt Einstellungen, Lizenz und Spielstand neu (man bleibt angemeldet). **Shut down** beendet + alles. +- Passwörter werden nur als PBKDF2-Hash in `data/panel_users.json` gespeichert, Login mit Sperre nach + Fehlversuchen, Sitzungs-Cookie (HttpOnly, SameSite=Strict), Schutz gegen CSRF. + +### Hinter einem Reverse-Proxy (nginx, Traefik, Caddy, Nginx Proxy Manager …) + +Den Proxy einfach auf `http://:7777` zeigen lassen und in `server.json` setzen: + +```json +"PanelTrustProxy": true, +"PanelBasePath": "" +``` + +`PanelTrustProxy` übernimmt die echte Besucher-IP und HTTPS (`X-Forwarded-For` / `-Proto`), `PanelBasePath` (z.B. +`"/panel"`) bzw. `X-Forwarded-Prefix` für einen Unterpfad. Lieber einen eigenen Port? `"PanelPort": 40120` (und im +`docker-compose.yml` freigeben); mit `"PanelBindAddress": "127.0.0.1"` ist das Panel dann nur über den lokalen Proxy +erreichbar. Ganz aus: `"PanelEnabled": false`. + ## Konsolenbefehle | Befehl | Beschreibung | @@ -135,6 +179,9 @@ Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvari | `backup` | aktuellen Spielstand jetzt sichern | | `reset-world` | ganze Welt zurück auf den Reset-Punkt (den Spielstand, wie er dem Server gegeben wurde: Start, `--import-save`, `reload-save`) – alle verbinden neu | | `set-reset-point` | aktuellen Spielstand als neuen Reset-Punkt nehmen | +| `stop-game` / `start-game` | Spiel offline nehmen (Spieler raus, Panel läuft weiter) / wieder starten | +| `restart` | Server neu starten (Einstellungen, Lizenz, Spielstand neu laden) | +| `panel` | Adresse des Web-Panels und ggf. den Einrichtungs-Code anzeigen | | `stop` | speichern und beenden | Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F7-Konsole) das Admin-Menü: Kick/Ban, diff --git a/ServerConfig.cs b/ServerConfig.cs index 6b270ec..5ae69ef 100644 --- a/ServerConfig.cs +++ b/ServerConfig.cs @@ -33,6 +33,18 @@ public sealed class ServerConfig /// Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen. public List Admins { get; set; } = new(); + // ---------------------------------------------------------------- Web-Panel (wie txAdmin) + /// Web-Panel einschalten. + public bool PanelEnabled { get; set; } = true; + /// 0 = derselbe Port wie der Gameserver (Browser und Spiel werden automatisch unterschieden), sonst eigener Port. + public int PanelPort { get; set; } = 0; + /// Nur bei eigenem PanelPort: Adresse, auf der gelauscht wird ("0.0.0.0" = alle, "127.0.0.1" = nur lokal, z.B. hinter einem Proxy). + public string PanelBindAddress { get; set; } = "0.0.0.0"; + /// Hinter einem Reverse-Proxy: X-Forwarded-For/-Proto/-Prefix übernehmen (echte IP, HTTPS, Unterpfad). + public bool PanelTrustProxy { get; set; } = false; + /// Unterpfad, unter dem das Panel läuft, z.B. "/panel" (leer = direkt unter /). + public string PanelBasePath { get; set; } = ""; + static readonly JsonSerializerOptions Options = new() { WriteIndented = true }; public static ServerConfig LoadOrCreate(string path, out bool created) @@ -58,9 +70,14 @@ public sealed class ServerConfig /// MSCO_PUBLIC_ADDRESS, MSCO_STEAM_WEB_API_KEY (die Master-Adresse ist fest, siehe MasterServer) ///
/// Namen der gesetzten Umgebungsvariablen (fürs Log, damit klar ist, woher ein Wert kommt). + /// Per Umgebungsvariable gesetzte Einstellungen (nicht in server.json gespeichert). + [System.Text.Json.Serialization.JsonIgnore] + public List EnvironmentOverrides { get; private set; } = new(); + public List ApplyEnvironment() { var used = new List(); + EnvironmentOverrides = used; string Take(string name, string current) { string value = Env(name); @@ -78,6 +95,11 @@ public sealed class ServerConfig if (int.TryParse(Env("MSCO_MAX_PLAYERS"), out int max)) { MaxPlayers = max; used.Add("MSCO_MAX_PLAYERS"); } if (bool.TryParse(Env("MSCO_PUBLIC"), out bool isPublic)) { Public = isPublic; used.Add("MSCO_PUBLIC"); } if (bool.TryParse(Env("MSCO_REQUIRE_STEAM"), out bool requireSteam)) { RequireSteam = requireSteam; used.Add("MSCO_REQUIRE_STEAM"); } + if (bool.TryParse(Env("MSCO_PANEL_ENABLED"), out bool panel)) { PanelEnabled = panel; used.Add("MSCO_PANEL_ENABLED"); } + if (int.TryParse(Env("MSCO_PANEL_PORT"), out int panelPort)) { PanelPort = panelPort; used.Add("MSCO_PANEL_PORT"); } + if (bool.TryParse(Env("MSCO_PANEL_TRUST_PROXY"), out bool trustProxy)) { PanelTrustProxy = trustProxy; used.Add("MSCO_PANEL_TRUST_PROXY"); } + PanelBindAddress = Take("MSCO_PANEL_BIND", PanelBindAddress); + PanelBasePath = Take("MSCO_PANEL_BASE_PATH", PanelBasePath); return used; } diff --git a/Tests/Program.cs b/Tests/Program.cs index 78c5c60..640f3a5 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -18,6 +18,8 @@ config.Save(configPath); var server = new GameServer(config, dir, configPath, "Tester", offline: true); server.SaveUploadTimeoutSeconds = 1; // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; } +// Web-Panel auf demselben Port wie das Spiel (Standard) +server.Panel = AdminPanel.Start(server, config, dir); var serverThread = new Thread(server.Run) { IsBackground = true }; serverThread.Start(); Thread.Sleep(500); @@ -289,6 +291,8 @@ try afterReset.Close(); inGame.Close(); newcomer.Close(); + + await PanelTests(); } catch (Exception e) { @@ -296,6 +300,145 @@ catch (Exception e) Console.WriteLine(" FAIL exception: " + e); } +async Task PanelTests() +{ + Console.WriteLine("== Web panel on the game port"); + Check(server.Panel != null && server.Panel.SharesGamePort, "Panel started and shares the game port"); + string baseUrl = $"http://127.0.0.1:{config.Port}/"; + HttpClient Browser() + { + var handler = new HttpClientHandler { CookieContainer = new System.Net.CookieContainer(), UseCookies = true }; + var http = new HttpClient(handler) { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(10) }; + return http; + } + async Task<(int status, System.Text.Json.JsonElement json)> Call(HttpClient http, string method, string path, object body = null, bool csrf = true) + { + var req = new HttpRequestMessage(new HttpMethod(method), path); + if (csrf) req.Headers.Add("X-MSCO-Panel", "1"); + if (body != null) req.Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json"); + var res = await http.SendAsync(req); + string text = await res.Content.ReadAsStringAsync(); + System.Text.Json.JsonElement json = default; + try { json = System.Text.Json.JsonDocument.Parse(text).RootElement; } catch { } + return ((int)res.StatusCode, json); + } + + var owner = Browser(); + var page = await owner.GetAsync(""); + Check(page.IsSuccessStatusCode && (await page.Content.ReadAsStringAsync()).Contains("MSCO Server Panel"), + "Browser gets the panel page on the game port"); + Check(page.Headers.Contains("Content-Security-Policy"), "Security headers are set"); + + var session = await Call(owner, "GET", "api/session"); + Check(session.status == 200 && session.json.GetProperty("needsSetup").GetBoolean(), "Fresh panel asks for setup"); + Check((await Call(owner, "GET", "api/players")).status == 401, "API needs a login"); + Check((await Call(owner, "POST", "api/setup", new { code = "WRONG-CODE0", username = "owner", password = "supersecret1" })).status == 403, + "Wrong setup code is rejected"); + Check((await Call(owner, "POST", "api/setup", new { code = server.Panel.SetupCode, username = "owner", password = "supersecret1" }, csrf: false)).status == 403, + "Requests without the panel header are rejected (CSRF)"); + var setup = await Call(owner, "POST", "api/setup", new { code = server.Panel.SetupCode, username = "owner", password = "supersecret1" }); + Check(setup.status == 200 && server.Panel.SetupCode == null, "Setup with the console code creates the owner and logs in"); + Check(File.Exists(Path.Combine(dir, "panel_users.json")) && !File.ReadAllText(Path.Combine(dir, "panel_users.json")).Contains("supersecret1"), + "Users are stored with a password hash, not the password"); + + // Spiel läuft auf demselben Port weiter + var gamer = await TestClient.Join("PanelTarget", 0, null); + Check(gamer.Welcome != null, "Game clients still join on the same port"); + var players = await Call(owner, "GET", "api/players"); + bool listed = players.status == 200 && players.json.EnumerateArray().Any(p => p.GetProperty("name").GetString() == "PanelTarget"); + Check(listed, "Player list shows the connected player with ID"); + + var status = await Call(owner, "GET", "api/status"); + Check(status.status == 200 && status.json.GetProperty("players").GetInt32() >= 1, "Status shows players, uptime, world"); + + var cmd = await Call(owner, "POST", "api/console", new { command = "status" }); + Check(cmd.status == 200 && cmd.json.GetProperty("message").GetString().Contains("players"), "Console command runs and returns its output"); + var log = await Call(owner, "GET", "api/console?after=0"); + Check(log.status == 200 && log.json.GetProperty("lines").GetArrayLength() > 0, "Live console log is readable"); + + var add = await Call(owner, "POST", "api/users", new { name = "mod1", password = "modpassword", role = "Moderator" }); + Check(add.status == 200, "Owner adds a moderator"); + var mod = Browser(); + Check((await Call(mod, "POST", "api/login", new { username = "mod1", password = "nope-nope" })).status == 401, "Wrong password is rejected"); + Check((await Call(mod, "POST", "api/login", new { username = "mod1", password = "modpassword" })).status == 200, "Moderator logs in"); + Check((await Call(mod, "POST", "api/console", new { command = "stop" })).status == 403, "Moderator cannot run console commands"); + Check((await Call(mod, "POST", "api/world/reset")).status == 403, "Moderator cannot reset the world"); + Check((await Call(mod, "GET", "api/users")).status == 403, "Moderator cannot manage users"); + var modPlayers = await Call(mod, "GET", "api/players"); + Check(modPlayers.status == 200 && modPlayers.json.EnumerateArray().All(p => p.GetProperty("address").ValueKind == System.Text.Json.JsonValueKind.Null), + "Moderators do not see IP addresses"); + + int targetId = gamer.Id; + var kick = await Call(mod, "POST", $"api/players/{targetId}/kick", new { reason = "panel test" }); + Check(kick.status == 200, "Moderator kicks a player from the panel: " + (kick.json.ValueKind == System.Text.Json.JsonValueKind.Object ? kick.json.ToString() : "")); + var kickMsg = await gamer.WaitFor(MsgType.Reject); + string kickReason = kickMsg == null ? null : ReadString(kickMsg); + Check(kickReason != null && kickReason.Contains("panel test") && kickReason.Contains("mod1"), "The kicked player gets the reason and who kicked: " + kickReason); + + var ban = await Call(owner, "POST", "api/bans/add", new { id = "76561198000000001" }); + var bans = await Call(owner, "GET", "api/bans"); + Check(ban.status == 200 && bans.json.EnumerateArray().Any(b => b.GetString() == "76561198000000001"), "Offline SteamID can be banned"); + await Call(owner, "POST", "api/bans/remove", new { id = "76561198000000001" }); + + var backup = await Call(owner, "POST", "api/world/backup"); + Check(backup.status == 200, "Backup from the panel"); + Check((await Call(owner, "POST", "api/users/owner/delete")).status == 400, "Owner cannot delete themselves"); + + // Zu viele Fehlversuche -> gesperrt + var bruteforce = Browser(); + int lastStatus = 0; + for (int i = 0; i < 10; i++) lastStatus = (await Call(bruteforce, "POST", "api/login", new { username = "owner", password = "guess" + i })).status; + Check(lastStatus == 429, "Login is rate limited after too many failures"); + + // Einstellungen + var cfg = await Call(owner, "GET", "api/config"); + Check(cfg.status == 200 && cfg.json.GetProperty("licenseKeySet").GetBoolean() && !cfg.json.ToString().Contains("\"test\""), + "Settings are readable, keys are never sent to the browser"); + var cfgSave = await Call(owner, "POST", "api/config", new { name = "Renamed Server", maxPlayers = 6 }); + Check(cfgSave.status == 200 && config.Name == "Renamed Server" && config.MaxPlayers == 6 + && File.ReadAllText(configPath).Contains("Renamed Server"), "Settings are changed and saved to server.json"); + Check((await Call(mod, "POST", "api/config", new { name = "Hacked" })).status == 403, "Moderators cannot change settings"); + + // Spiel stoppen / starten + var stop = await Call(owner, "POST", "api/server/stop-game"); + Check(stop.status == 200, "Game server can be stopped from the panel"); + var whileStopped = await TestClient.Join("WhileStopped", 0, null, expectReject: true); + Check(whileStopped.RejectReason != null && whileStopped.RejectReason.Contains("stopped"), "Nobody can join while stopped: " + whileStopped.RejectReason); + Check((await Call(owner, "GET", "api/status")).json.GetProperty("gameStopped").GetBoolean(), "Status shows the stopped game server"); + Check((await Call(owner, "POST", "api/server/start-game")).status == 200, "Game server can be started again"); + var afterStart = await TestClient.Join("AfterStart", 0, null); + Check(afterStart.Welcome != null, "Players can join after start"); + afterStart.Close(); + Check((await Call(mod, "POST", "api/server/stop-game")).status == 403, "Moderators cannot stop the server"); + + // Spielstand als ZIP + byte[] zip; + using (var zms = new MemoryStream()) + { + using (var za = new System.IO.Compression.ZipArchive(zms, System.IO.Compression.ZipArchiveMode.Create, true)) + { + using (var w = new StreamWriter(za.CreateEntry("My Summer Car/defaultES2File.txt").Open())) w.Write("ZIPPED SAVE"); + using (var w = new StreamWriter(za.CreateEntry("../../evil.txt").Open())) w.Write("should be ignored"); + } + zip = zms.ToArray(); + } + var up = new HttpRequestMessage(HttpMethod.Post, "api/save/upload") { Content = new ByteArrayContent(zip) }; + up.Headers.Add("X-MSCO-Panel", "1"); + up.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip"); + var upRes = await owner.SendAsync(up); + Check(upRes.IsSuccessStatusCode && File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "ZIPPED SAVE", + "A save game ZIP can be uploaded: " + await upRes.Content.ReadAsStringAsync()); + Check(!File.Exists(Path.Combine(dir, "..", "evil.txt")) && !File.Exists(Path.Combine(dir, "evil.txt")), "Paths inside the ZIP are never used"); + Check(File.ReadAllText(Path.Combine(dir, "save_original", "defaultES2File.txt")) == "ZIPPED SAVE", "The uploaded save is the new reset point"); + var down = await owner.GetAsync("api/save/download"); + byte[] downZip = await down.Content.ReadAsByteArrayAsync(); + using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip))) + Check(down.IsSuccessStatusCode && da.GetEntry("defaultES2File.txt") != null, "The current save can be downloaded as ZIP"); + + await Call(owner, "POST", "api/logout"); + Check((await Call(owner, "GET", "api/status")).status == 401, "Logout ends the session"); +} + Console.WriteLine($"\n{passed} passed, {failed} failed"); server.EnqueueCommand("stop"); Thread.Sleep(300); diff --git a/docker-compose.yml b/docker-compose.yml index b6f0779..60af582 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,9 @@ services: max-size: "10m" max-file: "3" ports: + # Spiel UND Web-Panel (http://:7777/) - Browser werden am selben Port erkannt - "7777:7777/tcp" + # Eigener Panel-Port? In server.json "PanelPort": 40120 setzen und diese Zeile einkommentieren: + # - "40120:40120/tcp" volumes: - ./data:/data