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