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;
+ }
+}