From 91ac52626b666fe71ac85c6c15dd867829a223ac Mon Sep 17 00:00:00 2001 From: FangkuaiYa <2683748223@qq.com> Date: Thu, 1 Oct 2026 00:00:15 +0800 Subject: [PATCH 1/3] Add rate limiting --- README.md | 43 +++++ marketplace/Empostor.Plugins.PlayerLog.dll | Bin 22528 -> 22528 bytes src/Empostor.Api/Config/AntiCheatConfig.cs | 6 + src/Empostor.Api/Languages/LanguageService.cs | 15 ++ src/Empostor.Server/Http/AdminController.cs | 23 +-- .../Http/IpRateLimitMiddleware.cs | 160 ++++++++++++++++++ .../Http/IpRateLimitService.cs | 157 +++++++++++++++++ src/Empostor.Server/Http/RealIpResolver.cs | 36 ++++ src/Empostor.Server/Http/TokenController.cs | 79 ++++++--- src/Empostor.Server/Languages/de.json | 3 +- src/Empostor.Server/Languages/en.json | 3 +- src/Empostor.Server/Languages/es.json | 3 +- src/Empostor.Server/Languages/fil.json | 3 +- src/Empostor.Server/Languages/fr.json | 3 +- src/Empostor.Server/Languages/ga.json | 3 +- src/Empostor.Server/Languages/it.json | 3 +- src/Empostor.Server/Languages/ja.json | 3 +- src/Empostor.Server/Languages/ko.json | 3 +- src/Empostor.Server/Languages/nl.json | 3 +- src/Empostor.Server/Languages/pt.json | 3 +- src/Empostor.Server/Languages/pt_BR.json | 3 +- src/Empostor.Server/Languages/ru.json | 3 +- src/Empostor.Server/Languages/zh_CN.json | 3 +- src/Empostor.Server/Languages/zh_TW.json | 3 +- .../Net/IDeltaListenerManager.cs | 9 + .../Net/Manager/ClientManager.cs | 85 +++++++++- src/Empostor.Server/Net/Matchmaker.cs | 106 +++++++++++- src/Empostor.Server/Program.cs | 5 + src/Empostor.Server/config.json | 5 +- 29 files changed, 710 insertions(+), 64 deletions(-) create mode 100644 src/Empostor.Server/Http/IpRateLimitMiddleware.cs create mode 100644 src/Empostor.Server/Http/IpRateLimitService.cs create mode 100644 src/Empostor.Server/Http/RealIpResolver.cs diff --git a/README.md b/README.md index 28249e1..69c4baf 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,49 @@ It is also recommended to [set up a Reverse Proxy](https://empostor.github.io/ge 4. Press "Open in Among Us". Among Us should open. 5. Your server should appear in the regions list! +## Configuration + +All settings live in `config.json` next to the server binary (a full sample is [`src/Empostor.Server/config.json`](src/Empostor.Server/config.json)). Unknown keys are ignored, so an old config keeps working after an update. + +| Section | Purpose | +| --- | --- | +| `Server` | Public IP/port, listen address and the dynamic (delta) UDP port range `DeltaPortStart`..`DeltaPortEnd` | +| `HttpServer` | Web API, admin panel and matchmaking token endpoint (`Enabled`, `ListenIp`, `ListenPort`) | +| `AntiCheat` | Server-side anticheat, including the [IP request rate limit](#ip-request-rate-limit) | +| `Timeout` | Spawn and connection timeouts | +| `Compatibility` | Game version compatibility rules | +| `Debug` | Game recorder | +| `AuthApi` | Friend code resolution (`Mode`: `GameServices`, `Ume`, `Both` or `Off`) | +| `Admin` | Admin panel password and marketplace URL | +| `HPLP` | HPLP public region listing | + +### AntiCheat + +| Key | Default | Description | +| --- | --- | --- | +| `Enabled` | `true` | Master switch of the anticheat | +| `BanIpFromGame` | `true` | Also block banned IPs on the game (UDP) side | +| `AllowCheatingHosts` | `Never` | What happens to hosts that cheat | +| `EnableGameFlowChecks`, `EnableMustBeHostChecks`, `EnableOwnershipChecks`, `EnableRoleChecks`, `EnableTargetChecks` | `true` | Game flow / state validation | +| `EnableColorLimitChecks`, `EnableNameLimitChecks`, `EnableItemLimitChecks` | `true` | Lobby limits (colors, names, items) | +| `ForbidProtocolExtensions` | `true` | Reject unknown/extension protocol messages | +| `EnablePacketSizeChecks`, `PacketSizeLimit` | `true`, `1203` | Maximum inbound packet size | +| `EnableIpRateLimit` | `true` | IP request rate limit (below) | +| `IpRateLimitRequests` | `30` | Max TCP (HTTP) requests per IP per window, `0` disables the limit | +| `IpRateLimitWindowMinutes` | `5` | Length of the rate limit window in minutes | + +#### Ip request rate limit + +Within one window (5 minutes by default) the same IP may only make `IpRateLimitRequests` (default `30`) TCP (HTTP) requests — this protects the HTTP API against connection/request flooding. When the limit is exceeded: + +- Every normal endpoint answers with `429 Too Many Requests` and a localized hint, chosen from the request's `Accept-Language` header, e.g. `请求过于频繁请 5 分钟后重试`. The `Retry-After` header says how many seconds are left. +- `POST /api/user` **and** `PUT`/`GET /api/games` (the whole join flow of the game client: matchmaking token + FindHost) are **never rejected over HTTP** — failing them there would only leave the client on a black screen, which just logs `CoSendRequest failed` and hangs. Both still count against the quota, and while an IP is over quota the server also skips the outbound friend code API calls (a cached/fallback code is used instead) so the endpoint cannot be abused for amplification. To keep a flooding IP from draining the dynamic port range, such joins are **not given a pool port**: they all receive the same shared *reject port*, a single pre-bound UDP socket whose only job is to answer the handshake with the localized hint in the client's own language (`请求过于频繁请 5 分钟后重试`) and close it again — the player is told why they cannot join instead of seeing a black screen, and only one socket is used no matter how many requests arrive. `GamesController` echoes the token's port back, so the client is naturally sent to that reject port. In fixed-port mode (`DeltaPortStart=0`) the main port is used instead. +- `/admin` and `/api/admin` are neither counted nor limited, so the dashboard can never lock the operator out. + +The message lives under the key `ratelimit.too_frequent` in `Languages/*.json` (and in the built-in defaults in `LanguageService.cs`), so you can reword or translate it there. Set `IpRateLimitRequests` to `0` to turn the whole feature off. + +Empostor usually runs behind a CDN or reverse proxy, so the real client IP is taken from the `X-Real-IP` header, then from the first entry of `X-Forwarded-For`, and only afterwards from the socket address. Make sure your CDN/proxy forwards one of these headers — otherwise every player would share the proxy IP's quota. Note that the game client polls some endpoints (e.g. `/api/games`) while you play: if `30` per 5 minutes is too tight for your players, raise `IpRateLimitRequests` (for example to `120`). + ## Contributing Please read the [contributing guidelines](https://github.com/Empostor/Empostor/blob/main/CONTRIBUTING.md). diff --git a/marketplace/Empostor.Plugins.PlayerLog.dll b/marketplace/Empostor.Plugins.PlayerLog.dll index 9dba402f7003c9ee3e52c583b49a176fcd95adc9..cfad9c5d011a768cb11794dd5ee20da07a48f7e3 100644 GIT binary patch delta 235 zcmZqJz}T>XaY6^nr+eQ|ZtO8|5xBnW#d-f4evcg-Gry-Fd+oLPjEfGtdZML8ikWdr zl97RVqG@unnORzDN|I@kiE&a|Vw$C)Nt$VzsacBoW_ce)MwSR};qJ*Uz5xOaK|40Q zdEo86WBklb87BDL@6SQ=o!C)!h8{OP(_>K7Z-$xt02xjr?+0eG?fh87vr5 z7|a-qfn*Yc5rYAPIYT0YDMK<4ngL-NLn>It6ew!~RFwo2Nd#g`216h}4X6@?&46mm KH@^-RWdQ(xhfMXaY6^n)YY43Z0s>`5eU7t?ckBV3Wt^mRh!4}mcHM7#zlu+J=wxI$l0V%o$REY-5IGAUlb{6o``;On^K~AT|c_je#o67}6LL8B&2P5Z?r-&kTr7fojYc Lk~Y5%7G(hduTe{2 diff --git a/src/Empostor.Api/Config/AntiCheatConfig.cs b/src/Empostor.Api/Config/AntiCheatConfig.cs index db7fc64..ad169dd 100644 --- a/src/Empostor.Api/Config/AntiCheatConfig.cs +++ b/src/Empostor.Api/Config/AntiCheatConfig.cs @@ -33,5 +33,11 @@ namespace Empostor.Api.Config public bool EnablePacketSizeChecks { get; set; } = true; public int PacketSizeLimit { get; set; } = 1203; + + public bool EnableIpRateLimit { get; set; } = true; + + public int IpRateLimitRequests { get; set; } = 30; + + public int IpRateLimitWindowMinutes { get; set; } = 5; } } diff --git a/src/Empostor.Api/Languages/LanguageService.cs b/src/Empostor.Api/Languages/LanguageService.cs index dd5606a..dc0bb60 100644 --- a/src/Empostor.Api/Languages/LanguageService.cs +++ b/src/Empostor.Api/Languages/LanguageService.cs @@ -155,6 +155,7 @@ public sealed class LanguageService // Any Pr fix grammer wrongs is OK! private const string DefaultEn = """ { + "ratelimit.too_frequent": "Too many requests, please try again in {0} minute(s).", "command.error": "An error occurred while executing #{0}.", "command.usage": "Usage: #{0}", "command.help.list": "=== Commands ===", @@ -215,6 +216,7 @@ public sealed class LanguageService private const string DefaultZhCn = """ { + "ratelimit.too_frequent": "请求过于频繁请 {0} 分钟后重试", "command.error": "执行 #{0}时发生错误。", "command.usage": "用法:#{0}", "command.help.list": "=== 指令列表 ===", @@ -275,6 +277,7 @@ public sealed class LanguageService private const string DefaultZhTw = """ { + "ratelimit.too_frequent": "請求過於頻繁請 {0} 分鐘後重試", "command.error": "執行 #{0}時發生錯誤。", "command.usage": "用法:#{0}", "command.help.list": "=== 指令列表 ===", @@ -335,6 +338,7 @@ public sealed class LanguageService private const string DefaultKo = """ { + "ratelimit.too_frequent": "요청이 너무 많습니다. {0}분 후에 다시 시도해 주세요.", "command.error": "#{0}실행 중 오류가 발생했습니다.", "command.usage": "사용법: #{0}", "command.help.list": "=== 명령어 목록 ===", @@ -381,6 +385,7 @@ public sealed class LanguageService private const string DefaultRu = """ { + "ratelimit.too_frequent": "Слишком много запросов, повторите попытку через {0} мин.", "command.error": "Ошибка при выполнении #{0}.", "command.usage": "Использование: #{0}", "command.help.list": "=== Команды ===", @@ -427,6 +432,7 @@ public sealed class LanguageService private const string DefaultDe = """ { + "ratelimit.too_frequent": "Zu viele Anfragen, bitte versuche es in {0} Minute(n) erneut.", "command.error": "Fehler beim Ausführen von #{0}.", "command.usage": "Verwendung: #{0}", "command.help.list": "=== Befehle ===", @@ -473,6 +479,7 @@ public sealed class LanguageService private const string DefaultFr = """ { + "ratelimit.too_frequent": "Trop de requêtes, veuillez réessayer dans {0} minute(s).", "command.error": "Erreur lors de l'exécution de #{0}.", "command.usage": "Utilisation : #{0}", "command.help.list": "=== Commandes ===", @@ -519,6 +526,7 @@ public sealed class LanguageService private const string DefaultJa = """ { + "ratelimit.too_frequent": "リクエストが多すぎます。{0}分後に再試行してください。", "command.error": "#{0}の実行中にエラーが発生しました。", "command.usage": "使い方:#{0}", "command.help.list": "=== コマンド一覧 ===", @@ -565,6 +573,7 @@ public sealed class LanguageService private const string DefaultPt = """ { + "ratelimit.too_frequent": "Muitos pedidos, tente novamente em {0} minuto(s).", "command.error": "Ocorreu um erro ao executar #{0}.", "command.usage": "Uso: #{0}", "command.help.list": "=== Comandos ===", @@ -611,6 +620,7 @@ public sealed class LanguageService private const string DefaultPtBr = """ { + "ratelimit.too_frequent": "Muitas solicitações, tente novamente em {0} minuto(s).", "command.error": "Ocorreu um erro ao executar #{0}.", "command.usage": "Uso: #{0}", "command.help.list": "=== Comandos ===", @@ -655,6 +665,7 @@ public sealed class LanguageService private const string DefaultEs = """ { + "ratelimit.too_frequent": "Demasiadas solicitudes, inténtalo de nuevo en {0} minuto(s).", "command.error": "Error al ejecutar #{0}.", "command.usage": "Uso: #{0}", "command.help.list": "=== Comandos ===", @@ -699,6 +710,7 @@ public sealed class LanguageService private const string DefaultIt = """ { + "ratelimit.too_frequent": "Troppe richieste, riprova tra {0} minuto/i.", "command.error": "Errore durante l'esecuzione di #{0}.", "command.usage": "Utilizzo: #{0}", "command.help.list": "=== Comandi ===", @@ -743,6 +755,7 @@ public sealed class LanguageService private const string DefaultNl = """ { + "ratelimit.too_frequent": "Te veel verzoeken, probeer het over {0} minuut/minuten opnieuw.", "command.error": "Fout bij uitvoeren van #{0}.", "command.usage": "Gebruik: #{0}", "command.help.list": "=== Commando's ===", @@ -787,6 +800,7 @@ public sealed class LanguageService private const string DefaultFil = """ { + "ratelimit.too_frequent": "Masyaraming request, subukan muli sa {0} minuto.", "command.error": "May error sa pagpapatakbo ng #{0}.", "command.usage": "Paggamit: #{0}", "command.help.list": "=== Mga Command ===", @@ -831,6 +845,7 @@ public sealed class LanguageService private const string DefaultGa = """ { + "ratelimit.too_frequent": "Iarraidh ró-mhóra, bain triail eile as i {0} nóiméad.", "command.error": "Earráid agus #{0}á rith.", "command.usage": "Úsáid: #{0}", "command.help.list": "=== Orduithe ===", diff --git a/src/Empostor.Server/Http/AdminController.cs b/src/Empostor.Server/Http/AdminController.cs index 9c77d4a..46be14b 100644 --- a/src/Empostor.Server/Http/AdminController.cs +++ b/src/Empostor.Server/Http/AdminController.cs @@ -827,31 +827,12 @@ namespace Empostor.Server.Http private IPAddress? GetClientIp() { - // Check reverse-proxy headers first so we get the real client IP, + // CDN / reverse proxy headers first so we get the real client IP, // not the CDN/nginx IP. Using Connection.RemoteIpAddress directly // would rate-limit or ban the proxy instead of the actual user. - var xRealIp = HttpContext.Request.Headers["X-Real-IP"].FirstOrDefault(); - if (!string.IsNullOrEmpty(xRealIp) && IPAddress.TryParse(xRealIp, out var realIp)) - { - return Normalize(realIp); - } - - var xForwardedFor = HttpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - if (!string.IsNullOrEmpty(xForwardedFor)) - { - var first = xForwardedFor.Split(',')[0].Trim(); - if (IPAddress.TryParse(first, out var fwdIp)) - { - return Normalize(fwdIp); - } - } - - return Normalize(HttpContext.Connection.RemoteIpAddress); + return RealIpResolver.Resolve(HttpContext); } - private static IPAddress? Normalize(IPAddress? ip) - => ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4() : ip; - private static object Err(string msg) => new { error = msg }; private static string Fmt(TimeSpan t) diff --git a/src/Empostor.Server/Http/IpRateLimitMiddleware.cs b/src/Empostor.Server/Http/IpRateLimitMiddleware.cs new file mode 100644 index 0000000..286726a --- /dev/null +++ b/src/Empostor.Server/Http/IpRateLimitMiddleware.cs @@ -0,0 +1,160 @@ +using System; +using System.Globalization; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Tasks; +using Empostor.Api.Languages; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Empostor.Server.Http; + +// Anticheat "Ip request rate limit": rejects TCP (HTTP) requests of IPs that +// exceeded the configured quota and answers with a localized hint. +public sealed class IpRateLimitMiddleware +{ + private const string MessageKey = IpRateLimitService.MessageKey; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + private readonly RequestDelegate _next; + private readonly IpRateLimitService _rateLimit; + private readonly LanguageService _language; + private readonly ILogger _logger; + + public IpRateLimitMiddleware( + RequestDelegate next, + IpRateLimitService rateLimit, + LanguageService language, + ILogger logger) + { + _next = next; + _rateLimit = rateLimit; + _language = language; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + // The admin panel polls its own endpoints, so it neither consumes the + // quota nor is ever rate-limited. + if (IsAdminPath(context.Request.Path)) + { + await _next(context); + return; + } + + var ip = RealIpResolver.Resolve(context); + + // Always counted, so the join endpoint cannot be used to burn through + // the quota without being noticed. + var overQuota = !_rateLimit.TryRegister(ip, out var retryAfter); + + // The join flow of the game client (POST /api/user, then + // PUT/GET /api/games for FindHost) must never fail over HTTP: the + // client only logs such a failure and then hangs on a black screen. + // These endpoints are therefore counted but never blocked — over-quota + // players still take no pool port and no outbound auth call, and are + // pointed at one shared reject port whose listener sends the localized + // hint in-game (see TokenController / Matchmaker). + if (!overQuota || IsGameFlowPath(context.Request.Path)) + { + await _next(context); + return; + } + + var minutes = IpRateLimitService.MinutesUntilRetry(retryAfter); + + var message = _language + .Get(MessageKey, ResolveLangCode(context)) + .Format(minutes) + .Get(); + + _logger.LogWarning( + "IpRateLimit blocked {Ip} on {Path} │ retry in {Minutes}m", + ip?.ToString() ?? "unknown", + context.Request.Path.Value ?? "/", + minutes); + + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.Headers["Retry-After"] = + ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture); + context.Response.ContentType = "application/json; charset=utf-8"; + await context.Response.WriteAsync(JsonSerializer.Serialize(new { error = message }, JsonOptions)); + } + + // The admin panel polls its own endpoints, so it is never rate-limited. + private static bool IsAdminPath(PathString path) + => path.StartsWithSegments("/admin", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/api/admin", StringComparison.OrdinalIgnoreCase); + + // The join flow of the game client: matchmaking token + FindHost. + // GamesController echoes the token's port back, so the reject port handed + // out by /api/user is what the client connects to. + private static bool IsGameFlowPath(PathString path) + => path.StartsWithSegments("/api/user", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/api/games", StringComparison.OrdinalIgnoreCase); + + private static string ResolveLangCode(HttpContext context) + { + var acceptLanguage = context.Request.Headers["Accept-Language"].ToString(); + foreach (var entry in acceptLanguage.Split(',')) + { + var tag = entry.Split(';')[0].Trim(); + if (tag.Length == 0 || tag == "*") + { + continue; + } + + var code = MapLangTag(tag); + if (code != null) + { + return code; + } + } + + return "en"; + } + + // Maps a browser/client language tag (zh-CN, pt-BR, ...) to one of the + // server's Languages/*.json codes. + private static string? MapLangTag(string tag) + { + var lower = tag.ToLowerInvariant(); + + if (lower.StartsWith("zh", StringComparison.Ordinal)) + { + return lower.Contains("hant", StringComparison.Ordinal) + || lower.Contains("tw", StringComparison.Ordinal) + || lower.Contains("hk", StringComparison.Ordinal) + || lower.Contains("mo", StringComparison.Ordinal) + ? "zh_TW" + : "zh_CN"; + } + + if (lower.StartsWith("pt-br", StringComparison.Ordinal)) + { + return "pt_BR"; + } + + if (lower.StartsWith("fil", StringComparison.Ordinal) || lower.StartsWith("tl", StringComparison.Ordinal)) + { + return "fil"; + } + + foreach (var code in new[] { "en", "es", "pt", "ko", "ru", "nl", "fr", "de", "it", "ja", "ga" }) + { + if (lower.StartsWith(code, StringComparison.Ordinal)) + { + return code; + } + } + + return null; + } +} diff --git a/src/Empostor.Server/Http/IpRateLimitService.cs b/src/Empostor.Server/Http/IpRateLimitService.cs new file mode 100644 index 0000000..39d5444 --- /dev/null +++ b/src/Empostor.Server/Http/IpRateLimitService.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Concurrent; +using System.Net; +using Empostor.Api.Config; +using Microsoft.Extensions.Options; + +namespace Empostor.Server.Http; + +// Per-IP request counter used by the anticheat "Ip request rate limit". +// Every real IP gets a fixed window (default 5 minutes); when the IP sends +// more TCP (HTTP) requests than configured (default 30) it is blocked until +// the window slides away. +public sealed class IpRateLimitService +{ + // Language key of the localized hint, see Languages/*.json. + public const string MessageKey = "ratelimit.too_frequent"; + + private static readonly TimeSpan CleanupInterval = TimeSpan.FromSeconds(60); + + private readonly IOptions _options; + private readonly ConcurrentDictionary _windows = new(); + private DateTime _lastCleanupUtc = DateTime.MinValue; + + public IpRateLimitService(IOptions options) + { + _options = options; + } + + // Counts the request against the IP. Returns false when the IP exhausted + // its quota; retryAfter then tells how long the caller still has to wait. + public bool TryRegister(IPAddress? ip, out TimeSpan retryAfter) + { + retryAfter = TimeSpan.Zero; + + var config = _options.Value; + var window = GetWindow(config); + if (ip == null || window == null || config.IpRateLimitRequests <= 0) + { + return true; + } + + var value = window.Value; + Cleanup(value); + + var now = DateTime.UtcNow; + var entry = _windows.GetOrAdd(ip.ToString(), _ => new Window(now)); + + lock (entry) + { + if (now - entry.StartUtc >= value) + { + entry.StartUtc = now; + entry.Count = 0; + } + + entry.Count++; + if (entry.Count <= config.IpRateLimitRequests) + { + return true; + } + + // Blocked requests do not restart the window, so the lockout ends + // at most one full window after the first request of the burst. + retryAfter = entry.StartUtc + value - now; + if (retryAfter < TimeSpan.Zero) + { + retryAfter = TimeSpan.Zero; + } + + return false; + } + } + + // Reads the quota without counting a request — used to answer the client + // in-game (and to skip expensive work) when the IP is already over quota. + public bool IsLimited(IPAddress? ip, out TimeSpan retryAfter) + { + retryAfter = TimeSpan.Zero; + + var config = _options.Value; + var window = GetWindow(config); + if (ip == null || window == null || config.IpRateLimitRequests <= 0) + { + return false; + } + + if (!_windows.TryGetValue(ip.ToString(), out var entry)) + { + return false; + } + + var now = DateTime.UtcNow; + lock (entry) + { + if (now - entry.StartUtc >= window.Value || entry.Count <= config.IpRateLimitRequests) + { + return false; + } + + retryAfter = entry.StartUtc + window.Value - now; + if (retryAfter < TimeSpan.Zero) + { + retryAfter = TimeSpan.Zero; + } + + return true; + } + } + + private static TimeSpan? GetWindow(AntiCheatConfig config) + { + if (!config.EnableIpRateLimit) + { + return null; + } + + return TimeSpan.FromMinutes(Math.Max(1, config.IpRateLimitWindowMinutes)); + } + + // Whole minutes to wait, always at least one so the hint never says + // "please try again in 0 minutes". + public static int MinutesUntilRetry(TimeSpan retryAfter) + { + var minutes = (int)Math.Ceiling(retryAfter.TotalMinutes); + return minutes < 1 ? 1 : minutes; + } + + private void Cleanup(TimeSpan window) + { + var now = DateTime.UtcNow; + if (now - _lastCleanupUtc < CleanupInterval) + { + return; + } + + _lastCleanupUtc = now; + foreach (var pair in _windows) + { + if (now - pair.Value.StartUtc > window + CleanupInterval) + { + _windows.TryRemove(pair.Key, out _); + } + } + } + + private sealed class Window + { + public Window(DateTime startUtc) + { + StartUtc = startUtc; + } + + public DateTime StartUtc { get; set; } + + public int Count { get; set; } + } +} diff --git a/src/Empostor.Server/Http/RealIpResolver.cs b/src/Empostor.Server/Http/RealIpResolver.cs new file mode 100644 index 0000000..ea30e2d --- /dev/null +++ b/src/Empostor.Server/Http/RealIpResolver.cs @@ -0,0 +1,36 @@ +using System.Linq; +using System.Net; +using Microsoft.AspNetCore.Http; + +namespace Empostor.Server.Http; + +// Resolves the real client IP of an HTTP (TCP) request. +// Behind a CDN / reverse proxy Connection.RemoteIpAddress is the proxy's +// address, so the forwarding headers are checked first — otherwise rate +// limiting or banning would hit the CDN node instead of the actual user. +public static class RealIpResolver +{ + public static IPAddress? Resolve(HttpContext context) + { + var xRealIp = context.Request.Headers["X-Real-IP"].FirstOrDefault(); + if (!string.IsNullOrEmpty(xRealIp) && IPAddress.TryParse(xRealIp, out var realIp)) + { + return Normalize(realIp); + } + + var xForwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrEmpty(xForwardedFor)) + { + var first = xForwardedFor.Split(',')[0].Trim(); + if (IPAddress.TryParse(first, out var forwardedIp)) + { + return Normalize(forwardedIp); + } + } + + return Normalize(context.Connection.RemoteIpAddress); + } + + private static IPAddress? Normalize(IPAddress? ip) + => ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4() : ip; +} diff --git a/src/Empostor.Server/Http/TokenController.cs b/src/Empostor.Server/Http/TokenController.cs index 4b13840..57aadab 100644 --- a/src/Empostor.Server/Http/TokenController.cs +++ b/src/Empostor.Server/Http/TokenController.cs @@ -36,6 +36,7 @@ public sealed class TokenController : ControllerBase private readonly AuthApiConfig _authApiConfig; private readonly PortPoolService _portPool; private readonly IDeltaListenerManager _deltaListenerManager; + private readonly IpRateLimitService _rateLimit; public TokenController( ILogger logger, @@ -43,7 +44,8 @@ public sealed class TokenController : ControllerBase AuthCacheService authCache, IOptions authApiConfig, PortPoolService portPool, - IDeltaListenerManager deltaListenerManager) + IDeltaListenerManager deltaListenerManager, + IpRateLimitService rateLimit) { _logger = logger; _httpClientFactory = httpClientFactory; @@ -51,6 +53,7 @@ public sealed class TokenController : ControllerBase _authApiConfig = authApiConfig.Value; _portPool = portPool; _deltaListenerManager = deltaListenerManager; + _rateLimit = rateLimit; } [HttpPost] @@ -74,9 +77,44 @@ public sealed class TokenController : ControllerBase return Unauthorized(new { error = "Invalid token content" }); } - var friendCode = await GetFriendCodeAsync(eosToken, productUserId); - var matchmakerToken = GenerateMatchmakerToken(productUserId); var clientIp = GetClientIp(); + var friendCode = await GetFriendCodeAsync(eosToken, productUserId, clientIp); + var matchmakerToken = GenerateMatchmakerToken(productUserId); + + // Over quota: never take a pool port for a join that cannot succeed + // — one port per blocked request would let a single IP drain the + // whole delta range. The client is pointed at the shared reject + // port instead, whose listener answers with the localized hint: no + // game, no black screen, one socket. Port 0 (fixed-port mode) means + // the main port, which rejects it the same way. + if (_rateLimit.IsLimited(clientIp, out var joinRetryAfter)) + { + var rejectPort = await _deltaListenerManager.GetRateLimitRejectPortAsync(); + if (rejectPort > 0 || !_portPool.IsEnabled) + { + _logger.LogWarning( + "TokenUser {Puid} {Ip} over the request rate limit, sent to reject port {Port} (retry in {Seconds}s).", + productUserId, clientIp, rejectPort, + (int)Math.Ceiling(joinRetryAfter.TotalSeconds)); + + return Ok(Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new TokenResponse + { + Content = new TokenContent + { + ProductUserId = productUserId, + FriendCode = friendCode, + }, + Hash = matchmakerToken, + Port = rejectPort, + }))); + } + + // No reject port available (pool exhausted): fall through to + // the normal path, which has its own full/exhausted handling. + _logger.LogWarning( + "TokenUser {Puid} {Ip} over the request rate limit but no reject port available, falling back to a normal port.", + productUserId, clientIp); + } _authCache.Store(productUserId, matchmakerToken, friendCode, clientIp); @@ -175,32 +213,14 @@ public sealed class TokenController : ControllerBase private IPAddress? GetClientIp() { - var xRealIp = HttpContext.Request.Headers["X-Real-IP"].FirstOrDefault(); - if (!string.IsNullOrEmpty(xRealIp) && IPAddress.TryParse(xRealIp, out var realIp)) - { - return Normalize(realIp); - } - - var xForwardedFor = HttpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - if (!string.IsNullOrEmpty(xForwardedFor)) - { - var first = xForwardedFor.Split(',')[0].Trim(); - if (IPAddress.TryParse(first, out var fwdIp)) - { - return Normalize(fwdIp); - } - } - - return Normalize(HttpContext.Connection.RemoteIpAddress); + // CDN / reverse proxy aware: X-Real-IP / X-Forwarded-For first. + return RealIpResolver.Resolve(HttpContext); } - private static IPAddress? Normalize(IPAddress? ip) - => ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4() : ip; - private static string NormalizeIpString(IPAddress? ip) => ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4().ToString() : ip?.ToString() ?? "unknown"; - private async Task GetFriendCodeAsync(string eosToken, string productUserId) + private async Task GetFriendCodeAsync(string eosToken, string productUserId, IPAddress? clientIp) { var cached = TryGetFriendCodeFromCache(productUserId); if (cached != null) @@ -211,6 +231,17 @@ public sealed class TokenController : ControllerBase return cached; } + // The join request is never rejected because of the Ip rate limit (the + // player is told in-game instead), so skip the outbound auth calls + // here — otherwise an IP could keep amplifying traffic to us. + if (_rateLimit.IsLimited(clientIp, out _)) + { + _logger.LogWarning( + "TokenControllerFriendCode skipped for PUID={Puid}: {Ip} is over the request rate limit, using fallback", + productUserId, clientIp); + return GenerateFallbackFriendCode(productUserId); + } + var mode = _authApiConfig.Mode; _logger.LogDebug("TokenControllerAuthApi mode: {Mode}", mode); diff --git a/src/Empostor.Server/Languages/de.json b/src/Empostor.Server/Languages/de.json index 67fe4a0..7bfe694 100644 --- a/src/Empostor.Server/Languages/de.json +++ b/src/Empostor.Server/Languages/de.json @@ -39,5 +39,6 @@ "ban.notice.reason": "Grund: {0}", "ban.notice.unban": "Entbannungszeit: {0}", "ban.notice.unban_permanent": "Entbannungszeit: Dauerhaft", - "ban.notice.contact": "Bei Fragen wende dich an den Administrator." + "ban.notice.contact": "Bei Fragen wende dich an den Administrator.", + "ratelimit.too_frequent": "Zu viele Anfragen, bitte versuche es in {0} Minute(n) erneut." } diff --git a/src/Empostor.Server/Languages/en.json b/src/Empostor.Server/Languages/en.json index 73e3325..7553d4c 100644 --- a/src/Empostor.Server/Languages/en.json +++ b/src/Empostor.Server/Languages/en.json @@ -70,5 +70,6 @@ "ban.notice.reason": "Reason: {0}", "ban.notice.unban": "Unban time: {0}", "ban.notice.unban_permanent": "Unban time: Permanent", - "ban.notice.contact": "If you have any questions, please contact the administrator." + "ban.notice.contact": "If you have any questions, please contact the administrator.", + "ratelimit.too_frequent": "Too many requests, please try again in {0} minute(s)." } diff --git a/src/Empostor.Server/Languages/es.json b/src/Empostor.Server/Languages/es.json index 6015a27..86cd502 100644 --- a/src/Empostor.Server/Languages/es.json +++ b/src/Empostor.Server/Languages/es.json @@ -37,5 +37,6 @@ "ban.notice.reason": "Motivo: {0}", "ban.notice.unban": "Fin del baneo: {0}", "ban.notice.unban_permanent": "Fin del baneo: Permanente", - "ban.notice.contact": "Si tienes dudas, contacta con un administrador." + "ban.notice.contact": "Si tienes dudas, contacta con un administrador.", + "ratelimit.too_frequent": "Demasiadas solicitudes, inténtalo de nuevo en {0} minuto(s)." } diff --git a/src/Empostor.Server/Languages/fil.json b/src/Empostor.Server/Languages/fil.json index ad16ae7..7f809c5 100644 --- a/src/Empostor.Server/Languages/fil.json +++ b/src/Empostor.Server/Languages/fil.json @@ -37,5 +37,6 @@ "ban.notice.reason": "Dahilan: {0}", "ban.notice.unban": "Oras ng pag-unban: {0}", "ban.notice.unban_permanent": "Oras ng pag-unban: Permanente", - "ban.notice.contact": "Kung may tanong, makipag-ugnayan sa administrator." + "ban.notice.contact": "Kung may tanong, makipag-ugnayan sa administrator.", + "ratelimit.too_frequent": "Masyaraming request, subukan muli sa {0} minuto." } diff --git a/src/Empostor.Server/Languages/fr.json b/src/Empostor.Server/Languages/fr.json index d2db85b..31d1f41 100644 --- a/src/Empostor.Server/Languages/fr.json +++ b/src/Empostor.Server/Languages/fr.json @@ -39,5 +39,6 @@ "ban.notice.reason": "Raison : {0}", "ban.notice.unban": "Débannissement : {0}", "ban.notice.unban_permanent": "Débannissement : Permanent", - "ban.notice.contact": "En cas de question, contactez un administrateur." + "ban.notice.contact": "En cas de question, contactez un administrateur.", + "ratelimit.too_frequent": "Trop de requêtes, veuillez réessayer dans {0} minute(s)." } diff --git a/src/Empostor.Server/Languages/ga.json b/src/Empostor.Server/Languages/ga.json index d4297f1..5f64c35 100644 --- a/src/Empostor.Server/Languages/ga.json +++ b/src/Empostor.Server/Languages/ga.json @@ -37,5 +37,6 @@ "ban.notice.reason": "Cúis: {0}", "ban.notice.unban": "Am dí-chosc: {0}", "ban.notice.unban_permanent": "Am dí-chosc: Buan", - "ban.notice.contact": "Má tá ceist agat, déan teagmháil le riarthóir." + "ban.notice.contact": "Má tá ceist agat, déan teagmháil le riarthóir.", + "ratelimit.too_frequent": "Iarraidh ró-mhóra, bain triail eile as i {0} nóiméad." } diff --git a/src/Empostor.Server/Languages/it.json b/src/Empostor.Server/Languages/it.json index 48d0493..16d47f6 100644 --- a/src/Empostor.Server/Languages/it.json +++ b/src/Empostor.Server/Languages/it.json @@ -37,5 +37,6 @@ "ban.notice.reason": "Motivo: {0}", "ban.notice.unban": "Sbanno: {0}", "ban.notice.unban_permanent": "Sbanno: Permanente", - "ban.notice.contact": "Per qualsiasi domanda, contatta l'amministratore." + "ban.notice.contact": "Per qualsiasi domanda, contatta l'amministratore.", + "ratelimit.too_frequent": "Troppe richieste, riprova tra {0} minuto/i." } diff --git a/src/Empostor.Server/Languages/ja.json b/src/Empostor.Server/Languages/ja.json index 04b47f4..2f88eec 100644 --- a/src/Empostor.Server/Languages/ja.json +++ b/src/Empostor.Server/Languages/ja.json @@ -39,5 +39,6 @@ "ban.notice.reason": "理由:{0}", "ban.notice.unban": "BAN解除日時:{0}", "ban.notice.unban_permanent": "BAN解除日時:永久", - "ban.notice.contact": "ご不明な点があれば管理者にお問い合わせください。" + "ban.notice.contact": "ご不明な点があれば管理者にお問い合わせください。", + "ratelimit.too_frequent": "リクエストが多すぎます。{0}分後に再試行してください。" } diff --git a/src/Empostor.Server/Languages/ko.json b/src/Empostor.Server/Languages/ko.json index 6a406e7..38ff402 100644 --- a/src/Empostor.Server/Languages/ko.json +++ b/src/Empostor.Server/Languages/ko.json @@ -39,5 +39,6 @@ "ban.notice.reason": "사유: {0}", "ban.notice.unban": "차단 해제 시간: {0}", "ban.notice.unban_permanent": "차단 해제 시간: 영구", - "ban.notice.contact": "문의 사항이 있으면 관리자에게 연락해 주세요." + "ban.notice.contact": "문의 사항이 있으면 관리자에게 연락해 주세요.", + "ratelimit.too_frequent": "요청이 너무 많습니다. {0}분 후에 다시 시도해 주세요." } diff --git a/src/Empostor.Server/Languages/nl.json b/src/Empostor.Server/Languages/nl.json index be0dd76..7b6dac4 100644 --- a/src/Empostor.Server/Languages/nl.json +++ b/src/Empostor.Server/Languages/nl.json @@ -37,5 +37,6 @@ "ban.notice.reason": "Reden: {0}", "ban.notice.unban": "Ontbantijd: {0}", "ban.notice.unban_permanent": "Ontbantijd: Permanent", - "ban.notice.contact": "Neem bij vragen contact op met de beheerder." + "ban.notice.contact": "Neem bij vragen contact op met de beheerder.", + "ratelimit.too_frequent": "Te veel verzoeken, probeer het over {0} minuut/minuten opnieuw." } diff --git a/src/Empostor.Server/Languages/pt.json b/src/Empostor.Server/Languages/pt.json index bbffae1..83af6ba 100644 --- a/src/Empostor.Server/Languages/pt.json +++ b/src/Empostor.Server/Languages/pt.json @@ -39,5 +39,6 @@ "ban.notice.reason": "Motivo: {0}", "ban.notice.unban": "Desbanimento: {0}", "ban.notice.unban_permanent": "Desbanimento: Permanente", - "ban.notice.contact": "Em caso de dúvidas, contacte o administrador." + "ban.notice.contact": "Em caso de dúvidas, contacte o administrador.", + "ratelimit.too_frequent": "Muitos pedidos, tente novamente em {0} minuto(s)." } diff --git a/src/Empostor.Server/Languages/pt_BR.json b/src/Empostor.Server/Languages/pt_BR.json index 5c6c21b..b237d17 100644 --- a/src/Empostor.Server/Languages/pt_BR.json +++ b/src/Empostor.Server/Languages/pt_BR.json @@ -37,5 +37,6 @@ "ban.notice.reason": "Motivo: {0}", "ban.notice.unban": "Desbanimento: {0}", "ban.notice.unban_permanent": "Desbanimento: Permanente", - "ban.notice.contact": "Em caso de dúvidas, contate o administrador." + "ban.notice.contact": "Em caso de dúvidas, contate o administrador.", + "ratelimit.too_frequent": "Muitas solicitações, tente novamente em {0} minuto(s)." } diff --git a/src/Empostor.Server/Languages/ru.json b/src/Empostor.Server/Languages/ru.json index a1bc6e4..6a05047 100644 --- a/src/Empostor.Server/Languages/ru.json +++ b/src/Empostor.Server/Languages/ru.json @@ -39,5 +39,6 @@ "ban.notice.reason": "Причина: {0}", "ban.notice.unban": "Время разбана: {0}", "ban.notice.unban_permanent": "Время разбана: навсегда", - "ban.notice.contact": "Если у вас есть вопросы, обратитесь к администратору." + "ban.notice.contact": "Если у вас есть вопросы, обратитесь к администратору.", + "ratelimit.too_frequent": "Слишком много запросов, повторите попытку через {0} мин." } diff --git a/src/Empostor.Server/Languages/zh_CN.json b/src/Empostor.Server/Languages/zh_CN.json index 10df749..110dd07 100644 --- a/src/Empostor.Server/Languages/zh_CN.json +++ b/src/Empostor.Server/Languages/zh_CN.json @@ -70,5 +70,6 @@ "ban.notice.reason": "理由:{0}", "ban.notice.unban": "解封时间:{0}", "ban.notice.unban_permanent": "解封时间:永久", - "ban.notice.contact": "如有问题请联系管理员" + "ban.notice.contact": "如有问题请联系管理员", + "ratelimit.too_frequent": "请求过于频繁请 {0} 分钟后重试" } diff --git a/src/Empostor.Server/Languages/zh_TW.json b/src/Empostor.Server/Languages/zh_TW.json index 64cf17b..b4006d4 100644 --- a/src/Empostor.Server/Languages/zh_TW.json +++ b/src/Empostor.Server/Languages/zh_TW.json @@ -53,5 +53,6 @@ "ban.notice.reason": "理由:{0}", "ban.notice.unban": "解封時間:{0}", "ban.notice.unban_permanent": "解封時間:永久", - "ban.notice.contact": "如有問題請聯絡管理員" + "ban.notice.contact": "如有問題請聯絡管理員", + "ratelimit.too_frequent": "請求過於頻繁請 {0} 分鐘後重試" } diff --git a/src/Empostor.Server/Net/IDeltaListenerManager.cs b/src/Empostor.Server/Net/IDeltaListenerManager.cs index fdf5e78..600c2d2 100644 --- a/src/Empostor.Server/Net/IDeltaListenerManager.cs +++ b/src/Empostor.Server/Net/IDeltaListenerManager.cs @@ -18,4 +18,13 @@ public interface IDeltaListenerManager /// Stops and disposes the UDP listener on a specific port. /// ValueTask StopDeltaListenerAsync(int port); + + /// + /// Returns the single shared UDP port that rate-limited (over-quota) join + /// requests are pointed at, binding it on first use. Its listener never + /// creates a player, it only sends the localized "too frequent" hint, so + /// an over-quota IP cannot consume a pool port per request. + /// + /// The reject port, or 0 when the delta pool is unavailable. + ValueTask GetRateLimitRejectPortAsync(); } diff --git a/src/Empostor.Server/Net/Manager/ClientManager.cs b/src/Empostor.Server/Net/Manager/ClientManager.cs index ff15014..8b05e2e 100644 --- a/src/Empostor.Server/Net/Manager/ClientManager.cs +++ b/src/Empostor.Server/Net/Manager/ClientManager.cs @@ -6,10 +6,12 @@ using System.Threading; using System.Threading.Tasks; using Empostor.Api.Config; using Empostor.Api.Events.Managers; +using Empostor.Api.Languages; using Empostor.Api.Net; using Empostor.Api.Net.Manager; using Empostor.Api.Service; using Empostor.Server.Events.Client; +using Empostor.Server.Http; using Empostor.Server.Net.Factories; using Empostor.Server.Service; using Empostor.Server.Service.Shared; @@ -35,6 +37,8 @@ namespace Empostor.Server.Net.Manager private readonly PortPoolService _portPool; private readonly IpGeolocationService _ipGeo; private readonly ClientIdStore _clientIdStore; + private readonly IpRateLimitService _rateLimit; + private readonly LanguageService _language; private int _idLast; public ClientManager( @@ -47,7 +51,9 @@ namespace Empostor.Server.Net.Manager PlayerConnectStore playerConnectStore, PortPoolService portPool, IpGeolocationService ipGeo, - ClientIdStore clientIdStore) + ClientIdStore clientIdStore, + IpRateLimitService rateLimit, + LanguageService language) { _logger = logger; _eventManager = eventManager; @@ -60,6 +66,8 @@ namespace Empostor.Server.Net.Manager _portPool = portPool; _ipGeo = ipGeo; _clientIdStore = clientIdStore; + _rateLimit = rateLimit; + _language = language; _idLast = checked((int)_clientIdStore.GetLastId()); if (_compatibilityConfig.AllowFutureGameVersions) @@ -163,7 +171,15 @@ namespace Empostor.Server.Net.Manager { // Dynamic delta mode: an active port that has no auth info // bound to it is an unauthenticated UDP connection — reject - // it instead of admitting an anonymous client. + // it instead of admitting an anonymous client. An IP that is + // over the request quota gets the localized hint, everyone + // else the plain authentication notice. + if (IsRateLimited(null, clientIp, out var retryNoAuth)) + { + await RejectRateLimitedAsync(connection, 0, language, retryNoAuth, NormalizeIp(clientIp)); + return; + } + _logger.LogWarning( "#{Id} {Name} │ port {Port} has no auth info │ {Ip} │ rejecting unauthenticated UDP connection", id, name, deltaPort, NormalizeIp(clientIp)); @@ -172,6 +188,13 @@ namespace Empostor.Server.Net.Manager } // Port matched — cancel the allocation timeout + var limited = IsRateLimited(authInfo.ClientIp, clientIp, out var retryAfter); + if (limited) + { + await RejectRateLimitedAsync(connection, deltaPort, language, retryAfter, NormalizeIp(clientIp)); + return; + } + _portPool.ConfirmPort(deltaPort); // Mark the auth-cache entry as active so the inactivity // timer never clears the port while the player is connected. @@ -186,6 +209,12 @@ namespace Empostor.Server.Net.Manager else { // Fixed-port mode: no auth binding, no PUID/FriendCode. + if (IsRateLimited(null, clientIp, out var retryAfter)) + { + await RejectRateLimitedAsync(connection, 0, language, retryAfter, NormalizeIp(clientIp)); + return; + } + _logger.LogInformation( "#{Id} {Name} │ fixed port │ {Location} │ {Lang} │ {Platform}{Reactor}", id, name, locationStr, lang, platformStr, reactorStr); @@ -223,6 +252,58 @@ namespace Empostor.Server.Net.Manager && _clients.TryGetValue(client.Id, out var c) && ReferenceEquals(client, c); + // True when the IP already used up its TCP (HTTP) request quota. The + // IP of the HTTP handshake is preferred: behind a CDN that is the real + // player IP, while the UDP source address may be a proxy node. + private bool IsRateLimited(string? httpIp, IPAddress? udpIp, out TimeSpan retryAfter) + { + retryAfter = TimeSpan.Zero; + + IPAddress? ip = null; + if (!string.IsNullOrEmpty(httpIp) && IPAddress.TryParse(httpIp, out var parsed)) + { + ip = parsed; + } + + ip ??= udpIp; + if (ip == null) + { + return false; + } + + return _rateLimit.IsLimited(ip, out retryAfter); + } + + // Over-quota players get a localized dialog instead of a black screen: + // the HTTP layer deliberately lets the join request through so the + // client reaches this point and can display the message. + private async ValueTask RejectRateLimitedAsync( + IHazelConnection connection, + int deltaPort, + Language language, + TimeSpan retryAfter, + string ip) + { + var minutes = IpRateLimitService.MinutesUntilRetry(retryAfter); + var message = _language + .Get(IpRateLimitService.MessageKey, language) + .Format(minutes) + .Get(); + + _logger.LogWarning( + "IpRateLimit rejecting game connection from {Ip} │ retry in {Minutes}m", + ip, minutes); + + await connection.CustomDisconnectAsync(DisconnectReason.Custom, message); + + if (deltaPort > 0) + { + // The port was never confirmed, release it right away instead + // of waiting for the 5-minute allocation timeout. + _portPool.ReturnPort(deltaPort); + } + } + private static string NormalizeIp(IPAddress? addr) { if (addr == null) diff --git a/src/Empostor.Server/Net/Matchmaker.cs b/src/Empostor.Server/Net/Matchmaker.cs index a4f67df..ea1ae7c 100644 --- a/src/Empostor.Server/Net/Matchmaker.cs +++ b/src/Empostor.Server/Net/Matchmaker.cs @@ -2,10 +2,13 @@ using System; using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; +using System.Threading; using System.Threading.Tasks; using Empostor.Api.Config; using Empostor.Api.Events.Managers; +using Empostor.Api.Languages; using Empostor.Server.Events.Client; +using Empostor.Server.Http; using Empostor.Server.Net.Hazel; using Empostor.Server.Net.Manager; using Empostor.Server.Service.Auth; @@ -30,10 +33,16 @@ namespace Empostor.Server.Net private readonly ServerConfig _serverConfig; private readonly AuthCacheService _authCache; private readonly IOptions _antiCheatOptions; + private readonly IpRateLimitService _rateLimit; + private readonly LanguageService _language; private UdpConnectionListener? _mainListener; private readonly ConcurrentDictionary _deltaListeners = new(); + // Shared port for over-quota (rate limited) joins; 0 until first use. + private int _rejectPort; + private readonly SemaphoreSlim _rejectPortLock = new(1, 1); + private IPEndPoint? _mainEndPoint; public Matchmaker( @@ -46,7 +55,9 @@ namespace Empostor.Server.Net IFirewallService firewall, IOptions serverConfig, AuthCacheService authCache, - IOptions antiCheatOptions) + IOptions antiCheatOptions, + IpRateLimitService rateLimit, + LanguageService language) { _eventManager = eventManager; _clientManager = clientManager; @@ -58,6 +69,8 @@ namespace Empostor.Server.Net _serverConfig = serverConfig.Value; _authCache = authCache; _antiCheatOptions = antiCheatOptions; + _rateLimit = rateLimit; + _language = language; // Subscribe to port return events from the pool _portPool.OnPortReturned += OnPortReturned; @@ -189,6 +202,66 @@ namespace Empostor.Server.Net } } + /// + /// One shared UDP port for all over-quota joins: reserving it here + /// (once) means rate-limited TCP requests never take a pool port and + /// never start their own listener. The listener above answers every + /// handshake on this port with the localized hint and closes it, so + /// the player is told why they cannot join instead of seeing a black + /// screen — without letting them into a game. + /// + public async ValueTask GetRateLimitRejectPortAsync() + { + if (!_portPool.IsEnabled) + { + // Fixed-port mode: the main listener is the reject target. + return 0; + } + + var existing = Volatile.Read(ref _rejectPort); + if (existing > 0) + { + return existing; + } + + await _rejectPortLock.WaitAsync(); + try + { + existing = Volatile.Read(ref _rejectPort); + if (existing > 0) + { + return existing; + } + + var port = _portPool.AllocatePort("(rate limit reject)"); + if (port <= 0) + { + _logger.LogWarning("Matchmaker could not reserve the rate-limit reject port: pool unavailable"); + return 0; + } + + if (!await StartDeltaListenerAsync(port)) + { + // StartDeltaListenerAsync already returned the port. + return 0; + } + + // Confirmed so it is never reclaimed by the allocation timeout: + // this port lives for the whole lifetime of the server. + _portPool.ConfirmPort(port); + Volatile.Write(ref _rejectPort, port); + + _logger.LogInformation( + "Matchmaker rate-limit reject port reserved on {Port} (one shared port for all over-quota joins)", + port); + return port; + } + finally + { + _rejectPortLock.Release(); + } + } + public async ValueTask StopAsync() { if (_mainListener != null) @@ -228,6 +301,21 @@ namespace Empostor.Server.Net out var platformSpecificData); connection = new HazelConnection(e.Connection, _connectionLogger, _antiCheatOptions); + + // Shared rate-limit reject port: there is no auth entry and no + // player behind it, so the only thing to do is tell the client + // — in its own language — why it is not let in. + if (port != 0 && port == Volatile.Read(ref _rejectPort)) + { + var message = BuildRateLimitMessage(connection.EndPoint?.Address, language); + _logger.LogWarning( + "Matchmaker rate limit rejected {Ip} on reject port {Port} │ {Message}", + connection.EndPoint?.Address?.ToString() ?? "unknown", port, message); + + await connection.CustomDisconnectAsync(DisconnectReason.Custom, message); + return; + } + await _eventManager.CallAsync(new ClientConnectionEvent(connection, e.HandshakeData)); await _clientManager.RegisterConnectionAsync( connection, name, clientVersion, language, chatMode, platformSpecificData, @@ -244,6 +332,22 @@ namespace Empostor.Server.Net } } + // Localized "please try again in N minutes" hint. N is what is actually + // left for that IP when it can be read off the quota, the full window + // otherwise (behind a CDN the UDP source address may not be the key the + // requests were counted with). + private string BuildRateLimitMessage(IPAddress? ip, Language language) + { + var minutes = Math.Max(1, _antiCheatOptions.Value.IpRateLimitWindowMinutes); + + if (ip != null && _rateLimit.IsLimited(ip, out var retryAfter)) + { + minutes = IpRateLimitService.MinutesUntilRetry(retryAfter); + } + + return _language.Get(IpRateLimitService.MessageKey, language).Format(minutes).Get(); + } + private void OnPortReturned(int port) { _authCache.RemoveByPort(port); diff --git a/src/Empostor.Server/Program.cs b/src/Empostor.Server/Program.cs index b350da2..39e52ba 100644 --- a/src/Empostor.Server/Program.cs +++ b/src/Empostor.Server/Program.cs @@ -174,6 +174,7 @@ namespace Empostor.Server services.AddSingleton(); services.AddSingleton(p => p.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(); services.AddEventPools(); @@ -264,6 +265,10 @@ namespace Empostor.Server }); builder.Configure(app => { + // Anticheat IP request rate limit — runs before every + // HTTP (TCP) endpoint, including the plugin ones. + app.UseMiddleware(); + foreach (var p in app.ApplicationServices.GetRequiredService().Plugins) if (p.Startup is IPluginHttpStartup s) { diff --git a/src/Empostor.Server/config.json b/src/Empostor.Server/config.json index bc168ce..8160995 100644 --- a/src/Empostor.Server/config.json +++ b/src/Empostor.Server/config.json @@ -27,7 +27,10 @@ "EnableTargetChecks": true, "ForbidProtocolExtensions": true, "EnablePacketSizeChecks": true, - "PacketSizeLimit": 1203 + "PacketSizeLimit": 1203, + "EnableIpRateLimit": true, + "IpRateLimitRequests": 30, + "IpRateLimitWindowMinutes": 5 }, "Timeout": { "SpawnTimeout": 5000, From 71d384c8e66efc76a443a38b1898ed259d7bd0b4 Mon Sep 17 00:00:00 2001 From: linmeideli <2558527373@qq.com> Date: Fri, 2 Oct 2026 20:37:20 +0800 Subject: [PATCH 2/3] Update Anticheat --- .dockerignore | 4 + Dockerfile | 89 ---- compose.yml | 50 --- .../packages.lock.json | 3 +- src/Empostor.Api/CheatCategory.cs | 15 + src/Empostor.Api/Config/AntiCheatConfig.cs | 12 +- src/Empostor.Api/packages.lock.json | 3 +- .../Net/Anticheat/AntiCheatState.cs | 92 +++++ .../Net/Anticheat/PlayerTimeline.cs | 43 ++ src/Empostor.Server/Net/Client.cs | 5 + .../Net/Inner/InnerNetObject.Anticheat.cs | 386 ++++++++++++++++++ .../Net/Inner/Objects/InnerMeetingHud.cs | 13 +- .../Net/Inner/Objects/InnerPlayerControl.cs | 24 +- .../Objects/ShipStatus/InnerShipStatus.cs | 7 +- src/Empostor.Server/Net/State/Game.Data.cs | 21 +- src/Empostor.Server/Net/State/Game.cs | 5 + src/Empostor.Server/config.json | 7 +- 17 files changed, 626 insertions(+), 153 deletions(-) delete mode 100644 Dockerfile delete mode 100644 compose.yml create mode 100644 src/Empostor.Server/Net/Anticheat/AntiCheatState.cs create mode 100644 src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs diff --git a/.dockerignore b/.dockerignore index ace52dd..051b9e3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -37,3 +37,7 @@ Data/ tmp/ temp/ *.tmp + +# Prebuilt Dockerfile needs the publish output (see Dockerfile.prebuilt) +!src/Empostor.Server/bin/Release/net8.0/linux-x64/publish/ +!src/Empostor.Server/bin/Release/net8.0/linux-x64/publish/** diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index fa13d87..0000000 --- a/Dockerfile +++ /dev/null @@ -1,89 +0,0 @@ -# ============================================ -# Empostor Server Docker Image -# ============================================ -# Multi-stage build: compiles from source, then produces a minimal runtime image. -# -# Build (local): -# docker build -t empostor-server . -# -# Build (multi-arch): -# docker buildx build --platform linux/amd64,linux/arm64 -t empostor-server . -# -# Run: -# docker compose up -d -# ============================================ - -# ── Build stage ──────────────────────────── -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build - -ARG TARGETARCH -ARG VERSIONSUFFIX=docker - -WORKDIR /source - -# Restore dependencies (layer caching) -COPY src/Empostor.Server/Empostor.Server.csproj ./src/Empostor.Server/ -COPY src/Empostor.Api/Empostor.Api.csproj ./src/Empostor.Api/ -COPY src/Empostor.Api.Innersloth.Generator/Empostor.Api.Innersloth.Generator.csproj ./src/Empostor.Api.Innersloth.Generator/ -COPY src/Empostor.Plugins.PlayerLog/Empostor.Plugins.PlayerLog.csproj ./src/Empostor.Plugins.PlayerLog/ -COPY src/Directory.Build.props ./src/ - -RUN case "$TARGETARCH" in \ - amd64) RID=linux-x64 ;; \ - arm64) RID=linux-arm64 ;; \ - arm) RID=linux-arm ;; \ - *) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \ - esac && \ - dotnet restore -r "$RID" ./src/Empostor.Server/Empostor.Server.csproj - -# Copy sources and publish -COPY src/. ./src/ - -RUN case "$TARGETARCH" in \ - amd64) RID=linux-x64 ;; \ - arm64) RID=linux-arm64 ;; \ - arm) RID=linux-arm ;; \ - *) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \ - esac && \ - [ "$VERSIONSUFFIX" = "none" ] && VERSIONSUFFIX="" ; \ - dotnet publish ./src/Empostor.Server/Empostor.Server.csproj \ - -c Release \ - -r "$RID" \ - --no-restore \ - --self-contained false \ - -p:PublishSingleFile=true \ - -p:PublishTrimmed=false \ - -p:VersionSuffix="$VERSIONSUFFIX" \ - -o /app - -# Copy default config (must be overridden in production) -RUN cp ./src/Empostor.Server/config.json /app/config.json 2>/dev/null || true - -# ── Runtime stage ────────────────────────── -FROM mcr.microsoft.com/dotnet/aspnet:8.0 - -WORKDIR /app - -# Copy published output from build stage -COPY --from=build /app ./ - -# Create writable runtime directories -RUN mkdir -p /app/plugins /app/libraries /app/Languages /app/Log /app/Data /app/marketplace \ - && chmod 777 /app/plugins /app/libraries /app/Languages /app/Log /app/Data /app/marketplace - -# Environment -ENV ASPNETCORE_URLS=http://0.0.0.0:80 -ENV EMPOSTOR_Server__PublicIp=0.0.0.0 -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 -ENV DOTNET_RUNNING_IN_CONTAINER=1 - -EXPOSE 80/tcp -EXPOSE 22023/tcp -# Game / matchmaker UDP (Server.ListenPort) — always bound, even in delta mode. -EXPOSE 22023/udp -# Delta UDP port pool (Server.DeltaPortStart/End). Keep in sync with config.json. -EXPOSE 22024-42024/udp - -VOLUME ["/app/config", "/app/plugins", "/app/libraries", "/app/Languages", "/app/Log", "/app/Data", "/app/marketplace"] - -ENTRYPOINT ["./Empostor.Server"] diff --git a/compose.yml b/compose.yml deleted file mode 100644 index f4bb39b..0000000 --- a/compose.yml +++ /dev/null @@ -1,50 +0,0 @@ -services: - empostor: - build: - context: . - dockerfile: Dockerfile - container_name: empostor-server - restart: unless-stopped - - ports: - # HTTP API / Admin panel (TCP) - - "22023:22023/tcp" - # Game / Matchmaker UDP — REQUIRED. The matchmaker always binds this UDP - # port (Server.ListenPort) so players can connect; without it the server - # is unreachable even in delta mode. - - "22023:22023/udp" - # Delta UDP port pool. With DeltaPortStart/End enabled, each player is - # assigned a unique UDP port from this range (Server.DeltaPortStart/End). - # Must stay in sync with config.json. - - "22024-42024:22024-42024/udp" - # Uncomment to expose admin HTTP panel: - # - "80:80/tcp" - - volumes: - # Server config. NOTE: if ./config.json does NOT exist on the host, Docker - # creates an EMPTY DIRECTORY at that path and mounts it over the baked-in - # /app/config.json, which breaks startup. Create the file first (or remove - # this line to use the image's default config) before running. - - ./config.json:/app/config.json:ro - # Writable runtime data - - empostor_plugins:/app/plugins - - empostor_libraries:/app/libraries - - empostor_languages:/app/Languages - - ./Log:/app/Log - - ./Data:/app/Data - - ./marketplace:/app/marketplace - # Per-component configs (writable from admin panel) - - ./discord_webhook.json:/app/discord_webhook.json - - environment: - - DOTNET_RUNNING_IN_CONTAINER=1 - - ASPNETCORE_URLS=http://0.0.0.0:80 - # PublicIp MUST be your real public IP / domain, or remote players can't - # connect. 0.0.0.0 / 127.0.0.1 will NOT work for anyone but localhost. - - EMPOSTOR_Server__PublicIp=0.0.0.0 - - EMPOSTOR_Server__ListenIp=0.0.0.0 - -volumes: - empostor_plugins: - empostor_libraries: - empostor_languages: diff --git a/src/Empostor.Api.Innersloth.Generator/packages.lock.json b/src/Empostor.Api.Innersloth.Generator/packages.lock.json index 5850ca3..fb68fad 100644 --- a/src/Empostor.Api.Innersloth.Generator/packages.lock.json +++ b/src/Empostor.Api.Innersloth.Generator/packages.lock.json @@ -150,7 +150,6 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } } - }, - ".NETStandard,Version=v2.0/win-x64": {} + } } } \ No newline at end of file diff --git a/src/Empostor.Api/CheatCategory.cs b/src/Empostor.Api/CheatCategory.cs index 795633e..466e42b 100644 --- a/src/Empostor.Api/CheatCategory.cs +++ b/src/Empostor.Api/CheatCategory.cs @@ -35,6 +35,21 @@ public enum CheatCategory /// A packet was sent with more items than possible in the game. ItemLimits, + /// A kill that the killer's own cooldown cannot explain, like a second victim inside the window or another hit on a dead one. + Murder, + + /// A sabotage the current game state cannot produce, like switching systems too fast or sabotaging during a meeting. + Sabotage, + + /// A meeting or a report that this round cannot produce yet. + Meeting, + + /// A vote cast outside a meeting, or by a player who is already dead. + Voting, + + /// Vent and pipe use the current game state cannot produce. + Venting, + /// Legacy category for unsorted anticheat checks. Other, } diff --git a/src/Empostor.Api/Config/AntiCheatConfig.cs b/src/Empostor.Api/Config/AntiCheatConfig.cs index ad169dd..a3b43b7 100644 --- a/src/Empostor.Api/Config/AntiCheatConfig.cs +++ b/src/Empostor.Api/Config/AntiCheatConfig.cs @@ -28,6 +28,16 @@ namespace Empostor.Api.Config public bool EnableTargetChecks { get; set; } = true; + public bool EnableMurderChecks { get; set; } = true; + + public bool EnableSabotageChecks { get; set; } = true; + + public bool EnableMeetingChecks { get; set; } = true; + + public bool EnableVotingChecks { get; set; } = true; + + public bool EnableVentExploitCheck { get; set; } = false; + public bool ForbidProtocolExtensions { get; set; } = true; public bool EnablePacketSizeChecks { get; set; } = true; @@ -38,6 +48,6 @@ namespace Empostor.Api.Config public int IpRateLimitRequests { get; set; } = 30; - public int IpRateLimitWindowMinutes { get; set; } = 5; + public int IpRateLimitWindowMinutes { get; set; } = 1; } } diff --git a/src/Empostor.Api/packages.lock.json b/src/Empostor.Api/packages.lock.json index 21be9d2..9734f25 100644 --- a/src/Empostor.Api/packages.lock.json +++ b/src/Empostor.Api/packages.lock.json @@ -144,7 +144,6 @@ "resolved": "4.5.0", "contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g==" } - }, - "net8.0/win-x64": {} + } } } \ No newline at end of file diff --git a/src/Empostor.Server/Net/Anticheat/AntiCheatState.cs b/src/Empostor.Server/Net/Anticheat/AntiCheatState.cs new file mode 100644 index 0000000..244c5e8 --- /dev/null +++ b/src/Empostor.Server/Net/Anticheat/AntiCheatState.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; +using Empostor.Api.Config; +using Empostor.Api.Innersloth.GameOptions; + +namespace Empostor.Server.Net.Anticheat +{ + internal sealed class AntiCheatState + { + private const int TimelineCount = 256; + private const int UnknownTimelineIndex = TimelineCount - 1; + private readonly PlayerTimeline[] _timelines = new PlayerTimeline[TimelineCount]; + private readonly IGameOptions _options; + private double _roundStartedAt = -1; + private double _meetingOpenedAt = -1; + private bool _meetingActive; + + public AntiCheatState(IGameOptions options, AntiCheatConfig config) + { + _options = options; + Config = config; + + for (var i = 0; i < _timelines.Length; i++) + { + _timelines[i] = new PlayerTimeline(); + } + } + + public AntiCheatConfig Config { get; } + + public static double Now => (double)Stopwatch.GetTimestamp() / Stopwatch.Frequency; + + public bool ShipLoaded => _roundStartedAt >= 0; + + public bool ShipGraceOver + { + get + { + if (_roundStartedAt < 0) + { + return true; + } + + var cooldown = _options is NormalGameOptions normal ? normal.EmergencyCooldown : 0; + return cooldown <= 0 || Now - _roundStartedAt >= cooldown / 2; + } + } + + public bool InMeeting => _meetingActive; + + public double SecondsSinceRoundStarted => _roundStartedAt < 0 ? -1 : Now - _roundStartedAt; + + public bool MeetingGuardArmed => + _meetingActive && _meetingOpenedAt >= 0 && Now - _meetingOpenedAt >= 1; + + public PlayerTimeline For(byte playerId) + { + return playerId < TimelineCount ? _timelines[playerId] : _timelines[UnknownTimelineIndex]; + } + + public void NoteRoundStarted() + { + _roundStartedAt = Now; + _meetingActive = false; + _meetingOpenedAt = -1; + + foreach (var timeline in _timelines) + { + timeline.ResetRound(); + } + } + + public void NoteRoundEnded() + { + _roundStartedAt = -1; + _meetingActive = false; + _meetingOpenedAt = -1; + } + + public void NoteMeetingOpened() + { + _meetingActive = true; + _meetingOpenedAt = Now; + } + + public void NoteMeetingClosed() + { + _meetingActive = false; + _meetingOpenedAt = -1; + } + + } +} diff --git a/src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs b/src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs new file mode 100644 index 0000000..a438804 --- /dev/null +++ b/src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs @@ -0,0 +1,43 @@ +namespace Empostor.Server.Net.Anticheat +{ + internal sealed class PlayerTimeline + { + /// Gets or sets the moment the player's control was spawned. + public double SpawnedAt { get; set; } = -1; + + /// Gets or sets the moment of the last completed kill. + public double LastKillAt { get; set; } = -1; + + /// Gets or sets the victim of the last completed kill. + public byte LastKillTarget { get; set; } = byte.MaxValue; + + /// Gets or sets how many times in a row this player killed an already dead victim. + public int DeadTargetKills { get; set; } + + /// Gets or sets the moment of the last sabotage this player triggered. + public double LastSabotageAt { get; set; } = -1; + + /// Gets or sets the system of the last sabotage this player triggered. + public int LastSabotageSystem { get; set; } = -1; + + /// Gets or sets the moment of the vent in of a possible vent kick exploit. + public double VentExploitPendingAt { get; set; } = -1; + + public void NoteKill(byte victimId, double now) + { + LastKillAt = now; + LastKillTarget = victimId; + } + + public void ResetRound() + { + SpawnedAt = -1; + LastKillAt = -1; + LastKillTarget = byte.MaxValue; + DeadTargetKills = 0; + LastSabotageAt = -1; + LastSabotageSystem = -1; + VentExploitPendingAt = -1; + } + } +} diff --git a/src/Empostor.Server/Net/Client.cs b/src/Empostor.Server/Net/Client.cs index ef4d6e4..24cbecb 100644 --- a/src/Empostor.Server/Net/Client.cs +++ b/src/Empostor.Server/Net/Client.cs @@ -89,6 +89,11 @@ namespace Empostor.Server.Net CheatCategory.Target => _antiCheatConfig.EnableTargetChecks, CheatCategory.PacketSize => _antiCheatConfig.EnablePacketSizeChecks, CheatCategory.ItemLimits => _antiCheatConfig.EnableItemLimitChecks, + CheatCategory.Murder => _antiCheatConfig.EnableMurderChecks, + CheatCategory.Sabotage => _antiCheatConfig.EnableSabotageChecks, + CheatCategory.Meeting => _antiCheatConfig.EnableMeetingChecks, + CheatCategory.Voting => _antiCheatConfig.EnableVotingChecks, + CheatCategory.Venting => _antiCheatConfig.EnableVentExploitCheck, CheatCategory.Other => true, _ => LogUnknownCategory(category), }; diff --git a/src/Empostor.Server/Net/Inner/InnerNetObject.Anticheat.cs b/src/Empostor.Server/Net/Inner/InnerNetObject.Anticheat.cs index 5a8932e..81efe44 100644 --- a/src/Empostor.Server/Net/Inner/InnerNetObject.Anticheat.cs +++ b/src/Empostor.Server/Net/Inner/InnerNetObject.Anticheat.cs @@ -1,6 +1,11 @@ +using System; using System.Threading.Tasks; using Empostor.Api; +using Empostor.Api.Innersloth.GameOptions; using Empostor.Api.Net; +using Empostor.Api.Net.Inner; +using Empostor.Api.Net.Inner.Objects; +using Empostor.Server.Net.Anticheat; using Empostor.Server.Net.Inner.Objects; namespace Empostor.Server.Net.Inner @@ -132,6 +137,387 @@ namespace Empostor.Server.Net.Inner return true; } + protected async ValueTask ValidateMurderTiming(CheatContext context, IClientPlayer sender, byte killerPlayerId, IInnerPlayerControl? target) + { + if (!Game.AntiCheat.Config.EnableMurderChecks || target == null) + { + return true; + } + + var timeline = Game.AntiCheat.For(killerPlayerId); + + if (target.PlayerInfo is { IsDead: true }) + { + timeline.DeadTargetKills++; + + if (timeline.DeadTargetKills >= 6 && + await sender.Client.ReportCheatAsync( + context, + CheatCategory.Murder, + $"Client murdered the already dead player {target.PlayerId} {timeline.DeadTargetKills} times")) + { + return false; + } + } + else + { + timeline.DeadTargetKills = 0; + } + + if (Game.Options is not NormalGameOptions options || + Game.Options.GameMode is not (GameModes.Normal or GameModes.NormalFools)) + { + return true; + } + + var window = options.KillCooldown / 2; + + if (IsFastKill(timeline, target.PlayerId, window, AntiCheatState.Now, out var reason)) + { + return await sender.Client.ReportCheatAsync(context, CheatCategory.Murder, reason!); + } + + return true; + } + + private static bool IsFastKill(PlayerTimeline timeline, byte victimId, double windowSeconds, double now, out string? reason) + { + reason = null; + + if (windowSeconds <= 0 || timeline.LastKillAt < 0 || timeline.LastKillTarget == victimId) + { + return false; + } + + if (now - timeline.LastKillAt >= windowSeconds) + { + return false; + } + + reason = $"Killed {victimId} only {now - timeline.LastKillAt:0.###}s after killing {timeline.LastKillTarget}"; + return true; + } + + protected async ValueTask ValidateMeetingTiming(CheatContext context, IClientPlayer sender) + { + if (!Game.AntiCheat.ShipLoaded) + { + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.Meeting, + "Client sent a meeting before the map of this round was spawned"); + } + + if (Game.AntiCheat.SecondsSinceRoundStarted >= 0 && + Game.AntiCheat.SecondsSinceRoundStarted < 10) + { + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.Meeting, + $"Client sent a meeting {Game.AntiCheat.SecondsSinceRoundStarted:0.###}s into the round (grace 10s)"); + } + + return true; + } + + protected async ValueTask ValidateUpdateSystem(CheatContext context, IClientPlayer sender, IMessageReader reader) + { + if (!Game.AntiCheat.Config.EnableSabotageChecks) + { + return true; + } + + var position = reader.Position; + + try + { + if (!TryReadUpdateSystem(reader, out var systemType, out var playerControl, out var isVent, out var sequenceId, out var state, out var ventId)) + { + return await sender.Client.ReportCheatAsync(context, CheatCategory.ProtocolExtension, "Client sent a malformed UpdateSystem RPC"); + } + + if (!sender.IsHost && !playerControl.IsOwnedBy(sender)) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Ownership, $"Client sent {nameof(RpcCalls.UpdateSystem)} for a {nameof(InnerPlayerControl)} it does not own")) + { + return false; + } + } + + var actorId = ResolveActorId(playerControl, sender); + + if (isVent) + { + return await ValidateVentExploit(context, sender, actorId, sequenceId, state, ventId); + } + + if (!Game.AntiCheat.ShipGraceOver) + { + return true; + } + + if (!await ValidateSabotageInMeeting(context, sender, systemType)) + { + return false; + } + + if (!await ValidateRapidSabotage(context, sender, actorId, systemType)) + { + return false; + } + + var actorIsImpostor = ResolveActorPlayerInfo(playerControl, sender)?.IsImpostor ?? false; + return await ValidateSabotageRole(context, sender, actorIsImpostor, systemType, state); + } + finally + { + reader.Seek(position); + } + } + + protected async ValueTask ValidateVentExploit(CheatContext context, IClientPlayer sender, byte playerId, ushort sequenceId, byte state, byte ventId) + { + if (!Game.AntiCheat.Config.EnableVentExploitCheck) + { + return true; + } + + const byte EnterVentState = 2; + const byte BootFromVentState = 5; + const ushort ExploitSequenceId = 1; + + var timeline = Game.AntiCheat.For(playerId); + var now = AntiCheatState.Now; + + if (state == EnterVentState && sequenceId == 0 && ventId == 0) + { + timeline.VentExploitPendingAt = now; + return true; + } + + if (state != BootFromVentState || sequenceId != ExploitSequenceId || ventId != 0 || + timeline.VentExploitPendingAt < 0 || + now - timeline.VentExploitPendingAt > 1) + { + return true; + } + + timeline.VentExploitPendingAt = -1; + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.Venting, + "Client sent the vent kick exploit pair (vent 0, sequence id 0 then 1)"); + } + + protected async ValueTask ValidateSabotageInMeeting(CheatContext context, IClientPlayer sender, SystemTypes system) + { + if (!Game.AntiCheat.MeetingGuardArmed) + { + return true; + } + + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.Sabotage, + $"Client sabotaged {system} while a meeting is in progress"); + } + + protected async ValueTask ValidateRapidSabotage(CheatContext context, IClientPlayer sender, byte playerId, SystemTypes system) + { + if ((int)system == 16) + { + return true; + } + + var timeline = Game.AntiCheat.For(playerId); + var now = AntiCheatState.Now; + + if (timeline.LastSabotageAt >= 0 && + now - timeline.LastSabotageAt < 0.1 && + timeline.LastSabotageSystem != (int)system) + { + var reason = $"Sabotaged {system} {(now - timeline.LastSabotageAt) * 1000:0.###}ms after {timeline.LastSabotageSystem}"; + timeline.LastSabotageAt = now; + timeline.LastSabotageSystem = (int)system; + return await sender.Client.ReportCheatAsync(context, CheatCategory.Sabotage, reason); + } + + timeline.LastSabotageAt = now; + timeline.LastSabotageSystem = (int)system; + return true; + } + + protected async ValueTask ValidateSabotageRole(CheatContext context, IClientPlayer sender, bool actorIsImpostor, SystemTypes system, byte state) + { + if (actorIsImpostor) + { + return true; + } + + var startingSabotage = (state & 0x80) != 0; + if (!startingSabotage && system != SystemTypes.MushroomMixupSabotage) + { + return true; + } + + var reason = startingSabotage + ? $"Non-impostor started a sabotage of {system} (state 0x{state:X2})" + : $"Non-impostor triggered {system}"; + + return await sender.Client.ReportCheatAsync(context, CheatCategory.Sabotage, reason); + } + + protected async ValueTask ValidateVoteCast(CheatContext context, IClientPlayer sender) + { + if (!Game.AntiCheat.Config.EnableVotingChecks) + { + return true; + } + + if (!Game.AntiCheat.InMeeting && + await sender.Client.ReportCheatAsync(context, CheatCategory.Voting, "Client cast a vote while no meeting is in progress")) + { + return false; + } + + if (sender.Character?.PlayerInfo is { IsDead: true } && + await sender.Client.ReportCheatAsync(context, CheatCategory.Voting, "Client cast a vote while being dead")) + { + return false; + } + + return true; + } + + protected async ValueTask ValidateVoterCount(CheatContext context, IClientPlayer sender, IMessageReader reader) + { + var position = reader.Position; + int voters; + + try + { + voters = reader.ReadPackedInt32(); + } + catch (Exception) + { + return true; + } + finally + { + reader.Seek(position); + } + + var limit = Math.Max(Game.Options.MaxPlayers, Game.PlayerCount); + if (voters >= 0 && voters <= limit && voters <= reader.Length - position) + { + return true; + } + + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.ProtocolExtension, + $"Client sent a VotingComplete with {voters} voters (max {limit})"); + } + + internal static async ValueTask ValidateGameDataTag(CheatContext context, IClientPlayer sender, byte tag) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.ProtocolExtension, $"Client sent an unknown game data tag {tag}")) + { + return false; + } + + return true; + } + + private bool TryReadUpdateSystem( + IMessageReader reader, + out SystemTypes systemType, + out InnerPlayerControl playerControl, + out bool isVent, + out ushort sequenceId, + out byte state, + out byte ventId) + { + systemType = SystemTypes.Hallway; + playerControl = null!; + isVent = false; + sequenceId = 0; + state = 0; + ventId = byte.MaxValue; + + if (reader.Position >= reader.Length) + { + return false; + } + + systemType = (SystemTypes)reader.ReadByte(); + + var control = reader.ReadNetObject(Game); + if (control == null) + { + return false; + } + + playerControl = control; + + if (systemType == SystemTypes.Ventilation) + { + if (reader.Length - reader.Position < 4) + { + return false; + } + + isVent = true; + sequenceId = reader.ReadUInt16(); + state = reader.ReadByte(); + ventId = reader.ReadByte(); + return true; + } + + if (reader.Position >= reader.Length) + { + return false; + } + + if (systemType == SystemTypes.Sabotage) + { + if (reader.Length - reader.Position < 2) + { + return false; + } + + systemType = (SystemTypes)reader.ReadByte(); + } + + state = reader.ReadByte(); + return true; + } + + private byte ResolveActorId(InnerPlayerControl control, IClientPlayer sender) + { + if (control.OwnerId >= 0) + { + var owner = Game.GetClientPlayer(control.OwnerId); + if (owner?.Character != null) + { + return owner.Character.PlayerId; + } + } + + return sender.Character?.PlayerId ?? byte.MaxValue; + } + + private InnerPlayerInfo? ResolveActorPlayerInfo(InnerPlayerControl control, IClientPlayer sender) + { + if (control.PlayerInfo != null) + { + return control.PlayerInfo; + } + + var owner = control.OwnerId >= 0 ? Game.GetClientPlayer(control.OwnerId) : null; + return (InnerPlayerInfo?)(owner?.Character?.PlayerInfo ?? sender.Character?.PlayerInfo); + } + protected async ValueTask UnregisteredCall(CheatContext context, IClientPlayer sender) { if (await sender.Client.ReportCheatAsync(context, CheatCategory.ProtocolExtension, "Client sent unregistered call")) diff --git a/src/Empostor.Server/Net/Inner/Objects/InnerMeetingHud.cs b/src/Empostor.Server/Net/Inner/Objects/InnerMeetingHud.cs index fca6ded..612d591 100644 --- a/src/Empostor.Server/Net/Inner/Objects/InnerMeetingHud.cs +++ b/src/Empostor.Server/Net/Inner/Objects/InnerMeetingHud.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -140,6 +140,7 @@ namespace Empostor.Server.Net.Inner.Objects Rpc22Close.Deserialize(reader); _judgeOverrules.Clear(); + Game.AntiCheat.NoteMeetingClosed(); break; } @@ -150,6 +151,11 @@ namespace Empostor.Server.Net.Inner.Objects return false; } + if (!await ValidateVoterCount(call, sender, reader)) + { + return false; + } + if (sender.Client.GameVersion >= JudgeMinVersion) { Rpc23VotingComplete.Deserialize(reader, out var states, out var playerId, out var tie, out var wasOverruled, out var overrideId); @@ -247,6 +253,11 @@ namespace Empostor.Server.Net.Inner.Objects } } + if (!await ValidateVoteCast(RpcCalls.CastVote, sender)) + { + return false; + } + if (!sender.IsHost) { var playerVoteArea = _playerStates.Single(x => x.TargetPlayer.PlayerId == playerId); diff --git a/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs b/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs index 1c871e2..e0bcca5 100644 --- a/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs +++ b/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Threading.Tasks; using Empostor.Api; @@ -13,6 +13,7 @@ using Empostor.Api.Net.Inner.Objects; using Empostor.Api.Net.Messages.Rpcs; using Empostor.Api.Utils; using Empostor.Server.Events.Player; +using Empostor.Server.Net.Anticheat; using Empostor.Server.Net.Inner.Objects.Components; using Empostor.Server.Net.State; using Microsoft.Extensions.DependencyInjection; @@ -255,10 +256,14 @@ namespace Empostor.Server.Net.Inner.Objects return false; } + if (!await ValidateMeetingTiming(call, sender)) + { + return false; + } + Rpc11ReportDeadBody.Deserialize(reader, out var targetId); break; } - case RpcCalls.MurderPlayer: { if (!await ValidateHost(call, sender) || @@ -291,6 +296,11 @@ namespace Empostor.Server.Net.Inner.Objects return false; } + if (!await ValidateMeetingTiming(call, sender)) + { + return false; + } + Rpc14StartMeeting.Deserialize(reader, out var targetId); await HandleStartMeeting(targetId); break; @@ -1045,6 +1055,11 @@ namespace Empostor.Server.Net.Inner.Objects IsMurdering = target; } + if (!await ValidateMurderTiming(RpcCalls.CheckMurder, sender, PlayerId, target)) + { + return false; + } + if (_game.IsHostAuthoritive) { return true; @@ -1074,6 +1089,11 @@ namespace Empostor.Server.Net.Inner.Objects { target.ProtectedOn = null; await ForceMurderPlayerAsync(target, evt.Result); + + if (!evt.Result.IsFailed()) + { + Game.AntiCheat.For(PlayerId).NoteKill(target.PlayerId, AntiCheatState.Now); + } } } diff --git a/src/Empostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs b/src/Empostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs index 8afa14f..8211d07 100644 --- a/src/Empostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs +++ b/src/Empostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Numerics; @@ -89,13 +89,12 @@ namespace Empostor.Server.Net.Inner.Objects.ShipStatus case RpcCalls.UpdateSystem: { - if (!await ValidateCmd(call, sender, target)) + if (!await ValidateCmd(call, sender, target) || + !await ValidateUpdateSystem(call, sender, reader)) { return false; } - // TODO: properly deserialize this RPC - // Rpc35UpdateSystem.Deserialize(reader, Game, out var systemType, out var playerControl, out var sequenceId, out var state, out var ventId); break; } diff --git a/src/Empostor.Server/Net/State/Game.Data.cs b/src/Empostor.Server/Net/State/Game.Data.cs index 3036fd4..0c89e1e 100644 --- a/src/Empostor.Server/Net/State/Game.Data.cs +++ b/src/Empostor.Server/Net/State/Game.Data.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -9,6 +9,7 @@ using Empostor.Api.Unity; using Empostor.Server.Events.Game.Player; using Empostor.Server.Events.Meeting; using Empostor.Server.Events.Player; +using Empostor.Server.Net.Anticheat; using Empostor.Server.Net.Inner; using Empostor.Server.Net.Inner.Objects; using Empostor.Server.Net.Inner.Objects.Components; @@ -367,6 +368,12 @@ namespace Empostor.Server.Net.State default: { _logger.LogWarning("{Code} - Bad GameData tag {Tag}", Code, reader.Tag); + + if (!await InnerNetObject.ValidateGameDataTag(new CheatContext(nameof(GameDataTag)), sender, reader.Tag)) + { + return GameDataResult.Abort; + } + return GameDataResult.Continue; } } @@ -411,6 +418,7 @@ namespace Empostor.Server.Net.State case InnerShipStatus shipStatus: { GameNet.ShipStatus = shipStatus; + AntiCheat.NoteRoundStarted(); break; } @@ -427,6 +435,8 @@ namespace Empostor.Server.Net.State await sender.Client.ReportCheatAsync(new CheatContext(nameof(GameDataTag.SpawnFlag)), CheatCategory.GameFlow, "Failed to find player that spawned the InnerPlayerControl"); } + AntiCheat.For(control.PlayerId).SpawnedAt = AntiCheatState.Now; + // Hook up InnerPlayerControl <-> InnerPlayerControl.PlayerInfo. var playerInfo = GameNet.GameData.GetPlayerById(control.PlayerId); @@ -466,6 +476,8 @@ namespace Empostor.Server.Net.State case InnerMeetingHud meetingHud: { + AntiCheat.NoteMeetingOpened(); + foreach (var player in _players.Values) { if (GameNet.ShipStatus != null) @@ -501,6 +513,13 @@ namespace Empostor.Server.Net.State case InnerShipStatus: { GameNet.ShipStatus = null; + AntiCheat.NoteRoundEnded(); + break; + } + + case InnerMeetingHud: + { + AntiCheat.NoteMeetingClosed(); break; } diff --git a/src/Empostor.Server/Net/State/Game.cs b/src/Empostor.Server/Net/State/Game.cs index 58f32a6..ce34f19 100644 --- a/src/Empostor.Server/Net/State/Game.cs +++ b/src/Empostor.Server/Net/State/Game.cs @@ -15,7 +15,9 @@ using Empostor.Api.Net.Inner.Objects; using Empostor.Api.Net.Manager; using Empostor.Api.Net.Messages.S2C; using Empostor.Server.Events; +using Empostor.Server.Net.Anticheat; using Empostor.Server.Net.Manager; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -68,8 +70,11 @@ namespace Empostor.Server.Net.State _compatibilityConfig = compatibilityConfig.Value; _timeoutConfig = timeoutConfig.Value; Items = new ConcurrentDictionary(); + AntiCheat = new AntiCheatState(options, serviceProvider.GetRequiredService>().Value); } + internal AntiCheatState AntiCheat { get; } + public IPEndPoint PublicIp { get; } public GameCode Code { get; } diff --git a/src/Empostor.Server/config.json b/src/Empostor.Server/config.json index 3719ae5..4cfb5d2 100644 --- a/src/Empostor.Server/config.json +++ b/src/Empostor.Server/config.json @@ -27,12 +27,17 @@ "EnableOwnershipChecks": true, "EnableRoleChecks": true, "EnableTargetChecks": true, + "EnableMurderChecks": true, + "EnableSabotageChecks": true, + "EnableMeetingChecks": true, + "EnableVotingChecks": true, + "EnableVentExploitCheck": false, "ForbidProtocolExtensions": true, "EnablePacketSizeChecks": true, "PacketSizeLimit": 1203, "EnableIpRateLimit": true, "IpRateLimitRequests": 30, - "IpRateLimitWindowMinutes": 5 + "IpRateLimitWindowMinutes": 1 }, "Timeout": { "SpawnTimeout": 5000, From 8899510583efc5160506e36f2d74535d81e55c91 Mon Sep 17 00:00:00 2001 From: linmeideli <2558527373@qq.com> Date: Fri, 2 Oct 2026 20:44:07 +0800 Subject: [PATCH 3/3] fix --- .../Empostor.Plugins.DiscordWebhook.dll | Bin 45056 -> 45568 bytes .../DiscordWebhookListener.cs | 33 ++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/marketplace/Empostor.Plugins.DiscordWebhook.dll b/marketplace/Empostor.Plugins.DiscordWebhook.dll index 0e4aa20e0f68c19c68fc0f114cea3e4f7d3ad127..5f1fdb3dd609ca24fa44503d83cfe6905d9692de 100644 GIT binary patch delta 18497 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 Mf=_&=*7kA#FOc#w$N&HU delta 18117 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 s.Length <= 300 ? s : s.Substring(0, 300) + "…"; + /// Returns true when the message no longer exists (404). private async ValueTask PatchLiveMessageAsync(TrackedLobby lobby, object embed) {