diff --git a/docs/configuration.md b/docs/configuration.md
index a6e5727c536..96af7805dd8 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1553,6 +1553,13 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin
Description |
Sunshine will attempt to open ports for streaming over the internet.
+ If Sunshine's router is behind another router (double NAT) and both support UPnP,
+ the ports are also opened on the outer router, forwarded to the inner router.
+ Existing rules for another destination are left unchanged, and only rules owned by Sunshine are removed.
+ A disabled rule created by someone else is also left unchanged, and its port is reported as not opened.
+ Rules without an expiry are used only when the router explicitly requires them; they are recorded in
+ upnp_mappings.journal in Sunshine's application data directory before creation.
+ At the next start Sunshine attempts to remove outstanding recorded rules, even if UPnP has since been disabled.
|
diff --git a/src/upnp.cpp b/src/upnp.cpp
index ea2bab0cdf6..a00186d5a31 100644
--- a/src/upnp.cpp
+++ b/src/upnp.cpp
@@ -4,6 +4,11 @@
*/
// standard includes
#include
+#include
+#include
+#include
+#include
+#include
#include // workaround for type_t error in miniupnpc 2.3.3, see https://github.com/miniupnp/miniupnp/commit/e263ab6f56c382e10fed31347ec68095d691a0e8
// lib includes
@@ -20,6 +25,7 @@
#include "rtsp.h"
#include "stream.h"
#include "upnp.h"
+#include "upnp_mapping.h"
#include "utility.h"
using namespace std::literals;
@@ -39,6 +45,52 @@ namespace upnp {
std::string description; ///< Human-readable UPnP lease description advertised to the gateway.
};
+ /**
+ * @brief A discovered IGD and the data needed to manage its port mappings.
+ */
+ struct igd_t {
+ std::string host; ///< Address of the IGD, taken from its description URL.
+ urls_t urls; ///< Control URLs of the IGD.
+ IGDdatas data {}; ///< Service data of the IGD.
+ };
+
+ /**
+ * @brief Describe the discovered IGDs other than the one already in the list.
+ * @param device Device list from upnpDiscover().
+ * @param igds List holding the IGD chosen by UPNP_GetValidIGD(); the other IGDs are appended to it.
+ */
+ static void add_other_igds(const device_t &device, std::vector &igds) {
+ std::set> seen {igds.front().urls->rootdescURL};
+ for (auto dev = device.get(); dev != nullptr; dev = dev->pNext) {
+ if (!seen.emplace(dev->descURL).second) {
+ continue;
+ }
+
+ igd_t igd;
+ std::array lan_addr {};
+ if (UPNP_GetIGDFromUrl(dev->descURL, &igd.urls.el, &igd.data, lan_addr.data(), (int) lan_addr.size()) != 1 || igd.urls->controlURL == nullptr || igd.urls->controlURL[0] == 0 || igd.data.first.servicetype[0] == 0) {
+ continue;
+ }
+
+ igd.host = url_host(dev->descURL);
+ igds.emplace_back(std::move(igd));
+ }
+ }
+
+ /**
+ * @brief Get the external address an IGD reports.
+ * @param igd The IGD to ask.
+ * @return The external address, or an empty string if the IGD doesn't report one.
+ */
+ static std::string external_address(const igd_t &igd) {
+ std::array addr {};
+ if (UPNP_GetExternalIPAddress(igd.urls->controlURL, igd.data.first.servicetype, addr.data()) != UPNPCOMMAND_SUCCESS) {
+ return {};
+ }
+
+ return addr.data();
+ }
+
static std::string_view status_string(igd_status_e status) {
using enum igd_status_e;
@@ -88,6 +140,148 @@ namespace upnp {
}
}
+ /**
+ * @brief Check whether a value is a private (LAN) IPv4 address.
+ * @param address The value to check.
+ * @return `true` if `address` is an IPv4 address in a private, shared or link-local range.
+ */
+ static bool is_private_ipv4(std::string_view address) {
+ boost::system::error_code ec;
+ const auto addr = boost::asio::ip::make_address_v4(std::string {address}, ec);
+ return !ec && net::from_address(addr.to_string()) == net::LAN;
+ }
+
+ int shared_prefix_length(std::string_view a, std::string_view b) {
+ boost::system::error_code ec_a;
+ boost::system::error_code ec_b;
+ const auto addr_a = boost::asio::ip::make_address_v4(std::string {a}, ec_a);
+ const auto addr_b = boost::asio::ip::make_address_v4(std::string {b}, ec_b);
+ if (ec_a || ec_b) {
+ return -1;
+ }
+
+ return std::countl_zero(addr_a.to_uint() ^ addr_b.to_uint());
+ }
+
+ std::string url_host(std::string_view url) {
+ if (const auto scheme = url.find("://"sv); scheme != std::string_view::npos) {
+ url.remove_prefix(scheme + 3);
+ }
+
+ if (url.starts_with('[')) {
+ return std::string {url.substr(1, url.find(']') - 1)};
+ }
+
+ return std::string {url.substr(0, url.find_first_of(":/"sv))};
+ }
+
+ std::size_t select_inner_igd(const std::vector &igd_hosts, std::string_view lan_addr) {
+ std::size_t best = 0;
+ int best_bits = -1;
+ for (std::size_t i = 0; i < igd_hosts.size(); ++i) {
+ if (const auto bits = shared_prefix_length(igd_hosts[i], lan_addr); bits > best_bits) {
+ best = i;
+ best_bits = bits;
+ }
+ }
+
+ return best;
+ }
+
+ std::optional select_outer_igd(const std::vector &igd_hosts, std::size_t inner, std::string_view inner_external_addr) {
+ // A public external address means the inner gateway is the edge router: there is nothing in front of it to map.
+ if (!is_private_ipv4(inner_external_addr)) {
+ return std::nullopt;
+ }
+
+ // The outer router sits in the same network as the inner gateway's external address.
+ constexpr int min_shared_bits = 16;
+ std::optional best;
+ int best_bits = min_shared_bits - 1;
+ for (std::size_t i = 0; i < igd_hosts.size(); ++i) {
+ if (i == inner) {
+ continue;
+ }
+
+ if (const auto bits = shared_prefix_length(igd_hosts[i], inner_external_addr); bits > best_bits) {
+ best = i;
+ best_bits = bits;
+ }
+ }
+
+ return best;
+ }
+
+ /**
+ * @brief Gateway calls through MiniUPnPc.
+ */
+ struct miniupnpc_gateway_t {
+ /**
+ * @brief Look up the rule on the requested gateway port.
+ * @param rule Rule whose gateway, service, port and protocol are looked up.
+ * @return The gateway's current rule.
+ */
+ mapping_entry_t lookup(const owned_mapping_t &rule) const {
+ std::array client {};
+ std::array port {};
+ std::array description {};
+ std::array enabled {};
+ std::array lease {};
+ const auto result = UPNP_GetSpecificPortMappingEntry(rule.control_url.c_str(), rule.service.c_str(), rule.external_port.c_str(), rule.protocol.c_str(), nullptr, client.data(), port.data(), description.data(), enabled.data(), lease.data());
+ return {result, client.data(), port.data(), description.data(), enabled.data(), lease.data()};
+ }
+
+ /**
+ * @brief Add or renew a rule.
+ * @param rule Rule to add.
+ * @param lease Requested lease in seconds, `0` for a rule without expiry.
+ * @return MiniUPnPc result.
+ */
+ int add(const owned_mapping_t &rule, const std::string &lease) const {
+ return UPNP_AddPortMapping(rule.control_url.c_str(), rule.service.c_str(), rule.external_port.c_str(), rule.internal_port.c_str(), rule.target.c_str(), rule.description.c_str(), rule.protocol.c_str(), nullptr, lease.c_str());
+ }
+
+ /**
+ * @brief Remove a rule.
+ * @param rule Rule to remove.
+ * @return MiniUPnPc result.
+ */
+ int remove(const owned_mapping_t &rule) const {
+ return UPNP_DeletePortMapping(rule.control_url.c_str(), rule.service.c_str(), rule.external_port.c_str(), rule.protocol.c_str(), nullptr);
+ }
+ };
+
+ static_assert(mapping_gateway);
+
+ /**
+ * @brief Log a mapping attempt that left the port closed.
+ * @param rule Requested rule.
+ * @param outcome Mapping outcome.
+ * @param error Last gateway result.
+ */
+ static void report_mapping(const owned_mapping_t &rule, map_outcome_e outcome, int error) {
+ switch (outcome) {
+ case map_outcome_e::conflict:
+ BOOST_LOG(warning) << "UPnP port "sv << rule.external_port << " already forwards elsewhere; leaving it unchanged"sv;
+ break;
+ case map_outcome_e::disabled:
+ BOOST_LOG(warning) << "UPnP port "sv << rule.external_port << " has a disabled manual mapping; leaving it unchanged"sv;
+ break;
+ case map_outcome_e::unknown:
+ BOOST_LOG(warning) << "UPnP mapping ownership could not be checked: "sv << error;
+ break;
+ case map_outcome_e::failed:
+ BOOST_LOG(warning) << "Failed to map "sv << rule.protocol << ' ' << rule.external_port << ": "sv << error;
+ break;
+ case map_outcome_e::mapped:
+ case map_outcome_e::reused:
+ case map_outcome_e::unrecorded:
+ case map_outcome_e::cancelled:
+ // Open ports need no message; recording failures are logged where they happen.
+ break;
+ }
+ }
+
/**
* @brief RAII helper that runs shutdown cleanup when destroyed.
*/
@@ -195,137 +389,178 @@ namespace upnp {
}
/**
- * @brief Maps a port via UPnP.
- * @param data IGDdatas from UPNP_GetValidIGD()
- * @param urls urls_t from UPNP_GetValidIGD()
- * @param lan_addr Local IP address to map to
- * @param mapping Information about port to map
- * @return `true` on success.
+ * @brief Append recovery intent before creating a permanent rule, or completion after removing it.
+ * @param mapping Rule to record.
+ * @param added Whether this records creation intent.
+ * @return Whether the complete event reached the file stream successfully.
*/
- bool map_upnp_port(const IGDdatas &data, const urls_t &urls, const std::string &lan_addr, const mapping_t &mapping) {
- std::array intClient;
- std::array intPort;
- std::array desc;
- std::array enabled;
- std::array leaseDuration;
- bool indefinite = false;
-
- // First check if this port is already mapped successfully
- BOOST_LOG(debug) << "Checking for existing UPnP port mapping for "sv << mapping.port.wan;
- auto err = UPNP_GetSpecificPortMappingEntry(
- urls->controlURL,
- data.first.servicetype,
- // In params
- mapping.port.wan.c_str(),
- mapping.port.proto.c_str(),
- nullptr,
- // Out params
- intClient.data(),
- intPort.data(),
- desc.data(),
- enabled.data(),
- leaseDuration.data()
- );
- if (err == 714) { // NoSuchEntryInArray
- BOOST_LOG(debug) << "Mapping entry not found for "sv << mapping.port.wan;
- } else if (err == UPNPCOMMAND_SUCCESS) {
- // Some routers change the description, so we can't check that here
- if (std::string_view {intClient.data()} == lan_addr) {
- if (std::atoi(leaseDuration.data()) == 0) {
- BOOST_LOG(debug) << "Static mapping entry found for "sv << mapping.port.wan;
-
- // It's a static mapping, so we're done here
- return true;
- } else {
- BOOST_LOG(debug) << "Mapping entry found for "sv << mapping.port.wan << " ("sv << leaseDuration.data() << " seconds remaining)"sv;
- }
- } else {
- BOOST_LOG(warning) << "UPnP conflict detected with: "sv << intClient.data();
-
- // Some UPnP IGDs won't let unauthenticated clients delete other conflicting port mappings
- // for security reasons, but we will give it a try anyway.
- err = UPNP_DeletePortMapping(
- urls->controlURL,
- data.first.servicetype,
- mapping.port.wan.c_str(),
- mapping.port.proto.c_str(),
- nullptr
- );
- if (err) {
- BOOST_LOG(error) << "Unable to delete conflicting UPnP port mapping: "sv << err;
+ bool journal_mapping(const owned_mapping_t &mapping, bool added) {
+ const auto event = mapping_journal_event(mapping, added);
+ if (event.empty()) {
+ BOOST_LOG(warning) << "Invalid UPnP recovery identity; refusing an unrecorded permanent mapping"sv;
+ return false;
+ }
+ std::ofstream out {journal_path(), std::ios::app};
+ out << event;
+ out.flush();
+ out.close();
+ if (!out) {
+ BOOST_LOG(warning) << "Could not update UPnP recovery journal; refusing an unrecorded permanent mapping"sv;
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @brief Remember a rule independently of discovery and persist permanent-rule intent before adding it.
+ * @param mapping Rule owned by Sunshine.
+ * @return Whether the rule can safely be managed.
+ */
+ bool remember_mapping(owned_mapping_t mapping) {
+ auto found = std::find(owned_mappings.begin(), owned_mappings.end(), mapping);
+ if (mapping.permanent && (found == owned_mappings.end() || !found->permanent) && !journal_mapping(mapping, true)) {
+ return false;
+ }
+ if (found == owned_mappings.end()) {
+ owned_mappings.push_back(std::move(mapping));
+ } else {
+ found->generation = generation;
+ found->permanent = found->permanent || mapping.permanent;
+ }
+ return true;
+ }
+
+ /**
+ * @brief Remove only recorded rules whose current destination, port and description still match.
+ * @param stale_only Whether to retain rules wanted by the current discovery pass.
+ */
+ void unmap_owned_ports(bool stale_only = false) {
+ const miniupnpc_gateway_t gateway {};
+ std::erase_if(owned_mappings, [this, stale_only, &gateway](const owned_mapping_t &owned) {
+ if (stale_only && owned.generation == generation) {
+ return false;
+ }
+ const auto entry = gateway.lookup(owned);
+ const auto state = classify_mapping(entry.result, entry.client, entry.port, entry.description, owned);
+ if (state == mapping_state_e::unknown) {
+ BOOST_LOG(warning) << "Could not check UPnP rule for cleanup: "sv << owned.control_url << ' ' << owned.external_port;
+ return false;
+ }
+ if (state == mapping_state_e::ours) {
+ const auto removed = gateway.remove(owned);
+ if (removed != UPNPCOMMAND_SUCCESS && removed != 714) {
+ BOOST_LOG(warning) << "Failed to remove UPnP rule: "sv << removed;
return false;
}
}
- } else {
- BOOST_LOG(error) << "UPNP_GetSpecificPortMappingEntry() failed: "sv << err;
+ // Missing or replaced rules are forgotten without deleting the replacement.
+ return !owned.permanent || journal_mapping(owned, false);
+ });
+ if (std::none_of(owned_mappings.begin(), owned_mappings.end(), [](const auto &mapping) {
+ return mapping.permanent;
+ })) {
+ std::error_code ec;
+ std::filesystem::remove(journal_path(), ec);
+ }
+ }
- // If we get a strange error from the router, we'll assume it's some old broken IGDv1
- // device and only use indefinite lease durations to hopefully avoid confusing it.
- if (err != 606) { // Unauthorized
- indefinite = true;
+ /**
+ * @brief Maps every port on an IGD, stopping early on shutdown.
+ * @param igd The IGD to map the ports on.
+ * @param addr Address the ports are forwarded to.
+ * @return Whether every requested mapping succeeded before shutdown.
+ */
+ bool map_all_upnp_ports(const igd_t &igd, const std::string &addr) {
+ std::vector rules;
+ for (const auto &mapping : mappings) {
+ owned_mapping_t rule {igd.urls->controlURL, igd.data.first.servicetype, addr, mapping.port.wan, mapping.port.lan, mapping.port.proto, mapping.description, false, generation};
+ // A failed refresh must not forget a rule that was successfully added earlier.
+ for (auto &owned : owned_mappings) {
+ if (owned == rule) {
+ owned.generation = generation;
+ }
}
+ rules.push_back(std::move(rule));
}
- // Add/update the port mapping
- auto mapping_period = std::to_string(indefinite ? 0 : PORT_MAPPING_LIFETIME.count());
- err = UPNP_AddPortMapping(
- urls->controlURL,
- data.first.servicetype,
- mapping.port.wan.c_str(),
- mapping.port.lan.c_str(),
- lan_addr.data(),
- mapping.description.c_str(),
- mapping.port.proto.c_str(),
- nullptr,
- mapping_period.c_str()
+ auto shutdown_event = mail::man->event(mail::shutdown);
+ const miniupnpc_gateway_t gateway {};
+ return map_ports(
+ gateway,
+ rules,
+ std::to_string(PORT_MAPPING_LIFETIME.count()),
+ [this](const owned_mapping_t &rule) {
+ return remember_mapping(rule);
+ },
+ [&shutdown_event]() {
+ return shutdown_event->peek();
+ },
+ report_mapping
);
+ }
- if (err != UPNPCOMMAND_SUCCESS && !indefinite) {
- // This may be an old/broken IGD that doesn't like non-static mappings.
- BOOST_LOG(debug) << "Trying static mapping after failure: "sv << err;
- err = UPNP_AddPortMapping(
- urls->controlURL,
- data.first.servicetype,
- mapping.port.wan.c_str(),
- mapping.port.lan.c_str(),
- lan_addr.data(),
- mapping.description.c_str(),
- mapping.port.proto.c_str(),
- nullptr,
- "0"
- );
+ /**
+ * @brief Find the IGD of the network this host is attached to.
+ * @details Behind two routers, the IGD chosen by UPNP_GetValidIGD() may be the outer one, which can't reach
+ * this host. The other IGDs that answered discovery are described and the closest one is selected.
+ * @param device Device list from upnpDiscover().
+ * @param igds List holding the IGD chosen by UPNP_GetValidIGD(); the other IGDs are appended to it.
+ * @param igd_hosts Receives the address of each IGD in `igds`.
+ * @param lan_addr This host's local address.
+ * @return Index of the selected IGD in `igds`.
+ */
+ static std::size_t find_gateway(const device_t &device, std::vector &igds, std::vector &igd_hosts, const std::string &lan_addr) {
+ igds.front().host = url_host(igds.front().urls->rootdescURL);
+ add_other_igds(device, igds);
+ for (const auto &igd : igds) {
+ igd_hosts.push_back(igd.host);
}
- if (err) {
- BOOST_LOG(error) << "Failed to map "sv << mapping.port.proto << ' ' << mapping.port.lan << ": "sv << err;
- return false;
+ const auto inner = select_inner_igd(igd_hosts, lan_addr);
+ if (inner != 0) {
+ BOOST_LOG(debug) << "Using the IGD on this host's network instead: "sv << igds[inner].urls->rootdescURL;
}
- BOOST_LOG(debug) << "Successfully mapped "sv << mapping.port.proto << ' ' << mapping.port.lan;
- return true;
+ return inner;
}
/**
- * @brief Unmaps all ports.
- * @param urls urls_t from UPNP_GetValidIGD()
- * @param data IGDdatas from UPNP_GetValidIGD()
+ * @brief Behind double NAT, map the ports on the IGD in front of the gateway.
+ * @details If the gateway's external address is private, the same ports are mapped on the IGD in front of it,
+ * forwarded to the gateway's external address. That IGD is kept to remove the mappings on shutdown.
+ * @param igds The discovered IGDs.
+ * @param igd_hosts Address of each IGD in `igds`.
+ * @param inner Index of the gateway in `igds`.
+ * @param first_pass Whether this is the first mapping pass since the mappings were last completed.
*/
- void unmap_all_upnp_ports(const urls_t &urls, const IGDdatas &data) {
- for (auto it = std::begin(mappings); it != std::end(mappings); ++it) {
- auto status = UPNP_DeletePortMapping(
- urls->controlURL,
- data.first.servicetype,
- it->port.wan.c_str(),
- it->port.proto.c_str(),
- nullptr
- );
-
- if (status && status != 714) { // NoSuchEntryInArray
- BOOST_LOG(warning) << "Failed to unmap "sv << it->port.proto << ' ' << it->port.lan << ": "sv << status;
- } else {
- BOOST_LOG(debug) << "Successfully unmapped "sv << it->port.proto << ' ' << it->port.lan;
+ void map_outer_upnp_ports(std::vector &igds, const std::vector &igd_hosts, std::size_t inner, bool first_pass) {
+ const auto external_addr = external_address(igds[inner]);
+ const auto outer = select_outer_igd(igd_hosts, inner, external_addr);
+ if (!outer.has_value()) {
+ // A missing reply is not proof that a working outer mapping is obsolete.
+ for (auto &owned : owned_mappings) {
+ if (external_addr.empty() || (is_private_ipv4(external_addr) && owned.target == external_addr)) {
+ owned.generation = generation;
+ }
+ }
+ if (is_private_ipv4(external_addr) && !warned_no_outer_igd) {
+ BOOST_LOG(warning) << "The IGD's external address "sv << external_addr
+ << " is private and no UPnP IGD answered in front of it: ports must be forwarded on the outer router"sv;
+ warned_no_outer_igd = true;
}
+ return;
}
+
+ auto &outer_igd = igds[*outer];
+ const bool complete = map_all_upnp_ports(outer_igd, external_addr);
+ if (complete && (first_pass || mapped_outer_host != outer_igd.host)) {
+ BOOST_LOG(info) << "Completed UPnP port mappings to "sv << external_addr << " via "sv << outer_igd.urls->rootdescURL;
+ }
+
+ if (complete) {
+ mapped_outer_host = outer_igd.host;
+ }
+ warned_no_outer_igd = false;
}
/**
@@ -334,9 +569,25 @@ namespace upnp {
void upnp_thread_proc() {
platf::set_thread_name("upnp");
auto shutdown_event = mail::man->event(mail::shutdown);
+ std::ifstream journal {journal_path()};
+ std::error_code journal_error;
+ const bool journal_exists = std::filesystem::exists(journal_path(), journal_error);
+ if (journal_error || (journal_exists && !journal.is_open())) {
+ BOOST_LOG(error) << "Could not read UPnP recovery journal; leaving it intact and skipping new mappings"sv;
+ return;
+ }
+ const std::string contents {(std::istreambuf_iterator(journal)), std::istreambuf_iterator()};
+ if (journal.bad()) {
+ BOOST_LOG(error) << "Could not finish reading UPnP recovery journal; leaving it intact"sv;
+ return;
+ }
+ journal.close();
+ owned_mappings = read_mapping_journal(contents);
+ unmap_owned_ports();
+ if (!config::sunshine.flags[config::flag::UPNP]) {
+ return;
+ }
bool mapped = false;
- IGDdatas data;
- urls_t mapped_urls;
auto address_family = net::get_effective_address_family(net::af_from_enum_string(config::sunshine.address_family));
// Refresh UPnP rules every few minutes. They can be lost if the router reboots,
@@ -356,8 +607,8 @@ namespace upnp {
std::array lan_addr;
- urls_t urls;
- auto status = upnp::UPNP_GetValidIGDStatus(device, &urls, &data, lan_addr);
+ std::vector igds(1);
+ auto status = upnp::UPNP_GetValidIGDStatus(device, &igds.front().urls, &igds.front().data, lan_addr);
if (!is_valid_igd(status)) {
BOOST_LOG(error) << status_string(status);
mapped = false;
@@ -366,16 +617,20 @@ namespace upnp {
std::string lan_addr_str {lan_addr.data()};
- BOOST_LOG(debug) << "Found valid IGD device: "sv << urls->rootdescURL;
+ BOOST_LOG(debug) << "Found valid IGD device: "sv << igds.front().urls->rootdescURL;
- for (auto it = std::begin(mappings); it != std::end(mappings) && !shutdown_event->peek(); ++it) {
- map_upnp_port(data, urls, lan_addr_str, *it);
- }
+ std::vector igd_hosts;
+ const auto inner = find_gateway(device, igds, igd_hosts, lan_addr_str);
+ ++generation;
+ const bool complete = map_all_upnp_ports(igds[inner], lan_addr_str);
- if (!mapped) {
- BOOST_LOG(info) << "Completed UPnP port mappings to "sv << lan_addr_str << " via "sv << urls->rootdescURL;
+ if (!mapped && complete) {
+ BOOST_LOG(info) << "Completed UPnP port mappings to "sv << lan_addr_str << " via "sv << igds[inner].urls->rootdescURL;
}
+ map_outer_upnp_ports(igds, igd_hosts, inner, !mapped);
+ unmap_owned_ports(true);
+
// If we are listening on IPv6 and the IGD has an IPv6 firewall enabled, try to create IPv6 firewall pinholes
if (address_family == net::af_e::BOTH) {
if (create_ipv6_pinholes() && !mapped) {
@@ -384,18 +639,26 @@ namespace upnp {
}
}
- mapped = true;
- mapped_urls = std::move(urls);
+ mapped = complete;
} while (!shutdown_event->view(REFRESH_INTERVAL));
- if (mapped) {
- // Unmap ports upon termination
- BOOST_LOG(info) << "Unmapping UPNP ports..."sv;
- unmap_all_upnp_ports(mapped_urls, data);
- }
+ // Discovery failures and gateway changes never discard ownership records.
+ unmap_owned_ports();
+ }
+
+ /**
+ * @brief Locate the append-only recovery journal used even when UPnP is disabled at the next start.
+ * @return File containing outstanding permanent-rule intents.
+ */
+ static std::filesystem::path journal_path() {
+ return platf::appdata() / "upnp_mappings.journal";
}
- std::vector mappings; ///< Port mappings Sunshine should keep registered with the gateway.
+ std::vector mappings; ///< Ports requested by Sunshine.
+ std::vector owned_mappings; ///< Rules retained independently of discovery results.
+ std::string mapped_outer_host; ///< Last outer gateway logged as mapped.
+ std::size_t generation = 0; ///< Current successful discovery pass.
+ bool warned_no_outer_igd = false; ///< Whether the missing outer IGD has already been reported.
std::jthread upnp_thread; ///< Worker thread that refreshes mappings until shutdown.
};
@@ -403,7 +666,8 @@ namespace upnp {
* @brief Start UPnP port mapping and return its shutdown guard.
*/
std::unique_ptr start() {
- if (!config::sunshine.flags[config::flag::UPNP]) {
+ std::error_code ec;
+ if (!config::sunshine.flags[config::flag::UPNP] && !std::filesystem::exists(platf::appdata() / "upnp_mappings.journal", ec)) {
return nullptr;
}
diff --git a/src/upnp.h b/src/upnp.h
index 1f68f48a3d5..e984d1e4372 100644
--- a/src/upnp.h
+++ b/src/upnp.h
@@ -4,6 +4,13 @@
*/
#pragma once
+// standard includes
+#include
+#include
+#include
+#include
+#include
+
// lib includes
#include
@@ -72,6 +79,45 @@ namespace upnp {
*/
igd_status_e UPNP_GetValidIGDStatus(device_t &device, urls_t *urls, IGDdatas *data, std::array &lan_addr);
+ /**
+ * @brief Count the leading bits two IPv4 addresses have in common.
+ * @param a First IPv4 address in dotted notation.
+ * @param b Second IPv4 address in dotted notation.
+ * @return Number of shared leading bits (0 to 32), or -1 if either value is not an IPv4 address.
+ */
+ [[nodiscard]] int shared_prefix_length(std::string_view a, std::string_view b);
+
+ /**
+ * @brief Extract the host from a URL such as `http://192.168.1.1:1900/rootDesc.xml`.
+ * @param url The URL to parse.
+ * @return The host part of the URL, without port or brackets.
+ */
+ [[nodiscard]] std::string url_host(std::string_view url);
+
+ /**
+ * @brief Select the IGD of the network Sunshine is attached to.
+ * @details Behind two routers, the outer router often answers discovery too, and UPNP_GetValidIGD() prefers it
+ * because it has a public address. That router can't reach Sunshine's address, so a mapping there opens nothing.
+ * The gateway Sunshine is attached to is the one whose address shares the most leading bits with Sunshine's own
+ * address. On a tie, the earlier entry wins, so the first entry (the IGD chosen by UPNP_GetValidIGD()) is kept
+ * unless another IGD is strictly closer.
+ * @param igd_hosts Addresses of the discovered IGDs.
+ * @param lan_addr Sunshine's local address.
+ * @return Index of the selected IGD in `igd_hosts`, or 0 if the list is empty.
+ */
+ [[nodiscard]] std::size_t select_inner_igd(const std::vector &igd_hosts, std::string_view lan_addr);
+
+ /**
+ * @brief Select the IGD in front of the inner gateway when Sunshine is behind two routers (double NAT).
+ * @details The outer router is only looked for when the inner gateway's external address is private. It is the
+ * other IGD whose address shares the most leading bits (at least 16) with that external address.
+ * @param igd_hosts Addresses of the discovered IGDs.
+ * @param inner Index of the inner gateway in `igd_hosts`.
+ * @param inner_external_addr External address reported by the inner gateway.
+ * @return Index of the outer IGD in `igd_hosts`, or `std::nullopt` if there is none.
+ */
+ [[nodiscard]] std::optional select_outer_igd(const std::vector &igd_hosts, std::size_t inner, std::string_view inner_external_addr);
+
/**
* @brief Start UPnP port mapping and return its shutdown guard.
*
diff --git a/src/upnp_mapping.h b/src/upnp_mapping.h
new file mode 100644
index 00000000000..b075c306319
--- /dev/null
+++ b/src/upnp_mapping.h
@@ -0,0 +1,267 @@
+/**
+ * @file src/upnp_mapping.h
+ * @brief Ownership and recovery records for UPnP port mappings.
+ */
+#pragma once
+
+// standard includes
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace upnp {
+ /**
+ * @brief A forwarding rule Sunshine owns, including its gateway and destination.
+ */
+ struct owned_mapping_t {
+ std::string control_url; ///< Gateway's SOAP endpoint.
+ std::string service; ///< Gateway's WAN connection service.
+ std::string target; ///< Host or inner router receiving the forwarded traffic.
+ std::string external_port; ///< Gateway port.
+ std::string internal_port; ///< Destination port.
+ std::string protocol; ///< TCP or UDP.
+ std::string description; ///< Description used when Sunshine created the rule.
+ bool permanent = false; ///< Rule requires recovery after an unexpected process exit.
+ std::size_t generation = 0; ///< Last successful discovery pass that wanted this exact rule.
+
+ /**
+ * @brief Compare persistent rule identities, excluding refresh bookkeeping.
+ * @param other Rule to compare.
+ * @return Whether both records identify the same forwarding rule.
+ */
+ bool operator==(const owned_mapping_t &other) const {
+ return std::tie(control_url, service, target, external_port, internal_port, protocol, description) ==
+ std::tie(other.control_url, other.service, other.target, other.external_port, other.internal_port, other.protocol, other.description);
+ }
+ };
+
+ /**
+ * @brief Relationship between a gateway's existing mapping and the requested rule.
+ */
+ enum class mapping_state_e {
+ missing, ///< Gateway explicitly reports no such mapping.
+ ours, ///< Destination, port and description match Sunshine's record.
+ existing, ///< Someone else's rule already forwards to the requested destination.
+ conflict, ///< Rule forwards elsewhere and must be left untouched.
+ unknown, ///< Lookup failed; ownership cannot be established.
+ };
+
+ /**
+ * @brief Classify a lookup without treating a destination alone as proof of ownership.
+ * @param result MiniUPnPc lookup result.
+ * @param client Reported destination address.
+ * @param port Reported destination port.
+ * @param description Reported description.
+ * @param wanted Requested or previously recorded rule.
+ * @return Ownership classification.
+ */
+ inline mapping_state_e classify_mapping(int result, std::string_view client, std::string_view port, std::string_view description, const owned_mapping_t &wanted) {
+ if (result == 714) {
+ return mapping_state_e::missing;
+ }
+ if (result != 0) {
+ return mapping_state_e::unknown;
+ }
+ // 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;
+ }
+
+ /**
+ * @brief Whether a gateway reports a rule as switched off.
+ * @param enabled The rule's `NewEnabled` value; UPnP booleans may be `0`/`1`, `false`/`true` or `no`/`yes`.
+ * @return Whether the rule cannot forward traffic. A missing value is not proof that it is disabled.
+ */
+ inline bool is_disabled_mapping(std::string_view enabled) {
+ return std::ranges::any_of(std::array {std::string_view {"0"}, std::string_view {"false"}, std::string_view {"no"}}, [enabled](std::string_view value) {
+ return std::ranges::equal(enabled, value, [](char a, char b) {
+ return std::tolower(static_cast(a)) == b;
+ });
+ });
+ }
+
+ /**
+ * @brief A gateway rule as reported by a lookup.
+ */
+ struct mapping_entry_t {
+ int result = 0; ///< MiniUPnPc lookup result.
+ std::string client; ///< Destination address.
+ std::string port; ///< Destination port.
+ std::string description; ///< Rule description.
+ std::string enabled; ///< Whether the rule is enabled.
+ std::string lease; ///< Remaining lease in seconds, `0` for a rule without expiry.
+ };
+
+ /**
+ * @brief Gateway calls used to manage rules: MiniUPnPc in Sunshine, a recorder in tests.
+ * @details `lookup` reports the rule on the requested gateway port, `add` adds or renews a rule with the given
+ * lease in seconds (`0` for no expiry), and `remove` deletes a rule. `add` and `remove` return MiniUPnPc results.
+ */
+ template
+ concept mapping_gateway = requires(T &calls, const owned_mapping_t &rule, const std::string &lease) {
+ { calls.lookup(rule) } -> std::same_as;
+ { calls.add(rule, lease) } -> std::convertible_to;
+ { calls.remove(rule) } -> std::convertible_to;
+ };
+
+ /**
+ * @brief Result of one mapping attempt.
+ */
+ enum class map_outcome_e {
+ mapped, ///< Sunshine added or renewed the rule.
+ reused, ///< An enabled rule already forwards to the destination and needs no renewal.
+ disabled, ///< Someone else's rule forwards to the destination but is disabled; left unchanged.
+ conflict, ///< The port forwards elsewhere; left unchanged.
+ unknown, ///< The lookup failed; nothing was changed.
+ unrecorded, ///< Ownership could not be recorded; nothing more was changed.
+ cancelled, ///< Shutdown was requested before the rule was added.
+ failed, ///< The gateway refused the rule.
+ };
+
+ /**
+ * @brief Whether an outcome leaves the port forwarded to the destination.
+ * @param outcome Mapping outcome.
+ * @return Whether the port is open.
+ */
+ inline bool is_mapped(map_outcome_e outcome) {
+ return outcome == map_outcome_e::mapped || outcome == map_outcome_e::reused;
+ }
+
+ /**
+ * @brief Map one port without deleting, renewing or adopting a rule belonging to another application.
+ * @param gateway Gateway holding the rule.
+ * @param wanted Desired rule.
+ * @param lifetime Lease requested for a renewable rule, in seconds.
+ * @param remember Records a rule Sunshine owns; must persist recovery intent before a rule without expiry is added.
+ * @param is_cancelled Whether shutdown was requested.
+ * @param error Receives the last gateway result.
+ * @return Mapping outcome.
+ */
+ template
+ map_outcome_e map_port(Gateway &gateway, owned_mapping_t wanted, const std::string &lifetime, Remember &&remember, Cancelled &&is_cancelled, int &error) {
+ const auto entry = gateway.lookup(wanted);
+ error = entry.result;
+ const bool disabled = is_disabled_mapping(entry.enabled);
+ switch (classify_mapping(entry.result, entry.client, entry.port, entry.description, wanted)) {
+ case mapping_state_e::conflict:
+ return map_outcome_e::conflict;
+ case mapping_state_e::unknown:
+ return map_outcome_e::unknown;
+ case mapping_state_e::existing:
+ // Never renew or remove someone else's rule, but a disabled one does not open the port.
+ return disabled ? map_outcome_e::disabled : map_outcome_e::reused;
+ case mapping_state_e::ours:
+ wanted.permanent = entry.lease == "0";
+ if (!remember(wanted)) {
+ return map_outcome_e::unrecorded;
+ }
+ if (wanted.permanent && !disabled) {
+ return map_outcome_e::reused;
+ }
+ // Renew the lease, or enable Sunshine's own disabled rule again.
+ break;
+ case mapping_state_e::missing:
+ break;
+ }
+ if (is_cancelled()) {
+ return map_outcome_e::cancelled;
+ }
+ error = gateway.add(wanted, lifetime);
+ if (error == 725 && !is_cancelled()) { // OnlyPermanentLeasesSupported
+ wanted.permanent = true;
+ // Persist intent first: a process crash immediately after adding the rule must be recoverable.
+ if (!remember(wanted)) {
+ return map_outcome_e::unrecorded;
+ }
+ error = gateway.add(wanted, "0");
+ }
+ if (error != 0) {
+ return map_outcome_e::failed;
+ }
+ return remember(std::move(wanted)) ? map_outcome_e::mapped : map_outcome_e::unrecorded;
+ }
+
+ /**
+ * @brief Map every requested rule on one gateway, stopping early on shutdown.
+ * @param gateway Gateway holding the rules.
+ * @param rules Desired rules.
+ * @param lifetime Lease requested for renewable rules, in seconds.
+ * @param remember Records a rule Sunshine owns.
+ * @param is_cancelled Whether shutdown was requested.
+ * @param report Receives each rule, its outcome and the last gateway result.
+ * @return Whether every requested port forwards to its destination.
+ */
+ template
+ bool map_ports(Gateway &gateway, const std::vector &rules, const std::string &lifetime, Remember &&remember, Cancelled &&is_cancelled, Report &&report) {
+ bool complete = true;
+ for (const auto &rule : rules) {
+ if (is_cancelled()) {
+ return false;
+ }
+ int error = 0;
+ const auto outcome = map_port(gateway, rule, lifetime, remember, is_cancelled, error);
+ report(rule, outcome, error);
+ complete = is_mapped(outcome) && complete;
+ }
+ return complete;
+ }
+
+ /**
+ * @brief Encode one append-only recovery event before adding or after removing a permanent rule.
+ * @param mapping Rule being recorded.
+ * @param added Whether this records creation intent rather than completed removal.
+ * @return A newline-delimited event, or an empty string for an invalid identity.
+ */
+ inline std::string mapping_journal_event(const owned_mapping_t &mapping, bool added) {
+ for (const auto *field : {&mapping.control_url, &mapping.service, &mapping.target, &mapping.external_port, &mapping.internal_port, &mapping.protocol, &mapping.description}) {
+ if (field->empty() || field->find_first_of("\r\n") != std::string::npos) {
+ return {};
+ }
+ }
+ std::ostringstream out;
+ out << '\n'
+ << (added ? '+' : '-') << ' ' << std::quoted(mapping.control_url) << ' ' << std::quoted(mapping.service) << ' '
+ << std::quoted(mapping.target) << ' ' << std::quoted(mapping.external_port) << ' ' << std::quoted(mapping.internal_port)
+ << ' ' << std::quoted(mapping.protocol) << ' ' << std::quoted(mapping.description) << '\n';
+ return out.str();
+ }
+
+ /**
+ * @brief Recover outstanding permanent rules, ignoring incomplete or invalid journal lines.
+ * @param text Previously written recovery events.
+ * @return Rules whose creation intent has no matching completed removal.
+ */
+ inline std::vector read_mapping_journal(const std::string &text) {
+ std::vector mappings;
+ std::istringstream input {text};
+ std::string line;
+ while (std::getline(input, line)) {
+ std::istringstream entry {line};
+ char event = 0;
+ owned_mapping_t mapping;
+ if (!(entry >> event >> std::quoted(mapping.control_url) >> std::quoted(mapping.service) >> std::quoted(mapping.target) >> std::quoted(mapping.external_port) >> std::quoted(mapping.internal_port) >> std::quoted(mapping.protocol) >> std::quoted(mapping.description)) || (event != '+' && event != '-') || mapping.control_url.empty() || mapping.service.empty() || mapping.target.empty() || mapping.external_port.empty() || mapping.internal_port.empty() || (mapping.protocol != "TCP" && mapping.protocol != "UDP") || mapping.description.empty()) {
+ continue;
+ }
+ mapping.permanent = true;
+ const auto found = std::find(mappings.begin(), mappings.end(), mapping);
+ if (event == '+' && found == mappings.end()) {
+ mappings.push_back(std::move(mapping));
+ } else if (event == '-' && found != mappings.end()) {
+ mappings.erase(found);
+ }
+ }
+ return mappings;
+ }
+} // namespace upnp
diff --git a/tests/unit/test_upnp.cpp b/tests/unit/test_upnp.cpp
index bf0304d32a8..dfe809a1df4 100644
--- a/tests/unit/test_upnp.cpp
+++ b/tests/unit/test_upnp.cpp
@@ -40,3 +40,62 @@ TEST(UpnpIsValidIgdTest, RejectsOutOfRange) {
ASSERT_FALSE(upnp::is_valid_igd(static_cast(5)));
ASSERT_FALSE(upnp::is_valid_igd(static_cast(100)));
}
+
+TEST(UpnpSharedPrefixLengthTest, CountsSharedLeadingBits) {
+ EXPECT_EQ(upnp::shared_prefix_length("192.168.20.181", "192.168.20.181"), 32);
+ EXPECT_EQ(upnp::shared_prefix_length("192.168.20.181", "192.168.1.100"), 19);
+ EXPECT_EQ(upnp::shared_prefix_length("10.0.0.86", "10.0.0.1"), 25);
+ EXPECT_EQ(upnp::shared_prefix_length("10.0.0.1", "192.168.1.1"), 0);
+}
+
+TEST(UpnpSharedPrefixLengthTest, RejectsNonIpv4Values) {
+ EXPECT_EQ(upnp::shared_prefix_length("router.local", "192.168.1.1"), -1);
+ EXPECT_EQ(upnp::shared_prefix_length("192.168.1.1", "fe80::1"), -1);
+ EXPECT_EQ(upnp::shared_prefix_length("", ""), -1);
+}
+
+TEST(UpnpUrlHostTest, ExtractsHost) {
+ EXPECT_EQ(upnp::url_host("http://192.168.1.100:1900/rootDesc.xml"), "192.168.1.100");
+ EXPECT_EQ(upnp::url_host("http://10.0.0.1/rootDesc.xml"), "10.0.0.1");
+ EXPECT_EQ(upnp::url_host("http://[fe80::1]:5000/rootDesc.xml"), "fe80::1");
+ EXPECT_EQ(upnp::url_host("192.168.1.1:80"), "192.168.1.1");
+}
+
+TEST(UpnpSelectInnerIgdTest, KeepsFirstIgdWhenAlone) {
+ EXPECT_EQ(upnp::select_inner_igd({"192.168.1.1"}, "192.168.1.20"), 0U);
+ EXPECT_EQ(upnp::select_inner_igd({}, "192.168.1.20"), 0U);
+}
+
+TEST(UpnpSelectInnerIgdTest, PrefersIgdOnHostNetwork) {
+ // Double NAT: UPNP_GetValidIGD() chose the outer router (public address), the inner router shares the host's network.
+ EXPECT_EQ(upnp::select_inner_igd({"10.0.0.1", "192.168.1.100"}, "192.168.20.181"), 1U);
+}
+
+TEST(UpnpSelectInnerIgdTest, KeepsFirstIgdOnTie) {
+ EXPECT_EQ(upnp::select_inner_igd({"192.168.1.1", "192.168.1.2"}, "192.168.1.20"), 0U);
+ EXPECT_EQ(upnp::select_inner_igd({"router.local", "gateway.local"}, "192.168.1.20"), 0U);
+}
+
+TEST(UpnpSelectOuterIgdTest, FindsIgdInFrontOfGateway) {
+ EXPECT_EQ(upnp::select_outer_igd({"192.168.1.100", "10.0.0.1"}, 0, "10.0.0.86"), 1U);
+}
+
+TEST(UpnpSelectOuterIgdTest, IgnoresGatewayWithPublicAddress) {
+ EXPECT_EQ(upnp::select_outer_igd({"192.168.1.100", "10.0.0.1"}, 0, "203.0.113.5"), std::nullopt);
+}
+
+TEST(UpnpSelectOuterIgdTest, IgnoresMissingOrInvalidExternalAddress) {
+ EXPECT_EQ(upnp::select_outer_igd({"192.168.1.100", "10.0.0.1"}, 0, ""), std::nullopt);
+ EXPECT_EQ(upnp::select_outer_igd({"192.168.1.100", "10.0.0.1"}, 0, "not-an-address"), std::nullopt);
+ EXPECT_EQ(upnp::select_outer_igd({"192.168.1.100", "10.0.0.1"}, 0, "fc00::1"), std::nullopt);
+}
+
+TEST(UpnpSelectOuterIgdTest, IgnoresInnerGatewayAndUnrelatedIgds) {
+ // The only other IGD is on the host's own network, not in front of the gateway.
+ EXPECT_EQ(upnp::select_outer_igd({"192.168.1.100", "192.168.1.2"}, 0, "10.0.0.86"), std::nullopt);
+ EXPECT_EQ(upnp::select_outer_igd({"10.0.0.1"}, 0, "10.0.0.86"), std::nullopt);
+}
+
+TEST(UpnpSelectOuterIgdTest, PrefersClosestIgd) {
+ EXPECT_EQ(upnp::select_outer_igd({"192.168.1.100", "10.0.5.1", "10.0.0.1"}, 0, "10.0.0.86"), 2U);
+}
diff --git a/tests/unit/test_upnp_mapping.cpp b/tests/unit/test_upnp_mapping.cpp
new file mode 100644
index 00000000000..c82d9f2bbb5
--- /dev/null
+++ b/tests/unit/test_upnp_mapping.cpp
@@ -0,0 +1,294 @@
+/**
+ * @file tests/unit/test_upnp_mapping.cpp
+ * @brief Verify UPnP ownership boundaries and permanent-rule crash recovery.
+ */
+
+// test includes
+#include "../tests_common.h"
+
+// standard includes
+#include