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:
Johnathan Corgan
2026-02-10 23:35:09 +00:00
parent 9d36977ec8
commit 1c2cda480d
9 changed files with 1260 additions and 126 deletions
+33 -1
View File
@@ -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
View File
@@ -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(),
}
}
+284
View File
@@ -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
}
}
}