feat(network): allow binding to specific interface (#4481)

This commit is contained in:
David Lane
2025-12-23 13:08:12 -05:00
committed by GitHub
parent 1fa7457eab
commit 0aa7e3fd67
13 changed files with 205 additions and 9 deletions

View File

@ -1502,6 +1502,46 @@ editing the `conf` file in a text editor. Use the examples as reference.
</tr>
</table>
### bind_address
<table>
<tr>
<td>Description</td>
<td colspan="2">
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).
<br><br>
<strong>Note:</strong> 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".
</td>
</tr>
<tr>
<td>Default</td>
<td colspan="2">@code{}
(empty - bind to all interfaces)
@endcode</td>
</tr>
<tr>
<td>Example (IPv4)</td>
<td colspan="2">@code{}
bind_address = 192.168.1.100
@endcode</td>
</tr>
<tr>
<td>Example (IPv6)</td>
<td colspan="2">@code{}
bind_address = 2001:db8::1
@endcode</td>
</tr>
<tr>
<td>Example (Loopback)</td>
<td colspan="2">@code{}
bind_address = 127.0.0.1
@endcode</td>
</tr>
</table>
### port
<table>

View File

@ -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);

View File

@ -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;

View File

@ -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) {

View File

@ -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

View File

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

View File

@ -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<SimpleWeb::HTTP>;
@ -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) {

View File

@ -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;
}

View File

@ -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();

View File

@ -191,6 +191,7 @@
options: {
"upnp": "disabled",
"address_family": "ipv4",
"bind_address": "",
"port": 47989,
"origin_web_ui_allowed": "lan",
"external_ip": "",

View File

@ -33,6 +33,13 @@ const effectivePort = computed(() => +config.value?.port ?? defaultMoonlightPort
<div class="form-text">{{ $t('config.address_family_desc') }}</div>
</div>
<!-- Bind address -->
<div class="mb-3">
<label for="bind_address" class="form-label">{{ $t('config.bind_address') }}</label>
<input type="text" class="form-control" id="bind_address" v-model="config.bind_address" />
<div class="form-text">{{ $t('config.bind_address_desc') }}</div>
</div>
<!-- Port family -->
<div class="mb-3">
<label for="port" class="form-label">{{ $t('config.port') }}</label>

View File

@ -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",

View File

@ -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), "::");
}