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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:02:05 +02:00
co-authored by Claude Opus 5.5
parent d4e5897b7f
commit 14db3ef4f4
10 changed files with 325 additions and 24 deletions
+47 -1
View File
@@ -155,7 +155,7 @@ public sealed class AdminPanel
app.StartAsync().GetAwaiter().GetResult(); app.StartAsync().GetAwaiter().GetResult();
if (users.Empty) CreateSetupCode(); if (users.Empty && SetupCode == null) CreateSetupCode();
if (SharesGamePort) if (SharesGamePort)
GameServer.Log($"Web panel: http://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)"); GameServer.Log($"Web panel: http://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
else else
@@ -165,6 +165,30 @@ public sealed class AdminPanel
/// <summary>Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu).</summary> /// <summary>Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu).</summary>
public void UpdateConfig(ServerConfig newConfig) => config = newConfig; public void UpdateConfig(ServerConfig newConfig) => config = newConfig;
/// <summary>
/// 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.
/// </summary>
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() public void Stop()
{ {
try try
@@ -498,6 +522,7 @@ public sealed class AdminPanel
"start" => Ok(host.RequestStart(by)), "start" => Ok(host.RequestStart(by)),
"stop" => Ok(host.RequestStop(by)), "stop" => Ok(host.RequestStop(by)),
"restart" => Ok(host.RequestRestart(by)), "restart" => Ok(host.RequestRestart(by)),
"restart-panel" => PanelRestart(ctx, by),
_ => Error(404, "Unknown action") _ => Error(404, "Unknown action")
}; };
}); });
@@ -617,6 +642,27 @@ public sealed class AdminPanel
}); });
} }
/// <summary>Panel neu starten (z.B. nach Port-Änderung). Antwortet zuerst - der Neustart beendet diese Verbindung.</summary>
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 // ---------------------------------------------------------------- Hilfen
static string By(PanelUsers.User user) => user.Name + " (panel)"; static string By(PanelUsers.User user) => user.Name + " (panel)";
+2
View File
@@ -15,6 +15,7 @@ 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,
@@ -69,6 +70,7 @@ 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;
+55 -7
View File
@@ -282,14 +282,11 @@ public sealed class GameServer
{ {
var result = heartbeat.Result; var result = heartbeat.Result;
heartbeat = null; heartbeat = null;
if (result.Status == 401 || result.Status == 403) OnLicenseResult(result.Status);
{ if (result.Status == 0 || result.Status >= 500) Log("Master server not reachable - server is not listed right now.");
Log("License key was rejected by the master server - shutting down."); else if (result.Status >= 200 && result.Status < 300) ReportReachability(result);
running = false;
}
else if (result.Status == 0) Log("Master server not reachable - server is not listed right now.");
else ReportReachability(result);
} }
CheckLicenseGrace();
if (!offline && now >= nextHeartbeat && heartbeat == null) if (!offline && now >= nextHeartbeat && heartbeat == null)
{ {
nextHeartbeat = now + HeartbeatSeconds; nextHeartbeat = now + HeartbeatSeconds;
@@ -1067,6 +1064,7 @@ public sealed class GameServer
players = JoinedCount, players = JoinedCount,
maxPlayers = config.MaxPlayers, maxPlayers = config.MaxPlayers,
state = "running", state = "running",
licenseWarning = LicenseWarning,
uptimeSeconds = (long)clock.Elapsed.TotalSeconds, uptimeSeconds = (long)clock.Elapsed.TotalSeconds,
port = config.Port, port = config.Port,
isPublic = config.Public, isPublic = config.Public,
@@ -1116,6 +1114,56 @@ public sealed class GameServer
public bool ExitRequested { get; private set; } public bool ExitRequested { get; private set; }
string stoppedBy; // gesetzt = gezielt gestoppt (Spieler bekommen den Grund statt "verbinde neu") string stoppedBy; // gesetzt = gezielt gestoppt (Spieler bekommen den Grund statt "verbinde neu")
// ---------------------------------------------------------------- Lizenz: Kulanzzeit, wenn der Master ausfällt
/// <summary>Wann der Master den Schlüssel zuletzt bestätigt hat (setzt der Host beim Start).</summary>
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>
public string LicenseWarning { get; private set; }
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 - nie sofort stoppen.</summary>
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" : ""));
}
}
/// <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;
+55
View File
@@ -0,0 +1,55 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace MSCO.Server;
/// <summary>
/// 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.
/// </summary>
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();
/// <summary>Gemerkte Bestätigung für genau diesen Schlüssel (null = keine).</summary>
public static Entry Load(string dataDir, string key)
{
try
{
string path = PathOf(dataDir);
if (!File.Exists(path)) return null;
var entry = JsonSerializer.Deserialize<Entry>(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); }
}
/// <summary>"3 h", "2 d 4 h" ...</summary>
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";
}
+35 -7
View File
@@ -306,7 +306,32 @@ function renderControl(el, s) {
: h("button", { class: "btn small ghost", type: "button", onclick: () => go("console") }, "Open console"); : 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); 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 // ------------------------------------------------------------------ Einstellungen
@@ -376,7 +401,9 @@ 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" },
@@ -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("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("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?", 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 * Warnung, wenn Spiel- oder Panel-Port geändert werden: Ports übernimmt "Panel neu starten" (oder ein Programmneustart),
* (der Restart-Knopf startet nur den Gameserver neu), danach ist das Panel nur noch am neuen Port - in Docker nur, * 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). * wenn der Port in docker-compose.yml freigegeben ist. Text direkt in beiden Sprachen (enthält Zahlen).
*/ */
function portChangeWarning(c, body) { function portChangeWarning(c, body) {
@@ -419,8 +447,8 @@ function portChangeWarning(c, body) {
if (port !== c.port) if (port !== c.port)
lines.push(de ? `Spieler verbinden sich dann über Port ${port}.` : `Players then connect on port ${port}.`); lines.push(de ? `Spieler verbinden sich dann über Port ${port}.` : `Players then connect on port ${port}.`);
lines.push(de lines.push(de
? "Das gilt erst nach einem Neustart des ganzen Programms (docker compose restart) - der Neustart-Knopf startet nur den Gameserver neu." ? "Das gilt, sobald du unten „Panel neu starten“ drückst (oder das Programm neu startest)."
: "This only applies after the whole program restarts (docker compose restart) - the Restart button only restarts the game server."); : "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(", "); const ports = [...new Set([port, newPanel])].map((p) => `"${p}:${p}/tcp"`).join(", ");
lines.push(c.inDocker lines.push(c.inDocker
? (de ? `Docker: trag vorher unter ports in docker-compose.yml ${ports} ein, sonst sperrst du dich aus.` ? (de ? `Docker: trag vorher unter ports in docker-compose.yml ${ports} ein, sonst sperrst du dich aus.`
+14
View File
@@ -281,6 +281,16 @@
"The save game is too large": "Der Spielstand ist zu groß", "The save game is too large": "Der Spielstand ist zu groß",
"Enter a command": "Gib einen Befehl ein", "Enter a command": "Gib einen Befehl ein",
"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",
"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) // Zusammengesetzte Texte (Name, Zahl ... darin)
@@ -330,6 +340,10 @@
[/^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 (.+)$/,
(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"], [/^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"],
]; ];
+5
View File
@@ -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, - 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. Lizenzschlüssel und Spielstand im Panel hinterlegen, Start.
- 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,
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 - 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,6 +33,12 @@ 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;
+62 -8
View File
@@ -19,6 +19,7 @@ public sealed class ServerHost
static readonly TimeSpan CrashWindow = TimeSpan.FromMinutes(10); static readonly TimeSpan CrashWindow = TimeSpan.FromMinutes(10);
static readonly TimeSpan CrashRestartDelay = TimeSpan.FromSeconds(10); static readonly TimeSpan CrashRestartDelay = TimeSpan.FromSeconds(10);
static readonly TimeSpan MasterRetryDelay = TimeSpan.FromSeconds(30); static readonly TimeSpan MasterRetryDelay = TimeSpan.FromSeconds(30);
static readonly TimeSpan LicenseRetryDelay = TimeSpan.FromMinutes(5);
readonly string dataDir, configPath; readonly string dataDir, configPath;
readonly bool offline; readonly bool offline;
@@ -87,6 +88,30 @@ public sealed class ServerHost
return "Stopping the server - the panel keeps running"; return "Stopping the server - the panel keeps running";
} }
/// <summary>server.json für einen Panel-Neustart lesen (Umgebungsvariablen gelten weiter).</summary>
public ServerConfig LoadConfigForRestart()
{
if (offline) return Config;
try
{
var fresh = ServerConfig.LoadOrCreate(configPath, out _);
fresh.ApplyEnvironment();
return fresh;
}
catch { return null; }
}
/// <summary>Panel mit neuen Einstellungen neu starten; geänderter Spiel-Port: Gameserver neu starten bzw. Port neu binden.</summary>
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) public string RequestRestart(string by)
{ {
var server = Server; var server = Server;
@@ -146,6 +171,14 @@ 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 ...");
@@ -220,23 +253,43 @@ public sealed class ServerHost
} }
string owner = "Test"; string owner = "Test";
DateTime confirmed = DateTime.UtcNow;
if (!offline) if (!offline)
{ {
if (string.IsNullOrWhiteSpace(Config.LicenseKey)) 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"); 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} ..."); GameServer.Log($"Checking license key at {MasterServer.Url} ...");
var key = new MasterClient(MasterServer.Url).ValidateKeyAsync(Config.LicenseKey).GetAwaiter().GetResult(); 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");
}
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})"); GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})");
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)}");
}
}
} }
GameServer server; 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"); : "No save game yet - upload one as ZIP in the panel (World & Backups) or copy it to data/save");
server.Panel = Panel; server.Panel = Panel;
server.Host = this; server.Host = this;
server.LicenseConfirmedUtc = confirmed;
ConfigureServer?.Invoke(server); ConfigureServer?.Invoke(server);
if (Config.Port != listenerPort) Listen(Config.Port); // Port in den Einstellungen geändert if (Config.Port != listenerPort) Listen(Config.Port); // Port in den Einstellungen geändert
return server; return server;
+43
View File
@@ -486,6 +486,49 @@ 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
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"); await Call(owner, "POST", "api/logout");
Check((await Call(owner, "GET", "api/status")).status == 401, "Logout ends the session"); Check((await Call(owner, "GET", "api/status")).status == 401, "Logout ends the session");
} }