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).