Skip to main content

fips_core/discovery/lan/
mod.rs

1//! LAN peer discovery via mDNS / DNS-SD (RFC 6762 / RFC 6763).
2//!
3//! Publishes a `_fips._udp.local.` service advert carrying our `npub` and
4//! optional discovery scope on the local link, and concurrently browses for the
5//! same service type to learn peers reachable on the same broadcast
6//! domain. Active scans resolve peers in a few seconds without any Nostr-relay
7//! roundtrip, STUN observation, or NAT traversal; scans repeat every minute so
8//! discovery remains available offline without a permanent mobile CPU tax.
9//!
10//! ## Trust model
11//!
12//! mDNS adverts are unauthenticated: anyone on the LAN can multicast a
13//! TXT carrying `npub=...`. Identity is still proven end-to-end by the
14//! Noise IK handshake the Node initiates against the observed endpoint
15//! — a spoofed advert with another peer's npub fails the handshake and
16//! is silently dropped. Treat the mDNS advert as a routing hint, not as
17//! identity. LAN discovery is link-local mDNS only. It is not a Nostr advert
18//! and does not leave the broadcast domain unless the operator's LAN bridges
19//! mDNS.
20//!
21//! ## Scope filtering
22//!
23//! When a `discovery_scope` is configured, the advert carries it in a
24//! `scope=<name>` TXT entry and the browser only surfaces peers with a
25//! matching scope. Nodes on the same physical LAN but configured for
26//! different mesh networks don't cross-feed each other.
27
28use 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
42/// DNS-SD service type for the FIPS LAN advert. RFC 6763 §4.1.2: must
43/// end with `.local.`. The `_udp` is the IP transport, not the upper
44/// protocol — both UDP and TCP FIPS endpoints announce under the same
45/// service type because the link-layer punch/handshake travels over UDP
46/// either way.
47pub const SERVICE_TYPE: &str = "_fips._udp.local.";
48
49// Keep address-auto adverts responsive to interface changes without mdns-sd's
50// five-second network scan keeping mobile packet tunnels awake.
51const IP_CHECK_INTERVAL_SECS: u32 = 30;
52// mdns-sd queries at 0, 1, and 3 seconds within this window. All nodes then
53// close their multicast sockets and reconvene on the next wall-clock minute.
54const BROWSE_WINDOW: Duration = Duration::from_secs(5);
55const BROWSE_PERIOD_MS: u128 = 60_000;
56const DAEMON_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
57
58/// TXT key carrying the bech32-encoded npub of the publishing node.
59pub const TXT_KEY_NPUB: &str = "npub";
60
61/// TXT key carrying the publishing node's `discovery_scope`, if any.
62pub const TXT_KEY_SCOPE: &str = "scope";
63
64/// TXT key carrying the FIPS protocol version (matches the Nostr advert
65/// `PROTOCOL_VERSION`).
66pub 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/// A peer we learned about via mDNS. Identity is unverified at this
83/// point; the Node initiates a Noise IK handshake against `addr` to
84/// confirm `npub` actually controls the matching private key.
85#[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/// Browser-side events surfaced by `LanDiscovery::drain_events`.
94#[derive(Debug, Clone)]
95pub enum LanEvent {
96    Discovered(LanDiscoveredPeer),
97}
98
99/// Runtime configuration for the mDNS responder + browser.
100#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
101pub struct LanDiscoveryConfig {
102    /// Master switch. Default: `true` — we already publish identity on
103    /// Nostr relays; the marginal leak from also multicasting on the LAN
104    /// is zero, while the latency win is large for same-LAN peers.
105    #[serde(default = "LanDiscoveryConfig::default_enabled")]
106    pub enabled: bool,
107    /// Overridable service type, primarily so integration tests can run
108    /// multiple isolated services on the same loopback interface.
109    #[serde(default = "LanDiscoveryConfig::default_service_type")]
110    pub service_type: String,
111    /// Optional application/network scope carried in the LAN-only TXT
112    /// record. Browsers that set a scope ignore adverts for other scopes.
113    ///
114    /// This is intentionally separate from Nostr discovery's public `app`
115    /// tag so applications can keep relay-visible adverts generic while
116    /// still isolating LAN discovery per private network.
117    #[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
140/// Running mDNS responder + browser bound to the node's UDP advert port.
141pub 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    /// Start the mDNS responder and browser.
154    ///
155    /// `advertised_port` is the UDP port the operational UDP transport
156    /// is bound to — peers receiving our advert will initiate Noise IK
157    /// against that port. `scope` mirrors the Nostr discovery scope and
158    /// is used to filter the browser stream.
159    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        // mDNS DNS labels are capped at 63 bytes. 16 bech32 chars of npub
174        // give 80 bits of effective entropy — collisions on a single LAN
175        // are vanishingly unlikely. Prefixed for human-readable logs.
176        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        // host_ipv4 is set to "127.0.0.1" *and* enable_addr_auto() is
193        // called: the loopback seed makes the advert resolve for
194        // same-host peers (and same-host integration tests) while the
195        // auto-flag still appends every non-loopback interface address
196        // mdns-sd discovers. Belt-and-braces because addr_auto alone
197        // skips loopback by default on some platforms.
198        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                                // Our own advert echoed back on a loopback
271                                // or multi-homed interface.
272                                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                            // mdns-sd may report multiple interface IPs for
289                            // a multi-homed responder. Surface all routable
290                            // candidates — the Node side filters/dedups and
291                            // only dials addresses compatible with an active
292                            // UDP socket family. IPv6 link-local addresses
293                            // require an interface scope; preserve it when
294                            // mdns-sd provides one, and skip unusable
295                            // scope-less link-local records.
296                            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    /// Bech32 npub published by this node.
383    pub fn own_npub(&self) -> &str {
384        &self.own_npub
385    }
386
387    /// Drain pending browser events. Called once per Node tick.
388    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    /// Tear down the responder, browser, and event pump.
398    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;