use std::{
cell::RefCell,
collections::HashMap,
net::{IpAddr, Ipv4Addr},
rc::Rc,
};
use local_channel::mpsc::Sender;
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use mousehop_ipc::IfaceKind;
use tokio::task::{JoinHandle, spawn_local};
use crate::network::{default_interface_kind, is_routable_ip, local_addresses_with_kind};
const SERVICE_TYPE: &str = "_mousehop._udp.local.";
const TXT_PRIMARY_KEY: &str = "primary";
const TXT_IFACE_PREFIX: &str = "if_";
pub(crate) enum DiscoveryEvent {
Resolved {
instance: String,
addresses: Vec<IpAddr>,
interfaces: HashMap<IpAddr, IfaceKind>,
},
}
fn iface_kind_str(kind: IfaceKind) -> &'static str {
match kind {
IfaceKind::Wired => "wired",
IfaceKind::WiFi => "wifi",
IfaceKind::Other => "other",
}
}
fn parse_iface_kind(s: &str) -> Option<IfaceKind> {
match s {
"wired" => Some(IfaceKind::Wired),
"wifi" => Some(IfaceKind::WiFi),
"other" => Some(IfaceKind::Other),
_ => None,
}
}
fn primary_ipv4() -> Option<Ipv4Addr> {
let iface = netdev::get_default_interface().ok()?;
iface.ipv4.first().map(|net| net.addr())
}
fn local_hostname() -> String {
hostname::get()
.ok()
.and_then(|s| s.into_string().ok())
.unwrap_or_else(|| "mousehop".to_string())
}
fn strip_trailing_dot(s: &str) -> &str {
s.strip_suffix('.').unwrap_or(s)
}
fn instance_from_fullname<'a>(fullname: &'a str, service_type: &str) -> &'a str {
let suffix = format!(".{service_type}");
fullname.strip_suffix(&suffix).unwrap_or(fullname)
}
fn strip_conflict_suffix(s: &str) -> &str {
let trimmed = s.trim_end();
if let Some(open) = trimmed.rfind(" (") {
if let Some(num) = trimmed[open + 2..].strip_suffix(')') {
if !num.is_empty() && num.bytes().all(|b| b.is_ascii_digit()) {
return trimmed[..open].trim_end();
}
}
}
s
}
pub(crate) fn normalize_mdns_name(s: &str) -> String {
let s = strip_trailing_dot(s);
let s = s.strip_suffix(".local").unwrap_or(s);
let s = strip_conflict_suffix(s);
s.to_ascii_lowercase()
}
pub(crate) type PrimaryCache = Rc<RefCell<HashMap<String, IpAddr>>>;
pub(crate) struct Discovery {
daemon: Option<ServiceDaemon>,
registered_fullname: Option<String>,
primary_cache: PrimaryCache,
browse_task: Option<JoinHandle<()>>,
port: u16,
event_tx: Sender<DiscoveryEvent>,
}
impl Discovery {
pub(crate) fn new(
port: u16,
enabled: bool,
primary_cache: PrimaryCache,
event_tx: Sender<DiscoveryEvent>,
) -> Self {
if !enabled {
log::info!("mdns discovery disabled by config");
return Self::inert(port, primary_cache, event_tx);
}
match ServiceDaemon::new() {
Ok(daemon) => {
let browse_task = start_browse(&daemon, primary_cache.clone(), event_tx.clone());
let mut this = Self {
daemon: Some(daemon),
registered_fullname: None,
primary_cache,
browse_task,
port,
event_tx,
};
this.register();
this
}
Err(e) => {
log::warn!("mdns ServiceDaemon::new failed: {e}; discovery disabled");
Self::inert(port, primary_cache, event_tx)
}
}
}
fn inert(port: u16, primary_cache: PrimaryCache, event_tx: Sender<DiscoveryEvent>) -> Self {
Self {
daemon: None,
registered_fullname: None,
primary_cache,
browse_task: None,
port,
event_tx,
}
}
fn register(&mut self) {
let Some(daemon) = self.daemon.as_ref() else {
return;
};
if let Some(old) = self.registered_fullname.take() {
let _ = daemon.unregister(&old);
}
let host = local_hostname();
let host_record = format!("{host}.local.");
let primary = match primary_ipv4() {
Some(ip) => ip,
None => {
log::warn!(
"mdns: no default-route interface; skipping registration (will retry on \
interface change)"
);
return;
}
};
let (mut addresses, mut kinds) = local_addresses_with_kind();
if !addresses.contains(&IpAddr::V4(primary)) {
addresses.push(IpAddr::V4(primary));
}
kinds
.entry(IpAddr::V4(primary))
.or_insert_with(|| default_interface_kind().unwrap_or(IfaceKind::Other));
let mut props = HashMap::new();
props.insert(TXT_PRIMARY_KEY.to_string(), primary.to_string());
for (ip, kind) in &kinds {
props.insert(
format!("{TXT_IFACE_PREFIX}{ip}"),
iface_kind_str(*kind).to_string(),
);
}
let info = match ServiceInfo::new(
SERVICE_TYPE,
&host,
&host_record,
addresses.as_slice(),
self.port,
Some(props),
) {
Ok(i) => i,
Err(e) => {
log::warn!("mdns ServiceInfo::new failed: {e}; skipping registration");
return;
}
};
let fullname = info.get_fullname().to_string();
match daemon.register(info) {
Ok(()) => {
log::info!(
"mdns: registered {fullname} on {addresses:?}:{port} (primary {primary})",
port = self.port,
);
self.registered_fullname = Some(fullname);
}
Err(e) => log::warn!("mdns register failed: {e}"),
}
}
pub(crate) fn refresh(&mut self) {
if self.daemon.is_some() {
self.register();
}
}
pub(crate) fn set_port(&mut self, port: u16) {
if self.port == port {
return;
}
self.port = port;
self.refresh();
}
pub(crate) fn set_enabled(&mut self, enabled: bool) {
let currently = self.daemon.is_some();
if currently == enabled {
return;
}
if enabled {
*self = Self::new(
self.port,
true,
self.primary_cache.clone(),
self.event_tx.clone(),
);
} else {
self.shutdown();
}
}
fn shutdown(&mut self) {
if let Some(daemon) = self.daemon.take() {
if let Some(name) = self.registered_fullname.take() {
let _ = daemon.unregister(&name);
}
let _ = daemon.shutdown();
}
if let Some(task) = self.browse_task.take() {
task.abort();
}
}
}
impl Drop for Discovery {
fn drop(&mut self) {
self.shutdown();
}
}
fn start_browse(
daemon: &ServiceDaemon,
primary_cache: Rc<RefCell<HashMap<String, IpAddr>>>,
event_tx: Sender<DiscoveryEvent>,
) -> Option<JoinHandle<()>> {
let receiver = match daemon.browse(SERVICE_TYPE) {
Ok(rx) => rx,
Err(e) => {
log::warn!("mdns browse failed: {e}");
return None;
}
};
Some(spawn_local(async move {
while let Ok(event) = receiver.recv_async().await {
match event {
ServiceEvent::ServiceResolved(resolved) => {
let instance = instance_from_fullname(resolved.get_fullname(), SERVICE_TYPE);
let key = normalize_mdns_name(instance);
let target = strip_trailing_dot(resolved.get_hostname());
let primary = resolved
.get_property_val_str(TXT_PRIMARY_KEY)
.and_then(|s| s.parse::<IpAddr>().ok());
if let Some(ip) = primary {
primary_cache.borrow_mut().insert(key.clone(), ip);
}
let addresses: Vec<IpAddr> = resolved
.get_addresses()
.iter()
.map(|scoped| scoped.to_ip_addr())
.filter(is_routable_ip)
.collect();
let mut interfaces = HashMap::new();
for prop in resolved.txt_properties.iter() {
if let Some(ip_str) = prop.key().strip_prefix(TXT_IFACE_PREFIX) {
if let (Ok(ip), Some(kind)) =
(ip_str.parse::<IpAddr>(), parse_iface_kind(prop.val_str()))
{
interfaces.insert(ip, kind);
}
}
}
log::info!(
"mdns: peer instance={key} (target={target}) primary={primary:?} \
addrs={addresses:?} (port={port})",
port = resolved.get_port(),
);
let _ = event_tx.send(DiscoveryEvent::Resolved {
instance: key,
addresses,
interfaces,
});
}
ServiceEvent::ServiceRemoved(_, fullname) => {
log::debug!("mdns: service removed {fullname}");
}
_ => {}
}
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_collapses_variants_to_same_key() {
let base = normalize_mdns_name("omarchy");
assert_eq!(normalize_mdns_name("Omarchy.local"), base);
assert_eq!(normalize_mdns_name("omarchy.local."), base);
assert_eq!(normalize_mdns_name("omarchy (2)"), base);
assert_eq!(normalize_mdns_name("Omarchy (13).local"), base);
assert_ne!(normalize_mdns_name("omarchy (beta)"), base);
}
}