#nullable enable using System; using System.Collections.Generic; using System.Diagnostics; using Barotrauma.Networking; namespace Barotrauma { internal sealed class DoSProtection { /// /// A struct that executes an action when it's created and another one when it's disposed. /// public readonly ref struct DoSAction { private readonly Client sender; private readonly Action end; public DoSAction(Client sender, Action start, Action end) { this.sender = sender; this.end = end; start(sender); } public void Dispose() { end(sender); } } private sealed class OffenseData { /// /// Timer that keeps track of how long it takes to process a packet. /// public readonly Stopwatch Stopwatch = new(); /// /// Amount of strikes the client has received for causing the server to slow down. /// public int Strikes; /// /// How many packets have been sent in the last minute. /// public int PacketCount; /// /// Resets the strikes and packet count. /// public void ResetStrikes() { Strikes = 0; PacketCount = 0; } /// /// Resets the timer. /// public void ResetTimer() => Stopwatch.Reset(); } private readonly Dictionary clients = new(); private float stopwatchResetTimer, strikesResetTimer; private const int StopwatchResetInterval = 1, StrikesResetInterval = 60, StrikeThreshold = 6; private const int MinPacketLimitMultipler = 1; private static int GetMaxPacketLimit(ServerSettings settings) => (int)MathF.Ceiling( settings.MaxPacketAmount * MathF.Max( settings.TickRate / (float)ServerSettings.DefaultTickRate, MinPacketLimitMultipler)); // Prevent the rate limit multiplier from being less than 1. /// /// Called when the server receives a packet to start logging how much time it takes to process. /// /// The client to start a timer for. /// Nothing useful. Required for the "using" keyword. /// /// Calling stop is not required, the timer will be stopped automatically when the function it was started in returns. /// /// /// /// public void ServerRead(IReadMessage msg, Client c) /// { /// // start the timer /// using var _ = dosProtection.Start(connectedClient); /// /// if (condition) /// { /// // the timer will be stopped here. /// return; /// } /// /// ProcessMessage(msg); /// // the timer will be stopped here. /// } /// /// public DoSAction Start(Client client) => new DoSAction(client, StartFor, EndFor); /// /// Temporary pauses the timer for the client. /// Used when we know a packet is going to slow down the server but we don't want to count it as a strike. /// For example when a client is starting a round. /// /// The client to pause the timer for. /// Nothing useful. Required for the "using" keyword. /// /// Calling resume is not required, the timer will be resumed automatically when the using block ends. /// /// /// /// using (dos.Pause(client)) /// { /// // do something that will slow down the server /// } /// // the timer will be resumed here /// /// public DoSAction Pause(Client client) => new DoSAction(client, PauseFor, ResumeFor); private void StartFor(Client client) { clients.TryAdd(client, new OffenseData()); clients[client].Stopwatch.Start(); } private void EndFor(Client client) { if (GetData(client) is not { } data) { return; } data.PacketCount++; data.Stopwatch.Stop(); UpdateOffense(client, data); } // stops the clock but doesn't update offenses private void PauseFor(Client client) => GetData(client)?.Stopwatch.Stop(); private void ResumeFor(Client client) => GetData(client)?.Stopwatch.Start(); private void UpdateOffense(Client client, OffenseData data) { if (GameMain.Server?.ServerSettings is not { } settings) { return; } // client is sending too many packets, kick them if (data.PacketCount > GetMaxPacketLimit(settings) && settings.MaxPacketAmount > ServerSettings.PacketLimitMin) { AttemptKickClient(client, TextManager.Get("PacketLimitKicked")); clients.Remove(client); return; } // if the stopwatch has been running for an entire second without the Update() method resetting it (which it does every second) then something is wrong if (data.Stopwatch.ElapsedMilliseconds < 100) { return; } data.Strikes++; data.ResetTimer(); GameServer.Log($"{NetworkMember.ClientLogName(client)} is causing the server to slow down.", ServerLog.MessageType.DoSProtection); // too many strikes, get them out of here if (data.Strikes < StrikeThreshold) { return; } if (settings.EnableDoSProtection) { AttemptKickClient(client, TextManager.Get("DoSProtectionKicked")); } clients.Remove(client); static void AttemptKickClient(Client client, LocalizedString reason) { // ReSharper disable once ConvertToConstant.Local bool doesRateLimitAffectClient = #if DEBUG true; // for testing #else !RateLimiter.IsExempt(client); #endif if (!doesRateLimitAffectClient) { return; } GameMain.Server?.KickClient(client, reason.Value); } } public void Update(float deltaTime) { stopwatchResetTimer += deltaTime; strikesResetTimer += deltaTime; // reset the stopwatch every second if (stopwatchResetTimer > StopwatchResetInterval) { stopwatchResetTimer = 0; foreach (OffenseData data in clients.Values) { data.ResetTimer(); } } // reset the strikes every minute if (strikesResetTimer > StrikesResetInterval) { strikesResetTimer = 0; foreach (var (client, data) in clients) { if (GameMain.Server?.ServerSettings is { MaxPacketAmount: > ServerSettings.PacketLimitMin } settings) { if (data.PacketCount > GetMaxPacketLimit(settings) * 0.9f) { GameServer.Log($"{NetworkMember.ClientLogName(client)} is sending a lot of packets and almost got kicked! ({data.PacketCount}).", ServerLog.MessageType.DoSProtection); } } data.ResetStrikes(); } } } private OffenseData? GetData(Client client) => clients.TryGetValue(client, out OffenseData? data) ? data : null; } }