using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
namespace MSCO.Server;
/// Zustand des Gameservers aus Sicht des Hosts.
public enum HostState { Stopped, Starting, Running, Error }
///
/// 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".
///
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 commands = new();
readonly AutoResetEvent wake = new(false);
readonly List 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; }
/// Server-Updates (Prüfen/Installieren im Panel).
public Updater Updater { get; }
/// Rückgabewert von Run - Updater.RestartExitCode nach einem installierten Update.
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;
/// Für Tests: Einstellungen am frisch erzeugten Server vornehmen (z.B. kürzere Zeitlimits).
public Action 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)
/// Konsolenzeile: bei laufendem Server an ihn, sonst Host-Befehle (start, exit, status, panel).
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";
}
/// server.json für einen Panel-Neustart lesen (Umgebungsvariablen gelten weiter).
public ServerConfig LoadConfigForRestart()
{
if (offline) return Config;
try
{
var fresh = ServerConfig.LoadOrCreate(configPath, out _);
fresh.ApplyEnvironment();
return fresh;
}
catch { return null; }
}
/// Panel mit neuen Einstellungen neu starten; geänderter Spiel-Port: Gameserver neu starten bzw. Port neu binden.
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";
}
/// Update installiert: Weltzustand speichern, beenden und neu starten (Docker/systemd bzw. selbst).
public void ExitForUpdate()
{
ExitCode = Updater.RestartExitCode;
RequestExit();
}
/// Web-Panel ausschalten (Einstellung abgehakt + "Panel neu starten" oder Konsole "panel off").
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;
}
/// Konsole "panel on|off": Einstellung speichern und Panel starten bzw. stoppen.
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";
}
/// Ganzes Programm beenden (Strg+C, docker stop, "exit").
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;
}
/// Kurzstatus fürs Panel, wenn der Server nicht läuft (gleiche Felder wie GameServer.PanelStatus).
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);
}
}
///
/// 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.
///
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) break;
// TLS (HTTPS zum Panel) beginnt mit 0x16 0x03 - auf die ersten Bytes warten; sonst kein Buchstabe = Spiel
if (buffer[0] != 0x16 && (buffer[0] < 'A' || buffer[0] > 'Z')) break;
await Task.Delay(15, timeout.Token);
}
}
catch
{
try { tcp.Close(); } catch { }
return;
}
if (AdminPanel.LooksLikeHttp(buffer, n))
{
await Panel.RelayAsync(tcp);
return;
}
if (PanelTls.LooksLikeTls(buffer, n))
{
await Panel.RelayAsync(tcp, tls: true);
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 { }
}
}