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. 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. 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::Instant;
32
33use mdns_sd::{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/// 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 /// Optional application/network scope carried in the LAN-only TXT
101 /// record. Browsers that set a scope ignore adverts for other scopes.
102 ///
103 /// This is intentionally separate from Nostr discovery's public `app`
104 /// tag so applications can keep relay-visible adverts generic while
105 /// still isolating LAN discovery per private network.
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub scope: Option<String>,
108}
109
110impl Default for LanDiscoveryConfig {
111 fn default() -> Self {
112 Self {
113 enabled: Self::default_enabled(),
114 service_type: Self::default_service_type(),
115 scope: None,
116 }
117 }
118}
119
120impl LanDiscoveryConfig {
121 fn default_enabled() -> bool {
122 true
123 }
124 fn default_service_type() -> String {
125 SERVICE_TYPE.to_string()
126 }
127}
128
129/// Running mDNS responder + browser bound to the node's UDP advert port.
130pub struct LanDiscovery {
131 daemon: ServiceDaemon,
132 own_npub: String,
133 instance_fullname: String,
134 events_rx: Mutex<tokio::sync::mpsc::UnboundedReceiver<LanEvent>>,
135 event_pump: tokio::task::JoinHandle<()>,
136}
137
138impl LanDiscovery {
139 /// Start the mDNS responder and browser.
140 ///
141 /// `advertised_port` is the UDP port the operational UDP transport
142 /// is bound to — peers receiving our advert will initiate Noise XX
143 /// against that port. `scope` mirrors the Nostr discovery scope and
144 /// is used to filter the browser stream.
145 pub async fn start(
146 identity: &Identity,
147 scope: Option<String>,
148 advertised_port: u16,
149 config: LanDiscoveryConfig,
150 ) -> Result<Arc<Self>, LanDiscoveryError> {
151 if !config.enabled {
152 return Err(LanDiscoveryError::Disabled);
153 }
154 if advertised_port == 0 {
155 return Err(LanDiscoveryError::NoAdvertisedPort);
156 }
157
158 let daemon = ServiceDaemon::new().map_err(|e| LanDiscoveryError::Daemon(e.to_string()))?;
159
160 let npub = identity.npub();
161 // mDNS DNS labels are capped at 63 bytes. 16 bech32 chars of npub
162 // give 80 bits of effective entropy — collisions on a single LAN
163 // are vanishingly unlikely. Prefixed for human-readable logs.
164 let label_npub = &npub[..16.min(npub.len())];
165 let instance_name = format!("fips-{label_npub}");
166 let host_name = format!("{instance_name}.local.");
167
168 let mut props: HashMap<String, String> = HashMap::new();
169 props.insert(TXT_KEY_NPUB.to_string(), npub.clone());
170 if let Some(s) = scope.as_deref()
171 && !s.is_empty()
172 {
173 props.insert(TXT_KEY_SCOPE.to_string(), s.to_string());
174 }
175 props.insert(
176 TXT_KEY_VERSION.to_string(),
177 super::nostr::PROTOCOL_VERSION.to_string(),
178 );
179
180 // host_ipv4 is set to "127.0.0.1" *and* enable_addr_auto() is
181 // called: the loopback seed makes the advert resolve for
182 // same-host peers (and same-host integration tests) while the
183 // auto-flag still appends every non-loopback interface address
184 // mdns-sd discovers. Belt-and-braces because addr_auto alone
185 // skips loopback by default on some platforms.
186 let service_info = ServiceInfo::new(
187 &config.service_type,
188 &instance_name,
189 &host_name,
190 "127.0.0.1",
191 advertised_port,
192 Some(props),
193 )
194 .map_err(|e| LanDiscoveryError::Register(e.to_string()))?
195 .enable_addr_auto();
196
197 let instance_fullname = service_info.get_fullname().to_string();
198
199 daemon
200 .register(service_info)
201 .map_err(|e| LanDiscoveryError::Register(e.to_string()))?;
202
203 let browse_rx = daemon
204 .browse(&config.service_type)
205 .map_err(|e| LanDiscoveryError::Browse(e.to_string()))?;
206
207 let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel();
208 let own_npub = npub.clone();
209 let scope_filter = scope.clone().filter(|s| !s.is_empty());
210 let event_pump = tokio::spawn(async move {
211 // mdns-sd browse returns a flume::Receiver; pump until the
212 // daemon shuts down and the channel closes.
213 loop {
214 let event = match browse_rx.recv_async().await {
215 Ok(e) => e,
216 Err(_) => break,
217 };
218 match event {
219 ServiceEvent::ServiceResolved(info) => {
220 let mut peer_npub: Option<String> = None;
221 let mut peer_scope: Option<String> = None;
222 for prop in info.get_properties().iter() {
223 match prop.key() {
224 TXT_KEY_NPUB => {
225 peer_npub = Some(prop.val_str().to_string());
226 }
227 TXT_KEY_SCOPE => {
228 peer_scope = Some(prop.val_str().to_string());
229 }
230 _ => {}
231 }
232 }
233 let Some(peer_npub) = peer_npub else {
234 debug!(
235 instance = info.get_fullname(),
236 "lan: skip advert without npub TXT"
237 );
238 continue;
239 };
240 if peer_npub == own_npub {
241 // Our own advert echoed back on a loopback
242 // or multi-homed interface.
243 continue;
244 }
245 if scope_filter.is_some() && scope_filter != peer_scope {
246 debug!(
247 npub = %short(&peer_npub),
248 their_scope = ?peer_scope,
249 our_scope = ?scope_filter,
250 "lan: skip cross-scope advert"
251 );
252 continue;
253 }
254 let port = info.get_port();
255 if port == 0 {
256 continue;
257 }
258 let observed_at = Instant::now();
259 // mdns-sd may report multiple interface IPs for
260 // a multi-homed responder. Surface all routable
261 // candidates — the Node side filters/dedups and
262 // only dials addresses compatible with an active
263 // UDP socket family. IPv6 link-local addresses
264 // require an interface scope; preserve it when
265 // mdns-sd provides one, and skip unusable
266 // scope-less link-local records.
267 for scoped in info.get_addresses() {
268 let Some(addr) = socket_addr_from_scoped_ip(scoped, port) else {
269 debug!(
270 npub = %short(&peer_npub),
271 addr = %scoped.to_ip_addr(),
272 "lan: skip scope-less IPv6 link-local advert"
273 );
274 continue;
275 };
276 if events_tx
277 .send(LanEvent::Discovered(LanDiscoveredPeer {
278 npub: peer_npub.clone(),
279 scope: peer_scope.clone(),
280 addr,
281 observed_at,
282 }))
283 .is_err()
284 {
285 return;
286 }
287 }
288 }
289 ServiceEvent::ServiceRemoved(_, fullname) => {
290 debug!(fullname = %fullname, "lan: service removed");
291 }
292 other => {
293 debug!(?other, "lan: mDNS event");
294 }
295 }
296 }
297 });
298
299 info!(
300 instance = %instance_fullname,
301 port = advertised_port,
302 scope = ?scope,
303 "lan: mDNS discovery started"
304 );
305 Ok(Arc::new(Self {
306 daemon,
307 own_npub: npub,
308 instance_fullname,
309 events_rx: Mutex::new(events_rx),
310 event_pump,
311 }))
312 }
313
314 /// Bech32 npub published by this node.
315 pub fn own_npub(&self) -> &str {
316 &self.own_npub
317 }
318
319 /// Drain pending browser events. Called once per Node tick.
320 pub async fn drain_events(&self) -> Vec<LanEvent> {
321 let mut rx = self.events_rx.lock().await;
322 let mut events = Vec::new();
323 while let Ok(event) = rx.try_recv() {
324 events.push(event);
325 }
326 events
327 }
328
329 /// Tear down the responder, browser, and event pump.
330 pub async fn shutdown(self: &Arc<Self>) {
331 if let Err(e) = self.daemon.unregister(&self.instance_fullname) {
332 warn!(error = %e, "lan: unregister failed");
333 }
334 if let Err(e) = self.daemon.shutdown() {
335 warn!(error = %e, "lan: daemon shutdown failed");
336 }
337 self.event_pump.abort();
338 }
339}
340
341fn short(npub: &str) -> &str {
342 let end = 16.min(npub.len());
343 &npub[..end]
344}
345
346fn socket_addr_from_scoped_ip(scoped: &ScopedIp, port: u16) -> Option<SocketAddr> {
347 match scoped {
348 ScopedIp::V4(v4) => Some(SocketAddr::V4(SocketAddrV4::new(*v4.addr(), port))),
349 ScopedIp::V6(v6) => {
350 let ip = *v6.addr();
351 let scope_id = v6.scope_id().index;
352 if ipv6_is_unicast_link_local(ip) && scope_id == 0 {
353 return None;
354 }
355 Some(SocketAddr::V6(SocketAddrV6::new(ip, port, 0, scope_id)))
356 }
357 _ => None,
358 }
359}
360
361fn ipv6_is_unicast_link_local(ip: std::net::Ipv6Addr) -> bool {
362 (ip.segments()[0] & 0xffc0) == 0xfe80
363}
364
365#[cfg(test)]
366mod tests;