diff --git a/docs/configuration.md b/docs/configuration.md index d060c4e04..662a07f19 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1502,6 +1502,46 @@ editing the `conf` file in a text editor. Use the examples as reference. +### bind_address + + + + + + + + + + + + + + + + + + + + + + +
Description + Set the IP address to bind Sunshine to. This is useful when you have multiple network interfaces + and want to restrict Sunshine to a specific one. If not set, Sunshine will bind to all available + interfaces (0.0.0.0 for IPv4 or :: for IPv6). +

+ Note: The address must be valid for the system and must match the address family + being used. When using IPv6, you can specify an IPv6 address even with address_family set to "both". +
Default@code{} + (empty - bind to all interfaces) + @endcode
Example (IPv4)@code{} + bind_address = 192.168.1.100 + @endcode
Example (IPv6)@code{} + bind_address = 2001:db8::1 + @endcode
Example (Loopback)@code{} + bind_address = 127.0.0.1 + @endcode
+ ### port diff --git a/src/config.cpp b/src/config.cpp index 8e231a1f8..f65102e17 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -576,6 +576,7 @@ namespace config { {}, // cmd args 47989, // Base port number "ipv4", // Address family + {}, // Bind address platf::appdata().string() + "/sunshine.log", // log file false, // notify_pre_releases true, // system_tray @@ -1242,6 +1243,7 @@ namespace config { sunshine.port = (std::uint16_t) port; string_restricted_f(vars, "address_family", sunshine.address_family, {"ipv4"sv, "both"sv}); + string_f(vars, "bind_address", sunshine.bind_address); bool upnp = false; bool_f(vars, "upnp"s, upnp); diff --git a/src/config.h b/src/config.h index 7ba1bd234..e8d1594fb 100644 --- a/src/config.h +++ b/src/config.h @@ -253,6 +253,7 @@ namespace config { std::uint16_t port; std::string address_family; + std::string bind_address; std::string log_file; bool notify_pre_releases; diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 5fad47bbd..b706d4102 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -1219,7 +1219,7 @@ namespace confighttp { server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage; server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; server.config.reuse_address = true; - server.config.address = net::af_to_any_address_string(address_family); + server.config.address = net::get_bind_address(address_family); server.config.port = port_https; auto accept_and_run = [&](auto *server) { diff --git a/src/network.cpp b/src/network.cpp index fedee2b83..aad65a7a0 100644 --- a/src/network.cpp +++ b/src/network.cpp @@ -105,7 +105,7 @@ namespace net { return BOTH; } - std::string_view af_to_any_address_string(af_e af) { + std::string_view af_to_any_address_string(const af_e af) { switch (af) { case IPV4: return "0.0.0.0"sv; @@ -117,6 +117,16 @@ namespace net { return "::"sv; } + std::string get_bind_address(const af_e af) { + // If bind_address is configured, use it + if (!config::sunshine.bind_address.empty()) { + return config::sunshine.bind_address; + } + + // Otherwise use the wildcard address for the given address family + return std::string(af_to_any_address_string(af)); + } + boost::asio::ip::address normalize_address(boost::asio::ip::address address) { // Convert IPv6-mapped IPv4 addresses into regular IPv4 addresses if (address.is_v6()) { @@ -159,8 +169,8 @@ namespace net { enet_initialize(); }); - auto any_addr = net::af_to_any_address_string(af); - enet_address_set_host(&addr, any_addr.data()); + const auto bind_addr = net::get_bind_address(af); + enet_address_set_host(&addr, bind_addr.c_str()); enet_address_set_port(&addr, port); // Maximum of 128 clients, which should be enough for anyone diff --git a/src/network.h b/src/network.h index 99aa0189a..1b99a1c14 100644 --- a/src/network.h +++ b/src/network.h @@ -65,6 +65,13 @@ namespace net { */ std::string_view af_to_any_address_string(af_e af); + /** + * @brief Get the binding address to use based on config. + * @param af Address family. + * @return The configured bind address or wildcard if not configured. + */ + std::string get_bind_address(af_e af); + /** * @brief Convert an address to a normalized form. * @details Normalization converts IPv4-mapped IPv6 addresses into IPv4 addresses. diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index 716c16f16..d4e5ba73f 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -1158,7 +1158,7 @@ namespace nvhttp { https_server.resource["^/cancel$"]["GET"] = cancel; https_server.config.reuse_address = true; - https_server.config.address = net::af_to_any_address_string(address_family); + https_server.config.address = net::get_bind_address(address_family); https_server.config.port = port_https; http_server.default_resource["GET"] = not_found; @@ -1168,7 +1168,7 @@ namespace nvhttp { }; http_server.config.reuse_address = true; - http_server.config.address = net::af_to_any_address_string(address_family); + http_server.config.address = net::get_bind_address(address_family); http_server.config.port = port_http; auto accept_and_run = [&](auto *http_server) { diff --git a/src/rtsp.cpp b/src/rtsp.cpp index f5909eb87..45444b8c8 100644 --- a/src/rtsp.cpp +++ b/src/rtsp.cpp @@ -412,7 +412,14 @@ namespace rtsp_stream { acceptor.set_option(boost::asio::socket_base::reuse_address {true}); - acceptor.bind(tcp::endpoint(af == net::IPV4 ? tcp::v4() : tcp::v6(), port), ec); + auto bind_addr_str = net::get_bind_address(af); + const auto bind_addr = boost::asio::ip::make_address(bind_addr_str, ec); + if (ec) { + BOOST_LOG(error) << "Invalid bind address: "sv << bind_addr_str << " - " << ec.message(); + return -1; + } + + acceptor.bind(tcp::endpoint(bind_addr, port), ec); if (ec) { return -1; } diff --git a/src/stream.cpp b/src/stream.cpp index 6bfd5d977..0a342082a 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -1721,7 +1721,14 @@ namespace stream { BOOST_LOG(error) << "Failed to set video socket send buffer size (SO_SENDBUF)"; } - ctx.video_sock.bind(udp::endpoint(protocol, video_port), ec); + auto bind_addr_str = net::get_bind_address(address_family); + const auto bind_addr = boost::asio::ip::make_address(bind_addr_str, ec); + if (ec) { + BOOST_LOG(fatal) << "Invalid bind address: "sv << bind_addr_str << " - " << ec.message(); + return -1; + } + + ctx.video_sock.bind(udp::endpoint(bind_addr, video_port), ec); if (ec) { BOOST_LOG(fatal) << "Couldn't bind Video server to port ["sv << video_port << "]: "sv << ec.message(); @@ -1735,7 +1742,7 @@ namespace stream { return -1; } - ctx.audio_sock.bind(udp::endpoint(protocol, audio_port), ec); + ctx.audio_sock.bind(udp::endpoint(bind_addr, audio_port), ec); if (ec) { BOOST_LOG(fatal) << "Couldn't bind Audio server to port ["sv << audio_port << "]: "sv << ec.message(); diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index 887062d6c..c85192b87 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -191,6 +191,7 @@ options: { "upnp": "disabled", "address_family": "ipv4", + "bind_address": "", "port": 47989, "origin_web_ui_allowed": "lan", "external_ip": "", diff --git a/src_assets/common/assets/web/configs/tabs/Network.vue b/src_assets/common/assets/web/configs/tabs/Network.vue index 4fac049db..69b7fd17a 100644 --- a/src_assets/common/assets/web/configs/tabs/Network.vue +++ b/src_assets/common/assets/web/configs/tabs/Network.vue @@ -33,6 +33,13 @@ const effectivePort = computed(() => +config.value?.port ?? defaultMoonlightPort
{{ $t('config.address_family_desc') }}
+ +
+ + +
{{ $t('config.bind_address_desc') }}
+
+
diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 1a9e8fa79..331dc6315 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -138,6 +138,8 @@ "av1_mode_desc": "Allows the client to request AV1 Main 8-bit or 10-bit video streams. AV1 is more CPU-intensive to encode, so enabling this may reduce performance when using software encoding.", "back_button_timeout": "Home/Guide Button Emulation Timeout", "back_button_timeout_desc": "If the Back/Select button is held down for the specified number of milliseconds, a Home/Guide button press is emulated. If set to a value < 0 (default), holding the Back/Select button will not emulate the Home/Guide button.", + "bind_address": "Bind address", + "bind_address_desc": "Set the specific IP address Sunshine will bind to. If left blank, Sunshine will bind to all available addresses.", "capture": "Force a Specific Capture Method", "capture_desc": "On automatic mode Sunshine will use the first one that works. NvFBC requires patched nvidia drivers.", "cert": "Certificate", diff --git a/tests/unit/test_network.cpp b/tests/unit/test_network.cpp index 227358652..ef150539b 100644 --- a/tests/unit/test_network.cpp +++ b/tests/unit/test_network.cpp @@ -26,3 +26,115 @@ INSTANTIATE_TEST_SUITE_P( std::make_tuple(std::string(128, 'a'), std::string(63, 'a')) ) ); + +/** + * @brief Test fixture for bind_address tests with setup/teardown + */ +class BindAddressTest: public ::testing::Test { +protected: + std::string original_bind_address; + + void SetUp() override { + // Save the original bind_address config + original_bind_address = config::sunshine.bind_address; + } + + void TearDown() override { + // Restore the original bind_address config + config::sunshine.bind_address = original_bind_address; + } +}; + +/** + * @brief Test that get_bind_address returns wildcard when bind_address is not configured + */ +TEST_F(BindAddressTest, DefaultBehaviorIPv4) { + // Clear bind_address to test the default behavior + config::sunshine.bind_address = ""; + + const auto bind_addr = net::get_bind_address(net::af_e::IPV4); + ASSERT_EQ(bind_addr, "0.0.0.0"); +} + +/** + * @brief Test that get_bind_address returns wildcard when bind_address is not configured (IPv6) + */ +TEST_F(BindAddressTest, DefaultBehaviorIPv6) { + // Clear bind_address to test the default behavior + config::sunshine.bind_address = ""; + + const auto bind_addr = net::get_bind_address(net::af_e::BOTH); + ASSERT_EQ(bind_addr, "::"); +} + +/** + * @brief Test that get_bind_address returns configured IPv4 address + */ +TEST_F(BindAddressTest, ConfiguredIPv4Address) { + // Set a specific IPv4 address + config::sunshine.bind_address = "192.168.1.100"; + + const auto bind_addr = net::get_bind_address(net::af_e::IPV4); + ASSERT_EQ(bind_addr, "192.168.1.100"); +} + +/** + * @brief Test that get_bind_address returns configured IPv6 address + */ +TEST_F(BindAddressTest, ConfiguredIPv6Address) { + // Set a specific IPv6 address + config::sunshine.bind_address = "::1"; + + const auto bind_addr = net::get_bind_address(net::af_e::BOTH); + ASSERT_EQ(bind_addr, "::1"); +} + +/** + * @brief Test that get_bind_address returns configured address regardless of address family + */ +TEST_F(BindAddressTest, ConfiguredAddressOverridesFamily) { + // Set a specific IPv6 address but request IPv4 family + // The configured address should still be returned + config::sunshine.bind_address = "2001:db8::1"; + + const auto bind_addr = net::get_bind_address(net::af_e::IPV4); + ASSERT_EQ(bind_addr, "2001:db8::1"); +} + +/** + * @brief Test with loopback addresses + */ +TEST_F(BindAddressTest, LoopbackAddresses) { + // Test IPv4 loopback + config::sunshine.bind_address = "127.0.0.1"; + const auto bind_addr_v4 = net::get_bind_address(net::af_e::IPV4); + ASSERT_EQ(bind_addr_v4, "127.0.0.1"); + + // Test IPv6 loopback + config::sunshine.bind_address = "::1"; + const auto bind_addr_v6 = net::get_bind_address(net::af_e::BOTH); + ASSERT_EQ(bind_addr_v6, "::1"); +} + +/** + * @brief Test with link-local addresses + */ +TEST_F(BindAddressTest, LinkLocalAddresses) { + // Test IPv4 link-local + config::sunshine.bind_address = "169.254.1.1"; + const auto bind_addr_v4 = net::get_bind_address(net::af_e::IPV4); + ASSERT_EQ(bind_addr_v4, "169.254.1.1"); + + // Test IPv6 link-local + config::sunshine.bind_address = "fe80::1"; + const auto bind_addr_v6 = net::get_bind_address(net::af_e::BOTH); + ASSERT_EQ(bind_addr_v6, "fe80::1"); +} + +/** + * @brief Test that af_to_any_address_string still works correctly + */ +TEST_F(BindAddressTest, WildcardAddressFunction) { + ASSERT_EQ(net::af_to_any_address_string(net::af_e::IPV4), "0.0.0.0"); + ASSERT_EQ(net::af_to_any_address_string(net::af_e::BOTH), "::"); +}