Web panel (like txAdmin) on the game port: login/roles/users, players, kick/ban/op, bans, live console, world reset/backups/restore, settings editor, save ZIP upload/download, start/stop/restart; in-process restart loop; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 18:18:58 +02:00
co-authored by Claude Opus 5.5
parent b949418476
commit f8aad82d18
15 changed files with 2522 additions and 50 deletions
+421 -8
View File
@@ -30,6 +30,7 @@ public sealed class GameServer
/// <summary>SteamID von Steam bestätigt (Ticket per Web API geprüft). Nur dann gibt es Admin-Rechte.</summary>
public bool SteamVerified;
public bool IsAdmin;
public DateTime JoinedAt;
public DateTime DestroyWindowStart;
public int DestroyCount;
/// <summary>
@@ -51,6 +52,7 @@ public sealed class GameServer
readonly WorldState world;
readonly Dictionary<int, Client> clients = new();
readonly ConcurrentQueue<string> commands = new();
readonly ConcurrentQueue<Action> actions = new(); // vom Web-Panel: laufen in der Server-Schleife
readonly Stopwatch clock = Stopwatch.StartNew();
TcpListener listener;
@@ -187,13 +189,54 @@ public sealed class GameServer
Log("Server stopped.");
}
/// <summary>Web-Panel (gesetzt vom Programm). Teilt es sich den Spiel-Port, werden Browser hier erkannt und weitergereicht.</summary>
public AdminPanel Panel { get; set; }
readonly ConcurrentQueue<TcpClient> sniffedGameClients = new();
void AcceptClients()
{
while (listener.Pending())
{
var c = new Client { Id = nextId++, Conn = new Connection(listener.AcceptTcpClient()), State = ClientState.New };
clients[c.Id] = c;
var tcp = listener.AcceptTcpClient();
if (Panel != null && Panel.SharesGamePort) _ = SniffAsync(tcp);
else AddClient(tcp);
}
while (sniffedGameClients.TryDequeue(out var tcp)) AddClient(tcp);
}
void AddClient(TcpClient tcp)
{
var c = new Client { Id = nextId++, Conn = new Connection(tcp), State = ClientState.New };
clients[c.Id] = c;
}
/// <summary>
/// Gleicher Port für Spiel und Web-Panel: in die ersten Bytes schauen (ohne sie zu verbrauchen). Ein Browser
/// schickt "GET /", "POST" ... - das Spiel beginnt mit einer kleinen Längenangabe (Bytes 2-4 sind 0).
/// Beide Seiten reden zuerst, der Server muss also nichts senden, bevor er es weiß.
/// </summary>
async Task SniffAsync(TcpClient tcp)
{
var buffer = new byte[4];
int n;
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);
else sniffedGameClients.Enqueue(tcp);
}
void PollClients()
@@ -242,7 +285,7 @@ public sealed class GameServer
}
// Regelmäßig den aktuellen Spielstand vom Spiel holen
if (config.SaveSyncMinutes > 0 && now >= nextSaveSync)
if (config.SaveSyncMinutes > 0 && !GameStopped && now >= nextSaveSync)
{
nextSaveSync = now + config.SaveSyncMinutes * 60.0;
RequestSave("regular save");
@@ -267,7 +310,7 @@ public sealed class GameServer
else if (result.Status == 0) Log("Master server not reachable - server is not listed right now.");
else ReportReachability(result);
}
if (!offline && now >= nextHeartbeat && heartbeat == null)
if (!offline && !GameStopped && now >= nextHeartbeat && heartbeat == null)
{
nextHeartbeat = now + HeartbeatSeconds;
heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion);
@@ -480,6 +523,7 @@ public sealed class GameServer
// Andere Version ist erlaubt (der Spieler wurde in der Mod schon gewarnt), nur vermerken
if (version != ModVersion) Log($"{c.Name} uses MSCO {version} (server {ModVersion}) - syncing may not work correctly");
if (config.BannedSteamIds.Contains(c.SteamId)) { Reject(c, "You are banned from this server"); return; }
if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; }
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
c.State = ClientState.Verifying;
@@ -490,6 +534,7 @@ public sealed class GameServer
{
string error = c.Verification.Result;
if (error != null) { Reject(c, error); return; }
if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; }
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
// Nur eine von Steam bestätigte SteamID gilt als Beweis. Ohne SteamWebApiKey kann jeder eine beliebige
@@ -503,6 +548,7 @@ public sealed class GameServer
}
c.State = ClientState.Joined;
c.JoinedAt = DateTime.UtcNow;
var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList();
c.Conn.Send(MsgType.Welcome, Wire.Build(w =>
{
@@ -836,15 +882,24 @@ public sealed class GameServer
void RunCommands()
{
while (commands.TryDequeue(out string line))
while (actions.TryDequeue(out var action))
{
try { action(); }
catch (Exception e) { Log("Panel action failed: " + e.Message); }
}
while (commands.TryDequeue(out string line)) ExecuteCommand(line);
}
void ExecuteCommand(string line)
{
{
// BOM am Zeilenanfang (z.B. bei umgeleiteter Eingabe) ignorieren
string[] args = line.Trim().TrimStart('').Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (args.Length == 0) continue;
if (args.Length == 0) return;
switch (args[0].ToLowerInvariant())
{
case "help":
Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | set-reset-point | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | set-reset-point | panel | stop-game | start-game | restart | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
break;
case "say":
if (args.Length < 2) { Log("say <text>"); break; }
@@ -921,6 +976,10 @@ public sealed class GameServer
case "save":
SaveWorld();
break;
case "panel":
if (Panel != null) Panel.PrintInfo();
else Log("The web panel is disabled (PanelEnabled in server.json)");
break;
case "reset-world":
ResetWorld("Console");
break;
@@ -954,6 +1013,17 @@ public sealed class GameServer
case "exit":
running = false;
break;
case "restart":
Log("Restarting (settings, license and save game are loaded again) ...");
RestartRequested = true;
running = false;
break;
case "stop-game":
Log(StopGame("Console"));
break;
case "start-game":
Log(StartGame("Console"));
break;
default:
Log("Unknown command, type 'help'");
break;
@@ -993,5 +1063,348 @@ public sealed class GameServer
}
}
public static void Log(string text) => Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {text}");
public static void Log(string text)
{
string time = DateTime.Now.ToString("HH:mm:ss");
Console.WriteLine($"[{time}] {text}");
LogBuffer.Add(time, text);
}
// ---------------------------------------------------------------- Web-Panel
// Das Panel läuft auf eigenen Threads. Alles, was den Server-Zustand liest oder ändert, wird hier in die
// Server-Schleife gegeben (höchstens ein paar Millisekunden Wartezeit) - so gibt es keine Überschneidungen.
/// <summary>Funktion in der Server-Schleife ausführen und das Ergebnis zurückgeben.</summary>
public Task<T> Invoke<T>(Func<T> func)
{
var done = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
actions.Enqueue(() =>
{
try { done.SetResult(func()); }
catch (Exception e) { done.SetException(e); }
});
return done.Task;
}
/// <summary>Konsolenbefehl ausführen und alles zurückgeben, was er ins Log schreibt.</summary>
public string RunCommandCaptured(string line, string by)
{
Log($"> {line} ({by})");
long from = LogBuffer.LastSeq;
ExecuteCommand(line);
return string.Join("\n", LogBuffer.Since(from, 500).Select(l => l.Text));
}
public object PanelStatus()
{
string resetPoint = File.Exists(Path.Combine(originalPath, "defaultES2File.txt"))
? File.GetLastWriteTimeUtc(Path.Combine(originalPath, "defaultES2File.txt")).ToString("o") : null;
return new
{
name = config.Name,
description = config.Description,
version = ModVersion,
players = JoinedCount,
maxPlayers = config.MaxPlayers,
gameStopped = GameStopped,
uptimeSeconds = (long)clock.Elapsed.TotalSeconds,
port = config.Port,
isPublic = config.Public,
requireSteam = config.RequireSteam,
steamVerification = steam.Enabled,
objects = world.Objects.Count,
actions = world.Events.Count,
boughtItems = world.Appeared.Count,
admins = config.Admins.Count,
bans = config.BannedSteamIds.Count,
backups = SaveBackups().Count,
resetPoint,
saveSyncMinutes = config.SaveSyncMinutes
};
}
public object PanelPlayers(bool withAddress) =>
clients.Values.Where(c => c.State == ClientState.Joined && !c.Conn.IsClosing).OrderBy(c => c.Id).Select(c => new
{
id = c.Id,
name = c.Name,
steamId = c.SteamId,
admin = c.IsAdmin,
verified = c.SteamVerified,
address = withAddress ? c.Conn.RemoteAddress : null,
joinedAt = c.JoinedAt.ToString("o")
}).ToList();
public object PanelBans() => config.BannedSteamIds.ToList();
// ---------------------------------------------------------------- Start / Stopp / Neustart
/// <summary>Spiel offline (Panel läuft weiter): Spieler raus, nicht mehr gelistet, niemand kann beitreten.</summary>
public bool GameStopped { get; private set; }
/// <summary>Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden).</summary>
public bool RestartRequested { get; private set; }
public string StopGame(string by)
{
if (GameStopped) return "The game server is already stopped";
foreach (var c in clients.Values.Where(x => x.State == ClientState.Joined).ToList())
Reject(c, $"The server was stopped by {by}");
GameStopped = true;
saveUpload = null;
waitingForSave.Clear();
SaveWorld();
Log($"Game server stopped by {by} - players cannot join until it is started again (the panel keeps running)");
return "Game server stopped";
}
public string StartGame(string by)
{
if (!GameStopped) return "The game server is already running";
if (!LoadSave()) return "Could not load the save game - see the console";
GameStopped = false;
HeartbeatSoon();
Log($"Game server started by {by}");
return "Game server started";
}
public string PanelServerAction(string action, string by)
{
switch (action)
{
case "stop-game": return StopGame(by);
case "start-game": return StartGame(by);
case "restart":
Log($"Restart requested by {by} ...");
RestartRequested = true;
running = false;
return "Restarting - the panel is back in a few seconds";
case "shutdown":
Log($"Shutdown requested by {by} - the server and the panel stop");
running = false;
return "Shutting down";
default: return "Unknown action";
}
}
// ---------------------------------------------------------------- Einstellungen (server.json) im Panel
public object PanelConfig() => new
{
name = config.Name,
description = config.Description,
maxPlayers = config.MaxPlayers,
isPublic = config.Public,
requireSteam = config.RequireSteam,
publicAddress = config.PublicAddress,
saveSyncMinutes = config.SaveSyncMinutes,
autosaveSeconds = config.AutosaveSeconds,
port = config.Port,
panelEnabled = config.PanelEnabled,
panelPort = config.PanelPort,
panelBindAddress = config.PanelBindAddress,
panelTrustProxy = config.PanelTrustProxy,
panelBasePath = config.PanelBasePath,
licenseKeySet = !string.IsNullOrWhiteSpace(config.LicenseKey),
steamWebApiKeySet = !string.IsNullOrWhiteSpace(config.SteamWebApiKey),
environmentOverrides = config.EnvironmentOverrides
};
/// <summary>Einstellungen ändern. Schlüssel (Lizenz, Steam Web API) nur als Owner; leer = unverändert.</summary>
public string PanelUpdateConfig(System.Text.Json.JsonElement b, bool owner, string by)
{
if (b.ValueKind != System.Text.Json.JsonValueKind.Object) return "Invalid settings";
var changed = new List<string>();
bool restartNeeded = false;
string Text(string name, string current, int max)
{
if (!b.TryGetProperty(name, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String) return current;
string s = v.GetString().Trim();
if (s.Length > max) s = s[..max];
if (s != current) changed.Add(name);
return s;
}
int Number(string name, int current, int min, int max)
{
if (!b.TryGetProperty(name, out var v) || !v.TryGetInt32(out int n)) return current;
n = Math.Clamp(n, min, max);
if (n != current) changed.Add(name);
return n;
}
bool Flag(string name, bool current)
{
if (!b.TryGetProperty(name, out var v) || (v.ValueKind != System.Text.Json.JsonValueKind.True && v.ValueKind != System.Text.Json.JsonValueKind.False)) return current;
bool f = v.GetBoolean();
if (f != current) changed.Add(name);
return f;
}
string name = Text("name", config.Name, 60);
if (name.Length == 0) return "The server name cannot be empty";
config.Name = name;
config.Description = Text("description", config.Description, 300);
config.MaxPlayers = Number("maxPlayers", config.MaxPlayers, 1, 64);
config.Public = Flag("isPublic", config.Public);
config.RequireSteam = Flag("requireSteam", config.RequireSteam);
config.PublicAddress = Text("publicAddress", config.PublicAddress, 200);
config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240);
config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600);
int before = changed.Count;
config.Port = Number("port", config.Port, 1, 65535);
config.PanelEnabled = Flag("panelEnabled", config.PanelEnabled);
config.PanelPort = Number("panelPort", config.PanelPort, 0, 65535);
config.PanelBindAddress = Text("panelBindAddress", config.PanelBindAddress, 60);
config.PanelTrustProxy = Flag("panelTrustProxy", config.PanelTrustProxy);
config.PanelBasePath = Text("panelBasePath", config.PanelBasePath, 60);
if (changed.Count > before) restartNeeded = true;
foreach (string secret in new[] { "licenseKey", "steamWebApiKey" })
{
if (!b.TryGetProperty(secret, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String || v.GetString().Trim().Length == 0) continue;
if (!owner) return "Only the owner can change keys";
string value = v.GetString().Trim();
if (value.Length > 200) return "Key too long";
if (secret == "licenseKey") config.LicenseKey = value; else config.SteamWebApiKey = value;
changed.Add(secret);
restartNeeded = true;
}
if (changed.Count == 0) return "Nothing changed";
try { config.Save(configPath); }
catch (Exception e) { return "Could not save server.json: " + e.Message; }
HeartbeatSoon(); // Name/Beschreibung/Spielerzahl gleich an die Serverliste
Log($"Settings changed by {by}: {string.Join(", ", changed)}");
var overridden = changed.Where(c => config.EnvironmentOverrides.Any(e => e.Replace("_", "").Contains(c.ToUpperInvariant().Replace("ISPUBLIC", "PUBLIC")))).ToList();
return "Settings saved" + (restartNeeded ? " - some changes need a restart" : "") +
(overridden.Count > 0 ? " (note: " + string.Join(", ", overridden) + " is also set by an environment variable, which wins on restart)" : "");
}
// ---------------------------------------------------------------- Spielstand als ZIP hoch-/herunterladen
const long MaxSaveZip = 64L * 1024 * 1024;
/// <summary>
/// Spielstand aus einer ZIP übernehmen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem
/// Ordner in der ZIP) - Pfade aus der ZIP werden nie benutzt. Danach: Backup, laden, neuer Reset-Punkt, Welt leer,
/// alle verbinden neu.
/// </summary>
public string ImportSaveZip(byte[] zip, string by)
{
var found = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
try
{
using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(zip), System.IO.Compression.ZipArchiveMode.Read);
long total = 0;
foreach (var entry in archive.Entries)
{
string file = Path.GetFileName(entry.FullName.Replace('\\', '/'));
string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase));
if (known == null) continue;
if (entry.Length > MaxSaveZip || (total += entry.Length) > MaxSaveZip) return "The save game is too large";
using var s = entry.Open();
using var ms = new MemoryStream();
s.CopyTo(ms);
found[known] = ms.ToArray();
}
}
catch (InvalidDataException) { return "This is not a valid ZIP file"; }
if (!found.ContainsKey("defaultES2File.txt"))
return "No defaultES2File.txt in the ZIP - zip the files from %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car";
try
{
BackupSave();
Directory.CreateDirectory(savePath);
foreach (string name in SaveFiles)
{
string target = Path.Combine(savePath, name);
if (found.TryGetValue(name, out var data))
{
File.WriteAllBytes(target + ".tmp", data);
File.Move(target + ".tmp", target, true);
}
else if (File.Exists(target)) File.Delete(target); // gehört zum alten Spielstand
}
}
catch (Exception e) { return "Could not write the save game: " + e.Message; }
if (!LoadSave()) return "The uploaded save game could not be loaded - see the console";
SetResetPoint();
saveUpload = null;
waitingForSave.Clear();
world.Reset();
SaveWorld();
Log($"New save game uploaded by {by} ({found.Count} files) - it is also the new reset point");
ReconnectAll($"{by} uploaded a new save game - reconnecting...");
return $"Save game uploaded ({found.Count} files) - everyone reconnects";
}
/// <summary>Aktuellen Spielstand als ZIP.</summary>
public byte[] ExportSaveZip()
{
using var ms = new MemoryStream();
using (var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, true))
{
foreach (string name in SaveFiles)
{
string file = Path.Combine(savePath, name);
if (File.Exists(file)) System.IO.Compression.ZipFileExtensions.CreateEntryFromFile(archive, file, name);
}
}
return ms.ToArray();
}
public object PanelBackups() => SaveBackups().Select(d => Path.GetFileName(d)).ToList();
public string PanelKick(int id, bool ban, string reason, string by)
{
if (!clients.TryGetValue(id, out var target) || target.State != ClientState.Joined) return "Player not found";
return KickOrBan(target, ban, reason, by);
}
/// <summary>SteamID sperren, auch wenn der Spieler gerade nicht online ist.</summary>
public string PanelBanId(string steamId, string reason, string by)
{
steamId = (steamId ?? "").Trim();
bool valid = (steamId.Length == 17 && steamId.All(char.IsDigit)) || (steamId.StartsWith("name:") && steamId.Length > 5 && steamId.Length < 60);
if (!valid) return "Enter a SteamID64 (17 digits) or name:playername";
var online = clients.Values.FirstOrDefault(c => c.State == ClientState.Joined && c.SteamId == steamId);
if (online != null) return KickOrBan(online, true, reason, by);
if (config.BannedSteamIds.Contains(steamId)) return steamId + " is already banned";
config.BannedSteamIds.Add(steamId);
config.Save(configPath);
Log($"{steamId} was banned by {by}");
return steamId + " is banned";
}
public string PanelUnban(string id, string by) => Unban(id, by);
public string PanelSay(string text, string by)
{
text = (text ?? "").Trim();
if (text.Length == 0) return "Empty message";
if (text.Length > 200) text = text[..200];
ServerChat(text);
Log($"[chat] Server ({by}): {text}");
return "Sent";
}
public string PanelSetAdmin(int id, bool admin, string by) => RunCommandCaptured((admin ? "op " : "deop ") + id, by);
public string PanelWorld(string action, string arg, string by)
{
switch (action)
{
case "save": SaveWorld(); return "World state saved";
case "backup": return BackupNow(by);
case "reset": return ResetWorld(by);
case "restore": return RestoreSave(arg ?? "", by);
case "resetpoint":
if (!SetResetPoint()) return "No save game to use as reset point";
Log($"{by} set the current save game as the reset point");
return "The current save game is now the reset point";
case "reloadsave": return RunCommandCaptured("reload-save", by);
default: return "Unknown action";
}
}
}