Skip to content

feat(upnp): map ports through double NAT - #6074

Open
EnriqueWood wants to merge 6 commits into
SunshineStream:masterfrom
EnriqueWood:feat/upnp-double-nat
Open

EnriqueWood wants to merge 6 commits into
SunshineStream:masterfrom
EnriqueWood:feat/upnp-double-nat

Conversation

@EnriqueWood

@EnriqueWood EnriqueWood commented Nov 10, 2026 •

Copy link
Copy Markdown

Description

With upnp = enabled, Sunshine now opens its ports behind two routers (double NAT): on the user's router, and on the ISP router in front of it.

This is a very common setup. People put their own router (often a gaming router) behind the ISP's for what the ISP box lacks or locks down: QoS or SQM to keep game traffic ahead of bufferbloat, better Wi-Fi coverage or mesh, newer Wi-Fi (6/7, MU-MIMO), a VPN on the router, parental controls, DHCP reservations, or firmware such as OpenWrt. The ISP router often can't be put in bridge mode, and some ISPs don't allow replacing it at all. Sunshine already speaks UPnP, so completing it for double NAT lets these users stream over the internet without forwarding ports by hand, as long as both routers support UPnP. I also tried Tailscale: it often works, but sometimes the connection stays on DERP relays, and then streaming over the internet isn't stable enough to play.

What happens today. The ISP router usually answers discovery from the inner network too, and UPNP_GetValidIGD() prefers it because it reports a public address. Sunshine then maps the ports there to its own LAN address, which that router can't reach, and logs Completed UPnP port mappings, but nothing is reachable from the internet. The router Sunshine is actually attached to gets no mappings.

What this changes (src/upnp.cpp):

  • After UPNP_GetValidIGD(), Sunshine reads the description of the other IGDs that answered discovery and maps on the one whose address shares the most leading bits with its own address (the gateway of its network). On a tie it keeps the IGD chosen by UPNP_GetValidIGD(), so with a single router nothing changes.
  • If that router's external address is private, Sunshine looks among the other IGDs for the one in that network (at least 16 shared leading bits) and maps the same ports there, forwarded to the inner router's external address. Those mappings are refreshed with the others and removed on exit.
  • If the external address is private and no IGD answers in front of it, Sunshine logs a warning once, saying the ports must be forwarded on the outer router.

docs/configuration.md describes the double NAT behavior under upnp. There is no new option: the second hop is only used when the first router's external address is private and another IGD answers in its network.

Security follow-up

Merge #6098 first: it fixes the published MiniUPnPc CVEs in the pinned Flatpak source and hardens the HTTP parser used by Sunshine. This PR does not change or roll back those dependency fixes; the patched MiniUPnPc covers both single- and double-NAT calls. Other system packages still require their distributor's MiniUPnPc backports.

The mapping lifecycle now applies the same ownership rules to both gateways:

  • Leave mappings to another address or internal port unchanged; reuse manual mappings to the correct destination without renewing or deleting them.
  • Track rules by gateway endpoint, service, destination, external/internal port, protocol and description. Check their current ownership before deletion, retain records after refresh/discovery failures, and retire rules on a gateway or destination change.
  • Request a permanent lease only for UPnP error 725. Append recovery intent before creating that rule; if the journal cannot be written, refuse the permanent mapping. On the next start, clean recorded rules even when UPnP has been disabled, leaving replacements untouched and retaining unreachable rules for another attempt.
  • Do not report a completed mapping pass when a requested port failed.

Screenshot

Issues Fixed or Closed

Roadmap Issues

Testing

Unit tests (tests/unit/test_upnp.cpp) for the new helpers: shared_prefix_length, url_host, select_inner_igd (single IGD, the ISP router chosen first, ties) and select_outer_igd (outer router found, public or invalid external address, unrelated IGDs, closest IGD). Built on Fedora 44 (GCC 15.3, Release, -DBUILD_WERROR=ON, CUDA off); test_sunshine --gtest_filter='Upnp*:Network*': 19 tests passed. uv run --locked lb-update-clang-format applied.

The discovery and mapping calls need a router, so they were checked by hand:

Fake IGDs in an isolated Docker network (a small Python IGD answering SSDP and the WANIPConnection SOAP actions, logging every add/delete). The IGD in front reports a public address, so UPNP_GetValidIGD() picks it first, as with a real ISP router.

Scenario Before (2026.914.233613) After
One router, public external address 6 ports → host Same: 6 ports → host
Double NAT, both routers answer 6 ports on the outer router → host's LAN address; nothing on the inner router Inner router: 6 ports → host. Outer router: 6 ports → inner router's external address
Inner router with a private external address, nothing in front — Inner router: 6 ports → host, plus the warning

In every case the mappings Sunshine added were removed on exit.

Real double NAT (TP-Link Archer C5400X behind the ISP's Askey fiber router, both with UPnP on; Sunshine on Linux, KWin capture, NVENC, ports moved to 37989 to stay clear of other hosts). The client was Moonlight on Windows on another ISP's network, connecting to the public address with no VPN.

  • Before (2026.914.233613): mapped on the ISP router to the host's LAN address, nothing on the Archer; /serverinfo from outside timed out.
  • After: Completed UPnP port mappings to <host> via <Archer> and … to <Archer WAN> via <ISP router>. Checked with upnpc -l on both routers. From outside: /serverinfo answered (HTTP 200); pairing over the public address worked; a 20 s stream (1280×720 H.264) delivered video and audio, with 22–60 ms average network latency in Moonlight's overlay and 2 video frames lost on the way (each recovered with an IDR request, per Moonlight's log). The host's screen was idle and dark, so this checks the connection, not image quality. On exit, both routers' mappings were removed, and /serverinfo from outside timed out again.

Not tested: Windows and macOS hosts, other router models, IPv6 (pinhole code unchanged), and an outer router that changes while Sunshine runs.

Security-follow-up validation (the real-router results above predate this follow-up):

  • Nine added ownership/journal GoogleTest cases pass in an isolated Linux harness using the checked-in test logic with BaseTest replaced by testing::Test.
  • An additional local harness compiled the exact production mapping methods against fake MiniUPnPc calls. Passed: conflicting/manual rules left untouched, lookup failures, no permanent fallback for error 729, stale outer-gateway cleanup, intent present before permanent AddPortMapping, crash recovery, cleanup failure retained for retry, and refusing permanent rules when the journal cannot be written.
  • Merged this branch with fix(nvhttp): bound GameStream requests before routing #6098 locally without conflicts. The combined tree retains its dependency and HTTP fixes unchanged; all 14 HTTP/ownership/journal cases pass together in the isolated harness.
  • Ran uv sync --locked, uv run --locked lb-update-clang-format and git diff --check.
  • Full test_sunshine/application builds and new real-router tests were not run; the Windows environment has no required MSYS2 installation. No malformed traffic was sent to masterrace.

Type of Change

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix (non-breaking change which fixes an issue)
  • docs: Documentation only changes
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • perf: Code change that improves performance
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files
  • revert: Reverts a previous commit
  • BREAKING CHANGE: Introduces a breaking change (can be combined with any type above)

Checklist

  • Code follows the style guidelines of this project
  • Code has been self-reviewed
  • Code has been commented, particularly in hard-to-understand areas
  • Code docstring/documentation-blocks for new or existing methods/components have been added or updated
  • Tests have been added or updated for new or modified functionality, including regression tests for bug fixes

AI Usage

See our AI usage policy.

  • AI tools were used while preparing this PR

Security follow-up prepared with Codex (GPT-6; configured thinking level is not exposed to the agent).

Comment thread tests/unit/test_upnp.cpp Fixed
Comment thread tests/unit/test_upnp.cpp Fixed
Comment thread tests/unit/test_upnp.cpp Fixed
Comment thread tests/unit/test_upnp.cpp Fixed
Comment thread tests/unit/test_upnp.cpp Fixed
Comment thread tests/unit/test_upnp.cpp Fixed
Comment thread tests/unit/test_upnp.cpp Fixed
Comment thread tests/unit/test_upnp.cpp Fixed
Comment thread tests/unit/test_upnp.cpp Fixed
@sonarqubecloud

Copy link
Copy Markdown

Signed-off-by: Enrique Wood <ewoodg@hotmail.com>
Signed-off-by: Enrique Wood <ewoodg@hotmail.com>
Comment thread tests/unit/test_upnp_mapping.cpp Fixed
Comment thread tests/unit/test_upnp_mapping.cpp Fixed
Comment thread tests/unit/test_upnp_mapping.cpp Fixed
Comment thread tests/unit/test_upnp_mapping.cpp Fixed
Comment thread tests/unit/test_upnp_mapping.cpp Fixed
Comment thread tests/unit/test_upnp_mapping.cpp Fixed
Comment thread tests/unit/test_upnp_mapping.cpp Fixed
Comment thread tests/unit/test_upnp_mapping.cpp Fixed
Comment thread tests/unit/test_upnp_mapping.cpp Fixed

@ReenigneArcher ReenigneArcher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One correctness issue in the new mapping-reuse path: disabled manual mappings can be reported as successful without opening the port. Please preserve those rules but report the mapping as unavailable, and cover the disabled case in regression tests.

Reviewed head a13d24b4ca452944e4301d3bcca7d2a0b73e6e1e; GitHub currently reports no merge conflict. This is source-level verification, not a real-router reproduction or a full application/test build. CI, CodeQL and common lint for this head await maintainer approval; those missing results are a verification limit, not the reason for requesting changes. The PR's real-router results predate the ownership follow-up.

Review completed by Codex | Model: unavailable | Thinking: unavailable

Comment thread src/upnp.cpp Outdated
Comment on lines +390 to +391
// A manual rule already does what we need. Never renew or remove it.
return true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Do not count a disabled manual mapping as successful

UPNP_GetSpecificPortMappingEntry fills enabled, but this new early return ignores it. If a router returns a matching destination/internal port, a non-Sunshine description, enabled = "0", and a nonzero lease, this branch returns true without enabling the mapping. The mapping pass can therefore log completion while the port remains closed. This extends the problem beyond permanent rules: the target branch previously reached UPNP_AddPortMapping for nonzero leases instead of returning early.

Preserving a manual rule is appropriate, but a disabled one should be reported as unavailable rather than reused successfully. A minimal conservative correction for this branch is:

Suggested change
// A manual rule already does what we need. Never renew or remove it.
return true;
// Never renew or remove a manual rule, but do not count a disabled rule as usable.
if (std::string_view {enabled.data()} == "0"sv) {
BOOST_LOG(warning) << "UPnP port "sv << mapping.port.wan << " has a disabled manual mapping; leaving it unchanged"sv;
return false;
}
return true;

This suggestion has not been compiled or hardware-tested. Please add a production-path regression with a successful lookup returning the matching target/port, a manual description, enabled = "0", and lease "3600": expect false, no add/delete calls, and an incomplete mapping pass. Keep an enabled manual-rule control case returning true. Disabled Sunshine-owned permanent rules also deserve coverage, although that early-success behavior predates this PR.

Review completed by Codex | Model: unavailable | Thinking: unavailable

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, good catch. Fixed in 3a682f0.

A rule from someone else that forwards to the right place but is disabled is now left alone and reported as not open (UPnP port N has a disabled manual mapping; leaving it unchanged), so the pass stays incomplete. An enabled one is still used without renewing it. enabled counts as disabled for 0, false or no in any case, and an empty value still counts as enabled. A disabled Sunshine rule without expiry isn't taken as open either: it goes through the add path again, so it gets enabled again (with the 725 fallback if the router needs it).

To test the real path without a router, I moved the per-port logic out of deinit_t into upnp::map_port/upnp::map_ports in upnp_mapping.h. They take the gateway calls through a small mapping_gateway concept: Sunshine passes a MiniUPnPc wrapper and the tests pass a fake that records every call. New tests:

  • disabled manual rule (manual description, enabled = "0", lease "3600"): false, no add or delete calls, nothing recorded as owned
  • enabled manual rule: true, no add or delete calls
  • disabled manual rule next to a missing one: pass incomplete, the other port is still added
  • disabled Sunshine rule without expiry: added again (3600, then 0 after 725), still recorded as permanent
  • enabled Sunshine rule without expiry: kept, no add calls
  • 0/false/False/NO count as disabled, 1/true/YES/empty don't

The same commit passes the IPs and URLs in test_upnp_mapping.cpp as helper arguments, the way test_upnp.cpp does, for the Sonar findings on that file.

Since my real-router results were from before the ownership changes, I ran the branch again on my double NAT (TP-Link Archer behind the ISP router) and found one more problem, fixed in 3cf711d. The ISP router answers GetSpecificPortMappingEntry with NewInternalPort = 0 for every rule. With a13d24b the first pass mapped both routers, but from the first refresh on all 6 rules on the ISP router were logged as already forwards elsewhere, and on exit they were left behind. Now a port reported as 0 or empty isn't taken as another destination. With 3cf711d on the same network:

  • both routers got their 6 rules, and /serverinfo answered through the public address from another ISP
  • on refresh there were no conflicts; the ISP router answers 718 when an existing rule is added again, same as before this PR
  • an enabled rule I added by hand on each router, with another description, was used and left untouched, also after exit
  • on exit, all of Sunshine's rules were removed from both routers
  • I couldn't try a disabled rule on real hardware: both routers store NewEnabled=0 as enabled, so that case is only covered by the tests

Signed-off-by: Enrique Wood <ewoodg@hotmail.com>
Signed-off-by: Enrique Wood <ewoodg@hotmail.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
B Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

* @return Rule on the inner gateway.
*/
upnp::owned_mapping_t sample_mapping() {
return make_rule("http://192.168.1.1/control", "192.168.1.20");
* @return Rule on the inner gateway.
*/
upnp::owned_mapping_t sample_mapping() {
return make_rule("http://192.168.1.1/control", "192.168.1.20");
* @return Rule on the inner gateway.
*/
upnp::owned_mapping_t sample_mapping() {
return make_rule("http://192.168.1.1/control", "192.168.1.20");

TEST(UpnpMappingJournalTest, KeepsBothGatewaysAndOldDestinationsUntilRemoved) {
const auto inner = sample_mapping();
const auto outer = make_rule("http://10.0.0.1/control", "10.0.0.42");

TEST(UpnpMappingJournalTest, KeepsBothGatewaysAndOldDestinationsUntilRemoved) {
const auto inner = sample_mapping();
const auto outer = make_rule("http://10.0.0.1/control", "10.0.0.42");
}

TEST(UpnpMappingJournalTest, PreservesQuotedDescriptionsAndEndpointCharacters) {
auto mapping = make_rule("http://192.168.1.1/control?value=\\quoted", "192.168.1.20");
}

TEST(UpnpMappingJournalTest, PreservesQuotedDescriptionsAndEndpointCharacters) {
auto mapping = make_rule("http://192.168.1.1/control?value=\\quoted", "192.168.1.20");

TEST(UpnpMapPortTest, DisabledManualRuleDoesNotStopTheOtherPorts) {
const auto manual = sample_mapping();
const auto other = make_rule("http://192.168.1.1/control", "192.168.1.20", "47984");

TEST(UpnpMapPortTest, DisabledManualRuleDoesNotStopTheOtherPorts) {
const auto manual = sample_mapping();
const auto other = make_rule("http://192.168.1.1/control", "192.168.1.20", "47984");

TEST(UpnpMapPortTest, DisabledManualRuleDoesNotStopTheOtherPorts) {
const auto manual = sample_mapping();
const auto other = make_rule("http://192.168.1.1/control", "192.168.1.20", "47984");

@ReenigneArcher ReenigneArcher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The disabled-manual-rule fix is present and the extracted production-path checks now reject disabled manual rules without changing them. The new internal-port workaround introduces a separate ownership problem: an unreported port cannot establish that a rule forwards to the requested port. Please keep that uncertainty distinct from a verified match.

Reviewed 3cf711da; GitHub reports it mergeable. Focused C++ checks against the actual header passed, including ASan/UBSan with leak detection disabled because the runner cannot support it. These reproduce the unknown-port false-reuse/classification behavior, not a real-router failure. I have not run the full application or native test suite. CI, common lint and CodeQL await maintainer approval.

Review completed by Codex | Model: unavailable | Thinking: unavailable

Comment thread src/upnp_mapping.h
Comment on lines +74 to +79
// Some gateways report the internal port of an existing rule as 0 or leave it empty; that is not another destination.
const bool port_reported = !port.empty() && port != "0";
if (client != wanted.target || (port_reported && port != wanted.internal_port)) {
return mapping_state_e::conflict;
}
return description == wanted.description ? mapping_state_e::ours : mapping_state_e::existing;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Keep an unreported internal port distinct from a verified match

I understand the ISP router reports 0 for every internal port, but this wildcard also accepts rules Sunshine did not create. For example, an enabled manual rule 47989 -> same host:48000 on that router is now classified existing, so map_port returns reused and reports the requested 47989 -> host:47989 forwarding complete without changing anything. Matching the static Sunshine description instead yields ours; if a recorded rule was edited to another internal port while retaining its description, cleanup can delete that replacement.

I reproduced both classifications with the actual header. Missing information is not proof of a conflict, but it is not proof of a match either. A conservative guard is:

Suggested change
// Some gateways report the internal port of an existing rule as 0 or leave it empty; that is not another destination.
const bool port_reported = !port.empty() && port != "0";
if (client != wanted.target || (port_reported && port != wanted.internal_port)) {
return mapping_state_e::conflict;
}
return description == wanted.description ? mapping_state_e::ours : mapping_state_e::existing;
if (client != wanted.target) {
return mapping_state_e::conflict;
}
if (port.empty() || port == "0") {
return mapping_state_e::unknown;
}
if (port != wanted.internal_port) {
return mapping_state_e::conflict;
}
return description == wanted.description ? mapping_state_e::ours : mapping_state_e::existing;

This suggestion has not been router-tested and deliberately leaves ambiguous rules untouched. If support for this router needs a fallback, separate a verified rule/provenance path from this general classifier rather than using an unknown port as a wildcard for manual reuse and deletion. Add regressions for unknown-port manual rules (incomplete pass, no mutation) and recorded rules whose internal destination has changed but whose lookup hides that port (no deletion).

Review completed by Codex | Model: unavailable | Thinking: unavailable

This branch has not been deployed

No deployments
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