Skip to content

feat(matchmaking): let trusted servers override advertised address for NAT/tunnels - #249

Open
itpick wants to merge 4 commits into
timiimit:masterfrom
ut4-hub:feat/trusted-server-address-override
Open

feat(matchmaking): let trusted servers override advertised address for NAT/tunnels#249
itpick wants to merge 4 commits into
timiimit:masterfrom
ut4-hub:feat/trusted-server-address-override

Conversation

@itpick

@itpick itpick commented Jul 24, 2026

Copy link
Copy Markdown

Problem

UT4 dedicated servers register with the master via POST /ut/api/matchmaking/session (MatchmakingController.CreateGameServer). The handler unconditionally overwrites the server's declared serverAddress with the source IP of the registration request:

server.ServerAddress = ipClient.ToString();

That is correct for a server on a routable public IP. But for a server behind NAT or a tunnel — playit.gg, Cloudflare Spectrum, or a home server behind CGNAT — the source IP the master sees is the server's egress address, which is not the ingress address players must connect to. The result is that the server browser lists an address nobody can reach.

Change

For trusted servers only (trust != Untrusted, using the existing TrustedGameServerService), an operator can opt in to advertising the real public address. Both mechanisms are config-gated and default OFF, so upstream behavior is unchanged unless explicitly enabled. Untrusted-server behavior is left exactly as-is.

A new top-level Trusted config section (bound via IOptions<TrustedGameServerSettings>, matching the existing settings pattern) provides two opt-ins:

  1. Trusted:AllowDeclaredAddress (bool) — when true, honor the serverAddress the server declared in its request body, provided it is a valid, non-empty, non-"0.0.0.0" IP. The declared value is captured before the source-IP overwrite.
  2. Trusted:AllowDeclaredAddressX-Forwarded-For fallback — the UE5.8 UT client shim (OnlineSessionUT::RegisterServer) advertises its ServerAddressOverride via an X-Forwarded-For request header rather than in the body, so the body path above misses those servers entirely. Under the same AllowDeclaredAddress gate, when the trusted server declares no valid body address, fall back to the left-most X-Forwarded-For entry (validated the same way: non-empty, non-"0.0.0.0", parseable IP). Captured before the source-IP overwrite, same as the body path. Logged with reason xff-fallback.
  3. Trusted:AddressOverrides (dictionary sourceIP -> publicIP) — otherwise, if the request's source IP is a key in this map, advertise the mapped public IP instead.

Precedence: explicit body declaredAddressX-Forwarded-For fallback → AddressOverrides map → the default source-IP behavior.

When an override is applied, it is logged at Info level (server id, from → to, and reason).

Example config (environment variables)

Trusted__AllowDeclaredAddress=true
Trusted__AddressOverrides__23.123.225.250=69.9.179.19

Notes

  • No change to the model or wire format; only the value assigned to ServerAddress for trusted servers can differ.
  • Built with dotnet publish -c Release (the repo Dockerfile) — compiles clean.

🤖 Generated with Claude Code

…r 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds configuration-gated support for trusted UT4 dedicated servers to advertise a different serverAddress than the request source IP, enabling NAT/tunnel deployments to show a reachable ingress address while keeping untrusted behavior unchanged.

Changes:

  • Registers a new Trusted configuration section bound to TrustedGameServerSettings.
  • Captures the server-declared address before overwriting with source IP, and applies trusted-only overrides (declared address or source→public mapping).
  • Introduces TrustedGameServerSettings to model the opt-in override mechanisms.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
UT4MasterServer/Program.cs Binds new top-level Trusted settings section into DI options.
UT4MasterServer/Controllers/UT/MatchmakingController.cs Applies trusted-only server address override logic during session registration.
UT4MasterServer.Models/Settings/TrustedGameServerSettings.cs Defines settings for allowing declared addresses and source-IP→public-IP overrides.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread UT4MasterServer/Controllers/UT/MatchmakingController.cs Outdated
Comment thread UT4MasterServer/Controllers/UT/MatchmakingController.cs Outdated
- 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
@itpick

itpick commented Jul 26, 2026

Copy link
Copy Markdown
Author

@timiimit - please merge and update master server with this so that I can connect my hub. Thanks!

@itpick

itpick commented Jul 26, 2026

Copy link
Copy Markdown
Author

@Saibamen - would be awesome if you can see this one getting merged in! Thanks :)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread UT4MasterServer/Controllers/UT/MatchmakingController.cs
@itpick

itpick commented Jul 28, 2026

Copy link
Copy Markdown
Author

@timiimit - you alive?

… 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

UT4MasterServer/Controllers/UT/MatchmakingController.cs:134

  • The config-driven override validates against "0.0.0.0" but would still allow the IPv6 unspecified address ("::") (or any value that parses to IPAddress.Any/IPAddress.IPv6Any). It’s safer to reject unspecified addresses based on the parsed IP rather than string matching.
				&& addressOverrides.TryGetValue(overrideKey, out var mappedAddress)
				&& mappedAddress?.Trim() is { Length: > 0 } trimmedMapped
				&& trimmedMapped != "0.0.0.0"
				&& IPAddress.TryParse(trimmedMapped, out _))
			{

UT4MasterServer/Controllers/UT/MatchmakingController.cs:118

  • The declared-address override blocks only the IPv4 unspecified address ("0.0.0.0") but would still accept the IPv6 unspecified address ("::") (and any other value that parses to IPAddress.Any/IPAddress.IPv6Any). That would cause the master to advertise an explicitly non-routable address even when overrides are enabled.

This issue also appears on line 130 of the same file.

			if (overrides.AllowDeclaredAddress
				&& !string.IsNullOrWhiteSpace(declaredAddress)
				&& declaredAddress != "0.0.0.0"
				&& IPAddress.TryParse(declaredAddress, out _))
			{

@itpick

itpick commented Aug 1, 2026

Copy link
Copy Markdown
Author

@Saibamen @timiimit - I can send you guys a sandwich if you get this in!

…ress 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (3)

UT4MasterServer/Controllers/UT/MatchmakingController.cs:169

  • The config-driven AddressOverrides validation only rejects the IPv4 unspecified address string ("0.0.0.0"). A misconfiguration like "::" (IPv6Any) or a loopback value would still parse and be advertised. Validate the parsed IP and reject IPAddress.Any/IPAddress.IPv6Any and loopback to avoid advertising clearly invalid addresses.
			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 _))

UT4MasterServer/Controllers/UT/MatchmakingController.cs:154

  • The X-Forwarded-For fallback uses the same string check for "0.0.0.0" as the body-declared path, so it would still allow an unspecified IPv6 address ("::") or loopback address to be advertised if provided in the header. Validating the parsed IP and rejecting IPAddress.Any/IPAddress.IPv6Any and loopback would prevent advertising obviously-unreachable addresses.
			else if (overrides.AllowDeclaredAddress
				&& !string.IsNullOrWhiteSpace(forwardedForAddress)
				&& forwardedForAddress != "0.0.0.0"
				&& IPAddress.TryParse(forwardedForAddress, out _))
			{

UT4MasterServer/Controllers/UT/MatchmakingController.cs:140

  • The declared-address validation only rejects the IPv4 unspecified address string ("0.0.0.0"). If a trusted server declares an unspecified/placeholder IPv6 address ("::") or a loopback address, it will still pass IPAddress.TryParse and be advertised to clients, producing an unreachable server entry. Consider validating the parsed IP instead of comparing strings, and explicitly rejecting IPAddress.Any/IPAddress.IPv6Any and loopback.

This issue also appears in the following locations of the same file:

  • line 150
  • line 165
			if (overrides.AllowDeclaredAddress
				&& !string.IsNullOrWhiteSpace(declaredAddress)
				&& declaredAddress != "0.0.0.0"
				&& IPAddress.TryParse(declaredAddress, out _))
			{

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants