New Update (No Test):

## Fix: players keep getting kicked mid-game (auth cache TTL)

**Root cause**: the auth cache (`AuthCacheService`) has a 10-min TTL but `CreatedAt` was only set when the token was created — never refreshed while the player was actually connected. After ~12 min, the cleanup timer would expire the port entry, fire `OnPortExpired` → return the port to the pool → stop the delta UDP listener → the active player got force-disconnected ("Server shutting down" in the logs).

**What I changed**:
- `AuthCacheService`: added a `_confirmedPorts` set + `ConfirmPort()`. Cleanup now **skips confirmed (actively connected) ports** — as long as the player is connected, the port lease is never cleared.
- `ClientManager`: calls `_authCache.ConfirmPort(deltaPort)` right after `_portPool.ConfirmPort()` when the port matches, so an active session is marked confirmed.
- Port is still properly released on disconnect (existing `RemoveByPort` path untouched).

---

## Change: hide the default UDP port, use last delta port as default

**Why**: the well-known default port (my config: 22023) was getting flooded with garbage UDP. So now the server reserves the **last port** of the delta range as the main listener, so the default game socket is never the obvious 22023, and even if the pool is fully allocated, the server still runs.

**What I changed**:
- `ServerConfig`: new option `ReserveLastDeltaPortAsDefault` (default true).
- `PortPoolService`: `DefaultPort` = last delta port (`DeltaPortEnd`) when enabled; pool excludes it.
- `MatchmakerService`: main UDP listener binds to `DefaultPort` instead of `ListenPort`.
- `GamesController`: fallback port in `GetDeltaPort()` uses `DefaultPort`.

---

## Change: Filtaur-style line-speed UDP packet filter

**What I changed**: new `UdpPacketFilter` in Hazel + hooked into `UdpConnectionListener.ProcessData`. It drops junk at line speed before it reaches the game server:
- size check (min/max length),
- send-option byte check (layer-7),
- per-IP connection burst limit + temporary blacklist (default 30 burst / 60s blacklist).

Only applies to **unknown endpoints** (new connection attempts); established connections are never filtered, so in-game traffic is unaffected. This is the "filtaur" idea — force spammers through overhead and drop bad packets fast so they can't overload the server and drop everyone.

---

## Config (config.json)
Added `"ReserveLastDeltaPortAsDefault": true` under `Server`.
This commit is contained in:
FangkuaiYa committed 2026-08-12 12:15:41 +08:00
1 parent c6e6214288
commit 2cd2f92d4f
12 files changed
+273 -16

No files matched your search

+3 -1
View File
@@ -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"]
+6 -1
View File
@@ -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"
+8
View File
@@ -25,6 +25,14 @@ namespace Empostor.Api.Config
public ushort DeltaPortEnd { get; set; } = 0;
/// <summary>
/// When the delta port pool is enabled, reserve the last port of the
/// range as the main UDP listener instead of the well-known
/// <see cref="ListenPort" />. This hides the default port from garbage
/// UDP floods targeting the commonly known port (e.g. 22023).
/// </summary>
public bool ReserveLastDeltaPortAsDefault { get; set; } = true;
public string ResolvePublicIp()
{
return _resolvedPublicIp ??= IpUtils.ResolveIp(PublicIp);
+9 -3
View File
@@ -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> serverConfig,
AuthCacheService authCache)
AuthCacheService authCache,
PortPoolService portPool)
{
_gameManager = gameManager;
_listingManager = listingManager;
_serverConfig = serverConfig.Value;
_authCache = authCache;
_portPool = portPool;
}
/// <summary>
@@ -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()
@@ -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;
+6 -1
View File
@@ -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);
+29 -5
View File
@@ -26,6 +26,14 @@ public sealed class PortPoolService : IDisposable
public bool IsEnabled => _deltaPortStart > 0
&& _deltaPortEnd >= _deltaPortStart;
/// <summary>
/// The main UDP listener port. When delta ports are enabled and
/// <see cref="ServerConfig.ReserveLastDeltaPortAsDefault" /> 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.
/// </summary>
public int DefaultPort { get; }
/// <summary>
/// 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);
}
@@ -17,6 +17,12 @@ public sealed class AuthCacheService : IDisposable
private readonly ConcurrentDictionary<string, UserAuthInfo> _byIpDirect = new();
private readonly ConcurrentDictionary<string, int> _ipToPort = new();
/// <summary>
/// Ports that have an active connection. While a player is connected,
/// the port lease must not be cleared by the inactivity timer.
/// </summary>
private readonly ConcurrentDictionary<int, byte> _confirmedPorts = new();
/// <summary>
/// Invoked when a port lease should be returned to the pool (e.g., on expiry during cleanup).
/// </summary>
@@ -118,8 +124,31 @@ public sealed class AuthCacheService : IDisposable
return _byPort.TryGetValue(port, out var info) && !Expired(info) ? info : null;
}
/// <summary>
/// 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.
/// </summary>
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))
+12 -2
View File
@@ -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));
+3 -2
View File
@@ -6,8 +6,9 @@
"ListenPort": 22023,
"UseUfw": false,
"UseFirewalld": false,
"DeltaPortStart": 0,
"DeltaPortEnd": 0
"DeltaPortStart": 22024,
"DeltaPortEnd": 22099,
"ReserveLastDeltaPortAsDefault": true
},
"HttpServer": {
"Enabled": true,
@@ -20,6 +20,7 @@ public class UdpConnectionListener : NetworkConnectionListener
private static readonly ILogger Logger = Log.ForContext<UdpConnectionListener>();
private readonly ConcurrentDictionary<EndPoint, UdpServerConnection> _allConnections;
private readonly UdpConnectionRateLimit _connectionRateLimit;
private readonly UdpPacketFilter _packetFilter;
protected readonly ObjectPool<MessageReader> _readerPool;
private readonly Timer _reliablePacketTimer;
@@ -34,6 +35,12 @@ public class UdpConnectionListener : NetworkConnectionListener
/// </summary>
public AcceptConnectionCheck AcceptConnection;
/// <summary>
/// The layer-7 packet filter used to drop junk UDP at line speed.
/// Tune its thresholds before <see cref="StartAsync" /> if needed.
/// </summary>
public UdpPacketFilter PacketFilter => _packetFilter;
#nullable enable
/// <summary>
/// Creates a new UdpConnectionListener for the given <see cref="IPAddress" />, port and <see cref="IPMode" />.
@@ -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();
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The filter only applies to <b>new connection attempts</b> (packets from
/// unknown endpoints). Packets belonging to an established connection are
/// never rate-limited here, so in-game traffic is unaffected.
/// </remarks>
public sealed class UdpPacketFilter : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<UdpPacketFilter>();
/// <summary>
/// Minimal valid packet length (send-option byte + 4-byte handshake nonce).
/// </summary>
public int MinPacketLength { get; set; } = 5;
/// <summary>
/// Maximum accepted packet length. Anything larger is dropped instantly.
/// </summary>
public int MaxPacketLength { get; set; } = 1203;
/// <summary>
/// Allowed burst of connection attempts from a single IP before
/// <see cref="BlacklistSeconds" /> kicks in.
/// </summary>
public int BurstLimit { get; set; } = 30;
/// <summary>
/// How long an abusive IP stays blacklisted.
/// </summary>
public int BlacklistSeconds { get; set; } = 60;
private readonly bool[] _validOption = BuildValidOptionTable();
private readonly ConcurrentDictionary<IPAddress, IpState> _states = new();
private bool _isDisposed;
public UdpPacketFilter()
{
}
/// <summary>
/// Fast synchronous check. Returns <c>true</c> when the packet must be
/// dropped, <c>false</c> when it is allowed through.
/// This path performs no allocations.
/// </summary>
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;
}
}