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 | |---|---| | ![Spielerliste mit Kick, Ban und Admin-Rechten](docs/images/panel-players.png) | ![Live-Konsole mit Chat, Beitritten und Befehlseingabe](docs/images/panel-console.png) | @@ -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; }