mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 08:14:42 +00:00
Implement spanning tree announcement send/receive protocol
Add TreeAnnounce v1 wire format with versioned encoding (version byte 0x01), slim ancestry entries (32 bytes each, no per-entry signatures), and transitive trust model where only the direct peer's declaration signature is verified. Key changes: - Enrich TreeCoordinate with CoordEntry metadata (sequence, timestamp) - TreeAnnounce encode/decode with roundtrip tests - Per-peer rate limiting (500ms minimum interval) on ActivePeer - Parent selection: depth-based algorithm with broken-path detection - New node/tree.rs module: send/receive, periodic refresh, cleanup - Wire up dispatch, initial announce on promotion, tick integration - Update gossip protocol design doc with trust model (§2.7) 304 tests pass, clean build.
This commit is contained in:
+33
-1
@@ -43,6 +43,7 @@ impl Node {
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
self.process_pending_retries(now_ms).await;
|
||||
self.check_tree_state().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,6 +316,10 @@ impl Node {
|
||||
our_index = %our_index,
|
||||
"Inbound peer promoted to active"
|
||||
);
|
||||
// Send initial tree announce to new peer
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await {
|
||||
debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce");
|
||||
}
|
||||
}
|
||||
PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => {
|
||||
// Clean up the losing connection's link
|
||||
@@ -324,6 +329,10 @@ impl Node {
|
||||
loser_link_id = %loser_link_id,
|
||||
"Inbound cross-connection won, loser link cleaned up"
|
||||
);
|
||||
// Send initial tree announce to peer (new or reconnected)
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await {
|
||||
debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce");
|
||||
}
|
||||
}
|
||||
PromotionResult::CrossConnectionLost { winner_link_id } => {
|
||||
// This connection lost — clean up its link
|
||||
@@ -435,6 +444,10 @@ impl Node {
|
||||
node_addr = %node_addr,
|
||||
"Peer promoted to active"
|
||||
);
|
||||
// Send initial tree announce to new peer
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await {
|
||||
debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce");
|
||||
}
|
||||
}
|
||||
PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => {
|
||||
// Clean up the losing connection's link
|
||||
@@ -449,6 +462,10 @@ impl Node {
|
||||
loser_link_id = %loser_link_id,
|
||||
"Outbound cross-connection won, loser link cleaned up"
|
||||
);
|
||||
// Send initial tree announce to peer (new or reconnected)
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await {
|
||||
debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce");
|
||||
}
|
||||
}
|
||||
PromotionResult::CrossConnectionLost { winner_link_id } => {
|
||||
// This connection lost — clean up its link
|
||||
@@ -673,7 +690,7 @@ impl Node {
|
||||
match msg_type {
|
||||
0x10 => {
|
||||
// TreeAnnounce
|
||||
debug!("Received TreeAnnounce (not yet implemented)");
|
||||
self.handle_tree_announce(from, payload).await;
|
||||
}
|
||||
0x20 => {
|
||||
// FilterAnnounce
|
||||
@@ -727,6 +744,10 @@ impl Node {
|
||||
///
|
||||
/// Frees session index, removes link and address mappings. Used for
|
||||
/// both graceful disconnect and timeout-based eviction.
|
||||
///
|
||||
/// Also handles tree state cleanup: if the removed peer was our parent,
|
||||
/// selects an alternative or becomes root, and marks remaining peers
|
||||
/// for pending tree announce (delivered on next tick).
|
||||
pub(super) fn remove_active_peer(&mut self, node_addr: &NodeAddr) {
|
||||
let peer = match self.peers.remove(node_addr) {
|
||||
Some(p) => p,
|
||||
@@ -747,9 +768,20 @@ impl Node {
|
||||
// Remove link and address mapping
|
||||
self.remove_link(&link_id);
|
||||
|
||||
// Tree state cleanup
|
||||
let tree_changed = self.handle_peer_removal_tree_cleanup(node_addr);
|
||||
if tree_changed {
|
||||
// Mark all remaining peers for pending tree announce.
|
||||
// These will be sent on the next tick via check_tree_state().
|
||||
for peer in self.peers.values_mut() {
|
||||
peer.mark_tree_announce_pending();
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
node_addr = %node_addr,
|
||||
link_id = %link_id,
|
||||
tree_changed = tree_changed,
|
||||
"Peer removed and state cleaned up"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
mod handlers;
|
||||
mod lifecycle;
|
||||
mod retry;
|
||||
mod tree;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -255,6 +256,10 @@ pub struct Node {
|
||||
/// Rate limiter for msg1 processing (DoS protection).
|
||||
msg1_rate_limiter: HandshakeRateLimiter,
|
||||
|
||||
// === Tree Announce Timing ===
|
||||
/// Last time we refreshed our root announcement (Unix seconds).
|
||||
last_root_refresh_secs: u64,
|
||||
|
||||
// === Connection Retry ===
|
||||
/// Retry state for peers whose outbound connections have failed.
|
||||
/// Keyed by NodeAddr. Entries are created when a handshake times out
|
||||
@@ -317,6 +322,7 @@ impl Node {
|
||||
peers_by_index: HashMap::new(),
|
||||
pending_outbound: HashMap::new(),
|
||||
msg1_rate_limiter: HandshakeRateLimiter::new(),
|
||||
last_root_refresh_secs: 0,
|
||||
retry_pending: HashMap::new(),
|
||||
})
|
||||
}
|
||||
@@ -365,6 +371,7 @@ impl Node {
|
||||
peers_by_index: HashMap::new(),
|
||||
pending_outbound: HashMap::new(),
|
||||
msg1_rate_limiter: HandshakeRateLimiter::new(),
|
||||
last_root_refresh_secs: 0,
|
||||
retry_pending: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Spanning Tree Announce send/receive logic.
|
||||
//!
|
||||
//! Handles building, sending, and receiving TreeAnnounce messages,
|
||||
//! including periodic root refresh and rate-limited propagation.
|
||||
|
||||
use crate::protocol::TreeAnnounce;
|
||||
use crate::NodeAddr;
|
||||
|
||||
use super::{Node, NodeError};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Root nodes re-announce every 30 minutes to keep the tree alive.
|
||||
const ROOT_REFRESH_INTERVAL_SECS: u64 = 30 * 60;
|
||||
|
||||
impl Node {
|
||||
/// Build a TreeAnnounce from our current tree state.
|
||||
fn build_tree_announce(&self) -> Result<TreeAnnounce, NodeError> {
|
||||
let decl = self.tree_state.my_declaration().clone();
|
||||
let ancestry = self.tree_state.my_coords().clone();
|
||||
|
||||
if !decl.is_signed() {
|
||||
return Err(NodeError::SendFailed {
|
||||
node_addr: *self.identity.node_addr(),
|
||||
reason: "declaration not signed".into(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(TreeAnnounce::new(decl, ancestry))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(super) async fn send_tree_announce_to_peer(
|
||||
&mut self,
|
||||
peer_addr: &NodeAddr,
|
||||
) -> Result<(), NodeError> {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Check rate limit
|
||||
let peer = match self.peers.get_mut(peer_addr) {
|
||||
Some(p) => p,
|
||||
None => return Err(NodeError::PeerNotFound(*peer_addr)),
|
||||
};
|
||||
|
||||
if !peer.can_send_tree_announce(now_ms) {
|
||||
peer.mark_tree_announce_pending();
|
||||
debug!(
|
||||
peer = %peer_addr,
|
||||
"TreeAnnounce rate-limited, marking pending"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build and encode
|
||||
let announce = self.build_tree_announce()?;
|
||||
let encoded = announce.encode().map_err(|e| NodeError::SendFailed {
|
||||
node_addr: *peer_addr,
|
||||
reason: format!("encode failed: {}", e),
|
||||
})?;
|
||||
|
||||
// Send
|
||||
self.send_encrypted_link_message(peer_addr, &encoded).await?;
|
||||
|
||||
// Record send time
|
||||
if let Some(peer) = self.peers.get_mut(peer_addr) {
|
||||
peer.record_tree_announce_sent(now_ms);
|
||||
}
|
||||
|
||||
debug!(peer = %peer_addr, "Sent TreeAnnounce");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a TreeAnnounce to all active peers.
|
||||
pub(super) async fn send_tree_announce_to_all(&mut self) {
|
||||
let peer_addrs: Vec<NodeAddr> = self.peers.keys().copied().collect();
|
||||
|
||||
for peer_addr in peer_addrs {
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&peer_addr).await {
|
||||
debug!(
|
||||
peer = %peer_addr,
|
||||
error = %e,
|
||||
"Failed to send TreeAnnounce"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send pending rate-limited tree announces whose cooldown has expired.
|
||||
pub(super) async fn send_pending_tree_announces(&mut self) {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let ready: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| peer.has_pending_tree_announce() && peer.can_send_tree_announce(now_ms))
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
|
||||
for peer_addr in ready {
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&peer_addr).await {
|
||||
debug!(
|
||||
peer = %peer_addr,
|
||||
error = %e,
|
||||
"Failed to send pending TreeAnnounce"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an inbound TreeAnnounce from an authenticated peer.
|
||||
///
|
||||
/// 1. Decode the message
|
||||
/// 2. Verify the sender's declaration signature (pubkey from handshake)
|
||||
/// 3. Update the peer's tree state
|
||||
/// 4. Re-evaluate parent selection
|
||||
/// 5. If parent changed: increment seq, sign, recompute coords, announce to all
|
||||
pub(super) async fn handle_tree_announce(&mut self, from: &NodeAddr, payload: &[u8]) {
|
||||
let announce = match TreeAnnounce::decode(payload) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
debug!(from = %from, error = %e, "Malformed TreeAnnounce");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Verify sender's declaration signature using their known pubkey
|
||||
let pubkey = match self.peers.get(from) {
|
||||
Some(peer) => peer.pubkey(),
|
||||
None => {
|
||||
debug!(from = %from, "TreeAnnounce from unknown peer");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// The declaring node_addr in the announce should match the sender
|
||||
if announce.declaration.node_addr() != from {
|
||||
debug!(
|
||||
from = %from,
|
||||
declared = %announce.declaration.node_addr(),
|
||||
"TreeAnnounce node_addr mismatch"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = announce.declaration.verify(&pubkey) {
|
||||
warn!(
|
||||
from = %from,
|
||||
error = %e,
|
||||
"TreeAnnounce signature verification failed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Update peer's tree state in ActivePeer
|
||||
if let Some(peer) = self.peers.get_mut(from) {
|
||||
peer.update_tree_position(
|
||||
announce.declaration.clone(),
|
||||
announce.ancestry.clone(),
|
||||
now_ms,
|
||||
);
|
||||
}
|
||||
|
||||
// Update in TreeState
|
||||
let updated = self.tree_state.update_peer(
|
||||
announce.declaration.clone(),
|
||||
announce.ancestry.clone(),
|
||||
);
|
||||
|
||||
if !updated {
|
||||
debug!(from = %from, "TreeAnnounce not fresher than existing, ignored");
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
from = %from,
|
||||
seq = announce.declaration.sequence(),
|
||||
depth = announce.ancestry.depth(),
|
||||
root = %announce.ancestry.root_id(),
|
||||
"Processed TreeAnnounce"
|
||||
);
|
||||
|
||||
// Re-evaluate parent selection
|
||||
if let Some(new_parent) = self.tree_state.evaluate_parent() {
|
||||
let new_seq = self.tree_state.my_declaration().sequence() + 1;
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
self.tree_state.set_parent(new_parent, new_seq, timestamp);
|
||||
if let Err(e) = self.tree_state.sign_declaration(&self.identity) {
|
||||
warn!(error = %e, "Failed to sign declaration after parent switch");
|
||||
return;
|
||||
}
|
||||
self.tree_state.recompute_coords();
|
||||
|
||||
info!(
|
||||
new_parent = %new_parent,
|
||||
new_seq = new_seq,
|
||||
new_root = %self.tree_state.root(),
|
||||
depth = self.tree_state.my_coords().depth(),
|
||||
"Parent switched, announcing to all peers"
|
||||
);
|
||||
|
||||
self.send_tree_announce_to_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic tree maintenance, called from the tick handler.
|
||||
///
|
||||
/// - Sends pending rate-limited announces
|
||||
/// - Refreshes root announcement every ROOT_REFRESH_INTERVAL_SECS (if root)
|
||||
pub(super) async fn check_tree_state(&mut self) {
|
||||
// Send any pending rate-limited announces
|
||||
self.send_pending_tree_announces().await;
|
||||
|
||||
// Root refresh
|
||||
if self.tree_state.is_root() && !self.peers.is_empty() {
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
if now_secs.saturating_sub(self.last_root_refresh_secs) >= ROOT_REFRESH_INTERVAL_SECS {
|
||||
let new_seq = self.tree_state.my_declaration().sequence() + 1;
|
||||
self.tree_state
|
||||
.set_parent(*self.identity.node_addr(), new_seq, now_secs);
|
||||
if let Err(e) = self.tree_state.sign_declaration(&self.identity) {
|
||||
warn!(error = %e, "Failed to sign root refresh declaration");
|
||||
return;
|
||||
}
|
||||
self.tree_state.recompute_coords();
|
||||
self.last_root_refresh_secs = now_secs;
|
||||
|
||||
debug!(seq = new_seq, "Root refresh: announcing to all peers");
|
||||
self.send_tree_announce_to_all().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle tree state cleanup when a peer is removed.
|
||||
///
|
||||
/// Called from `remove_active_peer`. If the removed peer was our parent,
|
||||
/// attempts to find an alternative or becomes root.
|
||||
///
|
||||
/// Returns `true` if our tree state changed (caller should announce).
|
||||
pub(super) fn handle_peer_removal_tree_cleanup(&mut self, node_addr: &NodeAddr) -> bool {
|
||||
let was_parent = !self.tree_state.is_root()
|
||||
&& self.tree_state.my_declaration().parent_id() == node_addr;
|
||||
|
||||
self.tree_state.remove_peer(node_addr);
|
||||
|
||||
if was_parent {
|
||||
let changed = self.tree_state.handle_parent_lost();
|
||||
if changed {
|
||||
// Re-sign the new declaration
|
||||
if let Err(e) = self.tree_state.sign_declaration(&self.identity) {
|
||||
warn!(error = %e, "Failed to sign declaration after parent loss");
|
||||
}
|
||||
info!(
|
||||
new_root = %self.tree_state.root(),
|
||||
is_root = self.tree_state.is_root(),
|
||||
"Tree state updated after parent loss"
|
||||
);
|
||||
}
|
||||
changed
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user