Server 2.10.4: HTTPS for the web panel and hostile client hardening
- Panel over HTTPS on the game port (TLS handshake detected like HTTP); self-signed certificate in data/panel_cert.pfx (fingerprint in the log) or an own PFX (PanelCertificatePath); remote HTTP is redirected to HTTPS (not locally / behind a proxy); cookie Secure over HTTPS; PanelHttps=false turns it off - Panel login: also locked per user name (16 failures / 15 min), failure lists bounded, user names in the log without control characters - panel_users.json, panel_sessions.json, server.json, certificate: owner-only on Linux - Players: names/chat without control or direction characters (no fake log lines), no '<' '>' in names; '* ' system-style chat, msco.* messages (time/weather) and MSCO_INSTALL only from admins; NaN/absurd money changes dropped (could break the shared money for good) - Extra headers: Permissions-Policy, Cross-Origin-Opener-Policy - 11 new tests (165 pass) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+81
-10
@@ -52,6 +52,10 @@ public sealed class AdminPanel
|
|||||||
public string SetupCode { get; private set; }
|
public string SetupCode { get; private set; }
|
||||||
/// <summary>Interner Port, wenn das Panel den Spiel-Port mitbenutzt.</summary>
|
/// <summary>Interner Port, wenn das Panel den Spiel-Port mitbenutzt.</summary>
|
||||||
public int InternalPort { get; private set; }
|
public int InternalPort { get; private set; }
|
||||||
|
/// <summary>Interner HTTPS-Port (gleicher Port wie das Spiel: TLS-Verbindungen landen hier), 0 = kein HTTPS.</summary>
|
||||||
|
public int InternalTlsPort { get; private set; }
|
||||||
|
/// <summary>SHA-256-Fingerabdruck des HTTPS-Zertifikats (zum Vergleichen im Browser), null = kein HTTPS.</summary>
|
||||||
|
public string CertificateFingerprint { get; private set; }
|
||||||
public bool SharesGamePort { get; private set; }
|
public bool SharesGamePort { get; private set; }
|
||||||
|
|
||||||
AdminPanel(ServerHost host, ServerConfig config, string dataDir)
|
AdminPanel(ServerHost host, ServerConfig config, string dataDir)
|
||||||
@@ -85,22 +89,36 @@ public sealed class AdminPanel
|
|||||||
SharesGamePort = config.PanelPort <= 0 || config.PanelPort == config.Port;
|
SharesGamePort = config.PanelPort <= 0 || config.PanelPort == config.Port;
|
||||||
IPAddress bind;
|
IPAddress bind;
|
||||||
int port;
|
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)
|
if (SharesGamePort)
|
||||||
{
|
{
|
||||||
bind = IPAddress.Loopback;
|
bind = IPAddress.Loopback;
|
||||||
port = InternalPort = FreeLoopbackPort();
|
port = InternalPort = FreeLoopbackPort();
|
||||||
|
if (cert != null) InternalTlsPort = FreeLoopbackPort();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
bind = ParseBind(config.PanelBindAddress);
|
bind = ParseBind(config.PanelBindAddress);
|
||||||
port = config.PanelPort;
|
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 });
|
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { ContentRootPath = AppContext.BaseDirectory });
|
||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.WebHost.ConfigureKestrel(k =>
|
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.Limits.MaxRequestBodySize = 64 * 1024;
|
||||||
k.AddServerHeader = false;
|
k.AddServerHeader = false;
|
||||||
});
|
});
|
||||||
@@ -139,10 +157,28 @@ public sealed class AdminPanel
|
|||||||
app.UsePathBase(basePath);
|
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
|
// Sicherheits-Header
|
||||||
app.Use(async (ctx, next) =>
|
app.Use(async (ctx, next) =>
|
||||||
{
|
{
|
||||||
var h = ctx.Response.Headers;
|
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-Content-Type-Options"] = "nosniff";
|
||||||
h["X-Frame-Options"] = "DENY";
|
h["X-Frame-Options"] = "DENY";
|
||||||
h["Referrer-Policy"] = "no-referrer";
|
h["Referrer-Policy"] = "no-referrer";
|
||||||
@@ -157,10 +193,11 @@ public sealed class AdminPanel
|
|||||||
app.StartAsync().GetAwaiter().GetResult();
|
app.StartAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
if (users.Empty && SetupCode == null) CreateSetupCode();
|
if (users.Empty && SetupCode == null) CreateSetupCode();
|
||||||
|
string scheme = InternalTlsPort > 0 || ownPortHttps ? "https" : "http";
|
||||||
if (SharesGamePort)
|
if (SharesGamePort)
|
||||||
GameServer.Log($"Web panel: http://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
|
GameServer.Log($"Web panel: {scheme}://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
|
||||||
else
|
else
|
||||||
GameServer.Log($"Web panel: http://{(bind.Equals(IPAddress.Any) ? "<server>" : bind.ToString())}:{port}{basePath}/");
|
GameServer.Log($"Web panel: {scheme}://{(bind.Equals(IPAddress.Any) ? "<server>" : bind.ToString())}:{port}{basePath}/");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu).</summary>
|
/// <summary>Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu).</summary>
|
||||||
@@ -248,14 +285,15 @@ public sealed class AdminPanel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Browser-Verbindung vom Spiel-Port an das intern laufende Panel weiterreichen (in beide Richtungen).</summary>
|
/// <summary>Browser-Verbindung vom Spiel-Port an das intern laufende Panel weiterreichen (in beide Richtungen).</summary>
|
||||||
public async Task RelayAsync(TcpClient browser)
|
public async Task RelayAsync(TcpClient browser, bool tls = false)
|
||||||
{
|
{
|
||||||
var real = (browser.Client.RemoteEndPoint as IPEndPoint)?.Address;
|
var real = (browser.Client.RemoteEndPoint as IPEndPoint)?.Address;
|
||||||
using var panel = new TcpClient { NoDelay = true };
|
using var panel = new TcpClient { NoDelay = true };
|
||||||
int localPort = 0;
|
int localPort = 0;
|
||||||
try
|
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;
|
localPort = ((IPEndPoint)panel.Client.LocalEndPoint).Port;
|
||||||
if (real != null) relayed[localPort] = real.IsIPv4MappedToIPv6 ? real.MapToIPv4() : real;
|
if (real != null) relayed[localPort] = real.IsIPv4MappedToIPv6 ? real.MapToIPv4() : real;
|
||||||
using var a = browser.GetStream();
|
using var a = browser.GetStream();
|
||||||
@@ -341,14 +379,21 @@ public sealed class AdminPanel
|
|||||||
if (!CsrfOk(ctx)) return Forbidden("Missing header");
|
if (!CsrfOk(ctx)) return Forbidden("Missing header");
|
||||||
if (RateLimited(ctx)) return Error(429, "Too many failed logins - wait a few minutes");
|
if (RateLimited(ctx)) return Error(429, "Too many failed logins - wait a few minutes");
|
||||||
var body = await Body(ctx);
|
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)
|
if (user == null)
|
||||||
{
|
{
|
||||||
RecordFailure(ctx);
|
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");
|
return Error(401, "Wrong username or password");
|
||||||
}
|
}
|
||||||
failures.TryRemove(Ip(ctx), out _);
|
failures.TryRemove(Ip(ctx), out _);
|
||||||
|
failures.TryRemove(userKey, out _);
|
||||||
StartSession(ctx, user.Name);
|
StartSession(ctx, user.Name);
|
||||||
GameServer.Log($"[panel] {user.Name} logged in ({Ip(ctx)})");
|
GameServer.Log($"[panel] {user.Name} logged in ({Ip(ctx)})");
|
||||||
return Results.Json(new { ok = true });
|
return Results.Json(new { ok = true });
|
||||||
@@ -903,6 +948,7 @@ public sealed class AdminPanel
|
|||||||
{
|
{
|
||||||
string tmp = SessionsPath + ".tmp";
|
string tmp = SessionsPath + ".tmp";
|
||||||
File.WriteAllText(tmp, JsonSerializer.Serialize(sessions.ToDictionary(kv => kv.Key, kv => kv.Value), SessionJson));
|
File.WriteAllText(tmp, JsonSerializer.Serialize(sessions.ToDictionary(kv => kv.Key, kv => kv.Value), SessionJson));
|
||||||
|
PanelTls.Restrict(tmp);
|
||||||
File.Move(tmp, SessionsPath, true);
|
File.Move(tmp, SessionsPath, true);
|
||||||
}
|
}
|
||||||
catch { }
|
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<DateTime>());
|
if (failures.Count > 20_000) failures.Clear(); // Speicher begrenzen (viele verschiedene IPs/Namen)
|
||||||
lock (list) list.Add(DateTime.UtcNow);
|
var list = failures.GetOrAdd(key, _ => new List<DateTime>());
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Text aus einer Anfrage fürs Log: ohne Zeilenumbrüche/Steuerzeichen (keine gefälschten Log-Zeilen), gekürzt.</summary>
|
||||||
|
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<JsonElement> Body(HttpContext ctx)
|
static async Task<JsonElement> Body(HttpContext ctx)
|
||||||
|
|||||||
+64
-5
@@ -57,6 +57,8 @@ public sealed class GameServer
|
|||||||
// ---------------------------------------------------------------- Mods (data/mods)
|
// ---------------------------------------------------------------- Mods (data/mods)
|
||||||
List<ServerMod> mods = new();
|
List<ServerMod> mods = new();
|
||||||
const int ModPartBytes = 60_000, MaxModMessageBytes = 64 * 1024;
|
const int ModPartBytes = 60_000, MaxModMessageBytes = 64 * 1024;
|
||||||
|
/// <summary>Größte Geldänderung pro Nachricht (Einkäufe, Jobs) - mehr ist ein manipulierter Client.</summary>
|
||||||
|
const float MaxMoneyDelta = 500_000f;
|
||||||
|
|
||||||
/// <summary>Mods aus data/mods (neu) laden. Neue Spieler bekommen die neue Liste; Verbundene erst beim nächsten Beitritt.</summary>
|
/// <summary>Mods aus data/mods (neu) laden. Neue Spieler bekommen die neue Liste; Verbundene erst beim nächsten Beitritt.</summary>
|
||||||
public string ReloadMods()
|
public string ReloadMods()
|
||||||
@@ -551,11 +553,31 @@ public sealed class GameServer
|
|||||||
{
|
{
|
||||||
case MsgType.ObjStates: world.ApplyObjStates(m.Payload); break;
|
case MsgType.ObjStates: world.ApplyObjStates(m.Payload); break;
|
||||||
case MsgType.ObjRelease: world.ApplyObjRelease(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:
|
case MsgType.Player:
|
||||||
if (DateTime.UtcNow >= c.TrackPositionFrom) world.ApplyPlayer(c.SteamId, m.Payload);
|
if (DateTime.UtcNow >= c.TrackPositionFrom) world.ApplyPlayer(c.SteamId, m.Payload);
|
||||||
break;
|
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.DoorState: world.ApplyDoor(m.Payload); break;
|
||||||
case MsgType.VarSync: world.ApplyVars(m.Payload); break;
|
case MsgType.VarSync: world.ApplyVars(m.Payload); break;
|
||||||
case MsgType.WorldVars: world.ApplyJobVars(m.Payload); break;
|
case MsgType.WorldVars: world.ApplyJobVars(m.Payload); break;
|
||||||
@@ -578,11 +600,28 @@ public sealed class GameServer
|
|||||||
break;
|
break;
|
||||||
case MsgType.ModMessage:
|
case MsgType.ModMessage:
|
||||||
if (m.Payload.Length > MaxModMessageBytes) return;
|
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;
|
break;
|
||||||
case MsgType.Chat:
|
case MsgType.Chat:
|
||||||
|
{
|
||||||
if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten
|
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;
|
break;
|
||||||
|
}
|
||||||
case MsgType.TrafficStates:
|
case MsgType.TrafficStates:
|
||||||
if (c.Id != WorldAuthorityId) return; // nur einer simuliert den Verkehr
|
if (c.Id != WorldAuthorityId) return; // nur einer simuliert den Verkehr
|
||||||
break;
|
break;
|
||||||
@@ -607,8 +646,8 @@ public sealed class GameServer
|
|||||||
steamId = r.ReadUInt64();
|
steamId = r.ReadUInt64();
|
||||||
ticket = r.ReadBytes(r.ReadInt32());
|
ticket = r.ReadBytes(r.ReadInt32());
|
||||||
}
|
}
|
||||||
c.Name = string.IsNullOrWhiteSpace(c.Name) ? "Player" : c.Name.Trim();
|
c.Name = CleanText(c.Name ?? "", 24).Replace("<", "").Replace(">", "").Trim();
|
||||||
if (c.Name.Length > 24) c.Name = c.Name[..24];
|
if (c.Name.Length == 0) c.Name = "Player";
|
||||||
// Spieler ohne Steam (Non-Steam-Server) werden über ihren Namen wiedererkannt (Position, Sperren)
|
// Spieler ohne Steam (Non-Steam-Server) werden über ihren Namen wiedererkannt (Position, Sperren)
|
||||||
c.SteamId = steamId != 0 ? steamId.ToString() : "name:" + c.Name.ToLowerInvariant();
|
c.SteamId = steamId != 0 ? steamId.ToString() : "name:" + c.Name.ToLowerInvariant();
|
||||||
|
|
||||||
@@ -1199,6 +1238,26 @@ public sealed class GameServer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
public static void Log(string text)
|
||||||
{
|
{
|
||||||
string time = DateTime.Now.ToString("HH:mm:ss");
|
string time = DateTime.Now.ToString("HH:mm:ss");
|
||||||
|
|||||||
+1
-1
@@ -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.3</Version>
|
<Version>2.10.4</Version>
|
||||||
<Company>MBM</Company>
|
<Company>MBM</Company>
|
||||||
<Product>MSCO Dedicated Server</Product>
|
<Product>MSCO Dedicated Server</Product>
|
||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
namespace MSCO.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class PanelTls
|
||||||
|
{
|
||||||
|
public const string FileName = "panel_cert.pfx";
|
||||||
|
|
||||||
|
public static X509Certificate2 Load(ServerConfig config, string dataDir, Action<string> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Datei nur für den Benutzer des Servers lesbar (Linux).</summary>
|
||||||
|
public static void Restrict(string file)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows()) return;
|
||||||
|
try { File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Beginnen die Bytes wie ein TLS-Handshake (ClientHello: 16 03 0x)?</summary>
|
||||||
|
public static bool LooksLikeTls(byte[] b, int n) => n >= 3 && b[0] == 0x16 && b[1] == 0x03 && b[2] <= 0x04;
|
||||||
|
}
|
||||||
@@ -146,6 +146,7 @@ public sealed class PanelUsers
|
|||||||
{
|
{
|
||||||
string tmp = path + ".tmp";
|
string tmp = path + ".tmp";
|
||||||
File.WriteAllText(tmp, JsonSerializer.Serialize(new FileData { Users = users }, Options));
|
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())
|
if (!OperatingSystem.IsWindows())
|
||||||
try { File.SetUnixFileMode(tmp, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
|
try { File.SetUnixFileMode(tmp, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
|
||||||
File.Move(tmp, path, true);
|
File.Move(tmp, path, true);
|
||||||
|
|||||||
@@ -128,9 +128,17 @@ Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvari
|
|||||||
## Web-Panel
|
## Web-Panel
|
||||||
|
|
||||||
Der Server bringt ein Web-Panel mit – **auf demselben Port wie das Spiel** (Standard `7777`). Einfach im Browser
|
Der Server bringt ein Web-Panel mit – **auf demselben Port wie das Spiel** (Standard `7777`). Einfach im Browser
|
||||||
`http://<server-ip>:7777/` öffnen; der Server erkennt selbst, ob ein Browser oder das Spiel verbindet. Es muss also
|
`https://<server-ip>:7777/` öffnen; der Server erkennt selbst, ob ein Browser oder das Spiel verbindet. Es muss also
|
||||||
kein weiterer Port freigegeben werden.
|
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 |
|
| Spieler | Live-Konsole |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
@@ -322,6 +330,13 @@ Reset-Punkt.
|
|||||||
Start), und eine unbestätigte SteamID kann keinen verbundenen Spieler verdrängen.
|
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
|
- 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.
|
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
|
## Lizenz
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -54,6 +54,16 @@ public sealed class ServerConfig
|
|||||||
public bool PanelTrustProxy { get; set; } = false;
|
public bool PanelTrustProxy { get; set; } = false;
|
||||||
/// <summary>Unterpfad, unter dem das Panel läuft, z.B. "/panel" (leer = direkt unter /).</summary>
|
/// <summary>Unterpfad, unter dem das Panel läuft, z.B. "/panel" (leer = direkt unter /).</summary>
|
||||||
public string PanelBasePath { get; set; } = "";
|
public string PanelBasePath { get; set; } = "";
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool PanelHttps { get; set; } = true;
|
||||||
|
/// <summary>Eigenes Zertifikat (PFX, Pfad absolut oder relativ zu data/) - leer = selbst signiertes in data/panel_cert.pfx.</summary>
|
||||||
|
public string PanelCertificatePath { get; set; } = "";
|
||||||
|
public string PanelCertificatePassword { get; set; } = "";
|
||||||
|
|
||||||
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
|
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
|
||||||
|
|
||||||
@@ -72,7 +82,11 @@ public sealed class ServerConfig
|
|||||||
return config;
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Umgebungsvariablen überschreiben server.json (praktisch für Docker/systemd):
|
/// 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"); }
|
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);
|
PanelBindAddress = Take("MSCO_PANEL_BIND", PanelBindAddress);
|
||||||
PanelBasePath = Take("MSCO_PANEL_BASE_PATH", PanelBasePath);
|
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;
|
return used;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -468,7 +468,9 @@ public sealed class ServerHost
|
|||||||
{
|
{
|
||||||
n = await tcp.Client.ReceiveAsync(buffer, SocketFlags.Peek, timeout.Token);
|
n = await tcp.Client.ReceiveAsync(buffer, SocketFlags.Peek, timeout.Token);
|
||||||
if (n == 0) { tcp.Close(); return; }
|
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);
|
await Task.Delay(15, timeout.Token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,6 +484,11 @@ public sealed class ServerHost
|
|||||||
await Panel.RelayAsync(tcp);
|
await Panel.RelayAsync(tcp);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (PanelTls.LooksLikeTls(buffer, n))
|
||||||
|
{
|
||||||
|
await Panel.RelayAsync(tcp, tls: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var server = Server;
|
var server = Server;
|
||||||
|
|||||||
@@ -129,6 +129,26 @@ try
|
|||||||
var longChat = await bob.WaitFor(MsgType.Chat, 800);
|
var longChat = await bob.WaitFor(MsgType.Chat, 800);
|
||||||
Check(longChat == null, "Huge chat messages are not relayed");
|
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");
|
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); }));
|
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");
|
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"),
|
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");
|
"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
|
// Spiel läuft auf demselben Port weiter
|
||||||
var gamer = await TestClient.Join("PanelTarget", 0, null);
|
var gamer = await TestClient.Join("PanelTarget", 0, null);
|
||||||
Check(gamer.Welcome != null, "Game clients still join on the same port");
|
Check(gamer.Welcome != null, "Game clients still join on the same port");
|
||||||
|
|||||||
+3
-1
@@ -158,7 +158,9 @@ public sealed class WorldState
|
|||||||
{
|
{
|
||||||
ChangesSinceSave++;
|
ChangesSinceSave++;
|
||||||
if (payload.Length < 4) return;
|
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;
|
Dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user