mDNS

Crate hopf-mdns: Multicast DNS (RFC 6762) and DNS-SD (RFC 6763) over UDP — both a responder (probe, announce, answer queries, goodbye) for one hostname and any number of DNS-SD services, and a querier with an active-refresh cache, wrapped in a push-based browse/register_service API. Reuses hopf-dns's wire types directly rather than a parallel codec, and is entirely async on the same reactor/timer model as the rest of Hopf.

Standards

Reference Role
RFC 6762 Multicast DNS: probing, simultaneous-probe tie-break, announcing, cache-flush, known-answer suppression, unicast-response (QU) bit, goodbye
RFC 6763 DNS-SD: service instance enumeration (PTR/SRV/TXT), the _services._dns-sd._udp meta-query, TXT record attributes

Architecture

App
 │  MdnsService::start(&rt, hostname_label, addresses)
 │    ├── responder: begin_probing → send_next_probe × N → begin_announcing
 │    │     └── register_service / ServiceHandle (DNS-SD)
 │    ├── querier: query(name, qtype, cb) / browse(service_type, cb)
 │    └── cache: MdnsCache (TTL-fraction refresh, cache-flush/goodbye grace)
 │
 └── socket::listen_mdns_udp — one UDP socket, group 224.0.0.251:5353,
       registered on a hopf-core ReactorHandle worker

This crate does not reimplement DNS wire format: hopf_dns::wire::{DnsMessage, DnsQuestion, DnsResourceRecord, DnsType, DnsClass} are used directly for parsing, serialising and constructing records (A, PTR, SRV, TXT). The bits module layers only the two mDNS-specific high-bit conventions on top — the QU "unicast response requested" bit (top bit of a question's QCLASS, §5.4) and the cache-flush bit (top bit of a record's CLASS, §10.2) — as pure bitmask helpers over raw_qclass/raw_class, since a class value with either bit set matches no DnsClass variant by design (RFC 3597 unknown-value preservation).

Every timer (probe/announce cadence, cache TTL-fraction refresh, goodbye/cache-flush grace period, browse re-poll, query timeout) is a one-shot hopf_core::ReactorHandle::schedule_timer callback that re-arms itself on firing — there is no repeating-timer primitive in hopf-core to lean on instead — and every send goes through ReactorHandle::udp_send, which only enqueues and never blocks the caller. The one synchronous stretch is one-time socket setup (bind, multicast join, register_udp) in socket::listen_mdns_udp, the same shape hopf-dns's own listen_dns_udp already uses. Public methods on MdnsService (and the handles it returns) may be called from any thread; internal state (responder::Shared) sits behind one Mutex that is never held across I/O.

IPv4 only: the mDNS group ff02::fb and IPv6 address records are not implemented.

Responder: probing, announcing, conflicts

MdnsService::start takes a hostname label and the caller's own IPv4 address(es) — this crate has no interface-enumeration utility, so, matching its push-rather-than-pull design throughout, the application supplies its routable addresses rather than the crate guessing at them. It then runs the full RFC 6762 §8 state machine before any record is served:

Querier and cache

MdnsService::query(name, qtype, cb) fires a one-shot multicast query and resolves cb with whatever is cached once query_timeout elapses, rather than racing to resolve the instant a matching answer arrives — a deliberate v1 simplification, since resolving on the first answer would ignore a slower-but-still-answering-within-a-second responder. MdnsService::lookup(name, qtype) is a synchronous, non-blocking peek at the cache with no query sent.

The cache (MdnsCache) is a pure, timer-free data structure — it never touches hopf-core itself; the responder owns the real ReactorHandle and calls back into it when scheduled timers fire, which keeps the cache's own logic testable as plain data in / data out.

DNS-SD: advertise and browse

MdnsService::register_service(ServiceRegistration { service_type, instance_name, port, txt }) builds and publishes one service instance's RRset, then re-announces:

The target host is always this responder's own current name (already probed for conflicts), so registering a service republishes the current RRset rather than running a separate probe cycle for the new records — close enough to RFC 6763 §8.3's "adding a new instance" flow for a first version. Dropping the returned ServiceHandle (or calling unregister()) removes that instance's records and announces the change.

MdnsService::browse(service_type, cb) queries the type's PTR periodically (every 10 seconds, layered on top of the cache's own per-record active refresh so a brand-new instance is found promptly rather than waiting for something else's TTL to near expiry), resolves each returned instance's SRV and TXT from the cache, and delivers BrowseEvent::Found { instance, host, port, txt } / BrowseEvent::Lost { instance } to the callback as instances appear or drop out of the PTR set. TXT values without an = are reported with an empty value. The returned BrowseHandle stops the browse when dropped.

Configuration

Timing (all fields public, defaults match RFC 6762 exactly):

Field Default Meaning
probe_initial_delay_max 250 ms §8.1: upper bound of the random delay before the first probe
probe_interval 250 ms §8.1: delay between probes
probe_count 3 §8.1: probes sent before announcing
probe_conflict_wait 1 s §8.2: wait before retrying after losing a simultaneous-probe tie-break
announce_interval 1 s §8.3: delay between announcements
announce_count 2 §8.3: announcements sent
record_ttl 120 s TTL applied to published records (not RFC-mandated as a single value; a longer conventional TTL for PTR records isn't distinguished from host/service records in this first version)
query_timeout 750 ms How long query waits before resolving from whatever is cached by then

MdnsService::start_with_timing(&rt, hostname_label, addresses, timing, multicast_if) takes a non-default Timing and, optionally, a single local interface (Ipv4Addr) to scope mDNS to — for multi-homed hosts, or a test pinned to loopback since not every host's default route is multicast-capable. listen_mdns_udp also sets IP/multicast TTL to 255 (§11), enables multicast loopback, and sets SO_REUSEADDR/SO_REUSEPORT so more than one mDNS-aware process can coexist on the host (§15.1).

Cargo features

Feature Effect
(default) Responder (probe/announce/goodbye), querier + cache, DNS-SD advertise/browse — a standalone crate with no optional pieces
integration Real loopback-multicast round-trip tests (tests/mdns_stub.rs); not compiled into the default --lib test run

Depends on hopf-core and hopf-dns (for wire types) plus mio, socket2 and getrandom. The umbrella hopf crate exposes it via feature mdns, which also pulls in dns.

Quick start

use std::sync::Arc;
use hopf_core::Runtime;
use hopf_mdns::{MdnsService, ServiceRegistration};

let rt = Arc::new(Runtime::start(Default::default())?);
let mdns = MdnsService::start(&rt, "my-host", vec![local_ipv4_addr])?;

// Advertise a service via DNS-SD.
let _service = mdns.register_service(ServiceRegistration {
    service_type: "_http._tcp".into(),
    instance_name: "My Web Server".into(),
    port: 8080,
    txt: vec![("path".into(), "/".into())],
});

// Browse for other instances of the same type.
let _browse = mdns.browse("_http._tcp", |event| {
    println!("{event:?}");
});

Smoke: cargo test -p hopf-mdns --features integration (tests/mdns_stub.rs) — real loopback-multicast round trips: probe-then-announce with the right record, a name conflict renaming the loser, known-answer suppression, DNS-SD register-and-browse between two separate responder instances, and goodbye on drop.

Limitations

Explicitly out of scope in this first version: