diff --git a/src/bloom/tests.rs b/src/bloom/tests.rs index 41df06c..1f8071b 100644 --- a/src/bloom/tests.rs +++ b/src/bloom/tests.rs @@ -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 +} diff --git a/src/config/mod.rs b/src/config/mod.rs index d536d9a..97c3a75 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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 diff --git a/src/config/node.rs b/src/config/node.rs index 1499030..9dee311 100644 --- a/src/config/node.rs +++ b/src/config/node.rs @@ -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, diff --git a/src/node/bloom.rs b/src/node/bloom.rs index 6103ee3..83e8b77 100644 --- a/src/node/bloom.rs +++ b/src/node/bloom.rs @@ -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 { 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) diff --git a/src/node/handlers/discovery.rs b/src/node/handlers/discovery.rs index a5e9844..4e5ef57 100644 --- a/src/node/handlers/discovery.rs +++ b/src/node/handlers/discovery.rs @@ -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 = 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 = 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 = 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; diff --git a/src/node/handlers/dispatch.rs b/src/node/handlers/dispatch.rs index c441b5e..48ac30b 100644 --- a/src/node/handlers/dispatch.rs +++ b/src/node/handlers/dispatch.rs @@ -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 = self.peers.keys().copied().collect(); self.bloom_state.mark_all_updates_needed(remaining_peers); diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index 37deba5..d0ad22f 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -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 { + // 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(()) } diff --git a/src/node/handlers/mmp.rs b/src/node/handlers/mmp.rs index e1a5f14..a485542 100644 --- a/src/node/handlers/mmp.rs +++ b/src/node/handlers/mmp.rs @@ -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) { diff --git a/src/node/mod.rs b/src/node/mod.rs index 4114811..fbf3283 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -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>, /// TUN writer thread handle. tun_writer_handle: Option>, - /// 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, // === 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 { 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 { + 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 { - self.addr_to_link - .get(&(transport_id, addr.clone())) - .copied() + pub fn find_link_by_addr(&self, transport_id: TransportId, addr: &TransportAddr) -> Option { + 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, diff --git a/src/node/tree.rs b/src/node/tree.rs index 8276928..1f5ba89 100644 --- a/src/node/tree.rs +++ b/src/node/tree.rs @@ -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) diff --git a/src/peer/active.rs b/src/peer/active.rs index 5b21243..81c4b75 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -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, @@ -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. diff --git a/src/peer/connection.rs b/src/peer/connection.rs index 1f8a8e4..7693e75 100644 --- a/src/peer/connection.rs +++ b/src/peer/connection.rs @@ -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, + /// Agreed bloom filter size class. + agreed_bloom_size_class: Option, + // === Handshake Resend === /// Wire-format msg1 bytes for resend (initiator only). handshake_msg1: Option>, @@ -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 { + self.peer_profile + } + + /// Get agreed bloom filter size class. + pub fn agreed_bloom_size_class(&self) -> Option { + 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. diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 98bff44..322d7ea 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -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, diff --git a/src/protocol/negotiation.rs b/src/protocol/negotiation.rs index 63a122e..39ce941 100644 --- a/src/protocol/negotiation.rs +++ b/src/protocol/negotiation.rs @@ -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 for NodeProfile { + type Error = ProtocolError; + + fn try_from(value: u8) -> Result { + 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 { + 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, 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 { + 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); + } } diff --git a/src/tree/state.rs b/src/tree/state.rs index 0599df5..586394a 100644 --- a/src/tree/state.rs +++ b/src/tree/state.rs @@ -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 { + /// + /// `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, + ) -> Option { 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) -> Option { + /// + /// `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, + skip_peers: &std::collections::HashSet, + ) -> Option { 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) -> 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()) diff --git a/src/tree/tests.rs b/src/tree/tests.rs index 2ebb4dc..0c3f136 100644 --- a/src/tree/tests.rs +++ b/src/tree/tests.rs @@ -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(°raded_costs); + let result = state.evaluate_parent(°raded_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); +} diff --git a/testing/ci-local.sh b/testing/ci-local.sh index 222eae4..c6bbf1c 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -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 diff --git a/testing/static/configs/topologies/mixed-profile.yaml b/testing/static/configs/topologies/mixed-profile.yaml new file mode 100644 index 0000000..69a7a16 --- /dev/null +++ b/testing/static/configs/topologies/mixed-profile.yaml @@ -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) diff --git a/testing/static/docker-compose.yml b/testing/static/docker-compose.yml index 415cbc1..5a243a9 100644 --- a/testing/static/docker-compose.yml +++ b/testing/static/docker-compose.yml @@ -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 diff --git a/testing/static/scripts/mixed-profile-test.sh b/testing/static/scripts/mixed-profile-test.sh new file mode 100755 index 0000000..b642621 --- /dev/null +++ b/testing/static/scripts/mixed-profile-test.sh @@ -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