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.
Contents
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:
- Probing (§8.1): after a random initial delay, sends
probe_count(default 3) probe queries for the candidate name's A record, each carrying the proposed record in the Authority section, spacedprobe_intervalapart. - Conflict during probing (§8.1): an incoming response that already asserts the candidate name (from an established host) restarts probing from scratch under a renamed candidate (
label-2.local,label-3.local, ...). - Simultaneous probing (§8.2): an incoming query for the same candidate name, itself carrying a proposed record in its Authority section, is a tie-break rather than a conflict — the two proposed A records are compared unsigned byte-wise (one representative record, not the full RRset ordering the RFC technically specifies); the loser waits
probe_conflict_waitand retries the same name (not a rename, since the other host lost equally and nothing says it will keep contesting it). - Announcing (§8.3): once probing completes uncontested, sends
announce_count(default 2) unsolicited multicast responses,announce_intervalapart, carrying every published record (hostname A record(s) plus any registered DNS-SD records) with the cache-flush bit set. - Answering queries: while announced, a query matching a published name/type gets a response — multicast by default, or unicast back to the querier if any question in the message carried the QU bit. Known-answer suppression (§7.1) is implemented: a record the query already lists as a known answer with more than half its TTL remaining is skipped.
- Goodbye (§10.1):
MdnsService::goodbye()sends a TTL-0 response for every published record;Dropcalls it automatically (best-effort) if the service is still announced when the last handle goes away, and dropping aServiceHandlesends a targeted goodbye for just that service's records (or folds into the next re-announcement if the responder hasn't finished announcing yet).
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.
- Active TTL refresh (§5.2): each cached record schedules a refresh query at 80/85/90/95% of its TTL, and expires at 100% if never refreshed. Each stage captures the record's generation (bumped on every upsert) so a stale timer from a since-superseded record is a harmless no-op.
- Cache-flush handling (§10.2): when an incoming answer group for a name/type carries the cache-flush bit, every currently cached record for that key not reasserted in the same group is scheduled for removal after a short grace period (1 second) rather than deleted immediately — tolerating the flush answer arriving split across more than one packet.
- Goodbye handling (§10.1): a TTL-0 answer schedules the matching cached record for the same grace-period removal.
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:
- PTR
<type>.local→<instance>.<type>.local— shared (not cache-flush; other instances share the same owner name, per §10.1). - SRV
<instance>.<type>.local→ this responder's own hostname, with the registered port (priority and weight are always 0) — cache-flush. - TXT on the same owner name, encoding the registration's key/value pairs as RFC 6763 §6.1 character-strings (a value over 255 bytes is dropped rather than silently truncated; no attributes still emits one required zero-length string) — cache-flush.
- Meta-PTR (§9):
_services._dns-sd._udp.local→<type>.local, so a client enumerating all advertised service types on the network finds this one — shared, not cache-flush.
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:
- IPv6 — no
ff02::fbgroup, no AAAA records advertised or queried. - RFC 6763 §4.3 instance-name escaping — special characters (
.,\) in a service instance name are not escaped, so one containing them produces a technically-invalid but still-usable-in-practice owner name. - SRV priority/weight — always published as 0/0; not configurable per registration.
- Conflict tie-break compares one representative record (the hostname's first address) unsigned byte-wise, not the full lexicographic RRset ordering RFC 6762 §8.2 technically specifies.
- No central service registry — unlike a pull-based DNS-SD advertiser that walks a server's own listener registry, every service this crate advertises must be registered explicitly via
register_service; there is no workspace-wide registry to draw from since every Hopf protocol is an independent crate. - Browse re-poll is a fixed 10-second interval layered on top of the cache's own TTL-fraction refresh, not a dynamic schedule.
- No W3C Trace Context propagation and no process-local metrics counters (contrast DNS's telemetry).