mirror of
https://github.com/espressif/esp-protocols.git
synced 2026-07-07 17:10:52 +02:00
fix(mdns): Fix browse to stage A/AAAA to match PTRs records
also updates browse PTR model to support TTL=0/removal Closes https://github.com/espressif/esp-protocols/issues/1064
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2015-2025 Espressif Systems (Shanghai) CO LTD
|
||||
* SPDX-FileCopyrightText: 2015-2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
@@ -118,6 +118,33 @@ typedef struct mdns_result_s {
|
||||
} mdns_result_t;
|
||||
|
||||
typedef void (*mdns_query_notify_t)(mdns_search_once_t *search);
|
||||
|
||||
/**
|
||||
* @brief Browse result change notifier
|
||||
*
|
||||
* Called once per browse result that changed in a given response packet (not
|
||||
* once per packet). The @p result argument points at the changed entry, but it
|
||||
* remains a live node in the internal browse cache until the callback returns.
|
||||
*
|
||||
* @warning @p result->next links other cached instances for this browse, not
|
||||
* necessarily other results that changed in the same packet. For
|
||||
* ordinary add/update notifications, use only @p result; do not walk
|
||||
* @c next, because unchanged instances may appear there.
|
||||
*
|
||||
* @warning Batch PTR TTL=0 ("goodbye") responses (for example when a peer
|
||||
* exits and Bonjour sends many removals in one packet) may invoke the
|
||||
* notifier once while several instances in the cache are updated.
|
||||
* Until this is improved, applications may walk @c next and treat
|
||||
* entries with @c ttl == 0 as removals. Do not assume every node on
|
||||
* @c next changed in the current packet.
|
||||
*
|
||||
* @warning If handling is deferred outside this callback, copy @p result first
|
||||
* (including strings and addresses). Goodbye entries may be freed when
|
||||
* the callback returns.
|
||||
*
|
||||
* @param result The browse result that changed. See the warnings above for
|
||||
* use of @c result->next.
|
||||
*/
|
||||
typedef void (*mdns_browse_notify_t)(mdns_result_t *result);
|
||||
|
||||
/**
|
||||
@@ -913,9 +940,21 @@ esp_err_t mdns_netif_action(esp_netif_t *esp_netif, mdns_event_actions_t event_a
|
||||
*
|
||||
* @param service Pointer to the `_service` which will be browsed.
|
||||
* @param proto Pointer to the `_proto` which will be browsed.
|
||||
* @param notifier The callback which will be called when the browsing service changed.
|
||||
* @param notifier The callback which will be called when a browse result changes.
|
||||
* See @ref mdns_browse_notify_t for callback semantics and limitations.
|
||||
* @return mdns_browse_t pointer to new browse object if initiated successfully.
|
||||
* NULL otherwise.
|
||||
*
|
||||
* @note When several service instances share the same SRV target hostname, A/AAAA
|
||||
* addresses from a response are attached only to the first matching browse
|
||||
* result for that hostname (per interface and IP protocol). Other instances
|
||||
* with the same target host are not populated automatically; applications
|
||||
* that need host-level addresses for every instance must resolve or cache
|
||||
* them separately until this behavior is improved.
|
||||
*
|
||||
* @note If one response packet contains answers for multiple active browses,
|
||||
* only one browse is synchronized for that packet. This should not affect
|
||||
* typical browse traffic, where packets answer one service type.
|
||||
*/
|
||||
mdns_browse_t *mdns_browse_new(const char *service, const char *proto, mdns_browse_notify_t notifier);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2015-2025 Espressif Systems (Shanghai) CO LTD
|
||||
* SPDX-FileCopyrightText: 2015-2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
@@ -56,6 +56,14 @@ static void browse_item_free(mdns_browse_t *browse)
|
||||
mdns_mem_free(browse);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Deliver browse updates to the user notifier
|
||||
*
|
||||
* Invokes the notifier once per changed result accumulated for the current
|
||||
* packet. The passed @c result pointer is a live node in @c browse->result;
|
||||
* @c result->next is not cleared before the callback (only after TTL=0 removal).
|
||||
* See @ref mdns_browse_notify_t for how callers should use @c next.
|
||||
*/
|
||||
static void browse_sync(mdns_browse_sync_t *browse_sync)
|
||||
{
|
||||
mdns_browse_t *browse = browse_sync->browse;
|
||||
@@ -199,9 +207,162 @@ static void browse_add(mdns_browse_t *browse)
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t add_browse_result(mdns_browse_sync_t *sync_browse, mdns_result_t *r);
|
||||
|
||||
/**
|
||||
* @brief Called from packet parser to find matching running search
|
||||
*
|
||||
* @note Called from the mDNS service task while the service lock is held.
|
||||
* The returned browse is an active cache node that the parser may update.
|
||||
*/
|
||||
mdns_browse_t *mdns_priv_browse_find_ptr(mdns_name_t *name)
|
||||
{
|
||||
mdns_browse_t *b = s_browse;
|
||||
|
||||
if (mdns_utils_str_null_or_empty(name->service) || mdns_utils_str_null_or_empty(name->proto)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
while (b) {
|
||||
if (!strcasecmp(name->service, b->service) && !strcasecmp(name->proto, b->proto)) {
|
||||
return b;
|
||||
}
|
||||
b = b->next;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @note Only one browse sync object is kept per parsed packet. If @p sync
|
||||
* is already allocated for a *different* browse, this function returns
|
||||
* the existing object unchanged — callers that compare
|
||||
* out_sync_browse->browse against the current browse will silently
|
||||
* skip the update. This is acceptable because mDNS answers for
|
||||
* multiple browsed service types in a single packet are uncommon.
|
||||
* The returned object must still be checked by the caller because NULL
|
||||
* means allocation failed when @p browse was non-NULL.
|
||||
*/
|
||||
mdns_browse_sync_t *mdns_priv_browse_ensure_sync(mdns_browse_t *browse, mdns_browse_sync_t *sync)
|
||||
{
|
||||
if (!browse) {
|
||||
return sync;
|
||||
}
|
||||
if (!sync) {
|
||||
sync = (mdns_browse_sync_t *)mdns_mem_malloc(sizeof(mdns_browse_sync_t));
|
||||
if (!sync) {
|
||||
HOOK_MALLOC_FAILED;
|
||||
return NULL;
|
||||
}
|
||||
sync->browse = browse;
|
||||
sync->sync_result = NULL;
|
||||
}
|
||||
return sync;
|
||||
}
|
||||
|
||||
void mdns_priv_browse_staged_ip_free(mdns_browse_staged_ip_t *staged)
|
||||
{
|
||||
while (staged) {
|
||||
mdns_browse_staged_ip_t *next = staged->next;
|
||||
mdns_mem_free(staged);
|
||||
staged = next;
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t mdns_priv_browse_stage_ip(mdns_browse_staged_ip_t **staged, const char *hostname, esp_ip_addr_t *ip,
|
||||
mdns_if_t tcpip_if, mdns_ip_protocol_t ip_protocol, uint32_t ttl)
|
||||
{
|
||||
mdns_browse_staged_ip_t *item = (mdns_browse_staged_ip_t *)mdns_mem_malloc(sizeof(mdns_browse_staged_ip_t));
|
||||
if (!item) {
|
||||
HOOK_MALLOC_FAILED;
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
memset(item, 0, sizeof(mdns_browse_staged_ip_t));
|
||||
strncpy(item->hostname, hostname, MDNS_NAME_BUF_LEN - 1);
|
||||
item->hostname[MDNS_NAME_BUF_LEN - 1] = '\0';
|
||||
item->ip = *ip;
|
||||
item->tcpip_if = tcpip_if;
|
||||
item->ip_protocol = ip_protocol;
|
||||
item->ttl = ttl;
|
||||
item->next = *staged;
|
||||
*staged = item;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Apply packet-staged A/AAAA records after SRV hostnames are known
|
||||
*
|
||||
* @note Each staged address is applied via mdns_priv_browse_result_add_ip(), which
|
||||
* attaches the address only to the first browse result with a matching
|
||||
* hostname on the same interface and IP protocol. Additional instances that
|
||||
* share the same target host do not receive a copy automatically.
|
||||
*/
|
||||
void mdns_priv_browse_apply_staged_ips(mdns_browse_t *browse, mdns_browse_staged_ip_t *staged,
|
||||
mdns_browse_sync_t *out_sync_browse)
|
||||
{
|
||||
if (!browse || !staged || !out_sync_browse || out_sync_browse->browse != browse) {
|
||||
return;
|
||||
}
|
||||
while (staged) {
|
||||
mdns_priv_browse_result_add_ip(browse, staged->hostname, &staged->ip, staged->tcpip_if,
|
||||
staged->ip_protocol, staged->ttl, out_sync_browse);
|
||||
staged = staged->next;
|
||||
}
|
||||
}
|
||||
|
||||
void mdns_priv_browse_result_add_ptr(mdns_browse_t *browse, const char *instance, const char *service, const char *proto,
|
||||
mdns_if_t tcpip_if, mdns_ip_protocol_t ip_protocol, uint32_t ttl,
|
||||
mdns_browse_sync_t *out_sync_browse)
|
||||
{
|
||||
if (!browse || !out_sync_browse || out_sync_browse->browse != browse) {
|
||||
return;
|
||||
}
|
||||
mdns_result_t *r = browse->result;
|
||||
while (r) {
|
||||
if (r->esp_netif == mdns_priv_get_esp_netif(tcpip_if) && r->ip_protocol == ip_protocol &&
|
||||
!mdns_utils_str_null_or_empty(r->instance_name) && !strcasecmp(instance, r->instance_name) &&
|
||||
!mdns_utils_str_null_or_empty(r->service_type) && !strcasecmp(service, r->service_type) &&
|
||||
!mdns_utils_str_null_or_empty(r->proto) && !strcasecmp(proto, r->proto)) {
|
||||
if (r->ttl != ttl) {
|
||||
uint32_t previous_ttl = r->ttl;
|
||||
if (r->ttl == 0) {
|
||||
r->ttl = ttl;
|
||||
} else {
|
||||
mdns_priv_query_update_result_ttl(r, ttl);
|
||||
}
|
||||
if (previous_ttl != r->ttl) {
|
||||
add_browse_result(out_sync_browse, r);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
r = r->next;
|
||||
}
|
||||
|
||||
r = (mdns_result_t *)mdns_mem_malloc(sizeof(mdns_result_t));
|
||||
if (!r) {
|
||||
HOOK_MALLOC_FAILED;
|
||||
return;
|
||||
}
|
||||
memset(r, 0, sizeof(mdns_result_t));
|
||||
r->instance_name = mdns_mem_strdup(instance);
|
||||
r->service_type = mdns_mem_strdup(service);
|
||||
r->proto = mdns_mem_strdup(proto);
|
||||
if (!r->instance_name || !r->service_type || !r->proto) {
|
||||
HOOK_MALLOC_FAILED;
|
||||
mdns_mem_free(r->instance_name);
|
||||
mdns_mem_free(r->service_type);
|
||||
mdns_mem_free(r->proto);
|
||||
mdns_mem_free(r);
|
||||
return;
|
||||
}
|
||||
r->esp_netif = mdns_priv_get_esp_netif(tcpip_if);
|
||||
r->ip_protocol = ip_protocol;
|
||||
r->ttl = ttl;
|
||||
r->next = browse->result;
|
||||
browse->result = r;
|
||||
add_browse_result(out_sync_browse, r);
|
||||
}
|
||||
|
||||
mdns_browse_t *mdns_priv_browse_find(mdns_name_t *name, uint16_t type, mdns_if_t tcpip_if, mdns_ip_protocol_t ip_protocol)
|
||||
{
|
||||
mdns_browse_t *b = s_browse;
|
||||
@@ -310,7 +471,12 @@ static esp_err_t add_browse_result(mdns_browse_sync_t *sync_browse, mdns_result_
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Called from parser to add A/AAAA data to search result
|
||||
* @brief Called from parser to add A/AAAA data to browse result
|
||||
*
|
||||
* @note Only the first browse result with a matching @p hostname (same interface
|
||||
* and IP protocol) receives the address. This predates browse staging and
|
||||
* also limits mdns_priv_browse_apply_staged_ips() when several instances
|
||||
* share one target host.
|
||||
*/
|
||||
void mdns_priv_browse_result_add_ip(mdns_browse_t *browse, const char *hostname, esp_ip_addr_t *ip,
|
||||
mdns_if_t tcpip_if, mdns_ip_protocol_t ip_protocol, uint32_t ttl, mdns_browse_sync_t *out_sync_browse)
|
||||
|
||||
@@ -598,10 +598,19 @@ static void mdns_parse_packet(mdns_rx_packet_t *packet)
|
||||
bool do_not_reply = false;
|
||||
mdns_search_once_t *search_result = NULL;
|
||||
mdns_browse_t *browse_result = NULL;
|
||||
/*
|
||||
* Browse packet limitations (see also mdns_browse_new() / mdns_browse_notify_t in mdns.h):
|
||||
* - Only one active browse is tracked per incoming packet; if a packet answers
|
||||
* two different browsed service types, only the last match gets staged A/AAAA.
|
||||
* - Staged A/AAAA are applied via mdns_priv_browse_result_add_ip(), which sets
|
||||
* addresses on the first browse result per target hostname only.
|
||||
*/
|
||||
mdns_browse_t *packet_browse = NULL;
|
||||
char *browse_result_instance = NULL;
|
||||
char *browse_result_service = NULL;
|
||||
char *browse_result_proto = NULL;
|
||||
mdns_browse_sync_t *out_sync_browse = NULL;
|
||||
mdns_browse_staged_ip_t *staged_browse_ips = NULL;
|
||||
|
||||
DBG_RX_PACKET(packet, data, len);
|
||||
|
||||
@@ -808,15 +817,10 @@ static void mdns_parse_packet(mdns_rx_packet_t *packet)
|
||||
search_result = mdns_priv_query_find(name, type, packet->tcpip_if, packet->ip_protocol);
|
||||
browse_result = mdns_priv_browse_find(name, type, packet->tcpip_if, packet->ip_protocol);
|
||||
if (browse_result) {
|
||||
packet_browse = browse_result;
|
||||
out_sync_browse = mdns_priv_browse_ensure_sync(browse_result, out_sync_browse);
|
||||
if (!out_sync_browse) {
|
||||
// will be freed in function `browse_sync`
|
||||
out_sync_browse = (mdns_browse_sync_t *)mdns_mem_malloc(sizeof(mdns_browse_sync_t));
|
||||
if (!out_sync_browse) {
|
||||
HOOK_MALLOC_FAILED;
|
||||
goto clear_rx_packet;
|
||||
}
|
||||
out_sync_browse->browse = browse_result;
|
||||
out_sync_browse->sync_result = NULL;
|
||||
goto clear_rx_packet;
|
||||
}
|
||||
if (!browse_result_service) {
|
||||
browse_result_service = (char *)mdns_mem_malloc(MDNS_NAME_BUF_LEN);
|
||||
@@ -848,10 +852,20 @@ static void mdns_parse_packet(mdns_rx_packet_t *packet)
|
||||
}
|
||||
|
||||
if (type == MDNS_TYPE_PTR) {
|
||||
mdns_browse_t *browse_for_ptr = mdns_priv_browse_find_ptr(name);
|
||||
if (!mdns_utils_parse_fqdn(data, data_ptr, name, len)) {
|
||||
continue;//error
|
||||
}
|
||||
if (search_result) {
|
||||
if (browse_for_ptr) {
|
||||
packet_browse = browse_for_ptr;
|
||||
out_sync_browse = mdns_priv_browse_ensure_sync(browse_for_ptr, out_sync_browse);
|
||||
if (!out_sync_browse) {
|
||||
goto clear_rx_packet;
|
||||
}
|
||||
mdns_priv_browse_result_add_ptr(browse_for_ptr, name->host, browse_for_ptr->service,
|
||||
browse_for_ptr->proto, packet->tcpip_if, packet->ip_protocol,
|
||||
ttl, out_sync_browse);
|
||||
} else if (search_result) {
|
||||
mdns_priv_query_result_add_ptr(search_result, name->host, name->service, name->proto,
|
||||
packet->tcpip_if, packet->ip_protocol, ttl);
|
||||
} else if ((discovery || ours) && !name->sub && is_ours(name)) {
|
||||
@@ -1096,10 +1110,13 @@ static void mdns_parse_packet(mdns_rx_packet_t *packet)
|
||||
esp_ip_addr_t ip6;
|
||||
ip6.type = ESP_IPADDR_TYPE_V6;
|
||||
memcpy(ip6.u_addr.ip6.addr, data_ptr, MDNS_ANSWER_AAAA_SIZE);
|
||||
if (browse_result) {
|
||||
mdns_priv_browse_result_add_ip(browse_result, name->host, &ip6, packet->tcpip_if,
|
||||
packet->ip_protocol,
|
||||
ttl, out_sync_browse);
|
||||
if (packet_browse || browse_result) {
|
||||
mdns_browse_t *browse_ip = browse_result ? browse_result : packet_browse;
|
||||
out_sync_browse = mdns_priv_browse_ensure_sync(browse_ip, out_sync_browse);
|
||||
if (out_sync_browse && mdns_priv_browse_stage_ip(&staged_browse_ips, name->host, &ip6,
|
||||
packet->tcpip_if, packet->ip_protocol, ttl) != ESP_OK) {
|
||||
goto clear_rx_packet;
|
||||
}
|
||||
}
|
||||
if (search_result) {
|
||||
//check for more applicable searches (PTR & A/AAAA at the same time)
|
||||
@@ -1159,10 +1176,13 @@ static void mdns_parse_packet(mdns_rx_packet_t *packet)
|
||||
esp_ip_addr_t ip;
|
||||
ip.type = ESP_IPADDR_TYPE_V4;
|
||||
memcpy(&(ip.u_addr.ip4.addr), data_ptr, 4);
|
||||
if (browse_result) {
|
||||
mdns_priv_browse_result_add_ip(browse_result, name->host, &ip, packet->tcpip_if,
|
||||
packet->ip_protocol,
|
||||
ttl, out_sync_browse);
|
||||
if (packet_browse || browse_result) {
|
||||
mdns_browse_t *browse_ip = browse_result ? browse_result : packet_browse;
|
||||
out_sync_browse = mdns_priv_browse_ensure_sync(browse_ip, out_sync_browse);
|
||||
if (out_sync_browse && mdns_priv_browse_stage_ip(&staged_browse_ips, name->host, &ip,
|
||||
packet->tcpip_if, packet->ip_protocol, ttl) != ESP_OK) {
|
||||
goto clear_rx_packet;
|
||||
}
|
||||
}
|
||||
if (search_result) {
|
||||
//check for more applicable searches (PTR & A/AAAA at the same time)
|
||||
@@ -1221,6 +1241,19 @@ static void mdns_parse_packet(mdns_rx_packet_t *packet)
|
||||
}
|
||||
}
|
||||
|
||||
if (staged_browse_ips) {
|
||||
mdns_browse_t *browse_apply = packet_browse;
|
||||
if (!browse_apply && out_sync_browse) {
|
||||
browse_apply = out_sync_browse->browse;
|
||||
}
|
||||
out_sync_browse = mdns_priv_browse_ensure_sync(browse_apply, out_sync_browse);
|
||||
if (browse_apply && out_sync_browse) {
|
||||
mdns_priv_browse_apply_staged_ips(browse_apply, staged_browse_ips, out_sync_browse);
|
||||
}
|
||||
}
|
||||
mdns_priv_browse_staged_ip_free(staged_browse_ips);
|
||||
staged_browse_ips = NULL;
|
||||
|
||||
if (!do_not_reply && mdns_priv_pcb_is_after_probing(packet) && (parsed_packet->questions || parsed_packet->discovery)) {
|
||||
mdns_priv_create_answer_from_parsed_packet(parsed_packet);
|
||||
}
|
||||
@@ -1238,6 +1271,8 @@ static void mdns_parse_packet(mdns_rx_packet_t *packet)
|
||||
}
|
||||
|
||||
clear_rx_packet:
|
||||
mdns_priv_browse_staged_ip_free(staged_browse_ips);
|
||||
staged_browse_ips = NULL;
|
||||
while (parsed_packet->questions) {
|
||||
mdns_parsed_question_t *question = parsed_packet->questions;
|
||||
parsed_packet->questions = parsed_packet->questions->next;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
|
||||
* SPDX-FileCopyrightText: 2025-2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
@@ -28,6 +28,57 @@ void mdns_priv_browse_free(void);
|
||||
*/
|
||||
mdns_browse_t *mdns_priv_browse_find(mdns_name_t *name, uint16_t type, mdns_if_t tcpip_if, mdns_ip_protocol_t ip_protocol);
|
||||
|
||||
/**
|
||||
* @brief Looks for an active browse matching a PTR owner name (service._proto.local)
|
||||
*
|
||||
* @note Called from the packet parser (mdns_receive.c)
|
||||
*/
|
||||
mdns_browse_t *mdns_priv_browse_find_ptr(mdns_name_t *name);
|
||||
|
||||
/**
|
||||
* @brief Packet-scoped staging for browse A/AAAA records received before SRV
|
||||
*/
|
||||
typedef struct mdns_browse_staged_ip {
|
||||
struct mdns_browse_staged_ip *next;
|
||||
char hostname[MDNS_NAME_BUF_LEN];
|
||||
esp_ip_addr_t ip;
|
||||
mdns_if_t tcpip_if;
|
||||
mdns_ip_protocol_t ip_protocol;
|
||||
uint32_t ttl;
|
||||
} mdns_browse_staged_ip_t;
|
||||
|
||||
/**
|
||||
* @brief Allocate or return existing browse sync object for a packet
|
||||
*/
|
||||
mdns_browse_sync_t *mdns_priv_browse_ensure_sync(mdns_browse_t *browse, mdns_browse_sync_t *sync);
|
||||
|
||||
/**
|
||||
* @brief Stage an A/AAAA record to apply after all packet records are parsed
|
||||
*/
|
||||
esp_err_t mdns_priv_browse_stage_ip(mdns_browse_staged_ip_t **staged, const char *hostname, esp_ip_addr_t *ip,
|
||||
mdns_if_t tcpip_if, mdns_ip_protocol_t ip_protocol, uint32_t ttl);
|
||||
|
||||
/**
|
||||
* @brief Apply staged A/AAAA records once SRV/hostnames are known
|
||||
*
|
||||
* @note Delegates to mdns_priv_browse_result_add_ip(), which updates only the
|
||||
* first matching instance per hostname; see mdns_browse_new() in mdns.h.
|
||||
*/
|
||||
void mdns_priv_browse_apply_staged_ips(mdns_browse_t *browse, mdns_browse_staged_ip_t *staged,
|
||||
mdns_browse_sync_t *out_sync_browse);
|
||||
|
||||
/**
|
||||
* @brief Free staged A/AAAA list
|
||||
*/
|
||||
void mdns_priv_browse_staged_ip_free(mdns_browse_staged_ip_t *staged);
|
||||
|
||||
/**
|
||||
* @brief Add a PTR record to the browse result (including TTL=0 removal)
|
||||
*/
|
||||
void mdns_priv_browse_result_add_ptr(mdns_browse_t *browse, const char *instance, const char *service, const char *proto,
|
||||
mdns_if_t tcpip_if, mdns_ip_protocol_t ip_protocol, uint32_t ttl,
|
||||
mdns_browse_sync_t *out_sync_browse);
|
||||
|
||||
/**
|
||||
* @brief Send out all browse queries
|
||||
*
|
||||
@@ -63,6 +114,8 @@ void mdns_priv_browse_result_add_txt(mdns_browse_t *browse, const char *instance
|
||||
* @brief Add an IP record to the browse result
|
||||
*
|
||||
* @note Called from the packet parser (mdns_receive.c)
|
||||
* @note Attaches @p ip only to the first browse result with matching @p hostname
|
||||
* on the given interface and IP protocol; see mdns_browse_new() in mdns.h.
|
||||
*/
|
||||
void mdns_priv_browse_result_add_ip(mdns_browse_t *browse, const char *hostname, esp_ip_addr_t *ip,
|
||||
mdns_if_t tcpip_if, mdns_ip_protocol_t ip_protocol, uint32_t ttl, mdns_browse_sync_t *out_sync_browse);
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
# SPDX-License-Identifier: Unlicense OR CC0-1.0
|
||||
"""Mock Bonjour-style mDNS responder for browse host tests.
|
||||
|
||||
Default response order mimics Bonjour browse answers:
|
||||
Answer: PTR
|
||||
Additional: A, AAAA, SRV, TXT
|
||||
|
||||
Use ``--goodbye-after N`` to answer the first N browse queries normally and then
|
||||
reply with a standalone PTR TTL=0 (Bonjour service removal).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import dns.flags
|
||||
import dns.message
|
||||
import dns.name
|
||||
import dns.rdataclass
|
||||
import dns.rdatatype
|
||||
import dns.rrset
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MDNS_ADDR = '224.0.0.251'
|
||||
MDNS_PORT = 5353
|
||||
|
||||
DEFAULTS = {
|
||||
'service': '_v301test',
|
||||
'proto': '_tcp',
|
||||
'instance': 'v301instance',
|
||||
'hostname': 'v301host',
|
||||
'port': 8080,
|
||||
'ipv4': '192.168.1.100',
|
||||
'ipv6': 'fe80::1234:5678:9abc:def0',
|
||||
'txt': 'vdv301=test',
|
||||
}
|
||||
|
||||
|
||||
def _fqdn(*labels: str) -> str:
|
||||
return '.'.join(labels) + '.local.'
|
||||
|
||||
|
||||
def build_goodbye_response(
|
||||
query: dns.message.Message,
|
||||
*,
|
||||
service: str,
|
||||
proto: str,
|
||||
instance: str,
|
||||
) -> dns.message.Message:
|
||||
service_name = _fqdn(service, proto)
|
||||
instance_name = _fqdn(instance, service, proto)
|
||||
|
||||
response = dns.message.make_response(query)
|
||||
response.flags |= dns.flags.AA
|
||||
response.answer.append(
|
||||
dns.rrset.from_text(service_name, 0, dns.rdataclass.IN, dns.rdatatype.PTR, instance_name)
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def build_ptr_response(
|
||||
query: dns.message.Message,
|
||||
*,
|
||||
service: str,
|
||||
proto: str,
|
||||
instance: str,
|
||||
hostname: str,
|
||||
port: int,
|
||||
ipv4: str,
|
||||
ipv6: str,
|
||||
txt: str,
|
||||
srv_first: bool,
|
||||
) -> dns.message.Message:
|
||||
service_name = _fqdn(service, proto)
|
||||
instance_name = _fqdn(instance, service, proto)
|
||||
host_name = _fqdn(hostname)
|
||||
|
||||
response = dns.message.make_response(query)
|
||||
response.flags |= dns.flags.AA
|
||||
|
||||
response.answer.append(
|
||||
dns.rrset.from_text(service_name, 120, dns.rdataclass.IN, dns.rdatatype.PTR, instance_name)
|
||||
)
|
||||
|
||||
a_rr = dns.rrset.from_text(host_name, 120, dns.rdataclass.IN, dns.rdatatype.A, ipv4)
|
||||
aaaa_rr = dns.rrset.from_text(host_name, 120, dns.rdataclass.IN, dns.rdatatype.AAAA, ipv6)
|
||||
srv_rr = dns.rrset.from_text(
|
||||
instance_name,
|
||||
120,
|
||||
dns.rdataclass.IN,
|
||||
dns.rdatatype.SRV,
|
||||
f'0 0 {port} {host_name}',
|
||||
)
|
||||
txt_rr = dns.rrset.from_text(instance_name, 120, dns.rdataclass.IN, dns.rdatatype.TXT, txt)
|
||||
|
||||
if srv_first:
|
||||
response.additional = [srv_rr, txt_rr, a_rr, aaaa_rr]
|
||||
else:
|
||||
response.additional = [a_rr, aaaa_rr, srv_rr, txt_rr]
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def create_socket(interface: str | None) -> socket.socket:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
if interface:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, interface.encode() + b'\0')
|
||||
|
||||
sock.bind(('', MDNS_PORT))
|
||||
|
||||
membership = struct.pack(
|
||||
'=4s4s',
|
||||
socket.inet_aton(MDNS_ADDR),
|
||||
socket.inet_aton('0.0.0.0'),
|
||||
)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
|
||||
return sock
|
||||
|
||||
|
||||
def query_matches(query: dns.message.Message, service: str, proto: str) -> bool:
|
||||
target = dns.name.from_text(_fqdn(service, proto))
|
||||
for question in query.question:
|
||||
if question.rdtype != dns.rdatatype.PTR:
|
||||
continue
|
||||
if question.name == target:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def send_mdns_response(sock: socket.socket, payload: bytes, source: tuple[str, int]) -> None:
|
||||
if source[1] == MDNS_PORT:
|
||||
destination = (MDNS_ADDR, MDNS_PORT)
|
||||
else:
|
||||
destination = source
|
||||
sock.sendto(payload, destination)
|
||||
logger.info('Sent %d byte response to %s (query source %s:%d)', len(payload), destination, *source)
|
||||
|
||||
|
||||
def serve(args: argparse.Namespace) -> None:
|
||||
sock = create_socket(args.interface)
|
||||
order = 'SRV, TXT, A, AAAA' if args.srv_first else 'A, AAAA, SRV, TXT'
|
||||
logger.info(
|
||||
'Listening on %s:%d (%s) for PTR %s.%s.local; additional order: %s; goodbye-after=%s',
|
||||
MDNS_ADDR,
|
||||
MDNS_PORT,
|
||||
args.interface or 'all interfaces',
|
||||
args.service,
|
||||
args.proto,
|
||||
order,
|
||||
args.goodbye_after,
|
||||
)
|
||||
|
||||
response_count = 0
|
||||
while True:
|
||||
try:
|
||||
data, source = sock.recvfrom(9000)
|
||||
except KeyboardInterrupt:
|
||||
logger.info('Stopping responder')
|
||||
break
|
||||
|
||||
try:
|
||||
query = dns.message.from_wire(data)
|
||||
except Exception as exc: # noqa: BLE001 - ignore malformed packets
|
||||
logger.debug('Ignoring non-DNS payload from %s:%d (%s)', source[0], source[1], exc)
|
||||
continue
|
||||
|
||||
if query.flags & dns.flags.QR:
|
||||
continue
|
||||
if not query_matches(query, args.service, args.proto):
|
||||
continue
|
||||
|
||||
if args.goodbye_after >= 0 and response_count >= args.goodbye_after:
|
||||
response = build_goodbye_response(
|
||||
query,
|
||||
service=args.service,
|
||||
proto=args.proto,
|
||||
instance=args.instance,
|
||||
)
|
||||
logger.info('Answered PTR goodbye (TTL=0) from %s:%d', source[0], source[1])
|
||||
else:
|
||||
response = build_ptr_response(
|
||||
query,
|
||||
service=args.service,
|
||||
proto=args.proto,
|
||||
instance=args.instance,
|
||||
hostname=args.hostname,
|
||||
port=args.port,
|
||||
ipv4=args.ipv4,
|
||||
ipv6=args.ipv6,
|
||||
txt=args.txt,
|
||||
srv_first=args.srv_first,
|
||||
)
|
||||
logger.info('Answered PTR browse from %s:%d', source[0], source[1])
|
||||
|
||||
send_mdns_response(sock, response.to_wire(), source)
|
||||
response_count += 1
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--interface', default='eth0', help='Network interface for mDNS (default: eth0)')
|
||||
parser.add_argument('--service', default=DEFAULTS['service'], help='Service type without domain')
|
||||
parser.add_argument('--proto', default=DEFAULTS['proto'], help='Protocol (_tcp/_udp)')
|
||||
parser.add_argument('--instance', default=DEFAULTS['instance'], help='Service instance name')
|
||||
parser.add_argument('--hostname', default=DEFAULTS['hostname'], help='SRV target host label')
|
||||
parser.add_argument('--port', type=int, default=DEFAULTS['port'], help='SRV port')
|
||||
parser.add_argument('--ipv4', default=DEFAULTS['ipv4'], help='A record address')
|
||||
parser.add_argument('--ipv6', default=DEFAULTS['ipv6'], help='AAAA record address')
|
||||
parser.add_argument('--txt', default=DEFAULTS['txt'], help='TXT record payload')
|
||||
parser.add_argument(
|
||||
'--srv-first',
|
||||
action='store_true',
|
||||
help='Place SRV/TXT before A/AAAA (control case where browse gets addresses)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--goodbye-after',
|
||||
type=int,
|
||||
default=-1,
|
||||
help='After N normal browse responses, reply with PTR TTL=0 only (-1 disables)',
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
serve(args)
|
||||
except PermissionError:
|
||||
logger.error('Binding to port %d requires root or CAP_NET_BIND_SERVICE', MDNS_PORT)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
logger.error('Failed to start responder: %s', exc)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,9 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
|
||||
# SPDX-License-Identifier: Unlicense OR CC0-1.0
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pexpect
|
||||
import pytest
|
||||
from bonjour_order_responder import DEFAULTS
|
||||
from dnsfixture import DnsPythonWrapper
|
||||
|
||||
# Configure logging
|
||||
@@ -12,6 +17,11 @@ logger = logging.getLogger(__name__)
|
||||
ipv6_enabled = False
|
||||
|
||||
|
||||
def _console_line(label: str, value: str) -> str:
|
||||
"""Build a line as printed by mdns_print_results (avoids flake8 E203 on f'LABEL : {x}')."""
|
||||
return label + value
|
||||
|
||||
|
||||
class MdnsConsole:
|
||||
def __init__(self, command):
|
||||
self.process = pexpect.spawn(command, encoding='utf-8')
|
||||
@@ -29,6 +39,22 @@ class MdnsConsole:
|
||||
logger.info(f'Received from stdout: {output}')
|
||||
return output
|
||||
|
||||
def wait_for_output(self, expected_data, timeout=10):
|
||||
logger.info(f'Waiting for: {expected_data}')
|
||||
self.process.expect(expected_data, timeout=timeout)
|
||||
output = (self.process.before or '') + (self.process.after or '')
|
||||
logger.info(f'Captured output while waiting: {output}')
|
||||
return output
|
||||
|
||||
def wait_for_browse_result(self, timeout=10):
|
||||
"""Wait until browse notifier printed the full result (A is last required field)."""
|
||||
return self.wait_for_output(_console_line('A : ', DEFAULTS['ipv4']), timeout=timeout)
|
||||
|
||||
def wait_for_browse_goodbye(self, timeout=10):
|
||||
"""Wait for Bonjour-style PTR TTL=0 removal notification (interface line then PTR)."""
|
||||
self.wait_for_output('TTL: 0', timeout=timeout)
|
||||
return self.wait_for_output(_console_line('PTR : ', DEFAULTS['instance']), timeout=timeout)
|
||||
|
||||
def terminate(self):
|
||||
self.send_input('exit')
|
||||
self.get_output('Exit')
|
||||
@@ -37,6 +63,67 @@ class MdnsConsole:
|
||||
assert self.process.exitstatus == 0
|
||||
|
||||
|
||||
def _bonjour_responder_cmd(srv_first: bool = False, goodbye_after: int = -1) -> list[str]:
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(Path(__file__).with_name('bonjour_order_responder.py')),
|
||||
'--interface',
|
||||
'eth0',
|
||||
'--service',
|
||||
DEFAULTS['service'],
|
||||
'--proto',
|
||||
DEFAULTS['proto'],
|
||||
]
|
||||
if srv_first:
|
||||
cmd.append('--srv-first')
|
||||
if goodbye_after >= 0:
|
||||
cmd.extend(['--goodbye-after', str(goodbye_after)])
|
||||
return cmd
|
||||
|
||||
|
||||
def _run_bonjour_responder(*, srv_first: bool = False, goodbye_after: int = -1, label: str):
|
||||
proc = subprocess.Popen(
|
||||
_bonjour_responder_cmd(srv_first=srv_first, goodbye_after=goodbye_after),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
# poll() stays None while the responder is still running after startup.
|
||||
assert proc.poll() is None, f'{label} failed to start (need eth0 and port 5353?)'
|
||||
return proc
|
||||
|
||||
|
||||
def _stop_bonjour_responder(proc):
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=3)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bonjour_responder():
|
||||
proc = _run_bonjour_responder(label='Bonjour-order responder')
|
||||
yield proc
|
||||
_stop_bonjour_responder(proc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bonjour_responder_srv_first():
|
||||
proc = _run_bonjour_responder(srv_first=True, label='SRV-first responder')
|
||||
yield proc
|
||||
_stop_bonjour_responder(proc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bonjour_responder_goodbye():
|
||||
proc = _run_bonjour_responder(goodbye_after=1, label='Goodbye responder')
|
||||
yield proc
|
||||
_stop_bonjour_responder(proc)
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def mdns_console():
|
||||
app = MdnsConsole('./build_linux_console/mdns_host.elf')
|
||||
@@ -219,5 +306,48 @@ def test_service_remove_all(mdns_console, dig_app):
|
||||
dig_app.check_record('_test._tcp.local', query_type='PTR', expected=False)
|
||||
|
||||
|
||||
def _browse_v301test(mdns_console, *, reset_browse: bool = True):
|
||||
service = DEFAULTS['service']
|
||||
proto = DEFAULTS['proto']
|
||||
if reset_browse:
|
||||
mdns_console.send_input(f'mdns_browse_del {service} {proto}')
|
||||
mdns_console.get_output('mdns>')
|
||||
mdns_console.send_input(f'mdns_browse {service} {proto}')
|
||||
return mdns_console.wait_for_browse_result(timeout=10)
|
||||
|
||||
|
||||
def test_browse_bonjour_additional_order_includes_ip(mdns_console, bonjour_responder):
|
||||
"""Bonjour order (A/AAAA before SRV) must populate browse addresses on first response."""
|
||||
output = _browse_v301test(mdns_console)
|
||||
assert _console_line('PTR : ', DEFAULTS['instance']) in output
|
||||
assert _console_line('A : ', DEFAULTS['ipv4']) in output
|
||||
mdns_console.send_input(f"mdns_browse_del {DEFAULTS['service']} {DEFAULTS['proto']}")
|
||||
mdns_console.get_output('mdns>')
|
||||
|
||||
|
||||
def test_browse_srv_first_includes_ip(mdns_console, bonjour_responder_srv_first):
|
||||
"""Control case: SRV before A/AAAA should populate browse addresses."""
|
||||
output = _browse_v301test(mdns_console)
|
||||
assert _console_line('PTR : ', DEFAULTS['instance']) in output
|
||||
assert _console_line('A : ', DEFAULTS['ipv4']) in output
|
||||
mdns_console.send_input(f"mdns_browse_del {DEFAULTS['service']} {DEFAULTS['proto']}")
|
||||
mdns_console.get_output('mdns>')
|
||||
|
||||
|
||||
def test_browse_ptr_goodbye_notifies_removal(mdns_console, bonjour_responder_goodbye):
|
||||
"""Bonjour service removal via standalone PTR TTL=0 must notify browse consumers."""
|
||||
service = DEFAULTS['service']
|
||||
proto = DEFAULTS['proto']
|
||||
mdns_console.send_input(f'mdns_browse_del {service} {proto}')
|
||||
mdns_console.get_output('mdns>')
|
||||
mdns_console.send_input(f'mdns_browse {service} {proto}')
|
||||
mdns_console.wait_for_browse_result(timeout=10)
|
||||
mdns_console.send_input(f'mdns_browse {service} {proto}')
|
||||
output = mdns_console.wait_for_browse_goodbye(timeout=10)
|
||||
assert _console_line('PTR : ', DEFAULTS['instance']) in output
|
||||
mdns_console.send_input(f'mdns_browse_del {service} {proto}')
|
||||
mdns_console.get_output('mdns>')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main(['-s', 'test_mdns.py'])
|
||||
|
||||
Reference in New Issue
Block a user