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:
+47
-1
@@ -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://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
|
||||
else
|
||||
@@ -165,6 +165,30 @@ public sealed class AdminPanel
|
||||
/// <summary>Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu).</summary>
|
||||
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()
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
static string By(PanelUsers.User user) => user.Name + " (panel)";
|
||||
|
||||
@@ -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;
|
||||
|
||||
+55
-7
@@ -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
|
||||
|
||||
/// <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()
|
||||
{
|
||||
ExitRequested = true;
|
||||
|
||||
@@ -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
@@ -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.`
|
||||
|
||||
@@ -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"],
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ public sealed class ServerConfig
|
||||
/// <summary>Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen.</summary>
|
||||
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
|
||||
/// <summary>Web-Panel einschalten.</summary>
|
||||
public bool PanelEnabled { get; set; } = true;
|
||||
|
||||
+62
-8
@@ -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";
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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");
|
||||
}
|
||||
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;
|
||||
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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user