From cff86358f8a7d91f3d43a2e3fe0ef1111cfd929a Mon Sep 17 00:00:00 2001 From: Luqmaan Ahmed Date: Fri, 8 May 2026 23:51:49 +0100 Subject: [PATCH 1/4] Add support for RFC 6762 legacy unicast responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers on Android (and similar one-shot resolvers like the legacy fallback path on iOS, Windows getaddrinfo, etc.) issue mDNS queries from an ephemeral source port and listen for unicast replies on that port, not on multicast 224.0.0.251:5353. The current implementation always multicasts replies, so these resolvers never receive an answer. Three changes: - Route the response unicast back to the querier's source IP+port when it isn't 5353 (RFC 6762 §6.7). pktinfo.addr_src already carries the full SocketAddr; we were dropping the port. Plumb the full address through handle_query, add an unicast_dest parameter to send_dns_outgoing/_impl, and a sibling unicast_on_intf helper. - Echo the question section and clear the cache-flush bit on legacy unicast responses (§6.7 + §10.2). Strict legacy resolvers can drop responses that omit the original question or set the unique bit. Adds DnsOutgoing::clear_cache_flush_bits(). - Pick the answer address set by question type, not socket family. Previously is_ipv4 was derived from the receiving socket, so an A question received over IPv6 transport was answered with AAAA records. Android getaddrinfo routinely sends both A and AAAA queries over its preferred IPv6 mDNS socket, so this was the second reason resolution did not complete after the unicast fix. RRType::A now returns IPv4 addrs, RRType::AAAA returns IPv6, RRType::ANY returns both, regardless of transport. Also flips a return to continue in the empty-addrs branch of the A/AAAA handler, which previously bailed out of the entire handle_query if any single question on the interface had no matching addresses. --- src/dns_parser.rs | 16 +++++++ src/service_daemon.rs | 101 +++++++++++++++++++++++++++++++++--------- 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/src/dns_parser.rs b/src/dns_parser.rs index 3ce5b35..9e5ad3f 100644 --- a/src/dns_parser.rs +++ b/src/dns_parser.rs @@ -1915,6 +1915,22 @@ impl DnsOutgoing { self.questions.push(q); } + /// Clear the cache-flush (unique) bit on every answer and additional + /// record. Required for RFC 6762 §6.7 (Legacy Unicast Responses) and + /// §10.2 — a legacy resolver doesn't know about the cache-flush bit + /// and may misinterpret responses where it is set. + pub fn clear_cache_flush_bits(&mut self) { + for (rec, _) in &mut self.answers { + rec.get_record_mut().entry.cache_flush = false; + } + for rec in &mut self.additionals { + rec.get_record_mut().entry.cache_flush = false; + } + for rec in &mut self.authorities { + rec.get_record_mut().entry.cache_flush = false; + } + } + /// Returns a list of actual DNS packet data to be sent on the wire. pub fn to_data_on_wire(&self) -> Vec> { let packet_list = self.to_packets(); diff --git a/src/service_daemon.rs b/src/service_daemon.rs index 0ee5f61..fe9dfca 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -2144,14 +2144,14 @@ impl Zeroconf { trace!("sending out probing of questions: {:?}", out.questions()); if let Some(sock) = self.ipv4_sock.as_mut() { if let Err(InternalError::IntfAddrInvalid(intf_addr)) = - send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None) + send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None, None) { invalid_intf_addrs.insert(intf_addr); } } if let Some(sock) = self.ipv6_sock.as_mut() { if let Err(InternalError::IntfAddrInvalid(intf_addr)) = - send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None) + send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None, None) { invalid_intf_addrs.insert(intf_addr); } @@ -2314,7 +2314,7 @@ impl Zeroconf { } // Only (at most) one packet is expected to be sent out. - let sent_vec = match send_dns_outgoing(&out, intf, sock, self.port, None) { + let sent_vec = match send_dns_outgoing(&out, intf, sock, self.port, None, None) { Ok(sent_vec) => sent_vec, Err(InternalError::IntfAddrInvalid(intf_addr)) => { let invalid_intf_addrs = HashSet::from([intf_addr]); @@ -2358,14 +2358,14 @@ impl Zeroconf { let mut invalid_intf_addrs = HashSet::new(); if let Some(sock) = self.ipv4_sock.as_ref() { if let Err(InternalError::IntfAddrInvalid(intf_addr)) = - send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None) + send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None, None) { invalid_intf_addrs.insert(intf_addr); } } if let Some(sock) = self.ipv6_sock.as_ref() { if let Err(InternalError::IntfAddrInvalid(intf_addr)) = - send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None) + send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None, None) { invalid_intf_addrs.insert(intf_addr); } @@ -2402,14 +2402,14 @@ impl Zeroconf { for (_, intf) in self.my_intfs.iter() { if let Some(sock) = self.ipv4_sock.as_ref() { if let Err(InternalError::IntfAddrInvalid(intf_addr)) = - send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None) + send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None, None) { invalid_intf_addrs.insert(intf_addr); } } if let Some(sock) = self.ipv6_sock.as_ref() { if let Err(InternalError::IntfAddrInvalid(intf_addr)) = - send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None) + send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None, None) { invalid_intf_addrs.insert(intf_addr); } @@ -2487,8 +2487,8 @@ impl Zeroconf { match DnsIncoming::new(buf, my_intf.into()) { Ok(msg) => { if msg.is_query() { - let querier_ip = pktinfo.addr_src.ip(); - self.handle_query(msg, pkt_if_index, querier_ip); + let querier_addr = pktinfo.addr_src; + self.handle_query(msg, pkt_if_index, querier_addr); } else if msg.is_response() { self.handle_response(msg, pkt_if_index); } else { @@ -3114,7 +3114,8 @@ impl Zeroconf { } /// Handle incoming query packets, figure out whether and what to respond. - fn handle_query(&mut self, msg: DnsIncoming, if_index: u32, querier_ip: IpAddr) { + fn handle_query(&mut self, msg: DnsIncoming, if_index: u32, querier_addr: SocketAddr) { + let querier_ip = querier_addr.ip(); let is_ipv4 = querier_ip.is_ipv4(); let sock_opt = if is_ipv4 { &self.ipv4_sock @@ -3179,11 +3180,19 @@ impl Zeroconf { let service_hostname = dns_registry.resolve_name(service.get_hostname()); if service_hostname.to_lowercase() == question.entry_name().to_lowercase() { - let intf_addrs = if is_ipv4 { - service.get_addrs_on_my_intf_v4(intf) - } else { - service.get_addrs_on_my_intf_v6(intf) - }; + // Pick addresses based on the question type, not the + // socket family. RFC 6762 doesn't require A queries + // to come over IPv4 transport — Android's getaddrinfo + // routinely sends both A and AAAA queries over its + // preferred IPv6 mDNS socket and expects A records + // to be answered with v4 addresses. + let mut intf_addrs: Vec = Vec::new(); + if qtype == RRType::A || qtype == RRType::ANY { + intf_addrs.extend(service.get_addrs_on_my_intf_v4(intf)); + } + if qtype == RRType::AAAA || qtype == RRType::ANY { + intf_addrs.extend(service.get_addrs_on_my_intf_v6(intf)); + } if intf_addrs.is_empty() && (qtype == RRType::A || qtype == RRType::AAAA) { @@ -3197,7 +3206,7 @@ impl Zeroconf { t, &intf ); - return; + continue; } for address in intf_addrs { out.add_answer( @@ -3266,8 +3275,28 @@ impl Zeroconf { .iter() .find(|if_addr| valid_ip_on_intf(&querier_ip, if_addr)); + // RFC 6762 §6.7 (Legacy Unicast Responses): if the querier's source + // port is not 5353, it's a one-shot legacy querier (e.g. Android's + // getaddrinfo, iOS resolver fallback). The response MUST be unicast + // back to the querier's source IP and port; multicast replies will + // never reach the querier's ephemeral socket. Legacy unicast + // responses must also echo the question section and clear the + // cache-flush bit, since legacy resolvers don't understand it. + let unicast_dest = if querier_addr.port() != MDNS_PORT { + Some(querier_addr) + } else { + None + }; + + if unicast_dest.is_some() { + for q in msg.questions() { + out.add_question(q.entry_name(), q.entry_type()); + } + out.clear_cache_flush_bits(); + } + if let Err(InternalError::IntfAddrInvalid(intf_addr)) = - send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, matched_source) + send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, matched_source, unicast_dest) { let invalid_intf_addr = HashSet::from([intf_addr]); let _ = self.send_cmd_to_self(Command::InvalidIntfAddrs(invalid_intf_addr)); @@ -4245,6 +4274,7 @@ fn send_dns_outgoing( sock: &PktInfoUdpSocket, port: u16, source: Option<&IfAddr>, + unicast_dest: Option, ) -> MyResult>> { let if_name = &my_intf.name; @@ -4265,7 +4295,7 @@ fn send_dns_outgoing( } }; - send_dns_outgoing_impl(out, if_name, my_intf.index, if_addr, sock, port) + send_dns_outgoing_impl(out, if_name, my_intf.index, if_addr, sock, port, unicast_dest) } /// Send an outgoing mDNS query or response, and returns the packet bytes. @@ -4276,6 +4306,7 @@ fn send_dns_outgoing_impl( if_addr: &IfAddr, sock: &PktInfoUdpSocket, port: u16, + unicast_dest: Option, ) -> MyResult>> { let qtype = if out.is_query() { "query" @@ -4343,11 +4374,40 @@ fn send_dns_outgoing_impl( let packet_list = out.to_data_on_wire(); for packet in packet_list.iter() { - multicast_on_intf(packet, if_name, if_index, if_addr, sock, port); + match unicast_dest { + Some(dest) => unicast_on_intf(packet, if_name, dest, sock), + None => multicast_on_intf(packet, if_name, if_index, if_addr, sock, port), + } } Ok(packet_list) } +/// Sends a unicast packet directly to `dest` (used for RFC 6762 §6.7 +/// legacy unicast responses). +fn unicast_on_intf( + packet: &[u8], + if_name: &str, + dest: SocketAddr, + socket: &PktInfoUdpSocket, +) { + if packet.len() > MAX_MSG_ABSOLUTE { + debug!("Drop over-sized packet ({})", packet.len()); + return; + } + + let sock_addr = dest.into(); + match socket.send_to(packet, &sock_addr) { + Ok(sz) => trace!( + "sent unicast {} bytes on interface {} to {}", + sz, if_name, dest + ), + Err(e) => trace!( + "Failed to send unicast to {} via {:?}: {}", + dest, &if_name, e + ), + } +} + /// Sends a multicast packet, and returns the packet bytes. fn multicast_on_intf( packet: &[u8], @@ -4553,7 +4613,7 @@ fn announce_service_on_intf( ) -> MyResult { let is_ipv4 = sock.domain() == Domain::IPV4; if let Some(out) = prepare_announce(info, intf, dns_registry, is_ipv4) { - let _ = send_dns_outgoing(&out, intf, sock, port, None)?; + let _ = send_dns_outgoing(&out, intf, sock, port, None, None)?; return Ok(true); } @@ -4942,6 +5002,7 @@ mod tests { &intf.addr, &sock.pktinfo, MDNS_PORT, + None, ) .unwrap(); } From 8c566de8a947802c90a3f60c55b1a27c05acebe4 Mon Sep 17 00:00:00 2001 From: Luqmaan Ahmed Date: Sat, 9 May 2026 00:03:56 +0100 Subject: [PATCH 2/4] rustfmt --- src/service_daemon.rs | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/service_daemon.rs b/src/service_daemon.rs index fe9dfca..06e03db 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -3295,9 +3295,14 @@ impl Zeroconf { out.clear_cache_flush_bits(); } - if let Err(InternalError::IntfAddrInvalid(intf_addr)) = - send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, matched_source, unicast_dest) - { + if let Err(InternalError::IntfAddrInvalid(intf_addr)) = send_dns_outgoing( + &out, + intf, + &sock.pktinfo, + self.port, + matched_source, + unicast_dest, + ) { let invalid_intf_addr = HashSet::from([intf_addr]); let _ = self.send_cmd_to_self(Command::InvalidIntfAddrs(invalid_intf_addr)); } @@ -4295,7 +4300,15 @@ fn send_dns_outgoing( } }; - send_dns_outgoing_impl(out, if_name, my_intf.index, if_addr, sock, port, unicast_dest) + send_dns_outgoing_impl( + out, + if_name, + my_intf.index, + if_addr, + sock, + port, + unicast_dest, + ) } /// Send an outgoing mDNS query or response, and returns the packet bytes. @@ -4384,12 +4397,7 @@ fn send_dns_outgoing_impl( /// Sends a unicast packet directly to `dest` (used for RFC 6762 §6.7 /// legacy unicast responses). -fn unicast_on_intf( - packet: &[u8], - if_name: &str, - dest: SocketAddr, - socket: &PktInfoUdpSocket, -) { +fn unicast_on_intf(packet: &[u8], if_name: &str, dest: SocketAddr, socket: &PktInfoUdpSocket) { if packet.len() > MAX_MSG_ABSOLUTE { debug!("Drop over-sized packet ({})", packet.len()); return; @@ -4399,11 +4407,15 @@ fn unicast_on_intf( match socket.send_to(packet, &sock_addr) { Ok(sz) => trace!( "sent unicast {} bytes on interface {} to {}", - sz, if_name, dest + sz, + if_name, + dest ), Err(e) => trace!( "Failed to send unicast to {} via {:?}: {}", - dest, &if_name, e + dest, + &if_name, + e ), } } From e7542fd50d2e24d565c2ac0987e138d3d7c61e57 Mon Sep 17 00:00:00 2001 From: Luqmaan Ahmed Date: Thu, 14 May 2026 12:05:33 +0100 Subject: [PATCH 3/4] Add test for legacy unicast responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sends an A-record query from an ephemeral source port and asserts the response comes back on that same socket. The socket is never joined to the mDNS multicast group, so receiving a reply at all proves it was sent unicast (RFC 6762 §6.7). Also checks the question is echoed and the cache-flush bit is cleared. --- src/service_daemon.rs | 153 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 148 insertions(+), 5 deletions(-) diff --git a/src/service_daemon.rs b/src/service_daemon.rs index 06e03db..e896f69 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -4817,20 +4817,20 @@ mod tests { _new_socket_bind, check_domain_suffix, check_service_name_length, hostname_change, my_ip_interfaces, name_change, send_dns_outgoing_impl, valid_instance_name, valid_ip_on_intf, HostnameResolutionEvent, MyIntf, ServiceDaemon, ServiceEvent, - ServiceInfo, MDNS_PORT, + ServiceInfo, GROUP_ADDR_V4, MDNS_PORT, }; use crate::{ dns_parser::{ - DnsIncoming, DnsOutgoing, DnsPointer, InterfaceId, RRType, ScopedIp, CLASS_IN, - FLAGS_AA, FLAGS_QR_RESPONSE, + DnsEntryExt, DnsIncoming, DnsOutgoing, DnsPointer, InterfaceId, RRType, ScopedIp, + CLASS_IN, FLAGS_AA, FLAGS_QR_QUERY, FLAGS_QR_RESPONSE, }, service_daemon::{add_answer_of_service, check_hostname}, }; use if_addrs::{IfAddr, Ifv4Addr}; use std::{ collections::HashSet, - net::{IpAddr, Ipv4Addr}, - time::{Duration, SystemTime}, + net::{IpAddr, Ipv4Addr, UdpSocket}, + time::{Duration, Instant, SystemTime}, }; use test_log::test; @@ -4884,6 +4884,149 @@ mod tests { assert!(!valid_instance_name("_printer._tcp.local.")); } + #[test] + fn test_legacy_unicast_response() { + // RFC 6762 §6.7: a query whose UDP source port is not 5353 (a + // "legacy" / "one-shot" querier, e.g. Android's getaddrinfo) must + // get its response via unicast, sent back to the querier's source + // address, with the question echoed and the cache-flush bit cleared. + // + // This test sends such a query from an ephemeral port and asserts + // the response arrives on that same socket. The socket is not joined + // to the mDNS multicast group, so a multicast-only reply would never + // reach it — simply receiving the response proves it was unicast. + + let intf_ip = match my_ip_interfaces(false) + .into_iter() + .find_map(|intf| match intf.ip() { + IpAddr::V4(ip) => Some(ip), + IpAddr::V6(_) => None, + }) { + Some(ip) => ip, + None => { + println!("No IPv4 interface available; skipping test."); + return; + } + }; + + // Register a service with a unique hostname on this host. + let daemon = ServiceDaemon::new().expect("Failed to create daemon"); + let unique = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_micros(); + let hostname = format!("legacy-unicast-test-{unique}.local."); + let service_info = ServiceInfo::new( + "_legacy-uni._udp.local.", + "test_instance", + &hostname, + &[IpAddr::V4(intf_ip)] as &[IpAddr], + 5353, // arbitrary; the test only resolves the hostname + None, + ) + .expect("invalid service info"); + daemon.register(service_info).expect("register service"); + + // A one-shot querier: ephemeral source port, not 5353. Binding to + // `intf_ip` directs the multicast query out that interface, which is + // one the daemon is listening on. + let querier = UdpSocket::bind((intf_ip, 0)).expect("bind querier socket"); + querier + .set_multicast_loop_v4(true) + .expect("enable multicast loopback"); + querier + .set_read_timeout(Some(Duration::from_millis(500))) + .expect("set read timeout"); + assert_ne!( + querier.local_addr().unwrap().port(), + MDNS_PORT, + "querier must use an ephemeral (non-5353) source port" + ); + + // Build a one-question A-record query for our hostname. + let mut query = DnsOutgoing::new(FLAGS_QR_QUERY); + query.add_question(&hostname, RRType::A); + let query_packet = query + .to_data_on_wire() + .pop() + .expect("query serialized to one packet"); + + let if_id = InterfaceId { + name: "test".to_string(), + index: 0, + }; + + // The service is announced asynchronously after register(), so retry + // the query until our answer comes back or the deadline passes. + let deadline = Instant::now() + Duration::from_secs(8); + let mut response = None; + 'outer: while Instant::now() < deadline { + querier + .send_to(&query_packet, (GROUP_ADDR_V4, MDNS_PORT)) + .expect("send query"); + + let mut buf = [0u8; 1500]; + loop { + match querier.recv_from(&mut buf) { + Ok((len, from)) => { + let Ok(msg) = DnsIncoming::new(buf[..len].to_vec(), if_id.clone()) else { + continue; + }; + if msg.is_response() + && msg + .answers() + .iter() + .any(|a| a.get_name().eq_ignore_ascii_case(&hostname)) + { + response = Some((msg, from)); + break 'outer; + } + } + Err(_) => break, // read timeout: re-send the query + } + } + } + + let (msg, from) = response.expect( + "expected a unicast response to the legacy query; \ + a multicast-only reply would never reach this un-joined socket", + ); + + // The reply came back to our ephemeral socket, from the mDNS port. + assert_eq!( + from.port(), + MDNS_PORT, + "response should originate from the mDNS port" + ); + + // RFC 6762 §6.7: the original question must be echoed. + assert!( + msg.questions() + .iter() + .any(|q| q.entry_name().eq_ignore_ascii_case(&hostname)), + "legacy unicast response must echo the question section" + ); + + // RFC 6762 §6.7 / §10.2: the answer must be the A record we asked + // for, with the cache-flush bit cleared. + let answer = msg + .answers() + .iter() + .find(|a| a.get_name().eq_ignore_ascii_case(&hostname)) + .expect("response contains an answer for our hostname"); + assert_eq!( + answer.get_type(), + RRType::A, + "an A query should be answered with an A record" + ); + assert!( + !answer.get_cache_flush(), + "legacy unicast responses must clear the cache-flush bit" + ); + + daemon.shutdown().unwrap(); + } + #[test] fn test_check_service_name_length() { let result = check_service_name_length("_tcp", 100); From 15ddc337eaff27a537b2889e5d3b27d787c04f7a Mon Sep 17 00:00:00 2001 From: Luqmaan Ahmed Date: Thu, 14 May 2026 12:08:14 +0100 Subject: [PATCH 4/4] Use while-let loop in legacy unicast test (clippy) --- src/service_daemon.rs | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/service_daemon.rs b/src/service_daemon.rs index e896f69..f6d148f 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -4965,24 +4965,21 @@ mod tests { .send_to(&query_packet, (GROUP_ADDR_V4, MDNS_PORT)) .expect("send query"); + // Drain whatever has arrived; on read timeout the loop ends and + // we re-send the query. let mut buf = [0u8; 1500]; - loop { - match querier.recv_from(&mut buf) { - Ok((len, from)) => { - let Ok(msg) = DnsIncoming::new(buf[..len].to_vec(), if_id.clone()) else { - continue; - }; - if msg.is_response() - && msg - .answers() - .iter() - .any(|a| a.get_name().eq_ignore_ascii_case(&hostname)) - { - response = Some((msg, from)); - break 'outer; - } - } - Err(_) => break, // read timeout: re-send the query + while let Ok((len, from)) = querier.recv_from(&mut buf) { + let Ok(msg) = DnsIncoming::new(buf[..len].to_vec(), if_id.clone()) else { + continue; + }; + if msg.is_response() + && msg + .answers() + .iter() + .any(|a| a.get_name().eq_ignore_ascii_case(&hostname)) + { + response = Some((msg, from)); + break 'outer; } } }