Files
Empostor/src/Empostor.Server/Net/Client.cs
T
2026-08-20 09:14:30 +08:00

604 lines
23 KiB
C#

using System;
using System.Linq;
using System.Threading.Tasks;
using Empostor.Api;
using Empostor.Api.Config;
using Empostor.Api.Events.Managers;
using Empostor.Api.Games;
using Empostor.Api.Innersloth.GameOptions;
using Empostor.Api.Net;
using Empostor.Api.Net.Custom;
using Empostor.Api.Net.Messages;
using Empostor.Api.Net.Messages.C2S;
using Empostor.Api.Net.Messages.S2C;
using Empostor.Server.Events.Player;
using Empostor.Server.Net.Manager;
using Empostor.Server.Service.Admin.Report;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Next.Hazel;
namespace Empostor.Server.Net
{
internal class Client : ClientBase
{
private readonly ILogger<Client> _logger;
private readonly AntiCheatConfig _antiCheatConfig;
private readonly ClientManager _clientManager;
private readonly GameManager _gameManager;
private readonly ICustomMessageManager<ICustomRootMessage> _customMessageManager;
private readonly IEventManager _eventManager;
private readonly ReportStore _reportStore;
public Client(ILogger<Client> logger, IOptions<AntiCheatConfig> antiCheatOptions, ClientManager clientManager, GameManager gameManager, ICustomMessageManager<ICustomRootMessage> customMessageManager, IEventManager eventManager, ReportStore reportStore, string name, GameVersion gameVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData, IHazelConnection connection)
: base(name, gameVersion, language, chatMode, platformSpecificData, connection)
{
_logger = logger;
_antiCheatConfig = antiCheatOptions.Value;
_clientManager = clientManager;
_gameManager = gameManager;
_customMessageManager = customMessageManager;
_eventManager = eventManager;
_reportStore = reportStore;
}
public override async ValueTask<bool> ReportCheatAsync(CheatContext context, CheatCategory category, string message)
{
if (!_antiCheatConfig.Enabled)
{
return false;
}
if (Player != null && Player.Game.ModGuid != null)
{
return false;
}
if (Player != null && Player.IsHost)
{
var isHostCheatingAllowed = _antiCheatConfig.AllowCheatingHosts switch
{
CheatingHostMode.Always => true,
CheatingHostMode.IfRequested => GameVersion.HasDisableServerAuthorityFlag,
CheatingHostMode.Never => false,
_ => false,
};
if (isHostCheatingAllowed)
{
return false;
}
}
bool LogUnknownCategory(CheatCategory category)
{
_logger.LogWarning("Client [{Id}] unknown cheat category {Category} when reporting", Id, category);
return true;
}
var isCategoryEnabled = category switch
{
CheatCategory.ProtocolExtension => _antiCheatConfig.ForbidProtocolExtensions,
CheatCategory.GameFlow => _antiCheatConfig.EnableGameFlowChecks,
CheatCategory.InvalidObject => _antiCheatConfig.EnableInvalidObjectChecks,
CheatCategory.PacketSize => _antiCheatConfig.EnablePacketSizeChecks,
CheatCategory.MustBeHost => _antiCheatConfig.EnableMustBeHostChecks,
CheatCategory.ColorLimits => _antiCheatConfig.EnableColorLimitChecks,
CheatCategory.NameLimits => _antiCheatConfig.EnableNameLimitChecks,
CheatCategory.Ownership => _antiCheatConfig.EnableOwnershipChecks,
CheatCategory.Role => _antiCheatConfig.EnableRoleChecks,
CheatCategory.Target => _antiCheatConfig.EnableTargetChecks,
CheatCategory.Other => true,
_ => LogUnknownCategory(category),
};
if (!isCategoryEnabled)
{
return false;
}
var supportCode = Random.Shared.Next(0, 999_999).ToString("000-000");
_logger.LogWarning("Client [{Id}]{Name} was caught cheating: [{SupportCode}] [{Context}-{Category}] {Message}", Id, Name, supportCode, context.Name, category, message);
if (Player is { } player)
{
if (_antiCheatConfig.BanIpFromGame)
{
player.Game.BanIp(Connection.EndPoint.Address);
}
await player.Game.HandleRemovePlayer(Id, DisconnectReason.Hacking);
}
var disconnectMessage =
$"""
You have been caught cheating and were {(_antiCheatConfig.BanIpFromGame ? "banned" : "kicked")} from the lobby.
For questions, contact your server admin and share the following code: {supportCode}.
""";
await DisconnectAsync(DisconnectReason.Custom, disconnectMessage);
return true;
}
public override async ValueTask HandleMessageAsync(IMessageReader reader, MessageType messageType)
{
var flag = reader.Tag;
_logger.LogTrace("Client [{Id}] received {Flag}", Id, MessageFlags.FlagToString(flag));
switch (flag)
{
case MessageFlags.HostGame:
case MessageFlags.HostModdedGame:
{
IGameOptions gameOptions;
GameFilterOptions gameFilterOptions;
Guid? modGuid = null;
if (flag == MessageFlags.HostModdedGame)
{
Message25HostModdedGameC2S.Deserialize(reader, out gameOptions, out _, out gameFilterOptions, out var parsedModGuid);
modGuid = parsedModGuid;
}
else
{
// Read game settings.
Message00HostGameC2S.Deserialize(reader, out gameOptions, out _, out gameFilterOptions);
}
// Create game.
var game = await _gameManager.CreateAsync(this, gameOptions, gameFilterOptions, modGuid);
if (game == null)
{
await DisconnectAsync(DisconnectReason.GameNotFound);
return;
}
if (modGuid != null)
{
_logger.LogInformation("Client {Name} ({Id}) hosted a modded game with mod GUID {ModGuid}.", Name, Id, modGuid);
}
// Code in the packet below will be used in JoinGame.
using (var writer = MessageWriter.Get(MessageType.Reliable))
{
Message00HostGameS2C.Serialize(writer, game.Code);
await Connection.SendAsync(writer);
}
break;
}
case MessageFlags.JoinGame:
{
Message01JoinGameC2S.Deserialize(reader, out var gameCode);
var game = _gameManager.Find(gameCode);
if (game == null)
{
await DisconnectAsync(DisconnectReason.GameNotFound);
return;
}
var result = await game.AddClientAsync(this);
switch (result.Error)
{
case GameJoinError.None:
break;
case GameJoinError.InvalidClient:
await DisconnectAsync(DisconnectReason.Custom, "Client is in an invalid state.");
break;
case GameJoinError.Banned:
await DisconnectAsync(DisconnectReason.Banned);
break;
case GameJoinError.GameFull:
await DisconnectAsync(DisconnectReason.GameFull);
break;
case GameJoinError.InvalidLimbo:
await DisconnectAsync(DisconnectReason.Custom, "Invalid limbo state while joining.");
break;
case GameJoinError.GameStarted:
await DisconnectAsync(DisconnectReason.GameStarted);
break;
case GameJoinError.GameDestroyed:
await DisconnectAsync(DisconnectReason.Custom, DisconnectMessages.Destroyed);
break;
case GameJoinError.ClientOutdated:
await DisconnectAsync(DisconnectReason.Custom, DisconnectMessages.ClientOutdated);
break;
case GameJoinError.ClientTooNew:
await DisconnectAsync(DisconnectReason.Custom, DisconnectMessages.ClientTooNew);
break;
case GameJoinError.Custom:
await DisconnectAsync(DisconnectReason.Custom, result.Message);
break;
default:
await DisconnectAsync(DisconnectReason.Custom, "Unknown error.");
break;
}
break;
}
case MessageFlags.StartGame:
{
if (!IsPacketAllowed(reader, true))
{
return;
}
await Player!.Game.HandleStartGame(reader);
break;
}
// No idea how this flag is triggered.
case MessageFlags.RemoveGame:
break;
case MessageFlags.RemovePlayer:
{
if (!IsPacketAllowed(reader, true))
{
return;
}
Message04RemovePlayerC2S.Deserialize(
reader,
out var playerId,
out var reason);
await Player!.Game.HandleRemovePlayer(playerId, (DisconnectReason)reason);
break;
}
case MessageFlags.GameData:
case MessageFlags.GameDataTo:
{
if (!IsPacketAllowed(reader, false))
{
return;
}
var toPlayer = flag == MessageFlags.GameDataTo;
var position = reader.Position;
var verified = await Player!.Game.HandleGameDataAsync(reader, Player, toPlayer);
reader.Seek(position);
if (verified && Player != null)
{
// Broadcast packet to all other players.
using (var writer = MessageWriter.Get(messageType))
{
if (toPlayer)
{
var target = reader.ReadPackedInt32();
reader.CopyTo(writer);
await Player.Game.SendToAsync(writer, target);
}
else
{
reader.CopyTo(writer);
await Player.Game.SendToAllExceptAsync(writer, Id);
}
}
}
break;
}
case MessageFlags.PackedGameDataTo:
{
if (Player == null)
{
return;
}
var game = Player.Game;
// Innersloth Special: this message uses PackedInt32 instead of a normal int32
var code = reader.ReadPackedInt32();
if (code != game.Code.Value)
{
_logger.LogWarning("gcm2 {0} {1}", code, game.Code.Value);
return;
}
// We're limiting this to hosts right now. If you have a use case for this for
// players to use this feature, we're open to changing this.
if (game.HostId != Id)
{
await ReportCheatAsync(
new CheatContext(MessageFlags.FlagToString(flag)),
CheatCategory.MustBeHost,
"Client sent a PackedGameDataTo message");
return;
}
while (reader.Position < reader.Length)
{
using var packed = reader.ReadMessage();
if (packed.Tag != MessageFlags.GameDataTo)
{
_logger.LogWarning("Client [{Id}] PackedGameDataTo contained non-GameDataTo flag {Flag}", Id, packed.Tag);
return;
}
if (packed.ReadInt32() != game.Code.Value)
{
_logger.LogWarning("Client [{Id}] PackedGameDataTo contained GameDataTo for wrong game", Id);
return;
}
var position = packed.Position;
var verified = await Player.Game.HandleGameDataAsync(packed, Player, true);
packed.Seek(position);
if (!verified || Player == null)
{
return;
}
using var writer = MessageWriter.Get(messageType);
var target = packed.ReadPackedInt32();
packed.CopyTo(writer);
await Player.Game.SendToAsync(writer, target);
}
break;
}
case MessageFlags.EndGame:
{
if (!IsPacketAllowed(reader, true))
{
return;
}
Message08EndGameC2S.Deserialize(
reader,
out var gameOverReason);
await Player!.Game.HandleEndGame(reader, gameOverReason);
break;
}
case MessageFlags.AlterGame:
{
if (!IsPacketAllowed(reader, true))
{
return;
}
Message10AlterGameC2S.Deserialize(
reader,
out var gameTag,
out var value);
if (gameTag != AlterGameTags.ChangePrivacy)
{
return;
}
await Player!.Game.HandleAlterGame(reader, Player, value);
break;
}
case MessageFlags.KickPlayer:
{
if (!IsPacketAllowed(reader, true))
{
return;
}
Message11KickPlayerC2S.Deserialize(
reader,
out var playerId,
out var isBan);
await Player!.Game.HandleKickPlayer(playerId, isBan);
break;
}
case MessageFlags.GetGameListV2:
{
await DisconnectAsync(DisconnectReason.Custom, DisconnectMessages.UdpMatchmakingUnsupported);
return;
}
case MessageFlags.SetActivePodType:
{
Message21SetActivePodType.Deserialize(reader, out _);
break;
}
case MessageFlags.QueryPlatformIds:
{
Message22QueryPlatformIdsC2S.Deserialize(reader, out var gameCode);
await OnQueryPlatformIds(gameCode);
break;
}
case MessageFlags.ReportPlayer:
{
await OnReportPlayerAsync(reader);
break;
}
default:
if (_customMessageManager.TryGet(flag, out var customRootMessage))
{
await customRootMessage.HandleMessageAsync(this, reader, messageType);
break;
}
_logger.LogWarning("Client [{Id}] sent unknown flag {Flag}", Id, flag);
break;
}
#if DEBUG
if (flag != MessageFlags.GameData &&
flag != MessageFlags.GameDataTo &&
flag != MessageFlags.PackedGameDataTo &&
flag != MessageFlags.EndGame &&
reader.Position < reader.Length)
{
_logger.LogWarning(
"Client [{Id}] unconsumed bytes for flag {Flag} ({Pos} < {Len})",
Id,
flag,
reader.Position,
reader.Length);
}
#endif
}
public override async ValueTask HandleDisconnectAsync(string reason)
{
try
{
if (Player != null)
{
// The client never sends over the real disconnect reason so we always assume ExitGame
var isRemote = reason == "The remote sent a disconnect request";
await Player.Game.HandleRemovePlayer(Id, isRemote ? DisconnectReason.ExitGame : DisconnectReason.Error);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception caught in client [{Id}] disconnection.", Id);
}
var platform = PlatformSpecificData?.Platform.ToString().ToLowerInvariant() ?? "unknown";
var hashPuid = ProductUserId?.Length >= 9 ? ProductUserId[..9] : ProductUserId ?? "0";
var fc = FriendCode ?? "unknown";
_logger.LogInformation("Client {Id} {Name} {FriendCode} {HashPuid} {Platform} disconnecting, reason: {Reason}",
Id, Name, fc, hashPuid, platform, reason);
_clientManager.Remove(this);
await _gameManager.OnClientDisconnectAsync(this);
}
private bool IsPacketAllowed(IMessageReader message, bool hostOnly)
{
if (Player == null)
{
return false;
}
var game = Player.Game;
// GameCode must match code of the current game assigned to the player.
var code = message.ReadInt32();
if (code != game.Code.Value)
{
return false;
}
// Some packets should only be sent by the host of the game.
if (hostOnly)
{
if (game.HostId == Id)
{
return true;
}
_logger.LogWarning("Client [{Id}] sent packet only allowed by the host ({HostId})", Id, game.HostId);
return false;
}
return true;
}
private bool IsPackedGameDataToAllowed(IMessageReader message)
{
if (Player == null)
{
return false;
}
var game = Player.Game;
if (message.ReadPackedInt32() != game.Code)
{
return false;
}
if (game.HostId == Id)
{
return true;
}
_logger.LogWarning("Client [{Id}] sent PackedGameDataTo only allowed by the host ({HostId})", Id, game.HostId);
return false;
}
/// <summary>
/// Triggered when a client reports another player.
/// </summary>
private async ValueTask OnReportPlayerAsync(IMessageReader reader)
{
var gameCode = reader.ReadInt32();
var reportedClientId = reader.ReadPackedInt32();
var reason = (ReportReasons)reader.ReadByte();
var reportedClient = _clientManager.Clients.FirstOrDefault(c => c.Id == reportedClientId);
_logger.LogWarning(
"[Report] {Reporter} ({ReporterFriendCode}) reported {Reported} ({ReporterFriendCode}) Reason={Reason} Game={Game}",
Name, string.IsNullOrEmpty(FriendCode) ? "unknown" : FriendCode,
reportedClient?.Name ?? "unknown", reportedClient?.FriendCode ?? "unknown",
reason, gameCode);
var outcome = ReportOutcome.NotReportedUnknown;
if (Player != null && Player.Character != null)
{
var reportEvent = new PlayerReportEvent(Player.Game, Player, Player.Character, reportedClient, reason);
await _eventManager.CallAsync(reportEvent);
outcome = reportEvent.Outcome;
}
_reportStore.Add(new ReportStore.ReportEntry
{
GameCode = gameCode.ToString(),
ReporterName = Name,
ReporterFriendCode = FriendCode,
ReportedName = reportedClient?.Name,
ReportedFriendCode = reportedClient?.FriendCode,
Reason = reason,
Outcome = outcome,
});
using var message = MessageWriter.Get(MessageType.Reliable);
message.StartMessage(MessageFlags.ReportPlayer);
message.WritePacked(reportedClientId);
message.Write((int)reason);
message.Write((byte)outcome);
message.Write(reportedClient?.Name ?? string.Empty);
message.EndMessage();
await Connection.SendAsync(message);
}
/// <summary>
/// Triggered when the connected client requests the PlatformSpecificData.
/// </summary>
/// <param name="code">
/// The GameCode of the game whose platform id's are checked.
/// </param>
private ValueTask OnQueryPlatformIds(GameCode code)
{
using var message = MessageWriter.Get(MessageType.Reliable);
var playerSpecificData = _gameManager.Find(code)?.Players.Select(p => p.Client.PlatformSpecificData) ?? Enumerable.Empty<PlatformSpecificData>();
Message22QueryPlatformIdsS2C.Serialize(message, code, playerSpecificData);
return Connection.SendAsync(message);
}
}
}