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] 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,