fix(mdns): advertise only served addresses on Linux (#5914)

This commit is contained in:
Daniel Bae authored and GitHub committed 2026-10-15 23:51:44 -04:00
1 parent 55ff34e21b
commit 3d890eadc4
3 files changed
+1417 -149

No files matched your search

+375 -149
View File
@@ -4,10 +4,28 @@
* @note Adapted from https://www.avahi.org/doxygen/html/client-publish-service_8c-example.html * @note Adapted from https://www.avahi.org/doxygen/html/client-publish-service_8c-example.html
*/ */
// standard includes // standard includes
#include <array>
#include <cstddef>
#include <cstring>
#include <ifaddrs.h>
#ifdef __linux__
// Netlink is a Linux-kernel API: these headers exist only on Linux.
#include <linux/netlink.h>
#include <linux/rtnetlink.h>
#endif
#include <net/if.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <thread> #include <thread>
#include <unistd.h>
#include <utility>
// local includes // local includes
#include "misc.h" #include "misc.h"
#include "publish.h"
#include "src/config.h"
#include "src/logging.h" #include "src/logging.h"
#include "src/network.h" #include "src/network.h"
#include "src/nvhttp.h" #include "src/nvhttp.h"
@@ -87,17 +105,6 @@ namespace avahi {
ERR_MAX = -54 ///< Sentinel below the valid Avahi error-code range; not an error code ERR_MAX = -54 ///< Sentinel below the valid Avahi error-code range; not an error code
}; };
constexpr auto IF_UNSPEC = -1; ///< Select all network interfaces instead of a specific interface index.
/**
* @brief Avahi address-family selectors.
*/
enum proto {
PROTO_INET = 0, ///< IPv4
PROTO_INET6 = 1, ///< IPv6
PROTO_UNSPEC = -1 ///< Unspecified/all protocol(s)
};
/** /**
* @brief Avahi daemon lifecycle states. * @brief Avahi daemon lifecycle states.
*/ */
@@ -139,34 +146,8 @@ namespace avahi {
CLIENT_NO_FAIL = 2 ///< Don't fail if the daemon is not available when avahi_client_new() is called, instead enter CLIENT_CONNECTING state and wait for the daemon to appear CLIENT_NO_FAIL = 2 ///< Don't fail if the daemon is not available when avahi_client_new() is called, instead enter CLIENT_CONNECTING state and wait for the daemon to appear
}; };
/**
* @brief Flags for publishing functions.
*/
enum PublishFlags {
PUBLISH_UNIQUE = 1, ///< For raw records: The RRset is intended to be unique
PUBLISH_NO_PROBE = 2, ///< For raw records: Though the RRset is intended to be unique no probes shall be sent
PUBLISH_NO_ANNOUNCE = 4, ///< For raw records: Do not announce this RR to other hosts
PUBLISH_ALLOW_MULTIPLE = 8, ///< For raw records: Allow multiple local records of this type, even if they are intended to be unique
PUBLISH_NO_REVERSE = 16, ///< For address records: don't create a reverse (PTR) entry
PUBLISH_NO_COOKIE = 32, ///< For service records: do not implicitly add the local service cookie to TXT data
PUBLISH_UPDATE = 64, ///< Update existing records instead of adding new ones
PUBLISH_USE_WIDE_AREA = 128, ///< Register the record using wide area DNS (i.e. unicast DNS update)
PUBLISH_USE_MULTICAST = 256 ///< Register the record using multicast DNS
};
/**
* @brief Network interface index type used by Avahi.
*/
using IfIndex = int;
/**
* @brief Avahi protocol/address-family selector type.
*/
using Protocol = int;
struct EntryGroup;
struct Poll; struct Poll;
struct SimplePoll; struct SimplePoll;
struct Client;
/** /**
* @brief Avahi client state-change callback signature. * @brief Avahi client state-change callback signature.
@@ -177,114 +158,93 @@ namespace avahi {
*/ */
using EntryGroupCallback = void (*)(EntryGroup *g, EntryGroupState state, void *userdata); using EntryGroupCallback = void (*)(EntryGroup *g, EntryGroupState state, void *userdata);
/**
* @brief Function pointer used to free Avahi-allocated memory.
*/
using free_fn = void (*)(void *);
/** /**
* @brief Function pointer used to create an Avahi client. * @brief Function pointer used to create an Avahi client.
*/ */
using client_new_fn = Client *(*)(const Poll *poll_api, ClientFlags flags, ClientCallback callback, void *userdata, int *error); using client_new_fn = Client *(*)(const Poll *poll_api, ClientFlags flags, ClientCallback callback, void *userdata, int *error);
/**
* @brief Function pointer used to destroy an Avahi client.
*/
using client_free_fn = void (*)(Client *);
/**
* @brief Function pointer used to request an alternative Avahi service name.
*/
using alternative_service_name_fn = char *(*)(char *);
/**
* @brief Function pointer used to get an Avahi client from an entry group.
*/
using entry_group_get_client_fn = Client *(*)(EntryGroup *);
/** /**
* @brief Function pointer used to create an Avahi entry group. * @brief Function pointer used to create an Avahi entry group.
*/ */
using entry_group_new_fn = EntryGroup *(*)(Client *, EntryGroupCallback, void *userdata); using entry_group_new_fn = EntryGroup *(*)(Client *, EntryGroupCallback, void *userdata);
/**
* @brief Function pointer used to add a service to an Avahi entry group.
*/
using entry_group_add_service_fn = int (*)(
EntryGroup *group,
IfIndex interface,
Protocol protocol,
PublishFlags flags,
const char *name,
const char *type,
const char *domain,
const char *host,
uint16_t port,
...
);
/**
* @brief Function pointer used to test whether an Avahi entry group is empty.
*/
using entry_group_is_empty_fn = int (*)(EntryGroup *);
/**
* @brief Function pointer used to reset an Avahi entry group.
*/
using entry_group_reset_fn = int (*)(EntryGroup *);
/**
* @brief Function pointer used to publish an Avahi entry group.
*/
using entry_group_commit_fn = int (*)(EntryGroup *);
/** /**
* @brief Function pointer used to duplicate Avahi strings. * @brief Function pointer used to duplicate Avahi strings.
*/ */
using strdup_fn = char *(*)(const char *); using strdup_fn = char *(*)(const char *);
/**
* @brief Function pointer used to format Avahi error codes.
*/
using strerror_fn = char *(*)(int);
/**
* @brief Function pointer used to read the last Avahi client error.
*/
using client_errno_fn = int (*)(Client *);
/**
* @brief Function pointer used to get the Avahi simple-poll API.
*/
using simple_poll_get_fn = Poll *(*)(SimplePoll *);
/** /**
* @brief Function pointer used to run the Avahi simple-poll loop. * @brief Function pointer used to run the Avahi simple-poll loop.
*/ */
using simple_poll_loop_fn = int (*)(SimplePoll *); using simple_poll_loop_fn = int (*)(SimplePoll *);
/**
* @brief Function pointer used to stop the Avahi simple-poll loop.
*/
using simple_poll_quit_fn = void (*)(SimplePoll *);
/** /**
* @brief Function pointer used to allocate an Avahi simple-poll instance. * @brief Function pointer used to allocate an Avahi simple-poll instance.
*/ */
using simple_poll_new_fn = SimplePoll *(*)(); using simple_poll_new_fn = SimplePoll *(*)();
/**
* @brief Function pointer used to free an Avahi simple-poll instance. client_new_fn client_new; ///< Client new.
*/ entry_group_new_fn entry_group_new; ///< Entry group new.
using simple_poll_free_fn = void (*)(SimplePoll *); strdup_fn strdup; ///< Strdup.
simple_poll_loop_fn simple_poll_loop; ///< Simple poll loop.
simple_poll_new_fn simple_poll_new; ///< Simple poll new.
free_fn free; ///< Free. free_fn free; ///< Free.
client_new_fn client_new; ///< Client new.
client_free_fn client_free; ///< Client free. client_free_fn client_free; ///< Client free.
simple_poll_free_fn simple_poll_free; ///< Simple poll free.
alternative_service_name_fn alternative_service_name; ///< Alternative service name. alternative_service_name_fn alternative_service_name; ///< Alternative service name.
entry_group_get_client_fn entry_group_get_client; ///< Entry group get client. entry_group_get_client_fn entry_group_get_client; ///< Entry group get client.
entry_group_new_fn entry_group_new; ///< Entry group new.
entry_group_add_service_fn entry_group_add_service; ///< Entry group add service. entry_group_add_service_fn entry_group_add_service; ///< Entry group add service.
entry_group_add_address_fn entry_group_add_address; ///< Entry group add address.
entry_group_is_empty_fn entry_group_is_empty; ///< Entry group is empty. entry_group_is_empty_fn entry_group_is_empty; ///< Entry group is empty.
entry_group_reset_fn entry_group_reset; ///< Entry group reset. entry_group_reset_fn entry_group_reset; ///< Entry group reset.
entry_group_commit_fn entry_group_commit; ///< Entry group commit. entry_group_commit_fn entry_group_commit; ///< Entry group commit.
strdup_fn strdup; ///< Strdup.
strerror_fn strerror; ///< Strerror. strerror_fn strerror; ///< Strerror.
client_errno_fn client_errno; ///< Client errno. client_errno_fn client_errno; ///< Client errno.
simple_poll_get_fn simple_poll_get; ///< Simple poll get.
simple_poll_loop_fn simple_poll_loop; ///< Simple poll loop.
simple_poll_quit_fn simple_poll_quit; ///< Simple poll quit. simple_poll_quit_fn simple_poll_quit; ///< Simple poll quit.
simple_poll_new_fn simple_poll_new; ///< Simple poll new. simple_poll_get_fn simple_poll_get; ///< Simple poll get.
simple_poll_free_fn simple_poll_free; ///< Simple poll free.
/**
* @brief Event types for Avahi poll watches.
* @details Layout-compatible with `AvahiWatchEvent` (see
* avahi-common/watch.h): the values are the `poll(2)` event flags.
* Scoped enum with an explicit `int` underlying type, so the layout
* stays identical to the C enum.
*/
enum class WatchEvent : int {
WATCH_IN = POLLIN, ///< Input event
WATCH_OUT = POLLOUT, ///< Output event
WATCH_ERR = POLLERR, ///< Error event
WATCH_HUP = POLLHUP ///< Hangup event
};
struct Watch;
struct Timeout;
/**
* @brief Avahi poll-watch callback signature.
*/
using WatchCallback = void (*)(Watch *w, int fd, WatchEvent event, void *userdata);
/**
* @brief Avahi poll-timeout callback signature.
*/
using TimeoutCallback = void (*)(Timeout *t, void *userdata);
/**
* @brief Abstracted event polling API.
* @details Layout-compatible with `AvahiPoll` (see avahi-common/watch.h):
* a user-data pointer followed by the watch and timeout operations. Only
* the watch operations are used here.
*/
struct Poll {
void *userdata; ///< User data for the poll provider.
Watch *(*watch_new)(const Poll *api, int fd, WatchEvent event, WatchCallback callback, void *userdata); ///< Create a watch.
void (*watch_update)(Watch *w, WatchEvent event); ///< Update a watch's events.
WatchEvent (*watch_get_events)(Watch *w); ///< Events that happened on a watch.
void (*watch_free)(Watch *w); ///< Free a watch.
Timeout *(*timeout_new)(const Poll *api, const timeval *tv, TimeoutCallback callback, void *userdata); ///< Create a timeout.
void (*timeout_update)(Timeout *t, const timeval *tv); ///< Update a timeout.
void (*timeout_free)(Timeout *t); ///< Free a timeout.
};
/** /**
* @brief Load Avahi common-library memory, naming, error, and simple-poll functions. * @brief Load Avahi common-library memory, naming, error, and simple-poll functions.
@@ -355,6 +315,7 @@ namespace avahi {
{(dyn::apiproc *) &entry_group_get_client, "avahi_entry_group_get_client"}, {(dyn::apiproc *) &entry_group_get_client, "avahi_entry_group_get_client"},
{(dyn::apiproc *) &entry_group_new, "avahi_entry_group_new"}, {(dyn::apiproc *) &entry_group_new, "avahi_entry_group_new"},
{(dyn::apiproc *) &entry_group_add_service, "avahi_entry_group_add_service"}, {(dyn::apiproc *) &entry_group_add_service, "avahi_entry_group_add_service"},
{(dyn::apiproc *) &entry_group_add_address, "avahi_entry_group_add_address"},
{(dyn::apiproc *) &entry_group_is_empty, "avahi_entry_group_is_empty"}, {(dyn::apiproc *) &entry_group_is_empty, "avahi_entry_group_is_empty"},
{(dyn::apiproc *) &entry_group_reset, "avahi_entry_group_reset"}, {(dyn::apiproc *) &entry_group_reset, "avahi_entry_group_reset"},
{(dyn::apiproc *) &entry_group_commit, "avahi_entry_group_commit"}, {(dyn::apiproc *) &entry_group_commit, "avahi_entry_group_commit"},
@@ -371,30 +332,6 @@ namespace avahi {
namespace platf::publish { namespace platf::publish {
/**
* @brief Release memory allocated by Avahi.
*
* @param p Avahi-allocated pointer to release.
*/
template<class T>
void free(T *p) {
avahi::free(p);
}
/**
* @brief Owning pointer for Avahi allocations released with `avahi_free`.
*/
template<class T>
using ptr_t = util::safe_ptr<T, free<T>>;
/**
* @brief Owning pointer for an Avahi client.
*/
using client_t = util::dyn_safe_ptr<avahi::Client, &avahi::client_free>;
/**
* @brief Owning pointer for an Avahi simple poll loop.
*/
using poll_t = util::dyn_safe_ptr<avahi::SimplePoll, &avahi::simple_poll_free>;
avahi::EntryGroup *group = nullptr; ///< Active Avahi entry group that owns the published service. avahi::EntryGroup *group = nullptr; ///< Active Avahi entry group that owns the published service.
poll_t poll; ///< Avahi poll loop used while the service is published. poll_t poll; ///< Avahi poll loop used while the service is published.
@@ -402,12 +339,121 @@ namespace platf::publish {
ptr_t<char> name; ///< Current service name, updated when Avahi reports a collision. ptr_t<char> name; ///< Current service name, updated when Avahi reports a collision.
/** unsigned host_name_collision_count = 0; ///< Suffix counter for regenerating the service host name after collisions.
* @brief Create or update the Avahi service entry group.
* std::optional<std::vector<ServedAddress>> published_addresses; ///< Committed snapshot; nullopt while withdrawn.
* @param c Avahi client used to allocate and commit the service entry group.
*/ #ifdef __linux__
void create_services(avahi::Client *c); int netlink_fd = -1; ///< Netlink socket watched for interface/address changes, -1 when not installed.
avahi::Watch *netlink_watch = nullptr; ///< Avahi poll watch for the netlink socket, nullptr when not installed.
#endif
/* Enumerate the local interface addresses available for publication. */
std::vector<ServedAddress> enumerate_interface_addresses() {
ifaddrs *interfaces = nullptr;
if (getifaddrs(&interfaces) != 0) {
BOOST_LOG(error) << "Failed to enumerate network interfaces."sv;
return {};
}
auto cleanup = util::fail_guard([&interfaces]() {
freeifaddrs(interfaces);
});
std::vector<ServedAddress> addresses;
for (const auto *ifa = interfaces; ifa != nullptr; ifa = ifa->ifa_next) {
if (ifa->ifa_addr == nullptr || (ifa->ifa_flags & IFF_UP) == 0) {
continue;
}
// The owning interface index scopes the record to the link it is
// reachable on; if_nametoindex() only fails for names that vanished
// mid-enumeration, and those records fall back to IF_UNSPEC.
avahi::IfIndex interface = avahi::IF_UNSPEC;
if (const auto index = if_nametoindex(ifa->ifa_name); index != 0) {
interface = static_cast<avahi::IfIndex>(index);
}
if (ifa->ifa_addr->sa_family == AF_INET) {
const auto *inet_address = reinterpret_cast<const sockaddr_in *>(ifa->ifa_addr);
boost::asio::ip::address_v4::bytes_type bytes {};
std::memcpy(bytes.data(), &inet_address->sin_addr, bytes.size());
addresses.emplace_back(interface, boost::asio::ip::address_v4(bytes));
} else if (ifa->ifa_addr->sa_family == AF_INET6) {
const auto *inet6_address = reinterpret_cast<const sockaddr_in6 *>(ifa->ifa_addr);
boost::asio::ip::address_v6::bytes_type bytes {};
std::memcpy(bytes.data(), &inet6_address->sin6_addr, bytes.size());
addresses.emplace_back(interface, boost::asio::ip::address_v6(bytes, inet6_address->sin6_scope_id));
}
}
return addresses;
}
/* Determine the addresses published for the service host name. */
std::vector<ServedAddress> served_addresses(net::af_e effective_family) {
// An explicit, non-wildcard bind address is, by definition, an address
// Sunshine serves: publish exactly it, with the interface index of the
// interface that owns it when one does.
if (config::sunshine.bind_address.empty()) {
// A wildcard bind serves every local interface address of the effective family.
return select_served_addresses(enumerate_interface_addresses(), effective_family);
}
boost::system::error_code ec;
auto bind_address = boost::asio::ip::make_address(config::sunshine.bind_address, ec);
if (ec) {
BOOST_LOG(warning) << "Configured bind address is not a valid IP address, falling back to interface enumeration: "sv
<< config::sunshine.bind_address;
// An unparsable bind serves every local interface address of the effective family.
return select_served_addresses(enumerate_interface_addresses(), effective_family);
}
bind_address = net::normalize_address(bind_address);
if (bind_address.is_unspecified()) {
// A wildcard bind serves every local interface address of the effective family.
return select_served_addresses(enumerate_interface_addresses(), effective_family);
}
if (effective_family == net::IPV4 && !bind_address.is_v4()) {
return {};
}
const auto enumerated = enumerate_interface_addresses();
const auto owner = std::find_if(
enumerated.begin(),
enumerated.end(),
[&bind_address](const ServedAddress &entry) {
return entry.address == bind_address;
}
);
const auto interface = owner != enumerated.end() ? owner->interface : avahi::IF_UNSPEC;
return {ServedAddress {interface, bind_address}};
}
/* Add address records for the service host name to the entry group. */
int add_service_addresses(
avahi::EntryGroup *entry_group,
const std::string &service_host,
const std::vector<ServedAddress> &addresses
) {
using enum avahi::proto;
for (const auto &address : addresses) {
const auto avahi_address = to_avahi_address(address.address);
const int ret = avahi::entry_group_add_address(
entry_group,
address.interface,
std::to_underlying(PROTO_UNSPEC),
avahi::PUBLISH_NO_REVERSE,
service_host.c_str(),
&avahi_address
);
if (ret < 0) {
BOOST_LOG(error) << "Failed to add avahi address for "sv << service_host << ": "sv << avahi::strerror(ret);
return ret;
}
}
return 0;
}
/** /**
* @brief React to Avahi entry-group state changes. * @brief React to Avahi entry-group state changes.
@@ -427,6 +473,11 @@ namespace platf::publish {
BOOST_LOG(info) << "Avahi service name collision, renaming service to " << name.get(); BOOST_LOG(info) << "Avahi service name collision, renaming service to " << name.get();
// The collision may have been on the service host name rather than
// the service name: regenerate the host name as well so the retry
// does not collide on the same host name again.
++host_name_collision_count;
create_services(avahi::entry_group_get_client(g)); create_services(avahi::entry_group_get_client(g));
break; break;
case avahi::ENTRY_GROUP_FAILURE: case avahi::ENTRY_GROUP_FAILURE:
@@ -438,14 +489,16 @@ namespace platf::publish {
} }
} }
/** /* Publish Sunshine's mDNS service through Avahi. */
* @brief Publish Sunshine's mDNS service through Avahi. void create_services(avahi::Client *c, bool quit_on_failure) {
*/ using enum avahi::proto;
void create_services(avahi::Client *c) {
int ret; int ret;
auto fg = util::fail_guard([]() { auto fg = util::fail_guard([quit_on_failure]() {
avahi::simple_poll_quit(poll.get()); if (quit_on_failure) {
avahi::simple_poll_quit(poll.get());
}
}); });
if (!group) { if (!group) {
@@ -456,17 +509,58 @@ namespace platf::publish {
} }
if (avahi::entry_group_is_empty(group)) { if (avahi::entry_group_is_empty(group)) {
// A fresh registration attempt starts from a withdrawn snapshot: the
// group is empty, so no previously committed snapshot is still live.
published_addresses.reset();
BOOST_LOG(info) << "Adding avahi service "sv << name.get(); BOOST_LOG(info) << "Adding avahi service "sv << name.get();
// Discovery transport stays on both protocols: the transport only
// decides which multicast groups carry the announcement, it does not
// filter the advertised host addresses (see #5860). The filter is the
// service-specific SRV target below: the daemon publishes address
// records for that host name only from the explicit records added
// here, so an IPv4-only configuration advertises no AAAA record even
// though the daemon's shared host name still carries both A and AAAA
// (publish-aaaa-on-ipv4). The effective family accounts for
// bind_address, matching the listener setup. On non-Linux platforms
// there is no address-change watch, so the service instead uses the
// daemon-managed host name (a null SRV target) and the daemon
// maintains the address records.
const auto effective_family = net::get_effective_address_family(
net::af_from_enum_string(config::sunshine.address_family)
);
const auto addresses = served_addresses(effective_family);
// The service-specific host name and its fixed address records are
// Linux-only: only Linux installs the address-change watch that
// refreshes them (see install_address_change_watch()).
const char *service_target = nullptr;
#ifdef __linux__
const auto service_host = service_host_name(platf::get_host_name(), host_name_collision_count);
if (addresses.empty()) {
BOOST_LOG(warning) << "No served addresses determined for "sv << service_host
<< "; falling back to the daemon host name, which may advertise unserved addresses (see #5860).";
} else {
service_target = service_host.c_str();
ret = add_service_addresses(group, service_host, addresses);
if (ret < 0) {
published_addresses.reset();
avahi::entry_group_reset(group);
return;
}
}
#endif
ret = avahi::entry_group_add_service( ret = avahi::entry_group_add_service(
group, group,
avahi::IF_UNSPEC, avahi::IF_UNSPEC,
avahi::PROTO_UNSPEC, std::to_underlying(PROTO_UNSPEC),
avahi::PublishFlags(0), avahi::PublishFlags(0),
name.get(), name.get(),
platf::SERVICE_TYPE, platf::SERVICE_TYPE,
nullptr, nullptr,
nullptr, service_target,
net::map_port(nvhttp::PORT_HTTP), net::map_port(nvhttp::PORT_HTTP),
nullptr nullptr
); );
@@ -477,15 +571,22 @@ namespace platf::publish {
name.reset(avahi::alternative_service_name(name.get())); name.reset(avahi::alternative_service_name(name.get()));
BOOST_LOG(info) << "Service name collision, renaming service to "sv << name.get(); BOOST_LOG(info) << "Service name collision, renaming service to "sv << name.get();
// The collision may have been on the service host name instead:
// regenerate it as well so the retry does not collide again.
++host_name_collision_count;
published_addresses.reset();
avahi::entry_group_reset(group); avahi::entry_group_reset(group);
create_services(c); create_services(c, quit_on_failure);
fg.disable(); fg.disable();
return; return;
} }
BOOST_LOG(error) << "Failed to add "sv << platf::SERVICE_TYPE << " service: "sv << avahi::strerror(ret); BOOST_LOG(error) << "Failed to add "sv << platf::SERVICE_TYPE << " service: "sv << avahi::strerror(ret);
published_addresses.reset();
avahi::entry_group_reset(group);
return; return;
} }
@@ -494,11 +595,112 @@ namespace platf::publish {
BOOST_LOG(error) << "Failed to commit entry group: "sv << avahi::strerror(ret); BOOST_LOG(error) << "Failed to commit entry group: "sv << avahi::strerror(ret);
return; return;
} }
published_addresses = addresses;
} }
fg.disable(); fg.disable();
} }
/* Rebuild the service records after local interface addresses change. */
void refresh_service_addresses() {
if (!client || !group) {
return;
}
const auto effective_family = net::get_effective_address_family(
net::af_from_enum_string(config::sunshine.address_family)
);
const auto current = served_addresses(effective_family);
if (published_addresses && current == *published_addresses) {
return;
}
BOOST_LOG(info) << "Local interface addresses changed, republishing Avahi service."sv;
published_addresses.reset();
avahi::entry_group_reset(group);
// A refresh failure must not kill discovery: the group is already reset
// (records withdrawn), and the next change notification retries, while
// quitting the poll loop here would leave the service withdrawn with no
// recovery short of restarting Sunshine.
create_services(client.get(), false);
}
#ifdef __linux__
/**
* @brief Refresh service publication after an interface/address change.
*
* @param fd Netlink route socket to drain before refreshing the addresses.
*/
void address_change_callback(avahi::Watch * /*watch*/, int fd, avahi::WatchEvent /*event*/, void * /*userdata*/) {
// Drain the netlink socket: a burst of address/link messages must not
// leave the fd readable, or the poll loop would spin on it.
std::array<std::byte, 4096> buffer {};
while (recv(fd, buffer.data(), buffer.size(), MSG_DONTWAIT) > 0) {
// Discard: the snapshot is re-read from the interfaces, not from the message.
}
refresh_service_addresses();
}
#endif
/* Subscribe to interface/address changes on the Avahi poll thread. */
bool install_address_change_watch() {
#ifdef __linux__
if (netlink_watch) {
return true;
}
// No existing network-change notification exists in Sunshine to reuse:
// the only related code is UPnP's WAN-IP renewal, which is unrelated to
// mDNS. A netlink route socket is the kernel's address/link change
// feed; registering it as an Avahi poll watch dispatches the callback
// on the existing poll thread, so no new thread and no cross-thread
// Avahi calls are introduced. Failure degrades to the old behavior
// (no refresh) instead of failing startup.
const int fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC | SOCK_NONBLOCK, NETLINK_ROUTE);
if (fd < 0) {
BOOST_LOG(warning) << "Failed to open netlink socket for address-change notifications, mDNS records will not refresh on network changes."sv;
return false;
}
sockaddr_nl address {};
address.nl_family = AF_NETLINK;
address.nl_groups = RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR;
if (bind(fd, (sockaddr *) &address, sizeof(address)) < 0) {
BOOST_LOG(warning) << "Failed to bind netlink socket for address-change notifications, mDNS records will not refresh on network changes."sv;
close(fd);
return false;
}
const auto *poll_api = avahi::simple_poll_get(poll.get());
if (!poll_api) {
BOOST_LOG(warning) << "Failed to get Avahi poll API for address-change notifications, mDNS records will not refresh on network changes."sv;
close(fd);
return false;
}
auto *watch = poll_api->watch_new(poll_api, fd, avahi::WatchEvent::WATCH_IN, address_change_callback, nullptr);
if (!watch) {
BOOST_LOG(warning) << "Failed to install Avahi watch for address-change notifications, mDNS records will not refresh on network changes."sv;
close(fd);
return false;
}
netlink_fd = fd;
netlink_watch = watch;
return true;
#else
// Netlink is a Linux-kernel API, so no address-change watch exists on
// other platforms. That is safe because create_services() publishes
// the service with a null SRV target there, selecting the Avahi
// daemon-managed host name: the daemon owns the interface address
// records and updates and withdraws them when addresses change, so
// discovery stays correct without a refresh.
return false;
#endif
}
/** /**
* @brief React to Avahi client state changes and register services when ready. * @brief React to Avahi client state changes and register services when ready.
* *
@@ -517,6 +719,9 @@ namespace platf::publish {
case avahi::CLIENT_S_COLLISION: case avahi::CLIENT_S_COLLISION:
case avahi::CLIENT_S_REGISTERING: case avahi::CLIENT_S_REGISTERING:
if (group) { if (group) {
// The daemon withdraws the entries on collision/re-registration,
// so the committed snapshot is no longer live.
published_addresses.reset();
avahi::entry_group_reset(group); avahi::entry_group_reset(group);
} }
break; break;
@@ -552,6 +757,21 @@ namespace platf::publish {
poll_thread.join(); poll_thread.join();
} }
// The poll thread has exited, so no watch callback can be in flight:
// release the address-change watch and its socket now.
#ifdef __linux__
if (netlink_watch) {
if (const auto *poll_api = avahi::simple_poll_get(poll.get()); poll_api) {
poll_api->watch_free(netlink_watch);
}
netlink_watch = nullptr;
}
if (netlink_fd >= 0) {
close(netlink_fd);
netlink_fd = -1;
}
#endif
poll.reset(); poll.reset();
} }
}; };
@@ -583,6 +803,12 @@ namespace platf::publish {
return nullptr; return nullptr;
} }
// Best-effort: without the watch, discovery still works. On Linux the
// records go stale after a network change (the pre-watch behavior);
// on other platforms the daemon-managed host name delegates address
// maintenance to the Avahi daemon, so no refresh is needed.
install_address_change_watch();
return std::make_unique<deinit_t>(std::jthread {[]() { return std::make_unique<deinit_t>(std::jthread {[]() {
avahi::simple_poll_loop(poll.get()); avahi::simple_poll_loop(poll.get());
+402
View File
@@ -0,0 +1,402 @@
/**
* @file src/platform/linux/publish.h
* @brief Declarations for publishing services on Linux (Avahi).
*/
#pragma once
// standard includes
#include <algorithm>
#include <array>
#include <cstdint>
#include <format>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
// local includes
#include "src/network.h"
namespace avahi {
/**
* @brief Enumerates supported proto options.
*/
enum class proto : int {
PROTO_INET = 0, ///< IPv4
PROTO_INET6 = 1, ///< IPv6
PROTO_UNSPEC = -1 ///< Unspecified/all protocol(s)
};
/**
* @brief Avahi protocol/address-family selector type.
*/
using Protocol = int;
/**
* @brief Address record published for a host name.
* @details Layout-compatible with Avahi's `AvahiAddress` (see
* avahi-common/address.h): a protocol selector followed by 16 bytes of
* address data. An IPv4 address occupies the first 4 bytes in network byte
* order and the remaining bytes are zero; an IPv6 address occupies all 16
* bytes.
*/
struct Address {
Protocol proto; ///< Address family of the record.
std::array<std::uint8_t, 16> data; ///< Address bytes, see details above.
/**
* @brief Compare two address records for equality.
* @param other Address record to compare against.
* @return True when the protocol selector and address bytes both match.
*/
bool operator==(const Address &other) const = default;
};
static_assert(sizeof(Address) == sizeof(Protocol) + 16, "avahi::Address must match the AvahiAddress layout");
/**
* @brief Flags for publishing functions.
*/
enum PublishFlags {
PUBLISH_UNIQUE = 1, ///< For raw records: The RRset is intended to be unique
PUBLISH_NO_PROBE = 2, ///< For raw records: Though the RRset is intended to be unique no probes shall be sent
PUBLISH_NO_ANNOUNCE = 4, ///< For raw records: Do not announce this RR to other hosts
PUBLISH_ALLOW_MULTIPLE = 8, ///< For raw records: Allow multiple local records of this type, even if they are intended to be unique
PUBLISH_NO_REVERSE = 16, ///< For address records: don't create a reverse (PTR) entry
PUBLISH_NO_COOKIE = 32, ///< For service records: do not implicitly add the local service cookie to TXT data
PUBLISH_UPDATE = 64, ///< Update existing records instead of adding new ones
PUBLISH_USE_WIDE_AREA = 128, ///< Register the record using wide area DNS (i.e. unicast DNS update)
PUBLISH_USE_MULTICAST = 256 ///< Register the record using multicast DNS
};
/**
* @brief Network interface index type used by Avahi.
*/
using IfIndex = int;
constexpr auto IF_UNSPEC = -1; ///< Protocol or platform constant for if unspec.
struct EntryGroup;
struct Client;
struct Poll;
struct SimplePoll;
/**
* @brief Function pointer used to free Avahi-allocated memory.
*/
using free_fn = void (*)(void *);
/**
* @brief Function pointer used to destroy an Avahi client.
*/
using client_free_fn = void (*)(Client *);
/**
* @brief Function pointer used to free an Avahi simple-poll instance.
*/
using simple_poll_free_fn = void (*)(SimplePoll *);
/**
* @brief Function pointer used to request an alternative Avahi service name.
*/
using alternative_service_name_fn = char *(*)(char *);
/**
* @brief Function pointer used to get an Avahi client from an entry group.
*/
using entry_group_get_client_fn = Client *(*)(EntryGroup *);
/**
* @brief Function pointer used to add a service to an Avahi entry group.
*/
using entry_group_add_service_fn = int (*)(
EntryGroup *group,
IfIndex interface,
Protocol protocol,
PublishFlags flags,
const char *name,
const char *type,
const char *domain,
const char *host,
uint16_t port,
...
);
/**
* @brief Function pointer used to add an address record to an Avahi entry group.
*/
using entry_group_add_address_fn = int (*)(
EntryGroup *group,
IfIndex interface,
Protocol protocol,
PublishFlags flags,
const char *name,
const Address *address
);
/**
* @brief Function pointer used to test whether an Avahi entry group is empty.
*/
using entry_group_is_empty_fn = int (*)(EntryGroup *);
/**
* @brief Function pointer used to reset an Avahi entry group.
*/
using entry_group_reset_fn = int (*)(EntryGroup *);
/**
* @brief Function pointer used to publish an Avahi entry group.
*/
using entry_group_commit_fn = int (*)(EntryGroup *);
/**
* @brief Function pointer used to format Avahi error codes.
*/
using strerror_fn = char *(*)(int);
/**
* @brief Function pointer used to read the last Avahi client error.
*/
using client_errno_fn = int (*)(Client *);
/**
* @brief Function pointer used to stop the Avahi simple-poll loop.
*/
using simple_poll_quit_fn = void (*)(SimplePoll *);
/**
* @brief Function pointer used to get the Avahi simple-poll API.
*/
using simple_poll_get_fn = Poll *(*)(SimplePoll *);
extern free_fn free; ///< Free.
extern client_free_fn client_free; ///< Client free.
extern simple_poll_free_fn simple_poll_free; ///< Simple poll free.
extern alternative_service_name_fn alternative_service_name; ///< Alternative service name.
extern entry_group_get_client_fn entry_group_get_client; ///< Entry group get client.
extern entry_group_add_service_fn entry_group_add_service; ///< Entry group add service.
extern entry_group_add_address_fn entry_group_add_address; ///< Entry group add address.
extern entry_group_is_empty_fn entry_group_is_empty; ///< Entry group is empty.
extern entry_group_reset_fn entry_group_reset; ///< Entry group reset.
extern entry_group_commit_fn entry_group_commit; ///< Entry group commit.
extern strerror_fn strerror; ///< Strerror.
extern client_errno_fn client_errno; ///< Client errno.
extern simple_poll_quit_fn simple_poll_quit; ///< Simple poll quit.
extern simple_poll_get_fn simple_poll_get; ///< Simple poll get.
} // namespace avahi
namespace platf::publish {
/**
* @brief Local address and the interface on which it is reachable.
*/
struct ServedAddress {
avahi::IfIndex interface; ///< Owning interface index.
boost::asio::ip::address address; ///< Local address on that interface.
/**
* @brief Compare two served addresses for equality.
* @param other Served address to compare against.
* @return True when the interface index and address both match.
*/
bool operator==(const ServedAddress &other) const = default;
};
/**
* @brief Release memory allocated by Avahi.
*
* @param p Avahi-allocated pointer to release.
*/
template<class T>
void free(T *p) {
avahi::free(p);
}
/**
* @brief Owning pointer for Avahi allocations released with `avahi_free`.
*/
template<class T>
using ptr_t = util::safe_ptr<T, free<T>>;
/**
* @brief Owning pointer for an Avahi client.
*/
using client_t = util::dyn_safe_ptr<avahi::Client, &avahi::client_free>;
/**
* @brief Owning pointer for an Avahi simple poll loop.
*/
using poll_t = util::dyn_safe_ptr<avahi::SimplePoll, &avahi::simple_poll_free>;
/**
* @brief Build the service-specific host name used as the mDNS SRV target.
* @details The name must differ from the daemon's shared host name: the
* daemon publishes both A and AAAA records for its own host name, so
* pointing the service at it would advertise an IPv6 endpoint an IPv4-only
* configuration does not serve (see #5860). The label is sanitized from
* the machine host name (alphanumerics and dashes; spaces become dashes;
* anything else ends the label), suffixed with `-sunshine` (plus
* `-<collision_suffix>` after a host-name collision), and qualified with
* `.local`.
* @param hostname Machine host name the label is derived from.
* @param collision_suffix Suffix appended after a host-name collision; 0 for the first attempt.
* @return Fully qualified service host name, e.g. `myhost-sunshine.local`.
*/
[[nodiscard]] inline std::string service_host_name(std::string_view hostname, unsigned collision_suffix) {
std::string label;
for (const char c : hostname) {
if (c == ' ') {
label.push_back('-');
} else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {
label.push_back(c);
} else {
break;
}
}
if (label.empty()) {
label = "sunshine";
}
std::string suffix = "-sunshine";
if (collision_suffix > 0) {
suffix += std::format("-{}", collision_suffix);
}
// A DNS label holds at most 63 characters.
if (constexpr std::size_t MAX_LABEL = 63; label.size() + suffix.size() > MAX_LABEL) {
label.resize(MAX_LABEL - suffix.size());
}
return label + suffix + ".local";
}
/**
* @brief Select the addresses published for the service host name.
* @details Keeps only addresses Sunshine serves: the candidate is
* normalized, dropped when it is unspecified or loopback, dropped when its
* family falls outside the effective family, and deduplicated by interface
* plus address, so the same address on two interfaces is published once
* per interface it is reachable on. In particular, an IPv4-only
* configuration selects no IPv6 address, so no AAAA endpoint is published
* for the service host name (see #5860).
* @param candidates Candidate addresses, e.g. from interface enumeration.
* @param effective_family Address family Sunshine actually serves.
* @return Addresses to publish, containing no address outside the effective family.
*/
[[nodiscard]] inline std::vector<ServedAddress> select_served_addresses(
const std::vector<ServedAddress> &candidates,
net::af_e effective_family
) {
std::vector<ServedAddress> selected;
for (auto candidate : candidates) {
candidate.address = net::normalize_address(candidate.address);
if (candidate.address.is_unspecified() || candidate.address.is_loopback()) {
continue;
}
if (effective_family == net::IPV4 && !candidate.address.is_v4()) {
continue;
}
if (std::find(selected.begin(), selected.end(), candidate) == selected.end()) {
selected.push_back(candidate);
}
}
return selected;
}
/**
* @brief Convert a generic address to the Avahi address-record layout.
* @details The address is normalized first, so IPv4-mapped IPv6 addresses
* become plain IPv4 addresses.
* @param address Address to convert.
* @return Avahi-compatible address record for `address`.
*/
[[nodiscard]] inline avahi::Address to_avahi_address(boost::asio::ip::address address) {
address = net::normalize_address(address);
avahi::Address result {};
if (address.is_v4()) {
result.proto = std::to_underlying(avahi::proto::PROTO_INET);
const auto bytes = address.to_v4().to_bytes();
std::ranges::copy(bytes, result.data.begin());
} else {
result.proto = std::to_underlying(avahi::proto::PROTO_INET6);
result.data = address.to_v6().to_bytes();
}
return result;
}
/**
* @brief Enumerate the local interface addresses available for publication.
* @return Interface addresses with their owning interface index, empty when enumeration fails.
*/
[[nodiscard]] std::vector<ServedAddress> enumerate_interface_addresses();
/**
* @brief Determine the addresses published for the service host name.
* @details An explicit, non-wildcard bind address is published exactly as
* configured, with the interface index of the interface that owns it
* (`IF_UNSPEC` when no local interface owns it). A wildcard (or
* unparsable) bind address publishes the selected local interface
* addresses.
* @param effective_family Address family Sunshine actually serves.
* @return Addresses to publish; empty when none can be determined.
*/
[[nodiscard]] std::vector<ServedAddress> served_addresses(net::af_e effective_family);
/**
* @brief Add address records for the service host name to the entry group.
*
* @param entry_group Entry group the address records are added to.
* @param service_host Service host name the address records are published for.
* @param addresses Addresses to publish for the service host name.
* @return 0 when all address records were added; otherwise the Avahi error code from the failed call.
*/
int add_service_addresses(
avahi::EntryGroup *entry_group,
const std::string &service_host,
const std::vector<ServedAddress> &addresses
);
/**
* @brief Create or update the Avahi service entry group.
*
* @param c Avahi client used to allocate and commit the service entry group.
* @param quit_on_failure Quit the Avahi poll loop when registration fails. Initial
* registration keeps the existing fail-fast behavior; a refresh triggered by a
* network change passes false so a transient failure does not kill discovery.
*/
void create_services(avahi::Client *c, bool quit_on_failure = true);
/**
* @brief Rebuild the service records after local interface addresses change.
* @details Compares the current served addresses against the last published
* snapshot and, when they differ, resets the entry group (withdrawing the
* stale records) and republishes. A no-op when the client or group is not
* yet set up, or when the snapshot is unchanged.
*/
void refresh_service_addresses();
/**
* @brief Subscribe to interface/address changes on the Avahi poll thread.
* @details Opens a netlink route socket and registers it as an Avahi poll
* watch, so address/link changes dispatch the refresh on the existing
* poll thread. Best-effort: returns false when the watch cannot be
* installed, and discovery continues without refresh. Linux-only:
* netlink is a Linux-kernel API, so on other platforms this always
* returns false and no watch is installed; the service there uses the
* daemon-managed host name (a null SRV target), so the Avahi daemon
* maintains the address records and no watch is needed.
* @return True when the watch is installed (or was already installed).
*/
bool install_address_change_watch();
#ifdef SUNSHINE_TESTS
// Test seams: registration tests drive the publisher with stubbed Avahi
// calls, so the publisher state they need to set is visible here. These
// declarations exist only in the test build.
extern avahi::EntryGroup *group; ///< Active Avahi entry group that owns the published service.
extern poll_t poll; ///< Avahi poll loop used while the service is published.
extern client_t client; ///< Avahi client used to register the Sunshine service.
extern ptr_t<char> name; ///< Current service name, updated when Avahi reports a collision.
extern unsigned host_name_collision_count; ///< Suffix counter for regenerating the service host name after collisions.
extern std::optional<std::vector<ServedAddress>> published_addresses; ///< Committed snapshot; nullopt while withdrawn.
#endif
} // namespace platf::publish
+640
View File
@@ -0,0 +1,640 @@
/**
* @file tests/unit/test_publish.cpp
* @brief Tests for Linux mDNS (Avahi) service publication.
*/
// The publisher (src/platform/linux/publish.cpp) is built on every
// non-Apple Unix platform (see cmake/compile_definitions/linux.cmake),
// so the stubbed-registration tests below compile wherever it is built.
// The plain unit tests stay Linux-gated; only the fallback regression
// runs on non-Linux, where the address-change watch is unsupported.
#if !defined(_WIN32) && !defined(__APPLE__)
// test includes
#include "../tests_common.h"
// standard includes
#include <array>
#include <cstdlib>
#include <cstring>
#include <string>
#include <utility>
#include <vector>
// local includes
#include <src/platform/linux/publish.h>
#if defined(__linux__)
// Linux-only includes for the interface-enumeration tests.
#include <ifaddrs.h>
#include <net/if.h>
#include <netinet/in.h>
#endif
#if defined(__linux__)
TEST(ServiceHostNameTest, IsDistinctFromDaemonHostName) {
// The SRV target must not be the daemon's shared host name: the daemon
// publishes both A and AAAA records for its own host name, so pointing the
// service at it would advertise an IPv6 endpoint an IPv4-only
// configuration does not serve (see #5860).
ASSERT_EQ(platf::publish::service_host_name("myhost", 0), "myhost-sunshine.local");
}
TEST(ServiceHostNameTest, SanitizesInvalidLabelCharacters) {
ASSERT_EQ(platf::publish::service_host_name("my host.example", 0), "my-host-sunshine.local");
ASSERT_EQ(platf::publish::service_host_name("", 0), "sunshine-sunshine.local");
}
TEST(ServiceHostNameTest, CollisionSuffixChangesName) {
ASSERT_EQ(platf::publish::service_host_name("myhost", 1), "myhost-sunshine-1.local");
ASSERT_NE(platf::publish::service_host_name("myhost", 0), platf::publish::service_host_name("myhost", 1));
}
TEST(ServiceHostNameTest, LabelStaysWithinDnsLimit) {
const auto host_name = platf::publish::service_host_name(std::string(100, 'a'), 0);
// The label (everything before ".local") must fit in 63 characters.
ASSERT_TRUE(host_name.find('.') <= 63);
}
TEST(SelectServedAddressesTest, Ipv4OnlyPublishesNoIpv6) {
// Regression core for #5860: with an IPv4-only configuration the selection
// contains no IPv6 address, so no AAAA endpoint is published for the
// service host name.
const auto selected = platf::publish::select_served_addresses(
{{1, boost::asio::ip::make_address("192.168.1.10")},
{1, boost::asio::ip::make_address("fe80::1")},
{2, boost::asio::ip::make_address("2001:db8::1")}},
net::IPV4
);
ASSERT_EQ(selected.size(), 1U);
ASSERT_EQ(selected[0].interface, 1);
ASSERT_EQ(selected[0].address, boost::asio::ip::make_address("192.168.1.10"));
}
TEST(SelectServedAddressesTest, BothPublishesBothFamilies) {
const auto selected = platf::publish::select_served_addresses(
{{1, boost::asio::ip::make_address("192.168.1.10")}, {2, boost::asio::ip::make_address("2001:db8::1")}},
net::BOTH
);
ASSERT_EQ(selected.size(), 2U);
ASSERT_EQ(selected[0].interface, 1);
ASSERT_EQ(selected[1].interface, 2);
}
TEST(SelectServedAddressesTest, DropsUnusableAndDuplicateAddresses) {
const auto selected = platf::publish::select_served_addresses(
{{1, boost::asio::ip::make_address("127.0.0.1")},
{1, boost::asio::ip::make_address("0.0.0.0")},
{1, boost::asio::ip::make_address("::1")},
{1, boost::asio::ip::make_address("192.168.1.10")},
{1, boost::asio::ip::make_address("192.168.1.10")},
{1, boost::asio::ip::make_address("::ffff:192.168.1.10")}},
net::BOTH
);
ASSERT_EQ(selected.size(), 1U);
ASSERT_EQ(selected[0].address, boost::asio::ip::make_address("192.168.1.10"));
}
TEST(SelectServedAddressesTest, DeduplicatesByInterfaceAndAddress) {
// The same address on two interfaces is reachable on both, so it is kept
// once per interface; the same interface-plus-address pair is kept once.
// A v4-mapped duplicate normalizes to the same IPv4 address.
const auto selected = platf::publish::select_served_addresses(
{{1, boost::asio::ip::make_address("192.168.1.10")},
{1, boost::asio::ip::make_address("192.168.1.10")},
{2, boost::asio::ip::make_address("192.168.1.10")},
{1, boost::asio::ip::make_address("::ffff:192.168.1.10")}},
net::BOTH
);
ASSERT_EQ(selected.size(), 2U);
ASSERT_EQ(selected[0], (platf::publish::ServedAddress {1, boost::asio::ip::make_address("192.168.1.10")}));
ASSERT_EQ(selected[1], (platf::publish::ServedAddress {2, boost::asio::ip::make_address("192.168.1.10")}));
}
TEST(ServedAddressesTest, ExplicitBindResolvesOwningInterface) {
// An explicit bind address is published with the interface index of the
// interface that owns it, not IF_UNSPEC.
const auto enumerated = platf::publish::enumerate_interface_addresses();
if (enumerated.empty()) {
GTEST_SKIP() << "No local interface addresses to resolve a bind address against.";
}
const auto &entry = enumerated[0];
const auto saved_bind = config::sunshine.bind_address;
config::sunshine.bind_address = entry.address.to_string();
const auto served = platf::publish::served_addresses(net::BOTH);
config::sunshine.bind_address = saved_bind;
ASSERT_EQ(served.size(), 1U);
ASSERT_EQ(served[0], entry);
}
TEST(ServedAddressesTest, ExplicitBindWithoutOwnerFallsBackToUnspec) {
// A bind address no local interface owns is still published exactly as
// configured, with IF_UNSPEC.
const auto saved_bind = config::sunshine.bind_address;
// TEST-NET-1 (RFC 5737) is never assigned to a real interface, so no
// local interface can own it.
config::sunshine.bind_address = "192.0.2.10";
const auto served = platf::publish::served_addresses(net::BOTH);
config::sunshine.bind_address = saved_bind;
ASSERT_EQ(served.size(), 1U);
ASSERT_EQ(served[0].interface, avahi::IF_UNSPEC);
ASSERT_EQ(served[0].address, boost::asio::ip::make_address("192.0.2.10"));
}
TEST(ServedAddressesTest, ExplicitBindScopedLinkLocalResolvesOwningInterface) {
// A scoped link-local bind must resolve to the interface that owns it:
// getifaddrs() reports the zone (sin6_scope_id), and the enumeration must
// preserve it, or the scoped bind never equals its enumerated owner and
// the address falls back to IF_UNSPEC, advertising the link-local
// endpoint on interfaces where Sunshine does not serve it.
ifaddrs *interfaces = nullptr;
if (getifaddrs(&interfaces) != 0) {
GTEST_SKIP() << "getifaddrs() failed; cannot find a scoped IPv6 address.";
}
std::string scoped_bind;
avahi::IfIndex expected_interface = avahi::IF_UNSPEC;
for (const auto *ifa = interfaces; ifa != nullptr; ifa = ifa->ifa_next) {
if (ifa->ifa_addr == nullptr || ifa->ifa_addr->sa_family != AF_INET6) {
continue;
}
const auto *inet6_address = reinterpret_cast<const sockaddr_in6 *>(ifa->ifa_addr);
if (inet6_address->sin6_scope_id == 0) {
continue;
}
boost::asio::ip::address_v6::bytes_type bytes {};
std::memcpy(bytes.data(), &inet6_address->sin6_addr, bytes.size());
const auto address = boost::asio::ip::address_v6(bytes, inet6_address->sin6_scope_id);
const auto index = if_nametoindex(ifa->ifa_name);
if (index == 0) {
continue;
}
scoped_bind = address.to_string();
expected_interface = static_cast<avahi::IfIndex>(index);
break;
}
freeifaddrs(interfaces);
if (scoped_bind.empty()) {
GTEST_SKIP() << "No scoped IPv6 interface address to resolve a bind address against.";
}
const auto saved_bind = config::sunshine.bind_address;
config::sunshine.bind_address = scoped_bind;
const auto served = platf::publish::served_addresses(net::BOTH);
config::sunshine.bind_address = saved_bind;
ASSERT_EQ(served.size(), 1U);
ASSERT_EQ(served[0].interface, expected_interface);
ASSERT_EQ(served[0].address, boost::asio::ip::make_address(scoped_bind));
}
TEST(ToAvahiAddressTest, Ipv4EncodesNetworkOrderBytes) {
const auto address = platf::publish::to_avahi_address(boost::asio::ip::make_address("192.168.1.10"));
ASSERT_EQ(address.proto, std::to_underlying(avahi::proto::PROTO_INET));
ASSERT_EQ(address.data, (std::array<std::uint8_t, 16> {192, 168, 1, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}));
}
TEST(ToAvahiAddressTest, Ipv6EncodesAllSixteenBytes) {
const auto address = platf::publish::to_avahi_address(boost::asio::ip::make_address("2001:db8::1"));
ASSERT_EQ(address.proto, std::to_underlying(avahi::proto::PROTO_INET6));
ASSERT_EQ(
address.data,
(std::array<std::uint8_t, 16> {0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01})
);
}
#endif // defined(__linux__)
namespace {
/**
* @brief Captured Avahi entry-group calls for registration tests.
*/
struct AddressCall {
avahi::IfIndex interface; ///< Interface index the record was published on.
avahi::PublishFlags flags; ///< Flags the record was published with.
avahi::Address address; ///< Address record published.
};
std::vector<AddressCall> g_address_calls; ///< Address-publication calls captured by the stub.
std::vector<std::string> g_service_hosts; ///< SRV-target host names captured by the stub.
int g_reset_count = 0; ///< Entry-group resets captured by the stub.
int g_commit_count = 0; ///< Entry-group commits captured by the stub.
int g_quit_count = 0; ///< Poll-loop quits captured by the stub.
bool g_group_empty = true; ///< Entry-group emptiness modeled by the stub: reset empties, commit fills.
bool g_add_service_collide_once = false; ///< Next add-service call reports a collision.
bool g_add_address_fail_once = false; ///< Next add-address call reports a failure.
/**
* @brief Avahi error code for a name collision (see `avahi::err_e`).
*/
constexpr int kErrCollision = -8;
void FakeFree(void *p) {
std::free(p);
}
void FakeClientFree(avahi::Client * /*client*/) {
// The test client is a fake pointer; nothing to release.
}
char *FakeAlternativeServiceName(char *name) {
return ::strdup(name);
}
char *FakeStrerror(int /*error*/) {
return const_cast<char *>("stub Avahi error");
}
int FakeClientErrno(avahi::Client * /*client*/) {
return 0;
}
void FakeSimplePollQuit(avahi::SimplePoll * /*poll*/) {
++g_quit_count;
}
int FakeEntryGroupIsEmpty(avahi::EntryGroup * /*group*/) {
return g_group_empty ? 1 : 0;
}
int FakeEntryGroupReset(avahi::EntryGroup * /*group*/) {
++g_reset_count;
g_group_empty = true;
return 0;
}
int FakeEntryGroupCommit(avahi::EntryGroup * /*group*/) {
++g_commit_count;
g_group_empty = false;
return 0;
}
int FakeEntryGroupAddService(
avahi::EntryGroup * /*group*/,
avahi::IfIndex /*interface*/,
avahi::Protocol /*protocol*/,
avahi::PublishFlags /*flags*/,
const char * /*name*/,
const char * /*type*/,
const char * /*domain*/,
const char *host,
std::uint16_t /*port*/,
...
) {
g_service_hosts.emplace_back(host ? host : "");
if (g_add_service_collide_once) {
g_add_service_collide_once = false;
return kErrCollision;
}
return 0;
}
/**
* @brief Stub address publication that models Avahi's reverse-PTR conflict.
* @details With the daemon's default `publish-addresses=yes`, the daemon
* already owns the reverse PTR for each local IP. Publishing the same IP
* for the service host without `PUBLISH_NO_REVERSE` creates a competing
* unique reverse PTR and Avahi rejects it with `ERR_COLLISION`.
*/
int FakeEntryGroupAddAddress(
avahi::EntryGroup * /*group*/,
avahi::IfIndex interface,
avahi::Protocol /*protocol*/,
avahi::PublishFlags flags,
const char * /*name*/,
const avahi::Address *address
) {
if ((flags & avahi::PUBLISH_NO_REVERSE) == 0) {
return kErrCollision;
}
g_address_calls.push_back({interface, flags, *address});
return 0;
}
/**
* @brief Stub address publication without the reverse-PTR conflict check.
* @details The F2/F3 tests install this stub so their fail-before
* evidence isolates their own finding: with the strict stub, the missing
* `PUBLISH_NO_REVERSE` flag (F1) would fail those tests first. Linux-only:
* only the Linux-gated registration tests install it.
*/
#if defined(__linux__)
int FakeEntryGroupAddAddressLenient(
avahi::EntryGroup * /*group*/,
avahi::IfIndex interface,
avahi::Protocol /*protocol*/,
avahi::PublishFlags flags,
const char * /*name*/,
const avahi::Address *address
) {
if (g_add_address_fail_once) {
g_add_address_fail_once = false;
return -1;
}
g_address_calls.push_back({interface, flags, *address});
return 0;
}
#endif
/**
* @brief Fixture driving the publisher with stubbed Avahi calls.
*/
class PublishRegistrationTest: public ::testing::Test {
protected:
void SetUp() override {
saved_bind_address_ = config::sunshine.bind_address;
saved_address_family_ = config::sunshine.address_family;
avahi::free = FakeFree;
avahi::client_free = FakeClientFree;
avahi::alternative_service_name = FakeAlternativeServiceName;
avahi::strerror = FakeStrerror;
avahi::client_errno = FakeClientErrno;
avahi::simple_poll_quit = FakeSimplePollQuit;
avahi::entry_group_is_empty = FakeEntryGroupIsEmpty;
avahi::entry_group_reset = FakeEntryGroupReset;
avahi::entry_group_commit = FakeEntryGroupCommit;
avahi::entry_group_add_service = FakeEntryGroupAddService;
avahi::entry_group_add_address = FakeEntryGroupAddAddress;
g_address_calls.clear();
g_service_hosts.clear();
g_reset_count = 0;
g_commit_count = 0;
g_quit_count = 0;
g_group_empty = true;
g_add_service_collide_once = false;
g_add_address_fail_once = false;
platf::publish::group = reinterpret_cast<avahi::EntryGroup *>(0x1000);
platf::publish::client.reset(reinterpret_cast<avahi::Client *>(0x2000));
platf::publish::name.reset(::strdup("test-host"));
platf::publish::host_name_collision_count = 0;
platf::publish::published_addresses.reset();
}
void TearDown() override {
// Release owned state through the stubs before uninstalling them:
// the deleters call the stubbed Avahi free functions.
platf::publish::client.reset();
platf::publish::name.reset();
platf::publish::group = nullptr;
platf::publish::host_name_collision_count = 0;
platf::publish::published_addresses.reset();
avahi::free = nullptr;
avahi::client_free = nullptr;
avahi::alternative_service_name = nullptr;
avahi::strerror = nullptr;
avahi::client_errno = nullptr;
avahi::simple_poll_quit = nullptr;
avahi::entry_group_is_empty = nullptr;
avahi::entry_group_reset = nullptr;
avahi::entry_group_commit = nullptr;
avahi::entry_group_add_service = nullptr;
avahi::entry_group_add_address = nullptr;
config::sunshine.bind_address = saved_bind_address_;
config::sunshine.address_family = saved_address_family_;
}
std::string saved_bind_address_;
std::string saved_address_family_;
};
} // namespace
#if defined(__linux__)
TEST_F(PublishRegistrationTest, AddressPublicationSkipsReversePtr) {
// The service-host alias must publish forward records only: with flags 0
// the daemon rejects the competing reverse PTR with ERR_COLLISION, the
// group resets, and the poll loop quits before registration.
const auto ret = platf::publish::add_service_addresses(
platf::publish::group,
"myhost-sunshine.local",
{{1, boost::asio::ip::make_address("192.168.1.10")}}
);
ASSERT_EQ(ret, 0);
ASSERT_EQ(g_address_calls.size(), 1U);
ASSERT_NE(g_address_calls[0].flags & avahi::PUBLISH_NO_REVERSE, 0);
ASSERT_EQ(g_quit_count, 0);
}
TEST_F(PublishRegistrationTest, AddressPublicationUsesOwningInterface) {
// Lenient stub: isolate the interface finding from the flag finding (F1).
avahi::entry_group_add_address = FakeEntryGroupAddAddressLenient;
// A LAN address and a Docker-bridge address must each be published on
// their owning interface, including distinct link-local IPv6 addresses:
// publishing everything with IF_UNSPEC would hand LAN clients the
// bridge address (and vice versa) under the same SRV target.
const auto ret = platf::publish::add_service_addresses(
platf::publish::group,
"myhost-sunshine.local",
{{1, boost::asio::ip::make_address("192.168.1.10")},
{2, boost::asio::ip::make_address("172.17.0.1")},
{1, boost::asio::ip::make_address("fe80::1")},
{2, boost::asio::ip::make_address("fe80::2")}}
);
ASSERT_EQ(ret, 0);
ASSERT_EQ(g_address_calls.size(), 4U);
ASSERT_EQ(g_address_calls[0].interface, 1);
ASSERT_EQ(g_address_calls[1].interface, 2);
ASSERT_EQ(g_address_calls[2].interface, 1);
ASSERT_EQ(g_address_calls[3].interface, 2);
ASSERT_EQ(g_address_calls[0].address, platf::publish::to_avahi_address(boost::asio::ip::make_address("192.168.1.10")));
ASSERT_EQ(g_address_calls[1].address, platf::publish::to_avahi_address(boost::asio::ip::make_address("172.17.0.1")));
ASSERT_EQ(g_address_calls[2].address, platf::publish::to_avahi_address(boost::asio::ip::make_address("fe80::1")));
ASSERT_EQ(g_address_calls[3].address, platf::publish::to_avahi_address(boost::asio::ip::make_address("fe80::2")));
}
TEST_F(PublishRegistrationTest, RefreshRepublishesOnAddressChange) {
// Lenient stub: isolate the refresh finding from the flag finding (F1).
avahi::entry_group_add_address = FakeEntryGroupAddAddressLenient;
// Register .10, change the snapshot to .20, deliver the change
// notification: the group must be reset (withdrawing .10) and the new
// snapshot committed, with no stale .10 record and the poll loop alive.
config::sunshine.bind_address = "192.168.1.10";
config::sunshine.address_family = "both";
platf::publish::create_services(reinterpret_cast<avahi::Client *>(0x2000));
ASSERT_EQ(g_commit_count, 1);
ASSERT_EQ(g_quit_count, 0);
ASSERT_EQ(g_address_calls.size(), 1U);
ASSERT_EQ(g_address_calls[0].address, platf::publish::to_avahi_address(boost::asio::ip::make_address("192.168.1.10")));
config::sunshine.bind_address = "192.168.1.20";
platf::publish::refresh_service_addresses();
ASSERT_EQ(g_reset_count, 1);
ASSERT_EQ(g_commit_count, 2);
ASSERT_EQ(g_quit_count, 0);
ASSERT_EQ(g_address_calls.size(), 2U);
ASSERT_EQ(g_address_calls[1].address, platf::publish::to_avahi_address(boost::asio::ip::make_address("192.168.1.20")));
// A second refresh with an unchanged snapshot is a no-op: no churn.
platf::publish::refresh_service_addresses();
ASSERT_EQ(g_reset_count, 1);
ASSERT_EQ(g_commit_count, 2);
}
TEST_F(PublishRegistrationTest, RefreshCollisionRetryDoesNotQuitPollLoop) {
// A service collision during a refresh retry must not quit the poll
// loop: the retry inherits the refresh's no-quit-on-failure behavior,
// and the service is re-registered under a new host name.
avahi::entry_group_add_address = FakeEntryGroupAddAddressLenient;
config::sunshine.bind_address = "192.168.1.10";
config::sunshine.address_family = "both";
platf::publish::create_services(reinterpret_cast<avahi::Client *>(0x2000));
ASSERT_EQ(g_commit_count, 1);
ASSERT_EQ(g_service_hosts.size(), 1U);
config::sunshine.bind_address = "192.168.1.20";
g_add_service_collide_once = true;
platf::publish::refresh_service_addresses();
ASSERT_EQ(g_quit_count, 0);
ASSERT_EQ(g_commit_count, 2);
// Three service registrations: the initial one, the refresh attempt that
// collided, and the retry under the regenerated host name.
ASSERT_EQ(g_service_hosts.size(), 3U);
ASSERT_EQ(g_service_hosts[1], g_service_hosts[0]);
ASSERT_NE(g_service_hosts[2], g_service_hosts[0]);
ASSERT_EQ(platf::publish::host_name_collision_count, 1U);
}
TEST_F(PublishRegistrationTest, RefreshFailureThenRestoreRepublishes) {
// A -> failed B -> A: the failed refresh withdraws the group and must
// invalidate the snapshot, so restoring A republishes instead of
// early-returning on a stale snapshot while the group sits empty.
avahi::entry_group_add_address = FakeEntryGroupAddAddressLenient;
config::sunshine.bind_address = "192.168.1.10";
config::sunshine.address_family = "both";
platf::publish::create_services(reinterpret_cast<avahi::Client *>(0x2000));
ASSERT_EQ(g_commit_count, 1);
ASSERT_EQ(g_quit_count, 0);
ASSERT_TRUE(platf::publish::published_addresses.has_value());
ASSERT_EQ(avahi::entry_group_is_empty(platf::publish::group), 0);
config::sunshine.bind_address = "192.168.1.20";
g_add_address_fail_once = true;
platf::publish::refresh_service_addresses();
// The refresh reset the group and the registration failed: the group is
// empty and no snapshot is committed.
ASSERT_EQ(g_commit_count, 1);
ASSERT_EQ(g_quit_count, 0);
ASSERT_FALSE(platf::publish::published_addresses.has_value());
ASSERT_NE(avahi::entry_group_is_empty(platf::publish::group), 0);
config::sunshine.bind_address = "192.168.1.10";
platf::publish::refresh_service_addresses();
// Restoring A must commit a second time, with a live group and the
// poll loop still running.
ASSERT_EQ(g_commit_count, 2);
ASSERT_EQ(g_quit_count, 0);
ASSERT_TRUE(platf::publish::published_addresses.has_value());
ASSERT_EQ(avahi::entry_group_is_empty(platf::publish::group), 0);
ASSERT_EQ(g_address_calls.size(), 2U);
ASSERT_EQ(g_address_calls[1].address, platf::publish::to_avahi_address(boost::asio::ip::make_address("192.168.1.10")));
// A further refresh with an unchanged snapshot is a no-op: no churn.
platf::publish::refresh_service_addresses();
ASSERT_EQ(g_commit_count, 2);
}
TEST_F(PublishRegistrationTest, RefreshFailureThenRestoreEmptySnapshotRepublishes) {
// Empty-A variant: a successfully published empty snapshot (the
// daemon-hostname fallback) is a valid committed snapshot, not a
// withdrawn group, so the failed-B invalidation must still let the
// restored empty snapshot republish.
avahi::entry_group_add_address = FakeEntryGroupAddAddressLenient;
// An IPv6 bind under an IPv4-only configuration serves no addresses:
// the service falls back to the daemon host name with no address records.
config::sunshine.bind_address = "2001:db8::1";
config::sunshine.address_family = "ipv4";
platf::publish::create_services(reinterpret_cast<avahi::Client *>(0x2000));
ASSERT_EQ(g_commit_count, 1);
ASSERT_EQ(g_quit_count, 0);
ASSERT_TRUE(platf::publish::published_addresses.has_value());
ASSERT_TRUE(platf::publish::published_addresses->empty());
ASSERT_EQ(avahi::entry_group_is_empty(platf::publish::group), 0);
ASSERT_EQ(g_address_calls.size(), 0U);
ASSERT_EQ(g_service_hosts.size(), 1U);
config::sunshine.bind_address = "192.168.1.20";
config::sunshine.address_family = "both";
g_add_address_fail_once = true;
platf::publish::refresh_service_addresses();
// The refresh reset the group and the registration failed: the group is
// empty and no snapshot is committed, even though the last good snapshot
// was itself empty.
ASSERT_EQ(g_commit_count, 1);
ASSERT_EQ(g_quit_count, 0);
ASSERT_FALSE(platf::publish::published_addresses.has_value());
ASSERT_NE(avahi::entry_group_is_empty(platf::publish::group), 0);
config::sunshine.bind_address = "2001:db8::1";
config::sunshine.address_family = "ipv4";
platf::publish::refresh_service_addresses();
// Restoring the empty snapshot must commit a second time, with a live
// group and the poll loop still running.
ASSERT_EQ(g_commit_count, 2);
ASSERT_EQ(g_quit_count, 0);
ASSERT_TRUE(platf::publish::published_addresses.has_value());
ASSERT_TRUE(platf::publish::published_addresses->empty());
ASSERT_EQ(avahi::entry_group_is_empty(platf::publish::group), 0);
ASSERT_EQ(g_address_calls.size(), 0U);
ASSERT_EQ(g_service_hosts.size(), 2U);
// A further refresh with an unchanged snapshot is a no-op: no churn.
platf::publish::refresh_service_addresses();
ASSERT_EQ(g_commit_count, 2);
}
#else
TEST_F(PublishRegistrationTest, NonLinuxFallbackUsesDaemonHostName) {
// On platforms without the netlink address-change watch (e.g. FreeBSD),
// the service must not publish fixed address records for a
// service-specific host name: with no refresh, a DHCP change would
// leave clients with the stale snapshot. Instead the service uses the
// daemon-managed host name (a null SRV target), and the Avahi daemon
// maintains the interface address records.
ASSERT_FALSE(platf::publish::install_address_change_watch());
// An explicit bind with a populated snapshot is the regression case:
// the pre-fix code published the custom host plus its fixed addresses.
config::sunshine.bind_address = "192.168.1.10";
config::sunshine.address_family = "both";
platf::publish::create_services(reinterpret_cast<avahi::Client *>(0x2000));
ASSERT_EQ(g_commit_count, 1);
ASSERT_EQ(g_quit_count, 0);
// No fixed alias address records were published ...
ASSERT_TRUE(g_address_calls.empty());
// ... and the service points at the daemon-managed host name.
ASSERT_EQ(g_service_hosts.size(), 1U);
ASSERT_TRUE(g_service_hosts[0].empty());
}
#endif // defined(__linux__)
#endif // !defined(_WIN32) && !defined(__APPLE__)