discovery: add opt-in mDNS LAN discovery

Add scoped mDNS / DNS-SD discovery for peers on the same local link,
giving sub-second pairing without a relay or NAT-traversal roundtrip.
A node advertises its npub, protocol version, and an optional network
scope over link-local multicast, and browses for matching adverts to
initiate Noise handshakes against same-LAN peers.

LAN discovery is disabled by default; operators enable it with
node.discovery.lan.enabled: true. Default-off avoids reintroducing a
per-LAN identity broadcast on nodes that have deliberately disabled
other discovery channels, and avoids any multicast surprise on upgrade.

The startup advertised-port picker now excludes bootstrap transports
and selects a non-bootstrap operational UDP transport with a stable
lowest-id selector, so the advertised port is deterministic across
restarts rather than dependent on HashMap iteration order. This
matches the per-dial transport selection used for discovered peers.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
This commit is contained in:
Martti Malmi
2026-05-30 01:32:06 +00:00
committed by Johnathan Corgan
co-authored by Johnathan Corgan
parent da0d9d39a0
commit 7d7b551ca1
10 changed files with 940 additions and 19 deletions
+1
View File
@@ -252,6 +252,7 @@ impl Node {
self.reload_peer_acl();
self.poll_pending_connects().await;
self.poll_nostr_discovery().await;
self.poll_lan_discovery().await;
self.resend_pending_handshakes(now_ms).await;
self.resend_pending_rekeys(now_ms).await;
self.resend_pending_session_handshakes(now_ms).await;
+190 -9
View File
@@ -15,6 +15,7 @@ use crate::transport::{Link, LinkDirection, LinkId, TransportAddr, TransportId,
use crate::upper::tun::{TunDevice, TunState, run_tun_reader, shutdown_tun_interface};
use crate::{NodeAddr, PeerIdentity};
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::thread;
use std::time::Duration;
use tracing::{debug, info, warn};
@@ -23,6 +24,13 @@ const OPEN_DISCOVERY_RETRY_LIFETIME_MULTIPLIER: u64 = 2;
const MAX_PARALLEL_PATH_CANDIDATES_PER_PEER: usize = 4;
const MAX_DISCOVERY_CONNECTS_PER_TICK: usize = 16;
fn socket_addr_families_compatible(local: SocketAddr, remote: SocketAddr) -> bool {
matches!(
(local, remote),
(SocketAddr::V4(_), SocketAddr::V4(_)) | (SocketAddr::V6(_), SocketAddr::V6(_))
)
}
impl Node {
/// Replace the runtime peer list.
///
@@ -324,6 +332,31 @@ impl Node {
})
}
/// Find a UDP transport whose bound socket can send to `remote_addr`.
///
/// LAN discovery can surface both IPv4 and IPv6 addresses for the same
/// service. A wildcard IPv4 socket cannot send to an IPv6 link-local
/// target, and vice versa, so callers must choose by socket family rather
/// than by transport type alone.
fn find_udp_transport_for_remote_addr(
&self,
remote_addr: SocketAddr,
) -> Option<(TransportId, SocketAddr)> {
self.transports
.iter()
.filter(|(id, handle)| {
handle.transport_type().name == "udp"
&& handle.is_operational()
&& !self.bootstrap_transports.contains(id)
})
.filter_map(|(id, handle)| {
let local_addr = handle.local_addr()?;
socket_addr_families_compatible(local_addr, remote_addr)
.then_some((*id, local_addr))
})
.min_by_key(|(id, _)| id.as_u32())
}
/// Initiate a connection to a peer on a specific transport and address.
///
/// For connectionless transports (UDP, Ethernet): allocates a link, starts
@@ -829,6 +862,90 @@ impl Node {
self.queue_open_discovery_retries(&bootstrap).await;
}
/// Resolve the LAN-only discovery scope. Applications with explicit
/// connectivity config can set `node.discovery.lan.scope` without
/// changing the public Nostr discovery `app` tag. The older fallback
/// extracts a scope from the Nostr app tag used by default scoped
/// discovery.
pub(super) fn lan_discovery_scope(&self) -> Option<String> {
if let Some(scope) = self.config.node.discovery.lan.scope.as_deref() {
let scope = scope.trim();
if !scope.is_empty() {
return Some(scope.to_string());
}
}
let app = self.config.node.discovery.nostr.app.trim();
if app.is_empty() {
return None;
}
if let Some(rest) = app.strip_prefix("fips-overlay-v1:") {
let scope = rest.trim();
if scope.is_empty() {
None
} else {
Some(scope.to_string())
}
} else {
Some(app.to_string())
}
}
/// Drain mDNS-discovered peers and initiate Noise XX handshakes.
/// The handshake itself is the authentication — a spoofed mDNS advert
/// with someone else's npub fails the XX exchange and is dropped.
pub(super) async fn poll_lan_discovery(&mut self) {
let Some(runtime) = self.lan_discovery.clone() else {
return;
};
let events = runtime.drain_events().await;
if events.is_empty() {
return;
}
for event in events {
let crate::discovery::lan::LanEvent::Discovered(peer) = event;
let Some((transport_id, local_addr)) =
self.find_udp_transport_for_remote_addr(peer.addr)
else {
debug!(
addr = %peer.addr,
"lan: skip discovered peer with no compatible UDP transport"
);
continue;
};
let identity = match crate::PeerIdentity::from_npub(&peer.npub) {
Ok(id) => id,
Err(err) => {
debug!(npub = %peer.npub, error = %err, "lan: skip bad npub");
continue;
}
};
let peer_node_addr = *identity.node_addr();
let remote_addr = crate::transport::TransportAddr::from_string(&peer.addr.to_string());
if self.peers.contains_key(&peer_node_addr)
|| self.is_connecting_to_peer(&peer_node_addr)
{
continue;
}
info!(
npub = %identity.short_npub(),
addr = %peer.addr,
local_addr = %local_addr,
"lan: initiating handshake to discovered peer"
);
if let Err(err) = self
.initiate_connection(transport_id, remote_addr, identity)
.await
{
debug!(
npub = %peer.npub,
error = %err,
"lan: failed to initiate connection to discovered peer"
);
}
}
}
/// Poll pending transport connects and initiate handshakes for ready ones.
///
/// Called from the tick handler. For each pending connect, queries the
@@ -1029,6 +1146,47 @@ impl Node {
}
}
// mDNS / DNS-SD LAN discovery. Independent of Nostr — runs even
// when Nostr is disabled, since it gives us sub-second pairing
// on the same link without any relay or NAT-traversal roundtrip.
if self.config.node.discovery.lan.enabled {
// Advertise the port of a non-bootstrap operational UDP transport.
// Bootstrap transports must be excluded (they are not the node's
// listening data-plane socket), and a stable selector (lowest
// TransportId) is used so the advertised port is deterministic
// across restarts rather than dependent on HashMap iteration
// order. This mirrors find_udp_transport_for_remote_addr.
let advertised_udp_port = self
.transports
.iter()
.filter(|(id, h)| {
h.transport_type().name == "udp"
&& h.is_operational()
&& !self.bootstrap_transports.contains(id)
})
.filter_map(|(id, h)| h.local_addr().map(|addr| (*id, addr.port())))
.min_by_key(|(id, _)| id.as_u32())
.map(|(_, port)| port)
.unwrap_or(0);
let scope = self.lan_discovery_scope();
match crate::discovery::lan::LanDiscovery::start(
&self.identity,
scope,
advertised_udp_port,
self.config.node.discovery.lan.clone(),
)
.await
{
Ok(runtime) => {
self.lan_discovery = Some(runtime);
info!("LAN mDNS discovery enabled");
}
Err(err) => {
debug!(error = %err, "LAN mDNS discovery not started");
}
}
}
// Connect to static peers before TUN is active
// This allows handshake messages to be sent before we start accepting packets
self.initiate_peer_connections().await;
@@ -1314,6 +1472,13 @@ impl Node {
warn!(error = %e, "Failed to shutdown Nostr overlay discovery");
}
// Tear down LAN mDNS responder + browser. Best-effort: the
// OS will eventually time the advert out via its TTL even if
// we don't get a clean unregister out before the daemon exits.
if let Some(lan) = self.lan_discovery.take() {
lan.shutdown().await;
}
// Shutdown transports (they're packet producers)
let transport_ids: Vec<_> = self.transports.keys().cloned().collect();
for transport_id in transport_ids {
@@ -1578,15 +1743,31 @@ impl Node {
continue;
}
} else {
let tid = match self.find_transport_for_type(&addr.transport) {
Some(id) => id,
None => {
debug!(
transport = %addr.transport,
addr = %addr.addr,
"No operational transport for address type"
);
continue;
let tid = if addr.transport == "udp"
&& let Ok(remote_socket_addr) = addr.addr.parse::<SocketAddr>()
{
match self.find_udp_transport_for_remote_addr(remote_socket_addr) {
Some((id, _)) => id,
None => {
debug!(
transport = %addr.transport,
addr = %addr.addr,
"No compatible operational UDP transport for address"
);
continue;
}
}
} else {
match self.find_transport_for_type(&addr.transport) {
Some(id) => id,
None => {
debug!(
transport = %addr.transport,
addr = %addr.addr,
"No operational transport for address type"
);
continue;
}
}
};
(tid, TransportAddr::from_string(&addr.addr))
+7
View File
@@ -463,6 +463,11 @@ pub struct Node {
/// Optional Nostr/STUN overlay discovery coordinator for `udp:nat` peers.
nostr_discovery: Option<Arc<crate::discovery::nostr::NostrDiscovery>>,
/// mDNS / DNS-SD responder + browser for local-link peer discovery.
/// Identity is unverified at this layer — the Noise XX handshake
/// initiated against an mDNS-observed endpoint is what proves the
/// peer holds the matching private key.
lan_discovery: Option<Arc<crate::discovery::lan::LanDiscovery>>,
/// Wall-clock ms when Nostr discovery successfully started, used to
/// schedule the one-shot startup advert sweep after a settle delay.
/// `None` until discovery comes up; remains `None` if discovery is
@@ -682,6 +687,7 @@ impl Node {
retry_pending: HashMap::new(),
nostr_discovery: None,
nostr_discovery_started_at_ms: None,
lan_discovery: None,
startup_open_discovery_sweep_done: false,
bootstrap_transports: HashSet::new(),
bootstrap_transport_npubs: HashMap::new(),
@@ -827,6 +833,7 @@ impl Node {
retry_pending: HashMap::new(),
nostr_discovery: None,
nostr_discovery_started_at_ms: None,
lan_discovery: None,
startup_open_discovery_sweep_done: false,
bootstrap_transports: HashSet::new(),
bootstrap_transport_npubs: HashMap::new(),