mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-19 15:51:39 +00:00
28 files changed
+710
-64
No files matched your search
@@ -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).
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ===",
|
||||
|
||||
@@ -750,31 +750,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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TokenController> logger,
|
||||
@@ -43,7 +44,8 @@ public sealed class TokenController : ControllerBase
|
||||
AuthCacheService authCache,
|
||||
IOptions<AuthApiConfig> 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<string> GetFriendCodeAsync(string eosToken, string productUserId)
|
||||
private async Task<string> 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);
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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}分後に再試行してください。"
|
||||
}
|
||||
@@ -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}분 후에 다시 시도해 주세요."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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} мин."
|
||||
}
|
||||
@@ -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} 分钟后重试"
|
||||
}
|
||||
@@ -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} 分鐘後重試"
|
||||
}
|
||||
@@ -18,4 +18,13 @@ public interface IDeltaListenerManager
|
||||
/// Stops and disposes the UDP listener on a specific port.
|
||||
/// </summary>
|
||||
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 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)
|
||||
|
||||
@@ -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<AntiCheatConfig> _antiCheatOptions;
|
||||
private readonly IpRateLimitService _rateLimit;
|
||||
private readonly LanguageService _language;
|
||||
|
||||
private UdpConnectionListener? _mainListener;
|
||||
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;
|
||||
|
||||
public Matchmaker(
|
||||
@@ -46,7 +55,9 @@ namespace Empostor.Server.Net
|
||||
IFirewallService firewall,
|
||||
IOptions<ServerConfig> serverConfig,
|
||||
AuthCacheService authCache,
|
||||
IOptions<AntiCheatConfig> antiCheatOptions)
|
||||
IOptions<AntiCheatConfig> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -173,6 +173,7 @@ namespace Empostor.Server
|
||||
services.AddSingleton<GameManager>();
|
||||
services.AddSingleton<IGameManager>(p => p.GetRequiredService<GameManager>());
|
||||
services.AddSingleton<ListingManager>();
|
||||
services.AddSingleton<IpRateLimitService>();
|
||||
|
||||
services.AddEventPools();
|
||||
|
||||
@@ -263,6 +264,10 @@ namespace Empostor.Server
|
||||
});
|
||||
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)
|
||||
if (p.Startup is IPluginHttpStartup s)
|
||||
{
|
||||
|
||||
@@ -29,7 +29,10 @@
|
||||
"EnableTargetChecks": true,
|
||||
"ForbidProtocolExtensions": true,
|
||||
"EnablePacketSizeChecks": true,
|
||||
"PacketSizeLimit": 1203
|
||||
"PacketSizeLimit": 1203,
|
||||
"EnableIpRateLimit": true,
|
||||
"IpRateLimitRequests": 30,
|
||||
"IpRateLimitWindowMinutes": 5
|
||||
},
|
||||
"Timeout": {
|
||||
"SpawnTimeout": 5000,
|
||||
|
||||
Reference in new issue
Block a user