use std::collections::BTreeMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use mdns_sd::{IfKind, ServiceDaemon, ServiceEvent, ServiceInfo};
use serde::Serialize;
pub const SERVICE_TYPE: &str = "_mold._tcp.local.";
const MAX_INSTANCE_BYTES: usize = 63;
fn is_ipv6_link_local(ip: &IpAddr) -> bool {
matches!(ip, IpAddr::V6(v6) if (v6.segments()[0] & 0xffc0) == 0xfe80)
}
pub struct MdnsGuard {
daemon: ServiceDaemon,
fullname: String,
}
impl MdnsGuard {
pub fn shutdown(self) {
match self.daemon.unregister(&self.fullname) {
Ok(_) => tracing::info!(fullname = %self.fullname, "mDNS: unregistered service"),
Err(e) => tracing::warn!(error = %e, "mDNS: unregister failed"),
}
if let Err(e) = self.daemon.shutdown() {
tracing::warn!(error = %e, "mDNS: daemon shutdown failed");
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DiscoveredServer {
pub name: String,
pub host: String,
pub addresses: Vec<String>,
pub port: u16,
pub url: String,
pub version: Option<String>,
pub auth_required: bool,
pub txt: BTreeMap<String, String>,
}
pub fn enabled_from_env() -> bool {
match std::env::var("MOLD_MDNS") {
Ok(v) => !matches!(
v.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
),
Err(_) => true,
}
}
pub fn is_advertisable(bind: &str, _port: u16) -> bool {
let host = bind.trim();
if host.is_empty() {
return false;
}
if matches!(host, "0.0.0.0" | "::" | "[::]") {
return true;
}
match host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
{
Ok(ip) => !ip.is_loopback(),
Err(_) => !host.eq_ignore_ascii_case("localhost"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Advertised {
Auto,
Fixed(IpAddr),
}
pub fn advertise_addr(bind: &str) -> Advertised {
let host = bind.trim();
if host.is_empty() || matches!(host, "0.0.0.0" | "::" | "[::]") {
return Advertised::Auto;
}
match host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
{
Ok(ip) if !ip.is_loopback() => Advertised::Fixed(ip),
_ => Advertised::Auto,
}
}
pub fn instance_name(hostname: &str, port: u16) -> String {
let mut sanitized = String::with_capacity(hostname.len());
let mut last_dash = false;
for ch in hostname.chars() {
if ch.is_ascii_alphanumeric() {
sanitized.push(ch);
last_dash = false;
} else if !last_dash {
sanitized.push('-');
last_dash = true;
}
}
let base = sanitized.trim_matches('-');
let base = if base.is_empty() { "mold" } else { base };
let suffix = format!("-{port}");
let budget = MAX_INSTANCE_BYTES.saturating_sub(suffix.len());
let mut trimmed = base;
while trimmed.len() > budget {
match trimmed.char_indices().next_back() {
Some((idx, _)) => trimmed = &trimmed[..idx],
None => break,
}
}
let trimmed = trimmed.trim_end_matches('-');
format!("{trimmed}{suffix}")
}
pub fn gpu_summary(names: &[String]) -> String {
match names {
[] => "cpu".to_string(),
[only] => format!("1x{only}"),
[first, rest @ ..] if rest.iter().all(|n| n == first) => {
format!("{}x{first}", names.len())
}
_ => format!("{} GPUs", names.len()),
}
}
pub fn build_txt_records(
version: &str,
git_sha: &str,
auth_required: bool,
gpu_summary: &str,
queue_capacity: usize,
) -> Vec<(String, String)> {
fn clamp(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_string()
}
}
vec![
("version".to_string(), clamp(version, 200)),
("sha".to_string(), clamp(git_sha, 64)),
(
"auth".to_string(),
if auth_required { "1" } else { "0" }.to_string(),
),
("gpu".to_string(), clamp(gpu_summary, 200)),
("queue".to_string(), queue_capacity.to_string()),
("proto".to_string(), "http".to_string()),
]
}
pub fn from_service_parts(
fullname: &str,
hostname: &str,
port: u16,
mut addresses: Vec<IpAddr>,
txt: Vec<(String, String)>,
) -> DiscoveredServer {
if addresses.iter().any(|ip| !ip.is_loopback()) {
addresses.retain(|ip| !ip.is_loopback());
}
addresses.sort_by(|a, b| match (a, b) {
(IpAddr::V4(_), IpAddr::V6(_)) => std::cmp::Ordering::Less,
(IpAddr::V6(_), IpAddr::V4(_)) => std::cmp::Ordering::Greater,
_ => a.cmp(b),
});
addresses.dedup();
let name = fullname
.strip_suffix(&format!(".{SERVICE_TYPE}"))
.unwrap_or(fullname)
.to_string();
let txt_map: BTreeMap<String, String> = txt.into_iter().collect();
let host = if let Some(ip) = addresses.iter().find(|ip| ip.is_ipv4()) {
ip.to_string()
} else if let Some(ip) = addresses.iter().find(|ip| !is_ipv6_link_local(ip)) {
format!("[{ip}]")
} else {
hostname.trim_end_matches('.').to_string()
};
DiscoveredServer {
name,
url: format!("http://{host}:{port}"),
host,
addresses: addresses.iter().map(|ip| ip.to_string()).collect(),
port,
version: txt_map.get("version").cloned(),
auth_required: txt_map.get("auth").map(|v| v == "1").unwrap_or(false),
txt: txt_map,
}
}
pub fn register(bind: &str, port: u16, txt: Vec<(String, String)>) -> Result<MdnsGuard> {
let daemon = ServiceDaemon::new().context("failed to start mDNS daemon")?;
if let Err(e) = daemon.disable_interface(vec![IfKind::LoopbackV4, IfKind::LoopbackV6]) {
tracing::warn!(error = %e, "mDNS: could not disable loopback interfaces");
}
let raw_host = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| "mold".to_string());
let instance = instance_name(&raw_host, port);
let host_label = {
let base = raw_host.split('.').next().unwrap_or("mold");
let base = if base.is_empty() { "mold" } else { base };
format!("{base}.local.")
};
let props: Vec<(&str, &str)> = txt.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let fixed_addr = match advertise_addr(bind) {
Advertised::Fixed(ip) => Some(ip.to_string()),
Advertised::Auto => None,
};
let service = ServiceInfo::new(
SERVICE_TYPE,
&instance,
&host_label,
fixed_addr.as_deref().unwrap_or(""),
port,
&props[..],
)
.context("failed to build mDNS service info")?;
let service = if fixed_addr.is_some() {
service
} else {
service.enable_addr_auto()
};
let fullname = service.get_fullname().to_string();
daemon
.register(service)
.context("failed to register mDNS service")?;
tracing::info!(fullname = %fullname, port, "mDNS: advertising service");
Ok(MdnsGuard { daemon, fullname })
}
pub fn discover(timeout: Duration) -> Result<Vec<DiscoveredServer>> {
let daemon = ServiceDaemon::new().context("failed to start mDNS daemon")?;
let receiver = daemon
.browse(SERVICE_TYPE)
.context("failed to start mDNS browse")?;
let mut found: BTreeMap<String, DiscoveredServer> = BTreeMap::new();
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match receiver.recv_timeout(remaining) {
Ok(ServiceEvent::ServiceResolved(info)) => {
let addresses: Vec<IpAddr> =
info.addresses.iter().map(|a| a.to_ip_addr()).collect();
let txt: Vec<(String, String)> = info
.txt_properties
.iter()
.map(|p| (p.key().to_string(), p.val_str().to_string()))
.collect();
let server =
from_service_parts(&info.fullname, &info.host, info.port, addresses, txt);
found.insert(info.fullname.clone(), server);
}
Ok(_) => {}
Err(_) => break, }
}
let _ = daemon.shutdown();
let mut servers: Vec<DiscoveredServer> = found.into_values().collect();
servers.sort_by(|a, b| a.name.cmp(&b.name));
Ok(servers)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
#[test]
fn is_advertisable_rejects_loopback_and_localhost() {
assert!(!is_advertisable("127.0.0.1", 7680));
assert!(!is_advertisable("::1", 7680));
assert!(!is_advertisable("[::1]", 7680));
assert!(!is_advertisable("localhost", 7680));
assert!(!is_advertisable("LocalHost", 7680));
assert!(!is_advertisable("", 7680));
}
#[test]
fn is_advertisable_accepts_wildcard_and_lan() {
assert!(is_advertisable("0.0.0.0", 7680));
assert!(is_advertisable("::", 7680));
assert!(is_advertisable("[::]", 7680));
assert!(is_advertisable("192.168.1.10", 7680));
assert!(is_advertisable("10.0.0.5", 7680));
assert!(is_advertisable("hal9000", 7680));
}
#[test]
fn advertise_addr_wildcard_is_auto() {
assert_eq!(advertise_addr("0.0.0.0"), Advertised::Auto);
assert_eq!(advertise_addr("::"), Advertised::Auto);
assert_eq!(advertise_addr("[::]"), Advertised::Auto);
assert_eq!(advertise_addr(""), Advertised::Auto);
assert_eq!(advertise_addr("hal9000"), Advertised::Auto);
}
#[test]
fn advertise_addr_specific_ip_is_fixed() {
assert_eq!(
advertise_addr("192.168.1.5"),
Advertised::Fixed(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5)))
);
assert_eq!(
advertise_addr("[fe80::1]"),
Advertised::Fixed(IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)))
);
}
#[test]
fn advertise_addr_loopback_falls_back_to_auto() {
assert_eq!(advertise_addr("127.0.0.1"), Advertised::Auto);
assert_eq!(advertise_addr("::1"), Advertised::Auto);
}
#[test]
fn instance_name_sanitizes_and_suffixes() {
assert_eq!(instance_name("hal9000", 7680), "hal9000-7680");
assert_eq!(instance_name("my.host.local", 7680), "my-host-local-7680");
assert_eq!(instance_name("weird__name!!", 8080), "weird-name-8080");
assert_eq!(instance_name("--edge--", 7680), "edge-7680");
}
#[test]
fn instance_name_empty_falls_back() {
assert_eq!(instance_name("", 7680), "mold-7680");
assert_eq!(instance_name("!!!", 7680), "mold-7680");
}
#[test]
fn instance_name_truncates_within_budget() {
let long = "a".repeat(200);
let name = instance_name(&long, 7680);
assert!(name.len() <= MAX_INSTANCE_BYTES, "got {} bytes", name.len());
assert!(name.ends_with("-7680"));
assert!(!name.contains("--"));
}
#[test]
fn gpu_summary_shapes() {
assert_eq!(gpu_summary(&[]), "cpu");
assert_eq!(gpu_summary(&["RTX 4090".into()]), "1xRTX 4090");
assert_eq!(
gpu_summary(&["RTX 4090".into(), "RTX 4090".into()]),
"2xRTX 4090"
);
assert_eq!(gpu_summary(&["A100".into(), "RTX 4090".into()]), "2 GPUs");
}
#[test]
fn build_txt_records_shape() {
let txt = build_txt_records("0.14.0", "abc1234", true, "1xRTX 4090", 200);
let map: BTreeMap<_, _> = txt.into_iter().collect();
assert_eq!(map["version"], "0.14.0");
assert_eq!(map["sha"], "abc1234");
assert_eq!(map["auth"], "1");
assert_eq!(map["gpu"], "1xRTX 4090");
assert_eq!(map["queue"], "200");
assert_eq!(map["proto"], "http");
}
#[test]
fn build_txt_records_auth_off_and_clamps() {
let txt = build_txt_records(&"v".repeat(300), "sha", false, "gpu", 0);
let map: BTreeMap<_, _> = txt.into_iter().collect();
assert_eq!(map["auth"], "0");
assert!(map["version"].len() <= 200);
}
#[test]
fn enabled_from_env_matrix() {
let key = "MOLD_MDNS";
unsafe {
std::env::remove_var(key);
assert!(enabled_from_env());
for on in ["1", "true", "TRUE", "yes", "on", "anything"] {
std::env::set_var(key, on);
assert!(enabled_from_env(), "{on} should enable");
}
for off in ["0", "false", "FALSE", "no", "off", " off "] {
std::env::set_var(key, off);
assert!(!enabled_from_env(), "{off} should disable");
}
std::env::remove_var(key);
}
}
#[test]
fn from_service_parts_prefers_ipv4_for_url() {
let v4 = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
let v6 = IpAddr::V6(Ipv6Addr::LOCALHOST);
let s = from_service_parts(
"hal9000-7680._mold._tcp.local.",
"hal9000.local.",
7680,
vec![v6, v4],
vec![
("version".to_string(), "0.14.0".to_string()),
("auth".to_string(), "1".to_string()),
],
);
assert_eq!(s.name, "hal9000-7680");
assert_eq!(s.host, "192.168.1.20");
assert_eq!(s.url, "http://192.168.1.20:7680");
assert_eq!(s.version.as_deref(), Some("0.14.0"));
assert!(s.auth_required);
assert_eq!(s.addresses.first().unwrap(), "192.168.1.20");
}
#[test]
fn from_service_parts_excludes_loopback_when_routable_exists() {
let loop4 = IpAddr::V4(Ipv4Addr::LOCALHOST);
let lan = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 142));
let s = from_service_parts(
"hal9000-7690._mold._tcp.local.",
"hal9000.local.",
7690,
vec![loop4, lan],
vec![],
);
assert_eq!(s.url, "http://192.168.1.142:7690");
assert_eq!(s.host, "192.168.1.142");
assert!(!s.addresses.iter().any(|a| a == "127.0.0.1"));
assert_eq!(s.addresses, vec!["192.168.1.142".to_string()]);
}
#[test]
fn from_service_parts_keeps_loopback_when_only_address() {
let loop4 = IpAddr::V4(Ipv4Addr::LOCALHOST);
let s = from_service_parts(
"self-7680._mold._tcp.local.",
"self.local.",
7680,
vec![loop4],
vec![],
);
assert_eq!(s.addresses, vec!["127.0.0.1".to_string()]);
assert_eq!(s.url, "http://127.0.0.1:7680");
}
#[test]
fn from_service_parts_routable_ipv6_only_brackets_url() {
let v6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
let s = from_service_parts(
"box._mold._tcp.local.",
"box.local.",
8080,
vec![v6],
vec![],
);
assert_eq!(s.url, "http://[2001:db8::1]:8080");
assert!(!s.auth_required);
assert!(s.version.is_none());
}
#[test]
fn from_service_parts_link_local_v6_only_uses_hostname() {
let ll = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1));
let s = from_service_parts(
"box._mold._tcp.local.",
"box.local.",
8080,
vec![ll],
vec![],
);
assert_eq!(s.url, "http://box.local:8080");
assert_eq!(s.host, "box.local");
assert_eq!(s.addresses, vec!["fe80::1".to_string()]);
}
#[test]
fn from_service_parts_ipv4_beats_link_local_v6() {
let ll = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1));
let v4 = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5));
let s = from_service_parts(
"box-7680._mold._tcp.local.",
"box.local.",
7680,
vec![ll, v4],
vec![],
);
assert_eq!(s.url, "http://192.168.1.5:7680");
assert_eq!(s.host, "192.168.1.5");
}
#[test]
fn from_service_parts_no_addresses_uses_hostname() {
let s = from_service_parts("box._mold._tcp.local.", "box.local.", 7680, vec![], vec![]);
assert_eq!(s.host, "box.local");
assert_eq!(s.url, "http://box.local:7680");
assert!(s.addresses.is_empty());
}
#[test]
#[ignore]
fn register_then_discover_roundtrip() {
let txt = build_txt_records("9.9.9", "deadbee", false, "cpu", 42);
let guard = register("0.0.0.0", 0, txt).expect("register");
let found = discover(Duration::from_secs(3)).expect("discover");
guard.shutdown();
assert!(
found.iter().any(|s| s.version.as_deref() == Some("9.9.9")),
"expected to rediscover our own advertisement, got {found:?}"
);
}
}