Files
MSCO-Dedicated-Server/ServerHost.cs
T
MrMahoandClaude Opus 5.5 835d3ce998 Panel: unticking 'web panel enabled' + restart panel really turns it off (2.9.5)
The restart used to refuse when the panel was disabled, so it stayed
reachable. Now it stops the panel (also the relay on the game port);
'panel on|off' in the console turns it on/off and saves server.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:07:18 +02:00

506 lines
20 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
namespace MSCO.Server;
/// <summary>Zustand des Gameservers aus Sicht des Hosts.</summary>
public enum HostState { Stopped, Starting, Running, Error }
/// <summary>
/// Host: läuft die ganze Zeit, besitzt den Port und das Web-Panel und startet/stoppt den Gameserver.
/// Ist der Gameserver aus (gestoppt, abgestürzt, fehlender Lizenzschlüssel oder Spielstand ...), bleibt das Panel
/// erreichbar - dort lässt sich alles einrichten und der Server wieder starten.
/// Verbindungen auf dem Spiel-Port: Browser -> Panel, Spiel -> laufender Server bzw. "gerade offline".
/// </summary>
public sealed class ServerHost
{
const int MaxCrashRestarts = 3;
static readonly TimeSpan CrashWindow = TimeSpan.FromMinutes(10);
static readonly TimeSpan CrashRestartDelay = TimeSpan.FromSeconds(10);
static readonly TimeSpan MasterRetryDelay = TimeSpan.FromSeconds(30);
static readonly TimeSpan LicenseRetryDelay = TimeSpan.FromMinutes(5);
readonly string dataDir, configPath;
readonly bool offline;
readonly ConcurrentQueue<string> commands = new();
readonly AutoResetEvent wake = new(false);
readonly List<DateTime> crashes = new();
TcpListener listener;
int listenerPort;
volatile bool exitRequested;
volatile bool startRequested = true; // beim Programmstart automatisch starten
DateTime startAt = DateTime.MinValue;
public ServerConfig Config { get; private set; }
public GameServer Server { get; private set; }
public AdminPanel Panel { get; private set; }
/// <summary>Server-Updates (Prüfen/Installieren im Panel).</summary>
public Updater Updater { get; }
/// <summary>Rückgabewert von Run - Updater.RestartExitCode nach einem installierten Update.</summary>
public int ExitCode { get; private set; }
public HostState State { get; private set; } = HostState.Stopped;
public string LastError { get; private set; }
public DateTime StateSince { get; private set; } = DateTime.UtcNow;
public string DataDir => dataDir;
public string ConfigPath => configPath;
/// <summary>Für Tests: Einstellungen am frisch erzeugten Server vornehmen (z.B. kürzere Zeitlimits).</summary>
public Action<GameServer> ConfigureServer { get; set; }
public ServerHost(ServerConfig config, string dataDir, string configPath, bool offline = false)
{
Config = config;
this.dataDir = dataDir;
this.configPath = configPath;
this.offline = offline;
Updater = new Updater(this, dataDir);
}
// ---------------------------------------------------------------- Steuerung (Konsole, Panel, Signale)
/// <summary>Konsolenzeile: bei laufendem Server an ihn, sonst Host-Befehle (start, exit, status, panel).</summary>
public void Command(string line)
{
string first = line.Trim().TrimStart('').Split(' ')[0].ToLowerInvariant();
if (first == "exit" || first == "quit") { RequestExit(); return; }
var server = Server;
if (server != null && State == HostState.Running) { server.EnqueueCommand(line); return; }
commands.Enqueue(line);
wake.Set();
}
public string RequestStart(string by)
{
if (State == HostState.Running || State == HostState.Starting) return "The server is already " + State.ToString().ToLower();
GameServer.Log($"Start requested by {by}");
crashes.Clear();
startAt = DateTime.MinValue;
startRequested = true;
wake.Set();
return "Starting the server";
}
public string RequestStop(string by)
{
var server = Server;
if (server == null || State != HostState.Running)
{
startRequested = false; // z.B. geplanter Neustart nach Absturz
return "The server is not running";
}
server.Invoke(() => server.PanelServerAction("stop", by));
return "Stopping the server - the panel keeps running";
}
/// <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;
if (!fresh.PanelEnabled) { StopPanel(by); return; } // "Web-Panel an" abgehakt: wirklich ausschalten
if (Panel == null) StartPanel();
else 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;
if (server == null || State != HostState.Running) return RequestStart(by);
server.Invoke(() => server.PanelServerAction("restart", by));
return "Restarting - the server is back in a few seconds";
}
/// <summary>Update installiert: Weltzustand speichern, beenden und neu starten (Docker/systemd bzw. selbst).</summary>
public void ExitForUpdate()
{
ExitCode = Updater.RestartExitCode;
RequestExit();
}
/// <summary>Web-Panel ausschalten (Einstellung abgehakt + "Panel neu starten" oder Konsole "panel off").</summary>
public void StopPanel(string by)
{
if (Panel == null) return;
GameServer.Log($"Web panel turned off by {by} - turn it on again with 'panel on' in the console or PanelEnabled in server.json");
var panel = Panel;
Panel = null;
if (Server != null) Server.Panel = null;
try { panel.Stop(); } catch { }
}
void StartPanel()
{
Panel = AdminPanel.Start(this, Config, dataDir);
if (Server != null) Server.Panel = Panel;
}
/// <summary>Konsole "panel on|off": Einstellung speichern und Panel starten bzw. stoppen.</summary>
public string SetPanelEnabled(bool enabled, string by)
{
if (!offline)
{
try
{
var fresh = ServerConfig.LoadOrCreate(configPath, out _);
fresh.PanelEnabled = enabled;
fresh.Save(configPath);
}
catch (Exception e) { return "Could not save server.json: " + e.Message; }
}
Config.PanelEnabled = enabled;
if (!enabled) { StopPanel(by); return "Web panel turned off"; }
if (Panel == null) StartPanel();
Panel?.PrintInfo();
return "Web panel turned on";
}
/// <summary>Ganzes Programm beenden (Strg+C, docker stop, "exit").</summary>
public void RequestExit()
{
exitRequested = true;
Server?.RequestExit();
wake.Set();
}
// ---------------------------------------------------------------- Hauptschleife
public int Run()
{
if (Config.PanelEnabled) Panel = AdminPanel.Start(this, Config, dataDir);
if (!Listen(Config.Port)) return 1;
if (Panel == null)
GameServer.Log("Web panel is disabled (PanelEnabled in server.json)");
if (!offline) Updater.StartBackground();
while (!exitRequested)
{
if (!startRequested || DateTime.UtcNow < startAt)
{
HandleHostCommands();
wake.WaitOne(200);
continue;
}
startRequested = false;
var server = TryCreateServer();
if (server == null)
{
// Ohne Panel gibt es niemanden, der es reparieren könnte: dann wie früher beenden
if (Panel == null) return 1;
continue;
}
Server = server;
SetState(HostState.Running, null);
try
{
server.Run();
}
catch (Exception e)
{
GameServer.Log("THE SERVER CRASHED: " + e);
OnCrash(e.Message);
Server = null;
continue;
}
Server = null;
if (server.ExitRequested) break;
if (server.RestartRequested)
{
GameServer.Log("Restarting the server ...");
startRequested = true;
continue;
}
SetState(HostState.Stopped, null);
if (Panel == null) break; // ohne Panel: Stopp = Programmende
GameServer.Log("The game server is stopped. The web panel keeps running - start it there or type 'start' ('exit' to quit).");
}
try { listener?.Stop(); } catch { }
Panel?.Stop();
GameServer.Log("Bye.");
return ExitCode;
}
void HandleHostCommands()
{
while (commands.TryDequeue(out string line))
{
string cmd = line.Trim().TrimStart('');
if (cmd.Length == 0) continue;
var server = Server;
if (server != null && State == HostState.Running)
{
server.EnqueueCommand(cmd);
continue;
}
string first = cmd.Split(' ')[0].ToLowerInvariant();
switch (first)
{
case "start":
case "restart":
GameServer.Log(RequestStart("Console"));
break;
case "exit":
case "quit":
RequestExit();
break;
case "stop":
startRequested = false;
GameServer.Log("The server is not running ('exit' quits the program)");
break;
case "panel":
{
string arg = cmd.Split(' ').Length > 1 ? cmd.Split(' ')[1].ToLowerInvariant() : "";
if (arg == "on" || arg == "off") GameServer.Log(SetPanelEnabled(arg == "on", "Console"));
else if (Panel != null) Panel.PrintInfo();
else GameServer.Log("The web panel is disabled - 'panel on' turns it on");
break;
}
case "status":
GameServer.Log($"Server {State.ToString().ToLower()}" + (LastError != null ? ": " + LastError : ""));
break;
default:
GameServer.Log("The game server is " + State.ToString().ToLower() + " - type 'start' (or 'exit' to quit)");
break;
}
}
}
GameServer TryCreateServer()
{
SetState(HostState.Starting, null);
if (!offline)
{
// Einstellungen neu lesen (im Panel geändert), Umgebungsvariablen gelten weiter
try
{
var fresh = ServerConfig.LoadOrCreate(configPath, out _);
fresh.ApplyEnvironment();
Config = fresh;
Panel?.UpdateConfig(fresh);
}
catch (Exception e) { GameServer.Log("Cannot read " + configPath + " (" + e.Message + ") - using the previous settings"); }
}
string owner = "Test";
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.Valid)
{
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 = LicenseCache.Grace;
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 until the next restart; a restart works for {LicenseCache.Span(left)} more.");
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;
try
{
server = new GameServer(Config, dataDir, configPath, owner, offline);
}
catch (Exception e) { return Fail("Could not create the server: " + e.Message); }
if (!server.LoadSave())
return Fail(SaveStore.HasSave(dataDir)
? "The save game could not be loaded - see the console"
: "No save game yet - upload one as ZIP in the panel (World & Backups), start a new game there 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;
}
GameServer Fail(string reason)
{
GameServer.Log("Server not started: " + reason);
SetState(HostState.Error, reason);
return null;
}
void OnCrash(string message)
{
SetState(HostState.Error, "Crashed: " + message);
crashes.Add(DateTime.UtcNow);
crashes.RemoveAll(t => DateTime.UtcNow - t > CrashWindow);
if (crashes.Count > MaxCrashRestarts)
{
GameServer.Log($"The server crashed {crashes.Count} times in {CrashWindow.TotalMinutes:0} minutes - not restarting automatically. Start it in the panel.");
return;
}
GameServer.Log($"Restarting in {CrashRestartDelay.TotalSeconds:0} s ...");
startRequested = true;
startAt = DateTime.UtcNow + CrashRestartDelay;
}
void SetState(HostState state, string error)
{
State = state;
LastError = error;
StateSince = DateTime.UtcNow;
}
/// <summary>Kurzstatus fürs Panel, wenn der Server nicht läuft (gleiche Felder wie GameServer.PanelStatus).</summary>
public object OfflineStatus() => new
{
state = State.ToString().ToLowerInvariant(),
lastError = LastError,
stateSince = StateSince.ToString("o"),
name = Config.Name,
description = Config.Description,
version = GameServer.ModVersion,
players = 0,
maxPlayers = Config.MaxPlayers,
uptimeSeconds = 0L,
port = Config.Port,
isPublic = Config.Public,
requireSteam = Config.RequireSteam,
steamVerification = !string.IsNullOrWhiteSpace(Config.SteamWebApiKey),
objects = 0,
actions = 0,
boughtItems = 0,
admins = Config.Admins.Count,
bans = Config.BannedSteamIds.Count,
backups = SaveStore.BackupDirs(dataDir).Count,
resetPoint = SaveStore.HasResetPoint(dataDir)
? File.GetLastWriteTimeUtc(Path.Combine(SaveStore.OriginalPath(dataDir), "defaultES2File.txt")).ToString("o") : null,
saveSyncMinutes = Config.SaveSyncMinutes,
hasSave = SaveStore.HasSave(dataDir) || SaveStore.IsNewGame(dataDir),
newGame = SaveStore.IsNewGame(dataDir),
hasLicenseKey = !string.IsNullOrWhiteSpace(Config.LicenseKey)
};
// ---------------------------------------------------------------- Port
bool Listen(int port)
{
try
{
var next = new TcpListener(IPAddress.Any, port);
next.Start();
try { listener?.Stop(); } catch { }
listener = next;
listenerPort = port;
GameServer.Log($"Listening on port {port}" + (Panel != null && Panel.SharesGamePort ? " (game and web panel)" : ""));
_ = AcceptLoopAsync(next);
return true;
}
catch (Exception e)
{
GameServer.Log($"Cannot listen on port {port}: {e.Message}");
return false;
}
}
async Task AcceptLoopAsync(TcpListener own)
{
while (true)
{
TcpClient tcp;
try { tcp = await own.AcceptTcpClientAsync(); }
catch { return; } // Listener gestoppt (Programmende oder Portwechsel)
_ = HandleConnectionAsync(tcp);
}
}
/// <summary>
/// In die ersten Bytes schauen (ohne sie zu verbrauchen): Browser ("GET /", "POST" ...) -> Panel,
/// sonst Spiel (Pakete beginnen mit einer kleinen Längenangabe). Beide Seiten reden zuerst.
/// </summary>
async Task HandleConnectionAsync(TcpClient tcp)
{
var buffer = new byte[4];
int n = 0;
if (Panel != null && Panel.SharesGamePort)
{
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
while (true)
{
n = await tcp.Client.ReceiveAsync(buffer, SocketFlags.Peek, timeout.Token);
if (n == 0) { tcp.Close(); return; }
if (n >= 4 || buffer[0] < 'A' || buffer[0] > 'Z') break; // kein Buchstabe = sicher das Spiel
await Task.Delay(15, timeout.Token);
}
}
catch
{
try { tcp.Close(); } catch { }
return;
}
if (AdminPanel.LooksLikeHttp(buffer, n))
{
await Panel.RelayAsync(tcp);
return;
}
}
var server = Server;
if (server != null && State == HostState.Running)
{
server.AcceptExternal(tcp);
return;
}
// Server aus: dem Spiel sagen, warum (statt einer toten Verbindung)
try
{
tcp.NoDelay = true;
var frame = Wire.Frame(MsgType.Reject, Wire.Build(w => w.Write("The server is offline at the moment - try again later")));
await tcp.GetStream().WriteAsync(frame);
tcp.Client.Shutdown(SocketShutdown.Send);
await Task.Delay(1000);
}
catch { }
try { tcp.Close(); } catch { }
}
}