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;