diff --git a/components/mdns/include/mdns.h b/components/mdns/include/mdns.h index 4c89dde73..c515e8a31 100644 --- a/components/mdns/include/mdns.h +++ b/components/mdns/include/mdns.h @@ -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); diff --git a/components/mdns/mdns_browser.c b/components/mdns/mdns_browser.c index c74a256bb..bd933b210 100644 --- a/components/mdns/mdns_browser.c +++ b/components/mdns/mdns_browser.c @@ -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) diff --git a/components/mdns/mdns_receive.c b/components/mdns/mdns_receive.c index ab7ff1f52..96ae09cca 100644 --- a/components/mdns/mdns_receive.c +++ b/components/mdns/mdns_receive.c @@ -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; diff --git a/components/mdns/private_include/mdns_browser.h b/components/mdns/private_include/mdns_browser.h index 30b7f7ac2..f89ecd984 100644 --- a/components/mdns/private_include/mdns_browser.h +++ b/components/mdns/private_include/mdns_browser.h @@ -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); diff --git a/components/mdns/tests/host_test/bonjour_order_responder.py b/components/mdns/tests/host_test/bonjour_order_responder.py new file mode 100644 index 000000000..7ec9ec6f9 --- /dev/null +++ b/components/mdns/tests/host_test/bonjour_order_responder.py @@ -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()) diff --git a/components/mdns/tests/host_test/pytest_mdns.py b/components/mdns/tests/host_test/pytest_mdns.py index 8c323034f..e0b7d0136 100644 --- a/components/mdns/tests/host_test/pytest_mdns.py +++ b/components/mdns/tests/host_test/pytest_mdns.py @@ -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'])