Files
MSCO-Dedicated-Server/ServerHost.cs
T

456 lines
18 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; }
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";
}
/// <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;
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.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 ...");
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";
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 = 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;
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;
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),
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 { }
}
}