use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use tokio::sync::mpsc;
pub const SERVICE_TYPE: &str = "_codetether-a2a._tcp.local.";
pub struct MdnsHandle {
daemon: Arc<ServiceDaemon>,
fullname: String,
}
impl MdnsHandle {
pub fn shutdown(self) {
let _ = self.daemon.unregister(&self.fullname);
let _ = self.daemon.shutdown();
}
}
impl Drop for MdnsHandle {
fn drop(&mut self) {
let _ = self.daemon.unregister(&self.fullname);
let _ = self.daemon.shutdown();
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DiscoveredPeer {
pub urls: Vec<String>,
pub instance_name: String,
}
pub fn announce_and_browse(
instance_name: &str,
bind_port: u16,
bound_addrs: Vec<IpAddr>,
peer_tx: mpsc::Sender<DiscoveredPeer>,
) -> Result<MdnsHandle> {
let daemon = ServiceDaemon::new().context("Failed to start mDNS daemon")?;
let daemon = Arc::new(daemon);
let mut props: HashMap<String, String> = HashMap::new();
props.insert("name".to_string(), instance_name.to_string());
props.insert("path".to_string(), "/".to_string());
props.insert("protocol".to_string(), "a2a-jsonrpc".to_string());
props.insert("version".to_string(), env!("CARGO_PKG_VERSION").to_string());
let mdns_hostname = format!("{}.local.", sanitize_hostname(instance_name));
let auto_detect = bound_addrs.is_empty();
let addrs: Vec<IpAddr> = if auto_detect {
vec!["127.0.0.1".parse().unwrap()]
} else {
bound_addrs.clone()
};
let mut service = ServiceInfo::new(
SERVICE_TYPE,
instance_name,
&mdns_hostname,
addrs.as_slice(),
bind_port,
Some(props),
)
.context("Failed to construct mDNS ServiceInfo")?;
if auto_detect {
service = service.enable_addr_auto();
}
let fullname = service.get_fullname().to_string();
daemon
.register(service)
.context("Failed to register mDNS service")?;
tracing::info!(
instance = %instance_name,
port = bind_port,
service_type = SERVICE_TYPE,
"Announced A2A peer over mDNS"
);
let receiver = daemon
.browse(SERVICE_TYPE)
.context("Failed to start mDNS browse")?;
let self_fullname = fullname.clone();
let self_port = bind_port;
tokio::task::spawn_blocking(move || {
while let Ok(event) = receiver.recv() {
match event {
ServiceEvent::SearchStarted(svc) => {
tracing::debug!(service = %svc, "mDNS browse search started");
}
ServiceEvent::ServiceFound(svc, fullname) => {
tracing::debug!(service = %svc, fullname = %fullname, "mDNS service found");
}
ServiceEvent::ServiceResolved(info) => {
let info_fullname = info.get_fullname().to_string();
tracing::debug!(
fullname = %info_fullname,
port = info.get_port(),
addrs = ?info.get_addresses(),
"mDNS service resolved"
);
if info_fullname == self_fullname {
continue;
}
let port = info.get_port();
let urls: Vec<String> = info
.get_addresses()
.iter()
.filter(|a| a.is_ipv4())
.filter(|a| !(port == self_port && a.is_loopback()))
.map(|a| format!("http://{a}:{port}"))
.collect();
if urls.is_empty() {
continue;
}
let instance = info_fullname
.strip_suffix(SERVICE_TYPE)
.unwrap_or(&info_fullname)
.trim_end_matches('.')
.to_string();
let peer = DiscoveredPeer {
urls,
instance_name: instance,
};
if peer_tx.blocking_send(peer).is_err() {
break;
}
}
ServiceEvent::ServiceRemoved(svc, fullname) => {
tracing::debug!(service = %svc, fullname = %fullname, "mDNS service removed");
}
ServiceEvent::SearchStopped(svc) => {
tracing::debug!(service = %svc, "mDNS browse search stopped");
}
}
}
});
Ok(MdnsHandle { daemon, fullname })
}
pub fn sanitize_hostname(input: &str) -> String {
const MAX_LABEL: usize = 63;
let mut s: String = input
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else if c == '-' {
c
} else {
'-'
}
})
.collect();
if s.len() > MAX_LABEL {
s.truncate(MAX_LABEL);
}
let trimmed = s.trim_matches('-');
if trimmed.is_empty() {
"agent".to_string()
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::sanitize_hostname;
#[test]
fn sanitize_hostname_preserves_alnum_and_dash() {
assert_eq!(sanitize_hostname("alice-7"), "alice-7");
}
#[test]
fn sanitize_hostname_replaces_dots_and_underscores() {
assert_eq!(sanitize_hostname("my.host_name"), "my-host-name");
}
#[test]
fn sanitize_hostname_lowercases() {
assert_eq!(sanitize_hostname("Alice-Host"), "alice-host");
}
#[test]
fn sanitize_hostname_trims_leading_and_trailing_dashes() {
assert_eq!(sanitize_hostname(".alice."), "alice");
assert_eq!(sanitize_hostname("---bob---"), "bob");
}
#[test]
fn sanitize_hostname_truncates_to_63_chars() {
let long = "a".repeat(100);
let out = sanitize_hostname(&long);
assert_eq!(out.len(), 63);
assert!(out.chars().all(|c| c == 'a'));
}
#[test]
fn sanitize_hostname_falls_back_when_all_dashes() {
assert_eq!(sanitize_hostname("..."), "agent");
assert_eq!(sanitize_hostname(""), "agent");
assert_eq!(sanitize_hostname("---"), "agent");
}
}