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/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/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 ad169dd..a3b43b7 100644
--- a/src/Empostor.Api/Config/AntiCheatConfig.cs
+++ b/src/Empostor.Api/Config/AntiCheatConfig.cs
@@ -28,6 +28,16 @@ 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;
@@ -38,6 +48,6 @@ namespace Empostor.Api.Config
public int IpRateLimitRequests { get; set; } = 30;
- public int IpRateLimitWindowMinutes { get; set; } = 5;
+ public int IpRateLimitWindowMinutes { get; set; } = 1;
}
}
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.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/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/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