mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 00:26:59 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
+30
-44
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Executable
+169
@@ -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
|
||||
Reference in New Issue
Block a user