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//! 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. The result is sub-second peer pairing without any Nostr-relay
7//! roundtrip, STUN observation, or NAT traversal — the observed
8//! endpoint is by construction routable from the consumer's LAN.
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 XX 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. The information leaked (our npub plus a LAN endpoint) is a
18//! subset of what we already publish on Nostr relays for every peer in
19//! the world to read, so there's no marginal privacy cost.
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;
30use std::sync::Arc;
31use std::time::Instant;
32
33use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
34use thiserror::Error;
35use tokio::sync::Mutex;
36use tracing::{debug, info, warn};
37
38use crate::Identity;
39
40/// DNS-SD service type for the FIPS LAN advert. RFC 6763 §4.1.2: must
41/// end with `.local.`. The `_udp` is the IP transport, not the upper
42/// protocol — both UDP and TCP FIPS endpoints announce under the same
43/// service type because the link-layer punch/handshake travels over UDP
44/// either way.
45pub const SERVICE_TYPE: &str = "_fips._udp.local.";
46
47/// TXT key carrying the bech32-encoded npub of the publishing node.
48pub const TXT_KEY_NPUB: &str = "npub";
49
50/// TXT key carrying the publishing node's `discovery_scope`, if any.
51pub const TXT_KEY_SCOPE: &str = "scope";
52
53/// TXT key carrying the FIPS protocol version (matches the Nostr advert
54/// `PROTOCOL_VERSION`).
55pub const TXT_KEY_VERSION: &str = "v";
56
57#[derive(Debug, Error)]
58pub enum LanDiscoveryError {
59    #[error("mDNS daemon init failed: {0}")]
60    Daemon(String),
61    #[error("mDNS register failed: {0}")]
62    Register(String),
63    #[error("mDNS browse failed: {0}")]
64    Browse(String),
65    #[error("no advertised UDP port — start a UDP transport first")]
66    NoAdvertisedPort,
67    #[error("LAN discovery disabled in config")]
68    Disabled,
69}
70
71/// A peer we learned about via mDNS. Identity is unverified at this
72/// point; the Node initiates a Noise XX handshake against `addr` to
73/// confirm `npub` actually controls the matching private key.
74#[derive(Debug, Clone)]
75pub struct LanDiscoveredPeer {
76    pub npub: String,
77    pub scope: Option<String>,
78    pub addr: SocketAddr,
79    pub observed_at: Instant,
80}
81
82/// Browser-side events surfaced by `LanDiscovery::drain_events`.
83#[derive(Debug, Clone)]
84pub enum LanEvent {
85    Discovered(LanDiscoveredPeer),
86}
87
88/// Runtime configuration for the mDNS responder + browser.
89#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
90pub struct LanDiscoveryConfig {
91    /// Master switch. Default: `true` — we already publish identity on
92    /// Nostr relays; the marginal leak from also multicasting on the LAN
93    /// is zero, while the latency win is large for same-LAN peers.
94    #[serde(default = "LanDiscoveryConfig::default_enabled")]
95    pub enabled: bool,
96    /// Overridable service type, primarily so integration tests can run
97    /// multiple isolated services on the same loopback interface.
98    #[serde(default = "LanDiscoveryConfig::default_service_type")]
99    pub service_type: String,
100}
101
102impl Default for LanDiscoveryConfig {
103    fn default() -> Self {
104        Self {
105            enabled: Self::default_enabled(),
106            service_type: Self::default_service_type(),
107        }
108    }
109}
110
111impl LanDiscoveryConfig {
112    fn default_enabled() -> bool {
113        true
114    }
115    fn default_service_type() -> String {
116        SERVICE_TYPE.to_string()
117    }
118}
119
120/// Running mDNS responder + browser bound to the node's UDP advert port.
121pub struct LanDiscovery {
122    daemon: ServiceDaemon,
123    own_npub: String,
124    instance_fullname: String,
125    events_rx: Mutex<tokio::sync::mpsc::UnboundedReceiver<LanEvent>>,
126    event_pump: tokio::task::JoinHandle<()>,
127}
128
129impl LanDiscovery {
130    /// Start the mDNS responder and browser.
131    ///
132    /// `advertised_port` is the UDP port the operational UDP transport
133    /// is bound to — peers receiving our advert will initiate Noise XX
134    /// against that port. `scope` mirrors the Nostr discovery scope and
135    /// is used to filter the browser stream.
136    pub async fn start(
137        identity: &Identity,
138        scope: Option<String>,
139        advertised_port: u16,
140        config: LanDiscoveryConfig,
141    ) -> Result<Arc<Self>, LanDiscoveryError> {
142        if !config.enabled {
143            return Err(LanDiscoveryError::Disabled);
144        }
145        if advertised_port == 0 {
146            return Err(LanDiscoveryError::NoAdvertisedPort);
147        }
148
149        let daemon = ServiceDaemon::new().map_err(|e| LanDiscoveryError::Daemon(e.to_string()))?;
150
151        let npub = identity.npub();
152        // mDNS DNS labels are capped at 63 bytes. 16 bech32 chars of npub
153        // give 80 bits of effective entropy — collisions on a single LAN
154        // are vanishingly unlikely. Prefixed for human-readable logs.
155        let label_npub = &npub[..16.min(npub.len())];
156        let instance_name = format!("fips-{label_npub}");
157        let host_name = format!("{instance_name}.local.");
158
159        let mut props: HashMap<String, String> = HashMap::new();
160        props.insert(TXT_KEY_NPUB.to_string(), npub.clone());
161        if let Some(s) = scope.as_deref()
162            && !s.is_empty()
163        {
164            props.insert(TXT_KEY_SCOPE.to_string(), s.to_string());
165        }
166        props.insert(
167            TXT_KEY_VERSION.to_string(),
168            super::nostr::PROTOCOL_VERSION.to_string(),
169        );
170
171        // host_ipv4 is set to "127.0.0.1" *and* enable_addr_auto() is
172        // called: the loopback seed makes the advert resolve for
173        // same-host peers (and same-host integration tests) while the
174        // auto-flag still appends every non-loopback interface address
175        // mdns-sd discovers. Belt-and-braces because addr_auto alone
176        // skips loopback by default on some platforms.
177        let service_info = ServiceInfo::new(
178            &config.service_type,
179            &instance_name,
180            &host_name,
181            "127.0.0.1",
182            advertised_port,
183            Some(props),
184        )
185        .map_err(|e| LanDiscoveryError::Register(e.to_string()))?
186        .enable_addr_auto();
187
188        let instance_fullname = service_info.get_fullname().to_string();
189
190        daemon
191            .register(service_info)
192            .map_err(|e| LanDiscoveryError::Register(e.to_string()))?;
193
194        let browse_rx = daemon
195            .browse(&config.service_type)
196            .map_err(|e| LanDiscoveryError::Browse(e.to_string()))?;
197
198        let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel();
199        let own_npub = npub.clone();
200        let scope_filter = scope.clone().filter(|s| !s.is_empty());
201        let event_pump = tokio::spawn(async move {
202            // mdns-sd browse returns a flume::Receiver; pump until the
203            // daemon shuts down and the channel closes.
204            loop {
205                let event = match browse_rx.recv_async().await {
206                    Ok(e) => e,
207                    Err(_) => break,
208                };
209                match event {
210                    ServiceEvent::ServiceResolved(info) => {
211                        let mut peer_npub: Option<String> = None;
212                        let mut peer_scope: Option<String> = None;
213                        for prop in info.get_properties().iter() {
214                            match prop.key() {
215                                TXT_KEY_NPUB => {
216                                    peer_npub = Some(prop.val_str().to_string());
217                                }
218                                TXT_KEY_SCOPE => {
219                                    peer_scope = Some(prop.val_str().to_string());
220                                }
221                                _ => {}
222                            }
223                        }
224                        let Some(peer_npub) = peer_npub else {
225                            debug!(
226                                instance = info.get_fullname(),
227                                "lan: skip advert without npub TXT"
228                            );
229                            continue;
230                        };
231                        if peer_npub == own_npub {
232                            // Our own advert echoed back on a loopback
233                            // or multi-homed interface.
234                            continue;
235                        }
236                        if scope_filter.is_some() && scope_filter != peer_scope {
237                            debug!(
238                                npub = %short(&peer_npub),
239                                their_scope = ?peer_scope,
240                                our_scope = ?scope_filter,
241                                "lan: skip cross-scope advert"
242                            );
243                            continue;
244                        }
245                        let port = info.get_port();
246                        if port == 0 {
247                            continue;
248                        }
249                        let observed_at = Instant::now();
250                        // mdns-sd may report multiple interface IPs for
251                        // a multi-homed responder. Surface all of them
252                        // — the Node side filters/dedups. mdns-sd 0.19
253                        // returns `ScopedIp` (with optional IPv6 zone);
254                        // we strip the zone here and pass plain IpAddr
255                        // — the consumer routes via standard tables.
256                        for scoped in info.get_addresses() {
257                            let ip = scoped.to_ip_addr();
258                            let addr = SocketAddr::new(ip, port);
259                            if events_tx
260                                .send(LanEvent::Discovered(LanDiscoveredPeer {
261                                    npub: peer_npub.clone(),
262                                    scope: peer_scope.clone(),
263                                    addr,
264                                    observed_at,
265                                }))
266                                .is_err()
267                            {
268                                return;
269                            }
270                        }
271                    }
272                    ServiceEvent::ServiceRemoved(_, fullname) => {
273                        debug!(fullname = %fullname, "lan: service removed");
274                    }
275                    other => {
276                        debug!(?other, "lan: mDNS event");
277                    }
278                }
279            }
280        });
281
282        info!(
283            instance = %instance_fullname,
284            port = advertised_port,
285            scope = ?scope,
286            "lan: mDNS discovery started"
287        );
288        Ok(Arc::new(Self {
289            daemon,
290            own_npub: npub,
291            instance_fullname,
292            events_rx: Mutex::new(events_rx),
293            event_pump,
294        }))
295    }
296
297    /// Bech32 npub published by this node.
298    pub fn own_npub(&self) -> &str {
299        &self.own_npub
300    }
301
302    /// Drain pending browser events. Called once per Node tick.
303    pub async fn drain_events(&self) -> Vec<LanEvent> {
304        let mut rx = self.events_rx.lock().await;
305        let mut events = Vec::new();
306        while let Ok(event) = rx.try_recv() {
307            events.push(event);
308        }
309        events
310    }
311
312    /// Tear down the responder, browser, and event pump.
313    pub async fn shutdown(self: &Arc<Self>) {
314        if let Err(e) = self.daemon.unregister(&self.instance_fullname) {
315            warn!(error = %e, "lan: unregister failed");
316        }
317        if let Err(e) = self.daemon.shutdown() {
318            warn!(error = %e, "lan: daemon shutdown failed");
319        }
320        self.event_pump.abort();
321    }
322}
323
324fn short(npub: &str) -> &str {
325    let end = 16.min(npub.len());
326    &npub[..end]
327}
328
329#[cfg(test)]
330mod tests;