1use std::collections::HashMap;
29use std::net::{SocketAddr, SocketAddrV4, SocketAddrV6};
30use std::sync::Arc;
31#[cfg(test)]
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
34
35use mdns_sd::{ScopedIp, ServiceDaemon, ServiceEvent, ServiceInfo};
36use thiserror::Error;
37use tokio::sync::{Mutex, watch};
38use tracing::{debug, info, warn};
39
40use crate::Identity;
41
42pub const SERVICE_TYPE: &str = "_fips._udp.local.";
48
49const IP_CHECK_INTERVAL_SECS: u32 = 30;
52const BROWSE_WINDOW: Duration = Duration::from_secs(5);
55const BROWSE_PERIOD_MS: u128 = 60_000;
56const DAEMON_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
57
58pub const TXT_KEY_NPUB: &str = "npub";
60
61pub const TXT_KEY_SCOPE: &str = "scope";
63
64pub const TXT_KEY_VERSION: &str = "v";
67
68#[derive(Debug, Error)]
69pub enum LanDiscoveryError {
70 #[error("mDNS daemon init failed: {0}")]
71 Daemon(String),
72 #[error("mDNS register failed: {0}")]
73 Register(String),
74 #[error("mDNS browse failed: {0}")]
75 Browse(String),
76 #[error("no advertised UDP port — start a UDP transport first")]
77 NoAdvertisedPort,
78 #[error("LAN discovery disabled in config")]
79 Disabled,
80}
81
82#[derive(Debug, Clone)]
86pub struct LanDiscoveredPeer {
87 pub npub: String,
88 pub scope: Option<String>,
89 pub addr: SocketAddr,
90 pub observed_at: Instant,
91}
92
93#[derive(Debug, Clone)]
95pub enum LanEvent {
96 Discovered(LanDiscoveredPeer),
97}
98
99#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
101pub struct LanDiscoveryConfig {
102 #[serde(default = "LanDiscoveryConfig::default_enabled")]
106 pub enabled: bool,
107 #[serde(default = "LanDiscoveryConfig::default_service_type")]
110 pub service_type: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub scope: Option<String>,
119}
120
121impl Default for LanDiscoveryConfig {
122 fn default() -> Self {
123 Self {
124 enabled: Self::default_enabled(),
125 service_type: Self::default_service_type(),
126 scope: None,
127 }
128 }
129}
130
131impl LanDiscoveryConfig {
132 fn default_enabled() -> bool {
133 true
134 }
135 fn default_service_type() -> String {
136 SERVICE_TYPE.to_string()
137 }
138}
139
140pub struct LanDiscovery {
142 own_npub: String,
143 events_rx: Mutex<tokio::sync::mpsc::UnboundedReceiver<LanEvent>>,
144 shutdown_tx: watch::Sender<bool>,
145 event_pump: Mutex<Option<tokio::task::JoinHandle<()>>>,
146 #[cfg(test)]
147 browse_paused: Arc<AtomicBool>,
148 #[cfg(test)]
149 daemon_active: Arc<AtomicBool>,
150}
151
152impl LanDiscovery {
153 pub async fn start(
160 identity: &Identity,
161 scope: Option<String>,
162 advertised_port: u16,
163 config: LanDiscoveryConfig,
164 ) -> Result<Arc<Self>, LanDiscoveryError> {
165 if !config.enabled {
166 return Err(LanDiscoveryError::Disabled);
167 }
168 if advertised_port == 0 {
169 return Err(LanDiscoveryError::NoAdvertisedPort);
170 }
171
172 let npub = identity.npub();
173 let label_npub = &npub[..16.min(npub.len())];
177 let instance_name = format!("fips-{label_npub}");
178 let host_name = format!("{instance_name}.local.");
179
180 let mut props: HashMap<String, String> = HashMap::new();
181 props.insert(TXT_KEY_NPUB.to_string(), npub.clone());
182 if let Some(s) = scope.as_deref()
183 && !s.is_empty()
184 {
185 props.insert(TXT_KEY_SCOPE.to_string(), s.to_string());
186 }
187 props.insert(
188 TXT_KEY_VERSION.to_string(),
189 super::nostr::PROTOCOL_VERSION.to_string(),
190 );
191
192 let service_info = ServiceInfo::new(
199 &config.service_type,
200 &instance_name,
201 &host_name,
202 "127.0.0.1",
203 advertised_port,
204 Some(props),
205 )
206 .map_err(|e| LanDiscoveryError::Register(e.to_string()))?
207 .enable_addr_auto();
208
209 let instance_fullname = service_info.get_fullname().to_string();
210 let service_type = config.service_type.clone();
211 let (daemon, browse_rx) = start_scan_daemon(&service_info, &service_type)?;
212
213 let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel();
214 let own_npub = npub.clone();
215 let scope_filter = scope.clone().filter(|s| !s.is_empty());
216 let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
217 #[cfg(test)]
218 let browse_paused = Arc::new(AtomicBool::new(false));
219 #[cfg(test)]
220 let event_pump_browse_paused = Arc::clone(&browse_paused);
221 #[cfg(test)]
222 let daemon_active = Arc::new(AtomicBool::new(true));
223 #[cfg(test)]
224 let event_pump_daemon_active = Arc::clone(&daemon_active);
225 let event_pump_instance_fullname = instance_fullname.clone();
226 let event_pump = tokio::spawn(async move {
227 let mut daemon = daemon;
228 let mut browse_rx = browse_rx;
229 let mut daemon_running = true;
230 'lifecycle: loop {
231 let window = tokio::time::sleep(BROWSE_WINDOW);
232 tokio::pin!(window);
233 loop {
234 let event = tokio::select! {
235 changed = shutdown_rx.changed() => {
236 if changed.is_err() || *shutdown_rx.borrow() {
237 break 'lifecycle;
238 }
239 continue;
240 }
241 event = browse_rx.recv_async() => match event {
242 Ok(event) => event,
243 Err(_) => break 'lifecycle,
244 },
245 () = &mut window => break,
246 };
247 match event {
248 ServiceEvent::ServiceResolved(info) => {
249 let mut peer_npub: Option<String> = None;
250 let mut peer_scope: Option<String> = None;
251 for prop in info.get_properties().iter() {
252 match prop.key() {
253 TXT_KEY_NPUB => {
254 peer_npub = Some(prop.val_str().to_string());
255 }
256 TXT_KEY_SCOPE => {
257 peer_scope = Some(prop.val_str().to_string());
258 }
259 _ => {}
260 }
261 }
262 let Some(peer_npub) = peer_npub else {
263 debug!(
264 instance = info.get_fullname(),
265 "lan: skip advert without npub TXT"
266 );
267 continue;
268 };
269 if peer_npub == own_npub {
270 continue;
273 }
274 if scope_filter.is_some() && scope_filter != peer_scope {
275 debug!(
276 npub = %short(&peer_npub),
277 their_scope = ?peer_scope,
278 our_scope = ?scope_filter,
279 "lan: skip cross-scope advert"
280 );
281 continue;
282 }
283 let port = info.get_port();
284 if port == 0 {
285 continue;
286 }
287 let observed_at = Instant::now();
288 for scoped in info.get_addresses() {
297 let Some(addr) = socket_addr_from_scoped_ip(scoped, port) else {
298 debug!(
299 npub = %short(&peer_npub),
300 addr = %scoped.to_ip_addr(),
301 "lan: skip scope-less IPv6 link-local advert"
302 );
303 continue;
304 };
305 if events_tx
306 .send(LanEvent::Discovered(LanDiscoveredPeer {
307 npub: peer_npub.clone(),
308 scope: peer_scope.clone(),
309 addr,
310 observed_at,
311 }))
312 .is_err()
313 {
314 return;
315 }
316 }
317 }
318 ServiceEvent::ServiceRemoved(_, fullname) => {
319 debug!(fullname = %fullname, "lan: service removed");
320 }
321 other => {
322 debug!(?other, "lan: mDNS event");
323 }
324 }
325 }
326 stop_scan_daemon(&daemon, &event_pump_instance_fullname).await;
327 daemon_running = false;
328 #[cfg(test)]
329 {
330 event_pump_daemon_active.store(false, Ordering::Release);
331 event_pump_browse_paused.store(true, Ordering::Release);
332 }
333 tokio::select! {
334 () = tokio::time::sleep(next_browse_window_delay()) => {}
335 changed = shutdown_rx.changed() => {
336 if changed.is_err() || *shutdown_rx.borrow() {
337 break 'lifecycle;
338 }
339 }
340 }
341 if *shutdown_rx.borrow() {
342 break 'lifecycle;
343 }
344 #[cfg(test)]
345 event_pump_browse_paused.store(false, Ordering::Release);
346 (daemon, browse_rx) = match start_scan_daemon(&service_info, &service_type) {
347 Ok(runtime) => runtime,
348 Err(error) => {
349 warn!(%error, "lan: restart mDNS browse failed");
350 break 'lifecycle;
351 }
352 };
353 daemon_running = true;
354 #[cfg(test)]
355 event_pump_daemon_active.store(true, Ordering::Release);
356 }
357 if daemon_running {
358 stop_scan_daemon(&daemon, &event_pump_instance_fullname).await;
359 #[cfg(test)]
360 event_pump_daemon_active.store(false, Ordering::Release);
361 }
362 });
363
364 info!(
365 instance = %instance_fullname,
366 port = advertised_port,
367 scope = ?scope,
368 "lan: mDNS discovery started"
369 );
370 Ok(Arc::new(Self {
371 own_npub: npub,
372 events_rx: Mutex::new(events_rx),
373 shutdown_tx,
374 event_pump: Mutex::new(Some(event_pump)),
375 #[cfg(test)]
376 browse_paused,
377 #[cfg(test)]
378 daemon_active,
379 }))
380 }
381
382 pub fn own_npub(&self) -> &str {
384 &self.own_npub
385 }
386
387 pub async fn drain_events(&self) -> Vec<LanEvent> {
389 let mut rx = self.events_rx.lock().await;
390 let mut events = Vec::new();
391 while let Ok(event) = rx.try_recv() {
392 events.push(event);
393 }
394 events
395 }
396
397 pub async fn shutdown(self: &Arc<Self>) {
399 let _ = self.shutdown_tx.send(true);
400 if let Some(event_pump) = self.event_pump.lock().await.take()
401 && tokio::time::timeout(DAEMON_SHUTDOWN_TIMEOUT, event_pump)
402 .await
403 .is_err()
404 {
405 warn!("lan: event pump did not stop before timeout");
406 }
407 }
408}
409
410fn start_scan_daemon(
411 service_info: &ServiceInfo,
412 service_type: &str,
413) -> Result<(ServiceDaemon, mdns_sd::Receiver<ServiceEvent>), LanDiscoveryError> {
414 let daemon = ServiceDaemon::new().map_err(|e| LanDiscoveryError::Daemon(e.to_string()))?;
415 daemon
416 .set_ip_check_interval(IP_CHECK_INTERVAL_SECS)
417 .map_err(|e| LanDiscoveryError::Daemon(e.to_string()))?;
418 if let Err(error) = daemon.register(service_info.clone()) {
419 let _ = daemon.shutdown();
420 return Err(LanDiscoveryError::Register(error.to_string()));
421 }
422 match daemon.browse(service_type) {
423 Ok(receiver) => Ok((daemon, receiver)),
424 Err(error) => {
425 let _ = daemon.shutdown();
426 Err(LanDiscoveryError::Browse(error.to_string()))
427 }
428 }
429}
430
431async fn stop_scan_daemon(daemon: &ServiceDaemon, instance_fullname: &str) {
432 if let Err(error) = daemon.unregister(instance_fullname) {
433 debug!(%error, "lan: unregister failed");
434 }
435 let shutdown = match daemon.shutdown() {
436 Ok(shutdown) => shutdown,
437 Err(error) => {
438 debug!(%error, "lan: daemon already stopped");
439 return;
440 }
441 };
442 if tokio::time::timeout(DAEMON_SHUTDOWN_TIMEOUT, shutdown.recv_async())
443 .await
444 .is_err()
445 {
446 warn!("lan: mDNS daemon did not stop before timeout");
447 }
448}
449
450fn next_browse_window_delay() -> Duration {
451 let now_ms = SystemTime::now()
452 .duration_since(UNIX_EPOCH)
453 .unwrap_or_default()
454 .as_millis();
455 browse_window_delay_at(now_ms)
456}
457
458fn browse_window_delay_at(now_ms: u128) -> Duration {
459 Duration::from_millis((BROWSE_PERIOD_MS - now_ms % BROWSE_PERIOD_MS) as u64)
460}
461
462fn short(npub: &str) -> &str {
463 let end = 16.min(npub.len());
464 &npub[..end]
465}
466
467fn socket_addr_from_scoped_ip(scoped: &ScopedIp, port: u16) -> Option<SocketAddr> {
468 match scoped {
469 ScopedIp::V4(v4) => Some(SocketAddr::V4(SocketAddrV4::new(*v4.addr(), port))),
470 ScopedIp::V6(v6) => {
471 let ip = *v6.addr();
472 let scope_id = v6.scope_id().index;
473 if ipv6_is_unicast_link_local(ip) && scope_id == 0 {
474 return None;
475 }
476 Some(SocketAddr::V6(SocketAddrV6::new(ip, port, 0, scope_id)))
477 }
478 _ => None,
479 }
480}
481
482fn ipv6_is_unicast_link_local(ip: std::net::Ipv6Addr) -> bool {
483 (ip.segments()[0] & 0xffc0) == 0xfe80
484}
485
486#[cfg(test)]
487mod tests;