Panel independent of the game server: ServerHost owns port + panel and starts/stops the game server; panel works while stopped/crashed/unconfigured (settings, save ZIP upload, backups, bans offline); crash auto-restart; players told 'offline'; tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+401
@@ -0,0 +1,401 @@
|
||||
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 (wie txAdmin): 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);
|
||||
|
||||
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; }
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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";
|
||||
}
|
||||
|
||||
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>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)");
|
||||
|
||||
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 0;
|
||||
}
|
||||
|
||||
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":
|
||||
if (Panel != null) Panel.PrintInfo(); else GameServer.Log("The web panel is disabled");
|
||||
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";
|
||||
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)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
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) or copy it to data/save");
|
||||
server.Panel = Panel;
|
||||
server.Host = this;
|
||||
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),
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user