Server 2.10.3: browse the MSCO mod list in the web panel

Mods -> MSCO mod list: search the approved mods from msco.mbm.wtf/mods and add or
update one with a click. The server lists and downloads only from the official
keymaster (never from a URL in the answer), checks the SHA-256 and stores it in
data/mods like an uploaded mod. Admins only. 6 new tests (154 pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:08:37 +02:00
co-authored by Claude Opus 5.5
parent 266a462fc3
commit d946c95198
7 changed files with 228 additions and 2 deletions
+26
View File
@@ -525,6 +525,32 @@ public sealed class AdminPanel
return await RunText(s => s.ReloadMods(), () => "Mod uploaded - players get it when the server runs"); return await RunText(s => s.ReloadMods(), () => "Mod uploaded - players get it when the server runs");
}); });
// Mod-Liste des Keymasters (msco.mbm.wtf/mods) durchsuchen und als Server-Mod übernehmen
app.MapGet("/api/mods/catalog", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
List<ModCatalog.Entry> list;
try { list = await ModCatalog.ListAsync(); }
catch (Exception e) { return Error(502, "The MSCO mod list is not reachable (" + e.Message + ")"); }
var installed = ModStore.Load(dataDir, null).ToDictionary(m => m.Id, StringComparer.OrdinalIgnoreCase);
return Results.Json(list.Select(m => new
{
id = m.Id, name = m.Name, description = m.Description, author = m.Author, version = m.Version,
size = m.Size, changelog = m.Changelog, downloads = m.Downloads, updated = m.UpdatedUtc,
installed = installed.ContainsKey(m.Id),
upToDate = installed.TryGetValue(m.Id, out var have) && string.Equals(have.Sha256, m.Sha256, StringComparison.OrdinalIgnoreCase),
}));
});
app.MapPost("/api/mods/catalog/{id}/install", async (HttpContext ctx, string id) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
string error = await ModCatalog.InstallAsync(dataDir, id);
if (error != null) return Error(400, error);
GameServer.Log($"[panel] {By(user)} added the mod {id} from the MSCO mod list");
return await RunText(s => s.ReloadMods(), () => "Mod added - players get it when the server runs");
});
app.MapPost("/api/mods/{id}/remove", async (HttpContext ctx, string id) => app.MapPost("/api/mods/{id}/remove", async (HttpContext ctx, string id) =>
{ {
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
+1 -1
View File
@@ -7,7 +7,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MSCO.Server</RootNamespace> <RootNamespace>MSCO.Server</RootNamespace>
<AssemblyName>MSCO.Server</AssemblyName> <AssemblyName>MSCO.Server</AssemblyName>
<Version>2.10.2</Version> <Version>2.10.3</Version>
<Company>MBM</Company> <Company>MBM</Company>
<Product>MSCO Dedicated Server</Product> <Product>MSCO Dedicated Server</Product>
<InvariantGlobalization>true</InvariantGlobalization> <InvariantGlobalization>true</InvariantGlobalization>
+81
View File
@@ -0,0 +1,81 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
namespace MSCO.Server;
/// <summary>
/// Mod-Liste des Keymasters (msco.mbm.wtf/mods: Mods von Spielern, von MSCO-Admins geprüft) im Web-Panel durchsuchen
/// und mit einem Klick als Server-Mod übernehmen. Geladen wird nur vom offiziellen Master, die SHA-256 wird geprüft;
/// danach liegt die Mod wie eine hochgeladene in data/mods (Datei &lt;id&gt;.dll).
/// </summary>
public static class ModCatalog
{
public sealed record Entry(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("author")] string Author,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("sha256")] string Sha256,
[property: JsonPropertyName("size")] long Size,
[property: JsonPropertyName("changelog")] string Changelog,
[property: JsonPropertyName("updatedUtc")] string UpdatedUtc,
[property: JsonPropertyName("downloads")] int Downloads);
record ListResponse([property: JsonPropertyName("mods")] List<Entry> Mods);
static readonly HttpClient http = CreateHttp();
/// <summary>Adresse der Mod-Liste: der offizielle Master (nur die Tests setzen hier einen eigenen).</summary>
public static string BaseUrl { get; set; } = MasterServer.Url;
static HttpClient CreateHttp()
{
var c = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
c.DefaultRequestHeaders.UserAgent.ParseAdd("MSCO-Server");
return c;
}
/// <summary>Freigegebene Mods vom Keymaster. Wirft bei Netzwerkfehlern.</summary>
public static async Task<List<Entry>> ListAsync()
{
var res = await http.GetFromJsonAsync<ListResponse>(BaseUrl + "/api/mods");
return res?.Mods ?? new List<Entry>();
}
/// <summary>Mod aus der Liste in data/mods übernehmen (bzw. aktualisieren). Fehlertext oder null.</summary>
public static async Task<string> InstallAsync(string dataDir, string id)
{
List<Entry> list;
try { list = await ListAsync(); }
catch (Exception e) { return "The MSCO mod list is not reachable (" + e.Message + ")"; }
var entry = list.FirstOrDefault(m => m.Id == id);
if (entry == null) return "This mod is not in the MSCO mod list (any more)";
if (entry.Size > ModStore.MaxModBytes) return "This mod is too large";
// Immer vom offiziellen Master laden - nie von einer Adresse aus der Antwort
string url = BaseUrl + "/mods/download/" + Uri.EscapeDataString(entry.Id) + "/" + Uri.EscapeDataString(entry.Version);
byte[] data;
try
{
using var res = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (!res.IsSuccessStatusCode) return "Download failed (HTTP " + (int)res.StatusCode + ")";
await using var stream = await res.Content.ReadAsStreamAsync();
using var ms = new MemoryStream();
var chunk = new byte[81920];
int read;
while ((read = await stream.ReadAsync(chunk)) > 0)
{
ms.Write(chunk, 0, read);
if (ms.Length > ModStore.MaxModBytes) return "This mod is too large";
}
data = ms.ToArray();
}
catch (Exception e) { return "Download failed (" + e.Message + ")"; }
string sha = Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
if (!string.Equals(sha, entry.Sha256, StringComparison.OrdinalIgnoreCase)) return "The download is damaged (checksum mismatch) - try again";
return ModStore.Add(dataDir, entry.Id + ".dll", data);
}
}
+44 -1
View File
@@ -761,6 +761,10 @@ function renderWorld(page) {
function renderMods(page) { function renderMods(page) {
const rows = h("tbody"); const rows = h("tbody");
const catalogRows = h("tbody", {}, h("tr", {}, h("td", { colspan: 4, class: "empty" }, "Loading …")));
const search = h("input", { type: "search", placeholder: "Search mods …", maxlength: 60 });
let catalog = null;
search.addEventListener("input", () => drawCatalog());
const fileInput = h("input", { type: "file", accept: ".dll" }); const fileInput = h("input", { type: "file", accept: ".dll" });
const kb = (n) => (n / 1024).toFixed(n < 102400 ? 1 : 0) + " KB"; const kb = (n) => (n / 1024).toFixed(n < 102400 ? 1 : 0) + " KB";
const upload = async () => { const upload = async () => {
@@ -798,7 +802,45 @@ function renderMods(page) {
h("div", { class: "section" }, h("div", { class: "section" },
h("div", { class: "section-head" }, h("h2", {}, "Server mods"), h("span", { class: "muted" }, "players get these when they join")), h("div", { class: "section-head" }, h("h2", {}, "Server mods"), h("span", { class: "muted" }, "players get these when they join")),
h("div", { class: "table-wrap" }, h("table", {}, h("div", { class: "table-wrap" }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Mod"), h("th", {}, "Version"), h("th", {}, "Size"), h("th", {}, "SHA-256"), h("th", {}, ""))), rows)))); h("thead", {}, h("tr", {}, h("th", {}, "Mod"), h("th", {}, "Version"), h("th", {}, "Size"), h("th", {}, "SHA-256"), h("th", {}, ""))), rows))),
h("div", { class: "section" },
h("div", { class: "section-head" }, h("h2", {}, "MSCO mod list"), h("span", { class: "muted" }, "mods by players, checked by the MSCO admins")),
h("div", { class: "section-body" }, h("div", { class: "row" }, search,
h("button", { class: "btn ghost small", type: "button", onclick: openList }, "msco.mbm.wtf/mods"))),
h("div", { class: "table-wrap" }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Mod"), h("th", {}, "Version"), h("th", {}, "Downloads"), h("th", {}, ""))), catalogRows))));
function openList() { window.open("https://msco.mbm.wtf/mods", "_blank", "noopener"); }
async function loadCatalog() {
try { catalog = await api("GET", "mods/catalog"); }
catch (e) { catalogRows.replaceChildren(h("tr", {}, h("td", { colspan: 4, class: "empty" }, e.message))); return; }
drawCatalog();
}
function drawCatalog() {
if (!catalog) return;
const q = search.value.trim().toLowerCase();
const list = catalog.filter((m) => !q || (m.name + " " + m.description + " " + m.author).toLowerCase().includes(q));
if (!catalog.length) { catalogRows.replaceChildren(h("tr", {}, h("td", { colspan: 4, class: "empty" }, "The MSCO mod list is empty."))); return; }
if (!list.length) { catalogRows.replaceChildren(h("tr", {}, h("td", { colspan: 4, class: "empty" }, "No mod matches the search."))); return; }
catalogRows.replaceChildren(...list.map((m) => h("tr", {},
h("td", {}, h("b", {}, m.name), h("span", { class: "muted" }, " · " + m.author),
m.description ? h("div", { class: "muted" }, m.description) : null),
h("td", {}, m.version, h("div", { class: "muted" }, kb(m.size))),
h("td", {}, String(m.downloads)),
h("td", {}, h("div", { class: "actions" },
m.upToDate ? h("span", { class: "muted" }, "Added")
: h("button", { class: "btn small primary", type: "button", onclick: (e) => install(m, e.target) }, m.installed ? "Update" : "Add to server"))))));
}
async function install(m, button) {
const ok = await modal({ title: (m.installed ? "Update " : "Add ") + m.name + "?", confirm: m.installed ? "Update" : "Add",
text: "Every player gets this mod offered when they join and decides whether to install it. It was checked by the MSCO admins - still only add mods you want on your server." });
if (!ok) return;
button.disabled = true;
await act(api("POST", "mods/catalog/" + encodeURIComponent(m.id) + "/install"));
load();
loadCatalog();
}
async function load() { async function load() {
try { try {
@@ -812,6 +854,7 @@ function renderMods(page) {
} catch (e) { toast(e.message, true); } } catch (e) { toast(e.message, true); }
} }
load(); load();
loadCatalog();
} }
// ------------------------------------------------------------------ Benutzer // ------------------------------------------------------------------ Benutzer
+13
View File
@@ -30,6 +30,19 @@
"The mods are stored in data/mods. Files copied there by hand are used after “Reload” (console: mods reload).": "The mods are stored in data/mods. Files copied there by hand are used after “Reload” (console: mods reload).":
"Die Mods liegen in data/mods. Von Hand dorthin kopierte Dateien gelten nach „Neu laden“ (Konsole: mods reload).", "Die Mods liegen in data/mods. Von Hand dorthin kopierte Dateien gelten nach „Neu laden“ (Konsole: mods reload).",
"Server mods": "Server-Mods", "Server mods": "Server-Mods",
"MSCO mod list": "MSCO-Modliste",
"mods by players, checked by the MSCO admins": "Mods von Spielern, von den MSCO-Admins geprüft",
"Search mods …": "Mods suchen …",
"The MSCO mod list is empty.": "Die MSCO-Modliste ist leer.",
"No mod matches the search.": "Keine Mod passt zur Suche.",
"Downloads": "Downloads",
"Added": "Hinzugefügt",
"Add to server": "Zum Server hinzufügen",
"Add": "Hinzufügen",
"Every player gets this mod offered when they join and decides whether to install it. It was checked by the MSCO admins - still only add mods you want on your server.":
"Jeder Spieler bekommt diese Mod beim Beitreten angeboten und entscheidet, ob er sie installiert. Sie wurde von den MSCO-Admins geprüft - trotzdem nur Mods hinzufügen, die du auf deinem Server willst.",
"Mod added - players get it when the server runs": "Mod hinzugefügt - Spieler bekommen sie, sobald der Server läuft",
"Loading …": "Lädt …",
"players get these when they join": "bekommen Spieler beim Beitreten", "players get these when they join": "bekommen Spieler beim Beitreten",
"Mod": "Mod", "Mod": "Mod",
"Size": "Größe", "Size": "Größe",
+4
View File
@@ -223,6 +223,10 @@ im Web-Panel unter **Mods** hochladen. Jeder Spieler bekommt beim Beitreten die
nach seiner Zustimmung direkt vom Server (SHA-256 geprüft, höchstens 30 Mods à 20 MB). In der Serverliste bekommt der nach seiner Zustimmung direkt vom Server (SHA-256 geprüft, höchstens 30 Mods à 20 MB). In der Serverliste bekommt der
Server automatisch den Tag **MODDED** und zeigt seine Mods an. Nach Änderungen im Ordner: `mods reload` (Panel: automatisch). Server automatisch den Tag **MODDED** und zeigt seine Mods an. Nach Änderungen im Ordner: `mods reload` (Panel: automatisch).
Im Panel unter **Mods → MSCO-Modliste** lassen sich Mods aus der offiziellen Liste
([msco.mbm.wtf/mods](https://msco.mbm.wtf/mods), von den MSCO-Admins geprüft) durchsuchen und mit einem Klick zum Server
hinzufügen oder aktualisieren – der Server lädt sie nur vom offiziellen Keymaster und prüft die SHA-256.
> Mods laufen auf den PCs der Spieler – nur Mods anbieten, denen du vertraust. > Mods laufen auf den PCs der Spieler – nur Mods anbieten, denen du vertraust.
## Konsolenbefehle ## Konsolenbefehle
+59
View File
@@ -431,6 +431,65 @@ async Task PanelTests()
var cmd = await Call(owner, "POST", "api/console", new { command = "status" }); 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"); Check(cmd.status == 200 && cmd.json.GetProperty("message").GetString().Contains("players"), "Console command runs and returns its output");
// Mod-Liste des Keymasters: kleiner Ersatz-Keymaster auf localhost
{
var modBytes = new byte[3000];
Random.Shared.NextBytes(modBytes);
modBytes[0] = 0x4d; modBytes[1] = 0x5a;
string modSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(modBytes)).ToLowerInvariant();
bool serveBroken = false;
int catalogPort = 39000 + Random.Shared.Next(900);
var listener = new System.Net.HttpListener();
listener.Prefixes.Add($"http://127.0.0.1:{catalogPort}/");
listener.Start();
_ = Task.Run(async () =>
{
while (listener.IsListening)
{
System.Net.HttpListenerContext c;
try { c = await listener.GetContextAsync(); } catch { break; }
byte[] body;
if (c.Request.Url.AbsolutePath == "/api/mods")
{
body = System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(new
{
mods = new[] { new { id = "catalogmod", name = "Catalog Mod", description = "From the list", author = "Tester", version = "1.0.0",
sha256 = modSha, size = modBytes.Length, changelog = "", updatedUtc = "2026-10-03", downloads = 3,
url = "http://evil.example/steal.dll" } }
}));
c.Response.ContentType = "application/json";
}
else if (c.Request.Url.AbsolutePath == "/mods/download/catalogmod/1.0.0")
{
body = serveBroken ? new byte[] { 0x4d, 0x5a, 1, 2, 3 } : modBytes;
}
else { c.Response.StatusCode = 404; body = Array.Empty<byte>(); }
await c.Response.OutputStream.WriteAsync(body);
c.Response.Close();
}
});
ModCatalog.BaseUrl = $"http://127.0.0.1:{catalogPort}";
var catalog = await Call(owner, "GET", "api/mods/catalog");
Check(catalog.status == 200 && catalog.json.EnumerateArray().Any(m => m.GetProperty("id").GetString() == "catalogmod" && !m.GetProperty("installed").GetBoolean()),
"Panel lists the MSCO mod list (not yet added)");
serveBroken = true;
var broken = await Call(owner, "POST", "api/mods/catalog/catalogmod/install");
Check(broken.status == 400 && !File.Exists(Path.Combine(dir, "mods", "catalogmod.dll")), "A download with the wrong checksum is not added");
serveBroken = false;
Check((await Call(mod1Placeholder(), "POST", "api/mods/catalog/catalogmod/install")).status is 401 or 403, "Without admin rights nothing can be added");
var installMod = await Call(owner, "POST", "api/mods/catalog/catalogmod/install");
Check(installMod.status == 200 && File.Exists(Path.Combine(dir, "mods", "catalogmod.dll"))
&& File.ReadAllBytes(Path.Combine(dir, "mods", "catalogmod.dll")).SequenceEqual(modBytes),
"Admin adds a mod from the list (downloaded from the keymaster, checksum checked)");
catalog = await Call(owner, "GET", "api/mods/catalog");
Check(catalog.json.EnumerateArray().Any(m => m.GetProperty("id").GetString() == "catalogmod" && m.GetProperty("upToDate").GetBoolean()),
"The list shows the mod as added");
Check((await Call(owner, "POST", "api/mods/catalog/doesnotexist/install")).status == 400, "Unknown mods cannot be added");
await Call(owner, "POST", "api/mods/catalogmod/remove");
listener.Stop();
ModCatalog.BaseUrl = MasterServer.Url;
}
HttpClient mod1Placeholder() => Browser();
var log = await Call(owner, "GET", "api/console?after=0"); 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"); Check(log.status == 200 && log.json.GetProperty("lines").GetArrayLength() > 0, "Live console log is readable");