When IPs aren't enough
You just finished a local multiplayer game server. You hand the binary to a friend. They ask, "What's the IP address?" You shrug. DHCP gave the machine a random number that changes every reboot. You don't want to scan the network manually. You don't want to hardcode addresses. You want the game to just appear on their screen like a printer or an AirPlay target.
That's the problem mDNS solves. It lets devices shout "I'm here!" and other devices listen without needing a central directory. Rust doesn't include mDNS in the standard library because it's a network protocol, not a language feature. The ecosystem fills the gap with the mdns-sd crate. It handles the socket management, packet encoding, and multicast logic so you can focus on your service.
mDNS and DNS-SD explained
Standard DNS relies on a hierarchy of servers. You ask a root server, which points to a top-level domain, which points to a nameserver. mDNS throws that hierarchy out for local networks. It uses multicast UDP. Every device listens on a specific address. When a service starts, it broadcasts a packet. Anyone listening hears it.
DNS-SD (DNS Service Discovery) is the structure of that packet. It tells you the name, the type, the port, and the host. Think of mDNS as the megaphone and DNS-SD as the script you read into it. The script says, "I am a web server, I run on port 8080, and my name is 'my-web'."
The protocol uses the .local. domain. This is reserved for multicast DNS. You won't see .local. on the public internet. It stays on your LAN. The service type follows a strict format: _service._protocol.local.. For a TCP web server, that's _http._tcp.local.. The underscores matter. They signal to the resolver that this is a service type, not a hostname.
Registering a service
The mdns-sd crate provides a ServiceDaemon to manage the network lifecycle. You create the daemon, define a ServiceInfo record, register it, and run the loop.
[dependencies]
mdns-sd = "0.11"
use mdns_sd::{ServiceDaemon, ServiceInfo};
/// Registers a simple HTTP service and keeps it alive.
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create the daemon. This opens the multicast socket.
let daemon = ServiceDaemon::new()?;
// Build the service record.
// Type: _http._tcp.local.
// Name: my-web
// Host: empty string means use the default interface.
// Port: 8080
let service = ServiceInfo::new(
"_http._tcp.local.",
"my-web",
"",
"192.168.1.10",
8080,
None,
)?;
// Send the announcement. Other devices will hear this.
daemon.register(service)?;
// Block and handle queries.
// If this returns, the daemon stopped.
daemon.run()?;
Ok(())
}
The ServiceInfo::new call validates the arguments. If the type string is malformed, it returns an error immediately. The register method sends the multicast announcement. The run call starts the event loop. It stays alive to answer queries. If another device asks, "Who is my-web?", the daemon responds with the IP and port.
Keep the daemon alive. If the daemon drops, the service vanishes from the network cache.
What happens under the hood
When you call ServiceDaemon::new, the crate opens a UDP socket bound to the multicast address 224.0.0.251 on port 5353. This is the reserved address for mDNS traffic. The socket is configured for multicast loopback. This means your own machine receives the packets you send. It lets you test discovery locally without a second device.
The ServiceInfo struct holds the DNS records. It contains the service name, the host address, the port, and optional TXT records. TXT records are key-value pairs that carry metadata. You can store a version number, a username, or capability flags there.
When you call register, the daemon constructs a DNS message. It includes a PTR record pointing to the service, a SRV record with the port and host, and an A record with the IP address. The daemon sends this message to the multicast group. It also schedules retransmissions. Network packets drop. The protocol requires multiple announcements to ensure reliability. The daemon handles this timing automatically.
Other devices receive the packet and update their local cache. They now know that my-web._http._tcp.local. exists. If they query for that name, the daemon responds with the full record set.
Trust the multicast address. It's the universal handshake for local discovery.
Browsing and metadata
Real applications usually browse for services rather than just registering them. Browsing returns a stream of events. You don't get the data immediately. mDNS separates discovery from resolution. You see the name first. Then the daemon asks for the IP. Then you get the address. This keeps the initial discovery fast.
use mdns_sd::{ServiceDaemon, ServiceEvent};
use std::collections::HashMap;
use std::time::Duration;
/// Browses for HTTP services and prints details.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let daemon = ServiceDaemon::new()?;
// Start browsing for HTTP services.
// Returns a receiver for events.
let mut browser = daemon.browse("_http._tcp.local.", None)?;
// Listen for 10 seconds.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while std::time::Instant::now() < deadline {
// Block briefly to check for events.
// recv_timeout prevents hanging forever.
if let Some(event) = browser.recv_timeout(Duration::from_millis(100)) {
match event {
// A new service appeared.
ServiceEvent::ServiceFound(_, name) => {
println!("Found: {}", name);
}
// The daemon resolved the IP and port.
ServiceEvent::ServiceResolved(info) => {
if let Some(addr) = info.get_addresses_v4().next() {
println!("Resolved: {} at {} port {}", info.get_fullname(), addr, info.get_port());
// Check TXT records for metadata.
if let Some(txt) = info.get_properties() {
if let Some(version) = txt.get("version") {
println!(" Version: {}", version.val_str());
}
}
}
}
// Service disappeared.
ServiceEvent::ServiceRemoved(_, name) => {
println!("Removed: {}", name);
}
_ => {}
}
}
}
Ok(())
}
The browse method returns a receiver. You poll this receiver for ServiceEvent variants. ServiceFound tells you a service exists. ServiceResolved gives you the full ServiceInfo. ServiceRemoved signals the service went offline. The daemon handles the resolution queries in the background. You just react to the events.
You can add TXT records to your service to share metadata. This is common for versioning or identifying capabilities.
use std::collections::HashMap;
fn create_service_with_metadata() -> Result<ServiceInfo, Box<dyn std::error::Error>> {
// TXT records are key-value pairs.
let mut txt = HashMap::new();
txt.insert("version".to_string(), "1.0".to_string());
txt.insert("host".to_string(), "rust-server".to_string());
// Pass the TXT map as the last argument.
let service = ServiceInfo::new(
"_http._tcp.local.",
"my-web",
"",
"192.168.1.10",
8080,
Some(txt),
)?;
Ok(service)
}
The community convention for TXT keys is lowercase. Parsers treat keys as case-insensitive, but lowercase avoids confusion. Store simple strings. Binary data requires base64 encoding in the value.
Handle the events. Discovery is a stream, not a one-time query.
Pitfalls and validation
mDNS is picky about format. The service type must end in .local.. If you pass _http._tcp, the crate rejects it. You'll get a configuration error from ServiceInfo::new. The error message usually points to the invalid type string.
Multicast traffic often hits firewalls. If your service doesn't appear, check that UDP port 5353 is allowed. Many desktop firewalls block incoming multicast by default. You may need to add a rule.
Interface selection can cause noise. By default, the daemon binds to all interfaces. On machines with many interfaces, this sends announcements everywhere. You can filter interfaces using IfKind. This limits traffic to specific network adapters.
use mdns_sd::IfKind;
// Bind only to IPv4 interfaces.
let daemon = ServiceDaemon::new_with_config(
mdns_sd::DaemonConfig::new().with_interface_kind(IfKind::V4)
)?;
If you forget to handle the Result from ServiceInfo::new, the compiler rejects you with E0277 (trait bound not satisfied) if you try to use the value in a context that requires a valid service. Always check the result.
Check the firewall. mDNS dies silently if UDP 5353 is blocked.
Choosing the right approach
Use mdns-sd when you need standard mDNS/DNS-SD compliance for local network discovery. Use ServiceDaemon for synchronous applications where you want a simple blocking loop. Use the async interface of mdns-sd when your application runs on tokio or async-std and you need non-blocking discovery. Use raw UDP sockets only if you are implementing a proprietary protocol that mimics mDNS transport but ignores the DNS-SD service registry.
Pick the interface that matches your runtime. Sync for scripts, async for servers.