Add FMP node profile negotiation with mixed-profile integration tests

NodeProfile enum (Full/NonRouting/Leaf) with FMP feature bitfield:
bits 0-2 profile, bits 3-6 MMP wants/provides, bit 7 bloom filter
size negotiable. Bloom size TLV always sent with min=max=1KB.

Config mapping: leaf_only -> Leaf, disable_routing -> NonRouting.
Profile and agreed bloom size stored on PeerConnection and
ActivePeer. Handshake msg2/msg3 carry FMP negotiation payload
with profile validation and bloom size agreement.

MMP report sending gated by profile wants/provides. Parent
selection and routing constraints skip non-full peers. One-way
bloom filters for non-routing peers (F inserts N as dependent).
Leaf mode: single-peer enforcement, suppress tree announces and
discovery forwarding.

Mixed-profile integration test: A(Full) + B(Full) + C(NonRouting)
+ D(Leaf) with 9 connectivity assertions.
This commit is contained in:
Johnathan Corgan
2026-04-11 08:16:01 +00:00
parent 8357200b0e
commit f4278f1dd7
20 changed files with 1323 additions and 299 deletions
+71
View File
@@ -571,3 +571,74 @@ fn test_bloom_state_mark_changed_peers_excludes_source() {
assert!(!state.needs_update(&peer1));
}
// === Non-routing dependent tests ===
#[test]
fn test_non_routing_peer_included_as_dependent() {
// When a non-routing peer connects, F adds it as a dependent.
// The outgoing filter should include the non-routing peer's identity.
let my_node = make_node_addr(0);
let mut state = BloomState::new(my_node);
let non_routing_peer = make_node_addr(5);
state.add_leaf_dependent(non_routing_peer);
let outgoing = state.compute_outgoing_filter(&make_node_addr(99), &HashMap::new());
assert!(outgoing.contains(&my_node));
assert!(outgoing.contains(&non_routing_peer));
}
#[test]
fn test_non_routing_dependent_removed_on_disconnect() {
let my_node = make_node_addr(0);
let mut state = BloomState::new(my_node);
let non_routing_peer = make_node_addr(5);
state.add_leaf_dependent(non_routing_peer);
assert!(state.leaf_dependents().contains(&non_routing_peer));
state.remove_leaf_dependent(&non_routing_peer);
assert!(!state.leaf_dependents().contains(&non_routing_peer));
// Filter no longer contains the peer
let outgoing = state.compute_outgoing_filter(&make_node_addr(99), &HashMap::new());
assert!(!outgoing.contains(&non_routing_peer));
}
#[test]
fn test_non_routing_filter_not_merged_into_outgoing() {
// Even if a non-routing peer somehow has an inbound filter,
// it should not be included in peer_filters passed to
// compute_outgoing_filter (enforced at the Node level).
// Here we verify that excluding a peer's filter from the map
// means their entries don't appear in the outgoing filter.
let my_node = make_node_addr(0);
let state = BloomState::new(my_node);
let full_peer = make_node_addr(10);
let non_routing_peer = make_node_addr(20);
let mut full_filter = BloomFilter::new();
full_filter.insert(&make_node_addr(100));
let mut nr_filter = BloomFilter::new();
nr_filter.insert(&make_node_addr(200));
// Only include the full peer's filter (simulating the Node-level exclusion)
let mut peer_filters = HashMap::new();
peer_filters.insert(full_peer, full_filter);
// nr_filter deliberately NOT included
let outgoing = state.compute_outgoing_filter(&make_node_addr(99), &peer_filters);
assert!(outgoing.contains(&my_node));
assert!(outgoing.contains(&make_node_addr(100))); // from full peer
assert!(!outgoing.contains(&make_node_addr(200))); // non-routing excluded
// But if non-routing peer is a dependent, its identity IS in the filter
let mut state2 = BloomState::new(my_node);
state2.add_leaf_dependent(non_routing_peer);
let outgoing2 = state2.compute_outgoing_filter(&make_node_addr(99), &peer_filters);
assert!(outgoing2.contains(&non_routing_peer)); // identity present
assert!(!outgoing2.contains(&make_node_addr(200))); // but not their filter entries
}
+15
View File
@@ -474,6 +474,21 @@ impl Config {
self.node.leaf_only
}
/// Derive the node profile from config.
///
/// leaf_only → Leaf (implies non-routing),
/// disable_routing → NonRouting,
/// otherwise → Full.
pub fn node_profile(&self) -> crate::protocol::NodeProfile {
if self.node.leaf_only {
crate::protocol::NodeProfile::Leaf
} else if self.node.disable_routing {
crate::protocol::NodeProfile::NonRouting
} else {
crate::protocol::NodeProfile::Full
}
}
/// Get the configured peers.
pub fn peers(&self) -> &[PeerConfig] {
&self.peers
+12
View File
@@ -638,7 +638,18 @@ pub struct NodeConfig {
#[serde(default)]
pub identity: IdentityConfig,
/// Non-routing mode (`node.disable_routing`).
///
/// Tree participation and one-way bloom receipt, but no transit
/// forwarding or bloom combination/propagation. Overridden by
/// `leaf_only` (leaf implies non-routing).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub disable_routing: bool,
/// Leaf-only mode (`node.leaf_only`).
///
/// Single upstream peer, no tree/bloom/transit. Implies
/// `disable_routing`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub leaf_only: bool,
@@ -725,6 +736,7 @@ impl Default for NodeConfig {
fn default() -> Self {
Self {
identity: IdentityConfig::default(),
disable_routing: false,
leaf_only: false,
tick_interval_secs: 1,
base_rtt_ms: 100,
+12 -2
View File
@@ -12,14 +12,17 @@ use std::collections::HashMap;
use tracing::debug;
impl Node {
/// Collect inbound filters from all peers for outgoing filter computation.
/// Collect inbound filters from full tree peers for outgoing filter computation.
///
/// Returns a map of (peer_node_addr -> filter) for peers that
/// have sent us a FilterAnnounce.
/// have sent us a FilterAnnounce. Non-routing and leaf peers are
/// excluded (they don't send filters; their identity is covered
/// via leaf_dependents).
fn peer_inbound_filters(&self) -> HashMap<NodeAddr, BloomFilter> {
let mut filters = HashMap::new();
for (addr, peer) in &self.peers {
if self.is_tree_peer(addr)
&& peer.peer_profile() == crate::protocol::NodeProfile::Full
&& let Some(filter) = peer.inbound_filter()
{
filters.insert(*addr, filter.clone());
@@ -98,7 +101,14 @@ impl Node {
}
/// Send pending rate-limited filter announces whose debounce has expired.
///
/// Non-routing nodes do not send filters (they receive only).
pub(super) async fn send_pending_filter_announces(&mut self) {
// Non-routing and leaf nodes don't send bloom filters
if self.node_profile != crate::protocol::NodeProfile::Full {
return;
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
+53 -26
View File
@@ -20,7 +20,11 @@ impl Node {
/// 4. Lazy purge expired entries
/// 5. If we're the target, generate and send response
/// 6. If TTL > 0, forward to tree peers whose bloom filter matches
pub(in crate::node) async fn handle_lookup_request(&mut self, from: &NodeAddr, payload: &[u8]) {
pub(in crate::node) async fn handle_lookup_request(
&mut self,
from: &NodeAddr,
payload: &[u8],
) {
self.stats_mut().discovery.req_received += 1;
let request = match LookupRequest::decode(payload) {
@@ -48,8 +52,10 @@ impl Node {
}
// Record for reverse-path forwarding and dedup
self.recent_requests
.insert(request.request_id, RecentRequest::new(*from, now_ms));
self.recent_requests.insert(
request.request_id,
RecentRequest::new(*from, now_ms),
);
// Lazy purge expired entries
self.purge_expired_requests(now_ms);
@@ -70,10 +76,7 @@ impl Node {
if request.can_forward() {
// Transit-side rate limit: collapse rapid-fire lookups for the
// same target from misbehaving nodes generating fresh request_ids.
if !self
.discovery_forward_limiter
.should_forward(&request.target)
{
if !self.discovery_forward_limiter.should_forward(&request.target) {
self.stats_mut().discovery.req_forward_rate_limited += 1;
debug!(
request_id = request.request_id,
@@ -189,8 +192,11 @@ impl Node {
// Verify the proof signature
let (xonly, _parity) = target_pubkey.x_only_public_key();
let peer_id = PeerIdentity::from_pubkey(xonly);
let proof_data =
LookupResponse::proof_bytes(response.request_id, &target, &response.target_coords);
let proof_data = LookupResponse::proof_bytes(
response.request_id,
&target,
&response.target_coords,
);
if !peer_id.verify(&proof_data, &response.proof) {
self.stats_mut().discovery.resp_proof_failed += 1;
warn!(
@@ -214,8 +220,12 @@ impl Node {
"Discovery succeeded, proof verified, route cached"
);
self.coord_cache
.insert_with_path_mtu(target, response.target_coords, now_ms, path_mtu);
self.coord_cache.insert_with_path_mtu(
target,
response.target_coords,
now_ms,
path_mtu,
);
// Clean up pending lookup tracking
self.pending_lookups.remove(&target);
@@ -252,11 +262,15 @@ impl Node {
let our_coords = self.tree_state().my_coords().clone();
// Sign proof: Identity::sign hashes with SHA-256 internally
let proof_data =
LookupResponse::proof_bytes(request.request_id, &request.target, &our_coords);
let proof_data = LookupResponse::proof_bytes(request.request_id, &request.target, &our_coords);
let proof = self.identity().sign(&proof_data);
let response = LookupResponse::new(request.request_id, request.target, our_coords, proof);
let response = LookupResponse::new(
request.request_id,
request.target,
our_coords,
proof,
);
// Route toward origin via reverse path.
let next_hop_addr = if let Some(recent) = self.recent_requests.get(&request.request_id) {
@@ -283,10 +297,7 @@ impl Node {
);
let encoded = response.encode();
if let Err(e) = self
.send_encrypted_link_message(&next_hop_addr, &encoded)
.await
{
if let Err(e) = self.send_encrypted_link_message(&next_hop_addr, &encoded).await {
debug!(
next_hop = %self.peer_display_name(&next_hop_addr),
error = %e,
@@ -309,20 +320,33 @@ impl Node {
return;
}
// Collect tree peers whose bloom filter contains the target
// Leaf nodes don't forward discovery requests
if self.node_profile == crate::protocol::NodeProfile::Leaf {
return;
}
// Collect full tree peers whose bloom filter contains the target
let forward_to: Vec<NodeAddr> = self
.peers
.iter()
.filter(|(addr, peer)| self.is_tree_peer(addr) && peer.may_reach(&request.target))
.filter(|(addr, peer)| {
peer.peer_profile() == crate::protocol::NodeProfile::Full
&& self.is_tree_peer(addr)
&& peer.may_reach(&request.target)
})
.map(|(addr, _)| *addr)
.collect();
// Fallback: if no tree peer matches, try non-tree bloom-matching peers
// Fallback: if no tree peer matches, try non-tree full bloom-matching peers
let (forward_to, used_fallback) = if forward_to.is_empty() {
let fallback: Vec<NodeAddr> = self
.peers
.iter()
.filter(|(addr, peer)| !self.is_tree_peer(addr) && peer.may_reach(&request.target))
.filter(|(addr, peer)| {
peer.peer_profile() == crate::protocol::NodeProfile::Full
&& !self.is_tree_peer(addr)
&& peer.may_reach(&request.target)
})
.map(|(addr, _)| *addr)
.collect();
if fallback.is_empty() {
@@ -383,11 +407,15 @@ impl Node {
let origin_coords = self.tree_state().my_coords().clone();
let request = LookupRequest::generate(*target, origin, origin_coords, ttl, 0);
// Send only to tree peers whose bloom filter contains the target
// Send only to full tree peers whose bloom filter contains the target
let peer_addrs: Vec<NodeAddr> = self
.peers
.iter()
.filter(|(addr, peer)| self.is_tree_peer(addr) && peer.may_reach(target))
.filter(|(addr, peer)| {
peer.peer_profile() == crate::protocol::NodeProfile::Full
&& self.is_tree_peer(addr)
&& peer.may_reach(target)
})
.map(|(addr, _)| *addr)
.collect();
@@ -471,8 +499,7 @@ impl Node {
return;
}
self.pending_lookups
.insert(*dest, PendingLookup::new(now_ms));
self.pending_lookups.insert(*dest, PendingLookup::new(now_ms));
let ttl = self.config.node.discovery.ttl;
let sent = self.initiate_lookup(dest, ttl).await;
+2 -1
View File
@@ -176,7 +176,8 @@ impl Node {
}
}
// Bloom filter cleanup: clear state for removed peer, mark all remaining peers
// Bloom filter cleanup: remove dependent (non-routing/leaf peers), clear state
self.bloom_state.remove_leaf_dependent(node_addr);
self.bloom_state.remove_peer_state(node_addr);
let remaining_peers: Vec<NodeAddr> = self.peers.keys().copied().collect();
self.bloom_state.mark_all_updates_needed(remaining_peers);
+108 -6
View File
@@ -124,8 +124,8 @@ impl Node {
packet.timestamp_ms,
);
// Create negotiation payload for msg2
let neg_payload = NegotiationPayload::new(1, 1, 0).encode();
// Create FMP negotiation payload for msg2 (includes profile, MMP bits, bloom TLV)
let neg_payload = NegotiationPayload::fmp(1, 1, self.node_profile).encode();
let our_keypair = self.identity.keypair();
let noise_msg1 = &packet.data[header.noise_msg1_offset..];
@@ -383,12 +383,12 @@ impl Node {
let conn = self.connections.get_mut(&link_id).unwrap();
// Create negotiation payload for msg3
let neg_payload = NegotiationPayload::new(1, 1, 0).encode();
// Create FMP negotiation payload for msg3 (includes profile, MMP bits, bloom TLV)
let neg_payload = NegotiationPayload::fmp(1, 1, self.node_profile).encode();
// Process Noise msg2 and generate msg3
let noise_msg2 = &packet.data[header.noise_msg2_offset..];
let (msg3_bytes, _received_negotiation) = match conn.complete_handshake(noise_msg2, Some(&neg_payload), packet.timestamp_ms) {
let (msg3_bytes, received_negotiation) = match conn.complete_handshake(noise_msg2, Some(&neg_payload), packet.timestamp_ms) {
Ok(result) => result,
Err(e) => {
warn!(
@@ -401,6 +401,18 @@ impl Node {
}
};
// Process peer's FMP negotiation payload from msg2
if let Some(neg_bytes) = &received_negotiation {
match process_fmp_negotiation(self.node_profile, conn, neg_bytes) {
Ok(()) => {}
Err(e) => {
warn!(link_id = %link_id, error = %e, "FMP negotiation failed");
conn.mark_failed();
return;
}
}
}
// Store their index
conn.set_their_index(header.sender_idx);
conn.set_source_addr(packet.remote_addr.clone());
@@ -701,7 +713,7 @@ impl Node {
// Process msg3 — learns initiator's identity and epoch
let noise_msg3 = &packet.data[header.noise_msg3_offset..];
let _received_negotiation = match conn.complete_handshake_msg3(noise_msg3, packet.timestamp_ms) {
let received_negotiation = match conn.complete_handshake_msg3(noise_msg3, packet.timestamp_ms) {
Ok(neg) => neg,
Err(e) => {
warn!(
@@ -719,6 +731,19 @@ impl Node {
}
};
// Process peer's FMP negotiation payload from msg3
if let Some(neg_bytes) = &received_negotiation {
match process_fmp_negotiation(self.node_profile, conn, neg_bytes) {
Ok(()) => {}
Err(e) => {
warn!(link_id = %link_id, error = %e, "FMP negotiation failed");
self.connections.remove(&link_id);
self.remove_link(&link_id);
return;
}
}
}
// Learn peer identity from msg3
let peer_identity = match conn.expected_identity() {
Some(id) => *id,
@@ -1042,12 +1067,34 @@ impl Node {
/// Promote a connection to active peer after successful authentication.
///
/// Handles cross-connection detection and resolution using tie-breaker rules.
/// Leaf nodes enforce single-peer constraint.
pub(in crate::node) fn promote_connection(
&mut self,
link_id: LinkId,
verified_identity: PeerIdentity,
current_time_ms: u64,
) -> Result<PromotionResult, NodeError> {
// Leaf nodes: reject if we already have a peer (single-peer enforcement)
let peer_node_addr_check = *verified_identity.node_addr();
if self.node_profile == crate::protocol::NodeProfile::Leaf
&& !self.peers.is_empty()
&& !self.peers.contains_key(&peer_node_addr_check)
{
info!(
peer = %self.peer_display_name(&peer_node_addr_check),
link_id = %link_id,
"Leaf node rejecting additional peer (single-peer enforcement)"
);
// Clean up the connection
if let Some(conn) = self.connections.remove(&link_id)
&& let Some(idx) = conn.our_index()
{
let _ = self.index_allocator.free(idx);
}
self.remove_link(&link_id);
return Err(NodeError::MaxPeersExceeded { max: 1 });
}
// Remove the connection from pending
let mut connection = self
.connections
@@ -1089,6 +1136,10 @@ impl Node {
})?.clone();
let link_stats = connection.link_stats().clone();
let remote_epoch = connection.remote_epoch();
let peer_profile = connection.peer_profile()
.unwrap_or(crate::protocol::NodeProfile::Full);
let agreed_bloom_size_class = connection.agreed_bloom_size_class()
.unwrap_or(crate::bloom::V1_SIZE_CLASS);
let peer_node_addr = *verified_identity.node_addr();
let is_outbound = connection.is_outbound();
@@ -1131,6 +1182,9 @@ impl Node {
is_outbound,
&self.config.node.mmp,
remote_epoch,
self.node_profile,
peer_profile,
agreed_bloom_size_class,
);
new_peer.set_tree_announce_min_interval_ms(self.config.node.tree.announce_min_interval_ms);
@@ -1140,6 +1194,12 @@ impl Node {
self.retry_pending.remove(&peer_node_addr);
self.register_identity(peer_node_addr, verified_identity.pubkey_full());
// Non-routing peers don't send filters; include them as
// dependents so our bloom filter advertises their identity.
if peer_profile != crate::protocol::NodeProfile::Full {
self.bloom_state.add_leaf_dependent(peer_node_addr);
}
debug!(
peer = %self.peer_display_name(&peer_node_addr),
winner_link = %link_id,
@@ -1220,6 +1280,9 @@ impl Node {
is_outbound,
&self.config.node.mmp,
remote_epoch,
self.node_profile,
peer_profile,
agreed_bloom_size_class,
);
new_peer.set_tree_announce_min_interval_ms(self.config.node.tree.announce_min_interval_ms);
if let Some(ts) = old_announce_ts {
@@ -1232,6 +1295,12 @@ impl Node {
self.retry_pending.remove(&peer_node_addr);
self.register_identity(peer_node_addr, verified_identity.pubkey_full());
// Non-routing peers don't send filters; include them as
// dependents so our bloom filter advertises their identity.
if peer_profile != crate::protocol::NodeProfile::Full {
self.bloom_state.add_leaf_dependent(peer_node_addr);
}
info!(
peer = %self.peer_display_name(&peer_node_addr),
link_id = %link_id,
@@ -1243,4 +1312,37 @@ impl Node {
Ok(PromotionResult::Promoted(peer_node_addr))
}
}
}
/// Process an FMP negotiation payload received from a peer.
///
/// Decodes the payload, validates profile pairing, agrees on bloom
/// filter size, and stores the results on the PeerConnection.
fn process_fmp_negotiation(
our_profile: crate::protocol::NodeProfile,
conn: &mut PeerConnection,
neg_bytes: &[u8],
) -> Result<(), crate::protocol::ProtocolError> {
let our_payload = NegotiationPayload::fmp(1, 1, our_profile);
let their_payload = NegotiationPayload::decode(neg_bytes)?;
// Validate profile pairing (at least one Full)
let their_profile = their_payload.node_profile()?;
NegotiationPayload::validate_profiles(our_profile, their_profile)?;
// Agree on bloom filter size
let agreed_bloom = our_payload.agree_bloom_size(&their_payload)?;
conn.set_negotiation_results(their_profile, agreed_bloom);
debug!(
link_id = %conn.link_id(),
our_profile = ?our_profile,
peer_profile = ?their_profile,
agreed_bloom_size_class = agreed_bloom,
"FMP negotiation complete"
);
Ok(())
}
+9 -3
View File
@@ -141,7 +141,8 @@ impl Node {
.filter(|(_, p)| p.has_srtt())
.map(|(a, p)| (*a, p.link_cost()))
.collect();
if let Some(new_parent) = self.tree_state.evaluate_parent(&peer_costs) {
let skip = self.non_full_peers();
if let Some(new_parent) = self.tree_state.evaluate_parent(&peer_costs, &skip) {
let new_seq = self.tree_state.my_declaration().sequence() + 1;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -194,22 +195,27 @@ impl Node {
.cloned()
.unwrap_or_else(|| peer.identity().short_npub());
let send_sr = peer.send_sr();
let send_rr = peer.send_rr();
let Some(mmp) = peer.mmp_mut() else {
continue;
};
let mode = mmp.mode();
// Sender reports: Full mode only
// Sender reports: gated by mode, profile wants/provides, and timing
if mode == MmpMode::Full
&& send_sr
&& mmp.sender.should_send_report(now)
&& let Some(sr) = mmp.sender.build_report(now)
{
sender_reports.push((*node_addr, sr.encode()));
}
// Receiver reports: Full and Lightweight modes
// Receiver reports: gated by mode, profile wants/provides, and timing
if mode != MmpMode::Minimal
&& send_rr
&& mmp.receiver.should_send_report(now)
&& let Some(rr) = mmp.receiver.build_report(now)
{
+128 -122
View File
@@ -5,43 +5,42 @@
//! Bloom filters, coordinate caches, transports, links, and peers.
mod bloom;
mod discovery_rate_limit;
mod handlers;
mod lifecycle;
mod rate_limit;
mod retry;
mod discovery_rate_limit;
mod rate_limit;
mod routing_error_rate_limit;
pub(crate) mod session;
pub(crate) mod session_wire;
pub(crate) mod wire;
pub(crate) mod stats;
mod tree;
#[cfg(test)]
mod tests;
mod tree;
pub(crate) mod wire;
use crate::bloom::BloomState;
use crate::protocol::NodeProfile;
use crate::cache::CoordCache;
use crate::utils::index::IndexAllocator;
use crate::node::session::SessionEntry;
use crate::peer::{ActivePeer, PeerConnection};
use self::discovery_rate_limit::{DiscoveryBackoff, DiscoveryForwardRateLimiter};
use self::rate_limit::HandshakeRateLimiter;
use self::routing_error_rate_limit::RoutingErrorRateLimiter;
use self::wire::{
FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted, build_established_header,
prepend_inner_header,
};
use crate::bloom::BloomState;
use crate::cache::CoordCache;
use crate::node::session::SessionEntry;
use crate::peer::{ActivePeer, PeerConnection};
use crate::transport::ethernet::EthernetTransport;
use crate::transport::tcp::TcpTransport;
use crate::transport::tor::TorTransport;
use crate::transport::udp::UdpTransport;
use crate::transport::{
Link, LinkId, PacketRx, PacketTx, TransportAddr, TransportError, TransportHandle, TransportId,
};
use crate::transport::udp::UdpTransport;
use crate::transport::tcp::TcpTransport;
use crate::transport::tor::TorTransport;
#[cfg(target_os = "linux")]
use crate::transport::ethernet::EthernetTransport;
use crate::tree::TreeState;
use crate::upper::hosts::HostMap;
use crate::upper::icmp_rate_limit::IcmpRateLimiter;
use crate::upper::tun::{TunError, TunOutboundRx, TunState, TunTx};
use crate::utils::index::IndexAllocator;
use self::wire::{build_encrypted, build_established_header, prepend_inner_header, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP};
use crate::{Config, ConfigError, Identity, IdentityError, NodeAddr, PeerIdentity};
use rand::Rng;
use std::collections::{HashMap, VecDeque};
@@ -108,11 +107,7 @@ pub enum NodeError {
SendFailed { node_addr: NodeAddr, reason: String },
#[error("mtu exceeded forwarding to {node_addr}: packet {packet_size} > mtu {mtu}")]
MtuExceeded {
node_addr: NodeAddr,
packet_size: usize,
mtu: u16,
},
MtuExceeded { node_addr: NodeAddr, packet_size: usize, mtu: u16 },
#[error("config error: {0}")]
Config(#[from] ConfigError),
@@ -278,6 +273,9 @@ pub struct Node {
/// Whether this is a leaf-only node.
is_leaf_only: bool,
/// Node profile derived from config (Full, NonRouting, Leaf).
node_profile: NodeProfile,
// === Spanning Tree ===
/// Local spanning tree state.
tree_state: TreeState,
@@ -370,10 +368,6 @@ pub struct Node {
tun_reader_handle: Option<JoinHandle<()>>,
/// TUN writer thread handle.
tun_writer_handle: Option<JoinHandle<()>>,
/// Shutdown pipe: writing to this fd unblocks the TUN reader thread on macOS.
/// On Linux, deleting the interface via netlink serves the same purpose.
#[cfg(target_os = "macos")]
tun_shutdown_fd: Option<std::os::unix::io::RawFd>,
// === DNS Responder ===
/// Receiver for resolved identities from the DNS responder.
@@ -454,6 +448,7 @@ impl Node {
let identity = config.create_identity()?;
let node_addr = *identity.node_addr();
let is_leaf_only = config.is_leaf_only();
let node_profile = config.node_profile();
let mut startup_epoch = [0u8; 8];
rand::rng().fill_bytes(&mut startup_epoch);
@@ -516,6 +511,7 @@ impl Node {
config,
state: NodeState::Created,
is_leaf_only,
node_profile,
tree_state,
bloom_state,
coord_cache,
@@ -544,8 +540,6 @@ impl Node {
tun_outbound_rx: None,
tun_reader_handle: None,
tun_writer_handle: None,
#[cfg(target_os = "macos")]
tun_shutdown_fd: None,
dns_identity_rx: None,
dns_task: None,
index_allocator: IndexAllocator::new(),
@@ -558,7 +552,10 @@ impl Node {
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval(
std::time::Duration::from_millis(coords_response_interval_ms),
),
discovery_backoff: DiscoveryBackoff::with_params(backoff_base_secs, backoff_max_secs),
discovery_backoff: DiscoveryBackoff::with_params(
backoff_base_secs,
backoff_max_secs,
),
discovery_forward_limiter: DiscoveryForwardRateLimiter::with_interval(
std::time::Duration::from_secs(forward_min_interval_secs),
),
@@ -626,6 +623,7 @@ impl Node {
config,
state: NodeState::Created,
is_leaf_only: false,
node_profile: NodeProfile::Full,
tree_state,
bloom_state,
coord_cache,
@@ -654,8 +652,6 @@ impl Node {
tun_outbound_rx: None,
tun_reader_handle: None,
tun_writer_handle: None,
#[cfg(target_os = "macos")]
tun_shutdown_fd: None,
dns_identity_rx: None,
dns_task: None,
index_allocator: IndexAllocator::new(),
@@ -685,6 +681,7 @@ impl Node {
pub fn leaf_only(config: Config) -> Result<Self, NodeError> {
let mut node = Self::new(config)?;
node.is_leaf_only = true;
node.node_profile = NodeProfile::Leaf;
node.bloom_state = BloomState::leaf_only(*node.identity.node_addr());
Ok(node)
}
@@ -712,19 +709,23 @@ impl Node {
}
// Create Ethernet transport instances
let eth_instances: Vec<_> = self
.config
.transports
.ethernet
.iter()
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
.collect();
let xonly = self.identity.pubkey();
for (name, eth_config) in eth_instances {
let transport_id = self.allocate_transport_id();
let mut eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
eth.set_local_pubkey(xonly);
transports.push(TransportHandle::Ethernet(eth));
#[cfg(target_os = "linux")]
{
let eth_instances: Vec<_> = self
.config
.transports
.ethernet
.iter()
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
.collect();
let xonly = self.identity.pubkey();
for (name, eth_config) in eth_instances {
let transport_id = self.allocate_transport_id();
let mut eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
eth.set_local_pubkey(xonly);
transports.push(TransportHandle::Ethernet(eth));
}
}
// Create TCP transport instances
@@ -794,9 +795,7 @@ impl Node {
#[cfg(any(not(feature = "ble"), test))]
if !ble_instances.is_empty() {
#[cfg(not(test))]
tracing::warn!(
"BLE transport configured but 'ble' feature not enabled at compile time"
);
tracing::warn!("BLE transport configured but 'ble' feature not enabled at compile time");
}
}
@@ -846,9 +845,18 @@ impl Node {
))
})?;
// Parse the MAC address
#[cfg(target_os = "linux")]
let mac = crate::transport::ethernet::parse_mac_string(mac_str).map_err(|e| {
NodeError::NoTransportForType(format!("invalid MAC in '{}': {}", addr_str, e))
})?;
#[cfg(not(target_os = "linux"))]
let mac: [u8; 6] = {
let _ = mac_str;
return Err(NodeError::NoTransportForType(
"Ethernet transport not available on this platform".into(),
));
};
Ok((transport_id, TransportAddr::from_bytes(&mac)))
}
@@ -857,9 +865,13 @@ impl Node {
/// (TransportId, TransportAddr) pair by finding the BLE transport
/// instance matching the adapter name.
#[cfg(target_os = "linux")]
fn resolve_ble_addr(&self, addr_str: &str) -> Result<(TransportId, TransportAddr), NodeError> {
fn resolve_ble_addr(
&self,
addr_str: &str,
) -> Result<(TransportId, TransportAddr), NodeError> {
let ta = TransportAddr::from_string(addr_str);
let adapter = crate::transport::ble::addr::adapter_from_addr(&ta).ok_or_else(|| {
let adapter = crate::transport::ble::addr::adapter_from_addr(&ta)
.ok_or_else(|| {
NodeError::NoTransportForType(format!(
"invalid BLE address format '{}': expected 'adapter/mac'",
addr_str
@@ -870,7 +882,9 @@ impl Node {
let transport_id = self
.transports
.iter()
.find(|(_, handle)| handle.transport_type().name == "ble" && handle.is_operational())
.find(|(_, handle)| {
handle.transport_type().name == "ble" && handle.is_operational()
})
.map(|(id, _)| *id)
.ok_or_else(|| {
NodeError::NoTransportForType(format!(
@@ -987,6 +1001,22 @@ impl Node {
self.is_leaf_only
}
/// Get the node's profile (Full, NonRouting, Leaf).
pub fn node_profile(&self) -> NodeProfile {
self.node_profile
}
/// Collect the set of peers that are not full nodes (non-routing/leaf).
///
/// Used by tree and routing functions to skip non-transit peers.
fn non_full_peers(&self) -> std::collections::HashSet<NodeAddr> {
self.peers
.iter()
.filter(|(_, p)| p.peer_profile() != NodeProfile::Full)
.map(|(addr, _)| *addr)
.collect()
}
// === Tree State ===
/// Get the tree state.
@@ -1066,10 +1096,9 @@ impl Node {
let now = std::time::Instant::now();
let should_log = match self.last_mesh_size_log {
None => true,
Some(last) => {
now.duration_since(last)
>= std::time::Duration::from_secs(self.config.node.mmp.log_interval_secs)
}
Some(last) => now.duration_since(last) >= std::time::Duration::from_secs(
self.config.node.mmp.log_interval_secs,
),
};
if should_log {
tracing::debug!(
@@ -1118,6 +1147,7 @@ impl Node {
self.tun_name.as_deref()
}
// === Resource Limits ===
/// Set the maximum number of connections (handshake phase).
@@ -1198,17 +1228,14 @@ impl Node {
/// Add a link.
pub fn add_link(&mut self, link: Link) -> Result<(), NodeError> {
if self.max_links > 0 && self.links.len() >= self.max_links {
return Err(NodeError::MaxLinksExceeded {
max: self.max_links,
});
return Err(NodeError::MaxLinksExceeded { max: self.max_links });
}
let link_id = link.link_id();
let transport_id = link.transport_id();
let remote_addr = link.remote_addr().clone();
self.links.insert(link_id, link);
self.addr_to_link
.insert((transport_id, remote_addr), link_id);
self.addr_to_link.insert((transport_id, remote_addr), link_id);
Ok(())
}
@@ -1223,14 +1250,8 @@ impl Node {
}
/// Find link ID by transport address.
pub fn find_link_by_addr(
&self,
transport_id: TransportId,
addr: &TransportAddr,
) -> Option<LinkId> {
self.addr_to_link
.get(&(transport_id, addr.clone()))
.copied()
pub fn find_link_by_addr(&self, transport_id: TransportId, addr: &TransportAddr) -> Option<LinkId> {
self.addr_to_link.get(&(transport_id, addr.clone())).copied()
}
/// Remove a link.
@@ -1376,14 +1397,11 @@ impl Node {
pub(crate) fn register_identity(&mut self, node_addr: NodeAddr, pubkey: secp256k1::PublicKey) {
let mut prefix = [0u8; 15];
prefix.copy_from_slice(&node_addr.as_bytes()[0..15]);
self.identity_cache
.insert(prefix, (node_addr, pubkey, Self::now_ms()));
self.identity_cache.insert(prefix, (node_addr, pubkey, Self::now_ms()));
// LRU eviction
let max = self.config.node.cache.identity_size;
if self.identity_cache.len() > max
&& let Some(oldest_key) = self
.identity_cache
.iter()
&& let Some(oldest_key) = self.identity_cache.iter()
.min_by_key(|(_, (_, _, ts))| *ts)
.map(|(k, _)| *k)
{
@@ -1392,10 +1410,7 @@ impl Node {
}
/// Look up a destination by FipsAddress prefix (bytes 1-15 of the IPv6 address).
pub(crate) fn lookup_by_fips_prefix(
&mut self,
prefix: &[u8; 15],
) -> Option<(NodeAddr, secp256k1::PublicKey)> {
pub(crate) fn lookup_by_fips_prefix(&mut self, prefix: &[u8; 15]) -> Option<(NodeAddr, secp256k1::PublicKey)> {
if let Some(entry) = self.identity_cache.get_mut(prefix) {
entry.2 = Self::now_ms(); // LRU touch
Some((entry.0, entry.1))
@@ -1434,7 +1449,9 @@ impl Node {
/// has declared us as their parent (making them our child).
pub(crate) fn is_tree_peer(&self, peer_addr: &NodeAddr) -> bool {
// Peer is our parent
if !self.tree_state.is_root() && self.tree_state.my_declaration().parent_id() == peer_addr {
if !self.tree_state.is_root()
&& self.tree_state.my_declaration().parent_id() == peer_addr
{
return true;
}
// Peer is our child (their declaration names us as parent)
@@ -1483,22 +1500,17 @@ impl Node {
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let dest_coords = self
.coord_cache
.get_and_touch(dest_node_addr, now_ms)?
.clone();
let dest_coords = self.coord_cache.get_and_touch(dest_node_addr, now_ms)?.clone();
// 3. Bloom filter candidates — requires dest_coords for loop-free selection.
// If no candidate is strictly closer, fall through to tree routing.
// 3. Bloom filter candidates — requires dest_coords for loop-free selection
let candidates: Vec<&ActivePeer> = self.destination_in_filters(dest_node_addr);
if !candidates.is_empty()
&& let Some(peer) = self.select_best_candidate(&candidates, &dest_coords)
{
return Some(peer);
if !candidates.is_empty() {
return self.select_best_candidate(&candidates, &dest_coords);
}
// 4. Greedy tree routing fallback
let next_hop_id = self.tree_state.find_next_hop(&dest_coords)?;
// 4. Greedy tree routing fallback (skip non-routing/leaf peers)
let skip = self.non_full_peers();
let next_hop_id = self.tree_state.find_next_hop(&dest_coords, &skip)?;
self.peers.get(&next_hop_id).filter(|p| p.can_send())
}
@@ -1558,9 +1570,15 @@ impl Node {
best.map(|(peer, _, _)| peer)
}
/// Check if a destination is in any peer's bloom filter.
/// Check if a destination is in any full peer's bloom filter.
///
/// Skips non-routing and leaf peers (defensive — they shouldn't have
/// filters claiming transit reachability, but guard against propagation bugs).
pub fn destination_in_filters(&self, dest: &NodeAddr) -> Vec<&ActivePeer> {
self.peers.values().filter(|p| p.may_reach(dest)).collect()
self.peers
.values()
.filter(|p| p.peer_profile() == NodeProfile::Full && p.may_reach(dest))
.collect()
}
/// Get the TUN packet sender channel.
@@ -1588,8 +1606,7 @@ impl Node {
node_addr: &NodeAddr,
plaintext: &[u8],
) -> Result<(), NodeError> {
self.send_encrypted_link_message_with_ce(node_addr, plaintext, false)
.await
self.send_encrypted_link_message_with_ce(node_addr, plaintext, false).await
}
/// Like `send_encrypted_link_message` but allows setting the FMP CE flag.
@@ -1601,9 +1618,7 @@ impl Node {
plaintext: &[u8],
ce_flag: bool,
) -> Result<(), NodeError> {
let peer = self
.peers
.get_mut(node_addr)
let peer = self.peers.get_mut(node_addr)
.ok_or(NodeError::PeerNotFound(*node_addr))?;
let their_index = peer.their_index().ok_or_else(|| NodeError::SendFailed {
@@ -1614,19 +1629,18 @@ impl Node {
node_addr: *node_addr,
reason: "no transport_id".into(),
})?;
let remote_addr = peer
.current_addr()
.cloned()
.ok_or_else(|| NodeError::SendFailed {
node_addr: *node_addr,
reason: "no current_addr".into(),
})?;
let remote_addr = peer.current_addr().cloned().ok_or_else(|| NodeError::SendFailed {
node_addr: *node_addr,
reason: "no current_addr".into(),
})?;
// Prepend 4-byte session-relative timestamp (inner header)
let timestamp_ms = peer.session_elapsed_ms();
// MMP: read spin bit value before entering session borrow
let sp_flag = peer.mmp().map(|mmp| mmp.spin_bit.tx_bit()).unwrap_or(false);
let sp_flag = peer.mmp()
.map(|mmp| mmp.spin_bit.tx_bit())
.unwrap_or(false);
let mut flags = if sp_flag { FLAG_SP } else { 0 };
if ce_flag {
flags |= FLAG_CE;
@@ -1635,12 +1649,10 @@ impl Node {
flags |= FLAG_KEY_EPOCH;
}
let session = peer
.noise_session_mut()
.ok_or_else(|| NodeError::SendFailed {
node_addr: *node_addr,
reason: "no noise session".into(),
})?;
let session = peer.noise_session_mut().ok_or_else(|| NodeError::SendFailed {
node_addr: *node_addr,
reason: "no noise session".into(),
})?;
// Inner plaintext: [timestamp:4 LE][msg_type][payload...]
let inner_plaintext = prepend_inner_header(timestamp_ms, plaintext);
@@ -1651,24 +1663,18 @@ impl Node {
let header = build_established_header(their_index, counter, flags, payload_len);
// Encrypt with AAD binding to the outer header
let ciphertext = session
.encrypt_with_aad(&inner_plaintext, &header)
.map_err(|e| NodeError::SendFailed {
node_addr: *node_addr,
reason: format!("encryption failed: {}", e),
})?;
let ciphertext = session.encrypt_with_aad(&inner_plaintext, &header).map_err(|e| NodeError::SendFailed {
node_addr: *node_addr,
reason: format!("encryption failed: {}", e),
})?;
let wire_packet = build_encrypted(&header, &ciphertext);
// Re-borrow peer for stats update after sending
let transport = self
.transports
.get(&transport_id)
let transport = self.transports.get(&transport_id)
.ok_or(NodeError::TransportNotFound(transport_id))?;
let bytes_sent = transport
.send(&remote_addr, &wire_packet)
.await
let bytes_sent = transport.send(&remote_addr, &wire_packet).await
.map_err(|e| match e {
TransportError::MtuExceeded { packet_size, mtu } => NodeError::MtuExceeded {
node_addr: *node_addr,
+9 -3
View File
@@ -30,11 +30,15 @@ impl Node {
/// Send a TreeAnnounce to a specific peer, respecting rate limits.
///
/// If the peer is rate-limited, the announce is marked pending for
/// delivery on the next tick cycle.
/// delivery on the next tick cycle. Leaf nodes do not send tree
/// announces (they don't participate in the spanning tree).
pub(super) async fn send_tree_announce_to_peer(
&mut self,
peer_addr: &NodeAddr,
) -> Result<(), NodeError> {
if self.node_profile == crate::protocol::NodeProfile::Leaf {
return Ok(());
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
@@ -221,7 +225,8 @@ impl Node {
.filter(|(_, peer)| peer.has_srtt())
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
if let Some(new_parent) = self.tree_state.evaluate_parent(&peer_costs) {
let skip = self.non_full_peers();
if let Some(new_parent) = self.tree_state.evaluate_parent(&peer_costs, &skip) {
let new_seq = self.tree_state.my_declaration().sequence() + 1;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -372,7 +377,8 @@ impl Node {
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
if let Some(new_parent) = self.tree_state.evaluate_parent(&peer_costs) {
let skip = self.non_full_peers();
if let Some(new_parent) = self.tree_state.evaluate_parent(&peer_costs, &skip) {
let new_seq = self.tree_state.my_declaration().sequence() + 1;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+50
View File
@@ -5,6 +5,7 @@
use crate::bloom::BloomFilter;
use crate::mmp::{MmpConfig, MmpPeerState};
use crate::protocol::{NegotiationPayload, NodeProfile};
use crate::utils::index::SessionIndex;
use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession};
use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId};
@@ -131,6 +132,16 @@ pub struct ActivePeer {
/// Remote peer's startup epoch (from handshake). Used to detect restarts.
remote_epoch: Option<[u8; 8]>,
// === Negotiated Profile ===
/// Peer's node profile (Full, NonRouting, Leaf).
peer_profile: NodeProfile,
/// Agreed bloom filter size class for this link.
agreed_bloom_size_class: u8,
/// Whether to send sender reports to this peer (our provides_sr AND peer wants_sr).
send_sr: bool,
/// Whether to send receiver reports to this peer (our provides_rr AND peer wants_rr).
send_rr: bool,
// === MMP ===
/// Per-peer MMP state (None for legacy peers without Noise sessions).
mmp: Option<MmpPeerState>,
@@ -216,6 +227,10 @@ impl ActivePeer {
authenticated_at,
last_seen: authenticated_at,
remote_epoch: None,
peer_profile: NodeProfile::Full,
agreed_bloom_size_class: crate::bloom::V1_SIZE_CLASS,
send_sr: true,
send_rr: true,
mmp: None,
last_heartbeat_sent: None,
handshake_msg2: None,
@@ -273,7 +288,16 @@ impl ActivePeer {
is_initiator: bool,
mmp_config: &MmpConfig,
remote_epoch: Option<[u8; 8]>,
our_profile: NodeProfile,
peer_profile: NodeProfile,
agreed_bloom_size_class: u8,
) -> Self {
// Compute MMP report gating: A sends to B iff A.provides AND B.wants
let our_neg = NegotiationPayload::fmp(0, 0, our_profile);
let their_neg = NegotiationPayload::fmp(0, 0, peer_profile);
let send_sr = our_neg.provides_sr() && their_neg.wants_sr();
let send_rr = our_neg.provides_rr() && their_neg.wants_rr();
let now = Instant::now();
Self {
identity,
@@ -298,6 +322,10 @@ impl ActivePeer {
authenticated_at,
last_seen: authenticated_at,
remote_epoch,
peer_profile,
agreed_bloom_size_class,
send_sr,
send_rr,
mmp: Some(MmpPeerState::new(mmp_config, is_initiator)),
last_heartbeat_sent: None,
handshake_msg2: None,
@@ -508,6 +536,28 @@ impl ActivePeer {
self.remote_epoch
}
// === Negotiated Profile ===
/// Get peer's node profile.
pub fn peer_profile(&self) -> NodeProfile {
self.peer_profile
}
/// Get agreed bloom filter size class for this link.
pub fn agreed_bloom_size_class(&self) -> u8 {
self.agreed_bloom_size_class
}
/// Whether to send sender reports to this peer.
pub fn send_sr(&self) -> bool {
self.send_sr
}
/// Whether to send receiver reports to this peer.
pub fn send_rr(&self) -> bool {
self.send_rr
}
// === Tree Accessors ===
/// Get the peer's tree coordinates, if known.
+31
View File
@@ -4,6 +4,7 @@
//! PeerConnection tracks the Noise XX handshake state and transitions to
//! ActivePeer upon successful authentication.
use crate::protocol::NodeProfile;
use crate::utils::index::SessionIndex;
use crate::noise::{self, NoiseError, NoiseSession};
use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId};
@@ -121,6 +122,12 @@ pub struct PeerConnection {
/// Remote peer's startup epoch (learned from handshake).
remote_epoch: Option<[u8; 8]>,
// === Negotiation Results ===
/// Peer's node profile (learned from negotiation payload).
peer_profile: Option<NodeProfile>,
/// Agreed bloom filter size class.
agreed_bloom_size_class: Option<u8>,
// === Handshake Resend ===
/// Wire-format msg1 bytes for resend (initiator only).
handshake_msg1: Option<Vec<u8>>,
@@ -161,6 +168,8 @@ impl PeerConnection {
transport_id: None,
source_addr: None,
remote_epoch: None,
peer_profile: None,
agreed_bloom_size_class: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
@@ -189,6 +198,8 @@ impl PeerConnection {
transport_id: None,
source_addr: None,
remote_epoch: None,
peer_profile: None,
agreed_bloom_size_class: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
@@ -221,6 +232,8 @@ impl PeerConnection {
transport_id: Some(transport_id),
source_addr: Some(source_addr),
remote_epoch: None,
peer_profile: None,
agreed_bloom_size_class: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
@@ -354,6 +367,24 @@ impl PeerConnection {
self.remote_epoch
}
// === Negotiation Results ===
/// Get peer's negotiated node profile.
pub fn peer_profile(&self) -> Option<NodeProfile> {
self.peer_profile
}
/// Get agreed bloom filter size class.
pub fn agreed_bloom_size_class(&self) -> Option<u8> {
self.agreed_bloom_size_class
}
/// Store negotiation results from peer's payload.
pub fn set_negotiation_results(&mut self, peer_profile: NodeProfile, bloom_size_class: u8) {
self.peer_profile = Some(peer_profile);
self.agreed_bloom_size_class = Some(bloom_size_class);
}
// === Handshake Resend ===
/// Store the wire-format msg1 bytes for resend and schedule the first resend.
+5 -1
View File
@@ -37,7 +37,11 @@ pub use link::{
pub use tree::TreeAnnounce;
pub use filter::FilterAnnounce;
pub use discovery::{LookupRequest, LookupResponse};
pub use negotiation::{NegotiationPayload, TlvEntry, NEGOTIATION_HEADER_SIZE};
pub use negotiation::{
BloomSizeRange, NegotiationPayload, NodeProfile, TlvEntry, NEGOTIATION_HEADER_SIZE,
FMP_FEAT_BLOOM_SIZE_NEG, FMP_FEAT_PROFILE_MASK, FMP_FEAT_PROVIDES_RR, FMP_FEAT_PROVIDES_SR,
FMP_FEAT_WANTS_RR, FMP_FEAT_WANTS_SR, TLV_BLOOM_SIZE,
};
pub use session::{
CoordsRequired, FspFlags, FspInnerFlags, MtuExceeded, PathBroken, PathMtuNotification,
SessionAck, SessionFlags, SessionMessageType, SessionMsg3, SessionReceiverReport,
+396
View File
@@ -22,6 +22,74 @@ pub const NEGOTIATION_HEADER_SIZE: usize = 10;
/// Format byte value for the initial negotiation format.
const NEGOTIATION_FORMAT_V0: u8 = 0;
// --- FMP feature bitfield constants ---
/// Mask for the 3-bit node profile enum (bits 0-2).
pub const FMP_FEAT_PROFILE_MASK: u64 = 0x07;
/// Bit 3: Can provide MMP sender reports.
pub const FMP_FEAT_PROVIDES_SR: u64 = 1 << 3;
/// Bit 4: Can provide MMP receiver reports.
pub const FMP_FEAT_PROVIDES_RR: u64 = 1 << 4;
/// Bit 5: Want MMP sender reports from peer.
pub const FMP_FEAT_WANTS_SR: u64 = 1 << 5;
/// Bit 6: Want MMP receiver reports from peer.
pub const FMP_FEAT_WANTS_RR: u64 = 1 << 6;
/// Bit 7: Bloom filter size is negotiable (check TLV).
pub const FMP_FEAT_BLOOM_SIZE_NEG: u64 = 1 << 7;
// --- TLV field numbers ---
/// TLV field for bloom filter size classes: `[min_class:1][max_class:1]`.
pub const TLV_BLOOM_SIZE: u16 = 1;
// --- Node profile enum ---
/// Node profile advertised during FMP negotiation.
///
/// Encoded in bits 0-2 of the FMP feature bitfield. Self-declared (not
/// AND-intersected). At least one side of a link must be `Full` or the
/// link is rejected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum NodeProfile {
/// Full routing node. Combines bloom filters, forwards transit.
Full = 0,
/// Non-routing node. Tree participation, one-way bloom receipt,
/// no transit forwarding.
NonRouting = 1,
/// Leaf node. Single upstream peer, no tree/bloom/transit.
Leaf = 2,
}
impl TryFrom<u8> for NodeProfile {
type Error = ProtocolError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Full),
1 => Ok(Self::NonRouting),
2 => Ok(Self::Leaf),
_ => Err(ProtocolError::Malformed(format!(
"unknown node profile: {value}"
))),
}
}
}
/// Bloom filter size class range from TLV negotiation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BloomSizeRange {
/// Minimum supported size class (512 << min_class bytes).
pub min_class: u8,
/// Maximum supported size class (512 << max_class bytes).
pub max_class: u8,
}
/// A TLV entry in the negotiation payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlvEntry {
@@ -163,6 +231,146 @@ impl NegotiationPayload {
}
Ok(agreed)
}
// --- FMP-specific helpers ---
/// Build an FMP negotiation payload for the given node profile.
///
/// Sets the profile bits, MMP wants/provides defaults for the profile,
/// bloom size negotiable bit, and bloom size TLV with the current
/// default size class (min=max=V1_SIZE_CLASS).
pub fn fmp(version_min: u8, version_max: u8, profile: NodeProfile) -> Self {
let (provides_sr, provides_rr, wants_sr, wants_rr) = match profile {
NodeProfile::Full => (true, true, true, true),
NodeProfile::NonRouting => (true, true, false, true),
NodeProfile::Leaf => (false, true, false, false),
};
let mut features = (profile as u8 as u64) & FMP_FEAT_PROFILE_MASK;
if provides_sr {
features |= FMP_FEAT_PROVIDES_SR;
}
if provides_rr {
features |= FMP_FEAT_PROVIDES_RR;
}
if wants_sr {
features |= FMP_FEAT_WANTS_SR;
}
if wants_rr {
features |= FMP_FEAT_WANTS_RR;
}
features |= FMP_FEAT_BLOOM_SIZE_NEG;
let bloom_size_class = crate::bloom::V1_SIZE_CLASS;
Self::new(version_min, version_max, features)
.with_tlv(TLV_BLOOM_SIZE, vec![bloom_size_class, bloom_size_class])
}
/// Extract the node profile from the FMP feature bitfield.
pub fn node_profile(&self) -> Result<NodeProfile, ProtocolError> {
let raw = (self.features & FMP_FEAT_PROFILE_MASK) as u8;
NodeProfile::try_from(raw)
}
/// Whether this peer can provide MMP sender reports.
pub fn provides_sr(&self) -> bool {
self.features & FMP_FEAT_PROVIDES_SR != 0
}
/// Whether this peer can provide MMP receiver reports.
pub fn provides_rr(&self) -> bool {
self.features & FMP_FEAT_PROVIDES_RR != 0
}
/// Whether this peer wants MMP sender reports.
pub fn wants_sr(&self) -> bool {
self.features & FMP_FEAT_WANTS_SR != 0
}
/// Whether this peer wants MMP receiver reports.
pub fn wants_rr(&self) -> bool {
self.features & FMP_FEAT_WANTS_RR != 0
}
/// Whether bloom filter size is negotiable.
pub fn bloom_size_negotiable(&self) -> bool {
self.features & FMP_FEAT_BLOOM_SIZE_NEG != 0
}
/// Extract bloom size range from TLV, if present.
pub fn bloom_size_range(&self) -> Result<Option<BloomSizeRange>, ProtocolError> {
for entry in &self.tlv_entries {
if entry.field_num == TLV_BLOOM_SIZE {
if entry.value.len() != 2 {
return Err(ProtocolError::Malformed(format!(
"bloom size TLV: expected 2 bytes, got {}",
entry.value.len()
)));
}
let min_class = entry.value[0];
let max_class = entry.value[1];
if min_class > max_class {
return Err(ProtocolError::Malformed(format!(
"bloom size: min_class ({min_class}) > max_class ({max_class})"
)));
}
if max_class as usize >= crate::bloom::SIZE_CLASS_BYTES.len() {
return Err(ProtocolError::Malformed(format!(
"bloom size: max_class ({max_class}) exceeds known size classes"
)));
}
return Ok(Some(BloomSizeRange { min_class, max_class }));
}
}
Ok(None)
}
/// Validate that two profiles form a valid link pairing.
///
/// At least one side must be `Full` or the link is rejected.
pub fn validate_profiles(
ours: NodeProfile,
theirs: NodeProfile,
) -> Result<(), ProtocolError> {
if ours != NodeProfile::Full && theirs != NodeProfile::Full {
return Err(ProtocolError::Malformed(format!(
"invalid profile pairing: {:?} <-> {:?} (at least one must be Full)",
ours, theirs
)));
}
Ok(())
}
/// Agree on a bloom filter size class with a peer.
///
/// Returns `min(our_max, their_max)`, rejecting if below either
/// side's minimum. Both sides must have the bloom size TLV and the
/// negotiable bit set.
pub fn agree_bloom_size(&self, other: &Self) -> Result<u8, ProtocolError> {
if !self.bloom_size_negotiable() || !other.bloom_size_negotiable() {
return Err(ProtocolError::Malformed(
"bloom size negotiation: both sides must set negotiable bit".to_string(),
));
}
let ours = self.bloom_size_range()?.ok_or_else(|| {
ProtocolError::Malformed("bloom size negotiation: missing TLV (ours)".to_string())
})?;
let theirs = other.bloom_size_range()?.ok_or_else(|| {
ProtocolError::Malformed("bloom size negotiation: missing TLV (theirs)".to_string())
})?;
let agreed = ours.max_class.min(theirs.max_class);
if agreed < ours.min_class || agreed < theirs.min_class {
return Err(ProtocolError::Malformed(format!(
"bloom size mismatch: ours [{},{}] theirs [{},{}]",
ours.min_class, ours.max_class, theirs.min_class, theirs.max_class
)));
}
Ok(agreed)
}
}
#[cfg(test)]
@@ -286,4 +494,192 @@ mod tests {
partial.extend_from_slice(&[0x01, 0x00]); // Only field_num, no length
assert!(NegotiationPayload::decode(&partial).is_err());
}
// --- Node profile tests ---
#[test]
fn test_node_profile_try_from() {
assert_eq!(NodeProfile::try_from(0).unwrap(), NodeProfile::Full);
assert_eq!(NodeProfile::try_from(1).unwrap(), NodeProfile::NonRouting);
assert_eq!(NodeProfile::try_from(2).unwrap(), NodeProfile::Leaf);
assert!(NodeProfile::try_from(3).is_err());
assert!(NodeProfile::try_from(7).is_err());
}
#[test]
fn test_fmp_payload_full_profile() {
let p = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
assert_eq!(p.node_profile().unwrap(), NodeProfile::Full);
assert!(p.provides_sr());
assert!(p.provides_rr());
assert!(p.wants_sr());
assert!(p.wants_rr());
assert!(p.bloom_size_negotiable());
let range = p.bloom_size_range().unwrap().unwrap();
assert_eq!(range.min_class, crate::bloom::V1_SIZE_CLASS);
assert_eq!(range.max_class, crate::bloom::V1_SIZE_CLASS);
}
#[test]
fn test_fmp_payload_nonrouting_profile() {
let p = NegotiationPayload::fmp(1, 1, NodeProfile::NonRouting);
assert_eq!(p.node_profile().unwrap(), NodeProfile::NonRouting);
assert!(p.provides_sr());
assert!(p.provides_rr());
assert!(!p.wants_sr());
assert!(p.wants_rr());
}
#[test]
fn test_fmp_payload_leaf_profile() {
let p = NegotiationPayload::fmp(1, 1, NodeProfile::Leaf);
assert_eq!(p.node_profile().unwrap(), NodeProfile::Leaf);
assert!(!p.provides_sr());
assert!(p.provides_rr());
assert!(!p.wants_sr());
assert!(!p.wants_rr());
}
#[test]
fn test_fmp_payload_roundtrip() {
for profile in [NodeProfile::Full, NodeProfile::NonRouting, NodeProfile::Leaf] {
let original = NegotiationPayload::fmp(1, 1, profile);
let encoded = original.encode();
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded, original);
assert_eq!(decoded.node_profile().unwrap(), profile);
}
}
#[test]
fn test_zero_features_is_full() {
// Full=0 means zero-initialized bitfield defaults to most capable
let p = NegotiationPayload::new(1, 1, 0);
assert_eq!(p.node_profile().unwrap(), NodeProfile::Full);
assert!(!p.provides_sr());
assert!(!p.wants_sr());
}
// --- Profile validation tests ---
#[test]
fn test_validate_profiles_valid() {
// F↔F
assert!(NegotiationPayload::validate_profiles(
NodeProfile::Full, NodeProfile::Full
).is_ok());
// F↔N
assert!(NegotiationPayload::validate_profiles(
NodeProfile::Full, NodeProfile::NonRouting
).is_ok());
// N↔F
assert!(NegotiationPayload::validate_profiles(
NodeProfile::NonRouting, NodeProfile::Full
).is_ok());
// F↔L
assert!(NegotiationPayload::validate_profiles(
NodeProfile::Full, NodeProfile::Leaf
).is_ok());
// L↔F
assert!(NegotiationPayload::validate_profiles(
NodeProfile::Leaf, NodeProfile::Full
).is_ok());
}
#[test]
fn test_validate_profiles_invalid() {
// N↔N
assert!(NegotiationPayload::validate_profiles(
NodeProfile::NonRouting, NodeProfile::NonRouting
).is_err());
// N↔L
assert!(NegotiationPayload::validate_profiles(
NodeProfile::NonRouting, NodeProfile::Leaf
).is_err());
// L↔N
assert!(NegotiationPayload::validate_profiles(
NodeProfile::Leaf, NodeProfile::NonRouting
).is_err());
// L↔L
assert!(NegotiationPayload::validate_profiles(
NodeProfile::Leaf, NodeProfile::Leaf
).is_err());
}
// --- Bloom size agreement tests ---
#[test]
fn test_bloom_size_agreement_identical() {
let a = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
let b = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
assert_eq!(a.agree_bloom_size(&b).unwrap(), crate::bloom::V1_SIZE_CLASS);
}
#[test]
fn test_bloom_size_agreement_different_ranges() {
// a supports [0,2], b supports [1,3]
let a = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![0, 2]);
let b = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![1, 3]);
// agreed = min(2,3) = 2, 2 >= 0 and 2 >= 1 → ok
assert_eq!(a.agree_bloom_size(&b).unwrap(), 2);
assert_eq!(b.agree_bloom_size(&a).unwrap(), 2);
}
#[test]
fn test_bloom_size_agreement_mismatch() {
// a supports [0,0], b supports [2,3]
let a = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![0, 0]);
let b = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![2, 3]);
// agreed = min(0,3) = 0, 0 < 2 → reject
assert!(a.agree_bloom_size(&b).is_err());
}
#[test]
fn test_bloom_size_missing_bit() {
let a = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
let b = NegotiationPayload::new(1, 1, 0); // no negotiable bit
assert!(a.agree_bloom_size(&b).is_err());
}
#[test]
fn test_bloom_size_missing_tlv() {
let a = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG); // bit set but no TLV
let b = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
assert!(a.agree_bloom_size(&b).is_err());
}
#[test]
fn test_bloom_size_tlv_bad_length() {
let p = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![1]); // only 1 byte, need 2
assert!(p.bloom_size_range().is_err());
}
#[test]
fn test_bloom_size_tlv_inverted_range() {
let p = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![3, 1]); // min > max
assert!(p.bloom_size_range().is_err());
}
#[test]
fn test_bloom_size_tlv_class_out_of_range() {
let p = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![0, 4]); // max_class=4 exceeds SIZE_CLASS_BYTES
assert!(p.bloom_size_range().is_err());
}
#[test]
fn test_bloom_size_no_tlv_returns_none() {
let p = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG);
assert_eq!(p.bloom_size_range().unwrap(), None);
}
}
+26 -3
View File
@@ -214,7 +214,14 @@ impl TreeState {
/// - No peers have coordinates
/// - Destination is in a different tree (different root)
/// - No peer is closer to the destination than we are
pub fn find_next_hop(&self, dest_coords: &TreeCoordinate) -> Option<NodeAddr> {
///
/// `skip_peers` contains peers that should not be used as transit
/// (e.g., non-routing and leaf nodes).
pub fn find_next_hop(
&self,
dest_coords: &TreeCoordinate,
skip_peers: &std::collections::HashSet<NodeAddr>,
) -> Option<NodeAddr> {
if self.my_coords.root_id() != dest_coords.root_id() {
return None;
}
@@ -224,6 +231,9 @@ impl TreeState {
let mut best: Option<(NodeAddr, usize)> = None;
for (peer_id, peer_coords) in &self.peer_ancestry {
if skip_peers.contains(peer_id) {
continue;
}
let distance = peer_coords.distance_to(dest_coords);
let dominated = match &best {
@@ -301,7 +311,14 @@ impl TreeState {
///
/// Returns `Some(peer_node_addr)` if a parent switch is recommended,
/// or `None` if the current parent is adequate.
pub fn evaluate_parent(&self, peer_costs: &HashMap<NodeAddr, f64>) -> Option<NodeAddr> {
///
/// `skip_peers` contains peers that should not be considered as parent
/// candidates (e.g., non-routing and leaf nodes that don't forward transit).
pub fn evaluate_parent(
&self,
peer_costs: &HashMap<NodeAddr, f64>,
skip_peers: &std::collections::HashSet<NodeAddr>,
) -> Option<NodeAddr> {
if self.peer_ancestry.is_empty() {
return None;
}
@@ -336,6 +353,10 @@ impl TreeState {
if *coords.root_id() != smallest_root {
continue;
}
// Skip non-routing/leaf peers (can't forward transit)
if skip_peers.contains(peer_id) {
continue;
}
// Reject candidates whose ancestry contains us (would create a loop)
if coords.contains(&self.my_node_addr) {
continue;
@@ -440,7 +461,9 @@ impl TreeState {
/// Returns `true` if the tree state changed (caller should re-announce).
pub fn handle_parent_lost(&mut self, peer_costs: &HashMap<NodeAddr, f64>) -> bool {
// Try to find an alternative parent
if let Some(new_parent) = self.evaluate_parent(peer_costs) {
if let Some(new_parent) =
self.evaluate_parent(peer_costs, &std::collections::HashSet::new())
{
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
+115 -40
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use super::*;
@@ -380,7 +380,7 @@ fn test_evaluate_parent_picks_smallest_root() {
make_coords(&[7, 2]),
);
let result = state.evaluate_parent(&HashMap::new());
let result = state.evaluate_parent(&HashMap::new(), &HashSet::new());
assert_eq!(result, Some(peer3));
}
@@ -406,7 +406,7 @@ fn test_evaluate_parent_prefers_shallowest_depth() {
make_coords(&[2, 3, 4, 0]),
);
let result = state.evaluate_parent(&HashMap::new());
let result = state.evaluate_parent(&HashMap::new(), &HashSet::new());
assert_eq!(result, Some(peer1));
}
@@ -423,7 +423,7 @@ fn test_evaluate_parent_stays_root_when_smallest() {
make_coords(&[1, 0]),
);
assert_eq!(state.evaluate_parent(&HashMap::new()), None);
assert_eq!(state.evaluate_parent(&HashMap::new(), &HashSet::new()), None);
}
#[test]
@@ -445,7 +445,7 @@ fn test_evaluate_parent_no_switch_when_already_best() {
state.recompute_coords();
// Now evaluate — should return None since peer1 is already our parent
assert_eq!(state.evaluate_parent(&HashMap::new()), None);
assert_eq!(state.evaluate_parent(&HashMap::new(), &HashSet::new()), None);
}
#[test]
@@ -453,7 +453,7 @@ fn test_evaluate_parent_no_peers() {
let my_node = make_node_addr(5);
let state = TreeState::new(my_node);
assert_eq!(state.evaluate_parent(&HashMap::new()), None);
assert_eq!(state.evaluate_parent(&HashMap::new(), &HashSet::new()), None);
}
#[test]
@@ -486,7 +486,7 @@ fn test_evaluate_parent_depth_threshold() {
make_coords(&[3, 0]),
);
let result = state.evaluate_parent(&HashMap::new());
let result = state.evaluate_parent(&HashMap::new(), &HashSet::new());
assert_eq!(result, Some(peer3));
}
@@ -507,7 +507,7 @@ fn test_evaluate_parent_rejects_loop_candidate() {
);
// Should return None — the only candidate creates a loop
assert_eq!(state.evaluate_parent(&HashMap::new()), None);
assert_eq!(state.evaluate_parent(&HashMap::new(), &HashSet::new()), None);
}
#[test]
@@ -532,7 +532,7 @@ fn test_evaluate_parent_picks_loop_free_over_loopy() {
make_coords(&[2, 3, 4, 0]),
);
let result = state.evaluate_parent(&HashMap::new());
let result = state.evaluate_parent(&HashMap::new(), &HashSet::new());
assert_eq!(result, Some(peer2));
}
@@ -628,7 +628,7 @@ fn test_find_next_hop_chain() {
add_peer(&mut state, 2, &[2, 1, 5, 0]);
let dest = make_coords(&[2, 1, 5, 0]);
assert_eq!(state.find_next_hop(&dest), Some(make_node_addr(2)));
assert_eq!(state.find_next_hop(&dest, &HashSet::new()), Some(make_node_addr(2)));
}
#[test]
@@ -640,7 +640,7 @@ fn test_find_next_hop_chain_indirect() {
add_peer(&mut state, 1, &[1, 5, 0]);
let dest = make_coords(&[2, 1, 5, 0]);
assert_eq!(state.find_next_hop(&dest), Some(make_node_addr(1)));
assert_eq!(state.find_next_hop(&dest, &HashSet::new()), Some(make_node_addr(1)));
}
#[test]
@@ -651,7 +651,7 @@ fn test_find_next_hop_toward_root() {
add_peer(&mut state, 1, &[1, 0]);
let dest = make_coords(&[0]);
assert_eq!(state.find_next_hop(&dest), Some(make_node_addr(1)));
assert_eq!(state.find_next_hop(&dest, &HashSet::new()), Some(make_node_addr(1)));
}
#[test]
@@ -666,7 +666,7 @@ fn test_find_next_hop_sibling() {
add_peer(&mut state, 3, &[3, 0]);
let dest = make_coords(&[3, 0]);
assert_eq!(state.find_next_hop(&dest), Some(make_node_addr(3)));
assert_eq!(state.find_next_hop(&dest, &HashSet::new()), Some(make_node_addr(3)));
}
#[test]
@@ -683,7 +683,7 @@ fn test_find_next_hop_tie_breaking() {
// Peer 3 distance: 2 (up to root, down to 4)
// Peer 2 distance: 2 (up to root, down to 4)
// All equal to our distance — no peer is strictly closer.
assert_eq!(state.find_next_hop(&dest), None);
assert_eq!(state.find_next_hop(&dest, &HashSet::new()), None);
}
#[test]
@@ -693,14 +693,14 @@ fn test_find_next_hop_different_root() {
// Destination in a different tree (root = 9)
let dest = make_coords(&[3, 9]);
assert_eq!(state.find_next_hop(&dest), None);
assert_eq!(state.find_next_hop(&dest, &HashSet::new()), None);
}
#[test]
fn test_find_next_hop_no_peers() {
let state = make_tree_state(5, &[5, 0]);
let dest = make_coords(&[3, 0]);
assert_eq!(state.find_next_hop(&dest), None);
assert_eq!(state.find_next_hop(&dest, &HashSet::new()), None);
}
#[test]
@@ -715,7 +715,7 @@ fn test_find_next_hop_local_minimum() {
add_peer(&mut state, 8, &[8, 5, 0]);
let dest = make_coords(&[3, 0]);
assert_eq!(state.find_next_hop(&dest), None);
assert_eq!(state.find_next_hop(&dest, &HashSet::new()), None);
}
#[test]
@@ -731,7 +731,7 @@ fn test_find_next_hop_best_of_multiple() {
add_peer(&mut state, 3, &[3, 1, 0]);
let dest = make_coords(&[7, 3, 1, 0]);
assert_eq!(state.find_next_hop(&dest), Some(make_node_addr(3)));
assert_eq!(state.find_next_hop(&dest, &HashSet::new()), Some(make_node_addr(3)));
}
// === Cost-based parent selection tests ===
@@ -769,7 +769,7 @@ fn test_effective_depth_selects_lower_cost_deeper_peer() {
);
let costs = make_costs(&[(1, 6.0), (2, 1.01)]);
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, Some(peer_b));
}
@@ -795,7 +795,7 @@ fn test_effective_depth_equal_cost_degenerates_to_depth() {
);
let costs = make_costs(&[(1, 1.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, Some(peer1));
}
@@ -820,7 +820,7 @@ fn test_effective_depth_tiebreak_by_node_addr() {
);
let costs = make_costs(&[(1, 1.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, Some(peer1)); // smaller NodeAddr
}
@@ -853,7 +853,7 @@ fn test_hysteresis_prevents_marginal_switch() {
state.recompute_coords();
let costs = make_costs(&[(1, 2.5), (2, 2.2)]);
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, None); // marginal improvement blocked by hysteresis
}
@@ -886,7 +886,7 @@ fn test_hysteresis_allows_significant_switch() {
state.recompute_coords();
let costs = make_costs(&[(1, 6.0), (2, 1.01)]);
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, Some(peer_b));
}
@@ -912,7 +912,7 @@ fn test_cold_start_default_cost() {
);
// Empty cost map — all peers get default 1.0
let result = state.evaluate_parent(&HashMap::new());
let result = state.evaluate_parent(&HashMap::new(), &HashSet::new());
assert_eq!(result, Some(peer1)); // shallowest wins
}
@@ -943,7 +943,7 @@ fn test_hold_down_suppresses_reeval() {
// Peer_b now offers better cost, but hold-down suppresses
let costs = make_costs(&[(1, 5.0), (2, 1.0)]);
state.set_parent_hysteresis(0.0); // no hysteresis, only hold-down
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, None); // suppressed by hold-down
}
@@ -973,7 +973,7 @@ fn test_mandatory_switch_bypasses_hold_down() {
// Remove peer_a (parent lost) — should bypass hold-down
state.remove_peer(&peer_a);
let result = state.evaluate_parent(&HashMap::new());
let result = state.evaluate_parent(&HashMap::new(), &HashSet::new());
assert_eq!(result, Some(peer_b)); // mandatory switch
}
@@ -1015,12 +1015,12 @@ fn test_heterogeneous_7node_avoids_bottleneck() {
);
// Without costs (all 1.0): picks peer 1 (smaller addr) — correct by luck
let result_no_cost = state.evaluate_parent(&HashMap::new());
let result_no_cost = state.evaluate_parent(&HashMap::new(), &HashSet::new());
assert_eq!(result_no_cost, Some(peer1));
// With costs: fiber (1.01) vs LoRa (6.0) — fiber wins definitively
let costs = make_costs(&[(1, 1.01), (2, 6.0)]);
let result_with_cost = state.evaluate_parent(&costs);
let result_with_cost = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result_with_cost, Some(peer1));
// Now test the critical case: node 5 currently has LoRa parent (peer 2).
@@ -1029,14 +1029,14 @@ fn test_heterogeneous_7node_avoids_bottleneck() {
state.recompute_coords();
assert_eq!(state.my_coords().depth(), 2); // depth 2 through LoRa peer
let result_switch = state.evaluate_parent(&costs);
let result_switch = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result_switch, Some(peer1)); // switches away from LoRa bottleneck
// With hysteresis enabled, still switches because the cost difference is large
state.set_parent_hysteresis(0.2);
// current_parent_eff = 1 + 6.0 = 7.0, best_eff = 1 + 1.01 = 2.01
// threshold = 7.0 * 0.8 = 5.6, 2.01 < 5.6 → switch
let result_hyst = state.evaluate_parent(&costs);
let result_hyst = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result_hyst, Some(peer1));
}
@@ -1072,14 +1072,14 @@ fn test_cost_degradation_triggers_switch() {
// Initial: both fiber-like costs. Node picks peer_a (smaller addr).
let initial_costs = make_costs(&[(1, 1.05), (2, 1.08)]);
let result = state.evaluate_parent(&initial_costs);
let result = state.evaluate_parent(&initial_costs, &HashSet::new());
assert_eq!(result, Some(peer_a));
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
// Verify stable: no switch with same costs
let result = state.evaluate_parent(&initial_costs);
let result = state.evaluate_parent(&initial_costs, &HashSet::new());
assert_eq!(result, None);
// Peer A's link degrades significantly (LoRa-like latency + loss)
@@ -1087,7 +1087,7 @@ fn test_cost_degradation_triggers_switch() {
// best_eff = 1 + 1.08 = 2.08
// threshold = 7.0 * 0.8 = 5.6, 2.08 < 5.6 → switch
let degraded_costs = make_costs(&[(1, 6.0), (2, 1.08)]);
let result = state.evaluate_parent(&degraded_costs);
let result = state.evaluate_parent(&degraded_costs, &HashSet::new());
assert_eq!(result, Some(peer_b));
}
@@ -1120,7 +1120,7 @@ fn test_cost_improvement_within_hysteresis_no_switch() {
// best_eff = 1 + 1.5 = 2.5
// threshold = 3.0 * 0.8 = 2.4, 2.5 > 2.4 → no switch
let costs = make_costs(&[(1, 2.0), (2, 1.5)]);
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, None);
}
@@ -1142,7 +1142,7 @@ fn test_single_peer_no_reeval_benefit() {
// Initial selection: picks the only peer
let costs = make_costs(&[(1, 1.05)]);
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, Some(peer_a));
state.set_parent(peer_a, 1, 1000);
@@ -1150,7 +1150,7 @@ fn test_single_peer_no_reeval_benefit() {
// Even with terrible cost, no switch (no alternative)
let bad_costs = make_costs(&[(1, 50.0)]);
let result = state.evaluate_parent(&bad_costs);
let result = state.evaluate_parent(&bad_costs, &HashSet::new());
assert_eq!(result, None);
}
@@ -1199,7 +1199,7 @@ fn test_flap_dampening_engages_after_threshold() {
// evaluate_parent should return None for non-mandatory switches
// Make peer_b much better than peer_a
let costs = make_costs(&[(1, 10.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, None); // suppressed by flap dampening
}
@@ -1235,7 +1235,7 @@ fn test_flap_dampening_allows_mandatory_switches() {
// Remove current parent (peer_a) — this is a mandatory switch
state.remove_peer(&peer_a);
let result = state.evaluate_parent(&HashMap::new());
let result = state.evaluate_parent(&HashMap::new(), &HashSet::new());
assert_eq!(result, Some(peer_b)); // mandatory switch bypasses dampening
}
@@ -1274,7 +1274,7 @@ fn test_flap_dampening_expires() {
// evaluate_parent should work normally now
let costs = make_costs(&[(1, 10.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, Some(peer_b)); // not suppressed
}
@@ -1311,7 +1311,7 @@ fn test_flap_dampening_below_threshold() {
// evaluate_parent should still work normally
let costs = make_costs(&[(1, 10.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs);
let result = state.evaluate_parent(&costs, &HashSet::new());
assert_eq!(result, Some(peer_b)); // not suppressed
}
@@ -1386,3 +1386,78 @@ fn test_flap_dampening_same_parent_no_count() {
// Should NOT be dampened since only the first was a real switch
assert!(!state.is_flap_dampened());
}
// === Skip peers (non-routing/leaf profile exclusion) ===
#[test]
fn test_evaluate_parent_skips_non_full_peer() {
// Two peers reaching the same root (0). Peer 3 is shallower (depth 1)
// but in skip set. Peer 7 is deeper (depth 2) but not skipped.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let root = make_node_addr(0);
let peer3 = make_node_addr(3);
let peer7 = make_node_addr(7);
// Peer 3: depth 1, root 0
state.update_peer(
ParentDeclaration::new(peer3, root, 1, 1000),
make_coords(&[3, 0]),
);
// Peer 7: depth 2, root 0
state.update_peer(
ParentDeclaration::new(peer7, make_node_addr(2), 1, 1000),
make_coords(&[7, 2, 0]),
);
let mut skip = HashSet::new();
skip.insert(peer3);
let result = state.evaluate_parent(&HashMap::new(), &skip);
assert_eq!(result, Some(peer7));
}
#[test]
fn test_evaluate_parent_all_skipped_returns_none() {
// Only peer is in skip set — no valid parent.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let root = make_node_addr(0);
let peer3 = make_node_addr(3);
state.update_peer(
ParentDeclaration::new(peer3, root, 1, 1000),
make_coords(&[3, 0]),
);
let mut skip = HashSet::new();
skip.insert(peer3);
assert_eq!(state.evaluate_parent(&HashMap::new(), &skip), None);
}
#[test]
fn test_find_next_hop_skips_non_full_peer() {
// Chain: 0 (root) <- 5 (us), peers 1 and 2.
// Peer 2 is the destination but is in skip set.
// Peer 1 is closer than us and not skipped, so route through 1.
let mut state = make_tree_state(5, &[5, 0]);
add_peer(&mut state, 1, &[1, 5, 0]);
add_peer(&mut state, 2, &[2, 1, 5, 0]);
let dest = make_coords(&[2, 1, 5, 0]);
let mut skip = HashSet::new();
skip.insert(make_node_addr(2));
assert_eq!(state.find_next_hop(&dest, &skip), Some(make_node_addr(1)));
}
#[test]
fn test_find_next_hop_all_closer_skipped() {
// Only peer closer to dest is in skip set — returns None.
let mut state = make_tree_state(5, &[5, 0]);
add_peer(&mut state, 1, &[1, 5, 0]);
let dest = make_coords(&[2, 1, 5, 0]);
let mut skip = HashSet::new();
skip.insert(make_node_addr(1));
assert_eq!(state.find_next_hop(&dest, &skip), None);
}
+30 -44
View File
@@ -14,7 +14,7 @@
# -h, --help Show this help
#
# Integration suites:
# static-mesh, static-chain, rekey, gateway,
# static-mesh, static-chain, rekey, mixed-profile,
# chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh,
# chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent,
# chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability,
@@ -63,7 +63,6 @@ CHAOS_SUITES=(
"mixed-technology mixed-technology"
"congestion-stress congestion-stress"
)
GATEWAY_SUITES=(gateway)
SIDECAR_SUITES=(sidecar)
# ── Colors ─────────────────────────────────────────────────────────────────
@@ -99,9 +98,6 @@ list_suites() {
echo " chaos-${parts[0]} (${parts[*]:1})"
done
echo ""
echo " Gateway:"
for s in "${GATEWAY_SUITES[@]}"; do echo " $s"; done
echo ""
echo " Sidecar:"
for s in "${SIDECAR_SUITES[@]}"; do echo " $s"; done
exit 0
@@ -157,14 +153,6 @@ run_build() {
return 1
fi
info "cargo fmt --check"
if cargo fmt --check 2>&1; then
record "fmt" 0
else
record "fmt" 1
return 1
fi
info "cargo clippy --all -- -D warnings"
if cargo clippy --all -- -D warnings 2>&1; then
record "clippy" 0
@@ -207,10 +195,8 @@ install_binaries() {
cp target/release/fips "$dest/fips"
cp target/release/fipsctl "$dest/fipsctl"
[[ -f target/release/fipstop ]] && cp target/release/fipstop "$dest/fipstop" || true
[[ -f target/release/fips-gateway ]] && cp target/release/fips-gateway "$dest/fips-gateway" || true
chmod +x "$dest/fips" "$dest/fipsctl"
[[ -f "$dest/fipstop" ]] && chmod +x "$dest/fipstop" || true
[[ -f "$dest/fips-gateway" ]] && chmod +x "$dest/fips-gateway" || true
}
# Run a static topology test (mesh, chain)
@@ -263,6 +249,31 @@ run_rekey() {
record "rekey" $rc
}
# Run the mixed-profile integration test (Full + NonRouting + Leaf)
run_mixed_profile() {
local compose="testing/static/docker-compose.yml"
local rc=0
info "[mixed-profile] Generating configs"
bash testing/static/scripts/generate-configs.sh mixed-profile || { record "mixed-profile" 1; return; }
bash testing/static/scripts/mixed-profile-test.sh inject-config || { record "mixed-profile" 1; return; }
info "[mixed-profile] Starting containers"
docker compose -f "$compose" --profile mixed-profile up -d || { record "mixed-profile" 1; return; }
info "[mixed-profile] Running mixed-profile test"
if bash testing/static/scripts/mixed-profile-test.sh; then
rc=0
else
rc=1
info "[mixed-profile] Collecting failure logs"
docker compose -f "$compose" --profile mixed-profile logs --no-color 2>&1 | tail -100
fi
docker compose -f "$compose" --profile mixed-profile down --volumes --remove-orphans 2>/dev/null
record "mixed-profile" $rc
}
# Run a chaos scenario
run_chaos() {
local name="$1"
@@ -279,31 +290,6 @@ run_chaos() {
record "chaos-$name" $rc
}
# Run gateway integration test
run_gateway() {
local compose="testing/static/docker-compose.yml"
local rc=0
info "[gateway] Generating configs"
bash testing/static/scripts/generate-configs.sh gateway gateway-test || { record "gateway" 1; return; }
bash testing/static/scripts/gateway-test.sh inject-config || { record "gateway" 1; return; }
info "[gateway] Starting containers"
docker compose -f "$compose" --profile gateway up -d || { record "gateway" 1; return; }
info "[gateway] Running gateway test"
if bash testing/static/scripts/gateway-test.sh; then
rc=0
else
rc=1
info "[gateway] Collecting failure logs"
docker compose -f "$compose" --profile gateway logs --no-color 2>&1 | tail -100
fi
docker compose -f "$compose" --profile gateway down --volumes --remove-orphans 2>/dev/null
record "gateway" $rc
}
# Run sidecar test
run_sidecar() {
local rc=0
@@ -346,8 +332,8 @@ run_integration() {
# Rekey
run_rekey
# Gateway
run_gateway
# Mixed-profile (Full + NonRouting + Leaf)
run_mixed_profile
# Chaos scenarios (parallel, throttled)
if [[ "$SKIP_CHAOS" != true ]]; then
@@ -411,8 +397,8 @@ run_suite() {
run_static "${suite#static-}" ;;
rekey)
run_rekey ;;
gateway)
run_gateway ;;
mixed-profile)
run_mixed_profile ;;
chaos-*)
local chaos_name="${suite#chaos-}"
local found=false
@@ -0,0 +1,49 @@
# Mixed Profile Topology Definition
#
# Tests node profile negotiation: Full, NonRouting, and Leaf nodes.
#
# A (Full) ─── B (Full)
# │ ╲ │
# │ ╲ │
# D (Leaf) C (NonRouting)
#
# Node A: Full hub, peers with B, C, D
# Node B: Full, peers with A, C
# Node C: NonRouting, peers with A, B — receives filters, doesn't send
# Node D: Leaf, peers with A only — single upstream, no tree/bloom/transit
#
# Profile overrides applied by mixed-profile-test.sh inject-config.
nodes:
a:
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
docker_ip: "172.20.0.10"
peers: [b, c, d]
b:
nsec: "b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0"
npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
docker_ip: "172.20.0.11"
peers: [a, c]
c:
nsec: "c102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fc0"
npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
docker_ip: "172.20.0.12"
peers: [a, b]
profile: non-routing
d:
nsec: "d102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fd0"
npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl"
docker_ip: "172.20.0.13"
peers: [a]
profile: leaf
# Expected behavior:
# - A↔B: Full F↔F, bidirectional bloom, transit routing
# - A↔C: F↔N, A sends filters to C, C doesn't send back, A includes C as dependent
# - A↔D: F↔L, single upstream, A includes D as dependent, D no tree/bloom
# - B↔C: F↔N, B sends filters to C, C doesn't send back
# - All nodes reachable via ping (C and D route through their Full peers)
+33 -48
View File
@@ -4,13 +4,6 @@ networks:
ipam:
config:
- subnet: 172.20.0.0/24
gateway-lan:
driver: bridge
enable_ipv6: true
ipam:
config:
- subnet: 172.20.1.0/24
- subnet: fd02::/64
x-fips-common: &fips-common
image: fips-test:latest
@@ -318,60 +311,52 @@ services:
fips-net:
ipv4_address: 172.20.0.12
# ── Gateway integration test (gateway + server + non-FIPS client)
gw-gateway:
# ── Mixed-profile topology ────────────────────────────────────────
# A (Full) + B (Full) + C (NonRouting) + D (Leaf)
mixed-a:
<<: *fips-common
profiles: ["gateway"]
container_name: fips-gw-gateway
hostname: gw-gateway
# Privileged required: gateway must enable IPv6 on eth1 (second network,
# attached after container start) and manage nftables NAT rules.
privileged: true
environment:
- RUST_LOG=info
- FIPS_TEST_MODE=gateway
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
- net.ipv6.conf.default.disable_ipv6=0
- net.ipv6.conf.all.forwarding=1
- net.ipv6.conf.all.proxy_ndp=1
profiles: ["mixed-profile"]
container_name: fips-node-a
hostname: node-a
volumes:
- ../docker/resolv.conf:/etc/resolv.conf:ro
- ./generated-configs/gateway/node-a.yaml:/etc/fips/fips.yaml:ro
- ./generated-configs/mixed-profile/node-a.yaml:/etc/fips/fips.yaml:ro
networks:
fips-net:
ipv4_address: 172.20.0.10
gateway-lan:
ipv4_address: 172.20.1.10
ipv6_address: fd02::10
gw-server:
mixed-b:
<<: *fips-common
profiles: ["gateway"]
container_name: fips-gw-server
hostname: gw-server
profiles: ["mixed-profile"]
container_name: fips-node-b
hostname: node-b
volumes:
- ../docker/resolv.conf:/etc/resolv.conf:ro
- ./generated-configs/gateway/node-b.yaml:/etc/fips/fips.yaml:ro
- ./generated-configs/mixed-profile/node-b.yaml:/etc/fips/fips.yaml:ro
networks:
fips-net:
ipv4_address: 172.20.0.11
gw-client:
image: fips-test-app:latest
profiles: ["gateway"]
container_name: fips-gw-client
hostname: gw-client
cap_add:
- NET_ADMIN
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
mixed-c:
<<: *fips-common
profiles: ["mixed-profile"]
container_name: fips-node-c
hostname: node-c
volumes:
- ./configs/gateway-resolv.conf:/etc/resolv.conf:ro
- ../docker/resolv.conf:/etc/resolv.conf:ro
- ./generated-configs/mixed-profile/node-c.yaml:/etc/fips/fips.yaml:ro
networks:
gateway-lan:
ipv4_address: 172.20.1.20
ipv6_address: fd02::20
restart: "no"
env_file:
- ./generated-configs/npubs.env
fips-net:
ipv4_address: 172.20.0.12
mixed-d:
<<: *fips-common
profiles: ["mixed-profile"]
container_name: fips-node-d
hostname: node-d
volumes:
- ../docker/resolv.conf:/etc/resolv.conf:ro
- ./generated-configs/mixed-profile/node-d.yaml:/etc/fips/fips.yaml:ro
networks:
fips-net:
ipv4_address: 172.20.0.13
+169
View File
@@ -0,0 +1,169 @@
#!/bin/bash
# Mixed-profile integration test: Full, NonRouting, and Leaf nodes.
#
# Topology:
# A (Full) ─── B (Full)
# │ \ │
# │ \ │
# D (Leaf) C (NonRouting)
#
# Usage:
# ./mixed-profile-test.sh inject-config Inject profile config overrides
# ./mixed-profile-test.sh Run the full test
#
# inject-config is run separately by CI after generate-configs.sh and
# before building Docker images.
set -e
trap 'echo ""; echo "Test interrupted"; exit 130' INT
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOPOLOGY="mixed-profile"
NODES="a b c d"
# ── inject-config subcommand ──────────────────────────────────────────
# Inject profile overrides into generated node configs.
if [ "${1:-}" = "inject-config" ]; then
echo "Injecting mixed-profile config overrides..."
# Node C: non-routing
cfg="$SCRIPT_DIR/../generated-configs/$TOPOLOGY/node-c.yaml"
if [ ! -f "$cfg" ]; then
echo " Error: $cfg not found" >&2
exit 1
fi
python3 -c "
import yaml
with open('$cfg') as f:
cfg = yaml.safe_load(f)
cfg.setdefault('node', {})['disable_routing'] = True
with open('$cfg', 'w') as f:
yaml.dump(cfg, f, default_flow_style=False, sort_keys=False)
"
echo " ✓ node-c (disable_routing: true)"
# Node D: leaf
cfg="$SCRIPT_DIR/../generated-configs/$TOPOLOGY/node-d.yaml"
if [ ! -f "$cfg" ]; then
echo " Error: $cfg not found" >&2
exit 1
fi
python3 -c "
import yaml
with open('$cfg') as f:
cfg = yaml.safe_load(f)
cfg.setdefault('node', {})['leaf_only'] = True
with open('$cfg', 'w') as f:
yaml.dump(cfg, f, default_flow_style=False, sort_keys=False)
"
echo " ✓ node-d (leaf_only: true)"
echo "✓ Config injection complete"
exit 0
fi
# ── Full test ─────────────────────────────────────────────────────────
source "$SCRIPT_DIR/../../lib/wait-converge.sh"
ENV_FILE="$SCRIPT_DIR/../generated-configs/npubs.env"
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found. Run generate-configs.sh first." >&2
exit 1
fi
source "$ENV_FILE"
PASSED=0
FAILED=0
check() {
local desc="$1"
shift
echo -n " $desc ... "
if "$@" >/dev/null 2>&1; then
echo "PASS"
PASSED=$((PASSED + 1))
else
echo "FAIL"
FAILED=$((FAILED + 1))
fi
}
ping_fips() {
local from="$1"
local to_npub="$2"
docker exec "fips-$from" ping6 -c 1 -W 5 "${to_npub}.fips"
}
echo "=== Mixed-Profile Integration Test ==="
echo ""
# Phase 1: Wait for link convergence
echo "Phase 1: Link convergence"
# A: peers with B, C, D → 3 links
# B: peers with A, C → 2 links
# C (NonRouting): peers with A, B → 2 links
# D (Leaf): peers with A → 1 link
wait_for_peers fips-node-a 3 30 || true
wait_for_peers fips-node-b 2 30 || true
wait_for_peers fips-node-c 2 30 || true
wait_for_peers fips-node-d 1 30 || true
# Phase 2: Verify link counts (already validated by wait_for_peers above)
echo ""
echo "Phase 2: Link counts verified via convergence wait"
# Phase 3: Wait for discovery/session convergence
echo ""
echo "Phase 3: Waiting for session convergence (up to 45s)..."
# Try all pairs repeatedly until they all work
CONV_START=$SECONDS
CONV_TIMEOUT=45
ALL_OK=false
while (( SECONDS - CONV_START < CONV_TIMEOUT )); do
ALL_GOOD=true
for pair in "node-a:$NPUB_B" "node-a:$NPUB_C" "node-a:$NPUB_D" \
"node-b:$NPUB_A" "node-b:$NPUB_C" \
"node-c:$NPUB_A" "node-c:$NPUB_B" \
"node-d:$NPUB_A" "node-d:$NPUB_B"; do
from="${pair%%:*}"
to="${pair##*:}"
if ! docker exec "fips-$from" ping6 -c 1 -W 1 "${to}.fips" >/dev/null 2>&1; then
ALL_GOOD=false
break
fi
done
if $ALL_GOOD; then
echo " All pairs reachable after $((SECONDS - CONV_START))s"
ALL_OK=true
break
fi
sleep 1
done
if ! $ALL_OK; then
echo " WARNING: Not all pairs converged within ${CONV_TIMEOUT}s (continuing with tests)"
fi
# Phase 4: Connectivity tests
echo ""
echo "Phase 4: F↔F connectivity"
check "A → B (Full → Full, direct)" ping_fips node-a "$NPUB_B"
check "B → A (Full → Full, direct)" ping_fips node-b "$NPUB_A"
echo ""
echo "Phase 5: F↔N connectivity"
check "A → C (Full → NonRouting, direct)" ping_fips node-a "$NPUB_C"
check "C → A (NonRouting → Full, direct)" ping_fips node-c "$NPUB_A"
check "B → C (Full → NonRouting, direct)" ping_fips node-b "$NPUB_C"
check "C → B (NonRouting → Full, direct)" ping_fips node-c "$NPUB_B"
echo ""
echo "Phase 6: F↔L connectivity"
check "A → D (Full → Leaf, direct)" ping_fips node-a "$NPUB_D"
check "D → A (Leaf → Full, direct)" ping_fips node-d "$NPUB_A"
echo ""
echo "Phase 7: Multi-hop through Full nodes"
check "D → B (Leaf → Full, via A)" ping_fips node-d "$NPUB_B"
echo ""
echo "=== Results: $PASSED passed, $FAILED failed ==="
[ "$FAILED" -eq 0 ] && exit 0 || exit 1