From bb3efb8cc3cf945329faa71f9fa627828febe6cd Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sat, 3 Oct 2026 11:11:14 +0200 Subject: [PATCH] Server 2.10.1: remote management from the MSCO Launcher - Polls the keymaster every 5 s for commands from the owner's launcher, runs only allowed commands (say, kick, ban, save, backup, restart, mods reload, status, players), sends results and the player list back - RemoteManagement in server.json / web panel settings (on by default) to turn it off Co-Authored-By: Claude Opus 5.5 --- ConfigEditor.cs | 2 ++ GameServer.cs | 35 +++++++++++++++++++++++++++++++++++ MSCO.Server.csproj | 2 +- MasterClient.cs | 19 +++++++++++++++++++ Panel/app.js | 4 +++- Panel/i18n.js | 5 +++++ README.md | 9 +++++++++ ServerConfig.cs | 5 +++++ 8 files changed, 79 insertions(+), 2 deletions(-) diff --git a/ConfigEditor.cs b/ConfigEditor.cs index 91b3c0a..43c1fc5 100644 --- a/ConfigEditor.cs +++ b/ConfigEditor.cs @@ -17,6 +17,7 @@ public static class ConfigEditor publicAddress = config.PublicAddress, saveSyncMinutes = config.SaveSyncMinutes, autosaveSeconds = config.AutosaveSeconds, + remoteManagement = config.RemoteManagement, port = config.Port, panelEnabled = config.PanelEnabled, panelPort = config.PanelPort, @@ -77,6 +78,7 @@ public static class ConfigEditor config.PublicAddress = Text("publicAddress", config.PublicAddress, 200); config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240); config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600); + config.RemoteManagement = Flag("remoteManagement", config.RemoteManagement); int before = changed.Count; int oldPort = config.Port, oldPanelPort = config.PanelPort; diff --git a/GameServer.cs b/GameServer.cs index 9895517..be556c3 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -317,10 +317,45 @@ public sealed class GameServer /// Spielerzahl hat sich geändert: gleich beim Master melden statt erst in 30 s. void HeartbeatSoon() => nextHeartbeat = Math.Min(nextHeartbeat, clock.Elapsed.TotalSeconds + 1); + // ---------------------------------------------------------------- Verwalten aus dem Launcher (über den Keymaster) + const double RemotePollSeconds = 5; + double nextRemotePoll; + Task> remotePoll; + readonly List remoteResults = new(); + /// Befehle, die der Besitzer aus dem Launcher schicken darf (gleiche Liste wie im Keymaster). + static readonly System.Text.RegularExpressions.Regex RemoteAllowed = + new(@"^(say .{1,150}|kick \d{1,6}( .{0,100})?|ban \d{1,6}( .{0,100})?|save|backup|restart|mods reload|status|players)$"); + + void RemoteManagementTick(double now) + { + if (offline || !config.RemoteManagement) return; + if (remotePoll != null) + { + if (!remotePoll.IsCompleted) return; + var commands = remotePoll.IsCompletedSuccessfully ? remotePoll.Result : null; + remotePoll = null; + if (commands == null) return; // Master nicht erreichbar: Ergebnisse beim nächsten Mal nochmal schicken + remoteResults.Clear(); + foreach (var cmd in commands.Take(10)) + { + string line = (cmd.Command ?? "").Trim(); + string by = "Launcher (" + (cmd.By ?? "owner") + ")"; + string output = RemoteAllowed.IsMatch(line) ? RunCommandCaptured(line, by) : "Not allowed on this server"; + remoteResults.Add(new MasterClient.RemoteResult(cmd.Id, output.Length > 2000 ? output[..2000] : output)); + } + if (remoteResults.Count > 0) nextRemotePoll = 0; // Ergebnisse gleich abliefern + } + if (now < nextRemotePoll) return; + nextRemotePoll = now + RemotePollSeconds; + var players = clients.Values.Where(c => c.State == ClientState.Joined).Select(c => new MasterClient.RemotePlayer(c.Id, c.Name)).ToList(); + remotePoll = master.PollAsync(config.LicenseKey, remoteResults.ToList(), players); + } + void Timers() { PullNewGameSave(); double now = clock.Elapsed.TotalSeconds; + RemoteManagementTick(now); if (now >= nextAutosave) { nextAutosave = now + config.AutosaveSeconds; diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj index 1da3262..302889d 100644 --- a/MSCO.Server.csproj +++ b/MSCO.Server.csproj @@ -7,7 +7,7 @@ enable MSCO.Server MSCO.Server - 2.10.0 + 2.10.1 MBM MSCO Dedicated Server true diff --git a/MasterClient.cs b/MasterClient.cs index 3b1a90b..cefa376 100644 --- a/MasterClient.cs +++ b/MasterClient.cs @@ -59,6 +59,25 @@ public sealed class MasterClient public sealed record HeartbeatResult(int Status, bool? Reachable, bool Listed, string Address, int Port); record HeartbeatReply(bool? Reachable, bool Listed, string Address, int Port); + public sealed record RemoteCommand(string Id, string Command, string By); + public sealed record RemoteResult(string Id, string Output); + public sealed record RemotePlayer(int Id, string Name); + record PollReply(List Commands); + + /// Befehle aus dem Launcher abholen (und Ergebnisse der letzten Befehle abliefern). null = Master nicht erreichbar. + public async Task> PollAsync(string key, List results, List players) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var response = await http.PostAsJsonAsync(baseUrl + "/api/servers/poll", new { key, results, players }, cts.Token); + if (!response.IsSuccessStatusCode) return null; + var reply = await response.Content.ReadFromJsonAsync(cancellationToken: cts.Token); + return reply?.Commands ?? new List(); + } + catch { return null; } + } + /// Beim Beenden: sofort aus der Serverliste nehmen (sonst erst nach dem Heartbeat-Timeout). public async Task UnregisterAsync(string key) { diff --git a/Panel/app.js b/Panel/app.js index ee0676e..16fa50c 100644 --- a/Panel/app.js +++ b/Panel/app.js @@ -407,7 +407,9 @@ function renderSettings(page) { number("maxPlayers", "Max players", c.maxPlayers, { min: 1, max: 64 }), text("publicAddress", "Public address", c.publicAddress, { max: 200, placeholder: "automatic", hint: "IP or domain players connect to (empty = automatic)" }), toggle("isPublic", "Listed in the server browser", c.isPublic), - toggle("requireSteam", "Steam required", c.requireSteam, "Off = players without Steam can join too"))), + toggle("requireSteam", "Steam required", c.requireSteam, "Off = players without Steam can join too"), + toggle("remoteManagement", "Manage from the MSCO Launcher", c.remoteManagement, + "Owners who linked this server with its license key in the launcher can send commands (say, kick, ban, save, backup, restart, mods reload)"))), h("div", { class: "section" }, h("div", { class: "section-head" }, h("h2", {}, "Saving")), h("div", { class: "section-body form-grid wide" }, diff --git a/Panel/i18n.js b/Panel/i18n.js index c61b741..64d4560 100644 --- a/Panel/i18n.js +++ b/Panel/i18n.js @@ -16,6 +16,11 @@ "The web panel is turned off - turn it on again with 'panel on' in the server console or PanelEnabled in server.json": "Das Web-Panel ist ausgeschaltet - wieder an mit 'panel on' in der Server-Konsole oder PanelEnabled in server.json", + // Verwalten aus dem Launcher + "Manage from the MSCO Launcher": "Verwalten aus dem MSCO Launcher", + "Owners who linked this server with its license key in the launcher can send commands (say, kick, ban, save, backup, restart, mods reload)": + "Wer diesen Server im Launcher mit seinem Lizenzschlüssel verknüpft hat, kann Befehle schicken (say, kick, ban, save, backup, restart, mods reload)", + // Mods "Mods": "Mods", "Add a mod": "Mod hinzufügen", diff --git a/README.md b/README.md index b7706d9..14f1a55 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ und neu starten. | `Public` | `false` = nicht in der Liste, nur Direct Connect | | `RequireSteam` | `true` = nur Spieler mit Steam; `false` = Non-Steam-Server (Spieler werden über den Namen erkannt) | | `PublicAddress` | öffentliche IP/Domain für die Liste (nur hinter NAT/Proxy nötig) | +| `RemoteManagement` | `true` = der Besitzer kann den Server im MSCO Launcher verwalten (Befehle über den Keymaster) | | `AutosaveSeconds` | wie oft der Weltzustand gespeichert wird | | `SaveSyncMinutes` | wie oft (Minuten) sich der Server von einem Spieler im Spiel den **aktuellen Spielstand** hochladen lässt (Standard 10, zusätzlich bei jedem Beitritt; nur wenn sich etwas geändert hat; 0 = nur beim Beitritt) | | `SteamWebApiKey` | Steam-Anmeldetickets der Spieler bei Steam prüfen – **nötig für Admins** (kostenlos: https://steamcommunity.com/dev/apikey) | @@ -207,6 +208,14 @@ erreichbar. Ganz aus: `"PanelEnabled": false`. > (`- "40120:40120/tcp"`). Schon ausgesperrt? `"PanelPort": 0` in `data/server.json` setzen und > `docker compose restart` – dann ist das Panel wieder auf dem Spiel-Port. +## Verwalten aus dem MSCO Launcher + +Im Launcher unter **Meine Server** verknüpft der Besitzer seinen Server einmal mit dem Lizenzschlüssel. Danach sieht +er dort Status und Spieler und kann Befehle schicken: `say`, `kick`, `ban`, `save`, `backup`, `restart`, +`mods reload`, `status`, `players`. Der Server holt sie alle 5 Sekunden beim Keymaster ab und führt nur Befehle aus +dieser Liste aus (im Log mit „Launcher (Konto)“). Abschalten: im Web-Panel unter *Einstellungen* „Verwalten aus dem +MSCO Launcher“ bzw. `"RemoteManagement": false` in `server.json`. + ## Mods MSCO-Mods (.dll, siehe [MODDING.md](https://git.mbm.wtf/MrMaho/MSCO/src/branch/main/MODDING.md)) in `data/mods` – oder diff --git a/ServerConfig.cs b/ServerConfig.cs index 0b9fb82..b293390 100644 --- a/ServerConfig.cs +++ b/ServerConfig.cs @@ -41,6 +41,11 @@ public sealed class ServerConfig // ---------------------------------------------------------------- Web-Panel /// Web-Panel einschalten. public bool PanelEnabled { get; set; } = true; + /// + /// Verwalten aus dem MSCO Launcher: wer den Server dort mit dem Lizenzschlüssel verknüpft hat, kann über den Keymaster + /// Befehle schicken (say, kick, ban, save, backup, restart, mods reload, status, players). false = aus. + /// + public bool RemoteManagement { get; set; } = true; /// 0 = derselbe Port wie der Gameserver (Browser und Spiel werden automatisch unterschieden), sonst eigener Port. public int PanelPort { get; set; } = 0; /// Nur bei eigenem PanelPort: Adresse, auf der gelauscht wird ("0.0.0.0" = alle, "127.0.0.1" = nur lokal, z.B. hinter einem Proxy).