diff --git a/AdminPanel.cs b/AdminPanel.cs
index 38d14e2..100642d 100644
--- a/AdminPanel.cs
+++ b/AdminPanel.cs
@@ -52,6 +52,10 @@ public sealed class AdminPanel
public string SetupCode { get; private set; }
/// Interner Port, wenn das Panel den Spiel-Port mitbenutzt.
public int InternalPort { get; private set; }
+ /// Interner HTTPS-Port (gleicher Port wie das Spiel: TLS-Verbindungen landen hier), 0 = kein HTTPS.
+ public int InternalTlsPort { get; private set; }
+ /// SHA-256-Fingerabdruck des HTTPS-Zertifikats (zum Vergleichen im Browser), null = kein HTTPS.
+ public string CertificateFingerprint { get; private set; }
public bool SharesGamePort { get; private set; }
AdminPanel(ServerHost host, ServerConfig config, string dataDir)
@@ -85,22 +89,36 @@ public sealed class AdminPanel
SharesGamePort = config.PanelPort <= 0 || config.PanelPort == config.Port;
IPAddress bind;
int port;
+ // HTTPS: auf dem Spiel-Port immer (eigener interner Port), bei eigenem Panel-Port nur mit PanelHttps
+ System.Security.Cryptography.X509Certificates.X509Certificate2 cert = null;
+ bool ownCert = !string.IsNullOrWhiteSpace(config.PanelCertificatePath);
+ if (SharesGamePort || (config.PanelHttps && ownCert))
+ {
+ try { cert = PanelTls.Load(config, dataDir, GameServer.Log); }
+ catch (Exception e) { GameServer.Log("Web panel: HTTPS not available (" + e.Message + ")"); }
+ }
+ CertificateFingerprint = cert != null ? PanelTls.Fingerprint(cert) : null;
+ InternalTlsPort = 0;
if (SharesGamePort)
{
bind = IPAddress.Loopback;
port = InternalPort = FreeLoopbackPort();
+ if (cert != null) InternalTlsPort = FreeLoopbackPort();
}
else
{
bind = ParseBind(config.PanelBindAddress);
port = config.PanelPort;
}
+ // Eigener Panel-Port: nur mit eigenem Zertifikat HTTPS (meist steht dort ein Reverse-Proxy, der HTTP erwartet)
+ bool ownPortHttps = !SharesGamePort && config.PanelHttps && ownCert && cert != null;
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { ContentRootPath = AppContext.BaseDirectory });
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(k =>
{
- k.Listen(bind, port);
+ k.Listen(bind, port, o => { if (ownPortHttps) o.UseHttps(cert); });
+ if (InternalTlsPort > 0) k.Listen(IPAddress.Loopback, InternalTlsPort, o => o.UseHttps(cert));
k.Limits.MaxRequestBodySize = 64 * 1024;
k.AddServerHeader = false;
});
@@ -139,10 +157,28 @@ public sealed class AdminPanel
app.UsePathBase(basePath);
}
+ // Von außen per HTTP auf dem Spiel-Port: auf HTTPS umleiten, damit Passwort und Sitzung verschlüsselt sind
+ // (lokal/über einen eigenen Proxy nicht - der Proxy macht HTTPS selbst)
+ app.Use(async (ctx, next) =>
+ {
+ var remote = ctx.Connection.RemoteIpAddress;
+ if (InternalTlsPort > 0 && config.PanelHttps && !config.PanelTrustProxy && !ctx.Request.IsHttps
+ && remote != null && !IPAddress.IsLoopback(remote))
+ {
+ string host = ctx.Request.Host.HasValue ? ctx.Request.Host.Value : "localhost";
+ ctx.Response.StatusCode = HttpMethods.IsGet(ctx.Request.Method) || HttpMethods.IsHead(ctx.Request.Method) ? 301 : 308;
+ ctx.Response.Headers.Location = "https://" + host + ctx.Request.PathBase + ctx.Request.Path + ctx.Request.QueryString;
+ return;
+ }
+ await next();
+ });
+
// Sicherheits-Header
app.Use(async (ctx, next) =>
{
var h = ctx.Response.Headers;
+ h["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()";
+ h["Cross-Origin-Opener-Policy"] = "same-origin";
h["X-Content-Type-Options"] = "nosniff";
h["X-Frame-Options"] = "DENY";
h["Referrer-Policy"] = "no-referrer";
@@ -157,10 +193,11 @@ public sealed class AdminPanel
app.StartAsync().GetAwaiter().GetResult();
if (users.Empty && SetupCode == null) CreateSetupCode();
+ string scheme = InternalTlsPort > 0 || ownPortHttps ? "https" : "http";
if (SharesGamePort)
- GameServer.Log($"Web panel: http://:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
+ GameServer.Log($"Web panel: {scheme}://:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
else
- GameServer.Log($"Web panel: http://{(bind.Equals(IPAddress.Any) ? "" : bind.ToString())}:{port}{basePath}/");
+ GameServer.Log($"Web panel: {scheme}://{(bind.Equals(IPAddress.Any) ? "" : bind.ToString())}:{port}{basePath}/");
}
/// Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu).
@@ -248,14 +285,15 @@ public sealed class AdminPanel
}
/// Browser-Verbindung vom Spiel-Port an das intern laufende Panel weiterreichen (in beide Richtungen).
- public async Task RelayAsync(TcpClient browser)
+ public async Task RelayAsync(TcpClient browser, bool tls = false)
{
var real = (browser.Client.RemoteEndPoint as IPEndPoint)?.Address;
using var panel = new TcpClient { NoDelay = true };
int localPort = 0;
try
{
- await panel.ConnectAsync(IPAddress.Loopback, InternalPort);
+ if (tls && InternalTlsPort == 0) return; // kein Zertifikat
+ await panel.ConnectAsync(IPAddress.Loopback, tls ? InternalTlsPort : InternalPort);
localPort = ((IPEndPoint)panel.Client.LocalEndPoint).Port;
if (real != null) relayed[localPort] = real.IsIPv4MappedToIPv6 ? real.MapToIPv4() : real;
using var a = browser.GetStream();
@@ -341,14 +379,21 @@ public sealed class AdminPanel
if (!CsrfOk(ctx)) return Forbidden("Missing header");
if (RateLimited(ctx)) return Error(429, "Too many failed logins - wait a few minutes");
var body = await Body(ctx);
- var user = users.Verify(Str(body, "username").Trim(), Str(body, "password"));
+ string name = Str(body, "username").Trim();
+ // Auch pro Benutzer sperren: sonst ließe sich ein Passwort über viele IPs verteilt durchprobieren
+ string userKey = "user:" + name.ToLowerInvariant();
+ if (RateLimited(userKey, MaxLoginFailures * 2, TimeSpan.FromMinutes(15)))
+ return Error(429, "Too many failed logins for this user - wait 15 minutes");
+ var user = users.Verify(name, Str(body, "password"));
if (user == null)
{
RecordFailure(ctx);
- GameServer.Log($"[panel] Failed login for '{Str(body, "username").Trim()}' from {Ip(ctx)}");
+ RecordFailure(userKey);
+ GameServer.Log($"[panel] Failed login for '{LogSafe(name)}' from {Ip(ctx)}");
return Error(401, "Wrong username or password");
}
failures.TryRemove(Ip(ctx), out _);
+ failures.TryRemove(userKey, out _);
StartSession(ctx, user.Name);
GameServer.Log($"[panel] {user.Name} logged in ({Ip(ctx)})");
return Results.Json(new { ok = true });
@@ -903,6 +948,7 @@ public sealed class AdminPanel
{
string tmp = SessionsPath + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(sessions.ToDictionary(kv => kv.Key, kv => kv.Value), SessionJson));
+ PanelTls.Restrict(tmp);
File.Move(tmp, SessionsPath, true);
}
catch { }
@@ -930,10 +976,35 @@ public sealed class AdminPanel
}
}
- void RecordFailure(HttpContext ctx)
+ void RecordFailure(HttpContext ctx) => RecordFailure(Ip(ctx));
+
+ void RecordFailure(string key)
{
- var list = failures.GetOrAdd(Ip(ctx), _ => new List());
- lock (list) list.Add(DateTime.UtcNow);
+ if (failures.Count > 20_000) failures.Clear(); // Speicher begrenzen (viele verschiedene IPs/Namen)
+ var list = failures.GetOrAdd(key, _ => new List());
+ lock (list)
+ {
+ list.Add(DateTime.UtcNow);
+ if (list.Count > 100) list.RemoveRange(0, list.Count - 100);
+ }
+ }
+
+ bool RateLimited(string key, int max, TimeSpan window)
+ {
+ if (!failures.TryGetValue(key, out var list)) return false;
+ lock (list)
+ {
+ list.RemoveAll(t => DateTime.UtcNow - t > window);
+ return list.Count >= max;
+ }
+ }
+
+ /// Text aus einer Anfrage fürs Log: ohne Zeilenumbrüche/Steuerzeichen (keine gefälschten Log-Zeilen), gekürzt.
+ static string LogSafe(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return "";
+ var chars = s.Take(64).Select(c => char.IsControl(c) ? '?' : c).ToArray();
+ return new string(chars);
}
static async Task Body(HttpContext ctx)
diff --git a/GameServer.cs b/GameServer.cs
index 3619d59..c108d84 100644
--- a/GameServer.cs
+++ b/GameServer.cs
@@ -57,6 +57,8 @@ public sealed class GameServer
// ---------------------------------------------------------------- Mods (data/mods)
List mods = new();
const int ModPartBytes = 60_000, MaxModMessageBytes = 64 * 1024;
+ /// Größte Geldänderung pro Nachricht (Einkäufe, Jobs) - mehr ist ein manipulierter Client.
+ const float MaxMoneyDelta = 500_000f;
/// Mods aus data/mods (neu) laden. Neue Spieler bekommen die neue Liste; Verbundene erst beim nächsten Beitritt.
public string ReloadMods()
@@ -551,11 +553,31 @@ public sealed class GameServer
{
case MsgType.ObjStates: world.ApplyObjStates(m.Payload); break;
case MsgType.ObjRelease: world.ApplyObjRelease(m.Payload); break;
- case MsgType.FsmEvent: world.AddFsmEvent(m.Payload); break;
+ case MsgType.FsmEvent:
+ {
+ // Pfad, FSM, Event: "MSCO_INSTALL" (Teil per Admin-Menü einbauen) nur von Admins
+ try
+ {
+ var fr = m.Reader();
+ fr.ReadString(); fr.ReadString();
+ if (fr.ReadString() == "MSCO_INSTALL" && !c.IsAdmin) return;
+ }
+ catch { return; }
+ world.AddFsmEvent(m.Payload);
+ break;
+ }
case MsgType.Player:
if (DateTime.UtcNow >= c.TrackPositionFrom) world.ApplyPlayer(c.SteamId, m.Payload);
break;
- case MsgType.MoneyDelta: world.ApplyMoney(m.Payload); break;
+ case MsgType.MoneyDelta:
+ {
+ // Ungültige Werte (NaN/unendlich) würden die gemeinsame Kasse dauerhaft zerstören - nicht annehmen
+ if (m.Payload.Length < 4) return;
+ float delta = BitConverter.ToSingle(m.Payload, 0);
+ if (!float.IsFinite(delta) || Math.Abs(delta) > MaxMoneyDelta) return;
+ world.ApplyMoney(m.Payload);
+ break;
+ }
case MsgType.DoorState: world.ApplyDoor(m.Payload); break;
case MsgType.VarSync: world.ApplyVars(m.Payload); break;
case MsgType.WorldVars: world.ApplyJobVars(m.Payload); break;
@@ -578,11 +600,28 @@ public sealed class GameServer
break;
case MsgType.ModMessage:
if (m.Payload.Length > MaxModMessageBytes) return;
+ // "msco.*" sind Nachrichten von MSCO selbst (z.B. msco.world = Uhrzeit/Wetter vom Admin-Menü) - nur von Admins
+ string modId;
+ try { modId = m.Reader().ReadString(); } catch { return; }
+ if (modId.StartsWith("msco.", StringComparison.OrdinalIgnoreCase) && !c.IsAdmin) return;
break;
case MsgType.Chat:
+ {
if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten
- Log($"[chat] {c.Name}: {m.Reader().ReadString()}");
+ string text;
+ try { text = m.Reader().ReadString(); } catch { return; }
+ // "* ..." zeigt das Spiel als Server-/Admin-Meldung - nur Admins dürfen so schreiben (sonst gefälschte
+ // "XY was banned"-Meldungen); bei anderen wird es normaler Chat
+ if (text.StartsWith("* ") && !c.IsAdmin)
+ {
+ text = text.TrimStart('*', ' ');
+ if (text.Length == 0) return;
+ string clean = text;
+ m = new Message(m.Type, Wire.Build(w => w.Write(clean)));
+ }
+ Log($"[chat] {c.Name}: {CleanText(text, 300)}");
break;
+ }
case MsgType.TrafficStates:
if (c.Id != WorldAuthorityId) return; // nur einer simuliert den Verkehr
break;
@@ -607,8 +646,8 @@ public sealed class GameServer
steamId = r.ReadUInt64();
ticket = r.ReadBytes(r.ReadInt32());
}
- c.Name = string.IsNullOrWhiteSpace(c.Name) ? "Player" : c.Name.Trim();
- if (c.Name.Length > 24) c.Name = c.Name[..24];
+ c.Name = CleanText(c.Name ?? "", 24).Replace("<", "").Replace(">", "").Trim();
+ if (c.Name.Length == 0) c.Name = "Player";
// Spieler ohne Steam (Non-Steam-Server) werden über ihren Namen wiedererkannt (Position, Sperren)
c.SteamId = steamId != 0 ? steamId.ToString() : "name:" + c.Name.ToLowerInvariant();
@@ -1199,6 +1238,26 @@ public sealed class GameServer
}
}
+ ///
+ /// Text von einem Spieler (Name, Chat) bereinigen: keine Zeilenumbrüche/Steuerzeichen (keine gefälschten Zeilen in Log
+ /// und Konsole), keine unsichtbaren Richtungs-Zeichen, gekürzt.
+ ///
+ public static string CleanText(string s, int max)
+ {
+ if (string.IsNullOrEmpty(s)) return "";
+ var sb = new System.Text.StringBuilder(Math.Min(s.Length, max));
+ foreach (char ch in s)
+ {
+ if (sb.Length >= max) break;
+ if (char.IsControl(ch)) { sb.Append(' '); continue; }
+ // Richtungs-/Format-Zeichen (z.B. U+202E dreht Text um) weglassen
+ var cat = char.GetUnicodeCategory(ch);
+ if (cat == System.Globalization.UnicodeCategory.Format) continue;
+ sb.Append(ch);
+ }
+ return sb.ToString();
+ }
+
public static void Log(string text)
{
string time = DateTime.Now.ToString("HH:mm:ss");
diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj
index ab700c3..1a3925d 100644
--- a/MSCO.Server.csproj
+++ b/MSCO.Server.csproj
@@ -7,7 +7,7 @@
enable
MSCO.Server
MSCO.Server
- 2.10.3
+ 2.10.4
MBM
MSCO Dedicated Server
true
diff --git a/PanelTls.cs b/PanelTls.cs
new file mode 100644
index 0000000..d7a4250
--- /dev/null
+++ b/PanelTls.cs
@@ -0,0 +1,80 @@
+using System.Net;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+
+namespace MSCO.Server;
+
+///
+/// Zertifikat für HTTPS im Web-Panel. Ohne eigenes Zertifikat (PanelCertificatePath) erzeugt der Server einmal ein
+/// selbst signiertes und speichert es in data/panel_cert.pfx (nur für den Server-Benutzer lesbar). Der Browser warnt
+/// beim ersten Besuch ("nicht vertrauenswürdig") - die Verbindung ist trotzdem verschlüsselt; den Fingerabdruck zeigt
+/// die Server-Konsole zum Vergleichen.
+///
+public static class PanelTls
+{
+ public const string FileName = "panel_cert.pfx";
+
+ public static X509Certificate2 Load(ServerConfig config, string dataDir, Action log)
+ {
+ if (!string.IsNullOrWhiteSpace(config.PanelCertificatePath))
+ {
+ string path = Path.IsPathRooted(config.PanelCertificatePath) ? config.PanelCertificatePath : Path.Combine(dataDir, config.PanelCertificatePath);
+ var own = X509CertificateLoader.LoadPkcs12FromFile(path, config.PanelCertificatePassword ?? "");
+ log($"Web panel HTTPS certificate: {own.Subject} (from {path})");
+ return own;
+ }
+
+ string file = Path.Combine(dataDir, FileName);
+ X509Certificate2 cert = null;
+ if (File.Exists(file))
+ {
+ try
+ {
+ cert = X509CertificateLoader.LoadPkcs12FromFile(file, null);
+ if (cert.NotAfter < DateTime.Now.AddDays(30)) cert = null; // bald abgelaufen: neu erzeugen
+ }
+ catch { cert = null; }
+ }
+ if (cert == null)
+ {
+ cert = CreateSelfSigned(config);
+ Directory.CreateDirectory(dataDir);
+ File.WriteAllBytes(file, cert.Export(X509ContentType.Pfx));
+ Restrict(file);
+ cert = X509CertificateLoader.LoadPkcs12FromFile(file, null); // aus der Datei laden (Windows braucht einen gespeicherten Schlüssel)
+ log("Web panel: created a self-signed HTTPS certificate (" + FileName + ")");
+ }
+ log("Web panel HTTPS fingerprint (SHA-256): " + Fingerprint(cert));
+ return cert;
+ }
+
+ public static string Fingerprint(X509Certificate2 cert) =>
+ string.Join(":", Convert.ToHexString(cert.GetCertHash(HashAlgorithmName.SHA256)).Chunk(2).Select(c => new string(c)));
+
+ static X509Certificate2 CreateSelfSigned(ServerConfig config)
+ {
+ using var rsa = RSA.Create(3072);
+ var req = new CertificateRequest("CN=MSCO Server Panel", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
+ req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
+ req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
+ var san = new SubjectAlternativeNameBuilder();
+ san.AddDnsName("localhost");
+ san.AddIpAddress(IPAddress.Loopback);
+ string publicAddress = (config.PublicAddress ?? "").Trim();
+ if (IPAddress.TryParse(publicAddress, out var ip)) san.AddIpAddress(ip);
+ else if (publicAddress.Length > 0 && Uri.CheckHostName(publicAddress) == UriHostNameType.Dns) san.AddDnsName(publicAddress);
+ req.CertificateExtensions.Add(san.Build());
+ return req.CreateSelfSigned(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddYears(5));
+ }
+
+ /// Datei nur für den Benutzer des Servers lesbar (Linux).
+ public static void Restrict(string file)
+ {
+ if (OperatingSystem.IsWindows()) return;
+ try { File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
+ }
+
+ /// Beginnen die Bytes wie ein TLS-Handshake (ClientHello: 16 03 0x)?
+ public static bool LooksLikeTls(byte[] b, int n) => n >= 3 && b[0] == 0x16 && b[1] == 0x03 && b[2] <= 0x04;
+}
diff --git a/PanelUsers.cs b/PanelUsers.cs
index a036b0f..071d73d 100644
--- a/PanelUsers.cs
+++ b/PanelUsers.cs
@@ -146,6 +146,7 @@ public sealed class PanelUsers
{
string tmp = path + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(new FileData { Users = users }, Options));
+ PanelTls.Restrict(tmp); // Passwort-Hashes nur für den Server-Benutzer lesbar (Linux)
if (!OperatingSystem.IsWindows())
try { File.SetUnixFileMode(tmp, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
File.Move(tmp, path, true);
diff --git a/README.md b/README.md
index d8d8b1f..cf6eaf9 100644
--- a/README.md
+++ b/README.md
@@ -128,9 +128,17 @@ Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvari
## Web-Panel
Der Server bringt ein Web-Panel mit – **auf demselben Port wie das Spiel** (Standard `7777`). Einfach im Browser
-`http://:7777/` öffnen; der Server erkennt selbst, ob ein Browser oder das Spiel verbindet. Es muss also
+`https://:7777/` öffnen; der Server erkennt selbst, ob ein Browser oder das Spiel verbindet. Es muss also
kein weiterer Port freigegeben werden.
+**HTTPS:** Das Panel ist verschlüsselt erreichbar. Ohne eigenes Zertifikat erzeugt der Server einmal ein selbst
+signiertes (`data/panel_cert.pfx`) – der Browser warnt beim ersten Besuch („nicht sicher“/„nicht vertrauenswürdig“),
+dann *Erweitert → Weiter*. Den **Fingerabdruck** (SHA-256) zeigt das Server-Log beim Start; er muss mit dem im Browser
+(Schloss-Symbol → Zertifikat) übereinstimmen. Wer von außen `http://` öffnet, wird auf `https://` umgeleitet, damit
+Passwort und Sitzung nie unverschlüsselt übertragen werden (lokal und hinter einem Reverse-Proxy nicht). Eigenes
+Zertifikat (PFX): `"PanelCertificatePath": "panel.pfx"` und `"PanelCertificatePassword"` in `server.json`;
+abschalten: `"PanelHttps": false`.
+
| Spieler | Live-Konsole |
|---|---|
|  |  |
@@ -322,6 +330,13 @@ Reset-Punkt.
Start), und eine unbestätigte SteamID kann keinen verbundenen Spieler verdrängen.
- Gebannte Spieler werden über ihre (bei Steam geprüfte) SteamID erkannt. Auf Non-Steam-Servern gibt es keine
verlässliche Identität – dort sind Bans nur über den Namen möglich.
+- Web-Panel: HTTPS (siehe oben), Passwörter als PBKDF2-Hash, Sitzungs-Cookie `HttpOnly` + `SameSite=Strict` (über
+ HTTPS `Secure`), Schutz gegen fremde Formulare (eigener Header), strenge Content-Security-Policy. Nach 8
+ Fehlversuchen pro IP bzw. 16 pro Benutzer ist der Login einige Minuten gesperrt. `panel_users.json`,
+ `panel_sessions.json`, `server.json` und das Zertifikat sind unter Linux nur für den Server-Benutzer lesbar.
+- Spieler-Eingaben werden geprüft: Namen/Chat ohne Steuerzeichen (keine gefälschten Log-Zeilen), Chat im Stil von
+ Server-Meldungen (`* …`), Uhrzeit/Wetter (`msco.*`) und Admin-Einbau von Teilen nur von Admins, ungültige oder
+ absurde Geldänderungen werden verworfen.
## Lizenz
diff --git a/ServerConfig.cs b/ServerConfig.cs
index b293390..b277ccf 100644
--- a/ServerConfig.cs
+++ b/ServerConfig.cs
@@ -54,6 +54,16 @@ public sealed class ServerConfig
public bool PanelTrustProxy { get; set; } = false;
/// Unterpfad, unter dem das Panel läuft, z.B. "/panel" (leer = direkt unter /).
public string PanelBasePath { get; set; } = "";
+ ///
+ /// HTTPS im Panel. Auf dem Spiel-Port immer verfügbar (https://<server>:<port>/); true = Browser, die von
+ /// außen per HTTP kommen, werden auf HTTPS umgeleitet (Passwörter nie unverschlüsselt). Bei eigenem PanelPort spricht
+ /// dieser nur mit eigenem Zertifikat (PanelCertificatePath) HTTPS. Hinter einem Reverse-Proxy (PanelTrustProxy) macht
+ /// der Proxy HTTPS.
+ ///
+ public bool PanelHttps { get; set; } = true;
+ /// Eigenes Zertifikat (PFX, Pfad absolut oder relativ zu data/) - leer = selbst signiertes in data/panel_cert.pfx.
+ public string PanelCertificatePath { get; set; } = "";
+ public string PanelCertificatePassword { get; set; } = "";
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
@@ -72,7 +82,11 @@ public sealed class ServerConfig
return config;
}
- public void Save(string path) => File.WriteAllText(path, JsonSerializer.Serialize(this, Options));
+ public void Save(string path)
+ {
+ File.WriteAllText(path, JsonSerializer.Serialize(this, Options));
+ PanelTls.Restrict(path); // enthält den Lizenzschlüssel und Passwörter (Linux: nur der Server-Benutzer)
+ }
///
/// Umgebungsvariablen überschreiben server.json (praktisch für Docker/systemd):
@@ -111,6 +125,9 @@ public sealed class ServerConfig
if (bool.TryParse(Env("MSCO_PANEL_TRUST_PROXY"), out bool trustProxy)) { PanelTrustProxy = trustProxy; used.Add("MSCO_PANEL_TRUST_PROXY"); }
PanelBindAddress = Take("MSCO_PANEL_BIND", PanelBindAddress);
PanelBasePath = Take("MSCO_PANEL_BASE_PATH", PanelBasePath);
+ if (bool.TryParse(Env("MSCO_PANEL_HTTPS"), out bool panelHttps)) { PanelHttps = panelHttps; used.Add("MSCO_PANEL_HTTPS"); }
+ PanelCertificatePath = Take("MSCO_PANEL_CERT", PanelCertificatePath);
+ PanelCertificatePassword = Take("MSCO_PANEL_CERT_PASSWORD", PanelCertificatePassword);
return used;
}
diff --git a/ServerHost.cs b/ServerHost.cs
index 1dea05b..c4493dc 100644
--- a/ServerHost.cs
+++ b/ServerHost.cs
@@ -468,7 +468,9 @@ public sealed class ServerHost
{
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
+ if (n >= 4) break;
+ // TLS (HTTPS zum Panel) beginnt mit 0x16 0x03 - auf die ersten Bytes warten; sonst kein Buchstabe = Spiel
+ if (buffer[0] != 0x16 && (buffer[0] < 'A' || buffer[0] > 'Z')) break;
await Task.Delay(15, timeout.Token);
}
}
@@ -482,6 +484,11 @@ public sealed class ServerHost
await Panel.RelayAsync(tcp);
return;
}
+ if (PanelTls.LooksLikeTls(buffer, n))
+ {
+ await Panel.RelayAsync(tcp, tls: true);
+ return;
+ }
}
var server = Server;
diff --git a/Tests/Program.cs b/Tests/Program.cs
index 199befd..141f731 100644
--- a/Tests/Program.cs
+++ b/Tests/Program.cs
@@ -129,6 +129,26 @@ try
var longChat = await bob.WaitFor(MsgType.Chat, 800);
Check(longChat == null, "Huge chat messages are not relayed");
+ Console.WriteLine("== Hostile clients");
+ alice.Send(MsgType.Chat, Wire.Build(w => w.Write("* Bob was banned by an admin")));
+ var fakeSystem = await bob.WaitFor(MsgType.Chat);
+ Check(fakeSystem != null && ReadStringAfterSender(fakeSystem) == "Bob was banned by an admin",
+ "Non-admins cannot send '* ' messages that look like server/admin messages");
+ alice.Send(MsgType.ModMessage, Wire.Build(w => { w.Write("msco.world"); w.Write(17); w.Write(new byte[17]); }));
+ Check(await bob.WaitFor(MsgType.ModMessage, 800) == null, "Reserved msco.* messages (time/weather) from non-admins are dropped");
+ alice.Send(MsgType.ModMessage, Wire.Build(w => { w.Write("somemod"); w.Write(3); w.Write(new byte[] { 1, 2, 3 }); }));
+ Check(await bob.WaitFor(MsgType.ModMessage, 2000) != null, "Normal mod messages are still relayed");
+ alice.Send(MsgType.MoneyDelta, BitConverter.GetBytes(float.NaN));
+ Check(await bob.WaitFor(MsgType.MoneyDelta, 800) == null, "NaN money changes are dropped (would break the shared money)");
+ alice.Send(MsgType.MoneyDelta, BitConverter.GetBytes(1e9f));
+ Check(await bob.WaitFor(MsgType.MoneyDelta, 800) == null, "Absurd money changes are dropped");
+ alice.Send(MsgType.MoneyDelta, BitConverter.GetBytes(25.5f));
+ Check(await bob.WaitFor(MsgType.MoneyDelta, 2000) != null, "Normal money changes are relayed");
+ alice.Send(MsgType.FsmEvent, Wire.Build(w => { w.Write("SATSUMA|x"); w.Write("Assembly"); w.Write("MSCO_INSTALL"); w.Write(""); w.Write(0f); w.Write(0f); w.Write(0f); }));
+ Check(await bob.WaitFor(MsgType.FsmEvent, 800) == null, "Admin part installs (MSCO_INSTALL) from non-admins are dropped");
+ Check(GameServer.CleanText("Eve\n[12:00] Server: you are banned\u202E", 64) == "Eve [12:00] Server: you are banned",
+ "Names/chat lose line breaks and invisible direction characters (no fake log lines)");
+
Console.WriteLine("== Bought / consumed items");
alice.Send(MsgType.ItemAppeared, Wire.Build(w => { w.Write(4242); w.Write("beer case(itemx)"); w.Write(1f); w.Write(2f); w.Write(3f); w.Write(0f); w.Write(0f); w.Write(0f); w.Write(1f); }));
Check(await bob.WaitFor(MsgType.ItemAppeared) != null, "Bought item is relayed to the other players");
@@ -419,6 +439,29 @@ async Task PanelTests()
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");
+ // HTTPS auf dem Spiel-Port (selbst signiertes Zertifikat)
+ System.Security.Cryptography.X509Certificates.X509Certificate2 seenCert = null;
+ var tlsHandler = new HttpClientHandler
+ {
+ CookieContainer = new System.Net.CookieContainer(), UseCookies = true,
+ ServerCertificateCustomValidationCallback = (_, cert, _, _) => { seenCert = cert == null ? null : new System.Security.Cryptography.X509Certificates.X509Certificate2(cert); return true; }
+ };
+ var tlsBrowser = new HttpClient(tlsHandler) { BaseAddress = new Uri($"https://127.0.0.1:{config.Port}/"), Timeout = TimeSpan.FromSeconds(10) };
+ var tlsPage = await tlsBrowser.GetAsync("");
+ Check(tlsPage.IsSuccessStatusCode && (await tlsPage.Content.ReadAsStringAsync()).Contains("MSCO Server Panel"),
+ "The panel also works over HTTPS on the game port");
+ Check(seenCert != null && host.Panel.CertificateFingerprint != null && File.Exists(Path.Combine(dir, "panel_cert.pfx"))
+ && host.Panel.CertificateFingerprint.Replace(":", "") == Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(seenCert.RawData)),
+ "A self-signed certificate is created and its fingerprint is shown");
+ var tlsLogin = new HttpRequestMessage(HttpMethod.Post, "api/login")
+ {
+ Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(new { username = "owner", password = "supersecret1" }), System.Text.Encoding.UTF8, "application/json")
+ };
+ tlsLogin.Headers.Add("X-MSCO-Panel", "1");
+ var tlsLoginRes = await tlsBrowser.SendAsync(tlsLogin);
+ Check(tlsLoginRes.IsSuccessStatusCode && tlsLoginRes.Headers.TryGetValues("Set-Cookie", out var tlsCookies)
+ && tlsCookies.Any(v => v.Contains("secure", StringComparison.OrdinalIgnoreCase)), "Over HTTPS the login cookie is marked Secure");
+
// 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");
diff --git a/WorldState.cs b/WorldState.cs
index 012a84a..421c887 100644
--- a/WorldState.cs
+++ b/WorldState.cs
@@ -158,7 +158,9 @@ public sealed class WorldState
{
ChangesSinceSave++;
if (payload.Length < 4) return;
- MoneyDelta += BitConverter.ToSingle(payload, 0);
+ float delta = BitConverter.ToSingle(payload, 0);
+ if (!float.IsFinite(delta)) return;
+ MoneyDelta += delta;
Dirty = true;
}