From c74c48dfc8ea7452e1acaa4391e7cc8f8a160ce6 Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sun, 27 Sep 2026 19:02:08 +0200 Subject: [PATCH] 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 --- AdminPanel.cs | 134 ++++++++++----- ConfigEditor.cs | 99 +++++++++++ GameServer.cs | 319 ++++++++---------------------------- Panel/app.css | 9 + Panel/app.js | 59 ++++--- Program.cs | 90 ++-------- README.md | 15 +- SaveStore.cs | 205 +++++++++++++++++++++++ ServerHost.cs | 401 +++++++++++++++++++++++++++++++++++++++++++++ Tests/Program.cs | 94 +++++++---- docker-compose.yml | 4 +- 11 files changed, 1012 insertions(+), 417 deletions(-) create mode 100644 ConfigEditor.cs create mode 100644 SaveStore.cs create mode 100644 ServerHost.cs diff --git a/AdminPanel.cs b/AdminPanel.cs index 988a89d..0489cf5 100644 --- a/AdminPanel.cs +++ b/AdminPanel.cs @@ -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 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) ? "" : bind.ToString())}:{port}{basePath}/"); } - /// Neustart im selben Programm: das Panel (und die Anmeldungen) bleiben, der Server ist neu. - public void Attach(GameServer newServer, ServerConfig newConfig) - { - server = newServer; - config = newConfig; - } + /// Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu). + 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()); }); 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 Run(Func func) + const string NotRunning = "The game server is not running - start it on the dashboard"; + + /// In der Server-Schleife ausführen; ist der Server aus, whenStopped (oder Fehler 409). + async Task Run(Func func, Func 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 RunText(Func func) + async Task RunText(Func func, Func 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); } + /// Server aus: Bann direkt in server.json setzen bzw. aufheben. + 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"); + } + + /// Server aus: Spielstand-Aktionen direkt auf den Dateien. + 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); diff --git a/ConfigEditor.cs b/ConfigEditor.cs new file mode 100644 index 0000000..268268f --- /dev/null +++ b/ConfigEditor.cs @@ -0,0 +1,99 @@ +namespace MSCO.Server; + +/// +/// server.json im Web-Panel lesen und ändern - bei laufendem Server (dann sofort wirksam) und wenn er aus ist +/// (z.B. Lizenzschlüssel eintragen, damit er überhaupt starten kann). +/// +public static class ConfigEditor +{ + public static object Build(ServerConfig config) => new + { + name = config.Name, + description = config.Description, + maxPlayers = config.MaxPlayers, + isPublic = config.Public, + requireSteam = config.RequireSteam, + publicAddress = config.PublicAddress, + saveSyncMinutes = config.SaveSyncMinutes, + autosaveSeconds = config.AutosaveSeconds, + port = config.Port, + panelEnabled = config.PanelEnabled, + panelPort = config.PanelPort, + panelBindAddress = config.PanelBindAddress, + panelTrustProxy = config.PanelTrustProxy, + panelBasePath = config.PanelBasePath, + licenseKeySet = !string.IsNullOrWhiteSpace(config.LicenseKey), + steamWebApiKeySet = !string.IsNullOrWhiteSpace(config.SteamWebApiKey), + environmentOverrides = config.EnvironmentOverrides + }; + + /// Einstellungen ändern. Schlüssel (Lizenz, Steam Web API) nur als Owner; leer = unverändert. + public static string Apply(ServerConfig config, string configPath, System.Text.Json.JsonElement b, bool owner, string by) + { + if (b.ValueKind != System.Text.Json.JsonValueKind.Object) return "Invalid settings"; + var changed = new List(); + bool restartNeeded = false; + + string Text(string name, string current, int max) + { + if (!b.TryGetProperty(name, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String) return current; + string s = v.GetString().Trim(); + if (s.Length > max) s = s[..max]; + if (s != current) changed.Add(name); + return s; + } + int Number(string name, int current, int min, int max) + { + if (!b.TryGetProperty(name, out var v) || !v.TryGetInt32(out int n)) return current; + n = Math.Clamp(n, min, max); + if (n != current) changed.Add(name); + return n; + } + bool Flag(string name, bool current) + { + if (!b.TryGetProperty(name, out var v) || (v.ValueKind != System.Text.Json.JsonValueKind.True && v.ValueKind != System.Text.Json.JsonValueKind.False)) return current; + bool f = v.GetBoolean(); + if (f != current) changed.Add(name); + return f; + } + + string name = Text("name", config.Name, 60); + if (name.Length == 0) return "The server name cannot be empty"; + config.Name = name; + config.Description = Text("description", config.Description, 300); + config.MaxPlayers = Number("maxPlayers", config.MaxPlayers, 1, 64); + config.Public = Flag("isPublic", config.Public); + config.RequireSteam = Flag("requireSteam", config.RequireSteam); + config.PublicAddress = Text("publicAddress", config.PublicAddress, 200); + config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240); + config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600); + + int before = changed.Count; + config.Port = Number("port", config.Port, 1, 65535); + config.PanelEnabled = Flag("panelEnabled", config.PanelEnabled); + config.PanelPort = Number("panelPort", config.PanelPort, 0, 65535); + config.PanelBindAddress = Text("panelBindAddress", config.PanelBindAddress, 60); + config.PanelTrustProxy = Flag("panelTrustProxy", config.PanelTrustProxy); + config.PanelBasePath = Text("panelBasePath", config.PanelBasePath, 60); + if (changed.Count > before) restartNeeded = true; + + foreach (string secret in new[] { "licenseKey", "steamWebApiKey" }) + { + if (!b.TryGetProperty(secret, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String || v.GetString().Trim().Length == 0) continue; + if (!owner) return "Only the owner can change keys"; + string value = v.GetString().Trim(); + if (value.Length > 200) return "Key too long"; + if (secret == "licenseKey") config.LicenseKey = value; else config.SteamWebApiKey = value; + changed.Add(secret); + restartNeeded = true; + } + + if (changed.Count == 0) return "Nothing changed"; + try { config.Save(configPath); } + catch (Exception e) { return "Could not save server.json: " + e.Message; } + GameServer.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)" : ""); + } +} diff --git a/GameServer.cs b/GameServer.cs index cdbffc1..4051385 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -15,7 +15,8 @@ public sealed class GameServer public const string ModVersion = "2.8"; // muss mit MSCO.ModVersion übereinstimmen public const int ServerId = 0; // Absender-ID des Servers; Spieler bekommen 1, 2, ... - static readonly string[] SaveFiles = { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" }; + static string[] SaveFiles => SaveStore.SaveFiles; + string DataDir => Path.GetDirectoryName(savePath)!; const double HeartbeatSeconds = 30; enum ClientState { New, Verifying, Joined } @@ -55,7 +56,16 @@ public sealed class GameServer readonly ConcurrentQueue actions = new(); // vom Web-Panel: laufen in der Server-Schleife readonly Stopwatch clock = Stopwatch.StartNew(); - TcpListener listener; + // Neue Verbindungen reicht der Host herein (er besitzt den Port und erkennt Browser fürs Panel) + readonly ConcurrentQueue incoming = new(); + readonly AutoResetEvent incomingSignal = new(false); + + /// Vom Host: eine neue Spiel-Verbindung. + public void AcceptExternal(TcpClient tcp) + { + incoming.Enqueue(tcp); + incomingSignal.Set(); + } List> saveFiles = new(); int nextId = 1; byte[] lastWorldTime; // letzter Uhrzeit-/Wetter-Stand @@ -80,7 +90,6 @@ public sealed class GameServer double nextSaveSync; readonly List waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten const long MaxUploadBytes = 32L * 1024 * 1024; - const int KeepSaveBackups = 5; /// So lange auf den hochgeladenen Spielstand warten, danach bekommen Wartende den gespeicherten. public double SaveUploadTimeoutSeconds { get; set; } = 15; const int MaxDestroyPerMinute = 20; @@ -141,9 +150,7 @@ public sealed class GameServer public void Run() { EnsureResetPoint(); - listener = new TcpListener(IPAddress.Any, config.Port); - listener.Start(); - Log($"Listening on port {config.Port} - {config.Name} (max {config.MaxPlayers} players)"); + Log($"Game server running on port {config.Port} - {config.Name} (max {config.MaxPlayers} players)"); if (!config.RequireSteam) Log("Non-Steam server: players without Steam can join (RequireSteam = false)"); else Log(steam.Enabled ? "Steam required - tickets are verified with the Steam Web API" : "Steam required (no Web API key set, tickets are not verified online)"); Log($"World: {world.Objects.Count} objects, {world.Events.Count} part/bolt actions"); @@ -166,9 +173,18 @@ public sealed class GameServer foreach (var c in clients.Values) { - // Meist ein Neustart (Update, docker compose up): die Spieler verbinden sich automatisch neu, sobald der - // Server wieder da ist. Bleibt er aus, bekommen sie nach ein paar Versuchen einen Hinweis im Hauptmenü. - if (c.State == ClientState.Joined) c.Conn.Send(MsgType.Reconnect, Wire.Build(w => w.Write("The server is restarting - reconnecting..."))); + if (c.State != ClientState.Joined) { c.Conn.CloseAfterSend("Server shutting down"); continue; } + if (stoppedBy != null && !RestartRequested) + { + // Gezielt gestoppt (Panel/Konsole): Grund zeigen, nicht endlos neu verbinden + c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write($"The server was stopped by {stoppedBy}"))); + } + else + { + // Neustart / Update (docker compose up): die Spieler verbinden sich automatisch neu, sobald der Server + // wieder da ist. Bleibt er aus, bekommen sie nach ein paar Versuchen einen Hinweis im Hauptmenü. + c.Conn.Send(MsgType.Reconnect, Wire.Build(w => w.Write("The server is restarting - reconnecting..."))); + } c.Conn.CloseAfterSend("Server shutting down"); } // Kurz warten, bis die Spieler die Meldung gelesen haben (sauber trennen statt hart abbrechen) @@ -179,7 +195,6 @@ public sealed class GameServer Thread.Sleep(20); } foreach (var c in clients.Values) c.Conn.Close("Server shutting down"); - listener.Stop(); SaveWorld(); if (!offline) { @@ -189,19 +204,13 @@ public sealed class GameServer Log("Server stopped."); } - /// Web-Panel (gesetzt vom Programm). Teilt es sich den Spiel-Port, werden Browser hier erkannt und weitergereicht. + /// Web-Panel (gehört dem Host, läuft auch, wenn dieser Server aus ist). public AdminPanel Panel { get; set; } - readonly ConcurrentQueue sniffedGameClients = new(); + public ServerHost Host { get; set; } void AcceptClients() { - while (listener.Pending()) - { - var tcp = listener.AcceptTcpClient(); - if (Panel != null && Panel.SharesGamePort) _ = SniffAsync(tcp); - else AddClient(tcp); - } - while (sniffedGameClients.TryDequeue(out var tcp)) AddClient(tcp); + while (incoming.TryDequeue(out var tcp)) AddClient(tcp); } void AddClient(TcpClient tcp) @@ -210,35 +219,6 @@ public sealed class GameServer clients[c.Id] = c; } - /// - /// Gleicher Port für Spiel und Web-Panel: in die ersten Bytes schauen (ohne sie zu verbrauchen). Ein Browser - /// schickt "GET /", "POST" ... - das Spiel beginnt mit einer kleinen Längenangabe (Bytes 2-4 sind 0). - /// Beide Seiten reden zuerst, der Server muss also nichts senden, bevor er es weiß. - /// - async Task SniffAsync(TcpClient tcp) - { - var buffer = new byte[4]; - int n; - try - { - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - while (true) - { - n = await tcp.Client.ReceiveAsync(buffer, SocketFlags.Peek, timeout.Token); - if (n == 0) { tcp.Close(); return; } - if (n >= 4 || buffer[0] < 'A' || buffer[0] > 'Z') break; // kein Buchstabe = sicher das Spiel - await Task.Delay(15, timeout.Token); - } - } - catch - { - try { tcp.Close(); } catch { } - return; - } - if (AdminPanel.LooksLikeHttp(buffer, n)) await Panel.RelayAsync(tcp); - else sniffedGameClients.Enqueue(tcp); - } - void PollClients() { List closed = null; @@ -285,7 +265,7 @@ public sealed class GameServer } // Regelmäßig den aktuellen Spielstand vom Spiel holen - if (config.SaveSyncMinutes > 0 && !GameStopped && now >= nextSaveSync) + if (config.SaveSyncMinutes > 0 && now >= nextSaveSync) { nextSaveSync = now + config.SaveSyncMinutes * 60.0; RequestSave("regular save"); @@ -310,7 +290,7 @@ public sealed class GameServer else if (result.Status == 0) Log("Master server not reachable - server is not listed right now."); else ReportReachability(result); } - if (!offline && !GameStopped && now >= nextHeartbeat && heartbeat == null) + if (!offline && now >= nextHeartbeat && heartbeat == null) { nextHeartbeat = now + HeartbeatSeconds; heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion); @@ -523,7 +503,6 @@ public sealed class GameServer // Andere Version ist erlaubt (der Spieler wurde in der Mod schon gewarnt), nur vermerken if (version != ModVersion) Log($"{c.Name} uses MSCO {version} (server {ModVersion}) - syncing may not work correctly"); if (config.BannedSteamIds.Contains(c.SteamId)) { Reject(c, "You are banned from this server"); return; } - if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; } if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; } c.State = ClientState.Verifying; @@ -534,7 +513,6 @@ public sealed class GameServer { string error = c.Verification.Result; if (error != null) { Reject(c, error); return; } - if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; } if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; } // Nur eine von Steam bestätigte SteamID gilt als Beweis. Ohne SteamWebApiKey kann jeder eine beliebige @@ -694,23 +672,10 @@ public sealed class GameServer } /// Vorhandene Spielstand-Backups, neueste zuerst. - List SaveBackups() - { - string backups = Path.Combine(Path.GetDirectoryName(savePath)!, "save_backups"); - return Directory.Exists(backups) ? Directory.GetDirectories(backups).OrderByDescending(d => d).ToList() : new List(); - } + List SaveBackups() => SaveStore.BackupDirs(DataDir); /// Bisherigen Spielstand nach data/save_backups/Zeitstempel kopieren, die letzten Stände behalten. - void BackupSave() - { - if (!Directory.Exists(savePath)) return; - string backups = Path.Combine(Path.GetDirectoryName(savePath)!, "save_backups"); - string target = Path.Combine(backups, DateTime.Now.ToString("yyyyMMdd-HHmmss")); - Directory.CreateDirectory(target); - foreach (string file in Directory.GetFiles(savePath)) File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true); - foreach (string old in Directory.GetDirectories(backups).OrderByDescending(d => d).Skip(KeepSaveBackups)) - try { Directory.Delete(old, true); } catch { } - } + void BackupSave() => SaveStore.Backup(DataDir); /// /// Alle Spieler neu verbinden lassen (nach reset-world / reload-save): sie sehen die Meldung, gehen kurz ins Menü @@ -743,16 +708,9 @@ public sealed class GameServer /// Aktuellen Spielstand als Reset-Punkt merken. bool SetResetPoint() { - if (!File.Exists(Path.Combine(savePath, "defaultES2File.txt"))) return false; - try - { - if (Directory.Exists(originalPath)) Directory.Delete(originalPath, true); - Directory.CreateDirectory(originalPath); - foreach (string file in Directory.GetFiles(savePath)) - if (!file.EndsWith(".tmp")) File.Copy(file, Path.Combine(originalPath, Path.GetFileName(file)), true); - return true; - } - catch (Exception e) { Log("Could not remember the reset point: " + e.Message); return false; } + string error = SaveStore.SetResetPoint(DataDir); + if (error != null && SaveStore.HasSave(DataDir)) Log(error); + return error == null; } /// @@ -870,11 +828,14 @@ public sealed class GameServer void WaitForTraffic() { waitList.Clear(); - waitList.Add(listener.Server); foreach (var c in clients.Values) if (!c.Conn.IsClosed) waitList.Add(c.Conn.Socket); - int micros = clients.Count == 0 ? 50_000 : 5_000; // leerer Server: seltener aufwachen - try { Socket.Select(waitList, null, null, micros); } + if (waitList.Count == 0) + { + incomingSignal.WaitOne(50); // leerer Server: warten, bis jemand verbindet (oder Timer fällig) + return; + } + try { Socket.Select(waitList, null, null, 5_000); } catch { Thread.Sleep(2); } // z.B. Socket wurde gerade geschlossen } @@ -899,7 +860,7 @@ public sealed class GameServer switch (args[0].ToLowerInvariant()) { case "help": - Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | backup | restore-save | set-reset-point | panel | stop-game | start-game | restart | say | op | deop | admins | save | reset-world | reload-save | stop"); + Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | backup | restore-save | set-reset-point | panel | restart | stop | exit | say | op | deop | admins | save | reset-world | reload-save | stop"); break; case "say": if (args.Length < 2) { Log("say "); break; } @@ -1009,21 +970,20 @@ public sealed class GameServer Log(SetResetPoint() ? "The current save game is now the reset point" : "No save game to use as reset point"); break; case "stop": + stoppedBy = "Console"; + running = false; + Log(Panel != null ? "Stopping the game server - the web panel keeps running ('start' to start again, 'exit' to quit)" : "Stopping ..."); + break; case "quit": case "exit": - running = false; + RequestExit(); break; case "restart": Log("Restarting (settings, license and save game are loaded again) ..."); RestartRequested = true; running = false; break; - case "stop-game": - Log(StopGame("Console")); - break; - case "start-game": - Log(StartGame("Console")); - break; + default: Log("Unknown command, type 'help'"); break; @@ -1106,7 +1066,7 @@ public sealed class GameServer version = ModVersion, players = JoinedCount, maxPlayers = config.MaxPlayers, - gameStopped = GameStopped, + state = "running", uptimeSeconds = (long)clock.Elapsed.TotalSeconds, port = config.Port, isPublic = config.Public, @@ -1137,153 +1097,51 @@ public sealed class GameServer public object PanelBans() => config.BannedSteamIds.ToList(); - // ---------------------------------------------------------------- Start / Stopp / Neustart + // ---------------------------------------------------------------- Stopp / Neustart (der Host startet neu oder wartet) - /// Spiel offline (Panel läuft weiter): Spieler raus, nicht mehr gelistet, niemand kann beitreten. - public bool GameStopped { get; private set; } /// Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden). public bool RestartRequested { get; private set; } + /// Ganzes Programm beenden (Konsole "exit", docker stop) - sonst bleibt das Panel an. + public bool ExitRequested { get; private set; } + string stoppedBy; // gesetzt = gezielt gestoppt (Spieler bekommen den Grund statt "verbinde neu") - public string StopGame(string by) + public void RequestExit() { - 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"; + ExitRequested = true; + running = false; } public string PanelServerAction(string action, string by) { switch (action) { - case "stop-game": return StopGame(by); - case "start-game": return StartGame(by); + case "stop": + Log($"Server stopped by {by} - the panel keeps running"); + stoppedBy = by; + running = false; + return "Stopping the server"; 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 - }; + public object PanelConfig() => ConfigEditor.Build(config); - /// Einstellungen ändern. Schlüssel (Lizenz, Steam Web API) nur als Owner; leer = unverändert. public string PanelUpdateConfig(System.Text.Json.JsonElement b, bool owner, string by) { - if (b.ValueKind != System.Text.Json.JsonValueKind.Object) return "Invalid settings"; - var changed = new List(); - bool restartNeeded = false; - - string Text(string name, string current, int max) - { - if (!b.TryGetProperty(name, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String) return current; - string s = v.GetString().Trim(); - if (s.Length > max) s = s[..max]; - if (s != current) changed.Add(name); - return s; - } - int Number(string name, int current, int min, int max) - { - if (!b.TryGetProperty(name, out var v) || !v.TryGetInt32(out int n)) return current; - n = Math.Clamp(n, min, max); - if (n != current) changed.Add(name); - return n; - } - bool Flag(string name, bool current) - { - if (!b.TryGetProperty(name, out var v) || (v.ValueKind != System.Text.Json.JsonValueKind.True && v.ValueKind != System.Text.Json.JsonValueKind.False)) return current; - bool f = v.GetBoolean(); - if (f != current) changed.Add(name); - return f; - } - - string name = Text("name", config.Name, 60); - if (name.Length == 0) return "The server name cannot be empty"; - config.Name = name; - config.Description = Text("description", config.Description, 300); - config.MaxPlayers = Number("maxPlayers", config.MaxPlayers, 1, 64); - config.Public = Flag("isPublic", config.Public); - config.RequireSteam = Flag("requireSteam", config.RequireSteam); - config.PublicAddress = Text("publicAddress", config.PublicAddress, 200); - config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240); - config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600); - - int before = changed.Count; - config.Port = Number("port", config.Port, 1, 65535); - config.PanelEnabled = Flag("panelEnabled", config.PanelEnabled); - config.PanelPort = Number("panelPort", config.PanelPort, 0, 65535); - config.PanelBindAddress = Text("panelBindAddress", config.PanelBindAddress, 60); - config.PanelTrustProxy = Flag("panelTrustProxy", config.PanelTrustProxy); - config.PanelBasePath = Text("panelBasePath", config.PanelBasePath, 60); - if (changed.Count > before) restartNeeded = true; - - foreach (string secret in new[] { "licenseKey", "steamWebApiKey" }) - { - if (!b.TryGetProperty(secret, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String || v.GetString().Trim().Length == 0) continue; - if (!owner) return "Only the owner can change keys"; - string value = v.GetString().Trim(); - if (value.Length > 200) return "Key too long"; - if (secret == "licenseKey") config.LicenseKey = value; else config.SteamWebApiKey = value; - changed.Add(secret); - restartNeeded = true; - } - - if (changed.Count == 0) return "Nothing changed"; - try { config.Save(configPath); } - catch (Exception e) { return "Could not save server.json: " + e.Message; } + string result = ConfigEditor.Apply(config, configPath, b, owner, by); 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)" : ""); + return result; } // ---------------------------------------------------------------- Spielstand als ZIP hoch-/herunterladen - const long MaxSaveZip = 64L * 1024 * 1024; - /// /// Spielstand aus einer ZIP übernehmen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem /// Ordner in der ZIP) - Pfade aus der ZIP werden nie benutzt. Danach: Backup, laden, neuer Reset-Punkt, Welt leer, @@ -1291,41 +1149,12 @@ public sealed class GameServer /// public string ImportSaveZip(byte[] zip, string by) { - var found = new Dictionary(StringComparer.OrdinalIgnoreCase); - try - { - using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(zip), System.IO.Compression.ZipArchiveMode.Read); - long total = 0; - foreach (var entry in archive.Entries) - { - string file = Path.GetFileName(entry.FullName.Replace('\\', '/')); - string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase)); - if (known == null) continue; - if (entry.Length > MaxSaveZip || (total += entry.Length) > MaxSaveZip) return "The save game is too large"; - using var s = entry.Open(); - using var ms = new MemoryStream(); - s.CopyTo(ms); - found[known] = ms.ToArray(); - } - } - catch (InvalidDataException) { return "This is not a valid ZIP file"; } - if (!found.ContainsKey("defaultES2File.txt")) - return "No defaultES2File.txt in the ZIP - zip the files from %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car"; - + string error = SaveStore.ExtractZip(zip, out var found); + if (error != null) return error; 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 - } + SaveStore.WriteSave(DataDir, found); } 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"; @@ -1340,19 +1169,7 @@ public sealed class GameServer } /// Aktuellen Spielstand als ZIP. - public byte[] ExportSaveZip() - { - using var ms = new MemoryStream(); - using (var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, true)) - { - foreach (string name in SaveFiles) - { - string file = Path.Combine(savePath, name); - if (File.Exists(file)) System.IO.Compression.ZipFileExtensions.CreateEntryFromFile(archive, file, name); - } - } - return ms.ToArray(); - } + public byte[] ExportSaveZip() => SaveStore.ExportZip(DataDir); public object PanelBackups() => SaveBackups().Select(d => Path.GetFileName(d)).ToList(); diff --git a/Panel/app.css b/Panel/app.css index 8867207..a4da64b 100644 --- a/Panel/app.css +++ b/Panel/app.css @@ -152,6 +152,15 @@ tbody tr:hover { background: rgba(255, 255, 255, .02); } .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; } +.control { display: grid; gap: 14px; } +.state-label { font-size: 13px; font-weight: 600; color: var(--muted); display: inline-flex; align-items: center; gap: 7px; } +.state-label::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--pending); } +.state-label.running::before { background: var(--online); } +.state-label.error::before { background: var(--danger); } +.notice { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; padding: 12px 14px; + border-radius: 10px; background: rgba(242, 194, 48, .08); border: 1px solid rgba(242, 194, 48, .25); } +.notice.bad { background: rgba(229, 72, 77, .08); border-color: rgba(229, 72, 77, .3); } +.notice b { display: block; margin-bottom: 2px; } .console-input { display: flex; gap: 10px; padding: 12px; border-top: 1px solid var(--border); background: var(--panel); } .console-input input { font-family: var(--mono); } diff --git a/Panel/app.js b/Panel/app.js index 08fe82f..d2c9c2c 100644 --- a/Panel/app.js +++ b/Panel/app.js @@ -200,11 +200,12 @@ async function refreshTopbar() { 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"; + const running = s.state === "running"; + $("serverMeta").textContent = running + ? `${s.players}/${s.maxPlayers} players · v${s.version} · up ${duration(s.uptimeSeconds)}` + : `server ${s.state} · v${s.version}`; + $("statusDot").className = "status-dot " + (running ? "ok" : s.state === "error" ? "bad" : ""); + $("statusDot").title = running ? "Online" : "Server " + s.state + (s.lastError ? ": " + s.lastError : ""); document.title = `${s.name} · MSCO Panel`; } catch { $("statusDot").className = "status-dot bad"; @@ -215,9 +216,9 @@ async function refreshTopbar() { function renderDashboard(page) { const cards = h("div", { class: "cards" }); - const control = h("div", { class: "section-body row" }); + const control = h("div", { class: "section-body control" }); if (can("Admin")) page.append(h("div", { class: "section" }, - h("div", { class: "section-head" }, h("h2", {}, "Server control"), h("span", { class: "muted", id: "gameState" }, "")), + h("div", { class: "section-head" }, h("h2", {}, "Server"), h("span", { class: "state-label", id: "gameState" }, "")), control)); const recent = h("div", { class: "console small" }); page.append( @@ -260,20 +261,40 @@ async function serverAction(action, confirmText) { } await act(api("POST", "server/" + action)); setTimeout(refreshTopbar, 800); + setTimeout(refreshTopbar, 2500); } +const STATE_TEXT = { + running: "Running - players can join", + starting: "Starting …", + stopped: "Stopped - players cannot join. The panel keeps running.", + error: "Not running", +}; + 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); + const label = $("gameState"); + const running = s.state === "running", starting = s.state === "starting"; + if (label) { + label.textContent = STATE_TEXT[s.state] || s.state; + label.className = "state-label " + s.state; + } + const buttons = h("div", { class: "row" }, + running || starting + ? h("button", { class: "btn", type: "button", disabled: starting, onclick: () => serverAction("stop", { title: "Stop the server?", + text: "All players are disconnected and nobody can join until you start it again. The panel keeps running.", danger: true, confirm: "Stop" }) }, "■ Stop") + : h("button", { class: "btn primary", type: "button", onclick: () => serverAction("start") }, "▶ Start"), + h("button", { class: "btn", type: "button", disabled: starting, onclick: () => serverAction("restart", { title: "Restart the server?", + text: "Settings (server.json), license and save game are loaded again. Players reconnect automatically.", confirm: "Restart" }) }, "⟳ Restart")); + + // Warum läuft er nicht? Mit direktem Weg zur Lösung + let problem = null; + if (!running && s.lastError) { + const fix = s.hasLicenseKey === false ? h("button", { class: "btn small", type: "button", onclick: () => go("settings") }, "Enter license key") + : s.hasSave === false ? h("button", { class: "btn small", type: "button", onclick: () => go("world") }, "Upload save game") + : h("button", { class: "btn small ghost", type: "button", onclick: () => go("console") }, "Open console"); + problem = h("div", { class: "notice " + (s.state === "error" ? "bad" : "") }, h("div", {}, h("b", {}, s.state === "error" ? "The server could not start" : "Note"), h("div", {}, s.lastError)), fix); + } + el.replaceChildren(buttons, problem); } // ------------------------------------------------------------------ Einstellungen @@ -355,7 +376,7 @@ function renderSettings(page) { 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?", + h("button", { class: "btn", type: "button", onclick: () => serverAction("restart", { title: "Restart / start the server now?", text: "Loads the saved settings. Players reconnect automatically.", confirm: "Restart" }) }, "Restart to apply"))); } } diff --git a/Program.cs b/Program.cs index ef6666f..dcfcda6 100644 --- a/Program.cs +++ b/Program.cs @@ -60,9 +60,10 @@ if (fromEnvironment.Count > 0) if (created && string.IsNullOrWhiteSpace(config.LicenseKey)) { GameServer.Log("Created " + configPath); - GameServer.Log("Get a license key at " + MasterServer.Url + "/keys and put it into server.json (\"LicenseKey\")."); - Pause(); - return 1; + GameServer.Log("Get a license key at " + MasterServer.Url + "/keys" + + (config.PanelEnabled ? " and enter it in the web panel (Settings) - or put it into server.json (\"LicenseKey\")." + : " and put it into server.json (\"LicenseKey\").")); + if (!config.PanelEnabled) { Pause(); return 1; } } // --import-save [Pfad]: Spielstand aus My Summer Car übernehmen @@ -94,12 +95,9 @@ if (Array.IndexOf(args, "--import-save") >= 0) if (Directory.Exists(original)) Directory.Delete(original, true); } -// Konsole, "msco " (docker exec) und Signale gehen immer an den gerade laufenden Server - -// nach einem Neustart aus dem Panel ist das ein neuer. Das Web-Panel bleibt dabei bestehen (man bleibt angemeldet). -GameServer server = null; -AdminPanel panel = null; -Action send = line => server?.EnqueueCommand(line); -Action stopRequested = () => { if (server == null) Environment.Exit(0); else server.EnqueueCommand("stop"); }; +// Der Host läuft die ganze Zeit (Port + Web-Panel) und startet/stoppt den Gameserver - auch wenn der nicht starten +// kann (Lizenz, Spielstand ...), bleibt das Panel erreichbar. Konsole, "msco " und Signale gehen an den Host. +var host = new ServerHost(config, dataDir, configPath); // 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. @@ -108,7 +106,7 @@ var input = new Thread(() => try { string line; - while ((line = Console.ReadLine()) != null) send(line); + while ((line = Console.ReadLine()) != null) host.Command(line); } catch { } }) { IsBackground = true }; @@ -126,7 +124,7 @@ var commandWatcher = new Thread(() => if (!File.Exists(commandFile)) continue; File.Move(commandFile, processing, true); foreach (string line in File.ReadAllLines(processing)) - if (line.Trim().Length > 0) { GameServer.Log("> " + line.Trim()); send(line.Trim()); } + if (line.Trim().Length > 0) { GameServer.Log("> " + line.Trim()); host.Command(line.Trim()); } File.Delete(processing); } catch { } @@ -135,71 +133,13 @@ var commandWatcher = new Thread(() => commandWatcher.Start(); // Sauber beenden (Weltzustand speichern) bei Strg+C, systemctl stop / docker stop (SIGTERM) und SIGQUIT -Console.CancelKeyPress += (_, e) => { e.Cancel = true; 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(); }); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; host.RequestExit(); }; +using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { ctx.Cancel = true; host.RequestExit(); }); +using var sigquit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, ctx => { ctx.Cancel = true; host.RequestExit(); }); -for (bool restarting = false; ; restarting = true) -{ - if (restarting) - { - // Neustart: server.json neu lesen (im Panel geänderte Einstellungen), Lizenz und Spielstand neu prüfen - try { config = ServerConfig.LoadOrCreate(configPath, out _); } - catch (Exception e) { GameServer.Log("Cannot read " + configPath + " (" + e.Message + ") - keeping the previous settings"); } - config.ApplyEnvironment(); - } - - // Lizenzschlüssel beim Keymaster prüfen - if (string.IsNullOrWhiteSpace(config.LicenseKey)) - { - GameServer.Log("No license key set. Get one at " + MasterServer.Url + "/keys and put it into server.json."); - Pause(); - return 1; - } - // Automatisch mit dem Master (msco.mbm.wtf) verbinden: ist er gerade nicht erreichbar, - // wird es mit wachsendem Abstand (max. 60 s) weiter versucht, statt aufzugeben. - var master = new MasterClient(MasterServer.Url); - MasterClient.KeyInfo key; - for (int attempt = 1; ; attempt++) - { - GameServer.Log($"Checking license key at {MasterServer.Url} ..."); - key = await master.ValidateKeyAsync(config.LicenseKey); - if (!key.Unreachable) break; - int wait = Math.Min(60, 5 * attempt); - GameServer.Log($"Master server not reachable ({key.Error}) - retrying in {wait} s (Ctrl+C to cancel)"); - await Task.Delay(TimeSpan.FromSeconds(wait)); - } - if (!key.Valid) - { - GameServer.Log("License key invalid: " + (key.Error ?? "unknown key")); - GameServer.Log("Manage your keys at " + MasterServer.Url + "/keys"); - Pause(); - return 1; - } - GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})"); - - var next = new GameServer(config, dataDir, configPath, key.Owner); - if (!next.LoadSave()) - { - Pause(); - return 1; - } - // Web-Panel (wie txAdmin) - standardmäßig auf dem Spiel-Port; bei einem Neustart dasselbe Panel weiterverwenden - if (config.PanelEnabled || panel != null) - { - if (panel == null) panel = AdminPanel.Start(next, config, dataDir); - else panel.Attach(next, config); - next.Panel = panel; - } - server = next; - - server.Run(); - if (!server.RestartRequested) break; - server = null; - GameServer.Log("Restarting the server ..."); -} -panel?.Stop(); -return 0; +int exitCode = host.Run(); +if (exitCode != 0) Pause(); +return exitCode; string ArgValue(string name) { diff --git a/README.md b/README.md index 6095ce3..06b48fa 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Mit diesem Code legst du im Browser das **Owner**-Konto an (erneut anzeigen: `ms | Bereich | Was geht | |---|---| -| Dashboard | Spieler, Laufzeit, Weltzustand, Backups – **Start / Stop / Restart / Shut down** | +| Dashboard | Spieler, Laufzeit, Weltzustand, Backups – **Start / Stop / Restart** des Gameservers, Grund wenn er nicht läuft | | 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 | @@ -144,9 +144,13 @@ Mit diesem Code legst du im Browser das **Owner**-Konto an (erneut anzeigen: `ms | 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. +- **Das Panel ist unabhängig vom Gameserver:** Es läuft auch, wenn der Server gestoppt ist, abgestürzt ist oder gar + nicht starten kann (z.B. kein Lizenzschlüssel, kein Spielstand). Das Dashboard zeigt dann den Grund – Schlüssel + unter **Settings** eintragen bzw. Spielstand als ZIP unter **World & Backups** hochladen und **Start** drücken. +- **Stop** fährt den Gameserver herunter (Spieler bekommen den Grund, niemand kann beitreten), **Start** startet ihn + wieder, **Restart** lädt Einstellungen, Lizenz und Spielstand neu. Nach einem Absturz startet er automatisch neu. +- Erster Start ganz ohne Einrichtung: `docker compose up -d`, Panel öffnen, Einrichtungs-Code aus dem Log eingeben, + Lizenzschlüssel und Spielstand im Panel hinterlegen, Start. - 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. @@ -179,8 +183,9 @@ erreichbar. Ganz aus: `"PanelEnabled": false`. | `backup` | aktuellen Spielstand jetzt sichern | | `reset-world` | ganze Welt zurück auf den Reset-Punkt (den Spielstand, wie er dem Server gegeben wurde: Start, `--import-save`, `reload-save`) – alle verbinden neu | | `set-reset-point` | aktuellen Spielstand als neuen Reset-Punkt nehmen | -| `stop-game` / `start-game` | Spiel offline nehmen (Spieler raus, Panel läuft weiter) / wieder starten | +| `stop` / `start` | Gameserver stoppen (das Web-Panel läuft weiter) / wieder starten | | `restart` | Server neu starten (Einstellungen, Lizenz, Spielstand neu laden) | +| `exit` | ganzes Programm beenden (auch das Panel) – mit Docker startet es danach wieder, dauerhaft aus: `docker compose stop` | | `panel` | Adresse des Web-Panels und ggf. den Einrichtungs-Code anzeigen | | `stop` | speichern und beenden | diff --git a/SaveStore.cs b/SaveStore.cs new file mode 100644 index 0000000..2c14916 --- /dev/null +++ b/SaveStore.cs @@ -0,0 +1,205 @@ +using System.IO.Compression; + +namespace MSCO.Server; + +/// +/// Spielstand-Dateien im Datenordner: data/save (aktiv), data/save_original (Reset-Punkt), data/save_backups/Zeitstempel. +/// Wird vom laufenden Server benutzt und vom Host/Panel, wenn der Server aus ist (z.B. Spielstand hochladen, bevor er +/// überhaupt starten kann). +/// +public static class SaveStore +{ + public static readonly string[] SaveFiles = { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" }; + public const int KeepBackups = 5; + public const long MaxZipBytes = 64L * 1024 * 1024; + + public static string SavePath(string dataDir) => Path.Combine(dataDir, "save"); + public static string OriginalPath(string dataDir) => Path.Combine(dataDir, "save_original"); + public static string BackupsPath(string dataDir) => Path.Combine(dataDir, "save_backups"); + public static string WorldPath(string dataDir) => Path.Combine(dataDir, "world.json"); + + public static bool HasSave(string dataDir) => File.Exists(Path.Combine(SavePath(dataDir), "defaultES2File.txt")); + public static bool HasResetPoint(string dataDir) => File.Exists(Path.Combine(OriginalPath(dataDir), "defaultES2File.txt")); + + /// Backup-Ordner, neueste zuerst. + public static List BackupDirs(string dataDir) + { + string backups = BackupsPath(dataDir); + return Directory.Exists(backups) ? Directory.GetDirectories(backups).OrderByDescending(d => d).ToList() : new List(); + } + + /// Aktuellen Spielstand nach save_backups/Zeitstempel kopieren, die letzten Stände behalten. + public static void Backup(string dataDir) + { + string save = SavePath(dataDir); + if (!Directory.Exists(save)) return; + string backups = BackupsPath(dataDir); + string target = Path.Combine(backups, DateTime.Now.ToString("yyyyMMdd-HHmmss")); + Directory.CreateDirectory(target); + foreach (string file in Directory.GetFiles(save)) + if (!file.EndsWith(".tmp")) File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true); + foreach (string old in Directory.GetDirectories(backups).OrderByDescending(d => d).Skip(KeepBackups)) + try { Directory.Delete(old, true); } catch { } + } + + /// Aktuellen Spielstand als Reset-Punkt merken. Gibt einen Fehler zurück oder null. + public static string SetResetPoint(string dataDir) + { + if (!HasSave(dataDir)) return "No save game to use as reset point"; + try + { + string original = OriginalPath(dataDir); + if (Directory.Exists(original)) Directory.Delete(original, true); + Directory.CreateDirectory(original); + foreach (string file in Directory.GetFiles(SavePath(dataDir))) + if (!file.EndsWith(".tmp")) File.Copy(file, Path.Combine(original, Path.GetFileName(file)), true); + return null; + } + catch (Exception e) { return "Could not remember the reset point: " + e.Message; } + } + + /// Dateien in den aktiven Spielstand schreiben; bekannte Dateien, die fehlen, werden gelöscht. + public static void WriteSave(string dataDir, IReadOnlyDictionary files) + { + string save = SavePath(dataDir); + Directory.CreateDirectory(save); + foreach (string name in SaveFiles) + { + string target = Path.Combine(save, name); + if (files.TryGetValue(name, out var data)) + { + File.WriteAllBytes(target + ".tmp", data); + File.Move(target + ".tmp", target, true); + } + else if (File.Exists(target)) File.Delete(target); + } + } + + /// Dateien eines Ordners (Backup, Reset-Punkt) lesen - nur die bekannten Spielstand-Dateien. + public static Dictionary ReadFolder(string folder) + { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string name in SaveFiles) + { + string file = Path.Combine(folder, name); + if (File.Exists(file)) files[name] = File.ReadAllBytes(file); + } + return files; + } + + /// Backup per Nummer (1 = neuestes) oder Ordnername finden. + public static string FindBackup(string dataDir, string arg) + { + var list = BackupDirs(dataDir); + arg = (arg ?? "").Trim(); + return int.TryParse(arg, out int n) && n >= 1 && n <= list.Count ? list[n - 1] + : list.FirstOrDefault(d => Path.GetFileName(d) == arg); + } + + /// + /// Spielstand aus einer ZIP lesen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem Ordner + /// der ZIP) - Pfade aus der ZIP werden nie benutzt. Gibt einen Fehler zurück oder null. + /// + public static string ExtractZip(byte[] zip, out Dictionary found) + { + found = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + using var archive = new ZipArchive(new MemoryStream(zip), 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 > MaxZipBytes || (total += entry.Length) > MaxZipBytes) 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"; + return null; + } + + /// Aktuellen Spielstand als ZIP. + public static byte[] ExportZip(string dataDir) + { + using var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true)) + { + foreach (string name in SaveFiles) + { + string file = Path.Combine(SavePath(dataDir), name); + if (File.Exists(file)) archive.CreateEntryFromFile(file, name); + } + } + return ms.ToArray(); + } + + // ---------------------------------------------------------------- Wenn der Server aus ist (Host/Panel) + + /// Server aus: ZIP als neuen Spielstand übernehmen (Backup vorher, neuer Reset-Punkt, Weltzustand leer). + public static string ImportZipOffline(string dataDir, byte[] zip, string by) + { + string error = ExtractZip(zip, out var files); + if (error != null) return error; + try + { + Backup(dataDir); + WriteSave(dataDir, files); + SetResetPoint(dataDir); + ResetWorldFile(dataDir, full: true); + } + catch (Exception e) { return "Could not write the save game: " + e.Message; } + GameServer.Log($"New save game uploaded by {by} ({files.Count} files, server stopped) - it is also the new reset point"); + return $"Save game uploaded ({files.Count} files) - start the server to use it"; + } + + /// Server aus: Backup wiederherstellen. + public static string RestoreOffline(string dataDir, string arg, string by) + { + string chosen = FindBackup(dataDir, arg); + if (chosen == null) return "Backup not found"; + try + { + var files = ReadFolder(chosen); // erst lesen - Backup() unten könnte alte Backups löschen + Backup(dataDir); + WriteSave(dataDir, files); + ResetWorldFile(dataDir, full: false); + } + catch (Exception e) { return "Could not restore: " + e.Message; } + GameServer.Log($"Restored save game {Path.GetFileName(chosen)} ({by}, server stopped)"); + return "Restored " + Path.GetFileName(chosen) + " - start the server to use it"; + } + + /// Server aus: Welt komplett auf den Reset-Punkt zurück. + public static string ResetOffline(string dataDir, string by) + { + try + { + if (HasResetPoint(dataDir)) + { + var files = ReadFolder(OriginalPath(dataDir)); + Backup(dataDir); + WriteSave(dataDir, files); + } + ResetWorldFile(dataDir, full: true); + } + catch (Exception e) { return "Could not reset: " + e.Message; } + GameServer.Log($"World reset by {by} (server stopped) - back to the reset point"); + return "World reset to the reset point"; + } + + /// world.json anpassen: full = alles weg, sonst nur was im neuen Spielstand steckt. + static void ResetWorldFile(string dataDir, bool full) + { + string path = WorldPath(dataDir); + var world = WorldState.Load(path); + if (full) world.Reset(); else world.ResetForNewSave(); + world.Save(path); + } +} diff --git a/ServerHost.cs b/ServerHost.cs new file mode 100644 index 0000000..6640661 --- /dev/null +++ b/ServerHost.cs @@ -0,0 +1,401 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; + +namespace MSCO.Server; + +/// Zustand des Gameservers aus Sicht des Hosts. +public enum HostState { Stopped, Starting, Running, Error } + +/// +/// Host (wie txAdmin): läuft die ganze Zeit, besitzt den Port und das Web-Panel und startet/stoppt den Gameserver. +/// Ist der Gameserver aus (gestoppt, abgestürzt, fehlender Lizenzschlüssel oder Spielstand ...), bleibt das Panel +/// erreichbar - dort lässt sich alles einrichten und der Server wieder starten. +/// Verbindungen auf dem Spiel-Port: Browser -> Panel, Spiel -> laufender Server bzw. "gerade offline". +/// +public sealed class ServerHost +{ + const int MaxCrashRestarts = 3; + static readonly TimeSpan CrashWindow = TimeSpan.FromMinutes(10); + static readonly TimeSpan CrashRestartDelay = TimeSpan.FromSeconds(10); + static readonly TimeSpan MasterRetryDelay = TimeSpan.FromSeconds(30); + + readonly string dataDir, configPath; + readonly bool offline; + readonly ConcurrentQueue commands = new(); + readonly AutoResetEvent wake = new(false); + readonly List crashes = new(); + + TcpListener listener; + int listenerPort; + volatile bool exitRequested; + volatile bool startRequested = true; // beim Programmstart automatisch starten + DateTime startAt = DateTime.MinValue; + + public ServerConfig Config { get; private set; } + public GameServer Server { get; private set; } + public AdminPanel Panel { get; private set; } + public HostState State { get; private set; } = HostState.Stopped; + public string LastError { get; private set; } + public DateTime StateSince { get; private set; } = DateTime.UtcNow; + public string DataDir => dataDir; + public string ConfigPath => configPath; + /// Für Tests: Einstellungen am frisch erzeugten Server vornehmen (z.B. kürzere Zeitlimits). + public Action ConfigureServer { get; set; } + + public ServerHost(ServerConfig config, string dataDir, string configPath, bool offline = false) + { + Config = config; + this.dataDir = dataDir; + this.configPath = configPath; + this.offline = offline; + } + + // ---------------------------------------------------------------- Steuerung (Konsole, Panel, Signale) + + /// Konsolenzeile: bei laufendem Server an ihn, sonst Host-Befehle (start, exit, status, panel). + public void Command(string line) + { + string first = line.Trim().TrimStart('').Split(' ')[0].ToLowerInvariant(); + if (first == "exit" || first == "quit") { RequestExit(); return; } + var server = Server; + if (server != null && State == HostState.Running) { server.EnqueueCommand(line); return; } + commands.Enqueue(line); + wake.Set(); + } + + public string RequestStart(string by) + { + if (State == HostState.Running || State == HostState.Starting) return "The server is already " + State.ToString().ToLower(); + GameServer.Log($"Start requested by {by}"); + crashes.Clear(); + startAt = DateTime.MinValue; + startRequested = true; + wake.Set(); + return "Starting the server"; + } + + public string RequestStop(string by) + { + var server = Server; + if (server == null || State != HostState.Running) + { + startRequested = false; // z.B. geplanter Neustart nach Absturz + return "The server is not running"; + } + server.Invoke(() => server.PanelServerAction("stop", by)); + return "Stopping the server - the panel keeps running"; + } + + public string RequestRestart(string by) + { + var server = Server; + if (server == null || State != HostState.Running) return RequestStart(by); + server.Invoke(() => server.PanelServerAction("restart", by)); + return "Restarting - the server is back in a few seconds"; + } + + /// Ganzes Programm beenden (Strg+C, docker stop, "exit"). + public void RequestExit() + { + exitRequested = true; + Server?.RequestExit(); + wake.Set(); + } + + // ---------------------------------------------------------------- Hauptschleife + + public int Run() + { + if (Config.PanelEnabled) Panel = AdminPanel.Start(this, Config, dataDir); + if (!Listen(Config.Port)) return 1; + if (Panel == null) + GameServer.Log("Web panel is disabled (PanelEnabled in server.json)"); + + while (!exitRequested) + { + if (!startRequested || DateTime.UtcNow < startAt) + { + HandleHostCommands(); + wake.WaitOne(200); + continue; + } + startRequested = false; + + var server = TryCreateServer(); + if (server == null) + { + // Ohne Panel gibt es niemanden, der es reparieren könnte: dann wie früher beenden + if (Panel == null) return 1; + continue; + } + + Server = server; + SetState(HostState.Running, null); + try + { + server.Run(); + } + catch (Exception e) + { + GameServer.Log("THE SERVER CRASHED: " + e); + OnCrash(e.Message); + Server = null; + continue; + } + Server = null; + + if (server.ExitRequested) break; + if (server.RestartRequested) + { + GameServer.Log("Restarting the server ..."); + startRequested = true; + continue; + } + SetState(HostState.Stopped, null); + if (Panel == null) break; // ohne Panel: Stopp = Programmende + GameServer.Log("The game server is stopped. The web panel keeps running - start it there or type 'start' ('exit' to quit)."); + } + + try { listener?.Stop(); } catch { } + Panel?.Stop(); + GameServer.Log("Bye."); + return 0; + } + + void HandleHostCommands() + { + while (commands.TryDequeue(out string line)) + { + string cmd = line.Trim().TrimStart(''); + if (cmd.Length == 0) continue; + var server = Server; + if (server != null && State == HostState.Running) + { + server.EnqueueCommand(cmd); + continue; + } + string first = cmd.Split(' ')[0].ToLowerInvariant(); + switch (first) + { + case "start": + case "restart": + GameServer.Log(RequestStart("Console")); + break; + case "exit": + case "quit": + RequestExit(); + break; + case "stop": + startRequested = false; + GameServer.Log("The server is not running ('exit' quits the program)"); + break; + case "panel": + if (Panel != null) Panel.PrintInfo(); else GameServer.Log("The web panel is disabled"); + break; + case "status": + GameServer.Log($"Server {State.ToString().ToLower()}" + (LastError != null ? ": " + LastError : "")); + break; + default: + GameServer.Log("The game server is " + State.ToString().ToLower() + " - type 'start' (or 'exit' to quit)"); + break; + } + } + } + + GameServer TryCreateServer() + { + SetState(HostState.Starting, null); + if (!offline) + { + // Einstellungen neu lesen (im Panel geändert), Umgebungsvariablen gelten weiter + try + { + var fresh = ServerConfig.LoadOrCreate(configPath, out _); + fresh.ApplyEnvironment(); + Config = fresh; + Panel?.UpdateConfig(fresh); + } + catch (Exception e) { GameServer.Log("Cannot read " + configPath + " (" + e.Message + ") - using the previous settings"); } + } + + string owner = "Test"; + if (!offline) + { + if (string.IsNullOrWhiteSpace(Config.LicenseKey)) + return Fail("No license key set - get one at " + MasterServer.Url + "/keys and enter it in the panel (Settings) or server.json"); + GameServer.Log($"Checking license key at {MasterServer.Url} ..."); + var key = new MasterClient(MasterServer.Url).ValidateKeyAsync(Config.LicenseKey).GetAwaiter().GetResult(); + if (key.Unreachable) + { + // Master gerade nicht erreichbar: gleich nochmal versuchen, bis der Server gestoppt wird + startRequested = true; + startAt = DateTime.UtcNow + MasterRetryDelay; + return Fail($"Master server not reachable ({key.Error}) - retrying in {MasterRetryDelay.TotalSeconds:0} s"); + } + if (!key.Valid) + return Fail("License key invalid: " + (key.Error ?? "unknown key") + " - manage your keys at " + MasterServer.Url + "/keys"); + GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})"); + owner = key.Owner; + } + + GameServer server; + try + { + server = new GameServer(Config, dataDir, configPath, owner, offline); + } + catch (Exception e) { return Fail("Could not create the server: " + e.Message); } + if (!server.LoadSave()) + return Fail(SaveStore.HasSave(dataDir) + ? "The save game could not be loaded - see the console" + : "No save game yet - upload one as ZIP in the panel (World & Backups) or copy it to data/save"); + server.Panel = Panel; + server.Host = this; + ConfigureServer?.Invoke(server); + if (Config.Port != listenerPort) Listen(Config.Port); // Port in den Einstellungen geändert + return server; + } + + GameServer Fail(string reason) + { + GameServer.Log("Server not started: " + reason); + SetState(HostState.Error, reason); + return null; + } + + void OnCrash(string message) + { + SetState(HostState.Error, "Crashed: " + message); + crashes.Add(DateTime.UtcNow); + crashes.RemoveAll(t => DateTime.UtcNow - t > CrashWindow); + if (crashes.Count > MaxCrashRestarts) + { + GameServer.Log($"The server crashed {crashes.Count} times in {CrashWindow.TotalMinutes:0} minutes - not restarting automatically. Start it in the panel."); + return; + } + GameServer.Log($"Restarting in {CrashRestartDelay.TotalSeconds:0} s ..."); + startRequested = true; + startAt = DateTime.UtcNow + CrashRestartDelay; + } + + void SetState(HostState state, string error) + { + State = state; + LastError = error; + StateSince = DateTime.UtcNow; + } + + /// Kurzstatus fürs Panel, wenn der Server nicht läuft (gleiche Felder wie GameServer.PanelStatus). + public object OfflineStatus() => new + { + state = State.ToString().ToLowerInvariant(), + lastError = LastError, + stateSince = StateSince.ToString("o"), + name = Config.Name, + description = Config.Description, + version = GameServer.ModVersion, + players = 0, + maxPlayers = Config.MaxPlayers, + uptimeSeconds = 0L, + port = Config.Port, + isPublic = Config.Public, + requireSteam = Config.RequireSteam, + steamVerification = !string.IsNullOrWhiteSpace(Config.SteamWebApiKey), + objects = 0, + actions = 0, + boughtItems = 0, + admins = Config.Admins.Count, + bans = Config.BannedSteamIds.Count, + backups = SaveStore.BackupDirs(dataDir).Count, + resetPoint = SaveStore.HasResetPoint(dataDir) + ? File.GetLastWriteTimeUtc(Path.Combine(SaveStore.OriginalPath(dataDir), "defaultES2File.txt")).ToString("o") : null, + saveSyncMinutes = Config.SaveSyncMinutes, + hasSave = SaveStore.HasSave(dataDir), + hasLicenseKey = !string.IsNullOrWhiteSpace(Config.LicenseKey) + }; + + // ---------------------------------------------------------------- Port + + bool Listen(int port) + { + try + { + var next = new TcpListener(IPAddress.Any, port); + next.Start(); + try { listener?.Stop(); } catch { } + listener = next; + listenerPort = port; + GameServer.Log($"Listening on port {port}" + (Panel != null && Panel.SharesGamePort ? " (game and web panel)" : "")); + _ = AcceptLoopAsync(next); + return true; + } + catch (Exception e) + { + GameServer.Log($"Cannot listen on port {port}: {e.Message}"); + return false; + } + } + + async Task AcceptLoopAsync(TcpListener own) + { + while (true) + { + TcpClient tcp; + try { tcp = await own.AcceptTcpClientAsync(); } + catch { return; } // Listener gestoppt (Programmende oder Portwechsel) + _ = HandleConnectionAsync(tcp); + } + } + + /// + /// In die ersten Bytes schauen (ohne sie zu verbrauchen): Browser ("GET /", "POST" ...) -> Panel, + /// sonst Spiel (Pakete beginnen mit einer kleinen Längenangabe). Beide Seiten reden zuerst. + /// + async Task HandleConnectionAsync(TcpClient tcp) + { + var buffer = new byte[4]; + int n = 0; + if (Panel != null && Panel.SharesGamePort) + { + 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); + return; + } + } + + var server = Server; + if (server != null && State == HostState.Running) + { + server.AcceptExternal(tcp); + return; + } + // Server aus: dem Spiel sagen, warum (statt einer toten Verbindung) + try + { + tcp.NoDelay = true; + var frame = Wire.Frame(MsgType.Reject, Wire.Build(w => w.Write("The server is offline at the moment - try again later"))); + await tcp.GetStream().WriteAsync(frame); + tcp.Client.Shutdown(SocketShutdown.Send); + await Task.Delay(1000); + } + catch { } + try { tcp.Close(); } catch { } + } +} diff --git a/Tests/Program.cs b/Tests/Program.cs index 640f3a5..5285ce5 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -15,14 +15,17 @@ string configPath = Path.Combine(dir, "server.json"); var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, MaxPlayers = 4, RequireSteam = false, Public = false, Admins = new() { AdminId } }; config.Save(configPath); -var server = new GameServer(config, dir, configPath, "Tester", offline: true); -server.SaveUploadTimeoutSeconds = 1; // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten -if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; } -// Web-Panel auf demselben Port wie das Spiel (Standard) -server.Panel = AdminPanel.Start(server, config, dir); -var serverThread = new Thread(server.Run) { IsBackground = true }; -serverThread.Start(); -Thread.Sleep(500); +// Host wie im echten Betrieb: besitzt Port + Web-Panel (gleicher Port wie das Spiel) und startet den Gameserver +var host = new ServerHost(config, dir, configPath, offline: true) +{ + ConfigureServer = s => s.SaveUploadTimeoutSeconds = 1 // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten +}; +var hostThread = new Thread(() => host.Run()) { IsBackground = true }; +hostThread.Start(); +for (int i = 0; i < 100 && host.State != HostState.Running; i++) Thread.Sleep(50); +if (host.State != HostState.Running) { Console.WriteLine("FAIL: server did not start: " + host.LastError); return 1; } +GameServer Srv() => host.Server; +async Task WaitForState(HostState state) { for (int i = 0; i < 100 && host.State != state; i++) await Task.Delay(50); } int failed = 0, passed = 0; void Check(bool ok, string what) { if (ok) passed++; else failed++; Console.WriteLine((ok ? " ok " : " FAIL ") + what); } @@ -151,7 +154,7 @@ try var hungry = await TestClient.Join("Hungry", 0, null); hungry.Send(MsgType.Ready, Array.Empty()); await hungry.WaitFor(MsgType.WorldSnapshot); - server.EnqueueCommand("save"); // (Zeitfenster TrackPositionFrom abwarten) + Srv().EnqueueCommand("save"); // (Zeitfenster TrackPositionFrom abwarten) await Task.Delay(8500); hungry.Send(MsgType.PlayerNeeds, Wire.Build(w => { w.Write(2); w.Write("PlayerHunger"); w.Write(77f); w.Write("PlayerThirst"); w.Write(12.5f); })); await Task.Delay(300); @@ -197,11 +200,11 @@ try Check(await watcher.WaitFor(MsgType.SpawnItem, 800) == null, "Non-admin cannot spawn items (not relayed)"); watcher.Send(MsgType.Ready, Array.Empty()); Check(Snapshot.Parse(await watcher.WaitFor(MsgType.WorldSnapshot)).Spawns == 0, "Non-admin spawn is not stored"); - server.EnqueueCommand("ban " + noAdmin.Id + " test"); + Srv().EnqueueCommand("ban " + noAdmin.Id + " test"); await Task.Delay(500); var bannedTry = await TestClient.Join("NoAdmin", 0, null, expectReject: true); Check(bannedTry.RejectReason != null && bannedTry.RejectReason.Contains("banned"), "Banned player cannot join: " + bannedTry.RejectReason); - server.EnqueueCommand("unban name:noadmin"); + Srv().EnqueueCommand("unban name:noadmin"); await Task.Delay(500); var unbanned = await TestClient.Join("NoAdmin", 0, null); Check(unbanned.Welcome != null, "After unban the player can join again"); @@ -214,7 +217,7 @@ try var spamming = true; var spamTask = Task.Run(async () => { while (spamming) { spammer.Send(MsgType.Player, PlayerPos(1f, 2f, 3f)); await Task.Delay(5); } }); await Task.Delay(300); - server.EnqueueCommand("kick " + spammer.Id + " testing"); + Srv().EnqueueCommand("kick " + spammer.Id + " testing"); var kickMsg = await spammer.WaitFor(MsgType.Reject, 3000); spamming = false; await spamTask; @@ -225,7 +228,7 @@ try Console.WriteLine("== reset-world makes everyone reconnect"); var r1 = await TestClient.Join("Resetter", 0, null); - server.EnqueueCommand("reset-world"); + Srv().EnqueueCommand("reset-world"); var rec = await r1.WaitFor(MsgType.Reconnect); Check(rec != null && new BinaryReader(new MemoryStream(rec)).ReadString().Contains("reset"), "Players get the 'world is being reset' reconnect message"); await Task.Delay(300); @@ -269,19 +272,19 @@ try string backupsDir = Path.Combine(dir, "save_backups"); int before = Directory.GetDirectories(backupsDir).Length; await Task.Delay(1100); // Backups heißen nach der Sekunde - server.EnqueueCommand("backup"); + Srv().EnqueueCommand("backup"); await Task.Delay(500); Check(Directory.GetDirectories(backupsDir).Length == Math.Min(before + 1, 5), "'backup' creates a new backup of the current save"); // Aktuellen Spielstand verändern, dann das neueste Backup (= CURRENT-SAVE) zurückholen File.WriteAllText(Path.Combine(dir, "save", "defaultES2File.txt"), "BROKEN"); - server.EnqueueCommand("restore-save 1"); + Srv().EnqueueCommand("restore-save 1"); var restoreMsg = await newcomer.WaitFor(MsgType.Reconnect); Check(restoreMsg != null, "Restoring a backup makes everyone reconnect"); Check(File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")).EndsWith("CURRENT-SAVE"), "The backup's files are back in the active save"); Console.WriteLine("== reset-world goes back to the ORIGINAL save (not the auto-uploaded one)"); var r2 = await TestClient.Join("Resetter2", 0, null); - server.EnqueueCommand("reset-world"); + Srv().EnqueueCommand("reset-world"); Check(await r2.WaitFor(MsgType.Reconnect) != null, "Players reconnect after the reset"); Check(File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "test defaultES2File.txt", "The active save is the original one again: " + File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")).Length + " chars"); @@ -303,7 +306,7 @@ catch (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"); + Check(host.Panel != null && host.Panel.SharesGamePort, "Panel started and shares the game port"); string baseUrl = $"http://127.0.0.1:{config.Port}/"; HttpClient Browser() { @@ -334,10 +337,10 @@ async Task PanelTests() 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, + Check((await Call(owner, "POST", "api/setup", new { code = host.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"); + var setup = await Call(owner, "POST", "api/setup", new { code = host.Panel.SetupCode, username = "owner", password = "supersecret1" }); + Check(setup.status == 200 && host.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"); @@ -399,17 +402,50 @@ async Task PanelTests() && 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"); + // Server stoppen - das Panel bleibt da + var online = await TestClient.Join("OnlineWhenStopped", 0, null); + var stop = await Call(owner, "POST", "api/server/stop"); + Check(stop.status == 200, "Server can be stopped from the panel"); + var stopMsg = await online.WaitFor(MsgType.Reject, 3000); + Check(stopMsg != null && ReadString(stopMsg).Contains("stopped by owner"), "Players get told who stopped the server: " + (stopMsg == null ? "-" : ReadString(stopMsg))); + await WaitForState(HostState.Stopped); + Check(host.State == HostState.Stopped && host.Server == null, "The game server is stopped"); + Check((await Call(owner, "GET", "api/session")).status == 200, "The panel is still reachable while the server is stopped"); + var offStatus = await Call(owner, "GET", "api/status"); + Check(offStatus.status == 200 && offStatus.json.GetProperty("state").GetString() == "stopped", "Status shows the stopped server"); + Check((await Call(owner, "GET", "api/players")).status == 200, "Player list works while stopped (empty)"); 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"); + Check(whileStopped.RejectReason != null && whileStopped.RejectReason.Contains("offline"), "Players are told the server is offline: " + whileStopped.RejectReason); + var offCfg = await Call(owner, "POST", "api/config", new { description = "edited while stopped" }); + Check(offCfg.status == 200 && File.ReadAllText(configPath).Contains("edited while stopped"), "Settings can be changed while stopped"); + var offBackup = await Call(owner, "POST", "api/world/backup"); + Check(offBackup.status == 200 && offBackup.json.GetProperty("message").GetString().Contains("Backup"), "Backups work while stopped"); + Check((await Call(owner, "POST", "api/players/1/kick")).status == 409, "Player actions say the server is not running"); + // Spielstand hochladen, während der Server aus ist - wird beim nächsten Start benutzt + byte[] offlineZip; + using (var oms = new MemoryStream()) + { + using (var oa = new System.IO.Compression.ZipArchive(oms, System.IO.Compression.ZipArchiveMode.Create, true)) + using (var w = new StreamWriter(oa.CreateEntry("defaultES2File.txt").Open())) w.Write("OFFLINE SAVE"); + offlineZip = oms.ToArray(); + } + var offUp = new HttpRequestMessage(HttpMethod.Post, "api/save/upload") { Content = new ByteArrayContent(offlineZip) }; + offUp.Headers.Add("X-MSCO-Panel", "1"); + offUp.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip"); + var offUpRes = await owner.SendAsync(offUp); + Check(offUpRes.IsSuccessStatusCode && File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "OFFLINE SAVE" + && File.ReadAllText(Path.Combine(dir, "save_original", "defaultES2File.txt")) == "OFFLINE SAVE", + "A save game ZIP can be uploaded while the server is stopped (also the new reset point)"); + Check((await Call(mod, "POST", "api/server/start")).status == 403, "Moderators cannot start the server"); + Check((await Call(owner, "POST", "api/server/start")).status == 200, "Server can be started again from the panel"); + await WaitForState(HostState.Running); + Check(host.State == HostState.Running, "The server runs again"); var afterStart = await TestClient.Join("AfterStart", 0, null); Check(afterStart.Welcome != null, "Players can join after start"); + Check(afterStart.Files.TryGetValue("defaultES2File.txt", out var startedSave) && System.Text.Encoding.UTF8.GetString(startedSave) == "OFFLINE SAVE", + "After start the server uses the save uploaded while it was stopped"); afterStart.Close(); - Check((await Call(mod, "POST", "api/server/stop-game")).status == 403, "Moderators cannot stop the server"); + Check((await Call(mod, "POST", "api/server/stop")).status == 403, "Moderators cannot stop the server"); // Spielstand als ZIP byte[] zip; @@ -440,8 +476,8 @@ async Task PanelTests() } Console.WriteLine($"\n{passed} passed, {failed} failed"); -server.EnqueueCommand("stop"); -Thread.Sleep(300); +host.RequestExit(); +hostThread.Join(5000); try { Directory.Delete(dir, true); } catch { } return failed == 0 ? 0 : 1; diff --git a/docker-compose.yml b/docker-compose.yml index 60af582..e8f9e26 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,9 @@ services: # Als dein Linux-Benutzer laufen (nicht root), damit du in data/ selbst Dateien anlegen/ändern darfst. # Andere ID? `id -u` / `id -g` ausgeben und in .env als MSCO_UID / MSCO_GID eintragen. user: "${MSCO_UID:-1000}:${MSCO_GID:-1000}" - restart: "on-failure:3" # bei fehlendem Schlüssel/Spielstand nicht endlos neu starten + # Das Programm läuft immer weiter (Web-Panel), auch wenn der Gameserver aus ist oder nicht starten kann + # (fehlender Schlüssel/Spielstand -> im Panel einrichten). Nach einem Neustart des Rechners startet es wieder. + restart: unless-stopped stdin_open: true # Server-Konsole per docker attach tty: true stop_grace_period: 30s