fix: Fixes TCPServer accept async, makes Firewall/IP Limiter multithreaded (#2134)
This commit is contained in:
parent
bcab91255a
commit
2a8c62e8be
15 changed files with 578 additions and 215 deletions
137
Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs
Normal file
137
Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Server.Network;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
public class FirewallTests
|
||||
{
|
||||
[Fact]
|
||||
public void Firewall_BlocksIPAddress_WhenAdded()
|
||||
{
|
||||
var ip = IPAddress.Parse("192.168.1.1");
|
||||
var entry = new SingleIpFirewallEntry("192.168.1.1");
|
||||
|
||||
Assert.False(Firewall.IsBlocked(ip));
|
||||
|
||||
Firewall.Add(entry);
|
||||
|
||||
Assert.True(Firewall.IsBlocked(ip));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Firewall_DoesNotBlockIPAddress_WhenNotAdded()
|
||||
{
|
||||
var ip = IPAddress.Parse("192.168.1.2");
|
||||
Assert.False(Firewall.IsBlocked(ip));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Firewall_StopsBlockingIPAddress_WhenRemoved()
|
||||
{
|
||||
var ip = IPAddress.Parse("192.168.1.3");
|
||||
var entry = new SingleIpFirewallEntry("192.168.1.3");
|
||||
|
||||
Firewall.Add(entry);
|
||||
Assert.True(Firewall.IsBlocked(ip));
|
||||
|
||||
Firewall.Remove(entry);
|
||||
Assert.False(Firewall.IsBlocked(ip));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Firewall_BlocksIPRange()
|
||||
{
|
||||
var entry = new CidrFirewallEntry(IPAddress.Parse("10.0.0.1"), IPAddress.Parse("10.0.0.5"));
|
||||
|
||||
Firewall.Add(entry);
|
||||
|
||||
Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.1")));
|
||||
Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.3")));
|
||||
Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.5")));
|
||||
|
||||
Assert.False(Firewall.IsBlocked(IPAddress.Parse("10.0.0.6")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Firewall_CacheInvalidation_WorksOnUpdate()
|
||||
{
|
||||
var ip = IPAddress.Parse("192.168.1.10");
|
||||
var entry = new SingleIpFirewallEntry("192.168.1.10");
|
||||
|
||||
Firewall.Add(entry);
|
||||
Assert.True(Firewall.IsBlocked(ip));
|
||||
|
||||
Firewall.Remove(entry);
|
||||
Assert.False(Firewall.IsBlocked(ip));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Firewall_ReadsFirewallSetCorrectly()
|
||||
{
|
||||
var entry = new SingleIpFirewallEntry("172.16.0.1");
|
||||
Firewall.Add(entry);
|
||||
|
||||
bool found = false;
|
||||
Firewall.ReadFirewallSet(set =>
|
||||
{
|
||||
found = set.Contains(entry);
|
||||
});
|
||||
|
||||
Assert.True(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Firewall_IsThreadSafe()
|
||||
{
|
||||
IPAddress[] testIps = new IPAddress[256];
|
||||
for (int i = 0; i <= 255; i++)
|
||||
{
|
||||
testIps[i] = IPAddress.Parse($"192.168.0.{i}");
|
||||
}
|
||||
|
||||
var entry = new CidrFirewallEntry(IPAddress.Parse("192.168.0.1"), IPAddress.Parse("192.168.0.255"));
|
||||
Firewall.Add(entry);
|
||||
|
||||
Parallel.ForEach(testIps, ip =>
|
||||
{
|
||||
bool shouldBlock = int.Parse(ip.ToString().Split('.')[3]) is > 0;
|
||||
Assert.Equal(shouldBlock, Firewall.IsBlocked(ip));
|
||||
});
|
||||
|
||||
Firewall.Remove(entry);
|
||||
|
||||
Parallel.ForEach(testIps, ip =>
|
||||
{
|
||||
Assert.False(Firewall.IsBlocked(ip));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Firewall_DoesNotThrowWhenRemovingNonExistentEntry()
|
||||
{
|
||||
var entry = new SingleIpFirewallEntry("203.0.113.5");
|
||||
Assert.False(Firewall.Remove(entry));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Firewall_CacheHandlesMultipleUpdates()
|
||||
{
|
||||
var ip = IPAddress.Parse("192.168.1.20");
|
||||
var entry = new SingleIpFirewallEntry("192.168.1.20");
|
||||
|
||||
Firewall.Add(entry);
|
||||
Assert.True(Firewall.IsBlocked(ip));
|
||||
|
||||
Firewall.Remove(entry);
|
||||
Assert.False(Firewall.IsBlocked(ip));
|
||||
|
||||
Firewall.Add(entry);
|
||||
Assert.True(Firewall.IsBlocked(ip));
|
||||
|
||||
Firewall.Remove(entry);
|
||||
Assert.False(Firewall.IsBlocked(ip));
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ public class ClientVersion : IComparable<ClientVersion>, IComparer<ClientVersion
|
|||
public static readonly ClientVersion Version6000 = new("6.0.0.0");
|
||||
public static readonly ClientVersion Version6000KR = new("66.55.38"); // KR 2.44.0.15 (First release)
|
||||
public static readonly ClientVersion Version6017 = new("6.0.1.7");
|
||||
public static readonly ClientVersion Version6050 = new("6.0.5.0");
|
||||
public static readonly ClientVersion Version60142 = new("6.0.14.2");
|
||||
public static readonly ClientVersion Version60142KR = new("66.55.53"); // KR 2.59.0.2
|
||||
public static readonly ClientVersion Version7000 = new("7.0.0.0");
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ public static class UOClient
|
|||
|
||||
public static CUOSettings CuoSettings { get; private set; }
|
||||
public static ClientVersion ServerClientVersion { get; private set; }
|
||||
public static ClientVersion MinRequired { get; set; }
|
||||
public static ClientVersion MaxRequired { get; set; }
|
||||
|
||||
public static void Load()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,18 +21,29 @@ namespace Server;
|
|||
|
||||
public sealed class EventLoopContext : SynchronizationContext
|
||||
{
|
||||
private readonly ConcurrentQueue<Action> _queue;
|
||||
private readonly Thread _mainThread;
|
||||
|
||||
public EventLoopContext()
|
||||
public enum Priority
|
||||
{
|
||||
_queue = new ConcurrentQueue<Action>();
|
||||
Normal,
|
||||
High
|
||||
}
|
||||
|
||||
private readonly ConcurrentQueue<Action> _queue;
|
||||
private readonly ConcurrentQueue<Action> _priorityQueue;
|
||||
private readonly Thread _mainThread;
|
||||
private readonly int _maxPerFrame;
|
||||
|
||||
public EventLoopContext(int maxPerFrame = 128)
|
||||
{
|
||||
_maxPerFrame = maxPerFrame;
|
||||
_queue = [];
|
||||
_priorityQueue = [];
|
||||
_mainThread = Thread.CurrentThread;
|
||||
}
|
||||
|
||||
public override SynchronizationContext CreateCopy() => new EventLoopContext();
|
||||
|
||||
public void Post(Action d) => _queue.Enqueue(d);
|
||||
public void Post(Action d, Priority priority = Priority.Normal) =>
|
||||
(priority == Priority.High ? _priorityQueue : _queue).Enqueue(d);
|
||||
|
||||
public override void Post(SendOrPostCallback d, object state) => _queue.Enqueue(() => d(state));
|
||||
|
||||
|
|
@ -62,7 +73,17 @@ public sealed class EventLoopContext : SynchronizationContext
|
|||
throw new Exception("Called EventLoop.ExecuteTasks on incorrect thread!");
|
||||
}
|
||||
|
||||
var count = _queue.Count;
|
||||
var count = _priorityQueue.Count;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (_priorityQueue.TryDequeue(out var a))
|
||||
{
|
||||
a();
|
||||
}
|
||||
}
|
||||
|
||||
count = Math.Min(_queue.Count, _maxPerFrame);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2024 - ModernUO Development Team *
|
||||
* Copyright 2019-2025 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: Firewall.cs *
|
||||
* *
|
||||
|
|
@ -14,28 +14,44 @@
|
|||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public static class Firewall
|
||||
{
|
||||
[ThreadStatic]
|
||||
private static InternalValidationEntry _validationEntry;
|
||||
private static readonly Dictionary<IPAddress, bool> _isBlockedCache = new();
|
||||
private static readonly ConcurrentDictionary<IPAddress, int> _isBlockedCache = [];
|
||||
private static readonly ReaderWriterLockSlim _firewallLock = new(LockRecursionPolicy.NoRecursion);
|
||||
|
||||
private static readonly SortedSet<IFirewallEntry> _firewallSet = new();
|
||||
private static int _firewallVersion;
|
||||
private static readonly SortedSet<IFirewallEntry> _firewallSet = [];
|
||||
|
||||
public static SortedSet<IFirewallEntry> FirewallSet => _firewallSet;
|
||||
public static int FirewallSetCount => _firewallSet.Count;
|
||||
|
||||
public static void ReadFirewallSet(Action<IReadOnlySet<IFirewallEntry>> callback)
|
||||
{
|
||||
_firewallLock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
callback(_firewallSet);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_firewallLock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsBlocked(IPAddress address)
|
||||
{
|
||||
ref var isBlocked = ref CollectionsMarshal.GetValueRefOrAddDefault(_isBlockedCache, address, out var exists);
|
||||
if (exists)
|
||||
if (_isBlockedCache.TryGetValue(address, out var blockVersion) && blockVersion == _firewallVersion)
|
||||
{
|
||||
return isBlocked;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_validationEntry == null)
|
||||
|
|
@ -47,35 +63,69 @@ public static class Firewall
|
|||
_validationEntry.Address = address;
|
||||
}
|
||||
|
||||
if (CheckBlocked(_validationEntry))
|
||||
{
|
||||
_isBlockedCache[address] = _firewallVersion;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CheckBlocked(IFirewallEntry validationEntry)
|
||||
{
|
||||
if (_firewallSet.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_firewallLock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var min = _firewallSet.Min;
|
||||
if (validationEntry.CompareTo(min) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get all entries that are lower than our validation entry
|
||||
var view = _firewallSet.GetViewBetween(_firewallSet.Min, _validationEntry);
|
||||
var view = _firewallSet.GetViewBetween(min, validationEntry);
|
||||
|
||||
// Loop backward since there shouldn't be any entries where the Min address is higher than ours
|
||||
foreach (var firewallEntry in view.Reverse())
|
||||
{
|
||||
if (firewallEntry.IsBlocked(_validationEntry.MinIpAddress))
|
||||
if (firewallEntry.IsBlocked(validationEntry.MinIpAddress))
|
||||
{
|
||||
isBlocked = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
isBlocked = view.Max?.IsBlocked(_validationEntry.MinIpAddress) == true;
|
||||
|
||||
return isBlocked;
|
||||
return view.Max?.IsBlocked(validationEntry.MinIpAddress) == true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_firewallLock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static bool Add(IFirewallEntry firewallEntry)
|
||||
{
|
||||
_firewallLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_firewallSet.Add(firewallEntry))
|
||||
{
|
||||
_isBlockedCache.Clear();
|
||||
Interlocked.Increment(ref _firewallVersion); // Update version
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_firewallLock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static bool Remove(IFirewallEntry entry)
|
||||
|
|
@ -85,14 +135,21 @@ public static class Firewall
|
|||
return false;
|
||||
}
|
||||
|
||||
_firewallLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_firewallSet.Remove(entry))
|
||||
{
|
||||
_isBlockedCache.Clear();
|
||||
Interlocked.Increment(ref _firewallVersion); // Update version
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_firewallLock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
private class InternalValidationEntry : BaseFirewallEntry
|
||||
{
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@ public interface IFirewallEntry : IComparable<IFirewallEntry>
|
|||
return 1;
|
||||
}
|
||||
|
||||
if (MaxIpAddress < other.MaxIpAddress)
|
||||
if (MaxIpAddress > other.MaxIpAddress)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (MaxIpAddress > other.MaxIpAddress)
|
||||
if (MaxIpAddress < other.MaxIpAddress)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2024 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: IPLimiter.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
|
||||
namespace Server.Misc;
|
||||
|
||||
public static class IPLimiter
|
||||
{
|
||||
private static readonly SortedSet<IPAccessLog> _connectionAttempts = [];
|
||||
private static readonly SortedSet<IPAccessLog> _throttledAddresses = [];
|
||||
|
||||
private static readonly IPAddress _localHost = IPAddress.Parse("127.0.0.1");
|
||||
|
||||
public static TimeSpan ConnectionAttemptsDuration { get; private set; }
|
||||
public static TimeSpan ConnectionThrottleDuration { get; private set; }
|
||||
|
||||
public static bool Enabled { get; private set; }
|
||||
public static int MaxConnections { get; private set; }
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
Enabled = ServerConfiguration.GetOrUpdateSetting("ipLimiter.enable", true);
|
||||
MaxConnections = ServerConfiguration.GetOrUpdateSetting("ipLimiter.maxConnectionsPerIP", 5);
|
||||
ConnectionAttemptsDuration = ServerConfiguration.GetOrUpdateSetting("ipLimiter.clearConnectionAttemptsDuration", TimeSpan.FromSeconds(10));
|
||||
ConnectionThrottleDuration = ServerConfiguration.GetOrUpdateSetting("ipLimiter.connectionThrottleDuration", TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
private static readonly IPAccessLog _accessCheck = new(IPAddress.None, DateTime.MinValue);
|
||||
|
||||
public static bool Verify(IPAddress ourAddress)
|
||||
{
|
||||
if (!Enabled || ourAddress.Equals(_localHost))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var now = Core.Now;
|
||||
|
||||
IPAccessLog accessLog;
|
||||
|
||||
while (_throttledAddresses.Count > 0)
|
||||
{
|
||||
accessLog = _throttledAddresses.Min;
|
||||
if (now <= accessLog.Expiration)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_throttledAddresses.Remove(accessLog);
|
||||
}
|
||||
|
||||
_accessCheck.IPAddress = ourAddress;
|
||||
|
||||
if (_connectionAttempts.TryGetValue(_accessCheck, out accessLog))
|
||||
{
|
||||
_connectionAttempts.Remove(accessLog);
|
||||
accessLog.Count++;
|
||||
accessLog.Expiration = now + ConnectionAttemptsDuration;
|
||||
|
||||
if (now <= accessLog.Expiration && accessLog.Count >= MaxConnections)
|
||||
{
|
||||
_throttledAddresses.Add(accessLog);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
accessLog = new IPAccessLog(ourAddress, now + ConnectionAttemptsDuration);
|
||||
}
|
||||
|
||||
// Add it back so it is sorted properly
|
||||
_connectionAttempts.Add(accessLog);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private class IPAccessLog : IComparable<IPAccessLog>
|
||||
{
|
||||
public IPAddress IPAddress;
|
||||
public DateTime Expiration;
|
||||
public int Count;
|
||||
|
||||
public IPAccessLog(IPAddress ipAddress, DateTime expiration)
|
||||
{
|
||||
IPAddress = ipAddress;
|
||||
Expiration = expiration;
|
||||
Count = 1;
|
||||
}
|
||||
|
||||
public int CompareTo(IPAccessLog other) =>
|
||||
IPAddress.Equals(other.IPAddress) ? 0 : Expiration.CompareTo(other.Expiration);
|
||||
}
|
||||
}
|
||||
197
Projects/Server/Network/IPRateLimiter.cs
Normal file
197
Projects/Server/Network/IPRateLimiter.cs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2025 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: IPRateLimiter.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public class IPRateLimiter
|
||||
{
|
||||
private static readonly ConcurrentQueue<IPStats> _statsPool = [];
|
||||
private const int MaxPoolSize = 32_768;
|
||||
|
||||
private readonly SemaphoreSlim _cleanupSignal = new(0, 1);
|
||||
private readonly ConcurrentDictionary<IPAddress, IPStats> _ipAttempts;
|
||||
private readonly ConcurrentQueue<IPAddress> _cleanupQueue;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
private readonly int _maxAttempts;
|
||||
private readonly long _timeWindow; // milliseconds
|
||||
private readonly long _initialBackoff; // milliseconds
|
||||
private readonly double _backoffMultiplier;
|
||||
private readonly long _maxBackoff; // milliseconds
|
||||
|
||||
public IPRateLimiter(
|
||||
int maxAttempts, long timeWindow, long initialBackoff, double backoffMultiplier, long maxBackoff,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
_ipAttempts = [];
|
||||
_cleanupQueue = [];
|
||||
_maxAttempts = maxAttempts;
|
||||
_timeWindow = timeWindow;
|
||||
_initialBackoff = initialBackoff;
|
||||
_backoffMultiplier = backoffMultiplier;
|
||||
_maxBackoff = maxBackoff;
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
|
||||
Task.Run(CleanupLoop, Core.ClosingTokenSource.Token);
|
||||
}
|
||||
|
||||
public bool Verify(IPAddress ip, out int totalAttempts)
|
||||
{
|
||||
var nowTicks = Core.TickCount;
|
||||
var ipStats = _ipAttempts.GetOrAdd(ip, _ => GetOrCreateIPStats());
|
||||
var added = ipStats.AttemptCount == 0;
|
||||
|
||||
lock (ipStats)
|
||||
{
|
||||
if (nowTicks - ipStats.LastAttemptTicks > _timeWindow)
|
||||
{
|
||||
ipStats.AttemptCount = 1; // Reset
|
||||
}
|
||||
else
|
||||
{
|
||||
ipStats.AttemptCount++;
|
||||
}
|
||||
|
||||
totalAttempts = ipStats.AttemptCount;
|
||||
ipStats.LastAttemptTicks = nowTicks;
|
||||
|
||||
if (ipStats.BlockUntilTicks - nowTicks > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ipStats.AttemptCount > _maxAttempts)
|
||||
{
|
||||
var backoffTime = Math.Min(
|
||||
(long)(_initialBackoff * Math.Pow(_backoffMultiplier, ipStats.AttemptCount - _maxAttempts)),
|
||||
_maxBackoff
|
||||
);
|
||||
|
||||
ipStats.BlockUntilTicks = nowTicks + backoffTime;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (added)
|
||||
{
|
||||
_cleanupQueue.Enqueue(ip);
|
||||
RunCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IPStats GetOrCreateIPStats() => _statsPool.TryDequeue(out var stats) ? stats : new IPStats();
|
||||
|
||||
private static void ReturnToPool(IPStats stats)
|
||||
{
|
||||
stats.Reset();
|
||||
|
||||
if (_statsPool.Count < MaxPoolSize)
|
||||
{
|
||||
_statsPool.Enqueue(stats);
|
||||
}
|
||||
}
|
||||
|
||||
private void RunCleanup()
|
||||
{
|
||||
if (_cleanupSignal.CurrentCount > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_cleanupSignal.Release();
|
||||
Task.Run(CleanupLoop, _cts.Token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask CleanupLoop()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
await _cleanupSignal.WaitAsync(_cts.Token);
|
||||
|
||||
int maxToProcess = Math.Min(_cleanupQueue.Count, 500);
|
||||
var nowTicks = Core.TickCount;
|
||||
|
||||
for (int i = 0; i < maxToProcess; i++)
|
||||
{
|
||||
if (!_cts.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!_cleanupQueue.TryDequeue(out var ip) || !_ipAttempts.TryGetValue(ip, out var ipStats))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lock (ipStats)
|
||||
{
|
||||
if (nowTicks - ipStats.LastAttemptTicks < _timeWindow)
|
||||
{
|
||||
_cleanupQueue.Enqueue(ip);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_ipAttempts.TryRemove(ip, out _))
|
||||
{
|
||||
ReturnToPool(ipStats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!_cleanupQueue.IsEmpty)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), _cts.Token);
|
||||
try
|
||||
{
|
||||
_cleanupSignal.Release();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class IPStats
|
||||
{
|
||||
public int AttemptCount;
|
||||
public long LastAttemptTicks;
|
||||
public long BlockUntilTicks;
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
AttemptCount = 0;
|
||||
LastAttemptTicks = 0;
|
||||
BlockUntilTicks = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,15 +13,17 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Server.Logging;
|
||||
using Server.Misc;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
|
|
@ -35,8 +37,11 @@ public static class TcpServer
|
|||
public static IPEndPoint[] ListeningAddresses { get; private set; }
|
||||
public static Socket[] Listeners { get; private set; }
|
||||
|
||||
private static IPRateLimiter _ipRateLimiter;
|
||||
|
||||
public static void Start()
|
||||
{
|
||||
_ipRateLimiter = new IPRateLimiter(10, 10000, 1000, 2.0, 3_600_000, Core.ClosingTokenSource.Token);
|
||||
HashSet<IPEndPoint> listeningAddresses = [];
|
||||
List<Socket> listeners = [];
|
||||
foreach (var ipep in ServerConfiguration.Listeners)
|
||||
|
|
@ -123,49 +128,92 @@ public static class TcpServer
|
|||
return null;
|
||||
}
|
||||
|
||||
private static async void BeginAcceptingSockets(Socket listener)
|
||||
private static async ValueTask BeginAcceptingSockets(Socket listener)
|
||||
{
|
||||
while (!Core.Closing)
|
||||
{
|
||||
Socket socket = null;
|
||||
try
|
||||
{
|
||||
socket = await listener.AcceptAsync();
|
||||
var socket = await listener.AcceptAsync(Core.ClosingTokenSource.Token);
|
||||
var remoteIP = ((IPEndPoint)socket.RemoteEndPoint)!.Address;
|
||||
|
||||
if (!IPLimiter.Verify(remoteIP))
|
||||
if (!_ipRateLimiter.Verify(remoteIP, out var totalAttempts))
|
||||
{
|
||||
TraceDisconnect("Past IP limit threshold", remoteIP);
|
||||
logger.Debug("{Address} Past IP limit threshold", remoteIP);
|
||||
logger.Debug("{Address} Past IP limit threshold ({TotalAttempts})", remoteIP, totalAttempts);
|
||||
}
|
||||
else if (Firewall.IsBlocked(remoteIP))
|
||||
{
|
||||
TraceDisconnect("Firewalled", remoteIP);
|
||||
logger.Debug("{Address} Firewalled", remoteIP);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = Task.Run(() => ProcessSocketConnection(socket), Core.ClosingTokenSource.Token);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask ProcessSocketConnection(Socket socket)
|
||||
{
|
||||
byte[] firstBytes = ArrayPool<byte>.Shared.Rent(83);
|
||||
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(Core.ClosingTokenSource.Token);
|
||||
cts.CancelAfter(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
try
|
||||
{
|
||||
var bytesRead = await socket.ReceiveAsync(firstBytes, SocketFlags.Peek, cts.Token);
|
||||
|
||||
var isValid =
|
||||
// Older clients only send the 4 byte seed first
|
||||
(UOClient.MinRequired == null || UOClient.MinRequired < ClientVersion.Version6050) && bytesRead == 4 ||
|
||||
(UOClient.MaxRequired == null || UOClient.MaxRequired >= ClientVersion.Version6050) && (
|
||||
// Account Login - 0xEF + 0x80 (83 bytes)
|
||||
bytesRead >= 83 && firstBytes[0] == 0xEF && firstBytes[21] == 0x80 ||
|
||||
// Game Login - 4 bytes + 0x91 (69 bytes)
|
||||
firstBytes[4] == 0x91 && bytesRead >= 69
|
||||
);
|
||||
|
||||
// TODO: Validate client version is v4 -> v7 for 0xEF packet
|
||||
// TODO: Validate Account Login seed matches Game Login seed
|
||||
// TODO: Validate AuthId for 0x91 packet
|
||||
// TODO: Validate username is ascii and not empty
|
||||
// TODO: Validate password is ascii and not empty
|
||||
if (isValid)
|
||||
{
|
||||
var args = new SocketConnectEventArgs(socket);
|
||||
EventSink.InvokeSocketConnect(args);
|
||||
|
||||
if (args.AllowConnection)
|
||||
{
|
||||
_ = new NetState(socket);
|
||||
continue;
|
||||
Core.LoopContext.Post(() => _ = new NetState(socket), EventLoopContext.Priority.High);
|
||||
return;
|
||||
}
|
||||
|
||||
TraceDisconnect("Rejected by socket event handler", remoteIP);
|
||||
|
||||
// Reject the connection
|
||||
socket.Send(_socketRejected, SocketFlags.None);
|
||||
}
|
||||
logger.Debug("{Address} Rejected by socket handler", ((IPEndPoint)socket.RemoteEndPoint)!.Address);
|
||||
|
||||
cts.TryReset();
|
||||
cts.CancelAfter(TimeSpan.FromMilliseconds(500));
|
||||
await socket.SendAsync(_socketRejected, SocketFlags.None, cts.Token);
|
||||
CloseSocket(socket);
|
||||
}
|
||||
else
|
||||
{
|
||||
ForceCloseSocket(socket);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
CloseSocket(socket);
|
||||
ForceCloseSocket(socket);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(firstBytes);
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -182,22 +230,16 @@ public static class TcpServer
|
|||
}
|
||||
}
|
||||
|
||||
private static void TraceDisconnect(string reason, IPAddress ip)
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void ForceCloseSocket(Socket socket)
|
||||
{
|
||||
try
|
||||
{
|
||||
using StreamWriter op = new StreamWriter("network-socket-disconnects.log", true);
|
||||
op.WriteLine($"# {Core.Now}");
|
||||
|
||||
op.WriteLine($"Address: {ip}");
|
||||
op.WriteLine(reason);
|
||||
|
||||
op.WriteLine();
|
||||
op.WriteLine();
|
||||
socket.Disconnect(false);
|
||||
}
|
||||
catch
|
||||
finally
|
||||
{
|
||||
// ignored
|
||||
socket.Close(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,5 +44,8 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="Migrations/*.v*.json" />
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>Server.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
using System;
|
||||
using Server.Buffers;
|
||||
using Server.Gumps;
|
||||
|
||||
namespace Server.Tests.Gumps;
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ namespace Server.Gumps
|
|||
AddLabel(150, 150, LabelHue, banned.ToString());
|
||||
|
||||
AddLabel(20, 170, LabelHue, "Firewalled:");
|
||||
AddLabel(150, 170, LabelHue, Firewall.FirewallSet.Count.ToString());
|
||||
AddLabel(150, 170, LabelHue, Firewall.FirewallSetCount.ToString());
|
||||
|
||||
AddLabel(20, 190, LabelHue, "Clients:");
|
||||
AddLabel(150, 190, LabelHue, NetState.Instances.Count.ToString());
|
||||
|
|
@ -1165,7 +1165,16 @@ namespace Server.Gumps
|
|||
{
|
||||
AddFirewallHeader();
|
||||
|
||||
m_List ??= Firewall.FirewallSet.ToList<object>();
|
||||
if (m_List == null)
|
||||
{
|
||||
Firewall.ReadFirewallSet(firewallSet =>
|
||||
{
|
||||
list = new List<object>(firewallSet.Count);
|
||||
list.AddRange(firewallSet);
|
||||
});
|
||||
|
||||
m_List = list;
|
||||
}
|
||||
|
||||
AddLabelCropped(12, 120, 358, 20, LabelHue, "IP Address");
|
||||
|
||||
|
|
@ -1178,7 +1187,7 @@ namespace Server.Gumps
|
|||
AddImage(375, 122, 0x25EA);
|
||||
}
|
||||
|
||||
if ((listPage + 1) * 12 < m_List.Count)
|
||||
if ((listPage + 1) * 12 < m_List!.Count)
|
||||
{
|
||||
AddButton(392, 122, 0x15E1, 0x15E5, GetButtonID(1, 1));
|
||||
}
|
||||
|
|
@ -3470,11 +3479,13 @@ namespace Server.Gumps
|
|||
|
||||
if (string.IsNullOrEmpty(match))
|
||||
{
|
||||
notice = "You must enter a username to search.";
|
||||
notice = "You must enter an IP to search.";
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var check in Firewall.FirewallSet)
|
||||
Firewall.ReadFirewallSet(firewallSet =>
|
||||
{
|
||||
foreach (var check in firewallSet)
|
||||
{
|
||||
var checkStr = check.ToString();
|
||||
|
||||
|
|
@ -3483,6 +3494,7 @@ namespace Server.Gumps
|
|||
results.Add(check);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (results.Count == 1)
|
||||
|
|
|
|||
|
|
@ -126,11 +126,14 @@ public static class AdminFirewall
|
|||
}
|
||||
|
||||
public static void Save()
|
||||
{
|
||||
Firewall.ReadFirewallSet(firewallSet =>
|
||||
{
|
||||
using var op = new StreamWriter(firewallConfigPath);
|
||||
foreach (var entry in Firewall.FirewallSet)
|
||||
foreach (var entry in firewallSet)
|
||||
{
|
||||
op.WriteLine(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,14 +27,12 @@ namespace Server.Misc
|
|||
public static bool AllowKR => (AllowedClientTypes & ClientType.KR) != 0;
|
||||
public static bool AllowSA => (AllowedClientTypes & ClientType.SA) != 0;
|
||||
|
||||
public static ClientVersion MinRequired { get; private set; }
|
||||
public static ClientVersion MaxRequired { get; private set; }
|
||||
public static TimeSpan KickDelay { get; private set; }
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
MinRequired = ServerConfiguration.GetSetting("clientVerification.minRequired", (ClientVersion)null);
|
||||
MaxRequired = ServerConfiguration.GetSetting("clientVerification.maxRequired", (ClientVersion)null);
|
||||
UOClient.MinRequired = ServerConfiguration.GetSetting("clientVerification.minRequired", (ClientVersion)null);
|
||||
UOClient.MaxRequired = ServerConfiguration.GetSetting("clientVerification.maxRequired", (ClientVersion)null);
|
||||
|
||||
_enable = ServerConfiguration.GetOrUpdateSetting("clientVerification.enable", true);
|
||||
_invalidClientResponse =
|
||||
|
|
@ -51,12 +49,12 @@ namespace Server.Misc
|
|||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (MinRequired == null && MaxRequired == null)
|
||||
if (UOClient.MinRequired == null && UOClient.MaxRequired == null)
|
||||
{
|
||||
MinRequired = UOClient.ServerClientVersion;
|
||||
UOClient.MinRequired = UOClient.ServerClientVersion;
|
||||
}
|
||||
|
||||
if (MinRequired != null || MaxRequired != null)
|
||||
if (UOClient.MinRequired != null || UOClient.MaxRequired != null)
|
||||
{
|
||||
logger.Information(
|
||||
"Restricting client version to {ClientVersion}. Action to be taken: {Action}",
|
||||
|
|
@ -70,17 +68,17 @@ namespace Server.Misc
|
|||
{
|
||||
if (_versionExpression == null)
|
||||
{
|
||||
if (MinRequired != null && MaxRequired != null)
|
||||
if (UOClient.MinRequired != null && UOClient.MaxRequired != null)
|
||||
{
|
||||
_versionExpression = $"{MinRequired}-{MaxRequired}";
|
||||
_versionExpression = $"{UOClient.MinRequired}-{UOClient.MaxRequired}";
|
||||
}
|
||||
else if (MinRequired != null)
|
||||
else if (UOClient.MinRequired != null)
|
||||
{
|
||||
_versionExpression = $"{MinRequired} or newer";
|
||||
_versionExpression = $"{UOClient.MinRequired} or newer";
|
||||
}
|
||||
else
|
||||
{
|
||||
_versionExpression = $"{MaxRequired} or older";
|
||||
_versionExpression = $"{UOClient.MaxRequired} or older";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,14 +131,14 @@ namespace Server.Misc
|
|||
bool shouldKick = false;
|
||||
bool isKRClient = version.Type == ClientType.KR;
|
||||
|
||||
if (!isKRClient && MinRequired != null && version < MinRequired)
|
||||
if (!isKRClient && UOClient.MinRequired != null && version < UOClient.MinRequired)
|
||||
{
|
||||
sb.Append($"This server doesn't support clients older than {MinRequired}.");
|
||||
sb.Append($"This server doesn't support clients older than {UOClient.MinRequired}.");
|
||||
shouldKick = strictRequirement;
|
||||
}
|
||||
else if (!isKRClient && MaxRequired != null && version > MaxRequired)
|
||||
else if (!isKRClient && UOClient.MaxRequired != null && version > UOClient.MaxRequired)
|
||||
{
|
||||
sb.Append($"This server doesn't support clients newer than {MaxRequired}.");
|
||||
sb.Append($"This server doesn't support clients newer than {UOClient.MaxRequired}.");
|
||||
shouldKick = strictRequirement;
|
||||
}
|
||||
else
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ public static class IncomingAccountPackets
|
|||
if (state.Seed == 0)
|
||||
{
|
||||
state.LogInfo("Invalid client detected, disconnecting");
|
||||
state.Disconnect("Duplicate seed sent.");
|
||||
state.Disconnect("Invalid client detected");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue