Add rate limiting

This commit is contained in:
FangkuaiYa committed 2026-10-01 00:00:15 +08:00
1 parent 6a98d63db2
commit 91ac52626b
29 files changed
+710 -64

No files matched your search

+43
View File
@@ -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. 4. Press "Open in Among Us". Among Us should open.
5. Your server should appear in the regions list! 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 ## Contributing
Please read the [contributing guidelines](https://github.com/Empostor/Empostor/blob/main/CONTRIBUTING.md). Please read the [contributing guidelines](https://github.com/Empostor/Empostor/blob/main/CONTRIBUTING.md).
Binary file not shown.
@@ -33,5 +33,11 @@ namespace Empostor.Api.Config
public bool EnablePacketSizeChecks { get; set; } = true; public bool EnablePacketSizeChecks { get; set; } = true;
public int PacketSizeLimit { get; set; } = 1203; public int PacketSizeLimit { get; set; } = 1203;
public bool EnableIpRateLimit { get; set; } = true;
public int IpRateLimitRequests { get; set; } = 30;
public int IpRateLimitWindowMinutes { get; set; } = 5;
} }
} }
@@ -155,6 +155,7 @@ public sealed class LanguageService
// Any Pr fix grammer wrongs is OK! // Any Pr fix grammer wrongs is OK!
private const string DefaultEn = """ 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.error": "An error occurred while executing #{0}.",
"command.usage": "Usage: #{0}", "command.usage": "Usage: #{0}",
"command.help.list": "=== Commands ===", "command.help.list": "=== Commands ===",
@@ -215,6 +216,7 @@ public sealed class LanguageService
private const string DefaultZhCn = """ private const string DefaultZhCn = """
{ {
"ratelimit.too_frequent": "请求过于频繁请 {0} 分钟后重试",
"command.error": "执行 #{0}时发生错误。", "command.error": "执行 #{0}时发生错误。",
"command.usage": "用法:#{0}", "command.usage": "用法:#{0}",
"command.help.list": "=== 指令列表 ===", "command.help.list": "=== 指令列表 ===",
@@ -275,6 +277,7 @@ public sealed class LanguageService
private const string DefaultZhTw = """ private const string DefaultZhTw = """
{ {
"ratelimit.too_frequent": "請求過於頻繁請 {0} 分鐘後重試",
"command.error": "執行 #{0}時發生錯誤。", "command.error": "執行 #{0}時發生錯誤。",
"command.usage": "用法:#{0}", "command.usage": "用法:#{0}",
"command.help.list": "=== 指令列表 ===", "command.help.list": "=== 指令列表 ===",
@@ -335,6 +338,7 @@ public sealed class LanguageService
private const string DefaultKo = """ private const string DefaultKo = """
{ {
"ratelimit.too_frequent": "요청이 너무 많습니다. {0}분 후에 다시 시도해 주세요.",
"command.error": "#{0}실행 중 오류가 발생했습니다.", "command.error": "#{0}실행 중 오류가 발생했습니다.",
"command.usage": "사용법: #{0}", "command.usage": "사용법: #{0}",
"command.help.list": "=== 명령어 목록 ===", "command.help.list": "=== 명령어 목록 ===",
@@ -381,6 +385,7 @@ public sealed class LanguageService
private const string DefaultRu = """ private const string DefaultRu = """
{ {
"ratelimit.too_frequent": "Слишком много запросов, повторите попытку через {0} мин.",
"command.error": "Ошибка при выполнении #{0}.", "command.error": "Ошибка при выполнении #{0}.",
"command.usage": "Использование: #{0}", "command.usage": "Использование: #{0}",
"command.help.list": "=== Команды ===", "command.help.list": "=== Команды ===",
@@ -427,6 +432,7 @@ public sealed class LanguageService
private const string DefaultDe = """ 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.error": "Fehler beim Ausführen von #{0}.",
"command.usage": "Verwendung: #{0}", "command.usage": "Verwendung: #{0}",
"command.help.list": "=== Befehle ===", "command.help.list": "=== Befehle ===",
@@ -473,6 +479,7 @@ public sealed class LanguageService
private const string DefaultFr = """ 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.error": "Erreur lors de l'exécution de #{0}.",
"command.usage": "Utilisation : #{0}", "command.usage": "Utilisation : #{0}",
"command.help.list": "=== Commandes ===", "command.help.list": "=== Commandes ===",
@@ -519,6 +526,7 @@ public sealed class LanguageService
private const string DefaultJa = """ private const string DefaultJa = """
{ {
"ratelimit.too_frequent": "リクエストが多すぎます。{0}分後に再試行してください。",
"command.error": "#{0}の実行中にエラーが発生しました。", "command.error": "#{0}の実行中にエラーが発生しました。",
"command.usage": "使い方:#{0}", "command.usage": "使い方:#{0}",
"command.help.list": "=== コマンド一覧 ===", "command.help.list": "=== コマンド一覧 ===",
@@ -565,6 +573,7 @@ public sealed class LanguageService
private const string DefaultPt = """ private const string DefaultPt = """
{ {
"ratelimit.too_frequent": "Muitos pedidos, tente novamente em {0} minuto(s).",
"command.error": "Ocorreu um erro ao executar #{0}.", "command.error": "Ocorreu um erro ao executar #{0}.",
"command.usage": "Uso: #{0}", "command.usage": "Uso: #{0}",
"command.help.list": "=== Comandos ===", "command.help.list": "=== Comandos ===",
@@ -611,6 +620,7 @@ public sealed class LanguageService
private const string DefaultPtBr = """ 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.error": "Ocorreu um erro ao executar #{0}.",
"command.usage": "Uso: #{0}", "command.usage": "Uso: #{0}",
"command.help.list": "=== Comandos ===", "command.help.list": "=== Comandos ===",
@@ -655,6 +665,7 @@ public sealed class LanguageService
private const string DefaultEs = """ private const string DefaultEs = """
{ {
"ratelimit.too_frequent": "Demasiadas solicitudes, inténtalo de nuevo en {0} minuto(s).",
"command.error": "Error al ejecutar #{0}.", "command.error": "Error al ejecutar #{0}.",
"command.usage": "Uso: #{0}", "command.usage": "Uso: #{0}",
"command.help.list": "=== Comandos ===", "command.help.list": "=== Comandos ===",
@@ -699,6 +710,7 @@ public sealed class LanguageService
private const string DefaultIt = """ private const string DefaultIt = """
{ {
"ratelimit.too_frequent": "Troppe richieste, riprova tra {0} minuto/i.",
"command.error": "Errore durante l'esecuzione di #{0}.", "command.error": "Errore durante l'esecuzione di #{0}.",
"command.usage": "Utilizzo: #{0}", "command.usage": "Utilizzo: #{0}",
"command.help.list": "=== Comandi ===", "command.help.list": "=== Comandi ===",
@@ -743,6 +755,7 @@ public sealed class LanguageService
private const string DefaultNl = """ 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.error": "Fout bij uitvoeren van #{0}.",
"command.usage": "Gebruik: #{0}", "command.usage": "Gebruik: #{0}",
"command.help.list": "=== Commando's ===", "command.help.list": "=== Commando's ===",
@@ -787,6 +800,7 @@ public sealed class LanguageService
private const string DefaultFil = """ private const string DefaultFil = """
{ {
"ratelimit.too_frequent": "Masyaraming request, subukan muli sa {0} minuto.",
"command.error": "May error sa pagpapatakbo ng #{0}.", "command.error": "May error sa pagpapatakbo ng #{0}.",
"command.usage": "Paggamit: #{0}", "command.usage": "Paggamit: #{0}",
"command.help.list": "=== Mga Command ===", "command.help.list": "=== Mga Command ===",
@@ -831,6 +845,7 @@ public sealed class LanguageService
private const string DefaultGa = """ 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.error": "Earráid agus #{0}á rith.",
"command.usage": "Úsáid: #{0}", "command.usage": "Úsáid: #{0}",
"command.help.list": "=== Orduithe ===", "command.help.list": "=== Orduithe ===",
+2 -21
View File
@@ -827,31 +827,12 @@ namespace Empostor.Server.Http
private IPAddress? GetClientIp() 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 // not the CDN/nginx IP. Using Connection.RemoteIpAddress directly
// would rate-limit or ban the proxy instead of the actual user. // would rate-limit or ban the proxy instead of the actual user.
var xRealIp = HttpContext.Request.Headers["X-Real-IP"].FirstOrDefault(); return RealIpResolver.Resolve(HttpContext);
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);
} }
private static IPAddress? Normalize(IPAddress? ip)
=> ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4() : ip;
private static object Err(string msg) => new { error = msg }; private static object Err(string msg) => new { error = msg };
private static string Fmt(TimeSpan t) private static string Fmt(TimeSpan t)
@@ -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<IpRateLimitMiddleware> _logger;
public IpRateLimitMiddleware(
RequestDelegate next,
IpRateLimitService rateLimit,
LanguageService language,
ILogger<IpRateLimitMiddleware> 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;
}
}
@@ -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<AntiCheatConfig> _options;
private readonly ConcurrentDictionary<string, Window> _windows = new();
private DateTime _lastCleanupUtc = DateTime.MinValue;
public IpRateLimitService(IOptions<AntiCheatConfig> 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; }
}
}
@@ -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;
}
+55 -24
View File
@@ -36,6 +36,7 @@ public sealed class TokenController : ControllerBase
private readonly AuthApiConfig _authApiConfig; private readonly AuthApiConfig _authApiConfig;
private readonly PortPoolService _portPool; private readonly PortPoolService _portPool;
private readonly IDeltaListenerManager _deltaListenerManager; private readonly IDeltaListenerManager _deltaListenerManager;
private readonly IpRateLimitService _rateLimit;
public TokenController( public TokenController(
ILogger<TokenController> logger, ILogger<TokenController> logger,
@@ -43,7 +44,8 @@ public sealed class TokenController : ControllerBase
AuthCacheService authCache, AuthCacheService authCache,
IOptions<AuthApiConfig> authApiConfig, IOptions<AuthApiConfig> authApiConfig,
PortPoolService portPool, PortPoolService portPool,
IDeltaListenerManager deltaListenerManager) IDeltaListenerManager deltaListenerManager,
IpRateLimitService rateLimit)
{ {
_logger = logger; _logger = logger;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
@@ -51,6 +53,7 @@ public sealed class TokenController : ControllerBase
_authApiConfig = authApiConfig.Value; _authApiConfig = authApiConfig.Value;
_portPool = portPool; _portPool = portPool;
_deltaListenerManager = deltaListenerManager; _deltaListenerManager = deltaListenerManager;
_rateLimit = rateLimit;
} }
[HttpPost] [HttpPost]
@@ -74,9 +77,44 @@ public sealed class TokenController : ControllerBase
return Unauthorized(new { error = "Invalid token content" }); return Unauthorized(new { error = "Invalid token content" });
} }
var friendCode = await GetFriendCodeAsync(eosToken, productUserId);
var matchmakerToken = GenerateMatchmakerToken(productUserId);
var clientIp = GetClientIp(); 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); _authCache.Store(productUserId, matchmakerToken, friendCode, clientIp);
@@ -175,32 +213,14 @@ public sealed class TokenController : ControllerBase
private IPAddress? GetClientIp() private IPAddress? GetClientIp()
{ {
var xRealIp = HttpContext.Request.Headers["X-Real-IP"].FirstOrDefault(); // CDN / reverse proxy aware: X-Real-IP / X-Forwarded-For first.
if (!string.IsNullOrEmpty(xRealIp) && IPAddress.TryParse(xRealIp, out var realIp)) return RealIpResolver.Resolve(HttpContext);
{
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);
} }
private static IPAddress? Normalize(IPAddress? ip)
=> ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4() : ip;
private static string NormalizeIpString(IPAddress? ip) private static string NormalizeIpString(IPAddress? ip)
=> ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4().ToString() : ip?.ToString() ?? "unknown"; => ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4().ToString() : ip?.ToString() ?? "unknown";
private async Task<string> GetFriendCodeAsync(string eosToken, string productUserId) private async Task<string> GetFriendCodeAsync(string eosToken, string productUserId, IPAddress? clientIp)
{ {
var cached = TryGetFriendCodeFromCache(productUserId); var cached = TryGetFriendCodeFromCache(productUserId);
if (cached != null) if (cached != null)
@@ -211,6 +231,17 @@ public sealed class TokenController : ControllerBase
return cached; 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; var mode = _authApiConfig.Mode;
_logger.LogDebug("TokenControllerAuthApi mode: {Mode}", mode); _logger.LogDebug("TokenControllerAuthApi mode: {Mode}", mode);
+2 -1
View File
@@ -39,5 +39,6 @@
"ban.notice.reason": "Grund: {0}", "ban.notice.reason": "Grund: {0}",
"ban.notice.unban": "Entbannungszeit: {0}", "ban.notice.unban": "Entbannungszeit: {0}",
"ban.notice.unban_permanent": "Entbannungszeit: Dauerhaft", "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."
} }
+2 -1
View File
@@ -70,5 +70,6 @@
"ban.notice.reason": "Reason: {0}", "ban.notice.reason": "Reason: {0}",
"ban.notice.unban": "Unban time: {0}", "ban.notice.unban": "Unban time: {0}",
"ban.notice.unban_permanent": "Unban time: Permanent", "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)."
} }
+2 -1
View File
@@ -37,5 +37,6 @@
"ban.notice.reason": "Motivo: {0}", "ban.notice.reason": "Motivo: {0}",
"ban.notice.unban": "Fin del baneo: {0}", "ban.notice.unban": "Fin del baneo: {0}",
"ban.notice.unban_permanent": "Fin del baneo: Permanente", "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)."
} }
+2 -1
View File
@@ -37,5 +37,6 @@
"ban.notice.reason": "Dahilan: {0}", "ban.notice.reason": "Dahilan: {0}",
"ban.notice.unban": "Oras ng pag-unban: {0}", "ban.notice.unban": "Oras ng pag-unban: {0}",
"ban.notice.unban_permanent": "Oras ng pag-unban: Permanente", "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."
} }
+2 -1
View File
@@ -39,5 +39,6 @@
"ban.notice.reason": "Raison : {0}", "ban.notice.reason": "Raison : {0}",
"ban.notice.unban": "Débannissement : {0}", "ban.notice.unban": "Débannissement : {0}",
"ban.notice.unban_permanent": "Débannissement : Permanent", "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)."
} }
+2 -1
View File
@@ -37,5 +37,6 @@
"ban.notice.reason": "Cúis: {0}", "ban.notice.reason": "Cúis: {0}",
"ban.notice.unban": "Am dí-chosc: {0}", "ban.notice.unban": "Am dí-chosc: {0}",
"ban.notice.unban_permanent": "Am dí-chosc: Buan", "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."
} }
+2 -1
View File
@@ -37,5 +37,6 @@
"ban.notice.reason": "Motivo: {0}", "ban.notice.reason": "Motivo: {0}",
"ban.notice.unban": "Sbanno: {0}", "ban.notice.unban": "Sbanno: {0}",
"ban.notice.unban_permanent": "Sbanno: Permanente", "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."
} }
+2 -1
View File
@@ -39,5 +39,6 @@
"ban.notice.reason": "理由:{0}", "ban.notice.reason": "理由:{0}",
"ban.notice.unban": "BAN解除日時:{0}", "ban.notice.unban": "BAN解除日時:{0}",
"ban.notice.unban_permanent": "BAN解除日時:永久", "ban.notice.unban_permanent": "BAN解除日時:永久",
"ban.notice.contact": "ご不明な点があれば管理者にお問い合わせください。" "ban.notice.contact": "ご不明な点があれば管理者にお問い合わせください。",
"ratelimit.too_frequent": "リクエストが多すぎます。{0}分後に再試行してください。"
} }
+2 -1
View File
@@ -39,5 +39,6 @@
"ban.notice.reason": "사유: {0}", "ban.notice.reason": "사유: {0}",
"ban.notice.unban": "차단 해제 시간: {0}", "ban.notice.unban": "차단 해제 시간: {0}",
"ban.notice.unban_permanent": "차단 해제 시간: 영구", "ban.notice.unban_permanent": "차단 해제 시간: 영구",
"ban.notice.contact": "문의 사항이 있으면 관리자에게 연락해 주세요." "ban.notice.contact": "문의 사항이 있으면 관리자에게 연락해 주세요.",
"ratelimit.too_frequent": "요청이 너무 많습니다. {0}분 후에 다시 시도해 주세요."
} }
+2 -1
View File
@@ -37,5 +37,6 @@
"ban.notice.reason": "Reden: {0}", "ban.notice.reason": "Reden: {0}",
"ban.notice.unban": "Ontbantijd: {0}", "ban.notice.unban": "Ontbantijd: {0}",
"ban.notice.unban_permanent": "Ontbantijd: Permanent", "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."
} }
+2 -1
View File
@@ -39,5 +39,6 @@
"ban.notice.reason": "Motivo: {0}", "ban.notice.reason": "Motivo: {0}",
"ban.notice.unban": "Desbanimento: {0}", "ban.notice.unban": "Desbanimento: {0}",
"ban.notice.unban_permanent": "Desbanimento: Permanente", "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)."
} }
+2 -1
View File
@@ -37,5 +37,6 @@
"ban.notice.reason": "Motivo: {0}", "ban.notice.reason": "Motivo: {0}",
"ban.notice.unban": "Desbanimento: {0}", "ban.notice.unban": "Desbanimento: {0}",
"ban.notice.unban_permanent": "Desbanimento: Permanente", "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)."
} }
+2 -1
View File
@@ -39,5 +39,6 @@
"ban.notice.reason": "Причина: {0}", "ban.notice.reason": "Причина: {0}",
"ban.notice.unban": "Время разбана: {0}", "ban.notice.unban": "Время разбана: {0}",
"ban.notice.unban_permanent": "Время разбана: навсегда", "ban.notice.unban_permanent": "Время разбана: навсегда",
"ban.notice.contact": "Если у вас есть вопросы, обратитесь к администратору." "ban.notice.contact": "Если у вас есть вопросы, обратитесь к администратору.",
"ratelimit.too_frequent": "Слишком много запросов, повторите попытку через {0} мин."
} }
+2 -1
View File
@@ -70,5 +70,6 @@
"ban.notice.reason": "理由:{0}", "ban.notice.reason": "理由:{0}",
"ban.notice.unban": "解封时间:{0}", "ban.notice.unban": "解封时间:{0}",
"ban.notice.unban_permanent": "解封时间:永久", "ban.notice.unban_permanent": "解封时间:永久",
"ban.notice.contact": "如有问题请联系管理员" "ban.notice.contact": "如有问题请联系管理员",
"ratelimit.too_frequent": "请求过于频繁请 {0} 分钟后重试"
} }
+2 -1
View File
@@ -53,5 +53,6 @@
"ban.notice.reason": "理由:{0}", "ban.notice.reason": "理由:{0}",
"ban.notice.unban": "解封時間:{0}", "ban.notice.unban": "解封時間:{0}",
"ban.notice.unban_permanent": "解封時間:永久", "ban.notice.unban_permanent": "解封時間:永久",
"ban.notice.contact": "如有問題請聯絡管理員" "ban.notice.contact": "如有問題請聯絡管理員",
"ratelimit.too_frequent": "請求過於頻繁請 {0} 分鐘後重試"
} }
@@ -18,4 +18,13 @@ public interface IDeltaListenerManager
/// Stops and disposes the UDP listener on a specific port. /// Stops and disposes the UDP listener on a specific port.
/// </summary> /// </summary>
ValueTask StopDeltaListenerAsync(int port); ValueTask StopDeltaListenerAsync(int port);
/// <summary>
/// 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.
/// </summary>
/// <returns>The reject port, or 0 when the delta pool is unavailable.</returns>
ValueTask<int> GetRateLimitRejectPortAsync();
} }
@@ -6,10 +6,12 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Empostor.Api.Config; using Empostor.Api.Config;
using Empostor.Api.Events.Managers; using Empostor.Api.Events.Managers;
using Empostor.Api.Languages;
using Empostor.Api.Net; using Empostor.Api.Net;
using Empostor.Api.Net.Manager; using Empostor.Api.Net.Manager;
using Empostor.Api.Service; using Empostor.Api.Service;
using Empostor.Server.Events.Client; using Empostor.Server.Events.Client;
using Empostor.Server.Http;
using Empostor.Server.Net.Factories; using Empostor.Server.Net.Factories;
using Empostor.Server.Service; using Empostor.Server.Service;
using Empostor.Server.Service.Shared; using Empostor.Server.Service.Shared;
@@ -35,6 +37,8 @@ namespace Empostor.Server.Net.Manager
private readonly PortPoolService _portPool; private readonly PortPoolService _portPool;
private readonly IpGeolocationService _ipGeo; private readonly IpGeolocationService _ipGeo;
private readonly ClientIdStore _clientIdStore; private readonly ClientIdStore _clientIdStore;
private readonly IpRateLimitService _rateLimit;
private readonly LanguageService _language;
private int _idLast; private int _idLast;
public ClientManager( public ClientManager(
@@ -47,7 +51,9 @@ namespace Empostor.Server.Net.Manager
PlayerConnectStore playerConnectStore, PlayerConnectStore playerConnectStore,
PortPoolService portPool, PortPoolService portPool,
IpGeolocationService ipGeo, IpGeolocationService ipGeo,
ClientIdStore clientIdStore) ClientIdStore clientIdStore,
IpRateLimitService rateLimit,
LanguageService language)
{ {
_logger = logger; _logger = logger;
_eventManager = eventManager; _eventManager = eventManager;
@@ -60,6 +66,8 @@ namespace Empostor.Server.Net.Manager
_portPool = portPool; _portPool = portPool;
_ipGeo = ipGeo; _ipGeo = ipGeo;
_clientIdStore = clientIdStore; _clientIdStore = clientIdStore;
_rateLimit = rateLimit;
_language = language;
_idLast = checked((int)_clientIdStore.GetLastId()); _idLast = checked((int)_clientIdStore.GetLastId());
if (_compatibilityConfig.AllowFutureGameVersions) if (_compatibilityConfig.AllowFutureGameVersions)
@@ -163,7 +171,15 @@ namespace Empostor.Server.Net.Manager
{ {
// Dynamic delta mode: an active port that has no auth info // Dynamic delta mode: an active port that has no auth info
// bound to it is an unauthenticated UDP connection — reject // 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( _logger.LogWarning(
"#{Id} {Name} │ port {Port} has no auth info │ {Ip} │ rejecting unauthenticated UDP connection", "#{Id} {Name} │ port {Port} has no auth info │ {Ip} │ rejecting unauthenticated UDP connection",
id, name, deltaPort, NormalizeIp(clientIp)); id, name, deltaPort, NormalizeIp(clientIp));
@@ -172,6 +188,13 @@ namespace Empostor.Server.Net.Manager
} }
// Port matched — cancel the allocation timeout // 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); _portPool.ConfirmPort(deltaPort);
// Mark the auth-cache entry as active so the inactivity // Mark the auth-cache entry as active so the inactivity
// timer never clears the port while the player is connected. // timer never clears the port while the player is connected.
@@ -186,6 +209,12 @@ namespace Empostor.Server.Net.Manager
else else
{ {
// Fixed-port mode: no auth binding, no PUID/FriendCode. // 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( _logger.LogInformation(
"#{Id} {Name} │ fixed port │ {Location} │ {Lang} │ {Platform}{Reactor}", "#{Id} {Name} │ fixed port │ {Location} │ {Lang} │ {Platform}{Reactor}",
id, name, locationStr, lang, platformStr, reactorStr); id, name, locationStr, lang, platformStr, reactorStr);
@@ -223,6 +252,58 @@ namespace Empostor.Server.Net.Manager
&& _clients.TryGetValue(client.Id, out var c) && _clients.TryGetValue(client.Id, out var c)
&& ReferenceEquals(client, 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) private static string NormalizeIp(IPAddress? addr)
{ {
if (addr == null) if (addr == null)
+105 -1
View File
@@ -2,10 +2,13 @@ using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Empostor.Api.Config; using Empostor.Api.Config;
using Empostor.Api.Events.Managers; using Empostor.Api.Events.Managers;
using Empostor.Api.Languages;
using Empostor.Server.Events.Client; using Empostor.Server.Events.Client;
using Empostor.Server.Http;
using Empostor.Server.Net.Hazel; using Empostor.Server.Net.Hazel;
using Empostor.Server.Net.Manager; using Empostor.Server.Net.Manager;
using Empostor.Server.Service.Auth; using Empostor.Server.Service.Auth;
@@ -30,10 +33,16 @@ namespace Empostor.Server.Net
private readonly ServerConfig _serverConfig; private readonly ServerConfig _serverConfig;
private readonly AuthCacheService _authCache; private readonly AuthCacheService _authCache;
private readonly IOptions<AntiCheatConfig> _antiCheatOptions; private readonly IOptions<AntiCheatConfig> _antiCheatOptions;
private readonly IpRateLimitService _rateLimit;
private readonly LanguageService _language;
private UdpConnectionListener? _mainListener; private UdpConnectionListener? _mainListener;
private readonly ConcurrentDictionary<int, UdpConnectionListener> _deltaListeners = new(); private readonly ConcurrentDictionary<int, UdpConnectionListener> _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; private IPEndPoint? _mainEndPoint;
public Matchmaker( public Matchmaker(
@@ -46,7 +55,9 @@ namespace Empostor.Server.Net
IFirewallService firewall, IFirewallService firewall,
IOptions<ServerConfig> serverConfig, IOptions<ServerConfig> serverConfig,
AuthCacheService authCache, AuthCacheService authCache,
IOptions<AntiCheatConfig> antiCheatOptions) IOptions<AntiCheatConfig> antiCheatOptions,
IpRateLimitService rateLimit,
LanguageService language)
{ {
_eventManager = eventManager; _eventManager = eventManager;
_clientManager = clientManager; _clientManager = clientManager;
@@ -58,6 +69,8 @@ namespace Empostor.Server.Net
_serverConfig = serverConfig.Value; _serverConfig = serverConfig.Value;
_authCache = authCache; _authCache = authCache;
_antiCheatOptions = antiCheatOptions; _antiCheatOptions = antiCheatOptions;
_rateLimit = rateLimit;
_language = language;
// Subscribe to port return events from the pool // Subscribe to port return events from the pool
_portPool.OnPortReturned += OnPortReturned; _portPool.OnPortReturned += OnPortReturned;
@@ -189,6 +202,66 @@ namespace Empostor.Server.Net
} }
} }
/// <summary>
/// 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.
/// </summary>
public async ValueTask<int> 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() public async ValueTask StopAsync()
{ {
if (_mainListener != null) if (_mainListener != null)
@@ -228,6 +301,21 @@ namespace Empostor.Server.Net
out var platformSpecificData); out var platformSpecificData);
connection = new HazelConnection(e.Connection, _connectionLogger, _antiCheatOptions); 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 _eventManager.CallAsync(new ClientConnectionEvent(connection, e.HandshakeData));
await _clientManager.RegisterConnectionAsync( await _clientManager.RegisterConnectionAsync(
connection, name, clientVersion, language, chatMode, platformSpecificData, 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) private void OnPortReturned(int port)
{ {
_authCache.RemoveByPort(port); _authCache.RemoveByPort(port);
+5
View File
@@ -174,6 +174,7 @@ namespace Empostor.Server
services.AddSingleton<GameManager>(); services.AddSingleton<GameManager>();
services.AddSingleton<IGameManager>(p => p.GetRequiredService<GameManager>()); services.AddSingleton<IGameManager>(p => p.GetRequiredService<GameManager>());
services.AddSingleton<ListingManager>(); services.AddSingleton<ListingManager>();
services.AddSingleton<IpRateLimitService>();
services.AddEventPools(); services.AddEventPools();
@@ -264,6 +265,10 @@ namespace Empostor.Server
}); });
builder.Configure(app => builder.Configure(app =>
{ {
// Anticheat IP request rate limit — runs before every
// HTTP (TCP) endpoint, including the plugin ones.
app.UseMiddleware<IpRateLimitMiddleware>();
foreach (var p in app.ApplicationServices.GetRequiredService<PluginLoaderService>().Plugins) foreach (var p in app.ApplicationServices.GetRequiredService<PluginLoaderService>().Plugins)
if (p.Startup is IPluginHttpStartup s) if (p.Startup is IPluginHttpStartup s)
{ {
+4 -1
View File
@@ -27,7 +27,10 @@
"EnableTargetChecks": true, "EnableTargetChecks": true,
"ForbidProtocolExtensions": true, "ForbidProtocolExtensions": true,
"EnablePacketSizeChecks": true, "EnablePacketSizeChecks": true,
"PacketSizeLimit": 1203 "PacketSizeLimit": 1203,
"EnableIpRateLimit": true,
"IpRateLimitRequests": 30,
"IpRateLimitWindowMinutes": 5
}, },
"Timeout": { "Timeout": {
"SpawnTimeout": 5000, "SpawnTimeout": 5000,