diff --git a/Dockerfile b/Dockerfile index e965c73..76c9a9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -79,7 +79,9 @@ ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 EXPOSE 80/tcp EXPOSE 22023/tcp -EXPOSE 22023/udp +# Main UDP listener moved to the last delta port (DeltaPortEnd). +# Expose the whole delta range so players can connect. +EXPOSE 22024-22099/udp VOLUME ["/app/config", "/app/plugins", "/app/libraries", "/app/Languages", "/app/Log", "/app/Data", "/app/marketplace"] diff --git a/compose.yml b/compose.yml index f862d7f..f252dce 100644 --- a/compose.yml +++ b/compose.yml @@ -7,8 +7,13 @@ services: restart: unless-stopped ports: + # HTTP API / admin panel (TCP) - "22023:22023/tcp" - - "22023:22023/udp" + # Main UDP listener now uses the last port of the delta range + # (DeltaPortEnd), so 22023/udp is no longer the game socket. + # Expose the whole delta range so players can connect. + # Adjust the range to match DeltaPortStart-DeltaPortEnd in config.json. + - "22024-22099:22024-22099/udp" # Uncomment to expose admin HTTP panel: # - "80:80/tcp" diff --git a/src/Empostor.Api/Config/ServerConfig.cs b/src/Empostor.Api/Config/ServerConfig.cs index 7ab7878..a3fe03e 100644 --- a/src/Empostor.Api/Config/ServerConfig.cs +++ b/src/Empostor.Api/Config/ServerConfig.cs @@ -25,6 +25,14 @@ namespace Empostor.Api.Config public ushort DeltaPortEnd { get; set; } = 0; + /// + /// When the delta port pool is enabled, reserve the last port of the + /// range as the main UDP listener instead of the well-known + /// . This hides the default port from garbage + /// UDP floods targeting the commonly known port (e.g. 22023). + /// + public bool ReserveLastDeltaPortAsDefault { get; set; } = true; + public string ResolvePublicIp() { return _resolvedPublicIp ??= IpUtils.ResolveIp(PublicIp); diff --git a/src/Empostor.Server/Http/GamesController.cs b/src/Empostor.Server/Http/GamesController.cs index c6d07f8..1727473 100644 --- a/src/Empostor.Server/Http/GamesController.cs +++ b/src/Empostor.Server/Http/GamesController.cs @@ -9,6 +9,7 @@ using Empostor.Api.Config; using Empostor.Api.Games; using Empostor.Api.Games.Managers; using Empostor.Server.Extensions; +using Empostor.Server.Net; using Empostor.Server.Service.Auth; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -23,17 +24,20 @@ public sealed class GamesController : ControllerBase private readonly ListingManager _listingManager; private readonly ServerConfig _serverConfig; private readonly AuthCacheService _authCache; + private readonly PortPoolService _portPool; public GamesController( IGameManager gameManager, ListingManager listingManager, IOptions serverConfig, - AuthCacheService authCache) + AuthCacheService authCache, + PortPoolService portPool) { _gameManager = gameManager; _listingManager = listingManager; _serverConfig = serverConfig.Value; _authCache = authCache; + _portPool = portPool; } /// @@ -182,7 +186,7 @@ public sealed class GamesController : ControllerBase var clientIp = GetClientIp(); if (clientIp == null) { - return _serverConfig.PublicPort; + return (ushort)(_portPool.DefaultPort > 0 ? _portPool.DefaultPort : _serverConfig.PublicPort); } var deltaPort = _authCache.FindPortByIp(clientIp); @@ -191,7 +195,9 @@ public sealed class GamesController : ControllerBase return (ushort)deltaPort; } - return _serverConfig.PublicPort; + // Fall back to the main listener port (reserved default port when the + // delta pool is enabled, otherwise the configured public port). + return (ushort)(_portPool.DefaultPort > 0 ? _portPool.DefaultPort : _serverConfig.PublicPort); } private IPAddress? GetClientIp() diff --git a/src/Empostor.Server/Net/Manager/ClientManager.cs b/src/Empostor.Server/Net/Manager/ClientManager.cs index 9945587..986d5b2 100644 --- a/src/Empostor.Server/Net/Manager/ClientManager.cs +++ b/src/Empostor.Server/Net/Manager/ClientManager.cs @@ -160,6 +160,9 @@ namespace Empostor.Server.Net.Manager { // Port matched — cancel the allocation timeout _portPool.ConfirmPort(deltaPort); + // Mark the auth-cache entry as active so the inactivity + // timer never clears the port while the player is connected. + _authCache.ConfirmPort(deltaPort); friendCode = authInfo.FriendCode; diff --git a/src/Empostor.Server/Net/MatchmakerService.cs b/src/Empostor.Server/Net/MatchmakerService.cs index 92865c9..deee40f 100644 --- a/src/Empostor.Server/Net/MatchmakerService.cs +++ b/src/Empostor.Server/Net/MatchmakerService.cs @@ -37,7 +37,12 @@ namespace Empostor.Server.Net public async Task StartAsync(CancellationToken cancellationToken) { - var endpoint = new IPEndPoint(IPAddress.Parse(_serverConfig.ResolveListenIp()), _serverConfig.ListenPort); + // Use the reserved default port (last delta port when enabled) so the + // well-known ListenPort is not exposed to garbage UDP floods. + var listenPort = _portPool.IsEnabled && _portPool.DefaultPort > 0 + ? _portPool.DefaultPort + : _serverConfig.ListenPort; + var endpoint = new IPEndPoint(IPAddress.Parse(_serverConfig.ResolveListenIp()), listenPort); await _matchmaker.StartAsync(endpoint); diff --git a/src/Empostor.Server/Net/PortPoolService.cs b/src/Empostor.Server/Net/PortPoolService.cs index 2a6dc0f..7a217db 100644 --- a/src/Empostor.Server/Net/PortPoolService.cs +++ b/src/Empostor.Server/Net/PortPoolService.cs @@ -26,6 +26,14 @@ public sealed class PortPoolService : IDisposable public bool IsEnabled => _deltaPortStart > 0 && _deltaPortEnd >= _deltaPortStart; + /// + /// The main UDP listener port. When delta ports are enabled and + /// is set, the + /// last port of the range is reserved for the main listener so the + /// well-known default port is never used for the game socket. + /// + public int DefaultPort { get; } + /// /// Invoked when a port is returned to the pool (via disconnect or timeout expiry). /// Subscribers should stop the delta listener and close firewall rules. @@ -45,10 +53,25 @@ public sealed class PortPoolService : IDisposable if (IsEnabled) { - var skipped = 0; - for (var port = _deltaPortStart; port <= _deltaPortEnd; port++) + // Reserve the last port of the delta range as the main listener + // (anti garbage-UDP-flood on the well-known default port). + if (cfg.ReserveLastDeltaPortAsDefault) { - if (port == _listenPort) + DefaultPort = _deltaPortEnd; + _logger.LogInformation( + "PortPool reserved last port {Port} as the main UDP listener (anti-flood).", + DefaultPort); + } + else + { + DefaultPort = _listenPort; + } + + var skipped = 0; + var poolEnd = cfg.ReserveLastDeltaPortAsDefault ? _deltaPortEnd - 1 : _deltaPortEnd; + for (var port = _deltaPortStart; port <= poolEnd; port++) + { + if (port == _listenPort || port == DefaultPort) { skipped++; continue; @@ -60,16 +83,17 @@ public sealed class PortPoolService : IDisposable if (skipped > 0) { _logger.LogWarning( - "PortPool skipped {Count} port(s) overlapping with main listen port {ListenPort}", + "PortPool skipped {Count} port(s) overlapping with the main listen port {ListenPort}", skipped, _listenPort); } _logger.LogInformation( "PortPool initialized with {Count} ports ({Start}-{End})", - _availablePorts.Count, _deltaPortStart, _deltaPortEnd); + _availablePorts.Count, _deltaPortStart, poolEnd); } else { + DefaultPort = _listenPort; _logger.LogInformation("PortPool disabled (DeltaPortStart={Start}, DeltaPortEnd={End})", _deltaPortStart, _deltaPortEnd); } diff --git a/src/Empostor.Server/Service/Auth/AuthCacheService.cs b/src/Empostor.Server/Service/Auth/AuthCacheService.cs index 2939836..014f4bc 100644 --- a/src/Empostor.Server/Service/Auth/AuthCacheService.cs +++ b/src/Empostor.Server/Service/Auth/AuthCacheService.cs @@ -17,6 +17,12 @@ public sealed class AuthCacheService : IDisposable private readonly ConcurrentDictionary _byIpDirect = new(); private readonly ConcurrentDictionary _ipToPort = new(); + /// + /// Ports that have an active connection. While a player is connected, + /// the port lease must not be cleared by the inactivity timer. + /// + private readonly ConcurrentDictionary _confirmedPorts = new(); + /// /// Invoked when a port lease should be returned to the pool (e.g., on expiry during cleanup). /// @@ -118,8 +124,31 @@ public sealed class AuthCacheService : IDisposable return _byPort.TryGetValue(port, out var info) && !Expired(info) ? info : null; } + /// + /// Marks a delta port as having an active connection. + /// The inactivity timer will skip confirmed ports so that connected + /// players are never kicked because of the auth-cache TTL. + /// + public void ConfirmPort(int port) + { + if (port <= 0) + { + return; + } + + _confirmedPorts[port] = 0; + + // Refresh the expiry so the entry is also valid for other lookups. + if (_byPort.TryGetValue(port, out var info)) + { + info.CreatedAt = DateTime.UtcNow; + } + } + public void RemoveByPort(int port) { + _confirmedPorts.TryRemove(port, out _); + if (_byPort.TryRemove(port, out var info) && info.ClientIp != null) { _byIpDirect.TryRemove(info.ClientIp, out _); @@ -174,7 +203,12 @@ public sealed class AuthCacheService : IDisposable } // Clean port-based entries - var expiredPorts = _byPort.Where(kv => Expired(kv.Value)).Select(kv => kv.Key).ToList(); + // Confirmed (actively connected) ports are skipped: the lease is only + // released when the player disconnects. + var expiredPorts = _byPort + .Where(kv => !_confirmedPorts.ContainsKey(kv.Key) && Expired(kv.Value)) + .Select(kv => kv.Key) + .ToList(); foreach (var port in expiredPorts) { if (_byPort.TryRemove(port, out var info)) diff --git a/src/Empostor.Server/Utils/StartupBanner.cs b/src/Empostor.Server/Utils/StartupBanner.cs index cb7f27a..3a42ce8 100644 --- a/src/Empostor.Server/Utils/StartupBanner.cs +++ b/src/Empostor.Server/Utils/StartupBanner.cs @@ -72,13 +72,23 @@ internal static class StartupBanner sw.WriteLine(C(BoxML + new string(BoxH[0], width - 2) + BoxMR, Cyan)); Row(sw, width, maxLabel, "Public IP", server.PublicIp); - Row(sw, width, maxLabel, "Listen Port", $"{server.ListenPort} (UDP)"); + var deltaEnabled = server.DeltaPortStart > 0 && server.DeltaPortEnd >= server.DeltaPortStart; + var actualListenPort = deltaEnabled && server.ReserveLastDeltaPortAsDefault + ? server.DeltaPortEnd + : server.ListenPort; + Row(sw, width, maxLabel, "Listen Port", $"{actualListenPort} (UDP)"); Row(sw, width, maxLabel, "HTTP API", $"http://{http.ListenIp}:{http.ListenPort}"); Row(sw, width, maxLabel, "Admin Panel", $"http://{server.PublicIp}:{http.ListenPort}/admin"); - var poolInfo = server.DeltaPortStart > 0 && server.DeltaPortEnd >= server.DeltaPortStart + var poolInfo = deltaEnabled ? $"{server.DeltaPortStart}-{server.DeltaPortEnd} ({server.DeltaPortEnd - server.DeltaPortStart + 1} ports)" : C("disabled", Yellow); Row(sw, width, maxLabel, "Port Pool", poolInfo); + if (deltaEnabled && server.ReserveLastDeltaPortAsDefault) + { + Row(sw, width, maxLabel, "Reserved", $"{server.DeltaPortEnd} (main listener)"); + } + + Row(sw, width, maxLabel, "Packet Filter", C("✔ Enabled", Green)); Row(sw, width, maxLabel, "AntiCheat", anticheat.Enabled ? C("✔ Enabled", Green) : C("✗ Disabled", Red)); Row(sw, width, maxLabel, "Log Level", logLevel); Row(sw, width, maxLabel, "Log File", TrimPath(logPath, width - maxLabel - 5)); diff --git a/src/Empostor.Server/config.json b/src/Empostor.Server/config.json index 3365ae6..5262118 100644 --- a/src/Empostor.Server/config.json +++ b/src/Empostor.Server/config.json @@ -6,8 +6,9 @@ "ListenPort": 22023, "UseUfw": false, "UseFirewalld": false, - "DeltaPortStart": 0, - "DeltaPortEnd": 0 + "DeltaPortStart": 22024, + "DeltaPortEnd": 22099, + "ReserveLastDeltaPortAsDefault": true }, "HttpServer": { "Enabled": true, diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs index 446e615..47589f6 100644 --- a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs @@ -20,6 +20,7 @@ public class UdpConnectionListener : NetworkConnectionListener private static readonly ILogger Logger = Log.ForContext(); private readonly ConcurrentDictionary _allConnections; private readonly UdpConnectionRateLimit _connectionRateLimit; + private readonly UdpPacketFilter _packetFilter; protected readonly ObjectPool _readerPool; private readonly Timer _reliablePacketTimer; @@ -34,6 +35,12 @@ public class UdpConnectionListener : NetworkConnectionListener /// public AcceptConnectionCheck AcceptConnection; + /// + /// The layer-7 packet filter used to drop junk UDP at line speed. + /// Tune its thresholds before if needed. + /// + public UdpPacketFilter PacketFilter => _packetFilter; + #nullable enable /// /// Creates a new UdpConnectionListener for the given , port and . @@ -66,6 +73,7 @@ public class UdpConnectionListener : NetworkConnectionListener _stoppingCts.Token.Register(() => { _socket.Dispose(); }); _connectionRateLimit = new UdpConnectionRateLimit(); + _packetFilter = new UdpPacketFilter(); Setup?.Invoke(_socket, _connectionRateLimit); } @@ -162,6 +170,14 @@ public class UdpConnectionListener : NetworkConnectionListener // Get client from active clients if (!_allConnections.TryGetValue(data.RemoteEndPoint, out var client)) { + // Filtaur-style layer-7 firewall: drop junk packets at line speed + // before they can reach the game server. Only applies to unknown + // endpoints; established connections are never filtered here. + if (_packetFilter.ShouldDrop(data.RemoteEndPoint.Address, data.Buffer, data.Buffer.Length)) + { + return; + } + // Check for malformed connection attempts if (data.Buffer[0] != (byte)UdpSendOption.Hello) return; @@ -230,6 +246,7 @@ public class UdpConnectionListener : NetworkConnectionListener await _reliablePacketTimer.DisposeAsync(); _connectionRateLimit.Dispose(); + _packetFilter.Dispose(); await base.DisposeAsync(); } diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpPacketFilter.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpPacketFilter.cs new file mode 100644 index 0000000..a5dda1c --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpPacketFilter.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Concurrent; +using System.Net; +using System.Threading; +using Next.Hazel.Abstractions; +using Serilog; + +namespace Next.Hazel.Udp; + +/// +/// A Filtaur-style layer-7 firewall for the UDP listener. +/// It drops malformed / junk packets at line speed before they reach the +/// game server, so a simple packet-flooding attacker cannot overload the +/// server and drop everyone out of their games. +/// +/// +/// The filter only applies to new connection attempts (packets from +/// unknown endpoints). Packets belonging to an established connection are +/// never rate-limited here, so in-game traffic is unaffected. +/// +public sealed class UdpPacketFilter : IDisposable +{ + private static readonly ILogger Logger = Log.ForContext(); + + /// + /// Minimal valid packet length (send-option byte + 4-byte handshake nonce). + /// + public int MinPacketLength { get; set; } = 5; + + /// + /// Maximum accepted packet length. Anything larger is dropped instantly. + /// + public int MaxPacketLength { get; set; } = 1203; + + /// + /// Allowed burst of connection attempts from a single IP before + /// kicks in. + /// + public int BurstLimit { get; set; } = 30; + + /// + /// How long an abusive IP stays blacklisted. + /// + public int BlacklistSeconds { get; set; } = 60; + + private readonly bool[] _validOption = BuildValidOptionTable(); + private readonly ConcurrentDictionary _states = new(); + private bool _isDisposed; + + public UdpPacketFilter() + { + } + + /// + /// Fast synchronous check. Returns true when the packet must be + /// dropped, false when it is allowed through. + /// This path performs no allocations. + /// + public bool ShouldDrop(IPAddress ip, byte[] buffer, int length) + { + // 1. Size check (cheapest, no state). + if (length < MinPacketLength || length > MaxPacketLength) + { + return true; + } + + // 2. Send-option byte check (layer-7, no state). + if (!_validOption[buffer[0]]) + { + return true; + } + + // 3. Per-IP connection burst limiting with a temporary blacklist. + var now = Environment.TickCount64; + var state = _states.GetOrAdd(ip, static _ => new IpState()); + + // Reset the window when the previous window expired, and lazily shed + // blacklisted entries whose cooldown is over. + if (now - state.WindowStart > 1000) + { + if (state.BlacklistUntil > 0 && state.BlacklistUntil <= now) + { + _states.TryRemove(ip, out _); + return false; + } + + state.WindowStart = now; + state.Count = 0; + } + + if (state.BlacklistUntil > now) + { + return true; + } + + state.Count++; + if (state.Count <= BurstLimit) + { + return false; + } + + // Abuse detected: blacklist the IP for a while. + state.BlacklistUntil = now + BlacklistSeconds * 1000L; + state.Count = 0; + Logger.Warning("UdpPacketFilter blacklisted {Ip} for {Seconds}s after too many connection attempts", + ip, BlacklistSeconds); + return true; + } + + private static bool[] BuildValidOptionTable() + { + var table = new bool[256]; + // MessageType + table[(byte)MessageType.Unreliable] = true; + table[(byte)MessageType.Reliable] = true; + // UdpSendOption + table[(byte)UdpSendOption.Hello] = true; + table[(byte)UdpSendOption.Ping] = true; + table[(byte)UdpSendOption.Disconnect] = true; + table[(byte)UdpSendOption.Acknowledgement] = true; + table[(byte)UdpSendOption.Fragment] = true; + return table; + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + _states.Clear(); + } + + private sealed class IpState + { + public int Count; + public long WindowStart; + public long BlacklistUntil; + } +}