Skip to content

Add support for RFC 6762 legacy unicast responses#469

Draft
luqs1 wants to merge 2 commits intokeepsimple1:mainfrom
luqs1:legacy-unicast
Draft

Add support for RFC 6762 legacy unicast responses#469
luqs1 wants to merge 2 commits intokeepsimple1:mainfrom
luqs1:legacy-unicast

Conversation

@luqs1
Copy link
Copy Markdown

@luqs1 luqs1 commented May 8, 2026

Hi, thanks for your package. I just wanted to contribute some code that my clanker wrote up. It helped me get mdns working when hosted on my macbook from my pixel 8. The rest of this PR is AI generated but hopefully still useful to read haha.

Browsers on Android can't resolve .local names published by mdns-sd. They resolve names published by Apple's mDNSResponder fine, and an NsdManager-based app on the same phone sees the records mdns-sd is publishing — so the records are reaching the phone, the browser just isn't getting them.

The reason is that Android's getaddrinfo (Mainline DnsResolver, since November 2021) issues mDNS queries from an ephemeral source port and waits for a unicast reply on that port. This is the "legacy/one-shot" case described in RFC 6762 §6.7. mdns-sd today always replies via multicast from port 5353 — so the answer goes to 224.0.0.251:5353, which the ephemeral getaddrinfo socket isn't listening on, and the lookup times out. The author's own comment at service_daemon.rs:790 flags this:

// Here we set the TTL to 255 for multicast as we don't support unicast yet.

Same shape on the wire. Before this patch, with my Mac publishing knock-knock.local:

phone fe80::bc35:….50742 > ff02::fb.5353: A (QM)? knock-knock.local.
mac   fe80::186a:….5353  > ff02::fb.5353: 4/0/0 knock-knock.local. AAAA …  ← phone never sees this

After:

mac   fe80::186a:….5353  > fe80::bc35:….50742: 1/0/0 A 192.168.1.247  ← unicast back to the ephemeral port

Chrome, Firefox, and DuckDuckGo on Android 16 all load http://knock-knock.local:8000/ immediately after the patch. macOS and iOS were never broken — their resolvers go through Bonjour, not the legacy unicast path.

What's in the diff

Three logically-distinct changes, all in service_daemon.rs plus a small helper in dns_parser.rs. Happy to split into separate PRs if you'd rather land them independently — they're somewhat orthogonal but all needed for the Android case.

Routing the response unicast for legacy queriers. pktinfo.addr_src already had the full SocketAddr of the querier; we were calling .ip() on it and dropping the port. Plumb the full address through handle_query, and when the source port isn't 5353, send the response unicast to it instead of multicasting. New unicast_on_intf helper mirrors multicast_on_intf for the unicast path. All other callers of send_dns_outgoing* pass None for the new parameter and behave exactly as before.

Echoing the question and clearing the cache-flush bit on legacy responses. §6.7 plus §10.2 — strict legacy resolvers can drop responses that omit the original question section or set the unique bit. Added DnsOutgoing::clear_cache_flush_bits() to walk answers, additionals, and authorities and reset.

Picking the address set by question type, not socket family. Independent bug found while debugging the above. Today the address set is selected from is_ipv4 = querier_ip.is_ipv4() — i.e. based on which socket received the question. So an A question received over IPv6 transport gets answered with AAAA records (correctly tagged by ip_address_rr_type, but the wrong record type for the question that was asked). Android's getaddrinfo happily sends both A and AAAA queries over its preferred IPv6 mDNS socket, so this was the second reason resolution didn't complete after the unicast fix landed. Now RRType::A returns IPv4 addresses, RRType::AAAA returns IPv6 addresses, RRType::ANY returns both, regardless of which socket the question came in on.

While I was in there I also flipped a return to continue on the empty-addrs branch — the previous code bailed out of the entire handle_query if any single A/AAAA question on the interface had no matching addresses, which silently dropped answers for sibling questions in the same query.

What I didn't change

  • The response TTL is unchanged. RFC §6.7 recommends ≤10s for legacy unicast; in practice modern resolvers don't seem to care, and Android accepts the existing default fine. Easy enough to add later behind the same unicast_dest.is_some() branch.
  • The QU bit on questions is still being masked away in dns_parser.rs:281-284 as cache-flush. Honoring QU is a stricter form of the same problem and is orthogonal — it only matters for queriers sourcing from port 5353 that explicitly request unicast (see also draft Implemented unicast responses to direct unicast, unicast questions and legacy unicast queries. #190, which addressed the QU case but not the legacy port case). Could be a follow-up.
  • I have not added unit tests. The behavior is cleanly observable on the wire — open an ephemeral UDP socket, send a question, assert the reply arrives unicast on that socket — happy to write that if you'd like before merge.

Testing

Reproduced and verified on an Android 16 Pixel 8 against a macOS 15.x host running this branch, with a small axum HTTP server bound to 0.0.0.0:8000 whose mDNS hostname is published as knock-knock.local. via ServiceInfo::new(...).enable_addr_auto().

  • Before: Chrome / Firefox / DuckDuckGo time out on http://knock-knock.local:8000/. NsdManager apps see the service. macOS dns-sd -q knock-knock.local resolves immediately.
  • After: all three browsers load on first attempt. macOS and iOS unaffected. tcpdump confirms responses go unicast to the querier's source port for legacy queriers and stay multicast for proper mDNS clients.

luqs1 added 2 commits May 8, 2026 23:51
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.
Copy link
Copy Markdown
Owner

@keepsimple1 keepsimple1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your PR. Nice fixes and a real useful enhancement!

Yes please add a test and then move it out of Draft state. I'd keep all changes together instead of separated PRs as they are needed for solving the same use case.

Comment thread src/service_daemon.rs
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 {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix! Just a note that now an ANY query received on IPv4 socket can return AAAA records as well. I think that's OK.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants