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..1be57ffa 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,35 @@ 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). + // 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(); + + // 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; @@ -85,6 +117,64 @@ 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; + + // 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" + && 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; + } + // 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. + // 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 _)) + { + logger.LogInformation( + "Trusted server {ServerID} (client {ClientID}) address override {From} -> {To} (reason: egress->ingress map)", + server.ID, server.OwningClientID, server.ServerAddress, trimmedMapped); + server.ServerAddress = trimmedMapped; + } + } + 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 =>