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:
David Cermak
2026-05-27 11:53:13 +02:00
parent 1c26376cd9
commit 26bfcd14b0
6 changed files with 697 additions and 22 deletions
+41 -2
View File
@@ -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);
+168 -2
View File
@@ -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)
+52 -17
View File
@@ -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;
+54 -1
View File
@@ -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'])