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:
@@ -18,6 +18,8 @@ config.Save(configPath);
|
||||
var server = new GameServer(config, dir, configPath, "Tester", offline: true);
|
||||
server.SaveUploadTimeoutSeconds = 1; // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten
|
||||
if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; }
|
||||
// Web-Panel auf demselben Port wie das Spiel (Standard)
|
||||
server.Panel = AdminPanel.Start(server, config, dir);
|
||||
var serverThread = new Thread(server.Run) { IsBackground = true };
|
||||
serverThread.Start();
|
||||
Thread.Sleep(500);
|
||||
@@ -289,6 +291,8 @@ try
|
||||
afterReset.Close();
|
||||
inGame.Close();
|
||||
newcomer.Close();
|
||||
|
||||
await PanelTests();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -296,6 +300,145 @@ catch (Exception e)
|
||||
Console.WriteLine(" FAIL exception: " + e);
|
||||
}
|
||||
|
||||
async Task PanelTests()
|
||||
{
|
||||
Console.WriteLine("== Web panel on the game port");
|
||||
Check(server.Panel != null && server.Panel.SharesGamePort, "Panel started and shares the game port");
|
||||
string baseUrl = $"http://127.0.0.1:{config.Port}/";
|
||||
HttpClient Browser()
|
||||
{
|
||||
var handler = new HttpClientHandler { CookieContainer = new System.Net.CookieContainer(), UseCookies = true };
|
||||
var http = new HttpClient(handler) { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(10) };
|
||||
return http;
|
||||
}
|
||||
async Task<(int status, System.Text.Json.JsonElement json)> Call(HttpClient http, string method, string path, object body = null, bool csrf = true)
|
||||
{
|
||||
var req = new HttpRequestMessage(new HttpMethod(method), path);
|
||||
if (csrf) req.Headers.Add("X-MSCO-Panel", "1");
|
||||
if (body != null) req.Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json");
|
||||
var res = await http.SendAsync(req);
|
||||
string text = await res.Content.ReadAsStringAsync();
|
||||
System.Text.Json.JsonElement json = default;
|
||||
try { json = System.Text.Json.JsonDocument.Parse(text).RootElement; } catch { }
|
||||
return ((int)res.StatusCode, json);
|
||||
}
|
||||
|
||||
var owner = Browser();
|
||||
var page = await owner.GetAsync("");
|
||||
Check(page.IsSuccessStatusCode && (await page.Content.ReadAsStringAsync()).Contains("MSCO Server Panel"),
|
||||
"Browser gets the panel page on the game port");
|
||||
Check(page.Headers.Contains("Content-Security-Policy"), "Security headers are set");
|
||||
|
||||
var session = await Call(owner, "GET", "api/session");
|
||||
Check(session.status == 200 && session.json.GetProperty("needsSetup").GetBoolean(), "Fresh panel asks for setup");
|
||||
Check((await Call(owner, "GET", "api/players")).status == 401, "API needs a login");
|
||||
Check((await Call(owner, "POST", "api/setup", new { code = "WRONG-CODE0", username = "owner", password = "supersecret1" })).status == 403,
|
||||
"Wrong setup code is rejected");
|
||||
Check((await Call(owner, "POST", "api/setup", new { code = server.Panel.SetupCode, username = "owner", password = "supersecret1" }, csrf: false)).status == 403,
|
||||
"Requests without the panel header are rejected (CSRF)");
|
||||
var setup = await Call(owner, "POST", "api/setup", new { code = server.Panel.SetupCode, username = "owner", password = "supersecret1" });
|
||||
Check(setup.status == 200 && server.Panel.SetupCode == null, "Setup with the console code creates the owner and logs in");
|
||||
Check(File.Exists(Path.Combine(dir, "panel_users.json")) && !File.ReadAllText(Path.Combine(dir, "panel_users.json")).Contains("supersecret1"),
|
||||
"Users are stored with a password hash, not the password");
|
||||
|
||||
// Spiel läuft auf demselben Port weiter
|
||||
var gamer = await TestClient.Join("PanelTarget", 0, null);
|
||||
Check(gamer.Welcome != null, "Game clients still join on the same port");
|
||||
var players = await Call(owner, "GET", "api/players");
|
||||
bool listed = players.status == 200 && players.json.EnumerateArray().Any(p => p.GetProperty("name").GetString() == "PanelTarget");
|
||||
Check(listed, "Player list shows the connected player with ID");
|
||||
|
||||
var status = await Call(owner, "GET", "api/status");
|
||||
Check(status.status == 200 && status.json.GetProperty("players").GetInt32() >= 1, "Status shows players, uptime, world");
|
||||
|
||||
var cmd = await Call(owner, "POST", "api/console", new { command = "status" });
|
||||
Check(cmd.status == 200 && cmd.json.GetProperty("message").GetString().Contains("players"), "Console command runs and returns its output");
|
||||
var log = await Call(owner, "GET", "api/console?after=0");
|
||||
Check(log.status == 200 && log.json.GetProperty("lines").GetArrayLength() > 0, "Live console log is readable");
|
||||
|
||||
var add = await Call(owner, "POST", "api/users", new { name = "mod1", password = "modpassword", role = "Moderator" });
|
||||
Check(add.status == 200, "Owner adds a moderator");
|
||||
var mod = Browser();
|
||||
Check((await Call(mod, "POST", "api/login", new { username = "mod1", password = "nope-nope" })).status == 401, "Wrong password is rejected");
|
||||
Check((await Call(mod, "POST", "api/login", new { username = "mod1", password = "modpassword" })).status == 200, "Moderator logs in");
|
||||
Check((await Call(mod, "POST", "api/console", new { command = "stop" })).status == 403, "Moderator cannot run console commands");
|
||||
Check((await Call(mod, "POST", "api/world/reset")).status == 403, "Moderator cannot reset the world");
|
||||
Check((await Call(mod, "GET", "api/users")).status == 403, "Moderator cannot manage users");
|
||||
var modPlayers = await Call(mod, "GET", "api/players");
|
||||
Check(modPlayers.status == 200 && modPlayers.json.EnumerateArray().All(p => p.GetProperty("address").ValueKind == System.Text.Json.JsonValueKind.Null),
|
||||
"Moderators do not see IP addresses");
|
||||
|
||||
int targetId = gamer.Id;
|
||||
var kick = await Call(mod, "POST", $"api/players/{targetId}/kick", new { reason = "panel test" });
|
||||
Check(kick.status == 200, "Moderator kicks a player from the panel: " + (kick.json.ValueKind == System.Text.Json.JsonValueKind.Object ? kick.json.ToString() : ""));
|
||||
var kickMsg = await gamer.WaitFor(MsgType.Reject);
|
||||
string kickReason = kickMsg == null ? null : ReadString(kickMsg);
|
||||
Check(kickReason != null && kickReason.Contains("panel test") && kickReason.Contains("mod1"), "The kicked player gets the reason and who kicked: " + kickReason);
|
||||
|
||||
var ban = await Call(owner, "POST", "api/bans/add", new { id = "76561198000000001" });
|
||||
var bans = await Call(owner, "GET", "api/bans");
|
||||
Check(ban.status == 200 && bans.json.EnumerateArray().Any(b => b.GetString() == "76561198000000001"), "Offline SteamID can be banned");
|
||||
await Call(owner, "POST", "api/bans/remove", new { id = "76561198000000001" });
|
||||
|
||||
var backup = await Call(owner, "POST", "api/world/backup");
|
||||
Check(backup.status == 200, "Backup from the panel");
|
||||
Check((await Call(owner, "POST", "api/users/owner/delete")).status == 400, "Owner cannot delete themselves");
|
||||
|
||||
// Zu viele Fehlversuche -> gesperrt
|
||||
var bruteforce = Browser();
|
||||
int lastStatus = 0;
|
||||
for (int i = 0; i < 10; i++) lastStatus = (await Call(bruteforce, "POST", "api/login", new { username = "owner", password = "guess" + i })).status;
|
||||
Check(lastStatus == 429, "Login is rate limited after too many failures");
|
||||
|
||||
// Einstellungen
|
||||
var cfg = await Call(owner, "GET", "api/config");
|
||||
Check(cfg.status == 200 && cfg.json.GetProperty("licenseKeySet").GetBoolean() && !cfg.json.ToString().Contains("\"test\""),
|
||||
"Settings are readable, keys are never sent to the browser");
|
||||
var cfgSave = await Call(owner, "POST", "api/config", new { name = "Renamed Server", maxPlayers = 6 });
|
||||
Check(cfgSave.status == 200 && config.Name == "Renamed Server" && config.MaxPlayers == 6
|
||||
&& File.ReadAllText(configPath).Contains("Renamed Server"), "Settings are changed and saved to server.json");
|
||||
Check((await Call(mod, "POST", "api/config", new { name = "Hacked" })).status == 403, "Moderators cannot change settings");
|
||||
|
||||
// Spiel stoppen / starten
|
||||
var stop = await Call(owner, "POST", "api/server/stop-game");
|
||||
Check(stop.status == 200, "Game server can be stopped from the panel");
|
||||
var whileStopped = await TestClient.Join("WhileStopped", 0, null, expectReject: true);
|
||||
Check(whileStopped.RejectReason != null && whileStopped.RejectReason.Contains("stopped"), "Nobody can join while stopped: " + whileStopped.RejectReason);
|
||||
Check((await Call(owner, "GET", "api/status")).json.GetProperty("gameStopped").GetBoolean(), "Status shows the stopped game server");
|
||||
Check((await Call(owner, "POST", "api/server/start-game")).status == 200, "Game server can be started again");
|
||||
var afterStart = await TestClient.Join("AfterStart", 0, null);
|
||||
Check(afterStart.Welcome != null, "Players can join after start");
|
||||
afterStart.Close();
|
||||
Check((await Call(mod, "POST", "api/server/stop-game")).status == 403, "Moderators cannot stop the server");
|
||||
|
||||
// Spielstand als ZIP
|
||||
byte[] zip;
|
||||
using (var zms = new MemoryStream())
|
||||
{
|
||||
using (var za = new System.IO.Compression.ZipArchive(zms, System.IO.Compression.ZipArchiveMode.Create, true))
|
||||
{
|
||||
using (var w = new StreamWriter(za.CreateEntry("My Summer Car/defaultES2File.txt").Open())) w.Write("ZIPPED SAVE");
|
||||
using (var w = new StreamWriter(za.CreateEntry("../../evil.txt").Open())) w.Write("should be ignored");
|
||||
}
|
||||
zip = zms.ToArray();
|
||||
}
|
||||
var up = new HttpRequestMessage(HttpMethod.Post, "api/save/upload") { Content = new ByteArrayContent(zip) };
|
||||
up.Headers.Add("X-MSCO-Panel", "1");
|
||||
up.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip");
|
||||
var upRes = await owner.SendAsync(up);
|
||||
Check(upRes.IsSuccessStatusCode && File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "ZIPPED SAVE",
|
||||
"A save game ZIP can be uploaded: " + await upRes.Content.ReadAsStringAsync());
|
||||
Check(!File.Exists(Path.Combine(dir, "..", "evil.txt")) && !File.Exists(Path.Combine(dir, "evil.txt")), "Paths inside the ZIP are never used");
|
||||
Check(File.ReadAllText(Path.Combine(dir, "save_original", "defaultES2File.txt")) == "ZIPPED SAVE", "The uploaded save is the new reset point");
|
||||
var down = await owner.GetAsync("api/save/download");
|
||||
byte[] downZip = await down.Content.ReadAsByteArrayAsync();
|
||||
using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip)))
|
||||
Check(down.IsSuccessStatusCode && da.GetEntry("defaultES2File.txt") != null, "The current save can be downloaded as ZIP");
|
||||
|
||||
await Call(owner, "POST", "api/logout");
|
||||
Check((await Call(owner, "GET", "api/status")).status == 401, "Logout ends the session");
|
||||
}
|
||||
|
||||
Console.WriteLine($"\n{passed} passed, {failed} failed");
|
||||
server.EnqueueCommand("stop");
|
||||
Thread.Sleep(300);
|
||||
|
||||
Reference in New Issue
Block a user