From d75d4f8de0c135a7867467dd45b64d7a277409e8 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Fri, 24 Jul 2026 10:59:34 -0500 Subject: [PATCH 1/4] feat(matchmaking): let trusted servers override advertised address for NAT/tunnels Game servers register via POST /ut/api/matchmaking/session, and CreateGameServer unconditionally overwrites the declared serverAddress with the request's source IP. For a server behind NAT or a tunnel (playit.gg, Cloudflare Spectrum, home servers behind CGNAT), that source IP is the egress address, not the ingress address players must connect to, so the browser lists an unreachable server. For TRUSTED servers only, allow an operator to opt in (both mechanisms default OFF, so upstream behavior is unchanged) to advertising the real public address via a new top-level "Trusted" config section: - Trusted:AllowDeclaredAddress (bool) -- honor the serverAddress the server declared in its request body, when it is a valid non-"0.0.0.0" IP. - Trusted:AddressOverrides (dict sourceIP -> publicIP) -- map the request source IP to a configured public IP. The declared value is captured before the source-IP overwrite. An override is logged at Info level (server id, from -> to, reason). Untrusted-server behavior is intentionally left unchanged. --- .../Settings/TrustedGameServerSettings.cs | 36 +++++++++++++++++ .../Controllers/UT/MatchmakingController.cs | 39 +++++++++++++++++++ UT4MasterServer/Program.cs | 1 + 3 files changed, 76 insertions(+) create mode 100644 UT4MasterServer.Models/Settings/TrustedGameServerSettings.cs diff --git a/UT4MasterServer.Models/Settings/TrustedGameServerSettings.cs b/UT4MasterServer.Models/Settings/TrustedGameServerSettings.cs new file mode 100644 index 00000000..ede554f1 --- /dev/null +++ b/UT4MasterServer.Models/Settings/TrustedGameServerSettings.cs @@ -0,0 +1,36 @@ +namespace UT4MasterServer.Models.Settings; + +/// +/// Optional overrides that let a trusted game server advertise a public +/// address different from the source IP of its registration request. +/// +/// +/// This exists for game servers behind NAT / tunnels (playit.gg, +/// Cloudflare Spectrum, home servers behind CGNAT): they register from an +/// egress IP that is not the address players must connect to, so the browser +/// would otherwise list an unreachable address. Both mechanisms below are +/// opt-in and default OFF, so upstream behavior is unchanged unless an +/// operator explicitly enables them for a trusted server. +/// +/// Bound from the top-level Trusted configuration section +/// (e.g. env vars Trusted__AllowDeclaredAddress and +/// Trusted__AddressOverrides__<sourceIP>). +/// +public sealed class TrustedGameServerSettings +{ + /// + /// When true, a trusted server may declare its own serverAddress in + /// the registration request body and it will be honored instead of the + /// request source IP, provided the declared value is a valid, non-empty, + /// non-"0.0.0.0" IP address. + /// + public bool AllowDeclaredAddress { get; set; } = false; + + /// + /// Static map of request source IP (egress) to public address (ingress). + /// When a trusted server registers from a source IP present as a key here, + /// the mapped value is advertised instead. Consulted only when + /// did not already override the address. + /// + public Dictionary AddressOverrides { get; set; } = new(); +} diff --git a/UT4MasterServer/Controllers/UT/MatchmakingController.cs b/UT4MasterServer/Controllers/UT/MatchmakingController.cs index be524a2a..cf321646 100644 --- a/UT4MasterServer/Controllers/UT/MatchmakingController.cs +++ b/UT4MasterServer/Controllers/UT/MatchmakingController.cs @@ -31,15 +31,18 @@ public sealed class MatchmakingController : JsonAPIController private readonly TrustedGameServerService trustedGameServerService; private readonly IOptions configuration; + private readonly IOptions trustedServerSettings; public MatchmakingController( ILogger logger, IOptions configuration, + IOptions trustedServerSettings, MatchmakingService matchmakingService, ClientService clientService, TrustedGameServerService trustedGameServerService) : base(logger) { this.configuration = configuration; + this.trustedServerSettings = trustedServerSettings; this.matchmakingService = matchmakingService; this.clientService = clientService; this.trustedGameServerService = trustedGameServerService; @@ -74,6 +77,11 @@ public async Task CreateGameServer([FromBody] GameServer server) server.ID = EpicID.GenerateNew(); server.LastUpdated = DateTime.UtcNow; + // Capture any address the server declared in its request body BEFORE we + // overwrite it, so a trusted server behind NAT/tunnels can opt in to + // advertising it (see the override block below). + var declaredAddress = server.ServerAddress; + server.ServerAddress = ipClient.ToString(); server.Started = false; @@ -85,6 +93,37 @@ public async Task CreateGameServer([FromBody] GameServer server) } server.Attributes.Set(GameServerAttributes.UT_SERVERTRUSTLEVEL_i, (int)trust); + // By default the advertised address is the request's source IP (upstream + // behavior, set above). Game servers behind NAT/tunnels (playit.gg, + // Cloudflare Spectrum, home servers behind CGNAT) register from an egress + // IP that differs from the ingress address players must connect to, so + // that default lists an unreachable server. For TRUSTED servers only, an + // operator can opt in (both mechanisms default OFF) to advertise the real + // public address. Untrusted-server behavior is intentionally unchanged. + if (trust != GameServerTrust.Untrusted) + { + var overrides = trustedServerSettings.Value; + + if (overrides.AllowDeclaredAddress + && !string.IsNullOrWhiteSpace(declaredAddress) + && declaredAddress != "0.0.0.0" + && IPAddress.TryParse(declaredAddress, out _)) + { + logger.LogInformation( + "Trusted server {ServerID} (client {ClientID}) address override {From} -> {To} (reason: declared address)", + server.ID, server.OwningClientID, server.ServerAddress, declaredAddress); + server.ServerAddress = declaredAddress; + } + else if (overrides.AddressOverrides.TryGetValue(ipClient.ToString(), out var mappedAddress) + && !string.IsNullOrWhiteSpace(mappedAddress)) + { + logger.LogInformation( + "Trusted server {ServerID} (client {ClientID}) address override {From} -> {To} (reason: egress->ingress map)", + server.ID, server.OwningClientID, server.ServerAddress, mappedAddress); + server.ServerAddress = mappedAddress; + } + } + if (trust != GameServerTrust.Untrusted) { var isGameInstance = (int?)server.Attributes.Get(GameServerAttributes.UT_GAMEINSTANCE_i) == 1; diff --git a/UT4MasterServer/Program.cs b/UT4MasterServer/Program.cs index 4f8b9e69..b1f7b6a4 100644 --- a/UT4MasterServer/Program.cs +++ b/UT4MasterServer/Program.cs @@ -64,6 +64,7 @@ public static void Main(string[] args) builder.Services .Configure(builder.Configuration.GetSection("ApplicationSettings")) .Configure(builder.Configuration.GetSection("StatisticsSettings")) + .Configure(builder.Configuration.GetSection("Trusted")) .Configure(builder.Configuration.GetSection("ReCaptchaSettings")); builder.Services.Configure(x => From cf6bee8853b0d91d0e740da9d5afb462602287d4 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Sat, 25 Jul 2026 16:17:03 -0500 Subject: [PATCH 2/4] fix(matchmaking): trim + validate override addresses (Copilot review) - trim declared/mapped addresses so a stray-whitespace value (e.g. " 0.0.0.0") cannot slip past the "0.0.0.0" guard while IPAddress.TryParse still accepts it - validate the config-driven AddressOverrides value the same way as the declared address (non-empty, != 0.0.0.0, parseable IP) so a misconfigured entry cannot make the master advertise an invalid/unreachable address --- .../Controllers/UT/MatchmakingController.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/UT4MasterServer/Controllers/UT/MatchmakingController.cs b/UT4MasterServer/Controllers/UT/MatchmakingController.cs index cf321646..fbfaca5a 100644 --- a/UT4MasterServer/Controllers/UT/MatchmakingController.cs +++ b/UT4MasterServer/Controllers/UT/MatchmakingController.cs @@ -80,7 +80,9 @@ public async Task CreateGameServer([FromBody] GameServer server) // Capture any address the server declared in its request body BEFORE we // overwrite it, so a trusted server behind NAT/tunnels can opt in to // advertising it (see the override block below). - var declaredAddress = server.ServerAddress; + // Trim so a stray-whitespace value (e.g. " 0.0.0.0") cannot slip past the + // "0.0.0.0" guard below while IPAddress.TryParse still accepts it. + var declaredAddress = server.ServerAddress?.Trim(); server.ServerAddress = ipClient.ToString(); server.Started = false; @@ -114,13 +116,18 @@ public async Task CreateGameServer([FromBody] GameServer server) server.ID, server.OwningClientID, server.ServerAddress, declaredAddress); server.ServerAddress = declaredAddress; } + // Validate the config-driven override the same way as the declared one: + // trim, reject empty/"0.0.0.0", and require a parseable IP so a misconfigured + // entry can't make the master advertise an invalid/unreachable address. else if (overrides.AddressOverrides.TryGetValue(ipClient.ToString(), out var mappedAddress) - && !string.IsNullOrWhiteSpace(mappedAddress)) + && mappedAddress?.Trim() is { Length: > 0 } trimmedMapped + && trimmedMapped != "0.0.0.0" + && IPAddress.TryParse(trimmedMapped, out _)) { logger.LogInformation( "Trusted server {ServerID} (client {ClientID}) address override {From} -> {To} (reason: egress->ingress map)", - server.ID, server.OwningClientID, server.ServerAddress, mappedAddress); - server.ServerAddress = mappedAddress; + server.ID, server.OwningClientID, server.ServerAddress, trimmedMapped); + server.ServerAddress = trimmedMapped; } } From e9290003fd2f1015ad21f38a4ad27abced71fe39 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:09:17 -0500 Subject: [PATCH 3/4] fix(matchmaking): null-guard AddressOverrides + normalize IPv4-mapped IPv6 key (Copilot review) TryGetValue could throw if AddressOverrides is bound null (JSON "AddressOverrides": null); and an IPv4-mapped IPv6 source (::ffff:1.2.3.4) never matched plain-IPv4 config keys. Guard the dictionary and look it up by a normalized IPv4 key. --- .../Controllers/UT/MatchmakingController.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/UT4MasterServer/Controllers/UT/MatchmakingController.cs b/UT4MasterServer/Controllers/UT/MatchmakingController.cs index fbfaca5a..ada18b50 100644 --- a/UT4MasterServer/Controllers/UT/MatchmakingController.cs +++ b/UT4MasterServer/Controllers/UT/MatchmakingController.cs @@ -106,6 +106,11 @@ public async Task CreateGameServer([FromBody] GameServer server) { var overrides = trustedServerSettings.Value; + // The override map is keyed by the client's egress IP. Normalize an + // IPv4-mapped IPv6 source (e.g. "::ffff:1.2.3.4") back to plain IPv4 so it + // matches config keys written as IPv4. + var overrideKey = ipClient.IsIPv4MappedToIPv6 ? ipClient.MapToIPv4().ToString() : ipClient.ToString(); + if (overrides.AllowDeclaredAddress && !string.IsNullOrWhiteSpace(declaredAddress) && declaredAddress != "0.0.0.0" @@ -119,7 +124,10 @@ public async Task CreateGameServer([FromBody] GameServer server) // Validate the config-driven override the same way as the declared one: // trim, reject empty/"0.0.0.0", and require a parseable IP so a misconfigured // entry can't make the master advertise an invalid/unreachable address. - else if (overrides.AddressOverrides.TryGetValue(ipClient.ToString(), out var mappedAddress) + // Null-guard AddressOverrides (e.g. JSON "AddressOverrides": null) so + // TryGetValue can't throw, and look it up by the normalized IPv4 key. + else if (overrides.AddressOverrides is { } addressOverrides + && addressOverrides.TryGetValue(overrideKey, out var mappedAddress) && mappedAddress?.Trim() is { Length: > 0 } trimmedMapped && trimmedMapped != "0.0.0.0" && IPAddress.TryParse(trimmedMapped, out _)) From 7cade5e670619abbc8751edab6117cefda5ce6f9 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 5 Aug 2026 21:30:49 -0500 Subject: [PATCH 4/4] feat(matchmaking): add X-Forwarded-For fallback to trusted-server address override The UE5.8 UT client shim advertises its ServerAddressOverride via an X-Forwarded-For header rather than the request body, so the AllowDeclaredAddress body path missed those servers entirely. When AllowDeclaredAddress is enabled and the trusted server declares no valid body address, fall back to the left-most X-Forwarded-For entry (validated: non-empty, not 0.0.0.0, parseable IP). Precedence: body declaredAddress, then XFF fallback, then AddressOverrides map, then the default source-IP behavior. Remains default-OFF and trusted-only. --- .../Controllers/UT/MatchmakingController.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/UT4MasterServer/Controllers/UT/MatchmakingController.cs b/UT4MasterServer/Controllers/UT/MatchmakingController.cs index ada18b50..1be57ffa 100644 --- a/UT4MasterServer/Controllers/UT/MatchmakingController.cs +++ b/UT4MasterServer/Controllers/UT/MatchmakingController.cs @@ -84,6 +84,28 @@ public async Task CreateGameServer([FromBody] GameServer server) // "0.0.0.0" guard below while IPAddress.TryParse still accepts it. var declaredAddress = server.ServerAddress?.Trim(); + // The UE5.8 UT client shim (OnlineSessionUT RegisterServer) advertises its + // ServerAddressOverride via an X-Forwarded-For request header rather than in + // the body, so also capture the left-most XFF entry BEFORE we overwrite + // ServerAddress. This is only consulted as a fallback for TRUSTED servers when + // the body declares no valid address (see the override block below). + string? forwardedForAddress = null; + foreach (var xffHeader in HttpContext.Request.Headers["X-Forwarded-For"]) + { + if (string.IsNullOrWhiteSpace(xffHeader)) + { + continue; + } + + // Left-most entry is the originally forwarded (client-declared) address. + var xffParts = xffHeader.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (xffParts.Length > 0) + { + forwardedForAddress = xffParts[0]; + break; + } + } + server.ServerAddress = ipClient.ToString(); server.Started = false; @@ -121,6 +143,20 @@ public async Task CreateGameServer([FromBody] GameServer server) server.ID, server.OwningClientID, server.ServerAddress, declaredAddress); server.ServerAddress = declaredAddress; } + // The UE5.8 client shim declares its address via an X-Forwarded-For header + // instead of the request body, so fall back to the left-most XFF entry when + // no valid declared body address was provided. Same AllowDeclaredAddress gate + // and validation (non-empty, not "0.0.0.0", parseable IP) as the body path. + else if (overrides.AllowDeclaredAddress + && !string.IsNullOrWhiteSpace(forwardedForAddress) + && forwardedForAddress != "0.0.0.0" + && IPAddress.TryParse(forwardedForAddress, out _)) + { + logger.LogInformation( + "Trusted server {ServerID} (client {ClientID}) address override {From} -> {To} (reason: xff-fallback)", + server.ID, server.OwningClientID, server.ServerAddress, forwardedForAddress); + server.ServerAddress = forwardedForAddress; + } // Validate the config-driven override the same way as the declared one: // trim, reject empty/"0.0.0.0", and require a parseable IP so a misconfigured // entry can't make the master advertise an invalid/unreachable address.