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 (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". /// 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 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; } 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; } // ---------------------------------------------------------------- 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"; } 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"; } /// 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)"); 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; } /// 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), 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 || 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 { } } }