From 14db3ef4f43556e8291848f93f9633a95e7a04b1 Mon Sep 17 00:00:00 2001 From: MrMaho Date: Thu, 1 Oct 2026 20:00:03 +0200 Subject: [PATCH] License grace period: keep running when the master is down or rejects the key (LicenseGraceHours, default 72 h), start with the last confirmed license, auto-retry; panel restart button (port/panel settings without program restart, login kept); tests Co-Authored-By: Claude Opus 5.5 --- AdminPanel.cs | 48 +++++++++++++++++++++++++++++++- ConfigEditor.cs | 2 ++ GameServer.cs | 62 ++++++++++++++++++++++++++++++++++++----- LicenseCache.cs | 55 ++++++++++++++++++++++++++++++++++++ Panel/app.js | 42 +++++++++++++++++++++++----- Panel/i18n.js | 14 ++++++++++ README.md | 5 ++++ ServerConfig.cs | 6 ++++ ServerHost.cs | 72 ++++++++++++++++++++++++++++++++++++++++++------ Tests/Program.cs | 43 +++++++++++++++++++++++++++++ 10 files changed, 325 insertions(+), 24 deletions(-) create mode 100644 LicenseCache.cs diff --git a/AdminPanel.cs b/AdminPanel.cs index 624a9b8..fe15738 100644 --- a/AdminPanel.cs +++ b/AdminPanel.cs @@ -155,7 +155,7 @@ public sealed class AdminPanel app.StartAsync().GetAwaiter().GetResult(); - if (users.Empty) CreateSetupCode(); + if (users.Empty && SetupCode == null) CreateSetupCode(); if (SharesGamePort) GameServer.Log($"Web panel: http://:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)"); else @@ -165,6 +165,30 @@ public sealed class AdminPanel /// Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu). public void UpdateConfig(ServerConfig newConfig) => config = newConfig; + /// + /// Mit neuen Einstellungen (Port, Unterpfad, Proxy ...) neu starten - Anmeldungen bleiben erhalten. Klappt es nicht + /// (z.B. Port belegt), läuft das Panel mit den bisherigen Einstellungen weiter. + /// + public bool RestartWeb(ServerConfig newConfig) + { + var previous = config; + Stop(); + config = newConfig; + try + { + StartWeb(); + GameServer.Log("Web panel restarted with the new settings"); + return true; + } + catch (Exception e) + { + GameServer.Log("Web panel could not start with the new settings (" + e.Message + ") - back to the previous settings"); + config = previous; + try { StartWeb(); } catch (Exception e2) { GameServer.Log("Web panel could not start: " + e2.Message); } + return false; + } + } + public void Stop() { try @@ -498,6 +522,7 @@ public sealed class AdminPanel "start" => Ok(host.RequestStart(by)), "stop" => Ok(host.RequestStop(by)), "restart" => Ok(host.RequestRestart(by)), + "restart-panel" => PanelRestart(ctx, by), _ => Error(404, "Unknown action") }; }); @@ -617,6 +642,27 @@ public sealed class AdminPanel }); } + /// Panel neu starten (z.B. nach Port-Änderung). Antwortet zuerst - der Neustart beendet diese Verbindung. + IResult PanelRestart(HttpContext ctx, string by) + { + var fresh = host.LoadConfigForRestart(); + if (fresh == null) return Error(500, "Could not read server.json"); + if (!fresh.PanelEnabled) return Error(400, "The panel is turned off in the settings - turn it on again or edit server.json by hand"); + bool shares = fresh.PanelPort <= 0 || fresh.PanelPort == fresh.Port; + int port = shares ? fresh.Port : fresh.PanelPort; + string basePath = (fresh.PanelBasePath ?? "").Trim().TrimEnd('/'); + if (basePath.Length > 0 && !basePath.StartsWith('/')) basePath = "/" + basePath; + // Neue Adresse für den Browser (hinter einem Proxy bleibt sie gleich) + string url = fresh.PanelTrustProxy ? null : $"{ctx.Request.Scheme}://{ctx.Request.Host.Host}:{port}{basePath}/"; + GameServer.Log($"[panel] {by} restarts the web panel"); + _ = Task.Run(async () => + { + await Task.Delay(700); // Antwort erst beim Browser ankommen lassen + host.RestartPanel(fresh, by); + }); + return Results.Json(new { ok = true, message = "The panel restarts - reloading in a few seconds", url }); + } + // ---------------------------------------------------------------- Hilfen static string By(PanelUsers.User user) => user.Name + " (panel)"; diff --git a/ConfigEditor.cs b/ConfigEditor.cs index 4b039b8..c6006db 100644 --- a/ConfigEditor.cs +++ b/ConfigEditor.cs @@ -15,6 +15,7 @@ public static class ConfigEditor requireSteam = config.RequireSteam, publicAddress = config.PublicAddress, saveSyncMinutes = config.SaveSyncMinutes, + licenseGraceHours = config.LicenseGraceHours, autosaveSeconds = config.AutosaveSeconds, port = config.Port, panelEnabled = config.PanelEnabled, @@ -69,6 +70,7 @@ public static class ConfigEditor config.PublicAddress = Text("publicAddress", config.PublicAddress, 200); config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240); config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600); + config.LicenseGraceHours = Number("licenseGraceHours", config.LicenseGraceHours, 1, 720); int before = changed.Count; int oldPort = config.Port, oldPanelPort = config.PanelPort; diff --git a/GameServer.cs b/GameServer.cs index 21ffcd1..3b6a9ba 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -282,14 +282,11 @@ public sealed class GameServer { var result = heartbeat.Result; heartbeat = null; - if (result.Status == 401 || result.Status == 403) - { - Log("License key was rejected by the master server - shutting down."); - running = false; - } - else if (result.Status == 0) Log("Master server not reachable - server is not listed right now."); - else ReportReachability(result); + OnLicenseResult(result.Status); + if (result.Status == 0 || result.Status >= 500) Log("Master server not reachable - server is not listed right now."); + else if (result.Status >= 200 && result.Status < 300) ReportReachability(result); } + CheckLicenseGrace(); if (!offline && now >= nextHeartbeat && heartbeat == null) { nextHeartbeat = now + HeartbeatSeconds; @@ -1067,6 +1064,7 @@ public sealed class GameServer players = JoinedCount, maxPlayers = config.MaxPlayers, state = "running", + licenseWarning = LicenseWarning, uptimeSeconds = (long)clock.Elapsed.TotalSeconds, port = config.Port, isPublic = config.Public, @@ -1116,6 +1114,56 @@ public sealed class GameServer public bool ExitRequested { get; private set; } string stoppedBy; // gesetzt = gezielt gestoppt (Spieler bekommen den Grund statt "verbinde neu") + // ---------------------------------------------------------------- Lizenz: Kulanzzeit, wenn der Master ausfällt + + /// Wann der Master den Schlüssel zuletzt bestätigt hat (setzt der Host beim Start). + public DateTime LicenseConfirmedUtc { get; set; } = DateTime.UtcNow; + /// Gesetzt, wenn der Server wegen fehlender Lizenzbestätigung gestoppt wurde. + public string LicenseStopReason { get; private set; } + /// Hinweis fürs Panel, solange der Master den Schlüssel nicht bestätigt (null = alles gut). + public string LicenseWarning { get; private set; } + DateTime nextLicenseWarning = DateTime.MinValue, lastLicenseCacheWrite = DateTime.MinValue; + + TimeSpan LicenseGrace => TimeSpan.FromHours(Math.Clamp(config.LicenseGraceHours, 1, 24 * 30)); + + /// Antwort des Masters auf den Heartbeat auswerten - nie sofort stoppen. + void OnLicenseResult(int status) + { + if (status >= 200 && status < 300) + { + if (LicenseWarning != null) Log("The master server confirms the license key again - all good."); + LicenseWarning = null; + LicenseConfirmedUtc = DateTime.UtcNow; + if (DateTime.UtcNow - lastLicenseCacheWrite > TimeSpan.FromMinutes(10)) + { + lastLicenseCacheWrite = DateTime.UtcNow; + var cached = LicenseCache.Load(DataDir, config.LicenseKey); + LicenseCache.Save(DataDir, config.LicenseKey, cached?.Owner ?? ownerName, cached?.Label, LicenseConfirmedUtc); + } + return; + } + bool rejected = status == 401 || status == 403; + var since = DateTime.UtcNow - LicenseConfirmedUtc; + var left = LicenseGrace - since; + LicenseWarning = (rejected ? "The master server rejects the license key" : "The master server is not reachable") + + $" (last confirmed {LicenseCache.Span(since)} ago) - the server keeps running for {LicenseCache.Span(left)}"; + if (DateTime.UtcNow >= nextLicenseWarning) + { + nextLicenseWarning = DateTime.UtcNow.AddMinutes(15); + Log("WARNING: " + LicenseWarning + (rejected ? ". Check your key at " + MasterServer.Url + "/keys" : "")); + } + } + + /// Kulanzzeit abgelaufen? Dann den Gameserver stoppen (das Panel bleibt, der Host versucht es weiter). + void CheckLicenseGrace() + { + if (LicenseStopReason != null || DateTime.UtcNow - LicenseConfirmedUtc <= LicenseGrace) return; + LicenseStopReason = $"The license key could not be confirmed by the master server for {LicenseCache.Span(LicenseGrace)}"; + Log(LicenseStopReason + " - stopping the game server. It starts again automatically as soon as the master confirms the key."); + stoppedBy = "the license check"; + running = false; + } + public void RequestExit() { ExitRequested = true; diff --git a/LicenseCache.cs b/LicenseCache.cs new file mode 100644 index 0000000..e5f0122 --- /dev/null +++ b/LicenseCache.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace MSCO.Server; + +/// +/// Letzte erfolgreiche Lizenzprüfung beim Master (data/license.json). Ist der Master mal nicht erreichbar oder lehnt +/// den Schlüssel kurz ab (z.B. nach einem Umzug ohne keys.json), läuft bzw. startet der Server damit weiter - bis +/// LicenseGraceHours ohne Bestätigung vergangen sind. Gespeichert wird nur ein Hash des Schlüssels. +/// +public static class LicenseCache +{ + public sealed class Entry + { + public string KeyHash { get; set; } + public string Owner { get; set; } + public string Label { get; set; } + public DateTime LastConfirmedUtc { get; set; } + } + + static string PathOf(string dataDir) => Path.Combine(dataDir, "license.json"); + + static string Hash(string key) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key ?? ""))).ToLowerInvariant(); + + /// Gemerkte Bestätigung für genau diesen Schlüssel (null = keine). + public static Entry Load(string dataDir, string key) + { + try + { + string path = PathOf(dataDir); + if (!File.Exists(path)) return null; + var entry = JsonSerializer.Deserialize(File.ReadAllText(path)); + return entry != null && entry.KeyHash == Hash(key) ? entry : null; + } + catch { return null; } + } + + public static void Save(string dataDir, string key, string owner, string label, DateTime confirmedUtc) + { + try + { + var entry = new Entry { KeyHash = Hash(key), Owner = owner, Label = label, LastConfirmedUtc = confirmedUtc }; + string path = PathOf(dataDir), tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(entry, new JsonSerializerOptions { WriteIndented = true })); + File.Move(tmp, path, true); + } + catch (Exception e) { GameServer.Log("Could not remember the license check: " + e.Message); } + } + + /// "3 h", "2 d 4 h" ... + public static string Span(TimeSpan t) => + t.TotalHours < 1 ? $"{Math.Max(1, (int)t.TotalMinutes)} min" : t.TotalDays < 1 ? $"{(int)t.TotalHours} h" : $"{(int)t.TotalDays} d {t.Hours} h"; +} diff --git a/Panel/app.js b/Panel/app.js index 2b9943c..13657d3 100644 --- a/Panel/app.js +++ b/Panel/app.js @@ -306,7 +306,32 @@ function renderControl(el, s) { : 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 ?? ""); // replaceChildren macht aus null den Text "null" + // Lizenz gerade nicht bestätigt (Master aus o.ä.) - der Server läuft weiter, aber nur noch eine Weile + const license = running && s.licenseWarning + ? h("div", { class: "notice" }, h("div", {}, h("b", {}, "License not confirmed"), h("div", {}, s.licenseWarning))) : null; + el.replaceChildren(...[buttons, problem, license].filter(Boolean)); // null würde sonst als Text "null" erscheinen +} + +/** Panel neu starten (Port, Unterpfad, Proxy übernehmen) und danach die (ggf. neue) Adresse laden. */ +async function restartPanel() { + const ok = await modal({ title: "Restart the panel?", + text: "Applies the saved port and panel settings. If the game port changed, the game server restarts too. You stay logged in.", + confirm: "Restart panel" }); + if (!ok) return; + const r = await act(api("POST", "server/restart-panel")); + if (!r) return; + const target = r.url && r.url !== location.origin + location.pathname ? r.url : location.href; + // Warten, bis das Panel wieder antwortet, dann neu laden + const started = Date.now(); + const tryReload = async () => { + try { + const res = await fetch(new URL("api/session", target).href, { credentials: "include", mode: target.startsWith(location.origin) ? "same-origin" : "no-cors" }); + if (res.ok || res.type === "opaque") { location.href = target; return; } + } catch { /* noch nicht da */ } + if (Date.now() - started < 30000) setTimeout(tryReload, 1000); + else location.href = target; + }; + setTimeout(tryReload, 2500); } // ------------------------------------------------------------------ Einstellungen @@ -376,7 +401,9 @@ function renderSettings(page) { h("div", { class: "section-head" }, h("h2", {}, "Saving")), h("div", { class: "section-body form-grid wide" }, number("saveSyncMinutes", "Take the save game from a player every … minutes", c.saveSyncMinutes, { min: 0, max: 240, hint: "0 = only when someone joins" }), - number("autosaveSeconds", "Write the world state every … seconds", c.autosaveSeconds, { min: 10, max: 3600 }))), + number("autosaveSeconds", "Write the world state every … seconds", c.autosaveSeconds, { min: 10, max: 3600 }), + number("licenseGraceHours", "Keep running without license confirmation for … hours", c.licenseGraceHours, { min: 1, max: 720, + hint: "If the master server is down or rejects the key, the server keeps running this long" }))), h("div", { class: "section" }, h("div", { class: "section-head" }, h("h2", {}, "Network & panel"), h("span", { class: "muted" }, "needs a restart")), h("div", { class: "section-body form-grid wide" }, @@ -396,13 +423,14 @@ function renderSettings(page) { h("a", { href: "https://steamcommunity.com/dev/apikey", target: "_blank", rel: "noopener noreferrer" }, "Get your free key on Steam ↗"))))), 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 / start the server now?", - text: "Loads the saved settings. Players reconnect automatically.", confirm: "Restart" }) }, "Restart to apply"))); + text: "Loads the saved settings. Players reconnect automatically.", confirm: "Restart" }) }, "Restart to apply"), + h("button", { class: "btn", type: "button", onclick: restartPanel }, "⟳ Restart panel"))); } } /** - * Warnung, wenn Spiel- oder Panel-Port geändert werden: Ports übernimmt erst ein Neustart des ganzen Programms - * (der Restart-Knopf startet nur den Gameserver neu), danach ist das Panel nur noch am neuen Port - in Docker nur, + * Warnung, wenn Spiel- oder Panel-Port geändert werden: Ports übernimmt "Panel neu starten" (oder ein Programmneustart), + * danach ist das Panel nur noch am neuen Port - in Docker nur, * wenn der Port in docker-compose.yml freigegeben ist. Text direkt in beiden Sprachen (enthält Zahlen). */ function portChangeWarning(c, body) { @@ -419,8 +447,8 @@ function portChangeWarning(c, body) { if (port !== c.port) lines.push(de ? `Spieler verbinden sich dann über Port ${port}.` : `Players then connect on port ${port}.`); lines.push(de - ? "Das gilt erst nach einem Neustart des ganzen Programms (docker compose restart) - der Neustart-Knopf startet nur den Gameserver neu." - : "This only applies after the whole program restarts (docker compose restart) - the Restart button only restarts the game server."); + ? "Das gilt, sobald du unten „Panel neu starten“ drückst (oder das Programm neu startest)." + : "It applies as soon as you press “Restart panel” at the bottom (or restart the program)."); const ports = [...new Set([port, newPanel])].map((p) => `"${p}:${p}/tcp"`).join(", "); lines.push(c.inDocker ? (de ? `Docker: trag vorher unter ports in docker-compose.yml ${ports} ein, sonst sperrst du dich aus.` diff --git a/Panel/i18n.js b/Panel/i18n.js index 56dce82..29c3c72 100644 --- a/Panel/i18n.js +++ b/Panel/i18n.js @@ -281,6 +281,16 @@ "The save game is too large": "Der Spielstand ist zu groß", "Enter a command": "Gib einen Befehl ein", "Choose a ZIP, RAR or 7z file": "Wähle eine ZIP-, RAR- oder 7z-Datei", + "License not confirmed": "Lizenz nicht bestätigt", + "Keep running without license confirmation for … hours": "Ohne Lizenzbestätigung weiterlaufen für … Stunden", + "If the master server is down or rejects the key, the server keeps running this long": "Ist der Master-Server aus oder lehnt den Schlüssel ab, läuft der Server so lange weiter", + "⟳ Restart panel": "⟳ Panel neu starten", + "Restart the panel?": "Panel neu starten?", + "Applies the saved port and panel settings. If the game port changed, the game server restarts too. You stay logged in.": + "Übernimmt die gespeicherten Port- und Panel-Einstellungen. Hat sich der Spiel-Port geändert, startet auch der Gameserver neu. Du bleibst angemeldet.", + "Restart panel": "Panel neu starten", + "The panel restarts - reloading in a few seconds": "Das Panel startet neu - die Seite lädt in ein paar Sekunden neu", + "The panel is turned off in the settings - turn it on again or edit server.json by hand": "Das Panel ist in den Einstellungen ausgeschaltet - schalte es wieder ein oder bearbeite server.json von Hand", }; // Zusammengesetzte Texte (Name, Zahl ... darin) @@ -330,6 +340,10 @@ [/^License key invalid: (.+)$/, "Lizenzschlüssel ungültig: $1"], [/^Master server not reachable (.+)$/, "Master-Server nicht erreichbar $1"], [/^Crashed: (.+)$/, "Abgestürzt: $1"], + [/^The master server (rejects the license key|is not reachable) \(last confirmed (.+) ago\) - the server keeps running for (.+)$/, + (m, why, ago, left) => (why.startsWith("rejects") ? "Der Master-Server lehnt den Lizenzschlüssel ab" : "Der Master-Server ist nicht erreichbar") + + " (zuletzt bestätigt vor " + ago + ") - der Server läuft noch " + left + " weiter"], + [/^The license key could not be confirmed by the master server for (.+)$/, "Der Lizenzschlüssel konnte $1 lang nicht vom Master-Server bestätigt werden"], [/^This file could not be read - upload a ZIP, RAR or 7z archive (.+)$/, "Die Datei konnte nicht gelesen werden - lade ein ZIP-, RAR- oder 7z-Archiv hoch $1"], [/^No defaultES2File\.txt in the archive(.*)$/, "Keine defaultES2File.txt im Archiv - pack die Dateien aus %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car"], ]; diff --git a/README.md b/README.md index 42e26fe..61ed68f 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,11 @@ Mit diesem Code legst du im Browser das **Owner**-Konto an (erneut anzeigen: `ms - 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. - Oberfläche auf **Deutsch oder Englisch** (Umschalter unten in der Seitenleiste). +- **Port oder Panel-Einstellungen geändert?** In den Einstellungen unten **„Panel neu starten“** – übernimmt sie sofort, + man bleibt angemeldet und wird auf die neue Adresse weitergeleitet. +- **Keymaster nicht erreichbar oder lehnt den Schlüssel ab?** Der Server läuft weiter und warnt im Log/Panel; erst nach + `LicenseGraceHours` (Standard 72 h) ohne Bestätigung stoppt der Gameserver und startet von selbst wieder, sobald der + Master den Schlüssel bestätigt. Auch ein Neustart klappt in dieser Zeit (letzte Bestätigung in `data/license.json`). - 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. diff --git a/ServerConfig.cs b/ServerConfig.cs index 0362c44..41a112e 100644 --- a/ServerConfig.cs +++ b/ServerConfig.cs @@ -33,6 +33,12 @@ public sealed class ServerConfig /// Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen. public List Admins { get; set; } = new(); + /// + /// So lange (Stunden) läuft bzw. startet der Server weiter, wenn der Master den Lizenzschlüssel nicht bestätigen + /// kann (nicht erreichbar oder lehnt ihn ab). Danach wird der Gameserver gestoppt, bis der Master ihn wieder bestätigt. + /// + public int LicenseGraceHours { get; set; } = 72; + // ---------------------------------------------------------------- Web-Panel /// Web-Panel einschalten. public bool PanelEnabled { get; set; } = true; diff --git a/ServerHost.cs b/ServerHost.cs index 480866c..ee3adbb 100644 --- a/ServerHost.cs +++ b/ServerHost.cs @@ -19,6 +19,7 @@ public sealed class ServerHost static readonly TimeSpan CrashWindow = TimeSpan.FromMinutes(10); static readonly TimeSpan CrashRestartDelay = TimeSpan.FromSeconds(10); static readonly TimeSpan MasterRetryDelay = TimeSpan.FromSeconds(30); + static readonly TimeSpan LicenseRetryDelay = TimeSpan.FromMinutes(5); readonly string dataDir, configPath; readonly bool offline; @@ -87,6 +88,30 @@ public sealed class ServerHost return "Stopping the server - the panel keeps running"; } + /// server.json für einen Panel-Neustart lesen (Umgebungsvariablen gelten weiter). + public ServerConfig LoadConfigForRestart() + { + if (offline) return Config; + try + { + var fresh = ServerConfig.LoadOrCreate(configPath, out _); + fresh.ApplyEnvironment(); + return fresh; + } + catch { return null; } + } + + /// Panel mit neuen Einstellungen neu starten; geänderter Spiel-Port: Gameserver neu starten bzw. Port neu binden. + public void RestartPanel(ServerConfig fresh, string by) + { + bool portChanged = fresh.Port != listenerPort; + Config = fresh; + Panel?.RestartWeb(fresh); + if (!portChanged) return; + if (Server != null && State == HostState.Running) RequestRestart(by); // bindet beim Start den neuen Port + else Listen(fresh.Port); + } + public string RequestRestart(string by) { var server = Server; @@ -146,6 +171,14 @@ public sealed class ServerHost Server = null; if (server.ExitRequested) break; + if (server.LicenseStopReason != null) + { + // Lizenz zu lange nicht bestätigt: regelmäßig neu versuchen - startet von selbst, sobald der Master wieder bestätigt + SetState(HostState.Error, server.LicenseStopReason); + startRequested = true; + startAt = DateTime.UtcNow + LicenseRetryDelay; + continue; + } if (server.RestartRequested) { GameServer.Log("Restarting the server ..."); @@ -220,23 +253,43 @@ public sealed class ServerHost } string owner = "Test"; + DateTime confirmed = DateTime.UtcNow; 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) + if (key.Valid) { - // 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"); + GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})"); + owner = key.Owner; + LicenseCache.Save(dataDir, Config.LicenseKey, key.Owner, key.Label, confirmed); + } + else + { + // Master nicht erreichbar oder lehnt ab: mit der letzten Bestätigung starten, solange sie frisch genug ist + var grace = TimeSpan.FromHours(Math.Clamp(Config.LicenseGraceHours, 1, 24 * 30)); + var cached = LicenseCache.Load(dataDir, Config.LicenseKey); + string problem = key.Unreachable ? $"Master server not reachable ({key.Error})" : "License key rejected: " + (key.Error ?? "unknown key"); + if (cached != null && DateTime.UtcNow - cached.LastConfirmedUtc < grace) + { + var left = grace - (DateTime.UtcNow - cached.LastConfirmedUtc); + GameServer.Log($"WARNING: {problem} - starting with the license confirmed {LicenseCache.Span(DateTime.UtcNow - cached.LastConfirmedUtc)} ago " + + $"(owner {cached.Owner}). The server keeps running for {LicenseCache.Span(left)} unless the master confirms it again."); + owner = cached.Owner ?? "?"; + confirmed = cached.LastConfirmedUtc; + } + else + { + // Nicht dauerhaft aufgeben: der Master kann gleich wieder da sein bzw. der Schlüssel wieder gelten + var retry = key.Unreachable ? MasterRetryDelay : LicenseRetryDelay; + startRequested = true; + startAt = DateTime.UtcNow + retry; + return Fail(problem + (key.Unreachable ? "" : " - manage your keys at " + MasterServer.Url + "/keys") + + $" - retrying in {LicenseCache.Span(retry)}"); + } } - 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; @@ -251,6 +304,7 @@ public sealed class ServerHost : "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; + server.LicenseConfirmedUtc = confirmed; ConfigureServer?.Invoke(server); if (Config.Port != listenerPort) Listen(Config.Port); // Port in den Einstellungen geändert return server; diff --git a/Tests/Program.cs b/Tests/Program.cs index 63fe48d..fc56c86 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -486,6 +486,49 @@ async Task PanelTests() using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip))) Check(down.IsSuccessStatusCode && da.GetEntry("defaultES2File.txt") != null, "The current save can be downloaded as ZIP"); + // Lizenz: Master fällt aus -> der Server läuft weiter, erst nach der Kulanzzeit wird er gestoppt + LicenseCache.Save(dir, "test", "Tester", "Test", DateTime.UtcNow.AddHours(-5)); + var cachedLicense = LicenseCache.Load(dir, "test"); + Check(cachedLicense != null && cachedLicense.Owner == "Tester" && LicenseCache.Load(dir, "other-key") == null + && !File.ReadAllText(Path.Combine(dir, "license.json")).Contains("\"test\""), + "The last license check is remembered (only a hash of the key)"); + Srv().LicenseConfirmedUtc = DateTime.UtcNow.AddHours(-10); // innerhalb der 72 h: läuft weiter + await Task.Delay(500); + Check(host.State == HostState.Running, "Server keeps running while the license is unconfirmed but within the grace period"); + Srv().LicenseConfirmedUtc = DateTime.UtcNow.AddHours(-100); // Kulanzzeit abgelaufen + await WaitForState(HostState.Error); + Check(host.State == HostState.Error && (host.LastError ?? "").Contains("license"), "After the grace period the game server stops: " + host.LastError); + Check((await Call(owner, "GET", "api/session")).status == 200, "The panel stays reachable after the license stop"); + Check((await Call(owner, "POST", "api/server/start")).status == 200, "It can be started again"); + await WaitForState(HostState.Running); + Check(host.State == HostState.Running, "Running again after the start"); + + // Panel neu starten: eigener Port, danach wieder zurück auf den Spiel-Port - angemeldet bleiben + Check((await Call(owner, "POST", "api/config", new { panelPort = 17791 })).status == 200, "Panel port can be changed"); + var pr = await Call(owner, "POST", "api/server/restart-panel"); + Check(pr.status == 200 && pr.json.GetProperty("url").GetString().Contains(":17791/"), "Restart panel returns the new address: " + pr.json); + int code = 0; + for (int i = 0; i < 30 && code != 200; i++) + { + await Task.Delay(300); + try { code = (int)(await owner.GetAsync("http://127.0.0.1:17791/api/status")).StatusCode; } catch { } + } + Check(code == 200, "Panel answers on the new port and the login is kept"); + var back = new HttpRequestMessage(HttpMethod.Post, "http://127.0.0.1:17791/api/config") + { Content = new StringContent("{\"panelPort\":0}", System.Text.Encoding.UTF8, "application/json") }; + back.Headers.Add("X-MSCO-Panel", "1"); + await owner.SendAsync(back); + var back2 = new HttpRequestMessage(HttpMethod.Post, "http://127.0.0.1:17791/api/server/restart-panel"); + back2.Headers.Add("X-MSCO-Panel", "1"); + await owner.SendAsync(back2); + code = 0; + for (int i = 0; i < 30 && code != 200; i++) + { + await Task.Delay(300); + try { code = (await Call(owner, "GET", "api/status")).status; } catch { } + } + Check(code == 200 && host.Panel.SharesGamePort, "Panel is back on the game port"); + await Call(owner, "POST", "api/logout"); Check((await Call(owner, "GET", "api/status")).status == 401, "Logout ends the session"); }