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