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 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. 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;
31use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
32
33use mdns_sd::{IfKind, ScopedIp, 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// Keep address-auto adverts responsive to interface changes without mdns-sd's
48// five-second network scan keeping mobile packet tunnels awake.
49const IP_CHECK_INTERVAL_SECS: u32 = 30;
50// mdns-sd queries at 0, 1, and 3 seconds within this window. All nodes then
51// close their multicast sockets and reconvene on the next wall-clock minute.
52const BROWSE_WINDOW: Duration = Duration::from_secs(5);
53const BROWSE_PERIOD_MS: u128 = 60_000;
54
55/// TXT key carrying the bech32-encoded npub of the publishing node.
56pub const TXT_KEY_NPUB: &str = "npub";
57
58/// TXT key carrying the publishing node's `discovery_scope`, if any.
59pub const TXT_KEY_SCOPE: &str = "scope";
60
61/// TXT key carrying the FIPS protocol version (matches the Nostr advert
62/// `PROTOCOL_VERSION`).
63pub const TXT_KEY_VERSION: &str = "v";
64
65#[derive(Debug, Error)]
66pub enum LanDiscoveryError {
67 #[error("mDNS daemon init failed: {0}")]
68 Daemon(String),
69 #[error("mDNS register failed: {0}")]
70 Register(String),
71 #[error("mDNS browse failed: {0}")]
72 Browse(String),
73 #[error("no advertised UDP port — start a UDP transport first")]
74 NoAdvertisedPort,
75 #[error("LAN discovery disabled in config")]
76 Disabled,
77}
78
79/// A peer we learned about via mDNS. Identity is unverified at this
80/// point; the Node initiates a Noise XX handshake against `addr` to
81/// confirm `npub` actually controls the matching private key.
82#[derive(Debug, Clone)]
83pub struct LanDiscoveredPeer {
84 pub npub: String,
85 pub scope: Option<String>,
86 pub addr: SocketAddr,
87 pub observed_at: Instant,
88}
89
90/// Browser-side events surfaced by `LanDiscovery::drain_events`.
91#[derive(Debug, Clone)]
92pub enum LanEvent {
93 Discovered(LanDiscoveredPeer),
94}
95
96/// Runtime configuration for the mDNS responder + browser.
97#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
98pub struct LanDiscoveryConfig {
99 /// Master switch. Default: `true` — we already publish identity on
100 /// Nostr relays; the marginal leak from also multicasting on the LAN
101 /// is zero, while the latency win is large for same-LAN peers.
102 #[serde(default = "LanDiscoveryConfig::default_enabled")]
103 pub enabled: bool,
104 /// Overridable service type, primarily so integration tests can run
105 /// multiple isolated services on the same loopback interface.
106 #[serde(default = "LanDiscoveryConfig::default_service_type")]
107 pub service_type: String,
108 /// Optional application/network scope carried in the LAN-only TXT
109 /// record. Browsers that set a scope ignore adverts for other scopes.
110 ///
111 /// This is intentionally separate from Nostr discovery's public `app`
112 /// tag so applications can keep relay-visible adverts generic while
113 /// still isolating LAN discovery per private network.
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub scope: Option<String>,
116}
117
118impl Default for LanDiscoveryConfig {
119 fn default() -> Self {
120 Self {
121 enabled: Self::default_enabled(),
122 service_type: Self::default_service_type(),
123 scope: None,
124 }
125 }
126}
127
128impl LanDiscoveryConfig {
129 fn default_enabled() -> bool {
130 true
131 }
132 fn default_service_type() -> String {
133 SERVICE_TYPE.to_string()
134 }
135}
136
137/// Running mDNS responder + browser bound to the node's UDP advert port.
138pub struct LanDiscovery {
139 daemon: ServiceDaemon,
140 own_npub: String,
141 instance_fullname: String,
142 events_rx: Mutex<tokio::sync::mpsc::UnboundedReceiver<LanEvent>>,
143 event_pump: tokio::task::JoinHandle<()>,
144}
145
146impl LanDiscovery {
147 /// Start the mDNS responder and browser.
148 ///
149 /// `advertised_port` is the UDP port the operational UDP transport
150 /// is bound to — peers receiving our advert will initiate Noise XX
151 /// against that port. `scope` mirrors the Nostr discovery scope and
152 /// is used to filter the browser stream.
153 pub async fn start(
154 identity: &Identity,
155 scope: Option<String>,
156 advertised_port: u16,
157 config: LanDiscoveryConfig,
158 ) -> Result<Arc<Self>, LanDiscoveryError> {
159 if !config.enabled {
160 return Err(LanDiscoveryError::Disabled);
161 }
162 if advertised_port == 0 {
163 return Err(LanDiscoveryError::NoAdvertisedPort);
164 }
165
166 let daemon = ServiceDaemon::new().map_err(|e| LanDiscoveryError::Daemon(e.to_string()))?;
167 daemon
168 .set_ip_check_interval(IP_CHECK_INTERVAL_SECS)
169 .map_err(|e| LanDiscoveryError::Daemon(e.to_string()))?;
170
171 let npub = identity.npub();
172 // mDNS DNS labels are capped at 63 bytes. 16 bech32 chars of npub
173 // give 80 bits of effective entropy — collisions on a single LAN
174 // are vanishingly unlikely. Prefixed for human-readable logs.
175 let label_npub = &npub[..16.min(npub.len())];
176 let instance_name = format!("fips-{label_npub}");
177 let host_name = format!("{instance_name}.local.");
178
179 let mut props: HashMap<String, String> = HashMap::new();
180 props.insert(TXT_KEY_NPUB.to_string(), npub.clone());
181 if let Some(s) = scope.as_deref()
182 && !s.is_empty()
183 {
184 props.insert(TXT_KEY_SCOPE.to_string(), s.to_string());
185 }
186 props.insert(
187 TXT_KEY_VERSION.to_string(),
188 super::nostr::PROTOCOL_VERSION.to_string(),
189 );
190
191 // host_ipv4 is set to "127.0.0.1" *and* enable_addr_auto() is
192 // called: the loopback seed makes the advert resolve for
193 // same-host peers (and same-host integration tests) while the
194 // auto-flag still appends every non-loopback interface address
195 // mdns-sd discovers. Belt-and-braces because addr_auto alone
196 // skips loopback by default on some platforms.
197 let service_info = ServiceInfo::new(
198 &config.service_type,
199 &instance_name,
200 &host_name,
201 "127.0.0.1",
202 advertised_port,
203 Some(props),
204 )
205 .map_err(|e| LanDiscoveryError::Register(e.to_string()))?
206 .enable_addr_auto();
207
208 let instance_fullname = service_info.get_fullname().to_string();
209
210 daemon
211 .register(service_info)
212 .map_err(|e| LanDiscoveryError::Register(e.to_string()))?;
213
214 let browse_rx = daemon
215 .browse(&config.service_type)
216 .map_err(|e| LanDiscoveryError::Browse(e.to_string()))?;
217
218 let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel();
219 let own_npub = npub.clone();
220 let scope_filter = scope.clone().filter(|s| !s.is_empty());
221 let browse_daemon = daemon.clone();
222 let service_type = config.service_type.clone();
223 let event_pump = tokio::spawn(async move {
224 let mut browse_rx = browse_rx;
225 loop {
226 let window = tokio::time::sleep(BROWSE_WINDOW);
227 tokio::pin!(window);
228 loop {
229 let event = tokio::select! {
230 event = browse_rx.recv_async() => match event {
231 Ok(event) => event,
232 Err(_) => return,
233 },
234 () = &mut window => break,
235 };
236 match event {
237 ServiceEvent::ServiceResolved(info) => {
238 let mut peer_npub: Option<String> = None;
239 let mut peer_scope: Option<String> = None;
240 for prop in info.get_properties().iter() {
241 match prop.key() {
242 TXT_KEY_NPUB => {
243 peer_npub = Some(prop.val_str().to_string());
244 }
245 TXT_KEY_SCOPE => {
246 peer_scope = Some(prop.val_str().to_string());
247 }
248 _ => {}
249 }
250 }
251 let Some(peer_npub) = peer_npub else {
252 debug!(
253 instance = info.get_fullname(),
254 "lan: skip advert without npub TXT"
255 );
256 continue;
257 };
258 if peer_npub == own_npub {
259 // Our own advert echoed back on a loopback
260 // or multi-homed interface.
261 continue;
262 }
263 if scope_filter.is_some() && scope_filter != peer_scope {
264 debug!(
265 npub = %short(&peer_npub),
266 their_scope = ?peer_scope,
267 our_scope = ?scope_filter,
268 "lan: skip cross-scope advert"
269 );
270 continue;
271 }
272 let port = info.get_port();
273 if port == 0 {
274 continue;
275 }
276 let observed_at = Instant::now();
277 // mdns-sd may report multiple interface IPs for
278 // a multi-homed responder. Surface all routable
279 // candidates — the Node side filters/dedups and
280 // only dials addresses compatible with an active
281 // UDP socket family. IPv6 link-local addresses
282 // require an interface scope; preserve it when
283 // mdns-sd provides one, and skip unusable
284 // scope-less link-local records.
285 for scoped in info.get_addresses() {
286 let Some(addr) = socket_addr_from_scoped_ip(scoped, port) else {
287 debug!(
288 npub = %short(&peer_npub),
289 addr = %scoped.to_ip_addr(),
290 "lan: skip scope-less IPv6 link-local advert"
291 );
292 continue;
293 };
294 if events_tx
295 .send(LanEvent::Discovered(LanDiscoveredPeer {
296 npub: peer_npub.clone(),
297 scope: peer_scope.clone(),
298 addr,
299 observed_at,
300 }))
301 .is_err()
302 {
303 return;
304 }
305 }
306 }
307 ServiceEvent::ServiceRemoved(_, fullname) => {
308 debug!(fullname = %fullname, "lan: service removed");
309 }
310 other => {
311 debug!(?other, "lan: mDNS event");
312 }
313 }
314 }
315 if let Err(error) = browse_daemon.stop_browse(&service_type) {
316 warn!(%error, "lan: stop mDNS browse failed");
317 return;
318 }
319 if let Err(error) = browse_daemon.disable_interface(IfKind::All) {
320 warn!(%error, "lan: suspend mDNS interfaces failed");
321 return;
322 }
323 tokio::time::sleep(next_browse_window_delay()).await;
324 if let Err(error) = browse_daemon.enable_interface(IfKind::All) {
325 warn!(%error, "lan: resume mDNS interfaces failed");
326 return;
327 }
328 browse_rx = match browse_daemon.browse(&service_type) {
329 Ok(receiver) => receiver,
330 Err(error) => {
331 warn!(%error, "lan: restart mDNS browse failed");
332 return;
333 }
334 };
335 }
336 });
337
338 info!(
339 instance = %instance_fullname,
340 port = advertised_port,
341 scope = ?scope,
342 "lan: mDNS discovery started"
343 );
344 Ok(Arc::new(Self {
345 daemon,
346 own_npub: npub,
347 instance_fullname,
348 events_rx: Mutex::new(events_rx),
349 event_pump,
350 }))
351 }
352
353 /// Bech32 npub published by this node.
354 pub fn own_npub(&self) -> &str {
355 &self.own_npub
356 }
357
358 /// Drain pending browser events. Called once per Node tick.
359 pub async fn drain_events(&self) -> Vec<LanEvent> {
360 let mut rx = self.events_rx.lock().await;
361 let mut events = Vec::new();
362 while let Ok(event) = rx.try_recv() {
363 events.push(event);
364 }
365 events
366 }
367
368 /// Tear down the responder, browser, and event pump.
369 pub async fn shutdown(self: &Arc<Self>) {
370 if let Err(e) = self.daemon.unregister(&self.instance_fullname) {
371 warn!(error = %e, "lan: unregister failed");
372 }
373 if let Err(e) = self.daemon.shutdown() {
374 warn!(error = %e, "lan: daemon shutdown failed");
375 }
376 self.event_pump.abort();
377 }
378}
379
380fn next_browse_window_delay() -> Duration {
381 let now_ms = SystemTime::now()
382 .duration_since(UNIX_EPOCH)
383 .unwrap_or_default()
384 .as_millis();
385 browse_window_delay_at(now_ms)
386}
387
388fn browse_window_delay_at(now_ms: u128) -> Duration {
389 Duration::from_millis((BROWSE_PERIOD_MS - now_ms % BROWSE_PERIOD_MS) as u64)
390}
391
392fn short(npub: &str) -> &str {
393 let end = 16.min(npub.len());
394 &npub[..end]
395}
396
397fn socket_addr_from_scoped_ip(scoped: &ScopedIp, port: u16) -> Option<SocketAddr> {
398 match scoped {
399 ScopedIp::V4(v4) => Some(SocketAddr::V4(SocketAddrV4::new(*v4.addr(), port))),
400 ScopedIp::V6(v6) => {
401 let ip = *v6.addr();
402 let scope_id = v6.scope_id().index;
403 if ipv6_is_unicast_link_local(ip) && scope_id == 0 {
404 return None;
405 }
406 Some(SocketAddr::V6(SocketAddrV6::new(ip, port, 0, scope_id)))
407 }
408 _ => None,
409 }
410}
411
412fn ipv6_is_unicast_link_local(ip: std::net::Ipv6Addr) -> bool {
413 (ip.segments()[0] & 0xffc0) == 0xfe80
414}
415
416#[cfg(test)]
417mod tests;