mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 16:43:12 +00:00
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:
committed by
Johnathan Corgan
co-authored by
Johnathan Corgan
parent
da0d9d39a0
commit
7d7b551ca1
@@ -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
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user