mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 00:26:59 +00:00
Merge master into refactor-hotpath
Bring the runtime peer-list refresh and opt-in mDNS LAN discovery work on master into the receive-path RejectReason / reloadable-config integration branch. Code files auto-merge clean; the only conflict is the CHANGELOG Unreleased section, resolved as the union of both sets of entries.
This commit is contained in:
@@ -12,6 +12,8 @@ use crate::transport::{TransportAddr, TransportId};
|
||||
use crate::{NodeAddr, PeerIdentity};
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
const MAX_RECENT_DISCOVERY_REQUESTS: usize = 4096;
|
||||
|
||||
impl Node {
|
||||
/// Handle an incoming LookupRequest from a peer.
|
||||
///
|
||||
@@ -37,6 +39,7 @@ impl Node {
|
||||
};
|
||||
|
||||
let now_ms = Self::now_ms();
|
||||
self.purge_expired_requests(now_ms);
|
||||
|
||||
// Dedup: drop if we've already seen this request_id.
|
||||
// Also serves as loop protection — tree routing is loop-free,
|
||||
@@ -53,13 +56,21 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.recent_requests.len() >= MAX_RECENT_DISCOVERY_REQUESTS {
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
from = %self.peer_display_name(from),
|
||||
recent_requests = self.recent_requests.len(),
|
||||
max_recent_requests = MAX_RECENT_DISCOVERY_REQUESTS,
|
||||
"Discovery request dedup cache full, dropping LookupRequest"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Record for reverse-path forwarding and dedup
|
||||
self.recent_requests
|
||||
.insert(request.request_id, RecentRequest::new(*from, now_ms));
|
||||
|
||||
// Lazy purge expired entries
|
||||
self.purge_expired_requests(now_ms);
|
||||
|
||||
// Are we the target?
|
||||
if request.target == *self.node_addr() {
|
||||
self.stats_mut().discovery.req_target_is_us += 1;
|
||||
|
||||
@@ -698,8 +698,15 @@ impl Node {
|
||||
// Complete the rekey handshake on the ActivePeer
|
||||
if let Some(peer) = self.peers.get_mut(&peer_node_addr) {
|
||||
match peer.complete_rekey_msg2(noise_msg2) {
|
||||
Ok(session) => {
|
||||
Ok((session, remote_epoch)) => {
|
||||
let our_index = peer.rekey_our_index().unwrap_or(header.receiver_idx);
|
||||
let remote_epoch_changed = matches!(
|
||||
(peer.remote_epoch(), remote_epoch),
|
||||
(Some(old), Some(new)) if old != new
|
||||
);
|
||||
if remote_epoch.is_some() {
|
||||
peer.set_remote_epoch(remote_epoch);
|
||||
}
|
||||
peer.set_pending_session(session, our_index, header.sender_idx);
|
||||
|
||||
if let Some(transport_id) = peer.transport_id() {
|
||||
@@ -707,6 +714,19 @@ impl Node {
|
||||
.insert((transport_id, our_index.as_u32()), peer_node_addr);
|
||||
}
|
||||
|
||||
if remote_epoch_changed {
|
||||
if self.sessions.remove(&peer_node_addr).is_some() {
|
||||
debug!(
|
||||
peer = %display_name,
|
||||
"Cleared stale FSP session after peer restart during FMP rekey"
|
||||
);
|
||||
}
|
||||
info!(
|
||||
peer = %display_name,
|
||||
"Peer restart detected during FMP rekey, replacing stale endpoint session"
|
||||
);
|
||||
}
|
||||
|
||||
debug!(
|
||||
peer = %display_name,
|
||||
new_our_index = %our_index,
|
||||
@@ -1083,9 +1103,15 @@ impl Node {
|
||||
if let Some(existing_peer) = self.peers.get(&peer_node_addr) {
|
||||
let existing_link_id = existing_peer.link_id();
|
||||
|
||||
// Determine which connection wins
|
||||
let this_wins =
|
||||
cross_connection_winner(self.identity.node_addr(), &peer_node_addr, is_outbound);
|
||||
let remote_epoch_changed = matches!((existing_peer.remote_epoch(), remote_epoch), (Some(old), Some(new)) if old != new);
|
||||
|
||||
// Determine which connection wins. A peer restart (different
|
||||
// startup epoch) is not a normal cross-connection: the old link
|
||||
// and FSP sessions are cryptographically stale, so the freshly
|
||||
// authenticated connection must replace them regardless of the
|
||||
// tie-breaker direction.
|
||||
let this_wins = remote_epoch_changed
|
||||
|| cross_connection_winner(self.identity.node_addr(), &peer_node_addr, is_outbound);
|
||||
|
||||
if this_wins {
|
||||
// This connection wins, replace the existing peer
|
||||
@@ -1111,6 +1137,21 @@ impl Node {
|
||||
let _ = self.index_allocator.free(old_idx);
|
||||
}
|
||||
|
||||
if remote_epoch_changed {
|
||||
if self.sessions.remove(&peer_node_addr).is_some() {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
"Cleared stale FSP session after peer restart during promotion"
|
||||
);
|
||||
}
|
||||
info!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
winner_link = %link_id,
|
||||
loser_link = %loser_link_id,
|
||||
"Peer restart detected during promotion, replacing stale active peer"
|
||||
);
|
||||
}
|
||||
|
||||
self.seed_path_mtu_for_link_peer(&peer_node_addr, transport_id, ¤t_addr);
|
||||
|
||||
let mut new_peer = ActivePeer::with_session(
|
||||
|
||||
@@ -260,6 +260,7 @@ impl Node {
|
||||
self.reload_host_map().await;
|
||||
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;
|
||||
|
||||
+696
-65
@@ -14,14 +14,185 @@ use crate::protocol::{Disconnect, DisconnectReason};
|
||||
use crate::transport::{Link, LinkDirection, LinkId, TransportAddr, TransportId, packet_channel};
|
||||
use crate::upper::tun::{TunDevice, TunState, run_tun_reader, shutdown_tun_interface};
|
||||
use crate::{NodeAddr, PeerIdentity};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
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.
|
||||
///
|
||||
/// Newly added auto-connect peers are dialed immediately, removed peers
|
||||
/// are dropped from retry bookkeeping, and existing peers get fresh
|
||||
/// address hints without tearing down an active link. If an existing peer
|
||||
/// is already connected and a new concrete candidate appears, FIPS starts
|
||||
/// an alternate handshake in parallel; promotion switches only after that
|
||||
/// handshake authenticates.
|
||||
pub async fn update_peers(
|
||||
&mut self,
|
||||
new_peers: Vec<PeerConfig>,
|
||||
) -> Result<crate::node::UpdatePeersOutcome, NodeError> {
|
||||
let mut new_by_addr: HashMap<NodeAddr, PeerConfig> =
|
||||
HashMap::with_capacity(new_peers.len());
|
||||
for peer in new_peers {
|
||||
let identity =
|
||||
PeerIdentity::from_npub(&peer.npub).map_err(|e| NodeError::InvalidPeerNpub {
|
||||
npub: peer.npub.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
new_by_addr.insert(*identity.node_addr(), peer);
|
||||
}
|
||||
|
||||
let current_by_addr: HashMap<NodeAddr, PeerConfig> = self
|
||||
.config
|
||||
.peers()
|
||||
.iter()
|
||||
.filter_map(|peer| {
|
||||
PeerIdentity::from_npub(&peer.npub)
|
||||
.ok()
|
||||
.map(|identity| (*identity.node_addr(), peer.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let new_addrs: HashSet<_> = new_by_addr.keys().copied().collect();
|
||||
let current_addrs: HashSet<_> = current_by_addr.keys().copied().collect();
|
||||
|
||||
let removed: Vec<_> = current_addrs.difference(&new_addrs).copied().collect();
|
||||
let added: Vec<_> = new_addrs.difference(¤t_addrs).copied().collect();
|
||||
let kept: Vec<_> = new_addrs.intersection(¤t_addrs).copied().collect();
|
||||
|
||||
let mut outcome = crate::node::UpdatePeersOutcome::default();
|
||||
let mut refresh_configs = Vec::new();
|
||||
|
||||
for node_addr in &removed {
|
||||
if self.retry_pending.remove(node_addr).is_some() {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
"Dropping retry entry for peer removed from runtime peer list"
|
||||
);
|
||||
}
|
||||
self.peer_aliases.remove(node_addr);
|
||||
outcome.removed += 1;
|
||||
}
|
||||
|
||||
for node_addr in &kept {
|
||||
let new_peer = &new_by_addr[node_addr];
|
||||
let current_peer = ¤t_by_addr[node_addr];
|
||||
let changed = new_peer.addresses != current_peer.addresses
|
||||
|| new_peer.alias != current_peer.alias
|
||||
|| new_peer.connect_policy != current_peer.connect_policy
|
||||
|| new_peer.auto_reconnect != current_peer.auto_reconnect
|
||||
|| new_peer.via_nostr != current_peer.via_nostr;
|
||||
|
||||
if changed {
|
||||
outcome.updated += 1;
|
||||
if let Some(state) = self.retry_pending.get_mut(node_addr) {
|
||||
state.peer_config = new_peer.clone();
|
||||
state.retry_after_ms = Self::now_ms();
|
||||
}
|
||||
if let Some(alias) = new_peer.alias.clone() {
|
||||
self.peer_aliases.insert(*node_addr, alias);
|
||||
}
|
||||
} else {
|
||||
outcome.unchanged += 1;
|
||||
}
|
||||
|
||||
if new_peer.is_auto_connect() && (!new_peer.addresses.is_empty() || new_peer.via_nostr)
|
||||
{
|
||||
refresh_configs.push(new_peer.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let added_configs: Vec<_> = added
|
||||
.iter()
|
||||
.map(|node_addr| new_by_addr[node_addr].clone())
|
||||
.collect();
|
||||
|
||||
self.config.peers = new_by_addr.into_values().collect();
|
||||
|
||||
for peer_config in added_configs {
|
||||
outcome.added += 1;
|
||||
let Ok(identity) = PeerIdentity::from_npub(&peer_config.npub) else {
|
||||
continue;
|
||||
};
|
||||
let name = peer_config
|
||||
.alias
|
||||
.clone()
|
||||
.unwrap_or_else(|| identity.short_npub());
|
||||
self.peer_aliases.insert(*identity.node_addr(), name);
|
||||
self.register_identity(*identity.node_addr(), identity.pubkey_full());
|
||||
|
||||
if peer_config.is_auto_connect()
|
||||
&& let Err(err) = self.initiate_peer_connection(&peer_config).await
|
||||
{
|
||||
debug!(
|
||||
npub = %peer_config.npub,
|
||||
error = %err,
|
||||
"Failed to initiate connection for newly added runtime peer"
|
||||
);
|
||||
self.schedule_retry(*identity.node_addr(), Self::now_ms());
|
||||
}
|
||||
}
|
||||
|
||||
for peer_config in refresh_configs {
|
||||
let Ok(identity) = PeerIdentity::from_npub(&peer_config.npub) else {
|
||||
continue;
|
||||
};
|
||||
let node_addr = *identity.node_addr();
|
||||
|
||||
if self.peers.contains_key(&node_addr) {
|
||||
match self
|
||||
.try_active_peer_alternative_addresses(&peer_config, identity)
|
||||
.await
|
||||
{
|
||||
Ok(true) => debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Started alternate-path handshake for active peer"
|
||||
),
|
||||
Ok(false) => {}
|
||||
Err(err) => debug!(
|
||||
npub = %peer_config.npub,
|
||||
error = %err,
|
||||
"Active peer alternate-path refresh did not start"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
match self.initiate_peer_connection(&peer_config).await {
|
||||
Ok(()) => {
|
||||
if let Some(state) = self.retry_pending.get_mut(&node_addr) {
|
||||
state.peer_config = peer_config;
|
||||
state.retry_after_ms = Self::now_ms().saturating_add(
|
||||
self.config.node.rate_limit.handshake_timeout_secs * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
npub = %peer_config.npub,
|
||||
error = %err,
|
||||
"Refreshed peer addresses did not initiate a direct connection"
|
||||
);
|
||||
self.schedule_retry(node_addr, Self::now_ms());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Initiate connections to configured static peers.
|
||||
///
|
||||
/// For each peer configured with AutoConnect policy, creates a link and
|
||||
@@ -142,6 +313,50 @@ impl Node {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_connecting_to_peer_on_path(
|
||||
&self,
|
||||
peer_node_addr: &NodeAddr,
|
||||
transport_id: TransportId,
|
||||
remote_addr: &TransportAddr,
|
||||
) -> bool {
|
||||
self.connections.values().any(|conn| {
|
||||
conn.expected_identity()
|
||||
.map(|id| id.node_addr() == peer_node_addr)
|
||||
.unwrap_or(false)
|
||||
&& conn.transport_id() == Some(transport_id)
|
||||
&& conn.source_addr() == Some(remote_addr)
|
||||
}) || self.pending_connects.iter().any(|pending| {
|
||||
pending.peer_identity.node_addr() == peer_node_addr
|
||||
&& pending.transport_id == transport_id
|
||||
&& pending.remote_addr == *remote_addr
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -340,6 +555,9 @@ impl Node {
|
||||
pub(super) async fn poll_transport_discovery(&mut self) {
|
||||
// Collect discoveries first to avoid borrow conflict with self
|
||||
let mut to_connect = Vec::new();
|
||||
let mut queued_per_peer: HashMap<NodeAddr, usize> = HashMap::new();
|
||||
let mut connect_budget = self.discovery_connect_budget();
|
||||
let mut skipped_budget = 0usize;
|
||||
|
||||
for (transport_id, transport) in &self.transports {
|
||||
if !transport.is_operational() {
|
||||
@@ -366,29 +584,80 @@ impl Node {
|
||||
if node_addr == *self.identity.node_addr() {
|
||||
continue;
|
||||
}
|
||||
// Skip if already connected
|
||||
|
||||
let candidate_transport_id = *transport_id;
|
||||
let remote_addr = peer.addr;
|
||||
|
||||
if self.peers.contains_key(&node_addr) {
|
||||
continue;
|
||||
}
|
||||
// Skip if connection already in progress
|
||||
let connecting = self.connections.values().any(|c| {
|
||||
c.expected_identity()
|
||||
.map(|id| id.node_addr() == &node_addr)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if connecting {
|
||||
let transport_name = transport.transport_type().name;
|
||||
let candidate = PeerAddress::new(transport_name, remote_addr.to_string());
|
||||
if self.active_peer_candidate_is_fresh_enough_to_skip(
|
||||
&node_addr,
|
||||
std::slice::from_ref(&candidate),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if self.is_connecting_to_peer_on_path(
|
||||
&node_addr,
|
||||
candidate_transport_id,
|
||||
&remote_addr,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let queued_for_peer = queued_per_peer.get(&node_addr).copied().unwrap_or(0);
|
||||
if connect_budget == 0
|
||||
|| self
|
||||
.path_candidate_attempt_budget(&node_addr)
|
||||
.saturating_sub(queued_for_peer)
|
||||
== 0
|
||||
{
|
||||
skipped_budget = skipped_budget.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
to_connect.push((candidate_transport_id, remote_addr, identity, true));
|
||||
*queued_per_peer.entry(node_addr).or_default() += 1;
|
||||
connect_budget = connect_budget.saturating_sub(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
to_connect.push((*transport_id, peer.addr, identity));
|
||||
if self.is_connecting_to_peer_on_path(
|
||||
&node_addr,
|
||||
candidate_transport_id,
|
||||
&remote_addr,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let queued_for_peer = queued_per_peer.get(&node_addr).copied().unwrap_or(0);
|
||||
if connect_budget == 0
|
||||
|| self
|
||||
.path_candidate_attempt_budget(&node_addr)
|
||||
.saturating_sub(queued_for_peer)
|
||||
== 0
|
||||
{
|
||||
skipped_budget = skipped_budget.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
to_connect.push((candidate_transport_id, remote_addr, identity, false));
|
||||
*queued_per_peer.entry(node_addr).or_default() += 1;
|
||||
connect_budget = connect_budget.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
for (transport_id, remote_addr, identity) in to_connect {
|
||||
if skipped_budget > 0 {
|
||||
debug!(
|
||||
skipped = skipped_budget,
|
||||
queued = to_connect.len(),
|
||||
"Transport discovery connect budget exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
for (transport_id, remote_addr, identity, active_refresh) in to_connect {
|
||||
info!(
|
||||
peer = %self.peer_display_name(identity.node_addr()),
|
||||
transport_id = %transport_id,
|
||||
remote_addr = %remote_addr,
|
||||
active_refresh,
|
||||
"Auto-connecting to discovered peer"
|
||||
);
|
||||
if let Err(e) = self
|
||||
@@ -593,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
|
||||
@@ -793,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;
|
||||
@@ -1078,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 {
|
||||
@@ -1228,8 +1629,10 @@ impl Node {
|
||||
.max()
|
||||
.unwrap_or(100)
|
||||
.saturating_add(1);
|
||||
let seen_at_ms = Self::now_ms();
|
||||
for endpoint in endpoints {
|
||||
let Some(candidate) = Self::overlay_endpoint_to_peer_address(&endpoint, next_priority)
|
||||
let Some(candidate) =
|
||||
Self::overlay_endpoint_to_peer_address(&endpoint, next_priority, seen_at_ms)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -1251,17 +1654,27 @@ impl Node {
|
||||
fn overlay_endpoint_to_peer_address(
|
||||
endpoint: &OverlayEndpointAdvert,
|
||||
priority: u8,
|
||||
seen_at_ms: u64,
|
||||
) -> Option<PeerAddress> {
|
||||
let transport = match endpoint.transport {
|
||||
OverlayTransportKind::Udp => "udp",
|
||||
OverlayTransportKind::Tcp => "tcp",
|
||||
OverlayTransportKind::Tor => "tor",
|
||||
};
|
||||
Some(PeerAddress::with_priority(
|
||||
transport,
|
||||
endpoint.addr.clone(),
|
||||
priority,
|
||||
))
|
||||
Some(
|
||||
PeerAddress::with_priority(transport, endpoint.addr.clone(), priority)
|
||||
.with_seen_at_ms(seen_at_ms),
|
||||
)
|
||||
}
|
||||
|
||||
async fn request_nostr_bootstrap(&self, peer_config: &PeerConfig) -> bool {
|
||||
let Some(bootstrap) = self.nostr_discovery.clone() else {
|
||||
debug!(npub = %peer_config.npub, "No Nostr overlay runtime for udp:nat address");
|
||||
return false;
|
||||
};
|
||||
bootstrap.request_connect(peer_config.clone()).await;
|
||||
info!(npub = %peer_config.npub, "Started Nostr UDP NAT traversal attempt");
|
||||
true
|
||||
}
|
||||
|
||||
async fn attempt_peer_address_list(
|
||||
@@ -1271,18 +1684,28 @@ impl Node {
|
||||
allow_bootstrap_nat: bool,
|
||||
addresses: &[PeerAddress],
|
||||
) -> Result<(), NodeError> {
|
||||
let peer_node_addr = *peer_identity.node_addr();
|
||||
let mut attempted = 0usize;
|
||||
let max_attempts = self.path_candidate_attempt_budget(&peer_node_addr);
|
||||
if max_attempts == 0 {
|
||||
return Err(NodeError::NoTransportForType(format!(
|
||||
"no outbound slots available for {}",
|
||||
peer_config.npub
|
||||
)));
|
||||
}
|
||||
|
||||
for addr in addresses {
|
||||
if attempted >= max_attempts {
|
||||
break;
|
||||
}
|
||||
if addr.transport == "udp" && addr.addr.eq_ignore_ascii_case("nat") {
|
||||
if !allow_bootstrap_nat {
|
||||
continue;
|
||||
}
|
||||
let Some(bootstrap) = self.nostr_discovery.clone() else {
|
||||
debug!(npub = %peer_config.npub, "No Nostr overlay runtime for udp:nat address");
|
||||
continue;
|
||||
};
|
||||
bootstrap.request_connect(peer_config.clone()).await;
|
||||
info!(npub = %peer_config.npub, "Started Nostr UDP NAT traversal attempt");
|
||||
return Ok(());
|
||||
if self.request_nostr_bootstrap(peer_config).await {
|
||||
attempted = attempted.saturating_add(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let (transport_id, remote_addr) = if addr.transport == "ethernet" {
|
||||
@@ -1320,25 +1743,45 @@ 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))
|
||||
};
|
||||
|
||||
if self.is_connecting_to_peer_on_path(&peer_node_addr, transport_id, &remote_addr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self
|
||||
.initiate_connection(transport_id, remote_addr, peer_identity)
|
||||
.await
|
||||
{
|
||||
Ok(()) => return Ok(()),
|
||||
Ok(()) => attempted = attempted.saturating_add(1),
|
||||
Err(e @ NodeError::AccessDenied(_)) => return Err(e),
|
||||
Err(e) => {
|
||||
debug!(
|
||||
@@ -1351,6 +1794,10 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
if attempted > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(NodeError::NoTransportForType(format!(
|
||||
"no operational transport for any of {}'s addresses",
|
||||
peer_config.npub
|
||||
@@ -1427,6 +1874,21 @@ impl Node {
|
||||
}
|
||||
|
||||
if configured_npubs.contains(&npub) {
|
||||
if let Ok(peer_identity) = PeerIdentity::from_npub(&npub) {
|
||||
let node_addr = *peer_identity.node_addr();
|
||||
if !self.peers.contains_key(&node_addr)
|
||||
&& !self.is_connecting_to_peer(&node_addr)
|
||||
&& let Some(state) = self.retry_pending.get_mut(&node_addr)
|
||||
&& state.retry_after_ms > now_ms
|
||||
{
|
||||
state.retry_after_ms = now_ms;
|
||||
debug!(
|
||||
peer = %peer_identity.short_npub(),
|
||||
caller = %caller,
|
||||
"open-discovery sweep: fresh configured-peer advert expedited retry"
|
||||
);
|
||||
}
|
||||
}
|
||||
skipped_configured = skipped_configured.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
@@ -1467,8 +1929,10 @@ impl Node {
|
||||
|
||||
let mut addresses = Vec::new();
|
||||
let mut priority = 120u8;
|
||||
let seen_at_ms = Self::now_ms();
|
||||
for endpoint in endpoints {
|
||||
let Some(candidate) = Self::overlay_endpoint_to_peer_address(&endpoint, priority)
|
||||
let Some(candidate) =
|
||||
Self::overlay_endpoint_to_peer_address(&endpoint, priority, seen_at_ms)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -1607,6 +2071,61 @@ impl Node {
|
||||
connection_slots.min(peer_slots)
|
||||
}
|
||||
|
||||
fn outbound_handshake_slots(&self) -> usize {
|
||||
let used = self
|
||||
.connections
|
||||
.len()
|
||||
.saturating_add(self.pending_connects.len());
|
||||
if self.max_connections == 0 {
|
||||
usize::MAX
|
||||
} else {
|
||||
self.max_connections.saturating_sub(used)
|
||||
}
|
||||
}
|
||||
|
||||
fn outbound_link_slots(&self) -> usize {
|
||||
if self.max_links == 0 {
|
||||
usize::MAX
|
||||
} else {
|
||||
self.max_links.saturating_sub(self.links.len())
|
||||
}
|
||||
}
|
||||
|
||||
fn path_candidate_attempt_budget(&self, peer_node_addr: &NodeAddr) -> usize {
|
||||
if !self.peers.contains_key(peer_node_addr)
|
||||
&& self.max_peers > 0
|
||||
&& self.peers.len() >= self.max_peers
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
let in_flight_for_peer = self
|
||||
.connections
|
||||
.values()
|
||||
.filter(|conn| {
|
||||
conn.expected_identity()
|
||||
.map(|identity| identity.node_addr() == peer_node_addr)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count()
|
||||
.saturating_add(
|
||||
self.pending_connects
|
||||
.iter()
|
||||
.filter(|pending| pending.peer_identity.node_addr() == peer_node_addr)
|
||||
.count(),
|
||||
);
|
||||
|
||||
self.outbound_handshake_slots()
|
||||
.min(self.outbound_link_slots())
|
||||
.min(MAX_PARALLEL_PATH_CANDIDATES_PER_PEER.saturating_sub(in_flight_for_peer))
|
||||
}
|
||||
|
||||
fn discovery_connect_budget(&self) -> usize {
|
||||
self.outbound_handshake_slots()
|
||||
.min(self.outbound_link_slots())
|
||||
.min(MAX_DISCOVERY_CONNECTS_PER_TICK)
|
||||
}
|
||||
|
||||
fn open_discovery_enqueue_budget(&self, configured_npubs: &HashSet<String>) -> usize {
|
||||
let current_open_discovery_pending = self
|
||||
.retry_pending
|
||||
@@ -1841,47 +2360,159 @@ impl Node {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Static-first dialing: avoid delaying configured address attempts on
|
||||
// advert fetch/network latency.
|
||||
let static_addresses = self.static_peer_addresses(peer_config);
|
||||
let candidates = self.peer_address_candidates(peer_config).await;
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Err(NodeError::NoTransportForType(format!(
|
||||
"no addresses known for {}",
|
||||
peer_config.npub
|
||||
)));
|
||||
}
|
||||
|
||||
if self
|
||||
.attempt_peer_address_list(
|
||||
peer_config,
|
||||
peer_identity,
|
||||
allow_bootstrap_nat,
|
||||
&static_addresses,
|
||||
)
|
||||
.attempt_peer_address_list(peer_config, peer_identity, allow_bootstrap_nat, &candidates)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
{
|
||||
let fallback = self
|
||||
.nostr_peer_fallback_addresses(peer_config, &static_addresses)
|
||||
.await;
|
||||
if !fallback.is_empty()
|
||||
&& self
|
||||
.attempt_peer_address_list(
|
||||
peer_config,
|
||||
peer_identity,
|
||||
allow_bootstrap_nat,
|
||||
&fallback,
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(NodeError::NoTransportForType(format!(
|
||||
"no operational transport for any of {}'s addresses",
|
||||
peer_config.npub
|
||||
)))
|
||||
}
|
||||
|
||||
async fn try_active_peer_alternative_addresses(
|
||||
&mut self,
|
||||
peer_config: &PeerConfig,
|
||||
peer_identity: PeerIdentity,
|
||||
) -> Result<bool, NodeError> {
|
||||
let peer_node_addr = *peer_identity.node_addr();
|
||||
let candidates = self.peer_address_candidates(peer_config).await;
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Err(NodeError::NoTransportForType(format!(
|
||||
"no addresses known for {}",
|
||||
peer_config.npub
|
||||
)));
|
||||
}
|
||||
|
||||
let concrete: Vec<_> = candidates
|
||||
.into_iter()
|
||||
.filter(|addr| !(addr.transport == "udp" && addr.addr.eq_ignore_ascii_case("nat")))
|
||||
.collect();
|
||||
let has_alternative = concrete
|
||||
.iter()
|
||||
.any(|addr| !self.active_peer_matches_candidate(&peer_node_addr, addr));
|
||||
let attempt_candidates: Vec<_> = if has_alternative {
|
||||
concrete
|
||||
.into_iter()
|
||||
.filter(|addr| !self.active_peer_matches_candidate(&peer_node_addr, addr))
|
||||
.collect()
|
||||
} else if self.active_peer_needs_same_path_refresh(&peer_node_addr) {
|
||||
concrete
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
if attempt_candidates.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.attempt_peer_address_list(peer_config, peer_identity, false, &attempt_candidates)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn peer_address_candidates(&self, peer_config: &PeerConfig) -> Vec<PeerAddress> {
|
||||
let static_addresses = self.static_peer_addresses(peer_config);
|
||||
let overlay_addresses = self
|
||||
.nostr_peer_fallback_addresses(peer_config, &static_addresses)
|
||||
.await;
|
||||
|
||||
let mut candidates = Vec::with_capacity(overlay_addresses.len() + static_addresses.len());
|
||||
for addr in overlay_addresses.into_iter().chain(static_addresses) {
|
||||
if !candidates.iter().any(|existing: &PeerAddress| {
|
||||
existing.transport == addr.transport && existing.addr == addr.addr
|
||||
}) {
|
||||
candidates.push(addr);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort_by(|a, b| match (a.seen_at_ms, b.seen_at_ms) {
|
||||
(Some(a_ts), Some(b_ts)) => b_ts.cmp(&a_ts),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
});
|
||||
candidates
|
||||
}
|
||||
|
||||
pub(in crate::node) fn active_peer_candidate_is_fresh_enough_to_skip(
|
||||
&self,
|
||||
peer_node_addr: &NodeAddr,
|
||||
candidates: &[PeerAddress],
|
||||
) -> bool {
|
||||
if !self.active_peer_matches_any_candidate(peer_node_addr, candidates) {
|
||||
return false;
|
||||
}
|
||||
!self.active_peer_needs_same_path_refresh(peer_node_addr)
|
||||
}
|
||||
|
||||
fn active_peer_needs_same_path_refresh(&self, peer_node_addr: &NodeAddr) -> bool {
|
||||
let Some(peer) = self.peers.get(peer_node_addr) else {
|
||||
return false;
|
||||
};
|
||||
let stale_after_ms = self
|
||||
.config
|
||||
.node
|
||||
.heartbeat_interval_secs
|
||||
.saturating_mul(1000)
|
||||
.max(1000);
|
||||
peer.idle_time(Self::now_ms()) > stale_after_ms
|
||||
}
|
||||
|
||||
fn active_peer_matches_any_candidate(
|
||||
&self,
|
||||
peer_node_addr: &NodeAddr,
|
||||
candidates: &[PeerAddress],
|
||||
) -> bool {
|
||||
candidates
|
||||
.iter()
|
||||
.any(|candidate| self.active_peer_matches_candidate(peer_node_addr, candidate))
|
||||
}
|
||||
|
||||
fn active_peer_matches_candidate(
|
||||
&self,
|
||||
peer_node_addr: &NodeAddr,
|
||||
candidate: &PeerAddress,
|
||||
) -> bool {
|
||||
let Some(peer) = self.peers.get(peer_node_addr) else {
|
||||
return false;
|
||||
};
|
||||
let Some(current_addr) = peer.current_addr() else {
|
||||
return false;
|
||||
};
|
||||
if peer
|
||||
.transport_id()
|
||||
.map(|id| self.bootstrap_transports.contains(&id))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let current_addr = current_addr.to_string();
|
||||
let current_transport = peer
|
||||
.transport_id()
|
||||
.and_then(|id| self.transports.get(&id))
|
||||
.map(|transport| transport.transport_type().name);
|
||||
|
||||
candidate.addr == current_addr
|
||||
&& current_transport
|
||||
.map(|transport| transport == candidate.transport)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
// === Control API methods ===
|
||||
|
||||
/// Connect to a peer via the control API.
|
||||
|
||||
@@ -202,6 +202,19 @@ impl fmt::Display for NodeState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports what changed when replacing the runtime peer list.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct UpdatePeersOutcome {
|
||||
/// Peers present in the new list but not the previous list.
|
||||
pub added: usize,
|
||||
/// Peers removed from the previous list.
|
||||
pub removed: usize,
|
||||
/// Existing peers whose configured behavior changed.
|
||||
pub updated: usize,
|
||||
/// Existing peers whose comparable config did not change.
|
||||
pub unchanged: usize,
|
||||
}
|
||||
|
||||
/// Recent request tracking for dedup and reverse-path forwarding.
|
||||
///
|
||||
/// When a LookupRequest is forwarded through a node, the node stores the
|
||||
@@ -453,6 +466,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
|
||||
@@ -669,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(),
|
||||
@@ -812,6 +831,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(),
|
||||
|
||||
+11
-1
@@ -11,6 +11,7 @@ use crate::identity::NodeAddr;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
// MAX_BACKOFF_MS is now derived from config: node.retry.max_backoff_secs * 1000
|
||||
const MAX_RETRY_CONNECTIONS_PER_TICK: usize = 16;
|
||||
|
||||
/// Tracks retry state for a peer across connection attempts.
|
||||
pub struct RetryState {
|
||||
@@ -248,8 +249,17 @@ impl Node {
|
||||
.filter(|(_, state)| now_ms >= state.retry_after_ms)
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
let deferred = due.len().saturating_sub(MAX_RETRY_CONNECTIONS_PER_TICK);
|
||||
if deferred > 0 {
|
||||
debug!(
|
||||
due = due.len(),
|
||||
processing = MAX_RETRY_CONNECTIONS_PER_TICK,
|
||||
deferred,
|
||||
"Retry processing budget exhausted; deferring remaining peers"
|
||||
);
|
||||
}
|
||||
|
||||
for node_addr in due {
|
||||
for node_addr in due.into_iter().take(MAX_RETRY_CONNECTIONS_PER_TICK) {
|
||||
// Peer may have connected inbound while we waited
|
||||
if self.peers.contains_key(&node_addr) {
|
||||
self.retry_pending.remove(&node_addr);
|
||||
|
||||
@@ -97,6 +97,58 @@ async fn test_nat_bootstrap_failure_falls_back_to_direct_udp_address() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_try_peer_addresses_races_all_concrete_udp_candidates() {
|
||||
let peer_identity = Identity::generate();
|
||||
let mut node = make_node();
|
||||
let (packet_tx, packet_rx) = packet_channel(64);
|
||||
node.packet_tx = Some(packet_tx.clone());
|
||||
node.packet_rx = Some(packet_rx);
|
||||
|
||||
let transport_id = TransportId::new(1);
|
||||
let mut udp = UdpTransport::new(
|
||||
transport_id,
|
||||
Some("main".to_string()),
|
||||
crate::config::UdpConfig {
|
||||
bind_addr: Some("127.0.0.1:0".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
packet_tx,
|
||||
);
|
||||
udp.start_async().await.unwrap();
|
||||
node.transports
|
||||
.insert(transport_id, TransportHandle::Udp(udp));
|
||||
|
||||
let peer_config = crate::config::PeerConfig {
|
||||
npub: peer_identity.npub(),
|
||||
alias: None,
|
||||
addresses: vec![
|
||||
crate::config::PeerAddress::with_priority("udp", "127.0.0.1:9", 1),
|
||||
crate::config::PeerAddress::with_priority("udp", "127.0.0.1:10", 2),
|
||||
],
|
||||
connect_policy: crate::config::ConnectPolicy::AutoConnect,
|
||||
auto_reconnect: true,
|
||||
via_nostr: false,
|
||||
};
|
||||
let peer_identity = PeerIdentity::from_npub(&peer_config.npub).unwrap();
|
||||
|
||||
node.try_peer_addresses(&peer_config, peer_identity, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut addrs = node
|
||||
.connections
|
||||
.values()
|
||||
.filter_map(|conn| conn.source_addr().and_then(|addr| addr.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
addrs.sort();
|
||||
assert_eq!(addrs, vec!["127.0.0.1:10", "127.0.0.1:9"]);
|
||||
|
||||
for transport in node.transports.values_mut() {
|
||||
transport.stop().await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_node_state_transitions() {
|
||||
let mut node = make_node();
|
||||
@@ -712,6 +764,48 @@ fn test_schedule_retry_increments() {
|
||||
assert_eq!(state.retry_after_ms, 11_000 + 20_000);
|
||||
}
|
||||
|
||||
/// Retry processing is paced so a large due set cannot start every
|
||||
/// handshake candidate in one maintenance tick.
|
||||
#[tokio::test]
|
||||
async fn test_process_pending_retries_is_budgeted_per_tick() {
|
||||
let mut node = make_node();
|
||||
let mut addrs = Vec::new();
|
||||
|
||||
for _ in 0..20 {
|
||||
let identity = Identity::generate();
|
||||
let npub = identity.npub();
|
||||
let peer_identity = PeerIdentity::from_npub(&npub).unwrap();
|
||||
let node_addr = *peer_identity.node_addr();
|
||||
node.retry_pending.insert(
|
||||
node_addr,
|
||||
crate::node::retry::RetryState {
|
||||
peer_config: crate::config::PeerConfig::new(npub, "udp", "10.0.0.2:2121"),
|
||||
retry_count: 0,
|
||||
retry_after_ms: 0,
|
||||
reconnect: true,
|
||||
expires_at_ms: None,
|
||||
},
|
||||
);
|
||||
addrs.push(node_addr);
|
||||
}
|
||||
|
||||
node.process_pending_retries(1).await;
|
||||
|
||||
let processed = addrs
|
||||
.iter()
|
||||
.filter(|addr| {
|
||||
node.retry_pending
|
||||
.get(addr)
|
||||
.is_some_and(|state| state.retry_count > 0)
|
||||
})
|
||||
.count();
|
||||
let deferred = addrs.len().saturating_sub(processed);
|
||||
|
||||
assert_eq!(processed, 16);
|
||||
assert_eq!(deferred, 4);
|
||||
assert_eq!(node.retry_pending.len(), 20);
|
||||
}
|
||||
|
||||
/// Test that auto-connect peers retry indefinitely (never exhaust).
|
||||
#[test]
|
||||
fn test_schedule_retry_auto_connect_never_exhausts() {
|
||||
@@ -864,6 +958,130 @@ async fn test_try_peer_addresses_skips_connecting_peer() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_peer_same_path_discovery_skips_fresh_peer() {
|
||||
let mut node = make_node();
|
||||
let peer_full = Identity::generate();
|
||||
let peer_identity = PeerIdentity::from_pubkey_full(peer_full.pubkey_full());
|
||||
let peer_node_addr = *peer_identity.node_addr();
|
||||
let transport_id = TransportId::new(1);
|
||||
let current_addr = TransportAddr::from_string("127.0.0.1:9");
|
||||
let mut active_peer = ActivePeer::new(peer_identity, LinkId::new(7), Node::now_ms());
|
||||
active_peer.set_current_addr(transport_id, current_addr.clone());
|
||||
node.peers.insert(peer_node_addr, active_peer);
|
||||
let candidate = crate::config::PeerAddress::new("udp", "127.0.0.1:9");
|
||||
|
||||
assert!(node.active_peer_candidate_is_fresh_enough_to_skip(
|
||||
&peer_node_addr,
|
||||
std::slice::from_ref(&candidate),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_peer_same_path_discovery_refreshes_stale_peer() {
|
||||
let mut node = make_node();
|
||||
let peer_full = Identity::generate();
|
||||
let peer_identity = PeerIdentity::from_pubkey_full(peer_full.pubkey_full());
|
||||
let peer_node_addr = *peer_identity.node_addr();
|
||||
let transport_id = TransportId::new(1);
|
||||
let current_addr = TransportAddr::from_string("127.0.0.1:9");
|
||||
let stale_at = Node::now_ms().saturating_sub(
|
||||
node.config
|
||||
.node
|
||||
.heartbeat_interval_secs
|
||||
.saturating_add(1)
|
||||
.saturating_mul(1000),
|
||||
);
|
||||
let mut active_peer = ActivePeer::new(peer_identity, LinkId::new(7), stale_at);
|
||||
active_peer.set_current_addr(transport_id, current_addr.clone());
|
||||
node.peers.insert(peer_node_addr, active_peer);
|
||||
let candidate = crate::config::PeerAddress::new("udp", "127.0.0.1:9");
|
||||
|
||||
assert!(!node.active_peer_candidate_is_fresh_enough_to_skip(
|
||||
&peer_node_addr,
|
||||
std::slice::from_ref(&candidate),
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_peers_races_new_alternative_without_dropping_active_peer() {
|
||||
let mut node = make_node();
|
||||
let (packet_tx, packet_rx) = packet_channel(64);
|
||||
node.packet_tx = Some(packet_tx.clone());
|
||||
node.packet_rx = Some(packet_rx);
|
||||
|
||||
let transport_id = TransportId::new(1);
|
||||
let mut udp = UdpTransport::new(
|
||||
transport_id,
|
||||
Some("main".to_string()),
|
||||
crate::config::UdpConfig {
|
||||
bind_addr: Some("127.0.0.1:0".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
packet_tx,
|
||||
);
|
||||
udp.start_async().await.unwrap();
|
||||
node.transports
|
||||
.insert(transport_id, TransportHandle::Udp(udp));
|
||||
|
||||
let peer_full = Identity::generate();
|
||||
let peer_identity = PeerIdentity::from_pubkey_full(peer_full.pubkey_full());
|
||||
let peer_node_addr = *peer_identity.node_addr();
|
||||
let current_addr = TransportAddr::from_string("127.0.0.1:9");
|
||||
let new_addr = TransportAddr::from_string("127.0.0.1:10");
|
||||
let old_link_id = LinkId::new(7);
|
||||
let mut active_peer = ActivePeer::new(peer_identity, old_link_id, Node::now_ms());
|
||||
active_peer.set_current_addr(transport_id, current_addr.clone());
|
||||
node.peers.insert(peer_node_addr, active_peer);
|
||||
node.links.insert(
|
||||
old_link_id,
|
||||
Link::connectionless(
|
||||
old_link_id,
|
||||
transport_id,
|
||||
current_addr.clone(),
|
||||
LinkDirection::Outbound,
|
||||
Duration::from_millis(100),
|
||||
),
|
||||
);
|
||||
|
||||
let old_peer = crate::config::PeerConfig {
|
||||
npub: peer_full.npub(),
|
||||
alias: None,
|
||||
addresses: vec![crate::config::PeerAddress::new("udp", "127.0.0.1:9")],
|
||||
connect_policy: crate::config::ConnectPolicy::AutoConnect,
|
||||
auto_reconnect: true,
|
||||
via_nostr: false,
|
||||
};
|
||||
let new_peer = crate::config::PeerConfig {
|
||||
addresses: vec![
|
||||
crate::config::PeerAddress::new("udp", "127.0.0.1:9"),
|
||||
crate::config::PeerAddress::new("udp", "127.0.0.1:10"),
|
||||
],
|
||||
..old_peer.clone()
|
||||
};
|
||||
node.config.peers = vec![old_peer];
|
||||
|
||||
let outcome = node.update_peers(vec![new_peer]).await.unwrap();
|
||||
|
||||
assert_eq!(outcome.updated, 1);
|
||||
assert_eq!(node.peer_count(), 1, "existing link must stay live");
|
||||
assert_eq!(node.connection_count(), 1);
|
||||
assert_eq!(
|
||||
node.connections
|
||||
.values()
|
||||
.next()
|
||||
.and_then(|conn| conn.source_addr()),
|
||||
Some(&new_addr)
|
||||
);
|
||||
let active = node.get_peer(&peer_node_addr).unwrap();
|
||||
assert_eq!(active.link_id(), old_link_id);
|
||||
assert_eq!(active.current_addr(), Some(¤t_addr));
|
||||
|
||||
for transport in node.transports.values_mut() {
|
||||
transport.stop().await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_nostr_traversal_failure_skips_connected_peer() {
|
||||
let mut node = make_node();
|
||||
|
||||
Reference in New Issue
Block a user