use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub const SERVICE_TYPE: &str = "_car-a2a._tcp.local.";
const TXT_URL: &str = "url";
const TXT_NAME: &str = "name";
pub fn host_label() -> String {
hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.map(|h| h.trim_end_matches(".local").to_string())
.filter(|h| !h.trim().is_empty())
.unwrap_or_else(|| "CAR".to_string())
}
fn rank_ipv4(a: &std::net::Ipv4Addr) -> u8 {
let o = a.octets();
match (o[0], o[1]) {
(192, 168) => 0,
(10, _) => 1,
(172, b) if (16..=31).contains(&b) => 3,
_ => 2,
}
}
pub fn primary_ipv4() -> Option<std::net::Ipv4Addr> {
let mut candidates: Vec<std::net::Ipv4Addr> = if_addrs::get_if_addrs()
.ok()?
.into_iter()
.filter(|i| !i.is_loopback())
.filter_map(|i| match i.addr.ip() {
std::net::IpAddr::V4(v4) => Some(v4),
std::net::IpAddr::V6(_) => None,
})
.filter(|v4| !v4.is_link_local() && !v4.is_unspecified())
.collect();
candidates.sort_by_key(|a| (rank_ipv4(a), *a));
candidates.into_iter().next()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanPeer {
pub name: String,
pub url: String,
pub fullname: String,
}
pub struct Advertisement {
daemon: ServiceDaemon,
fullname: String,
}
impl Advertisement {
pub fn fullname(&self) -> &str {
&self.fullname
}
}
impl Drop for Advertisement {
fn drop(&mut self) {
let _ = self.daemon.unregister(&self.fullname);
}
}
pub fn advertise(
instance: &str,
card_url: &str,
port: u16,
display_name: &str,
) -> Result<Advertisement, String> {
let daemon = ServiceDaemon::new().map_err(|e| format!("mdns daemon: {e}"))?;
let host = format!("{}.local.", sanitize_instance(instance));
let props = [(TXT_URL, card_url), (TXT_NAME, display_name)];
let info = ServiceInfo::new(
SERVICE_TYPE,
&sanitize_instance(instance),
&host,
(),
port,
&props[..],
)
.map_err(|e| format!("mdns service info: {e}"))?
.enable_addr_auto();
let fullname = info.get_fullname().to_string();
daemon
.register(info)
.map_err(|e| format!("mdns register: {e}"))?;
Ok(Advertisement { daemon, fullname })
}
fn sanitize_instance(raw: &str) -> String {
let cleaned: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect();
let trimmed = cleaned.trim_matches('-');
if trimmed.is_empty() {
"car".to_string()
} else {
trimmed.to_string()
}
}
#[derive(Clone, Default)]
pub struct LanDirectory {
peers: Arc<Mutex<HashMap<String, LanPeer>>>,
}
impl LanDirectory {
pub fn start() -> Result<Self, String> {
let daemon = ServiceDaemon::new().map_err(|e| format!("mdns daemon: {e}"))?;
let rx = daemon
.browse(SERVICE_TYPE)
.map_err(|e| format!("mdns browse: {e}"))?;
let dir = LanDirectory::default();
let peers = dir.peers.clone();
std::thread::Builder::new()
.name("car-lan-discovery".into())
.spawn(move || {
let _daemon = daemon;
while let Ok(event) = rx.recv() {
match event {
ServiceEvent::ServiceResolved(info) => {
if let Some(peer) = peer_from(&info) {
peers
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(peer.fullname.clone(), peer);
}
}
ServiceEvent::ServiceRemoved(_, fullname) => {
peers
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&fullname);
}
_ => {}
}
}
})
.map_err(|e| format!("spawn lan discovery thread: {e}"))?;
Ok(dir)
}
pub fn peers(&self) -> Vec<LanPeer> {
let mut v: Vec<LanPeer> = self
.peers
.lock()
.unwrap_or_else(|e| e.into_inner())
.values()
.cloned()
.collect();
v.sort_by(|a, b| a.name.cmp(&b.name));
v
}
#[doc(hidden)]
pub fn insert_for_test(&self, peer: LanPeer) {
self.peers
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(peer.fullname.clone(), peer);
}
}
fn peer_from(info: &ServiceInfo) -> Option<LanPeer> {
let url = info.get_property_val_str(TXT_URL)?.trim().to_string();
if !(url.starts_with("http://") || url.starts_with("https://")) {
return None;
}
let fullname = info.get_fullname().to_string();
let name = info
.get_property_val_str(TXT_NAME)
.map(str::to_string)
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| {
fullname.split('.').next().unwrap_or("car-peer").to_string()
});
Some(LanPeer {
name,
url,
fullname,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instance_names_lose_dots_and_other_label_breakers() {
assert_eq!(sanitize_instance("my.mac.local"), "my-mac-local");
assert_eq!(sanitize_instance("Matt's Mac"), "Matt-s-Mac");
assert_eq!(sanitize_instance("..."), "car");
assert_eq!(sanitize_instance(""), "car");
}
#[test]
fn the_directory_starts_empty_and_accepts_peers() {
let dir = LanDirectory::default();
assert!(dir.peers().is_empty());
dir.insert_for_test(LanPeer {
name: "desktop".into(),
url: "http://192.168.1.20:8731".into(),
fullname: "desktop._car-a2a._tcp.local.".into(),
});
assert_eq!(dir.peers().len(), 1);
assert_eq!(dir.peers()[0].name, "desktop");
}
#[test]
fn peers_are_sorted_by_name_for_a_stable_listing() {
let dir = LanDirectory::default();
for n in ["zeta", "alpha", "mid"] {
dir.insert_for_test(LanPeer {
name: n.into(),
url: format!("http://host/{n}"),
fullname: format!("{n}._car-a2a._tcp.local."),
});
}
let names: Vec<String> = dir.peers().into_iter().map(|p| p.name).collect();
assert_eq!(names, vec!["alpha", "mid", "zeta"]);
}
#[test]
fn a_hypervisor_interface_loses_to_the_real_lan() {
use std::net::Ipv4Addr;
let vm: Ipv4Addr = "172.16.201.2".parse().unwrap();
let lan: Ipv4Addr = "192.168.1.39".parse().unwrap();
assert!(rank_ipv4(&lan) < rank_ipv4(&vm));
let corporate: Ipv4Addr = "10.1.2.3".parse().unwrap();
assert!(rank_ipv4(&corporate) < rank_ipv4(&vm));
assert!(rank_ipv4(&lan) < rank_ipv4(&corporate));
}
#[test]
fn ranking_is_stable_for_equal_ranks() {
use std::net::Ipv4Addr;
let a: Ipv4Addr = "192.168.1.10".parse().unwrap();
let b: Ipv4Addr = "192.168.1.39".parse().unwrap();
assert_eq!(rank_ipv4(&a), rank_ipv4(&b));
let mut v = [b, a];
v.sort_by_key(|x| (rank_ipv4(x), *x));
assert_eq!(v[0], a);
}
#[test]
fn the_service_type_is_the_one_both_sides_agree_on() {
assert_eq!(SERVICE_TYPE, "_car-a2a._tcp.local.");
}
}