License: running server keeps running until the next restart

The configurable LicenseGraceHours setting is gone. If the master is down or
rejects the key, a running server now only warns and keeps running until the
next restart; a (re)start still works up to 72 h after the last confirmation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:45:38 +02:00
co-authored by Claude Opus 5.5
parent 816c538705
commit 1a2431db3a
9 changed files with 23 additions and 57 deletions
-2
View File
@@ -15,7 +15,6 @@ public static class ConfigEditor
requireSteam = config.RequireSteam, requireSteam = config.RequireSteam,
publicAddress = config.PublicAddress, publicAddress = config.PublicAddress,
saveSyncMinutes = config.SaveSyncMinutes, saveSyncMinutes = config.SaveSyncMinutes,
licenseGraceHours = config.LicenseGraceHours,
autosaveSeconds = config.AutosaveSeconds, autosaveSeconds = config.AutosaveSeconds,
port = config.Port, port = config.Port,
panelEnabled = config.PanelEnabled, panelEnabled = config.PanelEnabled,
@@ -70,7 +69,6 @@ public static class ConfigEditor
config.PublicAddress = Text("publicAddress", config.PublicAddress, 200); config.PublicAddress = Text("publicAddress", config.PublicAddress, 200);
config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240); config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240);
config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600); config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600);
config.LicenseGraceHours = Number("licenseGraceHours", config.LicenseGraceHours, 1, 720);
int before = changed.Count; int before = changed.Count;
int oldPort = config.Port, oldPanelPort = config.PanelPort; int oldPort = config.Port, oldPanelPort = config.PanelPort;
+5 -18
View File
@@ -287,7 +287,6 @@ public sealed class GameServer
if (result.Status == 0 || result.Status >= 500) Log("Master server not reachable - server is not listed right now."); 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); else if (result.Status >= 200 && result.Status < 300) ReportReachability(result);
} }
CheckLicenseGrace();
if (!offline && now >= nextHeartbeat && heartbeat == null) if (!offline && now >= nextHeartbeat && heartbeat == null)
{ {
nextHeartbeat = now + HeartbeatSeconds; nextHeartbeat = now + HeartbeatSeconds;
@@ -1119,15 +1118,14 @@ public sealed class GameServer
/// <summary>Wann der Master den Schlüssel zuletzt bestätigt hat (setzt der Host beim Start).</summary> /// <summary>Wann der Master den Schlüssel zuletzt bestätigt hat (setzt der Host beim Start).</summary>
public DateTime LicenseConfirmedUtc { get; set; } = DateTime.UtcNow; public DateTime LicenseConfirmedUtc { get; set; } = DateTime.UtcNow;
/// <summary>Gesetzt, wenn der Server wegen fehlender Lizenzbestätigung gestoppt wurde.</summary>
public string LicenseStopReason { get; private set; }
/// <summary>Hinweis fürs Panel, solange der Master den Schlüssel nicht bestätigt (null = alles gut).</summary> /// <summary>Hinweis fürs Panel, solange der Master den Schlüssel nicht bestätigt (null = alles gut).</summary>
public string LicenseWarning { get; private set; } public string LicenseWarning { get; private set; }
DateTime nextLicenseWarning = DateTime.MinValue, lastLicenseCacheWrite = DateTime.MinValue; DateTime nextLicenseWarning = DateTime.MinValue, lastLicenseCacheWrite = DateTime.MinValue;
TimeSpan LicenseGrace => TimeSpan.FromHours(Math.Clamp(config.LicenseGraceHours, 1, 24 * 30)); /// <summary>
/// Antwort des Masters auf den Heartbeat auswerten. Ein laufender Server wird deswegen nie gestoppt - er läuft bis zum
/// <summary>Antwort des Masters auf den Heartbeat auswerten - nie sofort stoppen.</summary> /// nächsten Neustart weiter; erst beim Start wird die Lizenz wieder geprüft (siehe ServerHost).
/// </summary>
void OnLicenseResult(int status) void OnLicenseResult(int status)
{ {
if (status >= 200 && status < 300) if (status >= 200 && status < 300)
@@ -1145,9 +1143,8 @@ public sealed class GameServer
} }
bool rejected = status == 401 || status == 403; bool rejected = status == 401 || status == 403;
var since = DateTime.UtcNow - LicenseConfirmedUtc; var since = DateTime.UtcNow - LicenseConfirmedUtc;
var left = LicenseGrace - since;
LicenseWarning = (rejected ? "The master server rejects the license key" : "The master server is not reachable") + 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)}"; $" (last confirmed {LicenseCache.Span(since)} ago) - the server keeps running until the next restart";
if (DateTime.UtcNow >= nextLicenseWarning) if (DateTime.UtcNow >= nextLicenseWarning)
{ {
nextLicenseWarning = DateTime.UtcNow.AddMinutes(15); nextLicenseWarning = DateTime.UtcNow.AddMinutes(15);
@@ -1155,16 +1152,6 @@ public sealed class GameServer
} }
} }
/// <summary>Kulanzzeit abgelaufen? Dann den Gameserver stoppen (das Panel bleibt, der Host versucht es weiter).</summary>
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() public void RequestExit()
{ {
ExitRequested = true; ExitRequested = true;
+4 -1
View File
@@ -7,10 +7,13 @@ namespace MSCO.Server;
/// <summary> /// <summary>
/// Letzte erfolgreiche Lizenzprüfung beim Master (data/license.json). Ist der Master mal nicht erreichbar oder lehnt /// 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 /// 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. /// GraceHours ohne Bestätigung vergangen sind (fest, nicht einstellbar). Gespeichert wird nur ein Hash des Schlüssels.
/// </summary> /// </summary>
public static class LicenseCache public static class LicenseCache
{ {
/// <summary>So lange startet ein Server ohne Bestätigung des Masters noch (ein laufender läuft bis zum Neustart weiter).</summary>
public static readonly TimeSpan Grace = TimeSpan.FromHours(72);
public sealed class Entry public sealed class Entry
{ {
public string KeyHash { get; set; } public string KeyHash { get; set; }
+1 -3
View File
@@ -401,9 +401,7 @@ function renderSettings(page) {
h("div", { class: "section-head" }, h("h2", {}, "Saving")), h("div", { class: "section-head" }, h("h2", {}, "Saving")),
h("div", { class: "section-body form-grid wide" }, 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("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" },
h("div", { class: "section-head" }, h("h2", {}, "Network & panel"), h("span", { class: "muted" }, "needs a restart")), h("div", { class: "section-head" }, h("h2", {}, "Network & panel"), h("span", { class: "muted" }, "needs a restart")),
h("div", { class: "section-body form-grid wide" }, h("div", { class: "section-body form-grid wide" },
+3 -5
View File
@@ -283,7 +283,6 @@
"Choose a ZIP, RAR or 7z file": "Wähle eine ZIP-, RAR- oder 7z-Datei", "Choose a ZIP, RAR or 7z file": "Wähle eine ZIP-, RAR- oder 7z-Datei",
"License not confirmed": "Lizenz nicht bestätigt", "License not confirmed": "Lizenz nicht bestätigt",
"Keep running without license confirmation for … hours": "Ohne Lizenzbestätigung weiterlaufen für … Stunden", "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 panel": "⟳ Panel neu starten",
"Restart the 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.": "Applies the saved port and panel settings. If the game port changed, the game server restarts too. You stay logged in.":
@@ -340,10 +339,9 @@
[/^License key invalid: (.+)$/, "Lizenzschlüssel ungültig: $1"], [/^License key invalid: (.+)$/, "Lizenzschlüssel ungültig: $1"],
[/^Master server not reachable (.+)$/, "Master-Server nicht erreichbar $1"], [/^Master server not reachable (.+)$/, "Master-Server nicht erreichbar $1"],
[/^Crashed: (.+)$/, "Abgestürzt: $1"], [/^Crashed: (.+)$/, "Abgestürzt: $1"],
[/^The master server (rejects the license key|is not reachable) \(last confirmed (.+) ago\) - the server keeps running for (.+)$/, [/^The master server (rejects the license key|is not reachable) \(last confirmed (.+) ago\) - the server keeps running until the next restart$/,
(m, why, ago, left) => (why.startsWith("rejects") ? "Der Master-Server lehnt den Lizenzschlüssel ab" : "Der Master-Server ist nicht erreichbar") + (m, why, ago) => (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"], " (zuletzt bestätigt vor " + ago + ") - der Server läuft bis zum nächsten Neustart 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"], [/^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"], [/^No defaultES2File\.txt in the archive(.*)$/, "Keine defaultES2File.txt im Archiv - pack die Dateien aus %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car"],
]; ];
+3 -3
View File
@@ -163,9 +163,9 @@ Mit diesem Code legst du im Browser das **Owner**-Konto an (erneut anzeigen: `ms
- Oberfläche auf **Deutsch oder Englisch** (Umschalter unten in der Seitenleiste). - 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, - **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. 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 - **Keymaster nicht erreichbar oder lehnt den Schlüssel ab?** Ein laufender Server läuft bis zum nächsten Neustart weiter
`LicenseGraceHours` (Standard 72 h) ohne Bestätigung stoppt der Gameserver und startet von selbst wieder, sobald der und warnt im Log/Panel. Ein Neustart klappt noch bis 72 Stunden nach der letzten Bestätigung (gespeichert in
Master den Schlüssel bestätigt. Auch ein Neustart klappt in dieser Zeit (letzte Bestätigung in `data/license.json`). `data/license.json`); danach startet der Server von selbst, sobald der Master den Schlüssel wieder bestätigt.
- Passwörter werden nur als PBKDF2-Hash in `data/panel_users.json` gespeichert, Login mit Sperre nach - 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. Fehlversuchen, Sitzungs-Cookie (HttpOnly, SameSite=Strict), Schutz gegen CSRF.
-6
View File
@@ -33,12 +33,6 @@ public sealed class ServerConfig
/// <summary>Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen.</summary> /// <summary>Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen.</summary>
public List<string> Admins { get; set; } = new(); public List<string> Admins { get; set; } = new();
/// <summary>
/// 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.
/// </summary>
public int LicenseGraceHours { get; set; } = 72;
// ---------------------------------------------------------------- Web-Panel // ---------------------------------------------------------------- Web-Panel
/// <summary>Web-Panel einschalten.</summary> /// <summary>Web-Panel einschalten.</summary>
public bool PanelEnabled { get; set; } = true; public bool PanelEnabled { get; set; } = true;
+2 -10
View File
@@ -171,14 +171,6 @@ public sealed class ServerHost
Server = null; Server = null;
if (server.ExitRequested) break; 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) if (server.RestartRequested)
{ {
GameServer.Log("Restarting the server ..."); GameServer.Log("Restarting the server ...");
@@ -269,14 +261,14 @@ public sealed class ServerHost
else else
{ {
// Master nicht erreichbar oder lehnt ab: mit der letzten Bestätigung starten, solange sie frisch genug ist // 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 grace = LicenseCache.Grace;
var cached = LicenseCache.Load(dataDir, Config.LicenseKey); var cached = LicenseCache.Load(dataDir, Config.LicenseKey);
string problem = key.Unreachable ? $"Master server not reachable ({key.Error})" : "License key rejected: " + (key.Error ?? "unknown key"); 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) if (cached != null && DateTime.UtcNow - cached.LastConfirmedUtc < grace)
{ {
var left = grace - (DateTime.UtcNow - cached.LastConfirmedUtc); var left = grace - (DateTime.UtcNow - cached.LastConfirmedUtc);
GameServer.Log($"WARNING: {problem} - starting with the license confirmed {LicenseCache.Span(DateTime.UtcNow - cached.LastConfirmedUtc)} ago " + 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}). The server keeps running until the next restart; a restart works for {LicenseCache.Span(left)} more.");
owner = cached.Owner ?? "?"; owner = cached.Owner ?? "?";
confirmed = cached.LastConfirmedUtc; confirmed = cached.LastConfirmedUtc;
} }
+5 -9
View File
@@ -486,7 +486,7 @@ async Task PanelTests()
using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip))) 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"); 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 // Lizenz: Master fällt aus -> der laufende Server läuft bis zum nächsten Neustart weiter
LicenseCache.Save(dir, "test", "Tester", "Test", DateTime.UtcNow.AddHours(-5)); LicenseCache.Save(dir, "test", "Tester", "Test", DateTime.UtcNow.AddHours(-5));
var cachedLicense = LicenseCache.Load(dir, "test"); var cachedLicense = LicenseCache.Load(dir, "test");
Check(cachedLicense != null && cachedLicense.Owner == "Tester" && LicenseCache.Load(dir, "other-key") == null Check(cachedLicense != null && cachedLicense.Owner == "Tester" && LicenseCache.Load(dir, "other-key") == null
@@ -494,14 +494,10 @@ async Task PanelTests()
"The last license check is remembered (only a hash of the key)"); "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 Srv().LicenseConfirmedUtc = DateTime.UtcNow.AddHours(-10); // innerhalb der 72 h: läuft weiter
await Task.Delay(500); await Task.Delay(500);
Check(host.State == HostState.Running, "Server keeps running while the license is unconfirmed but within the grace period"); Check(host.State == HostState.Running, "Server keeps running while the license is unconfirmed");
Srv().LicenseConfirmedUtc = DateTime.UtcNow.AddHours(-100); // Kulanzzeit abgelaufen Srv().LicenseConfirmedUtc = DateTime.UtcNow.AddHours(-100); // länger als die 72 h für einen Start
await WaitForState(HostState.Error); await Task.Delay(1500);
Check(host.State == HostState.Error && (host.LastError ?? "").Contains("license"), "After the grace period the game server stops: " + host.LastError); Check(host.State == HostState.Running, "A running server is never stopped by the license check - it runs until the next restart");
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 // 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"); Check((await Call(owner, "POST", "api/config", new { panelPort = 17791 })).status == 200, "Panel port can be changed");