Web panel (like txAdmin) on the game port: login/roles/users, players, kick/ban/op, bans, live console, world reset/backups/restore, settings editor, save ZIP upload/download, start/stop/restart; in-process restart loop; tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+670
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdminPanel
|
||||||
|
{
|
||||||
|
const string CookieName = "msco_panel";
|
||||||
|
const string CsrfHeader = "X-MSCO-Panel";
|
||||||
|
static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(12);
|
||||||
|
static readonly TimeSpan ActionTimeout = TimeSpan.FromSeconds(8);
|
||||||
|
const int MaxLoginFailures = 8;
|
||||||
|
static readonly TimeSpan LoginWindow = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
sealed class Session
|
||||||
|
{
|
||||||
|
public string User;
|
||||||
|
public DateTime Expires;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameServer server; // nach einem Neustart aus dem Panel ein neuer (Attach)
|
||||||
|
ServerConfig config;
|
||||||
|
readonly PanelUsers users;
|
||||||
|
readonly ConcurrentDictionary<string, Session> sessions = new();
|
||||||
|
readonly ConcurrentDictionary<string, List<DateTime>> failures = new();
|
||||||
|
// Weitergereichte Verbindungen (gleicher Port): lokaler Port der Weiterleitung -> echte Adresse des Browsers
|
||||||
|
readonly ConcurrentDictionary<int, IPAddress> relayed = new();
|
||||||
|
WebApplication app;
|
||||||
|
|
||||||
|
/// <summary>Einmal-Code für das erste Konto (null, sobald es einen Benutzer gibt).</summary>
|
||||||
|
public string SetupCode { get; private set; }
|
||||||
|
/// <summary>Interner Port, wenn das Panel den Spiel-Port mitbenutzt.</summary>
|
||||||
|
public int InternalPort { get; private set; }
|
||||||
|
public bool SharesGamePort { get; private set; }
|
||||||
|
|
||||||
|
AdminPanel(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://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
|
||||||
|
else
|
||||||
|
GameServer.Log($"Web panel: http://{(bind.Equals(IPAddress.Any) ? "<server>" : bind.ToString())}:{port}{basePath}/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Neustart im selben Programm: das Panel (und die Anmeldungen) bleiben, der Server ist neu.</summary>
|
||||||
|
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("============================================================");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Konsolenbefehl "panel": Adresse und (falls noch nötig) den Einrichtungs-Code zeigen.</summary>
|
||||||
|
public void PrintInfo()
|
||||||
|
{
|
||||||
|
GameServer.Log(SharesGamePort ? $"Web panel on the game port {config.Port}" : $"Web panel on port {config.PanelPort}");
|
||||||
|
if (SetupCode != null) GameServer.Log("Setup code for the first account: " + SetupCode);
|
||||||
|
else GameServer.Log($"{users.All().Count} panel user(s): " + string.Join(", ", users.All().Select(u => $"{u.Name} ({u.Role})")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int FreeLoopbackPort()
|
||||||
|
{
|
||||||
|
var probe = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
probe.Start();
|
||||||
|
int port = ((IPEndPoint)probe.LocalEndpoint).Port;
|
||||||
|
probe.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
static IPAddress ParseBind(string address)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(address) || address == "*" || address == "0.0.0.0") return IPAddress.Any;
|
||||||
|
if (address == "localhost") return IPAddress.Loopback;
|
||||||
|
return IPAddress.TryParse(address, out var ip) ? ip : IPAddress.Any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Gleicher Port: HTTP erkennen und weiterreichen
|
||||||
|
|
||||||
|
/// <summary>Beginnen die Bytes wie eine HTTP-Anfrage? (Spiel-Pakete beginnen mit einer kleinen Längenangabe.)</summary>
|
||||||
|
public static bool LooksLikeHttp(byte[] b, int n)
|
||||||
|
{
|
||||||
|
if (n < 4) return false;
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
if (b[i] < 'A' || b[i] > 'Z') return false;
|
||||||
|
return (b[3] >= 'A' && b[3] <= 'Z') || b[3] == ' '; // "GET ", "PUT ", POST, HEAD, DELE, OPTI, PATC ...
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Browser-Verbindung vom Spiel-Port an das intern laufende Panel weiterreichen (in beide Richtungen).</summary>
|
||||||
|
public async Task RelayAsync(TcpClient browser)
|
||||||
|
{
|
||||||
|
var real = (browser.Client.RemoteEndPoint as IPEndPoint)?.Address;
|
||||||
|
using var panel = new TcpClient { NoDelay = true };
|
||||||
|
int localPort = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await panel.ConnectAsync(IPAddress.Loopback, InternalPort);
|
||||||
|
localPort = ((IPEndPoint)panel.Client.LocalEndPoint).Port;
|
||||||
|
if (real != null) relayed[localPort] = real.IsIPv4MappedToIPv6 ? real.MapToIPv4() : real;
|
||||||
|
using var a = browser.GetStream();
|
||||||
|
using var b = panel.GetStream();
|
||||||
|
var up = a.CopyToAsync(b);
|
||||||
|
var down = b.CopyToAsync(a);
|
||||||
|
await Task.WhenAny(up, down);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (localPort != 0) relayed.TryRemove(localPort, out _);
|
||||||
|
try { browser.Close(); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Dateien (Oberfläche)
|
||||||
|
|
||||||
|
void MapFiles()
|
||||||
|
{
|
||||||
|
byte[] html = Resource("panel.index.html"), js = Resource("panel.app.js"), 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<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
|
||||||
|
if (sizeFeature != null && !sizeFeature.IsReadOnly) sizeFeature.MaxRequestBodySize = 64L * 1024 * 1024;
|
||||||
|
byte[] zip;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await ctx.Request.Body.CopyToAsync(ms);
|
||||||
|
zip = ms.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception e) { return Error(400, "Upload failed: " + e.Message); }
|
||||||
|
if (zip.Length == 0) return Error(400, "Choose a ZIP 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<IResult> Run<T>(Func<T> 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<IResult> RunText(Func<string> 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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nur Anfragen aus der eigenen Oberfläche: sie schicken einen eigenen Header, den eine fremde Webseite ohne
|
||||||
|
/// CORS-Freigabe nicht setzen kann (Schutz vor CSRF). Zusätzlich ist das Cookie SameSite=Strict.
|
||||||
|
/// </summary>
|
||||||
|
static bool CsrfOk(HttpContext ctx) => ctx.Request.Headers[CsrfHeader] == "1";
|
||||||
|
|
||||||
|
PanelUsers.User CurrentUser(HttpContext ctx)
|
||||||
|
{
|
||||||
|
string token = ctx.Request.Cookies[CookieName];
|
||||||
|
if (token == null || !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<DateTime>());
|
||||||
|
lock (list) list.Add(DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<JsonElement> Body(HttpContext ctx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ctx.Request.ContentLength is null or 0) return default;
|
||||||
|
return await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||||||
|
}
|
||||||
|
catch { return default; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Str(JsonElement body, string name) =>
|
||||||
|
body.ValueKind == JsonValueKind.Object && body.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
|
||||||
|
}
|
||||||
+3
-1
@@ -8,7 +8,8 @@ WORKDIR /src
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN dotnet publish -c Release -o /app
|
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
|
WORKDIR /app
|
||||||
COPY --from=build /app .
|
COPY --from=build /app .
|
||||||
ENV MSCO_DATA=/data
|
ENV MSCO_DATA=/data
|
||||||
@@ -17,6 +18,7 @@ ENV HOME=/tmp
|
|||||||
# Befehle an den laufenden Server: docker exec msco-server msco players
|
# 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
|
RUN printf '#!/bin/sh\nexec dotnet /app/MSCO.Server.dll --cmd "$@"\n' > /usr/local/bin/msco && chmod +x /usr/local/bin/msco
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
# Spiel UND Web-Panel (Browser werden am selben Port erkannt)
|
||||||
EXPOSE 7777/tcp
|
EXPOSE 7777/tcp
|
||||||
# docker stop schickt SIGTERM -> Server speichert den Weltzustand
|
# docker stop schickt SIGTERM -> Server speichert den Weltzustand
|
||||||
STOPSIGNAL SIGTERM
|
STOPSIGNAL SIGTERM
|
||||||
|
|||||||
+420
-7
@@ -30,6 +30,7 @@ public sealed class GameServer
|
|||||||
/// <summary>SteamID von Steam bestätigt (Ticket per Web API geprüft). Nur dann gibt es Admin-Rechte.</summary>
|
/// <summary>SteamID von Steam bestätigt (Ticket per Web API geprüft). Nur dann gibt es Admin-Rechte.</summary>
|
||||||
public bool SteamVerified;
|
public bool SteamVerified;
|
||||||
public bool IsAdmin;
|
public bool IsAdmin;
|
||||||
|
public DateTime JoinedAt;
|
||||||
public DateTime DestroyWindowStart;
|
public DateTime DestroyWindowStart;
|
||||||
public int DestroyCount;
|
public int DestroyCount;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -51,6 +52,7 @@ public sealed class GameServer
|
|||||||
readonly WorldState world;
|
readonly WorldState world;
|
||||||
readonly Dictionary<int, Client> clients = new();
|
readonly Dictionary<int, Client> clients = new();
|
||||||
readonly ConcurrentQueue<string> commands = new();
|
readonly ConcurrentQueue<string> commands = new();
|
||||||
|
readonly ConcurrentQueue<Action> actions = new(); // vom Web-Panel: laufen in der Server-Schleife
|
||||||
readonly Stopwatch clock = Stopwatch.StartNew();
|
readonly Stopwatch clock = Stopwatch.StartNew();
|
||||||
|
|
||||||
TcpListener listener;
|
TcpListener listener;
|
||||||
@@ -187,13 +189,54 @@ public sealed class GameServer
|
|||||||
Log("Server stopped.");
|
Log("Server stopped.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Web-Panel (gesetzt vom Programm). Teilt es sich den Spiel-Port, werden Browser hier erkannt und weitergereicht.</summary>
|
||||||
|
public AdminPanel Panel { get; set; }
|
||||||
|
readonly ConcurrentQueue<TcpClient> sniffedGameClients = new();
|
||||||
|
|
||||||
void AcceptClients()
|
void AcceptClients()
|
||||||
{
|
{
|
||||||
while (listener.Pending())
|
while (listener.Pending())
|
||||||
{
|
{
|
||||||
var c = new Client { Id = nextId++, Conn = new Connection(listener.AcceptTcpClient()), State = ClientState.New };
|
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;
|
clients[c.Id] = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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ß.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
void PollClients()
|
||||||
@@ -242,7 +285,7 @@ public sealed class GameServer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Regelmäßig den aktuellen Spielstand vom Spiel holen
|
// 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;
|
nextSaveSync = now + config.SaveSyncMinutes * 60.0;
|
||||||
RequestSave("regular save");
|
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 if (result.Status == 0) Log("Master server not reachable - server is not listed right now.");
|
||||||
else ReportReachability(result);
|
else ReportReachability(result);
|
||||||
}
|
}
|
||||||
if (!offline && now >= nextHeartbeat && heartbeat == null)
|
if (!offline && !GameStopped && now >= nextHeartbeat && heartbeat == null)
|
||||||
{
|
{
|
||||||
nextHeartbeat = now + HeartbeatSeconds;
|
nextHeartbeat = now + HeartbeatSeconds;
|
||||||
heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion);
|
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
|
// 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 (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 (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; }
|
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
|
||||||
|
|
||||||
c.State = ClientState.Verifying;
|
c.State = ClientState.Verifying;
|
||||||
@@ -490,6 +534,7 @@ public sealed class GameServer
|
|||||||
{
|
{
|
||||||
string error = c.Verification.Result;
|
string error = c.Verification.Result;
|
||||||
if (error != null) { Reject(c, error); return; }
|
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; }
|
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
|
// 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.State = ClientState.Joined;
|
||||||
|
c.JoinedAt = DateTime.UtcNow;
|
||||||
var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList();
|
var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList();
|
||||||
c.Conn.Send(MsgType.Welcome, Wire.Build(w =>
|
c.Conn.Send(MsgType.Welcome, Wire.Build(w =>
|
||||||
{
|
{
|
||||||
@@ -836,15 +882,24 @@ public sealed class GameServer
|
|||||||
|
|
||||||
void RunCommands()
|
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
|
// BOM am Zeilenanfang (z.B. bei umgeleiteter Eingabe) ignorieren
|
||||||
string[] args = line.Trim().TrimStart('').Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
string[] args = line.Trim().TrimStart('').Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (args.Length == 0) continue;
|
if (args.Length == 0) return;
|
||||||
switch (args[0].ToLowerInvariant())
|
switch (args[0].ToLowerInvariant())
|
||||||
{
|
{
|
||||||
case "help":
|
case "help":
|
||||||
Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | set-reset-point | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
|
Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | set-reset-point | panel | stop-game | start-game | restart | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
|
||||||
break;
|
break;
|
||||||
case "say":
|
case "say":
|
||||||
if (args.Length < 2) { Log("say <text>"); break; }
|
if (args.Length < 2) { Log("say <text>"); break; }
|
||||||
@@ -921,6 +976,10 @@ public sealed class GameServer
|
|||||||
case "save":
|
case "save":
|
||||||
SaveWorld();
|
SaveWorld();
|
||||||
break;
|
break;
|
||||||
|
case "panel":
|
||||||
|
if (Panel != null) Panel.PrintInfo();
|
||||||
|
else Log("The web panel is disabled (PanelEnabled in server.json)");
|
||||||
|
break;
|
||||||
case "reset-world":
|
case "reset-world":
|
||||||
ResetWorld("Console");
|
ResetWorld("Console");
|
||||||
break;
|
break;
|
||||||
@@ -954,6 +1013,17 @@ public sealed class GameServer
|
|||||||
case "exit":
|
case "exit":
|
||||||
running = false;
|
running = false;
|
||||||
break;
|
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:
|
default:
|
||||||
Log("Unknown command, type 'help'");
|
Log("Unknown command, type 'help'");
|
||||||
break;
|
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.
|
||||||
|
|
||||||
|
/// <summary>Funktion in der Server-Schleife ausführen und das Ergebnis zurückgeben.</summary>
|
||||||
|
public Task<T> Invoke<T>(Func<T> func)
|
||||||
|
{
|
||||||
|
var done = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
actions.Enqueue(() =>
|
||||||
|
{
|
||||||
|
try { done.SetResult(func()); }
|
||||||
|
catch (Exception e) { done.SetException(e); }
|
||||||
|
});
|
||||||
|
return done.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Konsolenbefehl ausführen und alles zurückgeben, was er ins Log schreibt.</summary>
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>Spiel offline (Panel läuft weiter): Spieler raus, nicht mehr gelistet, niemand kann beitreten.</summary>
|
||||||
|
public bool GameStopped { get; private set; }
|
||||||
|
/// <summary>Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden).</summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Einstellungen ändern. Schlüssel (Lizenz, Steam Web API) nur als Owner; leer = unverändert.</summary>
|
||||||
|
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<string>();
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public string ImportSaveZip(byte[] zip, string by)
|
||||||
|
{
|
||||||
|
var found = new Dictionary<string, byte[]>(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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aktuellen Spielstand als ZIP.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SteamID sperren, auch wenn der Spieler gerade nicht online ist.</summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace MSCO.Server;
|
||||||
|
|
||||||
|
/// <summary>Die letzten Log-Zeilen im Speicher - für die Live-Konsole im Web-Panel.</summary>
|
||||||
|
public static class LogBuffer
|
||||||
|
{
|
||||||
|
public readonly record struct Line(long Seq, string Time, string Text);
|
||||||
|
|
||||||
|
const int Capacity = 3000;
|
||||||
|
static readonly Queue<Line> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Zeilen nach der angegebenen Nummer (höchstens max, die neuesten).</summary>
|
||||||
|
public static List<Line> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,15 @@
|
|||||||
<ApplicationIcon>logo.ico</ApplicationIcon>
|
<ApplicationIcon>logo.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Web-Panel (Kestrel) -->
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
<EmbeddedResource Include="Panel/index.html" LogicalName="panel.index.html" />
|
||||||
|
<EmbeddedResource Include="Panel/app.js" LogicalName="panel.app.js" />
|
||||||
|
<EmbeddedResource Include="Panel/app.css" LogicalName="panel.app.css" />
|
||||||
|
<EmbeddedResource Include="Panel/logo.png" LogicalName="panel.logo.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Tests sind ein eigenes Projekt im Ordner Tests -->
|
<!-- Tests sind ein eigenes Projekt im Ordner Tests -->
|
||||||
<Compile Remove="Tests/**" />
|
<Compile Remove="Tests/**" />
|
||||||
|
|||||||
+184
@@ -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; }
|
||||||
|
}
|
||||||
+690
@@ -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();
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>MSCO Server Panel</title>
|
||||||
|
<link rel="icon" href="logo.png">
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
<script src="app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Anmeldung / Einrichtung -->
|
||||||
|
<main id="auth" class="auth hidden">
|
||||||
|
<form id="authForm" class="auth-card" autocomplete="on">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="logo.png" alt="">
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">MSCO</div>
|
||||||
|
<div class="brand-sub" id="authServer">Server Panel</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 id="authTitle">Sign in</h1>
|
||||||
|
<p id="authHint" class="muted"></p>
|
||||||
|
<label id="codeRow" class="hidden">Setup code
|
||||||
|
<input id="code" name="code" autocomplete="off" spellcheck="false" placeholder="XXXXX-XXXXX">
|
||||||
|
</label>
|
||||||
|
<label>Username
|
||||||
|
<input id="username" name="username" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label>Password
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<label id="password2Row" class="hidden">Repeat password
|
||||||
|
<input id="password2" type="password" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<p id="authError" class="error hidden"></p>
|
||||||
|
<button class="btn primary wide" id="authButton" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Panel -->
|
||||||
|
<div id="app" class="layout hidden">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="logo.png" alt="">
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">MSCO</div>
|
||||||
|
<div class="brand-sub">Server Panel</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav id="nav"></nav>
|
||||||
|
<div class="sidebar-foot">
|
||||||
|
<div class="me"><span id="meName"></span><span id="meRole" class="pill"></span></div>
|
||||||
|
<button class="btn ghost small" id="logout" type="button">Log out</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section class="content">
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="btn ghost small menu" id="menuToggle" type="button" aria-label="Menu">☰</button>
|
||||||
|
<div>
|
||||||
|
<div class="server-name" id="serverName">MSCO Server</div>
|
||||||
|
<div class="server-meta" id="serverMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="status-dot" id="statusDot" title="Server status"></div>
|
||||||
|
</header>
|
||||||
|
<div id="page" class="page"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modalRoot"></div>
|
||||||
|
<div id="toasts" class="toasts"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
+153
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace MSCO.Server;
|
||||||
|
|
||||||
|
/// <summary>Rollen im Web-Panel: Moderator (Spieler/Chat), Admin (alles am Server), Owner (zusätzlich Benutzer).</summary>
|
||||||
|
public enum PanelRole { Moderator = 1, Admin = 2, Owner = 3 }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Benutzer des Web-Panels in data/panel_users.json. Passwörter nur als PBKDF2-SHA256-Hash mit eigenem Salz.
|
||||||
|
/// </summary>
|
||||||
|
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<User> 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<User> users;
|
||||||
|
|
||||||
|
public PanelUsers(string path)
|
||||||
|
{
|
||||||
|
this.path = path;
|
||||||
|
users = File.Exists(path) ? JsonSerializer.Deserialize<FileData>(File.ReadAllText(path), Options)?.Users ?? new() : new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Empty { get { lock (gate) return users.Count == 0; } }
|
||||||
|
|
||||||
|
public List<User> 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";
|
||||||
|
|
||||||
|
/// <summary>Neuen Benutzer anlegen. Gibt einen Fehler zurück oder null.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Benutzer prüfen. Gibt ihn bei richtigem Passwort zurück, sonst null.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
-38
@@ -94,6 +94,61 @@ if (Array.IndexOf(args, "--import-save") >= 0)
|
|||||||
if (Directory.Exists(original)) Directory.Delete(original, true);
|
if (Directory.Exists(original)) Directory.Delete(original, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Konsole, "msco <befehl>" (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<string> 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.
|
||||||
|
var input = new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string line;
|
||||||
|
while ((line = Console.ReadLine()) != null) send(line);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}) { IsBackground = true };
|
||||||
|
input.Start();
|
||||||
|
|
||||||
|
// Befehle von "msco <befehl>" (docker exec) aus der Datei commands.in übernehmen
|
||||||
|
var commandWatcher = new Thread(() =>
|
||||||
|
{
|
||||||
|
string processing = commandFile + ".processing";
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
Thread.Sleep(500);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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()); send(line.Trim()); }
|
||||||
|
File.Delete(processing);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}) { IsBackground = true };
|
||||||
|
commandWatcher.Start();
|
||||||
|
|
||||||
|
// Sauber beenden (Weltzustand speichern) bei Strg+C, systemctl stop / docker stop (SIGTERM) und SIGQUIT
|
||||||
|
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(); });
|
||||||
|
|
||||||
|
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
|
// Lizenzschlüssel beim Keymaster prüfen
|
||||||
if (string.IsNullOrWhiteSpace(config.LicenseKey))
|
if (string.IsNullOrWhiteSpace(config.LicenseKey))
|
||||||
{
|
{
|
||||||
@@ -123,52 +178,27 @@ if (!key.Valid)
|
|||||||
}
|
}
|
||||||
GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})");
|
GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})");
|
||||||
|
|
||||||
var server = new GameServer(config, dataDir, configPath, key.Owner);
|
var next = new GameServer(config, dataDir, configPath, key.Owner);
|
||||||
if (!server.LoadSave())
|
if (!next.LoadSave())
|
||||||
{
|
{
|
||||||
Pause();
|
Pause();
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
// Web-Panel (wie txAdmin) - standardmäßig auf dem Spiel-Port; bei einem Neustart dasselbe Panel weiterverwenden
|
||||||
// Konsolenbefehle in eigenem Thread lesen, ausgeführt werden sie in der Server-Schleife.
|
if (config.PanelEnabled || panel != null)
|
||||||
// Als Dienst (systemd/Docker ohne Terminal) gibt es keine Eingabe - dann einfach ohne Konsole laufen.
|
|
||||||
var input = new Thread(() =>
|
|
||||||
{
|
{
|
||||||
try
|
if (panel == null) panel = AdminPanel.Start(next, config, dataDir);
|
||||||
{
|
else panel.Attach(next, config);
|
||||||
string line;
|
next.Panel = panel;
|
||||||
while ((line = Console.ReadLine()) != null) server.EnqueueCommand(line);
|
|
||||||
}
|
}
|
||||||
catch { }
|
server = next;
|
||||||
}) { IsBackground = true };
|
|
||||||
input.Start();
|
|
||||||
|
|
||||||
// Befehle von "msco <befehl>" (docker exec) aus der Datei commands.in übernehmen
|
|
||||||
var commandWatcher = new Thread(() =>
|
|
||||||
{
|
|
||||||
string processing = commandFile + ".processing";
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
Thread.Sleep(500);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
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()); }
|
|
||||||
File.Delete(processing);
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
}) { IsBackground = true };
|
|
||||||
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"); });
|
|
||||||
|
|
||||||
server.Run();
|
server.Run();
|
||||||
|
if (!server.RestartRequested) break;
|
||||||
|
server = null;
|
||||||
|
GameServer.Log("Restarting the server ...");
|
||||||
|
}
|
||||||
|
panel?.Stop();
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
string ArgValue(string name)
|
string ArgValue(string name)
|
||||||
|
|||||||
@@ -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_NAME`, `MSCO_PORT`, `MSCO_MAX_PLAYERS`, `MSCO_PUBLIC`, `MSCO_REQUIRE_STEAM`, `MSCO_PUBLIC_ADDRESS`,
|
||||||
`MSCO_STEAM_WEB_API_KEY`) überschreiben `server.json`.
|
`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://<server-ip>: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://<server>: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
|
## Konsolenbefehle
|
||||||
|
|
||||||
| Befehl | Beschreibung |
|
| Befehl | Beschreibung |
|
||||||
@@ -135,6 +179,9 @@ Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvari
|
|||||||
| `backup` | aktuellen Spielstand jetzt sichern |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `stop` | speichern und beenden |
|
||||||
|
|
||||||
Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F7-Konsole) das Admin-Menü: Kick/Ban,
|
Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F7-Konsole) das Admin-Menü: Kick/Ban,
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ public sealed class ServerConfig
|
|||||||
/// <summary>Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen.</summary>
|
/// <summary>Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen.</summary>
|
||||||
public List<string> Admins { get; set; } = new();
|
public List<string> Admins { get; set; } = new();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Web-Panel (wie txAdmin)
|
||||||
|
/// <summary>Web-Panel einschalten.</summary>
|
||||||
|
public bool PanelEnabled { get; set; } = true;
|
||||||
|
/// <summary>0 = derselbe Port wie der Gameserver (Browser und Spiel werden automatisch unterschieden), sonst eigener Port.</summary>
|
||||||
|
public int PanelPort { get; set; } = 0;
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
public string PanelBindAddress { get; set; } = "0.0.0.0";
|
||||||
|
/// <summary>Hinter einem Reverse-Proxy: X-Forwarded-For/-Proto/-Prefix übernehmen (echte IP, HTTPS, Unterpfad).</summary>
|
||||||
|
public bool PanelTrustProxy { get; set; } = false;
|
||||||
|
/// <summary>Unterpfad, unter dem das Panel läuft, z.B. "/panel" (leer = direkt unter /).</summary>
|
||||||
|
public string PanelBasePath { get; set; } = "";
|
||||||
|
|
||||||
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
|
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
|
||||||
|
|
||||||
public static ServerConfig LoadOrCreate(string path, out bool created)
|
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)
|
/// MSCO_PUBLIC_ADDRESS, MSCO_STEAM_WEB_API_KEY (die Master-Adresse ist fest, siehe MasterServer)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>Namen der gesetzten Umgebungsvariablen (fürs Log, damit klar ist, woher ein Wert kommt).</returns>
|
/// <returns>Namen der gesetzten Umgebungsvariablen (fürs Log, damit klar ist, woher ein Wert kommt).</returns>
|
||||||
|
/// <summary>Per Umgebungsvariable gesetzte Einstellungen (nicht in server.json gespeichert).</summary>
|
||||||
|
[System.Text.Json.Serialization.JsonIgnore]
|
||||||
|
public List<string> EnvironmentOverrides { get; private set; } = new();
|
||||||
|
|
||||||
public List<string> ApplyEnvironment()
|
public List<string> ApplyEnvironment()
|
||||||
{
|
{
|
||||||
var used = new List<string>();
|
var used = new List<string>();
|
||||||
|
EnvironmentOverrides = used;
|
||||||
string Take(string name, string current)
|
string Take(string name, string current)
|
||||||
{
|
{
|
||||||
string value = Env(name);
|
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 (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_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_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;
|
return used;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ config.Save(configPath);
|
|||||||
var server = new GameServer(config, dir, configPath, "Tester", offline: true);
|
var server = new GameServer(config, dir, configPath, "Tester", offline: true);
|
||||||
server.SaveUploadTimeoutSeconds = 1; // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten
|
server.SaveUploadTimeoutSeconds = 1; // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten
|
||||||
if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; }
|
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 };
|
var serverThread = new Thread(server.Run) { IsBackground = true };
|
||||||
serverThread.Start();
|
serverThread.Start();
|
||||||
Thread.Sleep(500);
|
Thread.Sleep(500);
|
||||||
@@ -289,6 +291,8 @@ try
|
|||||||
afterReset.Close();
|
afterReset.Close();
|
||||||
inGame.Close();
|
inGame.Close();
|
||||||
newcomer.Close();
|
newcomer.Close();
|
||||||
|
|
||||||
|
await PanelTests();
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
@@ -296,6 +300,145 @@ catch (Exception e)
|
|||||||
Console.WriteLine(" FAIL 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");
|
Console.WriteLine($"\n{passed} passed, {failed} failed");
|
||||||
server.EnqueueCommand("stop");
|
server.EnqueueCommand("stop");
|
||||||
Thread.Sleep(300);
|
Thread.Sleep(300);
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ services:
|
|||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
ports:
|
ports:
|
||||||
|
# Spiel UND Web-Panel (http://<server>:7777/) - Browser werden am selben Port erkannt
|
||||||
- "7777:7777/tcp"
|
- "7777:7777/tcp"
|
||||||
|
# Eigener Panel-Port? In server.json "PanelPort": 40120 setzen und diese Zeile einkommentieren:
|
||||||
|
# - "40120:40120/tcp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
|||||||
Reference in New Issue
Block a user