mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-19 15:51:39 +00:00
604 lines
23 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|