456 lines
18 KiB
C#
456 lines
18 KiB
C#
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 { }
|
||
}
|
||
}
|