Panel independent of the game server: ServerHost owns port + panel and starts/stops the game server; panel works while stopped/crashed/unconfigured (settings, save ZIP upload, backups, bans offline); crash auto-restart; players told 'offline'; tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+97
-37
@@ -38,7 +38,8 @@ public sealed class AdminPanel
|
||||
public DateTime Expires;
|
||||
}
|
||||
|
||||
GameServer server; // nach einem Neustart aus dem Panel ein neuer (Attach)
|
||||
readonly ServerHost host; // läuft immer - der Gameserver darunter kann aus sein
|
||||
readonly string dataDir;
|
||||
ServerConfig config;
|
||||
readonly PanelUsers users;
|
||||
readonly ConcurrentDictionary<string, Session> sessions = new();
|
||||
@@ -53,18 +54,19 @@ public sealed class AdminPanel
|
||||
public int InternalPort { get; private set; }
|
||||
public bool SharesGamePort { get; private set; }
|
||||
|
||||
AdminPanel(GameServer server, ServerConfig config, string dataDir)
|
||||
AdminPanel(ServerHost host, ServerConfig config, string dataDir)
|
||||
{
|
||||
this.server = server;
|
||||
this.host = host;
|
||||
this.dataDir = dataDir;
|
||||
this.config = config;
|
||||
users = new PanelUsers(Path.Combine(dataDir, "panel_users.json"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Start
|
||||
|
||||
public static AdminPanel Start(GameServer server, ServerConfig config, string dataDir)
|
||||
public static AdminPanel Start(ServerHost host, ServerConfig config, string dataDir)
|
||||
{
|
||||
var panel = new AdminPanel(server, config, dataDir);
|
||||
var panel = new AdminPanel(host, config, dataDir);
|
||||
try
|
||||
{
|
||||
panel.StartWeb();
|
||||
@@ -160,12 +162,8 @@ public sealed class AdminPanel
|
||||
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;
|
||||
}
|
||||
/// <summary>Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu).</summary>
|
||||
public void UpdateConfig(ServerConfig newConfig) => config = newConfig;
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
@@ -354,14 +352,14 @@ public sealed class AdminPanel
|
||||
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());
|
||||
return await Run(s => s.PanelStatus(), () => host.OfflineStatus());
|
||||
});
|
||||
|
||||
app.MapGet("/api/players", async (HttpContext ctx) =>
|
||||
{
|
||||
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
||||
bool withAddress = user.Role >= PanelRole.Admin; // IP-Adressen nur für Admins
|
||||
return await Run(() => server.PanelPlayers(withAddress));
|
||||
return await Run(s => s.PanelPlayers(withAddress), () => Array.Empty<object>());
|
||||
});
|
||||
|
||||
app.MapPost("/api/players/{id:int}/{action}", async (HttpContext ctx, int id, string action) =>
|
||||
@@ -373,10 +371,10 @@ public sealed class AdminPanel
|
||||
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)),
|
||||
"kick" => await RunText(s => s.PanelKick(id, false, reason, by)),
|
||||
"ban" => await RunText(s => s.PanelKick(id, true, reason, by)),
|
||||
"op" => await RunText(s => s.PanelSetAdmin(id, true, by)),
|
||||
"deop" => await RunText(s => s.PanelSetAdmin(id, false, by)),
|
||||
_ => Error(404, "Unknown action")
|
||||
};
|
||||
});
|
||||
@@ -386,14 +384,14 @@ public sealed class AdminPanel
|
||||
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));
|
||||
return await RunText(s => s.PanelSay(text, by));
|
||||
});
|
||||
|
||||
// --- Banns
|
||||
app.MapGet("/api/bans", async (HttpContext ctx) =>
|
||||
{
|
||||
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
||||
return await Run(() => server.PanelBans());
|
||||
return await Run(s => s.PanelBans(), () => config.BannedSteamIds.ToList());
|
||||
});
|
||||
|
||||
app.MapPost("/api/bans/add", async (HttpContext ctx) =>
|
||||
@@ -401,7 +399,7 @@ public sealed class AdminPanel
|
||||
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));
|
||||
return await RunText(s => s.PanelBanId(id, reason, by), () => OfflineBan(id, true, by));
|
||||
});
|
||||
|
||||
app.MapPost("/api/bans/remove", async (HttpContext ctx) =>
|
||||
@@ -409,7 +407,7 @@ public sealed class AdminPanel
|
||||
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));
|
||||
return await RunText(s => s.PanelUnban(id, by), () => OfflineBan(id, false, by));
|
||||
});
|
||||
|
||||
// --- Konsole
|
||||
@@ -432,14 +430,23 @@ public sealed class AdminPanel
|
||||
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));
|
||||
if (host.State != HostState.Running || host.Server == null)
|
||||
{
|
||||
// Server aus: Host-Befehle (start, status, panel ...) - Ausgabe aus dem Log einsammeln
|
||||
long from = LogBuffer.LastSeq;
|
||||
GameServer.Log($"> {command} ({by})");
|
||||
host.Command(command);
|
||||
await Task.Delay(400);
|
||||
return Ok(string.Join("\n", LogBuffer.Since(from, 200).Skip(1).Select(l => l.Text)));
|
||||
}
|
||||
return await RunText(s => s.RunCommandCaptured(command, by));
|
||||
});
|
||||
|
||||
// --- Welt / Backups
|
||||
app.MapGet("/api/backups", async (HttpContext ctx) =>
|
||||
{
|
||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||
return await Run(() => server.PanelBackups());
|
||||
return await Run(s => s.PanelBackups(), () => SaveStore.BackupDirs(dataDir).Select(d => Path.GetFileName(d)).ToList());
|
||||
});
|
||||
|
||||
app.MapPost("/api/world/{action}", async (HttpContext ctx, string action) =>
|
||||
@@ -447,22 +454,29 @@ public sealed class AdminPanel
|
||||
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));
|
||||
return await RunText(s => s.PanelWorld(action, name, by), () => OfflineWorld(action, name, by));
|
||||
});
|
||||
|
||||
// --- Server steuern (Start/Stopp/Neustart)
|
||||
app.MapPost("/api/server/{action}", async (HttpContext ctx, string action) =>
|
||||
{
|
||||
var user = Require(ctx, action == "shutdown" ? PanelRole.Owner : PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||
string by = By(user);
|
||||
return await RunText(() => server.PanelServerAction(action, by));
|
||||
await Task.CompletedTask;
|
||||
return action switch
|
||||
{
|
||||
"start" => Ok(host.RequestStart(by)),
|
||||
"stop" => Ok(host.RequestStop(by)),
|
||||
"restart" => Ok(host.RequestRestart(by)),
|
||||
_ => Error(404, "Unknown action")
|
||||
};
|
||||
});
|
||||
|
||||
// --- Einstellungen (server.json)
|
||||
app.MapGet("/api/config", async (HttpContext ctx) =>
|
||||
{
|
||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||
return await Run(() => server.PanelConfig());
|
||||
return await Run(s => s.PanelConfig(), () => ConfigEditor.Build(config));
|
||||
});
|
||||
|
||||
app.MapPost("/api/config", async (HttpContext ctx) =>
|
||||
@@ -471,17 +485,17 @@ public sealed class AdminPanel
|
||||
var body = await Body(ctx);
|
||||
bool owner = user.Role >= PanelRole.Owner;
|
||||
string by = By(user);
|
||||
return await RunText(() => server.PanelUpdateConfig(body, owner, by));
|
||||
return await RunText(s => s.PanelUpdateConfig(body, owner, by), () => ConfigEditor.Apply(config, host.ConfigPath, body, owner, by));
|
||||
});
|
||||
|
||||
// --- Spielstand als ZIP
|
||||
app.MapGet("/api/save/download", async (HttpContext ctx) =>
|
||||
{
|
||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||
var task = server.Invoke(() => server.ExportSaveZip());
|
||||
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy");
|
||||
await Task.CompletedTask;
|
||||
if (!SaveStore.HasSave(dataDir)) return Error(404, "There is no save game on the server yet");
|
||||
GameServer.Log($"[panel] {user.Name} downloaded the save game");
|
||||
return Results.File(await task, "application/zip", $"msco-save-{DateTime.Now:yyyyMMdd-HHmmss}.zip");
|
||||
return Results.File(SaveStore.ExportZip(dataDir), "application/zip", $"msco-save-{DateTime.Now:yyyyMMdd-HHmmss}.zip");
|
||||
});
|
||||
|
||||
app.MapPost("/api/save/upload", async (HttpContext ctx) =>
|
||||
@@ -500,7 +514,7 @@ public sealed class AdminPanel
|
||||
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));
|
||||
return await RunText(s => s.ImportSaveZip(zip, by), () => SaveStore.ImportZipOffline(dataDir, zip, by));
|
||||
});
|
||||
|
||||
// --- Benutzer (nur Owner)
|
||||
@@ -568,20 +582,66 @@ public sealed class AdminPanel
|
||||
|
||||
static string By(PanelUsers.User user) => user.Name + " (panel)";
|
||||
|
||||
async Task<IResult> Run<T>(Func<T> func)
|
||||
const string NotRunning = "The game server is not running - start it on the dashboard";
|
||||
|
||||
/// <summary>In der Server-Schleife ausführen; ist der Server aus, whenStopped (oder Fehler 409).</summary>
|
||||
async Task<IResult> Run<T>(Func<GameServer, T> func, Func<object> whenStopped = null)
|
||||
{
|
||||
var task = server.Invoke(func);
|
||||
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy or stopping");
|
||||
var server = host.Server;
|
||||
if (server == null || host.State != HostState.Running)
|
||||
return whenStopped != null ? Results.Json(whenStopped()) : Error(409, NotRunning);
|
||||
var task = server.Invoke(() => func(server));
|
||||
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task)
|
||||
return whenStopped != null ? Results.Json(whenStopped()) : Error(503, "The server is busy or stopping");
|
||||
return Results.Json(await task);
|
||||
}
|
||||
|
||||
async Task<IResult> RunText(Func<string> func)
|
||||
async Task<IResult> RunText(Func<GameServer, string> func, Func<string> whenStopped = null)
|
||||
{
|
||||
var task = server.Invoke(func);
|
||||
var server = host.Server;
|
||||
if (server == null || host.State != HostState.Running)
|
||||
return whenStopped != null ? Ok(whenStopped()) : Error(409, NotRunning);
|
||||
var task = server.Invoke(() => func(server));
|
||||
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy or stopping");
|
||||
return Ok(await task);
|
||||
}
|
||||
|
||||
/// <summary>Server aus: Bann direkt in server.json setzen bzw. aufheben.</summary>
|
||||
string OfflineBan(string id, bool ban, string by)
|
||||
{
|
||||
id = (id ?? "").Trim();
|
||||
if (ban)
|
||||
{
|
||||
bool valid = (id.Length == 17 && id.All(char.IsDigit)) || (id.StartsWith("name:") && id.Length > 5 && id.Length < 60);
|
||||
if (!valid) return "Enter a SteamID64 (17 digits) or name:playername";
|
||||
if (config.BannedSteamIds.Contains(id)) return id + " is already banned";
|
||||
config.BannedSteamIds.Add(id);
|
||||
}
|
||||
else if (!config.BannedSteamIds.Remove(id)) return id + " is not banned";
|
||||
try { config.Save(host.ConfigPath); } catch (Exception e) { return "Could not save server.json: " + e.Message; }
|
||||
GameServer.Log($"{id} was {(ban ? "banned" : "unbanned")} by {by}");
|
||||
return id + (ban ? " is banned" : " is no longer banned");
|
||||
}
|
||||
|
||||
/// <summary>Server aus: Spielstand-Aktionen direkt auf den Dateien.</summary>
|
||||
string OfflineWorld(string action, string name, string by)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "backup":
|
||||
if (!SaveStore.HasSave(dataDir)) return "There is no save game to back up";
|
||||
SaveStore.Backup(dataDir);
|
||||
GameServer.Log($"Save game backed up by {by}");
|
||||
return "Backup created";
|
||||
case "reset": return SaveStore.ResetOffline(dataDir, by);
|
||||
case "restore": return SaveStore.RestoreOffline(dataDir, name, by);
|
||||
case "resetpoint": return SaveStore.SetResetPoint(dataDir) ?? "The current save game is now the reset point";
|
||||
case "reloadsave": return "The server is stopped - it loads the save game when you start it";
|
||||
case "save": return "The server is stopped - nothing to save";
|
||||
default: return "Unknown action";
|
||||
}
|
||||
}
|
||||
|
||||
static IResult Ok(string message) => Results.Json(new { ok = true, message });
|
||||
static IResult Error(int status, string message) => Results.Json(new { error = message }, statusCode: status);
|
||||
static IResult Forbidden(string message) => Error(403, message);
|
||||
|
||||
Reference in New Issue
Block a user