diff --git a/src/config/mod.rs b/src/config/mod.rs index dc47fbb..546e2f6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -593,13 +593,13 @@ impl Config { /// leaf_only → Leaf (implies non-routing), /// disable_routing → NonRouting, /// otherwise → Full. - pub fn node_profile(&self) -> crate::protocol::NodeProfile { + pub fn node_profile(&self) -> crate::proto::fmp::NodeProfile { if self.node.leaf_only { - crate::protocol::NodeProfile::Leaf + crate::proto::fmp::NodeProfile::Leaf } else if self.node.disable_routing { - crate::protocol::NodeProfile::NonRouting + crate::proto::fmp::NodeProfile::NonRouting } else { - crate::protocol::NodeProfile::Full + crate::proto::fmp::NodeProfile::Full } } diff --git a/src/lib.rs b/src/lib.rs index 1d23993..9917609 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,8 +61,8 @@ pub use transport::{ // Re-export protocol types pub use protocol::{ - FilterAnnounce, HandshakeMessageType, LinkMessageType, ProtocolError, SessionAck, - SessionDatagram, SessionFlags, SessionMessageType, SessionSetup, TreeAnnounce, + FilterAnnounce, LinkMessageType, ProtocolError, SessionAck, SessionDatagram, SessionFlags, + SessionMessageType, SessionSetup, TreeAnnounce, }; // Re-export discovery wire types (relocated from protocol:: to proto::discovery) @@ -73,13 +73,22 @@ pub use proto::routing::{ COORDS_REQUIRED_SIZE, CoordsRequired, MTU_EXCEEDED_SIZE, MtuExceeded, PathBroken, }; +// Re-export FMP link-framing wire type (relocated from protocol:: to proto::fmp) +pub use proto::fmp::HandshakeMessageType; + +// Re-export FMP negotiation wire types (relocated from protocol:: to proto::fmp) +pub use proto::fmp::{NegotiationPayload, NodeProfile, TlvEntry}; + // Re-export cache types pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache}; +// Re-export FMP tie-break helper (relocated from peer:: to proto::fmp) +pub use proto::fmp::cross_connection_winner; + // Re-export peer types pub use peer::{ ActivePeer, ConnectivityState, HandshakeState, PeerConnection, PeerError, PeerSlot, - PromotionResult, cross_connection_winner, + PromotionResult, }; // Re-export node types diff --git a/src/node/bloom.rs b/src/node/bloom.rs index 989057a..7d1c907 100644 --- a/src/node/bloom.rs +++ b/src/node/bloom.rs @@ -24,7 +24,7 @@ impl Node { let mut filters = HashMap::new(); for (addr, peer) in &self.peers { if self.is_tree_peer(addr) - && peer.peer_profile() == crate::protocol::NodeProfile::Full + && peer.peer_profile() == crate::proto::fmp::NodeProfile::Full && let Some(filter) = peer.inbound_filter() { filters.insert(*addr, filter.clone()); @@ -179,7 +179,7 @@ impl Node { /// 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 { + if self.node_profile() != crate::proto::fmp::NodeProfile::Full { return; } @@ -402,7 +402,7 @@ impl Node { /// and marks all peers for update. fn check_adaptive_sizing(&mut self) { // Only Full nodes participate in filter sizing - if self.node_profile() != crate::protocol::NodeProfile::Full { + if self.node_profile() != crate::proto::fmp::NodeProfile::Full { return; } diff --git a/src/node/context.rs b/src/node/context.rs index e78c61b..69f1972 100644 --- a/src/node/context.rs +++ b/src/node/context.rs @@ -15,7 +15,7 @@ use std::sync::Arc; -use crate::protocol::NodeProfile; +use crate::proto::fmp::NodeProfile; use crate::{Config, Identity}; /// Effectively-immutable `Node` state, shared via `Arc`. diff --git a/src/node/handlers/discovery.rs b/src/node/handlers/discovery.rs index 46c389c..5c58681 100644 --- a/src/node/handlers/discovery.rs +++ b/src/node/handlers/discovery.rs @@ -39,13 +39,13 @@ impl crate::proto::discovery::RoutingView for NodeRoutingView<'_> { .collect() } fn node_is_leaf(&self) -> bool { - self.node.node_profile() == crate::protocol::NodeProfile::Leaf + self.node.node_profile() == crate::proto::fmp::NodeProfile::Leaf } fn peer_is_full(&self, addr: &NodeAddr) -> bool { self.node .peers .get(addr) - .is_some_and(|peer| peer.peer_profile() == crate::protocol::NodeProfile::Full) + .is_some_and(|peer| peer.peer_profile() == crate::proto::fmp::NodeProfile::Full) } fn peer_meets_mtu(&self, addr: &NodeAddr, min_mtu: u16) -> bool { self.node diff --git a/src/node/handlers/dispatch.rs b/src/node/handlers/dispatch.rs index 8ff72bb..39f4f9a 100644 --- a/src/node/handlers/dispatch.rs +++ b/src/node/handlers/dispatch.rs @@ -77,7 +77,7 @@ impl Node { /// entries — other removal paths (link-dead, decrypt failure, peer /// restart) all schedule reconnect. pub(in crate::node) fn handle_disconnect(&mut self, from: &NodeAddr, payload: &[u8]) { - let disconnect = match crate::protocol::Disconnect::decode(payload) { + let disconnect = match crate::proto::fmp::Disconnect::decode(payload) { Ok(msg) => msg, Err(e) => { debug!(from = %self.peer_display_name(from), error = %e, "Malformed disconnect message"); diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index dc81d47..afc129f 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -10,8 +10,11 @@ use crate::node::acl::PeerAclContext; use crate::node::reject::{HandshakeReject, RejectReason}; use crate::node::wire::{Msg1Header, Msg2Header, Msg3Header, build_msg2, build_msg3}; use crate::node::{Node, NodeError}; -use crate::peer::{ActivePeer, PeerConnection, PromotionResult, cross_connection_winner}; -use crate::protocol::{Disconnect, DisconnectReason, NegotiationPayload}; +use crate::peer::{ActivePeer, PeerConnection, PromotionResult}; +use crate::proto::fmp::{ + Disconnect, DisconnectReason, EstablishSnapshot, InboundDecision, InboundReject, + NegotiationPayload, WireOutcome, cross_connection_winner, decide_fmp_negotiation, +}; use crate::transport::{Link, LinkDirection, LinkId, ReceivedPacket}; use std::time::Duration; use tracing::{debug, info, warn}; @@ -886,11 +889,15 @@ impl Node { let link_id = match self.pending_inbound.remove(&key) { Some(id) => id, None => { - // Check if this is a rekey msg3 for an active peer. - // handle_rekey_msg3 records its own UnknownConnection or - // BadState classification depending on whether a matching - // rekey-responder slot is found. - self.handle_rekey_msg3(&packet, &header).await; + // No pending inbound handshake matches this msg3. The live + // rekey-responder path completes via pending_inbound above, so + // a miss here is an unknown connection. + debug!( + receiver_idx = %header.receiver_idx, + "No pending inbound or rekey state for msg3" + ); + self.stats_mut() + .record_reject(RejectReason::Handshake(HandshakeReject::UnknownConnection)); return; } }; @@ -1039,310 +1046,275 @@ impl Node { // debug rather than warn (expected policy rejection, not a fault). let our_index = our_index.unwrap_or(header.receiver_idx); - // Identity-based restart/rekey detection. + // Identity-based restart/rekey/cross-connection classification. // - // Now that we know the initiator's identity from msg3, perform the - // same checks that the old handle_msg1 used to do after decrypting msg1. - if let Some(existing_peer) = self.peers.get(&peer_node_addr) { - let new_epoch = remote_epoch; - let existing_epoch = existing_peer.remote_epoch(); + // Now that we know the initiator's identity from msg3, classify this + // inbound handshake against any existing active peer. The classification + // tree is the pure `Fmp::establish_inbound` decision; each effect it + // selects is driven shell-side below, preserving the pre-refactor per- + // branch ordering and cleanup exactly. The snapshot resolves the one + // clock read (session age) and the config-derived rekey floor up front. + // + // The rekey age floor sits BELOW the minimum possible rekey interval, or + // jittered rekeys are wrongly rejected. It bounds both the + // cross-connection branch (`< floor` -> initial cross-connection) and the + // rekey-responder branch (`>= floor` -> rekey), so the two partition + // cleanly; see the pre-refactor commentary retained on the decision. + let our_node_addr = *self.identity().node_addr(); + let rekey_enabled = self.config().node.rekey.enabled; + let rekey_age_floor_secs = { + let min_interval = self + .config() + .node + .rekey + .after_secs + .saturating_sub(crate::node::REKEY_JITTER_SECS.unsigned_abs()); + min_interval.saturating_sub(5).max(5) + }; + let wire = WireOutcome { + peer_node_addr, + remote_epoch, + }; + let snap = match self.peers.get(&peer_node_addr) { + Some(existing_peer) => EstablishSnapshot { + has_existing_peer: true, + existing_peer_epoch: existing_peer.remote_epoch(), + existing_session_age_secs: existing_peer + .session_established_at() + .elapsed() + .as_secs(), + has_session: existing_peer.has_session(), + is_healthy: existing_peer.is_healthy(), + pending_new_session: existing_peer.pending_new_session().is_some(), + rekey_in_progress: existing_peer.rekey_in_progress(), + existing_msg2: existing_peer.handshake_msg2().map(|m| m.to_vec()), + different_link: existing_peer.link_id() != link_id, + rekey_enabled, + rekey_age_floor_secs, + our_node_addr, + }, + None => EstablishSnapshot { + has_existing_peer: false, + existing_peer_epoch: None, + existing_session_age_secs: 0, + has_session: false, + is_healthy: false, + pending_new_session: false, + rekey_in_progress: false, + existing_msg2: None, + different_link: false, + rekey_enabled, + rekey_age_floor_secs, + our_node_addr, + }, + }; - match (existing_epoch, new_epoch) { - (Some(existing), Some(new)) if existing != new => { - // Epoch mismatch — peer restarted. Tear down stale session. - debug!( - peer = %self.peer_display_name(&peer_node_addr), - "Peer restart detected (epoch mismatch), removing stale session" - ); - self.remove_active_peer(&peer_node_addr); - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - self.schedule_reconnect(peer_node_addr, now_ms); - // Fall through to process as new connection - } - _ => { - // Same epoch (or no epoch stored). - let session_age_secs = - existing_peer.session_established_at().elapsed().as_secs(); - - // The minimum plausible age for an inbound XX handshake to - // be a scheduled REKEY rather than an initial-handshake - // cross-connection. Derived from the configured interval - // and jitter so it tracks the real minimum rekey spacing - // (see the rekey-responder gate below, which uses the same - // value). A session at least this old that receives an - // inbound msg3 on a different link is a rekey, NOT an - // initial cross-connection. - let rekey_age_floor_secs = { - let min_interval = self - .config() - .node - .rekey - .after_secs - .saturating_sub(crate::node::REKEY_JITTER_SECS.unsigned_abs()); - min_interval.saturating_sub(5).max(5) - }; - - // Simultaneous-init cross-connection (msg2-then-msg3 ordering). - // - // When both sides initiate XX in parallel (typical in - // bootstrap-handoff after Nostr UDP punch), each side runs - // two handshakes concurrently — its own outbound paired with - // the peer's inbound, and the peer's outbound paired with our - // inbound. If our outbound's msg2 arrives before the peer's - // outbound's msg3, handle_msg2 promoted our outbound under - // the "Normal path" (peers_contains_key was false). Now - // msg3 arrives for the unrelated inbound link with the peer - // already promoted at the same epoch — apply the same - // tie-breaker handle_msg2 uses for the inverse ordering, so - // both sides converge on a single Noise session pair. - // - // CRITICAL (jitter × XX rekey): the upper age bound MUST sit - // below the rekey floor. An initial cross-connection always - // resolves within ~1 RTT of promotion (both handshakes race - // in the same sub-second burst); a session old enough to be - // rekeying that receives a concurrent rekey msg3 must NOT be - // routed here. The old fixed `< 30` bound overlapped the - // jittered rekey floor (as low as 15s): under jitter a recent - // cutover resets `session_established_at`, so a concurrent - // rekey msg3 (always on a different temp link_id) landed in - // this branch and, on the "our outbound wins" side, the - // peer's rekey session was DISCARDED (index freed, no pending - // slot) — yet the peer cut over to it regardless, leaving the - // discarding node unable to decrypt the peer until the 30s - // dead-timer fired (Phase-5 link death, green crypto). Gating - // on the rekey floor makes any rekey-aged msg3 fall through to - // the rekey-responder path below, which converges both sides - // (dual-init tie-break) AND installs a `pending` slot. - if existing_peer.link_id() != link_id && session_age_secs < rekey_age_floor_secs - { - let our_inbound_wins = cross_connection_winner( - self.identity().node_addr(), - &peer_node_addr, - false, // this connection is inbound - ); - - if our_inbound_wins { - // Larger node side: swap to the inbound session so - // it pairs with the peer's kept outbound session. - let inbound_session = match self - .connections - .get_mut(&link_id) - .and_then(|c| c.take_session()) - { - Some(s) => s, - None => { - self.connections.remove(&link_id); - self.remove_link(&link_id); - self.stats_mut().record_reject(RejectReason::Handshake( - HandshakeReject::BadState, - )); - return; - } - }; - if let Some(peer) = self.peers.get_mut(&peer_node_addr) { - let old_our_index = peer.replace_session( - inbound_session, - our_index, - header.sender_idx, - ); - let Some(transport_id) = peer.transport_id() else { - self.connections.remove(&link_id); - self.remove_link(&link_id); - self.stats_mut().record_reject(RejectReason::Handshake( - HandshakeReject::BadState, - )); - return; - }; - if let Some(old_idx) = old_our_index { - self.peers_by_index - .remove(&(transport_id, old_idx.as_u32())); - let _ = self.index_allocator.free(old_idx); - } - self.peers_by_index - .insert((transport_id, our_index.as_u32()), peer_node_addr); - - debug!( - peer = %self.peer_display_name(&peer_node_addr), - new_our_index = %our_index, - new_their_index = %header.sender_idx, - "Simultaneous-init (msg3): swapped to inbound session (our inbound wins)" - ); - } - } else { - // Smaller node side: keep the existing outbound - // session, drop the inbound's allocated index. - let _ = self.index_allocator.free(our_index); - debug!( - peer = %self.peer_display_name(&peer_node_addr), - "Simultaneous-init (msg3): keeping outbound session (our outbound wins)" - ); - } - - self.connections.remove(&link_id); - self.remove_link(&link_id); - return; + match self.fmp.establish_inbound(&snap, &wire) { + InboundDecision::Reject { + reason: InboundReject::DualRekeyWon, + } => { + // Dual-init rekey tie-break: we win (smaller addr), drop their msg3. + info!( + peer = %self.peer_display_name(&peer_node_addr), + our_addr = %our_node_addr, + their_addr = %peer_node_addr, + rekey_in_progress = snap.rekey_in_progress, + pending_new_session = snap.pending_new_session, + "rekey-msg3 tie-break: we win (smaller addr), drop their msg3" + ); + self.connections.remove(&link_id); + self.links.remove(&link_id); + self.stats_mut() + .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); + return; + } + InboundDecision::ResendMsg2 { msg2 } => { + // Not a rekey — duplicate handshake from same epoch. Resend + // stored msg2, leaving the active peer untouched. + if let Some(msg2) = msg2 + && let Some(transport) = self.transports.get(&packet.transport_id) + { + match transport.send(&packet.remote_addr, &msg2).await { + Ok(_) => debug!( + peer = %self.peer_display_name(&peer_node_addr), + "Resent msg2 for duplicate handshake (same epoch)" + ), + Err(e) => debug!( + peer = %self.peer_display_name(&peer_node_addr), + error = %e, + "Failed to resend msg2" + ), } - - // Check for rekey: session must be old enough that an - // inbound XX handshake is plausibly a scheduled rekey - // rather than a fresh duplicate/restart. - // - // The floor (`rekey_age_floor_secs`, computed above) sits - // BELOW the minimum possible rekey interval, or jittered - // rekeys are wrongly rejected. The initiator's effective - // interval is `after_secs - REKEY_JITTER_SECS` at its lowest - // (20s for 35s ± 15s). A fixed 30s floor exceeds that 20s - // minimum, so under jitter the responder rejects a legitimate - // rekey as a "duplicate handshake" (resends msg2), the - // initiator cuts over anyway, and the endpoints diverge by - // one epoch → receiver starves → 30s link-dead. The same - // floor also bounds the cross-connection branch above, so the - // two paths partition cleanly: `< floor` → initial - // cross-connection, `>= floor` → rekey responder. - if self.config().node.rekey.enabled - && existing_peer.has_session() - && existing_peer.is_healthy() - && session_age_secs >= rekey_age_floor_secs + } + self.connections.remove(&link_id); + self.links.remove(&link_id); + return; + } + InboundDecision::CrossConnect { + peer, + our_inbound_wins, + } => { + debug_assert_eq!(peer, peer_node_addr); + // Simultaneous-init cross-connection (msg2-then-msg3 ordering): + // apply the same tie-breaker handle_msg2 uses for the inverse + // ordering so both sides converge on a single Noise session pair. + if our_inbound_wins { + // Larger node side: swap to the inbound session so it pairs + // with the peer's kept outbound session. + let inbound_session = match self + .connections + .get_mut(&link_id) + .and_then(|c| c.take_session()) { - // Dual-initiation detection: both sides initiated rekey - // simultaneously. Two states can reach this point: - // - rekey_in_progress=true: both sides still mid-handshake - // - pending_new_session=Some && !rekey_in_progress: both - // sides already completed their initiator path - // (set_pending_session cleared rekey_in_progress) - // The IK fix only caught the first state; the XX three-message - // handshake widens the window so the second state is reached - // when both sides' set_pending_session runs before either's - // msg3 lands at the peer. Apply the smaller-NodeAddr - // tie-breaker uniformly in both states so both sides converge - // on a single Noise session post-cutover. - if existing_peer.rekey_in_progress() - || existing_peer.pending_new_session().is_some() - { - let our_addr = self.identity().node_addr(); - if our_addr < &peer_node_addr { - // We win — keep our session, drop their msg3. - info!( - peer = %self.peer_display_name(&peer_node_addr), - our_addr = %our_addr, - their_addr = %peer_node_addr, - rekey_in_progress = existing_peer.rekey_in_progress(), - pending_new_session = existing_peer.pending_new_session().is_some(), - "rekey-msg3 tie-break: we win (smaller addr), drop their msg3" - ); - self.connections.remove(&link_id); - self.links.remove(&link_id); - self.stats_mut().record_reject(RejectReason::Handshake( - HandshakeReject::BadState, - )); - return; - } - // We lose — abandon our rekey/pending, fall through as responder. - // abandon_rekey clears both rekey_in_progress and any pending - // session state, returning whichever index needs freeing. - info!( - peer = %self.peer_display_name(&peer_node_addr), - our_addr = %our_addr, - their_addr = %peer_node_addr, - rekey_in_progress = existing_peer.rekey_in_progress(), - pending_new_session = existing_peer.pending_new_session().is_some(), - "rekey-msg3 tie-break: we lose (larger addr), abandon ours" - ); - if let Some(peer) = self.peers.get_mut(&peer_node_addr) - && let Some(idx) = peer.abandon_rekey() - { - if let Some(tid) = peer.transport_id() { - self.peers_by_index.remove(&(tid, idx.as_u32())); - self.pending_outbound.remove(&(tid, idx.as_u32())); - } - let _ = self.index_allocator.free(idx); - } - // Fall through to respond as responder + Some(s) => s, + None => { + self.connections.remove(&link_id); + self.remove_link(&link_id); + self.stats_mut() + .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); + return; } - - // Rekey: process as responder, store new session as pending - let noise_session = { - let Some(conn) = self.connections.get_mut(&link_id) else { - warn!(link_id = %link_id, "Connection removed during rekey msg3 processing"); - self.links.remove(&link_id); - self.stats_mut().record_reject(RejectReason::Handshake( - HandshakeReject::UnknownConnection, - )); - return; - }; - conn.take_session() + }; + if let Some(peer) = self.peers.get_mut(&peer_node_addr) { + let old_our_index = + peer.replace_session(inbound_session, our_index, header.sender_idx); + let Some(transport_id) = peer.transport_id() else { + self.connections.remove(&link_id); + self.remove_link(&link_id); + self.stats_mut() + .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); + return; }; - let our_new_index = our_index; - - let noise_session = match noise_session { - Some(s) => s, - None => { - warn!("Rekey msg3: no session from handshake"); - self.connections.remove(&link_id); - self.links.remove(&link_id); - self.stats_mut().record_reject(RejectReason::Handshake( - HandshakeReject::BadState, - )); - return; - } - }; - - // Store pending session on the existing peer - if let Some(peer) = self.peers.get_mut(&peer_node_addr) { - peer.set_pending_session( - noise_session, - our_new_index, - header.sender_idx, - ); - peer.record_peer_rekey(); + if let Some(old_idx) = old_our_index { + self.peers_by_index + .remove(&(transport_id, old_idx.as_u32())); + let _ = self.index_allocator.free(old_idx); } - - // Register new index in peers_by_index - self.peers_by_index.insert( - (packet.transport_id, our_new_index.as_u32()), - peer_node_addr, - ); - - // Clean up: remove the temporary connection/link. - // Do NOT remove addr_to_link — the entry must remain pointing - // to the original link. - self.connections.remove(&link_id); - self.links.remove(&link_id); + self.peers_by_index + .insert((transport_id, our_index.as_u32()), peer_node_addr); debug!( peer = %self.peer_display_name(&peer_node_addr), - our_addr = %self.identity().node_addr(), - new_our_index = %our_new_index, + new_our_index = %our_index, new_their_index = %header.sender_idx, - "rekey-msg3 responder: pending session set, awaiting K-bit cutover" + "Simultaneous-init (msg3): swapped to inbound session (our inbound wins)" ); + } + } else { + // Smaller node side: keep the existing outbound session, drop + // the inbound's allocated index. + let _ = self.index_allocator.free(our_index); + debug!( + peer = %self.peer_display_name(&peer_node_addr), + "Simultaneous-init (msg3): keeping outbound session (our outbound wins)" + ); + } + + self.connections.remove(&link_id); + self.remove_link(&link_id); + return; + } + InboundDecision::RekeyRespond { + peer, + abandon_first, + } => { + debug_assert_eq!(peer, peer_node_addr); + if abandon_first { + // We lose — abandon our rekey/pending, fall through as + // responder. abandon_rekey clears both rekey_in_progress and + // any pending session state, returning whichever index needs + // freeing. + info!( + peer = %self.peer_display_name(&peer_node_addr), + our_addr = %our_node_addr, + their_addr = %peer_node_addr, + rekey_in_progress = snap.rekey_in_progress, + pending_new_session = snap.pending_new_session, + "rekey-msg3 tie-break: we lose (larger addr), abandon ours" + ); + if let Some(peer) = self.peers.get_mut(&peer_node_addr) + && let Some(idx) = peer.abandon_rekey() + { + if let Some(tid) = peer.transport_id() { + self.peers_by_index.remove(&(tid, idx.as_u32())); + self.pending_outbound.remove(&(tid, idx.as_u32())); + } + let _ = self.index_allocator.free(idx); + } + } + + // Rekey: process as responder, store new session as pending. + let noise_session = { + let Some(conn) = self.connections.get_mut(&link_id) else { + warn!(link_id = %link_id, "Connection removed during rekey msg3 processing"); + self.links.remove(&link_id); + self.stats_mut().record_reject(RejectReason::Handshake( + HandshakeReject::UnknownConnection, + )); + return; + }; + conn.take_session() + }; + let our_new_index = our_index; + + let noise_session = match noise_session { + Some(s) => s, + None => { + warn!("Rekey msg3: no session from handshake"); + self.connections.remove(&link_id); + self.links.remove(&link_id); + self.stats_mut() + .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); return; } + }; - // Not a rekey — duplicate handshake from same epoch. - // Resend stored msg2. - if let Some(msg2) = existing_peer.handshake_msg2().map(|m| m.to_vec()) - && let Some(transport) = self.transports.get(&packet.transport_id) - { - match transport.send(&packet.remote_addr, &msg2).await { - Ok(_) => debug!( - peer = %self.peer_display_name(&peer_node_addr), - "Resent msg2 for duplicate handshake (same epoch)" - ), - Err(e) => debug!( - peer = %self.peer_display_name(&peer_node_addr), - error = %e, - "Failed to resend msg2" - ), - } - } - self.connections.remove(&link_id); - self.links.remove(&link_id); - return; + // Store pending session on the existing peer + if let Some(peer) = self.peers.get_mut(&peer_node_addr) { + peer.set_pending_session(noise_session, our_new_index, header.sender_idx); + peer.record_peer_rekey(); } + + // Register new index in peers_by_index + self.peers_by_index.insert( + (packet.transport_id, our_new_index.as_u32()), + peer_node_addr, + ); + + // Clean up: remove the temporary connection/link. + // Do NOT remove addr_to_link — the entry must remain pointing + // to the original link. + self.connections.remove(&link_id); + self.links.remove(&link_id); + + debug!( + peer = %self.peer_display_name(&peer_node_addr), + our_addr = %self.identity().node_addr(), + new_our_index = %our_new_index, + new_their_index = %header.sender_idx, + "rekey-msg3 responder: pending session set, awaiting K-bit cutover" + ); + return; + } + InboundDecision::RestartThenPromote { peer } => { + // Epoch mismatch — peer restarted. Tear down stale session, then + // fall through to promote the fresh connection in its place. + debug!( + peer = %self.peer_display_name(&peer), + "Peer restart detected (epoch mismatch), removing stale session" + ); + self.remove_active_peer(&peer); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + self.schedule_reconnect(peer, now_ms); + // Fall through to process as new connection. + } + InboundDecision::Promote => { + // Net-new inbound (or the post-restart re-promote): fall through + // to promote_connection, whose late max-peers cap and + // cross-connection won/lost handling stay shell-side. } } @@ -1460,77 +1432,6 @@ impl Node { } } - /// Handle a rekey msg3 for an already-active peer. - /// - /// When a rekey is in progress (responder side), the ActivePeer holds the - /// handshake state. This processes msg3 to complete the rekey responder - /// handshake. - async fn handle_rekey_msg3(&mut self, packet: &ReceivedPacket, header: &Msg3Header) { - // Look for a peer expecting a rekey msg3 as responder. - // The responder's rekey handshake state is stored after processing - // the initiator's rekey msg1+msg2 exchange via the existing link. - let peer_addr = self.peers.iter().find_map(|(addr, peer)| { - if peer.has_rekey_responder_handshake() - && peer.rekey_responder_our_index() == Some(header.receiver_idx) - { - Some(*addr) - } else { - None - } - }); - - let peer_node_addr = match peer_addr { - Some(addr) => addr, - None => { - debug!( - receiver_idx = %header.receiver_idx, - "No pending inbound or rekey state for msg3" - ); - self.stats_mut() - .record_reject(RejectReason::Handshake(HandshakeReject::UnknownConnection)); - return; - } - }; - - let display_name = self.peer_display_name(&peer_node_addr); - let noise_msg3 = &packet.data[header.noise_msg3_offset..]; - - if let Some(peer) = self.peers.get_mut(&peer_node_addr) { - match peer.complete_rekey_msg3(noise_msg3) { - Ok(session) => { - let our_index = peer - .rekey_responder_our_index() - .unwrap_or(header.receiver_idx); - peer.set_pending_session(session, our_index, header.sender_idx); - peer.record_peer_rekey(); - - if let Some(transport_id) = peer.transport_id() { - self.peers_by_index - .insert((transport_id, our_index.as_u32()), peer_node_addr); - } - - debug!( - peer = %display_name, - our_addr = %self.identity().node_addr(), - new_our_index = %our_index, - new_their_index = %header.sender_idx, - "rekey-msg3 responder (existing link): pending session set, awaiting K-bit cutover" - ); - } - Err(e) => { - warn!( - peer = %display_name, - error = %e, - "Rekey msg3 processing failed" - ); - peer.clear_rekey_responder(); - self.stats_mut() - .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); - } - } - } - } - /// Promote a connection to active peer after successful authentication. /// /// Handles cross-connection detection and resolution using tie-breaker rules. @@ -1543,7 +1444,7 @@ impl Node { ) -> Result { // Leaf nodes: reject if we already have a peer (single-peer enforcement) let peer_node_addr_check = *verified_identity.node_addr(); - if self.node_profile() == crate::protocol::NodeProfile::Leaf + if self.node_profile() == crate::proto::fmp::NodeProfile::Leaf && !self.peers.is_empty() && !self.peers.contains_key(&peer_node_addr_check) { @@ -1606,7 +1507,7 @@ impl Node { let remote_epoch = connection.remote_epoch(); let peer_profile = connection .peer_profile() - .unwrap_or(crate::protocol::NodeProfile::Full); + .unwrap_or(crate::proto::fmp::NodeProfile::Full); let peer_node_addr = *verified_identity.node_addr(); let is_outbound = connection.is_outbound(); @@ -1700,7 +1601,7 @@ impl Node { // 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 { + if peer_profile != crate::proto::fmp::NodeProfile::Full { self.bloom_state.add_leaf_dependent(peer_node_addr); } @@ -1814,7 +1715,7 @@ impl Node { // 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 { + if peer_profile != crate::proto::fmp::NodeProfile::Full { self.bloom_state.add_leaf_dependent(peer_node_addr); } @@ -1844,15 +1745,13 @@ impl Node { /// Decodes the payload, validates profile pairing, and stores the /// results on the PeerConnection. fn process_fmp_negotiation( - our_profile: crate::protocol::NodeProfile, + our_profile: crate::proto::fmp::NodeProfile, conn: &mut PeerConnection, neg_bytes: &[u8], ) -> Result<(), crate::protocol::ProtocolError> { - 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)?; + // The decode -> validate -> profile decision is the pure core split; the + // shell records the result on the connection and logs. + let their_profile = decide_fmp_negotiation(our_profile, neg_bytes)?; conn.set_negotiation_results(their_profile); diff --git a/src/node/handlers/rekey.rs b/src/node/handlers/rekey.rs index 9ee9030..23b527b 100644 --- a/src/node/handlers/rekey.rs +++ b/src/node/handlers/rekey.rs @@ -10,6 +10,7 @@ use crate::node::Node; use crate::node::reject::{HandshakeReject, RejectReason}; use crate::node::wire::build_msg1; use crate::noise::HandshakeState; +use crate::proto::fmp::{ConnAction, LifecycleView, PeerSnapshot, RekeyCfg, RekeyResendSnapshot}; use crate::protocol::{SessionDatagram, SessionSetup}; use tracing::{debug, info, trace, warn}; @@ -42,140 +43,119 @@ impl Node { return; } - let rekey_after_secs = self.config().node.rekey.after_secs; - let rekey_after_messages = self.config().node.rekey.after_messages; + let cfg = RekeyCfg { + after_secs: self.config().node.rekey.after_secs, + after_messages: self.config().node.rekey.after_messages, + }; - // Collect peers that need action (to avoid borrow conflicts) - let mut peers_to_cutover: Vec = Vec::new(); - let mut peers_to_drain: Vec = Vec::new(); - let mut peers_to_rekey: Vec = Vec::new(); - - for (node_addr, peer) in &self.peers { - if !peer.has_session() || !peer.is_healthy() { - continue; - } - - // 1. Initiator-side cutover: we completed a rekey and have - // a pending session ready. Cut over on the next tick. - if peer.pending_new_session().is_some() && !peer.rekey_in_progress() { - peers_to_cutover.push(*node_addr); - continue; - } - - // 2. Drain window expiry - if peer.is_draining() && peer.drain_expired(DRAIN_WINDOW_SECS) { - peers_to_drain.push(*node_addr); - } - - // 3. Rekey trigger - if peer.rekey_in_progress() { - continue; - } - if peer.pending_new_session().is_some() { - // Completed rekey awaiting cutover; don't stack another. - continue; - } - if peer.rekey_msg3_payload().is_some() { - // Initiator already cut over on its timer but is still - // retransmitting msg3 to a responder not yet confirmed on - // the new epoch. Don't start another rekey (which would - // overwrite the retained payload) until this cycle's msg3 - // is delivered or its budget exhausted. Mirrors FSP - // check_session_rekey. - continue; - } - if peer.is_rekey_dampened(REKEY_DAMPENING_SECS) { - continue; - } - - let elapsed = peer.session_established_at().elapsed().as_secs(); - let counter = peer - .noise_session() - .map(|s| s.current_send_counter()) - .unwrap_or(0); - - // Apply per-session symmetric jitter to desynchronize - // dual-initiation in symmetric-start meshes. - let effective_after_secs = - rekey_after_secs.saturating_add_signed(peer.rekey_jitter_secs()); - if elapsed >= effective_after_secs || counter >= rekey_after_messages { - peers_to_rekey.push(*node_addr); - } - } - - // Execute cutover for initiator side - for node_addr in peers_to_cutover { - let did_cutover = if let Some(peer) = self.peers.get_mut(&node_addr) { - if let Some(_old_our_index) = peer.cutover_to_new_session() { - // New index was pre-registered in peers_by_index during - // msg2 handling (handshake.rs). Verify, don't duplicate. - debug_assert!( - peer.transport_id().is_some() - && peer.our_index().is_some() - && self.peers_by_index.contains_key(&( - peer.transport_id().unwrap(), - peer.our_index().unwrap().as_u32() - )), - "peers_by_index should contain pre-registered new index after cutover" - ); - let our_index = peer.our_index(); - let their_index = peer.their_index(); - info!( - peer = %self.peer_display_name(&node_addr), - our_addr = %self.identity().node_addr(), - their_addr = %node_addr, - our_index = ?our_index, - their_index = ?their_index, - "Rekey cutover complete (initiator), K-bit flipped" - ); - true - } else { - false - } - } else { - false - }; - // Re-register the new session with the decrypt worker — the - // cache_key (transport_id, our_index) just changed, so the - // old worker entry is stale and every packet on the new - // session would miss the worker's HashMap lookup. - #[cfg(unix)] - if did_cutover { - self.register_decrypt_worker_session(&node_addr); - } - #[cfg(not(unix))] - let _ = did_cutover; - } - - // Execute drain completion - for node_addr in peers_to_drain { - // Extract the old index and transport_id under the peer - // borrow, then drop the borrow so the cache_key cleanup - // below can take &mut self for unregister_decrypt_worker_session. - let drained = self - .peers - .get_mut(&node_addr) - .and_then(|peer| peer.complete_drain().map(|idx| (idx, peer.transport_id()))); - if let Some((old_our_index, transport_id)) = drained { - if let Some(tid) = transport_id { - let cache_key = (tid, old_our_index.as_u32()); - self.peers_by_index.remove(&cache_key); + // The shell snapshots each healthy peer's rekey ages/flags (every clock + // read resolved here); the core decides cutover/drain/trigger with no + // clock, phase-grouped to preserve the pre-refactor execution order. + let snapshots = self.rekey_peers(); + for action in self.fmp.poll_rekey(snapshots, &cfg) { + match action { + // Execute cutover for initiator side. + ConnAction::Cutover { peer: node_addr } => { + let did_cutover = if let Some(peer) = self.peers.get_mut(&node_addr) { + if let Some(_old_our_index) = peer.cutover_to_new_session() { + // New index was pre-registered in peers_by_index + // during msg2 handling (handshake.rs). + debug_assert!( + peer.transport_id().is_some() + && peer.our_index().is_some() + && self.peers_by_index.contains_key(&( + peer.transport_id().unwrap(), + peer.our_index().unwrap().as_u32() + )), + "peers_by_index should contain pre-registered new index after cutover" + ); + let our_index = peer.our_index(); + let their_index = peer.their_index(); + info!( + peer = %self.peer_display_name(&node_addr), + our_addr = %self.identity().node_addr(), + their_addr = %node_addr, + our_index = ?our_index, + their_index = ?their_index, + "Rekey cutover complete (initiator), K-bit flipped" + ); + true + } else { + false + } + } else { + false + }; + // Re-register the new session with the decrypt worker — the + // cache_key (transport_id, our_index) just changed, so the + // old worker entry is stale and every packet on the new + // session would miss the worker's HashMap lookup. #[cfg(unix)] - self.unregister_decrypt_worker_session(cache_key); + if did_cutover { + self.register_decrypt_worker_session(&node_addr); + } + #[cfg(not(unix))] + let _ = did_cutover; } - let _ = self.index_allocator.free(old_our_index); - trace!( - peer = %self.peer_display_name(&node_addr), - old_index = %old_our_index, - "Drain complete, previous session erased" - ); + // Execute drain completion. + ConnAction::Drain { peer: node_addr } => { + // Extract the old index and transport_id under the peer + // borrow, then drop the borrow so the cache_key cleanup + // below can take &mut self for unregister_decrypt_worker_session. + let drained = self.peers.get_mut(&node_addr).and_then(|peer| { + peer.complete_drain().map(|idx| (idx, peer.transport_id())) + }); + if let Some((old_our_index, transport_id)) = drained { + if let Some(tid) = transport_id { + let cache_key = (tid, old_our_index.as_u32()); + self.peers_by_index.remove(&cache_key); + #[cfg(unix)] + self.unregister_decrypt_worker_session(cache_key); + } + let _ = self.index_allocator.free(old_our_index); + trace!( + peer = %self.peer_display_name(&node_addr), + old_index = %old_our_index, + "Drain complete, previous session erased" + ); + } + } + // Initiate a new rekey. + ConnAction::InitiateRekey { peer: node_addr } => { + self.initiate_rekey(&node_addr).await; + } + #[allow(unreachable_patterns)] + _ => {} } } + } - // Initiate new rekeys - for node_addr in peers_to_rekey { - self.initiate_rekey(&node_addr).await; - } + /// Snapshot every healthy peer with a session for the rekey decision, + /// pre-computing its monotonic ages and timer predicates so the pure core + /// applies the thresholds without reading a clock (see [`PeerSnapshot`]). + /// + /// Lives here, beside the drain/dampening constants and the FSP analog, so + /// the forward-merge onto `next` reconciles rekey timing in one place. + pub(in crate::node) fn rekey_peer_snapshots(&self) -> Vec { + self.peers + .iter() + .filter(|(_, peer)| peer.has_session() && peer.is_healthy()) + .map(|(node_addr, peer)| PeerSnapshot { + addr: *node_addr, + has_pending: peer.pending_new_session().is_some(), + rekey_in_progress: peer.rekey_in_progress(), + is_draining: peer.is_draining(), + drain_expired: peer.drain_expired(DRAIN_WINDOW_SECS), + is_dampened: peer.is_rekey_dampened(REKEY_DAMPENING_SECS), + rekey_msg3_pending: peer.rekey_msg3_payload().is_some(), + elapsed_secs: peer.session_established_at().elapsed().as_secs(), + counter: peer + .noise_session() + .map(|s| s.current_send_counter()) + .unwrap_or(0), + jitter_secs: peer.rekey_jitter_secs(), + }) + .collect() } /// Initiate an outbound rekey to a peer. @@ -285,62 +265,74 @@ impl Node { let backoff = self.config().node.rate_limit.handshake_resend_backoff; let max_resends = self.config().node.rate_limit.handshake_max_resends; - // Collect peers needing action - let mut to_resend: Vec<(NodeAddr, Vec)> = Vec::new(); - let mut to_abandon: Vec = Vec::new(); + // The shell snapshots each in-flight rekey (resend-due predicate + // resolved here); the core classifies abandon-vs-resend and computes + // the backoff, abandons first. + let candidates = self.rekey_resend_candidates(now_ms); + for action in + self.fmp + .poll_rekey_resends(candidates, now_ms, interval_ms, backoff, max_resends) + { + match action { + // Abandon rekey cycles that exhausted their retransmission budget. + ConnAction::AbandonRekey { peer: node_addr } => { + if let Some(peer) = self.peers.get_mut(&node_addr) { + peer.abandon_rekey(); + } + debug!( + peer = %self.peer_display_name(&node_addr), + "FMP rekey aborted: msg1 unconfirmed after max retransmissions, abandoning cycle" + ); + } + ConnAction::ResendRekeyMsg1 { + peer: node_addr, + bytes, + next_resend_at_ms, + } => { + let (transport_id, remote_addr) = match self.peers.get(&node_addr) { + Some(p) => match (p.transport_id(), p.current_addr()) { + (Some(tid), Some(addr)) => (tid, addr.clone()), + _ => continue, + }, + None => continue, + }; - for (node_addr, peer) in &self.peers { - if !peer.rekey_in_progress() || peer.rekey_msg1().is_none() { - continue; - } - if peer.rekey_msg1_resend_count() >= max_resends { - to_abandon.push(*node_addr); - continue; - } - if peer.needs_msg1_resend(now_ms) - && let Some(msg1) = peer.rekey_msg1() - { - to_resend.push((*node_addr, msg1.to_vec())); + let sent = if let Some(transport) = self.transports.get(&transport_id) { + transport.send(&remote_addr, &bytes).await.is_ok() + } else { + false + }; + + if sent && let Some(peer) = self.peers.get_mut(&node_addr) { + peer.record_rekey_msg1_resend(next_resend_at_ms); + let count = peer.rekey_msg1_resend_count(); + trace!( + peer = %self.peer_display_name(&node_addr), + resend = count, + "Resent rekey msg1" + ); + } + } + #[allow(unreachable_patterns)] + _ => {} } } + } - // Abandon rekey cycles that exhausted their retransmission budget. - for node_addr in to_abandon { - if let Some(peer) = self.peers.get_mut(&node_addr) { - peer.abandon_rekey(); - } - debug!( - peer = %self.peer_display_name(&node_addr), - "FMP rekey aborted: msg1 unconfirmed after max retransmissions, abandoning cycle" - ); - } - - for (node_addr, msg1_bytes) in to_resend { - let (transport_id, remote_addr) = match self.peers.get(&node_addr) { - Some(p) => match (p.transport_id(), p.current_addr()) { - (Some(tid), Some(addr)) => (tid, addr.clone()), - _ => continue, - }, - None => continue, - }; - - let sent = if let Some(transport) = self.transports.get(&transport_id) { - transport.send(&remote_addr, &msg1_bytes).await.is_ok() - } else { - false - }; - - if sent && let Some(peer) = self.peers.get_mut(&node_addr) { - let count = peer.rekey_msg1_resend_count() + 1; - let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64; - peer.record_rekey_msg1_resend(next); - trace!( - peer = %self.peer_display_name(&node_addr), - resend = count, - "Resent rekey msg1" - ); - } - } + /// Snapshot every peer with a rekey handshake in flight (and a stored + /// msg1) for the retransmission decision, pre-evaluating the resend-due + /// predicate against `now_ms` so the core reads no clock. + pub(in crate::node) fn rekey_resend_snapshots(&self, now_ms: u64) -> Vec { + self.peers + .iter() + .filter(|(_, peer)| peer.rekey_in_progress() && peer.rekey_msg1().is_some()) + .map(|(node_addr, peer)| RekeyResendSnapshot { + peer: *node_addr, + resend_count: peer.rekey_msg1_resend_count(), + needs_resend: peer.needs_msg1_resend(now_ms), + msg1: peer.rekey_msg1().unwrap().to_vec(), + }) + .collect() } /// Retransmit FMP rekey msg3 until the responder is confirmed on the diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index 412bcee..6e1b02c 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -20,10 +20,10 @@ use crate::node::session_wire::{ use crate::node::wire::{ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, build_established_header}; use crate::node::{Node, NodeError}; use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, HANDSHAKE_MSG3_SIZE, HandshakeState}; +use crate::proto::fmp::NegotiationPayload; use crate::proto::routing::{CoordsRequired, MtuExceeded, PathBroken}; #[cfg(unix)] use crate::protocol::LinkMessageType; -use crate::protocol::NegotiationPayload; #[cfg(unix)] use crate::protocol::SESSION_DATAGRAM_HEADER_SIZE; use crate::protocol::{ diff --git a/src/node/handlers/timeout.rs b/src/node/handlers/timeout.rs index 289402c..51efd7c 100644 --- a/src/node/handlers/timeout.rs +++ b/src/node/handlers/timeout.rs @@ -3,14 +3,77 @@ use crate::node::Node; use crate::peer::HandshakeState; +use crate::proto::fmp::{ + ConnAction, ConnSnapshot, LifecycleView, PeerSnapshot, RekeyResendSnapshot, +}; use crate::transport::LinkId; use tracing::{debug, info}; +impl LifecycleView for Node { + fn stale_connections(&self, now_ms: u64, timeout_ms: u64) -> Vec { + self.connections + .iter() + .filter(|(_, conn)| conn.is_timed_out(now_ms, timeout_ms) || conn.is_failed()) + .map(|(link_id, conn)| ConnSnapshot { + link: *link_id, + is_outbound: conn.is_outbound(), + retry_addr: conn.expected_identity().map(|id| *id.node_addr()), + resend_count: 0, + msg1: Vec::new(), + }) + .collect() + } + + fn resend_candidates(&self, now_ms: u64, max_resends: u32) -> Vec { + self.connections + .iter() + .filter(|(_, conn)| { + conn.is_outbound() + && conn.handshake_state() == HandshakeState::SentMsg1 + && conn.resend_count() < max_resends + && conn.next_resend_at_ms() > 0 + && now_ms >= conn.next_resend_at_ms() + // Skip resend if the target peer is already promoted — a + // cross-connection was resolved via the inbound path and + // resending msg1 would start a new handshake on the peer, + // creating a session mismatch. + && !conn + .expected_identity() + .map(|id| self.peers.contains_key(id.node_addr())) + .unwrap_or(false) + }) + .filter_map(|(link_id, conn)| { + conn.handshake_msg1().map(|msg1| ConnSnapshot { + link: *link_id, + is_outbound: true, + retry_addr: None, + resend_count: conn.resend_count(), + msg1: msg1.to_vec(), + }) + }) + .collect() + } + + fn rekey_peers(&self) -> Vec { + // The snapshot builder lives in `rekey` beside its drain/dampening + // constants; the read-seam unifies here. + self.rekey_peer_snapshots() + } + + fn rekey_resend_candidates(&self, now_ms: u64) -> Vec { + self.rekey_resend_snapshots(now_ms) + } +} + impl Node { /// Check for timed-out handshake connections and clean them up. /// /// Called periodically by the RX event loop. Removes connections that have /// been idle longer than the configured handshake timeout or are in Failed state. + /// + /// The stale/failed predicate and every registry mutation stay shell-side; + /// the retry-then-teardown choreography is the pure + /// [`Fmp::poll_timeouts`](crate::proto::fmp::Fmp::poll_timeouts) decision. pub(in crate::node) fn check_timeouts(&mut self) { if self.connections.is_empty() { return; @@ -19,41 +82,34 @@ impl Node { let now_ms = Self::now_ms(); let timeout_ms = self.config().node.rate_limit.handshake_timeout_secs * 1000; - let stale: Vec = self - .connections - .iter() - .filter(|(_, conn)| conn.is_timed_out(now_ms, timeout_ms) || conn.is_failed()) - .map(|(link_id, _)| *link_id) - .collect(); - - for link_id in stale { - // Log and schedule retry before cleanup (need connection state) - if let Some(conn) = self.connections.get(&link_id) { - let direction = conn.direction(); - let idle_ms = conn.idle_time(now_ms); - if conn.is_failed() { - debug!( - link_id = %link_id, - direction = %direction, - "Failed handshake connection cleaned up" - ); - } else { - debug!( - link_id = %link_id, - direction = %direction, - idle_secs = idle_ms / 1000, - "Stale handshake connection timed out" - ); - } - - // Schedule retry for failed outbound auto-connect peers - if conn.is_outbound() - && let Some(identity) = conn.expected_identity() - { - self.schedule_retry(*identity.node_addr(), now_ms); + let stale = self.stale_connections(now_ms, timeout_ms); + for action in self.fmp.poll_timeouts(stale) { + match action { + ConnAction::ScheduleRetry { peer } => self.schedule_retry(peer, now_ms), + ConnAction::Teardown { link } => { + // Log before cleanup (needs live connection state). + if let Some(conn) = self.connections.get(&link) { + let direction = conn.direction(); + if conn.is_failed() { + debug!( + link_id = %link, + direction = %direction, + "Failed handshake connection cleaned up" + ); + } else { + debug!( + link_id = %link, + direction = %direction, + idle_secs = conn.idle_time(now_ms) / 1000, + "Stale handshake connection timed out" + ); + } + } + self.cleanup_stale_connection(link, now_ms); } + #[allow(unreachable_patterns)] + _ => {} } - self.cleanup_stale_connection(link_id, now_ms); } } @@ -98,33 +154,24 @@ impl Node { let interval_ms = self.config().node.rate_limit.handshake_resend_interval_ms; let backoff = self.config().node.rate_limit.handshake_resend_backoff; - // Collect resend candidates: outbound, in SentMsg1, with stored msg1, - // under max resends, and past the scheduled time. - // Skip resend if the target peer is already promoted — a cross-connection - // was resolved via the inbound path and resending msg1 would start a new - // handshake on the peer, creating a session mismatch. - let candidates: Vec<(LinkId, Vec)> = self - .connections - .iter() - .filter(|(_, conn)| { - conn.is_outbound() - && conn.handshake_state() == HandshakeState::SentMsg1 - && conn.resend_count() < max_resends - && conn.next_resend_at_ms() > 0 - && now_ms >= conn.next_resend_at_ms() - && !conn - .expected_identity() - .map(|id| self.peers.contains_key(id.node_addr())) - .unwrap_or(false) - }) - .filter_map(|(link_id, conn)| { - conn.handshake_msg1().map(|msg1| (*link_id, msg1.to_vec())) - }) - .collect(); + // The shell resolves the resend-candidate predicate and copies the + // opaque msg1 bytes; the core computes the backoff schedule. + let candidates = self.resend_candidates(now_ms, max_resends); + for action in self + .fmp + .poll_resends(candidates, now_ms, interval_ms, backoff) + { + let ConnAction::ResendMsg1 { + link, + bytes, + next_resend_at_ms, + } = action + else { + continue; + }; - for (link_id, msg1_bytes) in candidates { // Get transport and address info from the connection - let (transport_id, remote_addr) = match self.connections.get(&link_id) { + let (transport_id, remote_addr) = match self.connections.get(&link) { Some(conn) => match (conn.transport_id(), conn.source_addr()) { (Some(tid), Some(addr)) => (tid, addr.clone()), _ => continue, @@ -134,11 +181,11 @@ impl Node { // Send the stored msg1 let sent = if let Some(transport) = self.transports.get(&transport_id) { - match transport.send(&remote_addr, &msg1_bytes).await { + match transport.send(&remote_addr, &bytes).await { Ok(_) => true, Err(e) => { debug!( - link_id = %link_id, + link_id = %link, error = %e, "Handshake msg1 resend failed" ); @@ -149,13 +196,11 @@ impl Node { false }; - if sent && let Some(conn) = self.connections.get_mut(&link_id) { - let count = conn.resend_count() + 1; - let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64; - conn.record_resend(next); + if sent && let Some(conn) = self.connections.get_mut(&link) { + conn.record_resend(next_resend_at_ms); debug!( - link_id = %link_id, - resend = count, + link_id = %link, + resend = conn.resend_count(), "Resent handshake msg1" ); } diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 7077a6f..1e86fb8 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -10,7 +10,7 @@ use crate::discovery::{BootstrapHandoffResult, EstablishedTraversal}; use crate::node::acl::PeerAclContext; use crate::node::wire::build_msg1; use crate::peer::PeerConnection; -use crate::protocol::{Disconnect, DisconnectReason}; +use crate::proto::fmp::{Disconnect, DisconnectReason}; use crate::transport::{Link, LinkDirection, LinkId, TransportAddr, TransportId, packet_channel}; use crate::upper::tun::{TunDevice, TunState, run_tun_reader, shutdown_tun_interface}; use crate::{NodeAddr, PeerIdentity}; diff --git a/src/node/mod.rs b/src/node/mod.rs index 0cc10b5..b0535b3 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -58,8 +58,9 @@ use crate::cache::CoordCache; use crate::node::session::SessionEntry; use crate::peer::{ActivePeer, PeerConnection}; use crate::proto::discovery::{Discovery, DiscoveryBackoff, DiscoveryForwardRateLimiter}; +use crate::proto::fmp::Fmp; +use crate::proto::fmp::NodeProfile; use crate::proto::routing::{self, Router, RoutingErrorRateLimiter}; -use crate::protocol::NodeProfile; #[cfg(unix)] use crate::transport::ethernet::EthernetTransport; use crate::transport::nym::NymTransport; @@ -436,6 +437,9 @@ pub struct Node { icmp_rate_limiter: IcmpRateLimiter, /// Routing-subsystem state (routing error-signal rate limiter). routing: Router, + /// FMP connection-lifecycle decision anchor (stateless; drives the + /// tick-poll maintain/teardown decisions). + fmp: Fmp, /// Rate limiter for source-side CoordsRequired/PathBroken responses. coords_response_rate_limiter: RoutingErrorRateLimiter, @@ -681,6 +685,7 @@ impl Node { msg1_rate_limiter, icmp_rate_limiter: IcmpRateLimiter::new(), routing: Router::new(), + fmp: Fmp::new(), coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms( coords_response_interval_ms, ), @@ -842,6 +847,7 @@ impl Node { msg1_rate_limiter, icmp_rate_limiter: IcmpRateLimiter::new(), routing: Router::new(), + fmp: Fmp::new(), coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms( coords_response_interval_ms, ), diff --git a/src/node/retry.rs b/src/node/retry.rs index 720bed5..cfafe2d 100644 --- a/src/node/retry.rs +++ b/src/node/retry.rs @@ -8,6 +8,7 @@ use super::{Node, NodeError}; use crate::PeerIdentity; use crate::config::PeerConfig; use crate::identity::NodeAddr; +use crate::proto::fmp::backoff_ms; use tracing::{debug, info, warn}; // MAX_BACKOFF_MS is now derived from config: node.retry.max_backoff_secs * 1000 @@ -45,17 +46,6 @@ impl RetryState { expires_at_ms: None, } } - - /// Calculate the backoff delay in milliseconds for the current retry count. - /// - /// Uses exponential backoff: `base_interval_ms * 2^retry_count`, - /// capped at `MAX_BACKOFF_MS`. - pub fn backoff_ms(&self, base_interval_ms: u64, max_backoff_ms: u64) -> u64 { - let multiplier = 1u64.checked_shl(self.retry_count).unwrap_or(u64::MAX); - base_interval_ms - .saturating_mul(multiplier) - .min(max_backoff_ms) - } } impl Node { @@ -93,7 +83,7 @@ impl Node { self.retry_pending.remove(&node_addr); return; } - let delay = state.backoff_ms(base_interval_ms, max_backoff_ms); + let delay = backoff_ms(state.retry_count, base_interval_ms, max_backoff_ms); state.retry_after_ms = now_ms + delay; debug!( peer = %peer_name, @@ -118,7 +108,7 @@ impl Node { let mut state = RetryState::new(pc); state.retry_count = 1; state.reconnect = true; - let delay = state.backoff_ms(base_interval_ms, max_backoff_ms); + let delay = backoff_ms(state.retry_count, base_interval_ms, max_backoff_ms); state.retry_after_ms = now_ms + delay; debug!( peer = %self.peer_display_name(&node_addr), @@ -175,7 +165,7 @@ impl Node { if let Some(state) = self.retry_pending.get_mut(&node_addr) { state.reconnect = true; state.retry_count += 1; - let delay = state.backoff_ms(base_interval_ms, max_backoff_ms); + let delay = backoff_ms(state.retry_count, base_interval_ms, max_backoff_ms); state.retry_after_ms = now_ms + delay; debug!( peer = %peer_name, @@ -188,7 +178,7 @@ impl Node { let mut state = RetryState::new(pc); state.reconnect = true; - let delay = state.backoff_ms(base_interval_ms, max_backoff_ms); + let delay = backoff_ms(state.retry_count, base_interval_ms, max_backoff_ms); state.retry_after_ms = now_ms + delay; debug!( @@ -343,75 +333,3 @@ impl Node { } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::PeerConfig; - - const TEST_MAX_BACKOFF_MS: u64 = 300_000; - - #[test] - fn test_backoff_exponential() { - let state = RetryState { - peer_config: PeerConfig::default(), - retry_count: 0, - retry_after_ms: 0, - reconnect: false, - expires_at_ms: None, - }; - // base = 5000ms - assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 5000); // 5s * 2^0 - - let state = RetryState { - retry_count: 1, - ..state - }; - assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 10_000); // 5s * 2^1 - - let state = RetryState { - retry_count: 2, - ..state - }; - assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 20_000); // 5s * 2^2 - - let state = RetryState { - retry_count: 3, - ..state - }; - assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 40_000); // 5s * 2^3 - - let state = RetryState { - retry_count: 4, - ..state - }; - assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 80_000); // 5s * 2^4 - } - - #[test] - fn test_backoff_cap() { - let state = RetryState { - peer_config: PeerConfig::default(), - retry_count: 20, // 2^20 * 5000 would be huge - retry_after_ms: 0, - reconnect: false, - expires_at_ms: None, - }; - assert_eq!( - state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), - TEST_MAX_BACKOFF_MS - ); - } - - #[test] - fn test_backoff_zero_base() { - let state = RetryState { - peer_config: PeerConfig::default(), - retry_count: 3, - retry_after_ms: 0, - reconnect: false, - expires_at_ms: None, - }; - assert_eq!(state.backoff_ms(0, TEST_MAX_BACKOFF_MS), 0); - } -} diff --git a/src/node/tests/disconnect.rs b/src/node/tests/disconnect.rs index a82621c..b4ee3fa 100644 --- a/src/node/tests/disconnect.rs +++ b/src/node/tests/disconnect.rs @@ -6,7 +6,7 @@ use super::spanning_tree::*; use super::*; -use crate::protocol::{Disconnect, DisconnectReason}; +use crate::proto::fmp::{Disconnect, DisconnectReason}; /// 3-node chain: middle node disconnects one peer. /// @@ -296,7 +296,7 @@ async fn test_disconnect_clears_session() { ); // Node 0 sends Disconnect to node 1. - let disconnect = crate::protocol::Disconnect::new(DisconnectReason::Shutdown); + let disconnect = crate::proto::fmp::Disconnect::new(DisconnectReason::Shutdown); nodes[0] .node .send_encrypted_link_message(&node1_addr, &disconnect.encode()) diff --git a/src/node/tests/handshake.rs b/src/node/tests/handshake.rs index 5f5ff05..9b53641 100644 --- a/src/node/tests/handshake.rs +++ b/src/node/tests/handshake.rs @@ -998,8 +998,8 @@ async fn test_duplicate_msg2_dropped() { /// Helper: create two test nodes, set their profiles, attempt a handshake, /// and return whether they successfully peered. async fn attempt_profile_handshake( - profile_a: crate::protocol::NodeProfile, - profile_b: crate::protocol::NodeProfile, + profile_a: crate::proto::fmp::NodeProfile, + profile_b: crate::proto::fmp::NodeProfile, ) -> (usize, usize) { let mut nodes = vec![ make_test_node_with_profile(profile_a).await, @@ -1016,7 +1016,7 @@ async fn attempt_profile_handshake( #[tokio::test] async fn test_nonrouting_nonrouting_rejected() { - use crate::protocol::NodeProfile; + use crate::proto::fmp::NodeProfile; let (a, b) = attempt_profile_handshake(NodeProfile::NonRouting, NodeProfile::NonRouting).await; assert_eq!(a, 0, "NonRouting↔NonRouting should reject: node A"); assert_eq!(b, 0, "NonRouting↔NonRouting should reject: node B"); @@ -1024,7 +1024,7 @@ async fn test_nonrouting_nonrouting_rejected() { #[tokio::test] async fn test_leaf_leaf_rejected() { - use crate::protocol::NodeProfile; + use crate::proto::fmp::NodeProfile; let (a, b) = attempt_profile_handshake(NodeProfile::Leaf, NodeProfile::Leaf).await; assert_eq!(a, 0, "Leaf↔Leaf should reject: node A"); assert_eq!(b, 0, "Leaf↔Leaf should reject: node B"); @@ -1032,7 +1032,7 @@ async fn test_leaf_leaf_rejected() { #[tokio::test] async fn test_nonrouting_leaf_rejected() { - use crate::protocol::NodeProfile; + use crate::proto::fmp::NodeProfile; let (a, b) = attempt_profile_handshake(NodeProfile::NonRouting, NodeProfile::Leaf).await; assert_eq!(a, 0, "NonRouting↔Leaf should reject: node A"); assert_eq!(b, 0, "NonRouting↔Leaf should reject: node B"); @@ -1040,7 +1040,7 @@ async fn test_nonrouting_leaf_rejected() { #[tokio::test] async fn test_leaf_nonrouting_rejected() { - use crate::protocol::NodeProfile; + use crate::proto::fmp::NodeProfile; let (a, b) = attempt_profile_handshake(NodeProfile::Leaf, NodeProfile::NonRouting).await; assert_eq!(a, 0, "Leaf↔NonRouting should reject: node A"); assert_eq!(b, 0, "Leaf↔NonRouting should reject: node B"); @@ -1048,7 +1048,7 @@ async fn test_leaf_nonrouting_rejected() { #[tokio::test] async fn test_full_nonrouting_accepted() { - use crate::protocol::NodeProfile; + use crate::proto::fmp::NodeProfile; let (a, b) = attempt_profile_handshake(NodeProfile::Full, NodeProfile::NonRouting).await; assert_eq!(a, 1, "Full↔NonRouting should accept: node A"); assert_eq!(b, 1, "Full↔NonRouting should accept: node B"); @@ -1056,7 +1056,7 @@ async fn test_full_nonrouting_accepted() { #[tokio::test] async fn test_full_leaf_accepted() { - use crate::protocol::NodeProfile; + use crate::proto::fmp::NodeProfile; let (a, b) = attempt_profile_handshake(NodeProfile::Full, NodeProfile::Leaf).await; assert_eq!(a, 1, "Full↔Leaf should accept: node A"); assert_eq!(b, 1, "Full↔Leaf should accept: node B"); diff --git a/src/node/tests/routing.rs b/src/node/tests/routing.rs index bfa4b0b..9425a79 100644 --- a/src/node/tests/routing.rs +++ b/src/node/tests/routing.rs @@ -809,7 +809,7 @@ async fn test_routing_reachability_100_nodes() { /// Node 0 should no longer be able to route to node 3. #[tokio::test] async fn test_routing_stops_after_peer_removal() { - use crate::protocol::{Disconnect, DisconnectReason}; + use crate::proto::fmp::{Disconnect, DisconnectReason}; let edges = vec![(0, 1), (1, 2), (2, 3)]; let mut nodes = run_tree_test(4, &edges, false).await; diff --git a/src/node/tests/spanning_tree.rs b/src/node/tests/spanning_tree.rs index 47b66de..10877da 100644 --- a/src/node/tests/spanning_tree.rs +++ b/src/node/tests/spanning_tree.rs @@ -84,8 +84,10 @@ pub(super) async fn make_test_node_with_mtu(mtu: u16) -> TestNode { /// Create a test node with a specific routing profile. Profile is immutable /// (lives in the shared context), so it is set via the `Config` flags that /// `Config::node_profile()` reads rather than poked post-construction. -pub(super) async fn make_test_node_with_profile(profile: crate::protocol::NodeProfile) -> TestNode { - use crate::protocol::NodeProfile; +pub(super) async fn make_test_node_with_profile( + profile: crate::proto::fmp::NodeProfile, +) -> TestNode { + use crate::proto::fmp::NodeProfile; let mut config = Config::new(); match profile { NodeProfile::Leaf => config.node.leaf_only = true, diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index 86f29dc..a750787 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -1263,7 +1263,7 @@ fn test_schedule_reconnect_preserves_backoff() { // With count=3, backoff should be 5s * 2^3 = 40s. let base_ms = node.config().node.retry.base_interval_secs * 1000; let max_ms = node.config().node.retry.max_backoff_secs * 1000; - let expected_delay = state.backoff_ms(base_ms, max_ms); + let expected_delay = crate::proto::fmp::backoff_ms(state.retry_count, base_ms, max_ms); assert_eq!( state.retry_after_ms, 31_000 + expected_delay, @@ -1299,7 +1299,7 @@ fn test_schedule_reconnect_fresh_state() { // Base delay: 5s * 2^0 = 5s let base_ms = node.config().node.retry.base_interval_secs * 1000; let max_ms = node.config().node.retry.max_backoff_secs * 1000; - let expected_delay = state.backoff_ms(base_ms, max_ms); + let expected_delay = crate::proto::fmp::backoff_ms(state.retry_count, base_ms, max_ms); assert_eq!(state.retry_after_ms, 1_000 + expected_delay); } @@ -1311,7 +1311,7 @@ fn test_schedule_reconnect_fresh_state() { /// decrypt failure, peer restart) all schedule reconnect. #[test] fn test_disconnect_schedules_reconnect() { - use crate::protocol::{Disconnect, DisconnectReason}; + use crate::proto::fmp::{Disconnect, DisconnectReason}; let peer_identity = Identity::generate(); let peer_npub = peer_identity.npub(); diff --git a/src/node/tree.rs b/src/node/tree.rs index e418d51..1216bdd 100644 --- a/src/node/tree.rs +++ b/src/node/tree.rs @@ -37,7 +37,7 @@ impl Node { &mut self, peer_addr: &NodeAddr, ) -> Result<(), NodeError> { - if self.node_profile() == crate::protocol::NodeProfile::Leaf { + if self.node_profile() == crate::proto::fmp::NodeProfile::Leaf { return Ok(()); } let now_ms = std::time::SystemTime::now() diff --git a/src/peer/active.rs b/src/peer/active.rs index 77e4bd3..6f846d4 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -7,7 +7,7 @@ use crate::bloom::BloomFilter; use crate::mmp::{MmpConfig, MmpPeerState}; use crate::node::REKEY_JITTER_SECS; use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession}; -use crate::protocol::{NegotiationPayload, NodeProfile}; +use crate::proto::fmp::{NegotiationPayload, NodeProfile}; use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId}; use crate::tree::{ParentDeclaration, TreeCoordinate}; use crate::utils::index::SessionIndex; diff --git a/src/peer/connection.rs b/src/peer/connection.rs index d458c8c..25c07df 100644 --- a/src/peer/connection.rs +++ b/src/peer/connection.rs @@ -1,142 +1,42 @@ //! Peer Connection (Handshake Phase) //! //! Represents an in-progress connection before authentication completes. -//! PeerConnection tracks the Noise XX handshake state and transitions to +//! PeerConnection tracks the Noise IK handshake state and transitions to //! ActivePeer upon successful authentication. use crate::PeerIdentity; use crate::noise::{self, NoiseError, NoiseSession}; -use crate::protocol::NodeProfile; +use crate::proto::fmp::{ConnectionState, NodeProfile}; use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId}; use crate::utils::index::SessionIndex; use secp256k1::Keypair; use std::fmt; -/// Handshake protocol state machine. -/// -/// For Noise XX pattern: -/// - Initiator: Initial → SentMsg1 → Complete (after processing msg2 + sending msg3) -/// - Responder: Initial → ReceivedMsg1 → Complete (after processing msg3) -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HandshakeState { - /// Initial state, ready to start handshake. - Initial, - /// Initiator: Sent message 1, awaiting message 2. - SentMsg1, - /// Responder: Received message 1, ready to send message 2. - ReceivedMsg1, - /// Handshake completed successfully. - Complete, - /// Handshake failed. - Failed, -} - -impl HandshakeState { - /// Check if handshake is still in progress. - pub fn is_in_progress(&self) -> bool { - matches!( - self, - HandshakeState::Initial | HandshakeState::SentMsg1 | HandshakeState::ReceivedMsg1 - ) - } - - /// Check if handshake completed successfully. - pub fn is_complete(&self) -> bool { - matches!(self, HandshakeState::Complete) - } - - /// Check if handshake failed. - pub fn is_failed(&self) -> bool { - matches!(self, HandshakeState::Failed) - } -} - -impl fmt::Display for HandshakeState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - HandshakeState::Initial => "initial", - HandshakeState::SentMsg1 => "sent_msg1", - HandshakeState::ReceivedMsg1 => "received_msg1", - HandshakeState::Complete => "complete", - HandshakeState::Failed => "failed", - }; - write!(f, "{}", s) - } -} +// The pure handshake-phase bookkeeping (`ConnectionState`) and its +// `HandshakeState` phase enum now live in `proto::fmp::state`. Re-export +// `HandshakeState` here so its original public path +// (`crate::peer::HandshakeState`) is preserved for existing call sites. +pub use crate::proto::fmp::HandshakeState; /// A connection in the handshake phase, before authentication completes. /// /// For outbound connections, we know the expected peer identity from config. /// For inbound connections, we learn the identity during the Noise handshake. +/// +/// This is the shell holder for the FMP crypto/state split: the pure +/// connection bookkeeping lives in [`ConnectionState`] (`proto::fmp::state`), +/// and the two Noise crypto handles stay here beside it. Pure public methods +/// delegate to `self.state`; the XX transition methods drive the crypto and +/// write results back through `self.state`'s setters. pub struct PeerConnection { - // === Link Reference === - /// The link carrying this connection. - link_id: LinkId, - - /// Connection direction (we initiated or they initiated). - direction: LinkDirection, - - // === Handshake State === - /// Current handshake state. - handshake_state: HandshakeState, - - /// Expected peer identity (known for outbound, learned for inbound). - /// Updated after receiving their static key in the handshake. - expected_identity: Option, + /// Pure, runtime-agnostic connection bookkeeping. + state: ConnectionState, /// Noise handshake state (consumes on completion). noise_handshake: Option, /// Completed Noise session (available after handshake complete). noise_session: Option, - - // === Timing === - /// When the connection attempt started (Unix milliseconds). - started_at: u64, - - /// When the last handshake message was sent/received. - last_activity: u64, - - // === Statistics === - /// Link statistics during handshake. - link_stats: LinkStats, - - // === Wire Protocol Index Tracking === - /// Our sender_idx for this handshake (chosen by us). - /// For outbound: included in msg1, used as receiver_idx in msg2 echo. - /// For inbound: chosen after processing msg1, included in msg2. - our_index: Option, - - /// Their sender_idx (learned from their messages). - /// For outbound: learned from msg2. - /// For inbound: learned from msg1. - their_index: Option, - - /// Transport ID (for index namespace). - transport_id: Option, - - /// Current source address (updated on packet receipt). - source_addr: Option, - - // === Epoch (Restart Detection) === - /// 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, - // === Handshake Resend === - /// Wire-format msg1 bytes for resend (initiator only). - handshake_msg1: Option>, - - /// Wire-format msg2 bytes for resend (responder only). - handshake_msg2: Option>, - - /// Number of resends performed so far. - resend_count: u32, - - /// When the next resend should fire (Unix ms). 0 = no resend scheduled. - next_resend_at_ms: u64, } impl PeerConnection { @@ -150,58 +50,22 @@ impl PeerConnection { current_time_ms: u64, ) -> Self { Self { - link_id, - direction: LinkDirection::Outbound, - handshake_state: HandshakeState::Initial, - expected_identity: Some(expected_identity), + state: ConnectionState::outbound(link_id, expected_identity, current_time_ms), noise_handshake: None, noise_session: None, - started_at: current_time_ms, - last_activity: current_time_ms, - - link_stats: LinkStats::new(), - our_index: None, - their_index: None, - transport_id: None, - source_addr: None, - remote_epoch: None, - peer_profile: None, - - handshake_msg1: None, - handshake_msg2: None, - resend_count: 0, - next_resend_at_ms: 0, } } - /// Create a new outbound connection without pre-known identity. + /// Create a new outbound connection without a pre-known identity. /// /// Used for anonymous discovery on shared-media transports (Ethernet, - /// BLE) where the beacon doesn't carry identity. The peer's identity - /// is learned from XX msg2 during the handshake. + /// BLE) where the beacon doesn't carry identity. The peer's identity is + /// learned from XX msg2 during the handshake. pub fn outbound_anonymous(link_id: LinkId, current_time_ms: u64) -> Self { Self { - link_id, - direction: LinkDirection::Outbound, - handshake_state: HandshakeState::Initial, - expected_identity: None, + state: ConnectionState::outbound_anonymous(link_id, current_time_ms), noise_handshake: None, noise_session: None, - started_at: current_time_ms, - last_activity: current_time_ms, - - link_stats: LinkStats::new(), - our_index: None, - their_index: None, - transport_id: None, - source_addr: None, - remote_epoch: None, - peer_profile: None, - - handshake_msg1: None, - handshake_msg2: None, - resend_count: 0, - next_resend_at_ms: 0, } } @@ -211,27 +75,9 @@ impl PeerConnection { /// identity from Noise message 1. pub fn inbound(link_id: LinkId, current_time_ms: u64) -> Self { Self { - link_id, - direction: LinkDirection::Inbound, - handshake_state: HandshakeState::Initial, - expected_identity: None, + state: ConnectionState::inbound(link_id, current_time_ms), noise_handshake: None, noise_session: None, - started_at: current_time_ms, - last_activity: current_time_ms, - - link_stats: LinkStats::new(), - our_index: None, - their_index: None, - transport_id: None, - source_addr: None, - remote_epoch: None, - peer_profile: None, - - handshake_msg1: None, - handshake_msg2: None, - resend_count: 0, - next_resend_at_ms: 0, } } @@ -245,206 +91,190 @@ impl PeerConnection { current_time_ms: u64, ) -> Self { Self { - link_id, - direction: LinkDirection::Inbound, - handshake_state: HandshakeState::Initial, - expected_identity: None, + state: ConnectionState::inbound_with_transport( + link_id, + transport_id, + source_addr, + current_time_ms, + ), noise_handshake: None, noise_session: None, - started_at: current_time_ms, - last_activity: current_time_ms, - - link_stats: LinkStats::new(), - our_index: None, - their_index: None, - transport_id: Some(transport_id), - source_addr: Some(source_addr), - remote_epoch: None, - peer_profile: None, - - handshake_msg1: None, - handshake_msg2: None, - resend_count: 0, - next_resend_at_ms: 0, } } - // === Accessors === + // === Accessors (delegated to the pure ConnectionState) === /// Get the link ID. pub fn link_id(&self) -> LinkId { - self.link_id + self.state.link_id() } /// Get the connection direction. pub fn direction(&self) -> LinkDirection { - self.direction + self.state.direction() } /// Get the handshake state. pub fn handshake_state(&self) -> HandshakeState { - self.handshake_state + self.state.handshake_state() } /// Get the expected/learned peer identity, if known. pub fn expected_identity(&self) -> Option<&PeerIdentity> { - self.expected_identity.as_ref() + self.state.expected_identity() } /// Check if this is an outbound connection. pub fn is_outbound(&self) -> bool { - self.direction == LinkDirection::Outbound + self.state.is_outbound() } /// Check if this is an inbound connection. pub fn is_inbound(&self) -> bool { - self.direction == LinkDirection::Inbound + self.state.is_inbound() } /// Check if handshake is in progress. pub fn is_in_progress(&self) -> bool { - self.handshake_state.is_in_progress() + self.state.is_in_progress() } /// Check if handshake completed. pub fn is_complete(&self) -> bool { - self.handshake_state.is_complete() + self.state.is_complete() } /// Check if handshake failed. pub fn is_failed(&self) -> bool { - self.handshake_state.is_failed() + self.state.is_failed() } /// When the connection started. pub fn started_at(&self) -> u64 { - self.started_at + self.state.started_at() } /// When the last activity occurred. pub fn last_activity(&self) -> u64 { - self.last_activity + self.state.last_activity() } /// Connection duration so far. pub fn duration(&self, current_time_ms: u64) -> u64 { - current_time_ms.saturating_sub(self.started_at) + self.state.duration(current_time_ms) } /// Time since last activity. pub fn idle_time(&self, current_time_ms: u64) -> u64 { - current_time_ms.saturating_sub(self.last_activity) + self.state.idle_time(current_time_ms) } /// Get link statistics. pub fn link_stats(&self) -> &LinkStats { - &self.link_stats + self.state.link_stats() } /// Get mutable link statistics. pub fn link_stats_mut(&mut self) -> &mut LinkStats { - &mut self.link_stats + self.state.link_stats_mut() } // === Index Accessors === /// Get our session index (if set). pub fn our_index(&self) -> Option { - self.our_index + self.state.our_index() } /// Set our session index. pub fn set_our_index(&mut self, index: SessionIndex) { - self.our_index = Some(index); + self.state.set_our_index(index); } /// Get their session index (if known). pub fn their_index(&self) -> Option { - self.their_index + self.state.their_index() } /// Set their session index. pub fn set_their_index(&mut self, index: SessionIndex) { - self.their_index = Some(index); + self.state.set_their_index(index); } /// Get the transport ID (if set). pub fn transport_id(&self) -> Option { - self.transport_id + self.state.transport_id() } /// Set the transport ID. pub fn set_transport_id(&mut self, id: TransportId) { - self.transport_id = Some(id); + self.state.set_transport_id(id); } /// Get the source address (if known). pub fn source_addr(&self) -> Option<&TransportAddr> { - self.source_addr.as_ref() + self.state.source_addr() } /// Set the source address. pub fn set_source_addr(&mut self, addr: TransportAddr) { - self.source_addr = Some(addr); + self.state.set_source_addr(addr); } // === Epoch Accessors === /// Get the remote peer's startup epoch (available after handshake). pub fn remote_epoch(&self) -> Option<[u8; 8]> { - self.remote_epoch + self.state.remote_epoch() } // === Negotiation Results === - /// Get peer's negotiated node profile. + /// Get the peer's negotiated node profile, if learned. pub fn peer_profile(&self) -> Option { - self.peer_profile + self.state.peer_profile() } - /// Store negotiation results from peer's payload. + /// Record the peer's node profile learned during FMP negotiation. pub fn set_negotiation_results(&mut self, peer_profile: NodeProfile) { - self.peer_profile = Some(peer_profile); + self.state.set_negotiation_results(peer_profile); } // === Handshake Resend === /// Store the wire-format msg1 bytes for resend and schedule the first resend. pub fn set_handshake_msg1(&mut self, msg1: Vec, first_resend_at_ms: u64) { - self.handshake_msg1 = Some(msg1); - self.resend_count = 0; - self.next_resend_at_ms = first_resend_at_ms; + self.state.set_handshake_msg1(msg1, first_resend_at_ms); } /// Store the wire-format msg2 bytes for resend on duplicate msg1. pub fn set_handshake_msg2(&mut self, msg2: Vec) { - self.handshake_msg2 = Some(msg2); + self.state.set_handshake_msg2(msg2); } /// Get the stored msg1 bytes (if any). pub fn handshake_msg1(&self) -> Option<&[u8]> { - self.handshake_msg1.as_deref() + self.state.handshake_msg1() } /// Get the stored msg2 bytes (if any). pub fn handshake_msg2(&self) -> Option<&[u8]> { - self.handshake_msg2.as_deref() + self.state.handshake_msg2() } /// Number of resends performed. pub fn resend_count(&self) -> u32 { - self.resend_count + self.state.resend_count() } /// When the next resend is scheduled (Unix ms). pub fn next_resend_at_ms(&self) -> u64 { - self.next_resend_at_ms + self.state.next_resend_at_ms() } /// Record a resend and schedule the next one. pub fn record_resend(&mut self, next_resend_at_ms: u64) { - self.resend_count += 1; - self.next_resend_at_ms = next_resend_at_ms; + self.state.record_resend(next_resend_at_ms); } // === Noise Handshake Operations === @@ -459,17 +289,17 @@ impl PeerConnection { epoch: [u8; 8], current_time_ms: u64, ) -> Result, NoiseError> { - if self.direction != LinkDirection::Outbound { + if self.state.direction() != LinkDirection::Outbound { return Err(NoiseError::WrongState { expected: "outbound connection".to_string(), got: "inbound connection".to_string(), }); } - if self.handshake_state != HandshakeState::Initial { + if self.state.handshake_state() != HandshakeState::Initial { return Err(NoiseError::WrongState { expected: "initial state".to_string(), - got: self.handshake_state.to_string(), + got: self.state.handshake_state().to_string(), }); } @@ -479,8 +309,8 @@ impl PeerConnection { let msg1 = hs.write_message_1()?; self.noise_handshake = Some(hs); - self.handshake_state = HandshakeState::SentMsg1; - self.last_activity = current_time_ms; + self.state.set_handshake_state(HandshakeState::SentMsg1); + self.state.touch(current_time_ms); Ok(msg1) } @@ -502,17 +332,17 @@ impl PeerConnection { negotiation_payload: Option<&[u8]>, current_time_ms: u64, ) -> Result, NoiseError> { - if self.direction != LinkDirection::Inbound { + if self.state.direction() != LinkDirection::Inbound { return Err(NoiseError::WrongState { expected: "inbound connection".to_string(), got: "outbound connection".to_string(), }); } - if self.handshake_state != HandshakeState::Initial { + if self.state.handshake_state() != HandshakeState::Initial { return Err(NoiseError::WrongState { expected: "initial state".to_string(), - got: self.handshake_state.to_string(), + got: self.state.handshake_state().to_string(), }); } @@ -534,8 +364,8 @@ impl PeerConnection { // XX: handshake NOT complete yet — need msg3. // Keep the handshake state for complete_handshake_msg3(). self.noise_handshake = Some(hs); - self.handshake_state = HandshakeState::ReceivedMsg1; - self.last_activity = current_time_ms; + self.state.set_handshake_state(HandshakeState::ReceivedMsg1); + self.state.touch(current_time_ms); Ok(msg2) } @@ -555,10 +385,10 @@ impl PeerConnection { negotiation_payload: Option<&[u8]>, current_time_ms: u64, ) -> Result<(Vec, Option>), NoiseError> { - if self.handshake_state != HandshakeState::SentMsg1 { + if self.state.handshake_state() != HandshakeState::SentMsg1 { return Err(NoiseError::WrongState { expected: "sent_msg1 state".to_string(), - got: self.handshake_state.to_string(), + got: self.state.handshake_state().to_string(), }); } @@ -589,10 +419,11 @@ impl PeerConnection { let remote_static = *hs .remote_static() .expect("remote static available after XX msg2"); - self.expected_identity = Some(PeerIdentity::from_pubkey_full(remote_static)); + self.state + .set_expected_identity(PeerIdentity::from_pubkey_full(remote_static)); // Capture remote epoch from msg2 - self.remote_epoch = hs.remote_epoch(); + self.state.set_remote_epoch(hs.remote_epoch()); // Generate XX msg3 let mut msg3 = hs.write_message_3()?; @@ -606,8 +437,8 @@ impl PeerConnection { // Handshake complete for initiator let session = hs.into_session()?; self.noise_session = Some(session); - self.handshake_state = HandshakeState::Complete; - self.last_activity = current_time_ms; + self.state.set_handshake_state(HandshakeState::Complete); + self.state.touch(current_time_ms); Ok((msg3, received_negotiation)) } @@ -624,10 +455,10 @@ impl PeerConnection { message: &[u8], current_time_ms: u64, ) -> Result>, NoiseError> { - if self.handshake_state != HandshakeState::ReceivedMsg1 { + if self.state.handshake_state() != HandshakeState::ReceivedMsg1 { return Err(NoiseError::WrongState { expected: "received_msg1 state".to_string(), - got: self.handshake_state.to_string(), + got: self.state.handshake_state().to_string(), }); } @@ -658,16 +489,17 @@ impl PeerConnection { let remote_static = *hs .remote_static() .expect("remote static available after XX msg3"); - self.expected_identity = Some(PeerIdentity::from_pubkey_full(remote_static)); + self.state + .set_expected_identity(PeerIdentity::from_pubkey_full(remote_static)); // Capture remote epoch from msg3 - self.remote_epoch = hs.remote_epoch(); + self.state.set_remote_epoch(hs.remote_epoch()); // Handshake complete for responder let session = hs.into_session()?; self.noise_session = Some(session); - self.handshake_state = HandshakeState::Complete; - self.last_activity = current_time_ms; + self.state.set_handshake_state(HandshakeState::Complete); + self.state.touch(current_time_ms); Ok(received_negotiation) } @@ -677,7 +509,7 @@ impl PeerConnection { /// Returns the NoiseSession for use in ActivePeer. Can only be called /// once after handshake completes. pub fn take_session(&mut self) -> Option { - if self.handshake_state == HandshakeState::Complete { + if self.state.handshake_state() == HandshakeState::Complete { self.noise_session.take() } else { None @@ -686,44 +518,45 @@ impl PeerConnection { /// Check if we have a completed session ready to take. pub fn has_session(&self) -> bool { - self.handshake_state == HandshakeState::Complete && self.noise_session.is_some() + self.state.handshake_state() == HandshakeState::Complete && self.noise_session.is_some() } // === State Transitions (for manual control if needed) === - /// Mark handshake as failed. + /// Mark handshake as failed. Sets the pure lifecycle state and drops the + /// shell-owned crypto handshake handle. pub fn mark_failed(&mut self) { - self.handshake_state = HandshakeState::Failed; + self.state.mark_failed(); self.noise_handshake = None; } /// Update last activity timestamp. pub fn touch(&mut self, current_time_ms: u64) { - self.last_activity = current_time_ms; + self.state.touch(current_time_ms); } // === Validation === /// Check if the connection has timed out. pub fn is_timed_out(&self, current_time_ms: u64, timeout_ms: u64) -> bool { - self.idle_time(current_time_ms) > timeout_ms + self.state.is_timed_out(current_time_ms, timeout_ms) } } impl fmt::Debug for PeerConnection { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("PeerConnection") - .field("link_id", &self.link_id) - .field("direction", &self.direction) - .field("handshake_state", &self.handshake_state) - .field("expected_identity", &self.expected_identity) + .field("link_id", &self.state.link_id()) + .field("direction", &self.state.direction()) + .field("handshake_state", &self.state.handshake_state()) + .field("expected_identity", &self.state.expected_identity()) .field("has_noise_handshake", &self.noise_handshake.is_some()) .field("has_noise_session", &self.noise_session.is_some()) - .field("our_index", &self.our_index) - .field("their_index", &self.their_index) - .field("transport_id", &self.transport_id) - .field("started_at", &self.started_at) - .field("last_activity", &self.last_activity) + .field("our_index", &self.state.our_index()) + .field("their_index", &self.state.their_index()) + .field("transport_id", &self.state.transport_id()) + .field("started_at", &self.state.started_at()) + .field("last_activity", &self.state.last_activity()) .finish() } } diff --git a/src/peer/mod.rs b/src/peer/mod.rs index 57688f8..77dd871 100644 --- a/src/peer/mod.rs +++ b/src/peer/mod.rs @@ -119,35 +119,6 @@ impl PromotionResult { } } -/// Determine winner of cross-connection tie-breaker. -/// -/// Rule: The node with the smaller node_addr prefers its OUTBOUND connection. -/// This is deterministic and symmetric: both nodes will reach the same conclusion. -/// -/// # Arguments -/// * `our_node_addr` - Our node's ID -/// * `their_node_addr` - The peer's node ID -/// * `this_is_outbound` - Whether the connection being evaluated is our outbound -/// -/// # Returns -/// `true` if this connection should win (survive), `false` if it should close. -pub fn cross_connection_winner( - our_node_addr: &NodeAddr, - their_node_addr: &NodeAddr, - this_is_outbound: bool, -) -> bool { - let we_are_smaller = our_node_addr < their_node_addr; - - // Smaller node's outbound wins - // If we're smaller: our outbound wins, our inbound loses - // If they're smaller: our outbound loses, our inbound wins - if we_are_smaller { - this_is_outbound - } else { - !this_is_outbound - } -} - // ============================================================================ // PeerSlot // ============================================================================ @@ -272,50 +243,11 @@ mod tests { use crate::transport::LinkId; use crate::{Identity, PeerIdentity}; - fn make_node_addr(val: u8) -> NodeAddr { - let mut bytes = [0u8; 16]; - bytes[0] = val; - NodeAddr::from_bytes(bytes) - } - fn make_peer_identity() -> PeerIdentity { let identity = Identity::generate(); PeerIdentity::from_pubkey(identity.pubkey()) } - #[test] - fn test_cross_connection_smaller_node_wins_outbound() { - let node_a = make_node_addr(1); // smaller - let node_b = make_node_addr(2); // larger - - // Node A's perspective - assert!(cross_connection_winner(&node_a, &node_b, true)); // A's outbound wins - assert!(!cross_connection_winner(&node_a, &node_b, false)); // A's inbound loses - - // Node B's perspective - assert!(!cross_connection_winner(&node_b, &node_a, true)); // B's outbound loses - assert!(cross_connection_winner(&node_b, &node_a, false)); // B's inbound wins - } - - #[test] - fn test_cross_connection_symmetric() { - let node_a = make_node_addr(1); - let node_b = make_node_addr(2); - - // A's outbound = B's inbound - let a_outbound_wins = cross_connection_winner(&node_a, &node_b, true); - let b_inbound_wins = cross_connection_winner(&node_b, &node_a, false); - assert_eq!(a_outbound_wins, b_inbound_wins); - - // A's inbound = B's outbound - let a_inbound_wins = cross_connection_winner(&node_a, &node_b, false); - let b_outbound_wins = cross_connection_winner(&node_b, &node_a, true); - assert_eq!(a_inbound_wins, b_outbound_wins); - - // Exactly one survives - assert!(a_outbound_wins != a_inbound_wins); - } - #[test] fn test_peer_slot_connecting() { let identity = make_peer_identity(); diff --git a/src/proto/discovery/wire.rs b/src/proto/discovery/wire.rs index b732938..6fc40c5 100644 --- a/src/proto/discovery/wire.rs +++ b/src/proto/discovery/wire.rs @@ -1,8 +1,8 @@ //! Discovery messages: LookupRequest and LookupResponse. use crate::NodeAddr; +use crate::proto::fmp::TlvEntry; use crate::protocol::ProtocolError; -use crate::protocol::TlvEntry; use crate::protocol::session::{decode_coords, encode_coords}; use crate::tree::TreeCoordinate; use secp256k1::schnorr::Signature; diff --git a/src/proto/fmp/core.rs b/src/proto/fmp/core.rs new file mode 100644 index 0000000..bf13b73 --- /dev/null +++ b/src/proto/fmp/core.rs @@ -0,0 +1,701 @@ +//! Sans-IO FMP connection-lifecycle decision core. +//! +//! Pure, runtime-agnostic maintain/teardown decisions for the FMP peer +//! connection lifecycle: handshake-connection timeout/teardown and outbound +//! msg1 resend scheduling. The async I/O adapters in `node::handlers::timeout` +//! build a [`LifecycleView`] over live node state (pre-computing every clock +//! read into plain `u64`/`bool` snapshot fields), call the `poll_*` decisions, +//! and drive the returned [`ConnAction`]s — the actual sends, registry +//! mutations, metrics, and logging. No I/O, no clock, no metrics, no logging +//! here. +//! +//! The establish leaf's Noise wire construction and `promote_connection` +//! effects stay shell-side; handshake message bytes are carried as opaque blobs +//! only. The XX **inbound classification** decision, however, is modelled here: +//! [`Fmp::establish_inbound`] maps an [`EstablishSnapshot`] + [`WireOutcome`] +//! onto an [`InboundDecision`] the shell dispatches for `handle_msg3`. The +//! born-on-next Noise leaf (`handle_msg{1,2,3}` crypto) remains shell-side. + +use super::state::Fmp; +use super::wire::{ + FMP_FEAT_PROFILE_MASK, FMP_FEAT_PROVIDES_RR, FMP_FEAT_PROVIDES_SR, FMP_FEAT_WANTS_RR, + FMP_FEAT_WANTS_SR, NegotiationPayload, NodeProfile, +}; +use crate::NodeAddr; +use crate::protocol::ProtocolError; +use crate::transport::LinkId; + +/// Determine winner of cross-connection tie-breaker. +/// +/// Rule: The node with the smaller node_addr prefers its OUTBOUND connection. +/// This is deterministic and symmetric: both nodes will reach the same conclusion. +/// +/// # Arguments +/// * `our_node_addr` - Our node's ID +/// * `their_node_addr` - The peer's node ID +/// * `this_is_outbound` - Whether the connection being evaluated is our outbound +/// +/// # Returns +/// `true` if this connection should win (survive), `false` if it should close. +pub fn cross_connection_winner( + our_node_addr: &NodeAddr, + their_node_addr: &NodeAddr, + this_is_outbound: bool, +) -> bool { + let we_are_smaller = our_node_addr < their_node_addr; + + // Smaller node's outbound wins + // If we're smaller: our outbound wins, our inbound loses + // If they're smaller: our outbound loses, our inbound wins + if we_are_smaller { + this_is_outbound + } else { + !this_is_outbound + } +} + +/// A snapshot of one handshake connection's lifecycle-relevant state, taken by +/// the shell so the core decides without touching live `Node` state or reading +/// a clock. +/// +/// Produced by the [`LifecycleView`] read-seam. Each `poll_*` decision only +/// reads the subset of fields relevant to it; the producing view method leaves +/// the rest at their defaults. +pub(crate) struct ConnSnapshot { + /// The connection's link identifier (teardown/resend target). + pub link: LinkId, + /// Teardown path: is this an outbound connection? Drives retry scheduling + /// (only outbound auto-connect peers are retried). + pub is_outbound: bool, + /// Teardown path: the retry target learned from the connection's expected + /// identity, if any. `None` when no identity is known. + pub retry_addr: Option, + /// Resend path: prior msg1 resend count. Drives the backoff exponent. + pub resend_count: u32, + /// Resend path: the stored outbound handshake msg1 wire bytes (an opaque + /// blob — the core never parses or constructs a Noise message). Empty on + /// the teardown path, which never reads it. + pub msg1: Vec, +} + +/// A snapshot of one active peer's rekey-relevant state, taken by the shell. +/// +/// Every clock read is resolved shell-side into a plain `u64`/`bool` before the +/// snapshot reaches the core: `elapsed_secs` is the monotonic session age, and +/// `drain_expired`/`is_dampened` are the pre-evaluated timer predicates. The +/// core applies the rekey thresholds and jitter with **no** clock read — the +/// deliberate master-side asymmetry with discovery (monotonic ages, not an +/// absolute `now_ms`), so the rekey timing stays behavior-identical under a +/// clock step. +pub(crate) struct PeerSnapshot { + /// The peer's node address (cutover/drain/rekey target). + pub addr: NodeAddr, + /// A pending post-rekey session is ready to cut over to. + pub has_pending: bool, + /// A rekey handshake is currently in flight. + pub rekey_in_progress: bool, + /// The peer is in its post-cutover drain window. + pub is_draining: bool, + /// The drain window has expired (pre-evaluated against the drain timer). + pub drain_expired: bool, + /// Local rekey initiation is dampened after a recently received peer rekey + /// msg1 (pre-evaluated against the dampening timer). + pub is_dampened: bool, + /// The initiator already cut over on its own timer but is still + /// retransmitting this cycle's rekey msg3 to a responder not yet confirmed + /// on the new epoch. Suppresses starting a fresh rekey (which would + /// overwrite the retained payload) until the msg3 is delivered or its + /// budget is exhausted. Mirrors FSP `check_session_rekey`. + pub rekey_msg3_pending: bool, + /// Monotonic session age in seconds (`session_established_at().elapsed()`). + pub elapsed_secs: u64, + /// Current Noise send counter (0 when there is no session). + pub counter: u64, + /// Per-session symmetric rekey jitter, added to the time threshold. + pub jitter_secs: i64, +} + +/// A snapshot of one peer with a rekey handshake in flight, taken by the shell +/// for the rekey-msg1 retransmission decision. +pub(crate) struct RekeyResendSnapshot { + /// The peer's node address (abandon/resend target). + pub peer: NodeAddr, + /// How many rekey-msg1 retransmissions have already happened. Drives both + /// the abandon-vs-resend classification and the backoff exponent. + pub resend_count: u32, + /// The stored rekey msg1 is due for retransmission as of the shell's + /// `now_ms` (pre-evaluated against the resend timer). + pub needs_resend: bool, + /// The stored rekey msg1 wire bytes (an opaque blob). + pub msg1: Vec, +} + +/// The rekey trigger thresholds, read shell-side from node config. +pub(crate) struct RekeyCfg { + /// Rekey after this many seconds of session age (before jitter). + pub after_secs: u64, + /// Rekey after this many sent messages. + pub after_messages: u64, +} + +/// The wire-learned facts about one inbound XX `msg3`, handed to the establish +/// decision core. +/// +/// On XX the responder learns the initiator's identity and startup epoch only +/// once `msg3` completes the Noise handshake (unlike IK, which learns them at +/// `msg1`). The shell-side Noise step (`complete_handshake_msg3`) yields these; +/// the core reads them to classify. The Noise bytes themselves never reach the +/// core — the fresh session extraction stays a shell effect. Only the two facts +/// the classification depends on travel here: the peer's node address (for the +/// smaller-NodeAddr tie-breaks) and the peer's captured startup epoch (for +/// restart detection). +pub(crate) struct WireOutcome { + /// The initiator's node address, learned from the `msg3` static key. Drives + /// both the cross-connection and the dual-init tie-breaks. + pub peer_node_addr: NodeAddr, + /// The initiator's startup epoch captured from the handshake, if present. + /// `None` when no epoch was carried (treated as same-epoch, never a + /// restart). + pub remote_epoch: Option<[u8; 8]>, +} + +/// A snapshot of the `Node` registry state the inbound XX establish decision +/// reads about the peer whose `msg3` just completed, taken by the shell so the +/// core decides without touching live `Node` state or reading a clock. +/// +/// Every clock read (`existing_session_age_secs`) and every config-derived +/// threshold (`rekey_age_floor_secs`) is resolved shell-side into a plain +/// `u64`, the same monotonic-ages asymmetry the rekey snapshot uses, so the +/// timing stays behavior-identical under a clock step. +pub(crate) struct EstablishSnapshot { + /// The peer identity is already an active peer in the registry. `false` here + /// is the net-new path (or the post-restart re-promote) — a plain promote. + pub has_existing_peer: bool, + /// The existing active peer's captured remote startup epoch, if any. + pub existing_peer_epoch: Option<[u8; 8]>, + /// Monotonic age in seconds of the existing peer's session + /// (`session_established_at().elapsed()`), resolved shell-side. `0` when + /// there is no existing peer. + pub existing_session_age_secs: u64, + /// The existing peer has an established Noise session. + pub has_session: bool, + /// The existing peer's session is healthy. + pub is_healthy: bool, + /// The existing peer already holds a pending post-rekey session awaiting + /// K-bit cutover. On XX this is one of the two dual-init tie-break states + /// (the widened window IK never reached) — NOT an unconditional reject. + pub pending_new_session: bool, + /// The existing peer has a rekey handshake in flight (the other dual-init + /// tie-break state). + pub rekey_in_progress: bool, + /// The existing peer's stored `msg2` wire bytes (an opaque blob), resent on + /// a same-epoch duplicate `msg3`. `None` when there is no existing peer or + /// it has no stored `msg2`. + pub existing_msg2: Option>, + /// The `msg3` arrived on a *different* link than the existing peer's active + /// link (`existing_peer.link_id() != link_id`). Required for the inline + /// cross-connection branch: a `msg3` on the same link is never a + /// cross-connection. + pub different_link: bool, + /// The local rekey trigger is enabled in config (gates treating an aged + /// same-epoch `msg3` as a rekey rather than a duplicate). + pub rekey_enabled: bool, + /// The config-derived minimum session age (seconds) that partitions an aged + /// rekey from a fresh cross-connection: `< floor` → initial + /// cross-connection, `>= floor` → rekey responder. Derived shell-side from + /// `rekey.after_secs` and the rekey jitter so it tracks the real minimum + /// rekey spacing. + pub rekey_age_floor_secs: u64, + /// This node's own address, for both tie-breaks (the smaller NodeAddr wins). + pub our_node_addr: NodeAddr, +} + +/// A registry/transport effect the async shell performs on the core's behalf. +/// +/// The scaffold subset covers the maintain/teardown half of the lifecycle. The +/// establish-leaf variants (`SendMsg2`, `PromoteToActive`) are deferred to the +/// born-on-next handshake component and are intentionally absent. +pub(crate) enum ConnAction { + /// Tear down and free the handshake connection on `link` + /// (`cleanup_stale_connection`): frees the session index, removes the + /// `pending_outbound` entry, and cleans up the link + address mapping. + Teardown { link: LinkId }, + /// Schedule an auto-connect retry toward `peer` (`schedule_retry`) before + /// its failed/stale outbound connection is torn down. + ScheduleRetry { peer: NodeAddr }, + /// Resend the stored handshake msg1 `bytes` on `link`, then (on a + /// successful send) record the resend and reschedule the next one at + /// `next_resend_at_ms`. The shell resolves the transport + remote address + /// from the live connection and performs the send; `bytes` is an opaque + /// blob the core neither parses nor builds. + ResendMsg1 { + link: LinkId, + bytes: Vec, + next_resend_at_ms: u64, + }, + /// Perform the initiator-side K-bit cutover to `peer`'s pending session + /// (`cutover_to_new_session` + decrypt-worker re-registration). + Cutover { peer: NodeAddr }, + /// Complete `peer`'s drain window: erase the previous session, free its + /// index, and unregister its decrypt-worker entry. + Drain { peer: NodeAddr }, + /// Initiate a fresh outbound rekey to `peer` (`initiate_rekey`: allocates a + /// new index, builds and sends msg1, inserts `pending_outbound`). The msg1 + /// construction is the establish leaf and stays shell-side; the action + /// carries only the target. + InitiateRekey { peer: NodeAddr }, + /// Abandon `peer`'s in-flight rekey cycle (`abandon_rekey`): its msg1 went + /// unconfirmed past the retransmission budget. + AbandonRekey { peer: NodeAddr }, + /// Retransmit `peer`'s stored rekey msg1 `bytes`, then (on a successful + /// send) record the retransmission and reschedule the next at + /// `next_resend_at_ms`. The shell resolves the transport + remote address; + /// `bytes` is an opaque blob. + ResendRekeyMsg1 { + peer: NodeAddr, + bytes: Vec, + next_resend_at_ms: u64, + }, +} + +/// Read-only view of FMP connection/peer state the lifecycle core needs. +/// +/// The core defines this interface; the async shell (`node`) implements it over +/// the live `connections`/`peers` maps. It is a **snapshot-iterator** seam: +/// each method returns owned snapshot vectors with all clock reads already +/// resolved shell-side, so the pure decisions never borrow `Node` and never +/// read a clock. Keeping it a trait keeps `proto` free of a `node` dependency +/// and lets the decisions be unit-tested against hand-built snapshots. +pub(crate) trait LifecycleView { + /// Snapshot every handshake connection that is stale (idle past + /// `timeout_ms`) or failed, as of `now_ms`. The shell resolves the + /// timeout/failed predicate; the core decides retry-then-teardown. + fn stale_connections(&self, now_ms: u64, timeout_ms: u64) -> Vec; + + /// Snapshot every outbound connection whose stored msg1 is due for a + /// resend as of `now_ms` and still under `max_resends`. The shell resolves + /// the "outbound, in `SentMsg1`, has stored msg1, under budget, past the + /// scheduled time" predicate and copies the opaque msg1 bytes; the core + /// computes the backoff schedule. + fn resend_candidates(&self, now_ms: u64, max_resends: u32) -> Vec; + + /// Snapshot every active peer with a session that is healthy, pre-computing + /// its rekey-relevant ages and timer predicates (see [`PeerSnapshot`]). The + /// shell resolves every clock read here; the core applies the thresholds. + fn rekey_peers(&self) -> Vec; + + /// Snapshot every peer with a rekey handshake in flight (and a stored + /// msg1), pre-evaluating the resend-due predicate against `now_ms`. The + /// core classifies abandon-vs-resend and computes the backoff. + fn rekey_resend_candidates(&self, now_ms: u64) -> Vec; +} + +/// The classification outcome for one inbound XX `msg3`, decided purely from the +/// [`EstablishSnapshot`] and [`WireOutcome`]. The shell matches on this and +/// drives the effects; the core consumes nothing and touches no live state. +/// +/// This is a **superset** of the IK inbound decision. Because XX learns the +/// initiator's identity only at `msg3`, the same-epoch cross-connection +/// tie-break resolves *here* (on `msg3`) rather than on the outbound `msg2` +/// completion — hence the [`CrossConnect`](InboundDecision::CrossConnect) +/// variant that the IK inbound decision deliberately lacked. The dual-init +/// tie-break is also widened: it covers both the `rekey_in_progress` and the +/// `pending_new_session` states (see [`EstablishSnapshot::pending_new_session`]), +/// where IK only caught the former. +#[derive(Debug)] +pub(crate) enum InboundDecision { + /// No existing peer for this identity: promote the completed connection via + /// `promote_connection` (whose late max-peers cap and cross-connection + /// won/lost handling stay shell-side). Everything the shell needs is in the + /// live connection it still holds, so the variant carries nothing. + Promote, + /// Existing peer at a *different* startup epoch — a peer restart. The shell + /// removes the stale active peer and schedules its reconnect, then runs the + /// same promote sequence as [`Promote`](InboundDecision::Promote). `peer` is + /// the teardown / reconnect target. + RestartThenPromote { peer: NodeAddr }, + /// Same-epoch cross-connection resolved inline on `msg3`: a still-fresh + /// session (age `< rekey_age_floor_secs`) received a concurrent `msg3` on a + /// different link. `our_inbound_wins` (the larger-NodeAddr side) selects + /// swap-to-inbound vs keep-outbound; the shell frees the loser index and + /// tears down the temporary link either way. `peer` is the tie-break target. + CrossConnect { + peer: NodeAddr, + our_inbound_wins: bool, + }, + /// Same-epoch aged rekey `msg3` on a healthy session: respond as the rekey + /// responder. The shell extracts the fresh Noise session from the live + /// connection, allocates a new index, and stores it as the peer's pending + /// (post-rekey) session awaiting K-bit cutover. `abandon_first` is set only + /// on the dual-initiation *loser* path (larger NodeAddr), where we first + /// abandon our own in-flight rekey/pending state. `peer` is the rekey target. + RekeyRespond { peer: NodeAddr, abandon_first: bool }, + /// Same-epoch duplicate `msg3` (not a cross-connection, not a rekey): resend + /// the existing peer's stored `msg2`. `msg2` is the opaque stored bytes + /// (`None` → nothing to resend, the silent no-op preserved from the + /// pre-refactor path). The active peer is left untouched. + ResendMsg2 { msg2: Option> }, + /// Drop this `msg3` with a handshake reject (`HandshakeReject::BadState`) and + /// no promotion. `reason` selects only the diagnostic log line. + Reject { reason: InboundReject }, +} + +/// Why an inbound XX `msg3` was dropped by the core classification. XX reaches +/// the core with a single reject cause; the negotiation reject and the late ACL +/// reject are shell steps that run *before* the decision, and the max-peers cap +/// is a late gate inside `promote_connection` — none of them reach here. +#[derive(Debug)] +pub(crate) enum InboundReject { + /// Dual rekey initiation and we are the tie-break *winner* (smaller + /// NodeAddr): drop the peer's `msg3` and keep driving our own rekey. + DualRekeyWon, +} + +impl Fmp { + /// Decide the teardown choreography for the stale/failed connections the + /// shell snapshotted. For each connection, an outbound one with a known + /// identity first gets an auto-connect retry scheduled, then every + /// connection is torn down. Pure over the snapshots. + /// + /// Preserves the pre-refactor per-connection order (retry before teardown). + pub(crate) fn poll_timeouts(&self, stale: Vec) -> Vec { + let mut actions = Vec::new(); + for snap in stale { + if snap.is_outbound + && let Some(peer) = snap.retry_addr + { + actions.push(ConnAction::ScheduleRetry { peer }); + } + actions.push(ConnAction::Teardown { link: snap.link }); + } + actions + } + + /// Decide the msg1 resend schedule for the outbound handshake connections + /// the shell snapshotted as due. Each candidate yields one + /// [`ConnAction::ResendMsg1`] carrying the opaque msg1 bytes and the + /// next-resend deadline computed from the exponential backoff + /// (`interval_ms * backoff^(count+1)`). Pure over the snapshots. + /// + /// The shell performs the send and only commits the resend (count++ and + /// reschedule) when it succeeds, preserving the pre-refactor behavior where + /// a failed send neither advances the count nor reschedules. + pub(crate) fn poll_resends( + &self, + candidates: Vec, + now_ms: u64, + interval_ms: u64, + backoff: f64, + ) -> Vec { + candidates + .into_iter() + .map(|snap| ConnAction::ResendMsg1 { + link: snap.link, + next_resend_at_ms: next_resend_at_ms( + now_ms, + interval_ms, + backoff, + snap.resend_count, + ), + bytes: snap.msg1, + }) + .collect() + } + + /// Decide the per-tick rekey choreography for the healthy peers the shell + /// snapshotted. Reproduces the pre-refactor priority and phase grouping + /// exactly: + /// + /// - **Cutover** takes precedence: a peer with a pending session and no + /// in-flight rekey cuts over and is considered for nothing else. + /// - Otherwise an expired drain window is completed, and — independently — + /// the rekey trigger fires when the peer is neither mid-rekey nor + /// dampened and its jittered time threshold or send counter is reached. + /// A draining peer can thus both drain and re-trigger in the same tick, + /// as before. + /// + /// Actions are returned phase-grouped (all cutovers, then all drains, then + /// all rekey initiations) to preserve the pre-refactor global execution + /// order across peers, which the shared `index_allocator` observes. + pub(crate) fn poll_rekey(&self, peers: Vec, cfg: &RekeyCfg) -> Vec { + let mut cutovers = Vec::new(); + let mut drains = Vec::new(); + let mut rekeys = Vec::new(); + for p in peers { + // 1. Initiator-side cutover. + if p.has_pending && !p.rekey_in_progress { + cutovers.push(ConnAction::Cutover { peer: p.addr }); + continue; + } + // 2. Drain window expiry (does not preclude a trigger below). + if p.is_draining && p.drain_expired { + drains.push(ConnAction::Drain { peer: p.addr }); + } + // 3. Rekey trigger. + if p.rekey_in_progress || p.is_dampened || p.rekey_msg3_pending { + continue; + } + let effective_after = cfg.after_secs.saturating_add_signed(p.jitter_secs); + if p.elapsed_secs >= effective_after || p.counter >= cfg.after_messages { + rekeys.push(ConnAction::InitiateRekey { peer: p.addr }); + } + } + cutovers.extend(drains); + cutovers.extend(rekeys); + cutovers + } + + /// Decide the rekey-msg1 retransmission choreography for the peers the + /// shell snapshotted as having a rekey in flight. A peer whose + /// retransmission count has reached `max_resends` has its cycle abandoned; + /// otherwise, if its msg1 is due, it is retransmitted with the next + /// deadline computed from the shared backoff. Pure over the snapshots. + /// + /// Actions are returned abandons-first (matching the pre-refactor + /// two-pass order), and the shell commits a retransmission's count++ and + /// reschedule only on a successful send. + pub(crate) fn poll_rekey_resends( + &self, + candidates: Vec, + now_ms: u64, + interval_ms: u64, + backoff: f64, + max_resends: u32, + ) -> Vec { + let mut abandons = Vec::new(); + let mut resends = Vec::new(); + for c in candidates { + if c.resend_count >= max_resends { + abandons.push(ConnAction::AbandonRekey { peer: c.peer }); + continue; + } + if c.needs_resend { + resends.push(ConnAction::ResendRekeyMsg1 { + peer: c.peer, + next_resend_at_ms: next_resend_at_ms( + now_ms, + interval_ms, + backoff, + c.resend_count, + ), + bytes: c.msg1, + }); + } + } + abandons.extend(resends); + abandons + } + + /// Classify one inbound XX `msg3` from the establish snapshot and the Noise + /// wire outcome. Pure: reads only `snap` and `wire`, mutates nothing, + /// consumes nothing. The returned [`InboundDecision`] tells the shell which + /// effect sequence to drive. + /// + /// Mirrors the pre-refactor inline `handle_msg3` classification order + /// exactly: + /// + /// 1. No existing peer → net-new [`Promote`](InboundDecision::Promote). + /// 2. Existing peer, different epoch → [`RestartThenPromote`]. + /// 3. Same epoch, different link, session younger than the rekey floor → + /// inline [`CrossConnect`] (the XX widening: IK resolves this on `msg2`). + /// `our_inbound_wins` is the larger-NodeAddr side, matching + /// `cross_connection_winner(our, peer, /*outbound=*/ false)`. + /// 4. Same epoch, rekey enabled + healthy session + age at/above the floor → + /// [`RekeyRespond`]. The widened dual-init tie-break fires when the peer + /// is in *either* the `rekey_in_progress` or the `pending_new_session` + /// state: the smaller NodeAddr wins ([`Reject`] the peer's `msg3`), the + /// larger loses (`abandon_first`, then respond). + /// 5. Otherwise same epoch → duplicate [`ResendMsg2`]. + /// + /// [`CrossConnect`]: InboundDecision::CrossConnect + /// [`RestartThenPromote`]: InboundDecision::RestartThenPromote + /// [`RekeyRespond`]: InboundDecision::RekeyRespond + /// [`Reject`]: InboundDecision::Reject + /// [`ResendMsg2`]: InboundDecision::ResendMsg2 + pub(crate) fn establish_inbound( + &self, + snap: &EstablishSnapshot, + wire: &WireOutcome, + ) -> InboundDecision { + if !snap.has_existing_peer { + // No existing peer for this identity → net-new promote. + return InboundDecision::Promote; + } + + let peer = wire.peer_node_addr; + match (snap.existing_peer_epoch, wire.remote_epoch) { + (Some(existing), Some(new)) if existing != new => { + // Epoch mismatch → peer restart. + InboundDecision::RestartThenPromote { peer } + } + _ => { + // Same epoch (or no epoch captured on either side). + + // Inline cross-connection (msg2-then-msg3 ordering): a + // still-fresh session receiving a concurrent msg3 on a different + // link. The upper age bound sits below the rekey floor so any + // rekey-aged msg3 falls through to the rekey responder path. + if snap.different_link && snap.existing_session_age_secs < snap.rekey_age_floor_secs + { + // `cross_connection_winner(our, peer, this_is_outbound=false)`: + // the smaller node prefers its outbound, so our *inbound* + // wins iff we are the larger node (the exact negation of the + // smaller-NodeAddr `<` test the dual-init tie-break uses). + let our_inbound_wins = snap.our_node_addr >= peer; + return InboundDecision::CrossConnect { + peer, + our_inbound_wins, + }; + } + + // Rekey responder gate: aged, healthy session with rekey enabled. + if snap.rekey_enabled + && snap.has_session + && snap.is_healthy + && snap.existing_session_age_secs >= snap.rekey_age_floor_secs + { + // Widened dual-init tie-break: both the still-in-progress and + // the already-pending states resolve by the smaller NodeAddr. + if snap.rekey_in_progress || snap.pending_new_session { + if snap.our_node_addr < peer { + // We win — keep our session, drop their msg3. + return InboundDecision::Reject { + reason: InboundReject::DualRekeyWon, + }; + } + // We lose — abandon ours, then respond as responder. + return InboundDecision::RekeyRespond { + peer, + abandon_first: true, + }; + } + return InboundDecision::RekeyRespond { + peer, + abandon_first: false, + }; + } + + // Not a cross-connection, not a rekey → duplicate handshake. + InboundDecision::ResendMsg2 { + msg2: snap.existing_msg2.clone(), + } + } + } + } +} + +/// Exponential-backoff schedule for the next handshake/rekey msg1 resend: +/// `now_ms + interval_ms * backoff^(prior_count + 1)`. Matches the pre-refactor +/// arithmetic (the exponent is the resend count *after* this attempt). +fn next_resend_at_ms(now_ms: u64, interval_ms: u64, backoff: f64, prior_count: u32) -> u64 { + let count = prior_count + 1; + now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64 +} + +// ============================================================================ +// FMP negotiation decision logic +// ============================================================================ +// +// The version-agreement, profile-extraction, and profile-pairing decisions +// relocated from `protocol::negotiation` (the payload *codec* stays in +// `wire.rs`). These are pure decisions over an already-decoded +// `NegotiationPayload`, so they belong beside the other FMP core decisions. + +impl NegotiationPayload { + /// Agree on a protocol version with a peer's negotiation payload. + /// + /// Returns `min(our_max, their_max)`, rejecting if the agreed version + /// is below either side's minimum. + pub fn agree_version(&self, other: &Self) -> Result { + let agreed = self.version_max.min(other.version_max); + if agreed < self.version_min || agreed < other.version_min { + return Err(ProtocolError::Malformed(format!( + "version mismatch: ours [{},{}] theirs [{},{}]", + self.version_min, self.version_max, other.version_min, other.version_max + ))); + } + Ok(agreed) + } + + /// Build an FMP negotiation payload for the given node profile. + /// + /// Sets the profile bits and MMP wants/provides defaults for the profile. + 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; + } + + Self::new(version_min, version_max, features) + } + + /// Extract the node profile from the FMP feature bitfield. + pub fn node_profile(&self) -> Result { + let raw = (self.features & FMP_FEAT_PROFILE_MASK) as u8; + NodeProfile::try_from(raw) + } + + /// Whether this peer can provide MMP sender reports. + pub fn provides_sr(&self) -> bool { + self.features & FMP_FEAT_PROVIDES_SR != 0 + } + + /// Whether this peer can provide MMP receiver reports. + pub fn provides_rr(&self) -> bool { + self.features & FMP_FEAT_PROVIDES_RR != 0 + } + + /// Whether this peer wants MMP sender reports. + pub fn wants_sr(&self) -> bool { + self.features & FMP_FEAT_WANTS_SR != 0 + } + + /// Whether this peer wants MMP receiver reports. + pub fn wants_rr(&self) -> bool { + self.features & FMP_FEAT_WANTS_RR != 0 + } + + /// 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(()) + } +} + +/// Decode, validate, and extract the peer's node profile from an FMP +/// negotiation payload. +/// +/// The pure decode -> validate -> profile decision lifted out of the async +/// `process_fmp_negotiation` shell adapter. The shell applies the returned +/// profile to the connection (`set_negotiation_results`) and logs. +pub(crate) fn decide_fmp_negotiation( + our_profile: NodeProfile, + neg_bytes: &[u8], +) -> Result { + let their_payload = NegotiationPayload::decode(neg_bytes)?; + let their_profile = their_payload.node_profile()?; + NegotiationPayload::validate_profiles(our_profile, their_profile)?; + Ok(their_profile) +} diff --git a/src/proto/fmp/limits.rs b/src/proto/fmp/limits.rs new file mode 100644 index 0000000..24407ad --- /dev/null +++ b/src/proto/fmp/limits.rs @@ -0,0 +1,16 @@ +//! FMP connection-retry backoff timing. +//! +//! Pure, runtime-agnostic backoff math for the auto-connect retry scheduler. +//! The `Node`-coupled `schedule_*` / `process_pending_retries` async drivers +//! stay in the shell (`node::retry`) and pass the retry count in. + +/// Calculate the backoff delay in milliseconds for the given retry count. +/// +/// Uses exponential backoff: `base_interval_ms * 2^retry_count`, capped at +/// `max_backoff_ms`. +pub(crate) fn backoff_ms(retry_count: u32, base_interval_ms: u64, max_backoff_ms: u64) -> u64 { + let multiplier = 1u64.checked_shl(retry_count).unwrap_or(u64::MAX); + base_interval_ms + .saturating_mul(multiplier) + .min(max_backoff_ms) +} diff --git a/src/proto/fmp/mod.rs b/src/proto/fmp/mod.rs new file mode 100644 index 0000000..f6514e6 --- /dev/null +++ b/src/proto/fmp/mod.rs @@ -0,0 +1,45 @@ +//! Sans-IO FMP connection-lifecycle state machine. +//! +//! Pure, runtime-agnostic maintain/teardown decisions for the FMP peer +//! connection lifecycle, migrated out of the async node shell. The async I/O +//! adapters remain in `node::handlers::{timeout,rekey}`. +//! +//! This covers the four tick-poll maintain/teardown drivers (handshake +//! timeout/teardown, msg1 resend, rekey cutover/drain/trigger, rekey-msg1 +//! resend) plus the XX inbound `msg3` establish classification +//! ([`InboundDecision`], decided by [`Fmp::establish_inbound`]). Handshake +//! message bytes are opaque blobs throughout — the establish leaf's Noise wire +//! construction and `promote_connection` effects are born-on-next and stay in +//! `node/`; only the classification decision lives here. +//! +//! - `core.rs` — the [`LifecycleView`] read-seam trait, the [`ConnAction`] +//! effect vocabulary, the snapshot types, the [`InboundDecision`] establish +//! classification, the pure `poll_*`/`establish_inbound` decisions, the +//! [`cross_connection_winner`] tie-break helper, and the FMP negotiation +//! decision logic (version agreement, profile validation). +//! - `limits.rs` — the pure connection-retry backoff math. +//! - `state.rs` — [`ConnectionState`], the pure handshake-phase connection +//! bookkeeping (owned by the shell `PeerConnection` beside its Noise crypto +//! handles) and its [`HandshakeState`] phase enum, plus [`Fmp`], the +//! (stateless) lifecycle anchor owned by `Node`. +//! - `wire.rs` — the FMP wire codec: XX handshake message types, disconnect +//! reasons, the orderly disconnect message, and the negotiation payload. + +mod core; +mod limits; +mod state; +mod wire; + +#[cfg(test)] +mod tests; + +pub use core::cross_connection_winner; +pub(crate) use core::{ + ConnAction, ConnSnapshot, EstablishSnapshot, InboundDecision, InboundReject, LifecycleView, + PeerSnapshot, RekeyCfg, RekeyResendSnapshot, WireOutcome, decide_fmp_negotiation, +}; +pub(crate) use limits::backoff_ms; +pub use state::HandshakeState; +pub(crate) use state::{ConnectionState, Fmp}; +pub(crate) use wire::{Disconnect, DisconnectReason}; +pub use wire::{HandshakeMessageType, NegotiationPayload, NodeProfile, TlvEntry}; diff --git a/src/proto/fmp/state.rs b/src/proto/fmp/state.rs new file mode 100644 index 0000000..821097c --- /dev/null +++ b/src/proto/fmp/state.rs @@ -0,0 +1,513 @@ +//! Sans-IO FMP connection-lifecycle state. +//! +//! The pure, runtime-agnostic bookkeeping for an in-progress FMP peer +//! connection — link/direction identity, the handshake-phase enum, learned +//! peer identity and epoch, index/transport/address tracking, handshake-resend +//! scheduling, and link statistics — extracted out of the async node shell. +//! +//! [`ConnectionState`] owns every **pure** field of the handshake-phase +//! connection. The Noise crypto handles (`noise::HandshakeState`, +//! `NoiseSession`) stay shell-owned in +//! [`PeerConnection`](crate::peer::PeerConnection), which holds a +//! `ConnectionState` alongside them and drives the two halves side by side. The +//! shell's XX transition methods validate against the pure phase, drive the +//! Noise objects, then write learned results back through the pure setters here +//! (`set_handshake_state`, `set_expected_identity`, `set_remote_epoch`, +//! `touch`). +//! +//! This state is `no_std`+`alloc`-clean with respect to transport: the +//! identifier/address/statistics value types are the plain-data `transport` +//! primitives (defined in the `no_std` `transport::types` module, named here +//! via their `crate::transport` re-export). Two `std`-tethers remain — +//! [`PeerIdentity`] and [`SessionIndex`] — plain-data types whose defining +//! modules are not yet `no_std`. They are named here as data only (never +//! driving crypto) and mirror the tether already carried by the sibling +//! decision [`core`](super::core). +//! +//! [`Fmp`] is the separate, stateless lifecycle anchor owned by +//! [`Node`](crate::node::Node); see its doc below. + +use super::wire::NodeProfile; +use crate::PeerIdentity; +use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId}; +use crate::utils::index::SessionIndex; +use core::fmt; + +/// Handshake protocol state machine. +/// +/// For Noise XX pattern: +/// - Initiator: Initial → SentMsg1 → Complete (after processing msg2 + sending msg3) +/// - Responder: Initial → ReceivedMsg1 → Complete (after processing msg3) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HandshakeState { + /// Initial state, ready to start handshake. + Initial, + /// Initiator: Sent message 1, awaiting message 2. + SentMsg1, + /// Responder: Received message 1, ready to send message 2. + ReceivedMsg1, + /// Handshake completed successfully. + Complete, + /// Handshake failed. + Failed, +} + +impl HandshakeState { + /// Check if handshake is still in progress. + pub fn is_in_progress(&self) -> bool { + matches!( + self, + HandshakeState::Initial | HandshakeState::SentMsg1 | HandshakeState::ReceivedMsg1 + ) + } + + /// Check if handshake completed successfully. + pub fn is_complete(&self) -> bool { + matches!(self, HandshakeState::Complete) + } + + /// Check if handshake failed. + pub fn is_failed(&self) -> bool { + matches!(self, HandshakeState::Failed) + } +} + +impl fmt::Display for HandshakeState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + HandshakeState::Initial => "initial", + HandshakeState::SentMsg1 => "sent_msg1", + HandshakeState::ReceivedMsg1 => "received_msg1", + HandshakeState::Complete => "complete", + HandshakeState::Failed => "failed", + }; + write!(f, "{}", s) + } +} + +/// Pure, runtime-agnostic bookkeeping for a connection in the handshake phase. +/// +/// Owns every non-crypto field of the handshake-phase connection. The Noise +/// crypto handles live beside it in the shell +/// [`PeerConnection`](crate::peer::PeerConnection); this struct is written only +/// as plain data — the shell extracts learned identity/epoch out of the crypto +/// objects and sets them here through the setters. +#[derive(Debug)] +pub struct ConnectionState { + // === Link Reference === + /// The link carrying this connection. + link_id: LinkId, + + /// Connection direction (we initiated or they initiated). + direction: LinkDirection, + + // === Handshake State === + /// Current handshake state. + handshake_state: HandshakeState, + + /// Expected peer identity (known for outbound, learned for inbound). + /// Updated after receiving their static key in the handshake. + expected_identity: Option, + + // === Timing === + /// When the connection attempt started (Unix milliseconds). + started_at: u64, + + /// When the last handshake message was sent/received. + last_activity: u64, + + // === Statistics === + /// Link statistics during handshake. + link_stats: LinkStats, + + // === Wire Protocol Index Tracking === + /// Our sender_idx for this handshake (chosen by us). + /// For outbound: included in msg1, used as receiver_idx in msg2 echo. + /// For inbound: chosen after processing msg1, included in msg2. + our_index: Option, + + /// Their sender_idx (learned from their messages). + /// For outbound: learned from msg2. + /// For inbound: learned from msg1. + their_index: Option, + + /// Transport ID (for index namespace). + transport_id: Option, + + /// Current source address (updated on packet receipt). + source_addr: Option, + + // === Epoch (Restart Detection) === + /// 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, + + // === Handshake Resend === + /// Wire-format msg1 bytes for resend (initiator only). + handshake_msg1: Option>, + + /// Wire-format msg2 bytes for resend (responder only). + handshake_msg2: Option>, + + /// Number of resends performed so far. + resend_count: u32, + + /// When the next resend should fire (Unix ms). 0 = no resend scheduled. + next_resend_at_ms: u64, +} + +impl ConnectionState { + /// Create the pure state for a new outbound connection (we initiate). + /// + /// For outbound, we know who we're trying to reach from configuration. + pub fn outbound( + link_id: LinkId, + expected_identity: PeerIdentity, + current_time_ms: u64, + ) -> Self { + Self { + link_id, + direction: LinkDirection::Outbound, + handshake_state: HandshakeState::Initial, + expected_identity: Some(expected_identity), + started_at: current_time_ms, + last_activity: current_time_ms, + link_stats: LinkStats::new(), + our_index: None, + their_index: None, + transport_id: None, + source_addr: None, + remote_epoch: None, + peer_profile: None, + handshake_msg1: None, + handshake_msg2: None, + resend_count: 0, + next_resend_at_ms: 0, + } + } + + /// Create the pure state for a new outbound connection without a pre-known + /// identity. + /// + /// Used for anonymous discovery on shared-media transports (Ethernet, BLE) + /// where the beacon doesn't carry identity. The peer's identity is learned + /// from XX msg2 during the handshake. + pub fn outbound_anonymous(link_id: LinkId, current_time_ms: u64) -> Self { + Self { + link_id, + direction: LinkDirection::Outbound, + handshake_state: HandshakeState::Initial, + expected_identity: None, + started_at: current_time_ms, + last_activity: current_time_ms, + link_stats: LinkStats::new(), + our_index: None, + their_index: None, + transport_id: None, + source_addr: None, + remote_epoch: None, + peer_profile: None, + handshake_msg1: None, + handshake_msg2: None, + resend_count: 0, + next_resend_at_ms: 0, + } + } + + /// Create the pure state for a new inbound connection (they initiate). + /// + /// For inbound, we don't know who they are until we decrypt their identity + /// from Noise message 1. + pub fn inbound(link_id: LinkId, current_time_ms: u64) -> Self { + Self { + link_id, + direction: LinkDirection::Inbound, + handshake_state: HandshakeState::Initial, + expected_identity: None, + started_at: current_time_ms, + last_activity: current_time_ms, + link_stats: LinkStats::new(), + our_index: None, + their_index: None, + transport_id: None, + source_addr: None, + remote_epoch: None, + peer_profile: None, + handshake_msg1: None, + handshake_msg2: None, + resend_count: 0, + next_resend_at_ms: 0, + } + } + + /// Create the pure state for a new inbound connection with transport info. + /// + /// Used when processing msg1 where we know the transport and source address. + pub fn inbound_with_transport( + link_id: LinkId, + transport_id: TransportId, + source_addr: TransportAddr, + current_time_ms: u64, + ) -> Self { + Self { + link_id, + direction: LinkDirection::Inbound, + handshake_state: HandshakeState::Initial, + expected_identity: None, + started_at: current_time_ms, + last_activity: current_time_ms, + link_stats: LinkStats::new(), + our_index: None, + their_index: None, + transport_id: Some(transport_id), + source_addr: Some(source_addr), + remote_epoch: None, + peer_profile: None, + handshake_msg1: None, + handshake_msg2: None, + resend_count: 0, + next_resend_at_ms: 0, + } + } + + // === Accessors === + + /// Get the link ID. + pub fn link_id(&self) -> LinkId { + self.link_id + } + + /// Get the connection direction. + pub fn direction(&self) -> LinkDirection { + self.direction + } + + /// Get the handshake state. + pub fn handshake_state(&self) -> HandshakeState { + self.handshake_state + } + + /// Get the expected/learned peer identity, if known. + pub fn expected_identity(&self) -> Option<&PeerIdentity> { + self.expected_identity.as_ref() + } + + /// Check if this is an outbound connection. + pub fn is_outbound(&self) -> bool { + self.direction == LinkDirection::Outbound + } + + /// Check if this is an inbound connection. + pub fn is_inbound(&self) -> bool { + self.direction == LinkDirection::Inbound + } + + /// Check if handshake is in progress. + pub fn is_in_progress(&self) -> bool { + self.handshake_state.is_in_progress() + } + + /// Check if handshake completed. + pub fn is_complete(&self) -> bool { + self.handshake_state.is_complete() + } + + /// Check if handshake failed. + pub fn is_failed(&self) -> bool { + self.handshake_state.is_failed() + } + + /// When the connection started. + pub fn started_at(&self) -> u64 { + self.started_at + } + + /// When the last activity occurred. + pub fn last_activity(&self) -> u64 { + self.last_activity + } + + /// Connection duration so far. + pub fn duration(&self, current_time_ms: u64) -> u64 { + current_time_ms.saturating_sub(self.started_at) + } + + /// Time since last activity. + pub fn idle_time(&self, current_time_ms: u64) -> u64 { + current_time_ms.saturating_sub(self.last_activity) + } + + /// Get link statistics. + pub fn link_stats(&self) -> &LinkStats { + &self.link_stats + } + + /// Get mutable link statistics. + pub fn link_stats_mut(&mut self) -> &mut LinkStats { + &mut self.link_stats + } + + // === Index Accessors === + + /// Get our session index (if set). + pub fn our_index(&self) -> Option { + self.our_index + } + + /// Set our session index. + pub fn set_our_index(&mut self, index: SessionIndex) { + self.our_index = Some(index); + } + + /// Get their session index (if known). + pub fn their_index(&self) -> Option { + self.their_index + } + + /// Set their session index. + pub fn set_their_index(&mut self, index: SessionIndex) { + self.their_index = Some(index); + } + + /// Get the transport ID (if set). + pub fn transport_id(&self) -> Option { + self.transport_id + } + + /// Set the transport ID. + pub fn set_transport_id(&mut self, id: TransportId) { + self.transport_id = Some(id); + } + + /// Get the source address (if known). + pub fn source_addr(&self) -> Option<&TransportAddr> { + self.source_addr.as_ref() + } + + /// Set the source address. + pub fn set_source_addr(&mut self, addr: TransportAddr) { + self.source_addr = Some(addr); + } + + // === Epoch Accessors === + + /// Get the remote peer's startup epoch (available after handshake). + pub fn remote_epoch(&self) -> Option<[u8; 8]> { + self.remote_epoch + } + + /// Record the remote peer's startup epoch, as extracted from the crypto + /// handshake by the shell. + pub fn set_remote_epoch(&mut self, epoch: Option<[u8; 8]>) { + self.remote_epoch = epoch; + } + + // === Learned Identity === + + /// Record the learned/confirmed peer identity, as extracted from the crypto + /// handshake by the shell. + pub fn set_expected_identity(&mut self, identity: PeerIdentity) { + self.expected_identity = Some(identity); + } + + // === Negotiation Results === + + /// Get the peer's negotiated node profile, if learned. + pub fn peer_profile(&self) -> Option { + self.peer_profile + } + + /// Record the peer's node profile learned during FMP negotiation. + pub fn set_negotiation_results(&mut self, peer_profile: NodeProfile) { + self.peer_profile = Some(peer_profile); + } + + // === Handshake Phase Advance === + + /// Advance the pure handshake phase. Driven by the shell after it has + /// stepped the Noise objects. + pub fn set_handshake_state(&mut self, state: HandshakeState) { + self.handshake_state = state; + } + + /// Mark the pure handshake phase failed. The shell drops the crypto handle + /// separately. + pub fn mark_failed(&mut self) { + self.handshake_state = HandshakeState::Failed; + } + + // === Handshake Resend === + + /// Store the wire-format msg1 bytes for resend and schedule the first resend. + pub fn set_handshake_msg1(&mut self, msg1: Vec, first_resend_at_ms: u64) { + self.handshake_msg1 = Some(msg1); + self.resend_count = 0; + self.next_resend_at_ms = first_resend_at_ms; + } + + /// Store the wire-format msg2 bytes for resend on duplicate msg1. + pub fn set_handshake_msg2(&mut self, msg2: Vec) { + self.handshake_msg2 = Some(msg2); + } + + /// Get the stored msg1 bytes (if any). + pub fn handshake_msg1(&self) -> Option<&[u8]> { + self.handshake_msg1.as_deref() + } + + /// Get the stored msg2 bytes (if any). + pub fn handshake_msg2(&self) -> Option<&[u8]> { + self.handshake_msg2.as_deref() + } + + /// Number of resends performed. + pub fn resend_count(&self) -> u32 { + self.resend_count + } + + /// When the next resend is scheduled (Unix ms). + pub fn next_resend_at_ms(&self) -> u64 { + self.next_resend_at_ms + } + + /// Record a resend and schedule the next one. + pub fn record_resend(&mut self, next_resend_at_ms: u64) { + self.resend_count += 1; + self.next_resend_at_ms = next_resend_at_ms; + } + + // === Activity / Timeout === + + /// Update last activity timestamp. + pub fn touch(&mut self, current_time_ms: u64) { + self.last_activity = current_time_ms; + } + + /// Check if the connection has timed out. + pub fn is_timed_out(&self, current_time_ms: u64, timeout_ms: u64) -> bool { + self.idle_time(current_time_ms) > timeout_ms + } +} + +/// FMP connection-lifecycle subsystem anchor owned by +/// [`Node`](crate::node::Node). +/// +/// Unlike [`Router`](crate::proto::routing::Router), the FMP lifecycle core +/// owns **no** mutable state: every registry mutation (index allocation, +/// `peers_by_index`/`addr_to_link`/`connections` insertion and removal, +/// decrypt-worker register/unregister) stays shell-side, driven by the +/// [`ConnAction`](super::ConnAction)s the pure `poll_*` decisions emit. `Fmp` +/// is therefore an empty namespace anchor: it exists so the maintain/teardown +/// decisions can hang off a `Node` field (`self.fmp`) in the same shape the +/// other migrated subsystems use, not to hold data. +pub(crate) struct Fmp; + +impl Fmp { + /// Create the (stateless) FMP lifecycle anchor. + pub(crate) fn new() -> Self { + Self + } +} diff --git a/src/proto/fmp/tests/core.rs b/src/proto/fmp/tests/core.rs new file mode 100644 index 0000000..7f6182d --- /dev/null +++ b/src/proto/fmp/tests/core.rs @@ -0,0 +1,680 @@ +//! Tests for the sans-IO FMP connection-lifecycle decision core. + +use super::util::{ + establish_snapshot, peer_snapshot, rekey_resend_snapshot, resend_snapshot, stale_snapshot, + wire_outcome, +}; +use crate::proto::fmp::{ + ConnAction, Fmp, InboundDecision, InboundReject, NegotiationPayload, NodeProfile, RekeyCfg, + cross_connection_winner, +}; +use crate::testutil::make_node_addr; +use crate::transport::LinkId; + +/// Matching-epoch default used by `establish_snapshot`. +const SAME_EPOCH: [u8; 8] = [0x01; 8]; + +/// Threshold config used across the rekey decision tests: rekey at 100s of +/// session age or 1000 sent messages. +fn cfg() -> RekeyCfg { + RekeyCfg { + after_secs: 100, + after_messages: 1_000, + } +} + +#[test] +fn empty_stale_set_yields_no_actions() { + let fmp = Fmp::new(); + assert!(fmp.poll_timeouts(Vec::new()).is_empty()); +} + +#[test] +fn inbound_stale_connection_is_torn_down_without_retry() { + let fmp = Fmp::new(); + let link = LinkId::new(7); + let actions = fmp.poll_timeouts(vec![stale_snapshot( + link, + false, + Some(make_node_addr(0x22)), + )]); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], ConnAction::Teardown { link: l } if l == link)); +} + +#[test] +fn outbound_stale_with_identity_schedules_retry_then_tears_down() { + let fmp = Fmp::new(); + let link = LinkId::new(9); + let peer = make_node_addr(0x33); + let actions = fmp.poll_timeouts(vec![stale_snapshot(link, true, Some(peer))]); + assert_eq!(actions.len(), 2); + // Retry is scheduled before teardown, matching the pre-refactor order. + assert!(matches!(actions[0], ConnAction::ScheduleRetry { peer: p } if p == peer)); + assert!(matches!(actions[1], ConnAction::Teardown { link: l } if l == link)); +} + +#[test] +fn outbound_stale_without_identity_only_tears_down() { + let fmp = Fmp::new(); + let link = LinkId::new(11); + let actions = fmp.poll_timeouts(vec![stale_snapshot(link, true, None)]); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], ConnAction::Teardown { link: l } if l == link)); +} + +#[test] +fn no_resend_candidates_yields_no_actions() { + let fmp = Fmp::new(); + assert!(fmp.poll_resends(Vec::new(), 1_000, 500, 2.0).is_empty()); +} + +#[test] +fn resend_emits_bytes_and_backoff_schedule() { + let fmp = Fmp::new(); + let link = LinkId::new(5); + let msg1 = vec![0xde, 0xad, 0xbe, 0xef]; + // now=1000, interval=500, backoff=2.0, prior_count=0 -> exponent 1 -> + // next = 1000 + 500 * 2^1 = 2000. + let actions = fmp.poll_resends( + vec![resend_snapshot(link, 0, msg1.clone())], + 1_000, + 500, + 2.0, + ); + assert_eq!(actions.len(), 1); + match &actions[0] { + ConnAction::ResendMsg1 { + link: l, + bytes, + next_resend_at_ms, + } => { + assert_eq!(*l, link); + assert_eq!(bytes, &msg1); + assert_eq!(*next_resend_at_ms, 2_000); + } + _ => panic!("expected ResendMsg1"), + } +} + +#[test] +fn resend_backoff_exponent_uses_count_plus_one() { + let fmp = Fmp::new(); + let link = LinkId::new(6); + // prior_count=2 -> exponent 3 -> next = 0 + 100 * 2^3 = 800. + let actions = fmp.poll_resends(vec![resend_snapshot(link, 2, vec![1])], 0, 100, 2.0); + match &actions[0] { + ConnAction::ResendMsg1 { + next_resend_at_ms, .. + } => assert_eq!(*next_resend_at_ms, 800), + _ => panic!("expected ResendMsg1"), + } +} + +// --- rekey decision (synthetic clock: elapsed_secs/counter fed directly) --- + +#[test] +fn rekey_no_peers_yields_no_actions() { + let fmp = Fmp::new(); + assert!(fmp.poll_rekey(Vec::new(), &cfg()).is_empty()); +} + +#[test] +fn rekey_cutover_takes_precedence_over_trigger() { + let fmp = Fmp::new(); + let mut p = peer_snapshot(0x10); + p.has_pending = true; + // Wildly over the time threshold, but cutover wins and nothing else fires. + p.elapsed_secs = 10_000; + p.counter = 10_000; + let actions = fmp.poll_rekey(vec![p], &cfg()); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], ConnAction::Cutover { peer } if peer == make_node_addr(0x10))); +} + +#[test] +fn rekey_pending_with_inflight_rekey_does_not_cut_over() { + let fmp = Fmp::new(); + let mut p = peer_snapshot(0x11); + p.has_pending = true; + p.rekey_in_progress = true; + p.elapsed_secs = 10_000; + // has_pending gated by !rekey_in_progress -> no cutover; in-progress -> no + // trigger either. + assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty()); +} + +#[test] +fn rekey_expired_drain_and_trigger_both_fire() { + let fmp = Fmp::new(); + let mut p = peer_snapshot(0x12); + p.is_draining = true; + p.drain_expired = true; + p.elapsed_secs = 150; // past 100s threshold + let actions = fmp.poll_rekey(vec![p], &cfg()); + // Draining does not preclude re-triggering in the same tick. + assert_eq!(actions.len(), 2); + assert!(matches!(actions[0], ConnAction::Drain { peer } if peer == make_node_addr(0x12))); + assert!( + matches!(actions[1], ConnAction::InitiateRekey { peer } if peer == make_node_addr(0x12)) + ); +} + +#[test] +fn rekey_triggers_on_counter() { + let fmp = Fmp::new(); + let mut p = peer_snapshot(0x13); + p.counter = 1_000; // == after_messages + let actions = fmp.poll_rekey(vec![p], &cfg()); + assert!(matches!(actions[0], ConnAction::InitiateRekey { .. })); +} + +#[test] +fn rekey_negative_jitter_lowers_time_threshold() { + let fmp = Fmp::new(); + let mut p = peer_snapshot(0x14); + p.elapsed_secs = 90; + p.jitter_secs = -15; // effective threshold 85 -> 90 >= 85 fires + assert!(matches!( + fmp.poll_rekey(vec![p], &cfg())[0], + ConnAction::InitiateRekey { .. } + )); +} + +#[test] +fn rekey_positive_jitter_raises_time_threshold() { + let fmp = Fmp::new(); + let mut p = peer_snapshot(0x15); + p.elapsed_secs = 105; + p.jitter_secs = 10; // effective threshold 110 -> 105 < 110, no time trigger + assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty()); +} + +#[test] +fn rekey_dampening_suppresses_trigger() { + let fmp = Fmp::new(); + let mut p = peer_snapshot(0x16); + p.elapsed_secs = 10_000; + p.is_dampened = true; + assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty()); +} + +#[test] +fn rekey_msg3_pending_suppresses_trigger() { + let fmp = Fmp::new(); + // The initiator already cut over and is still retransmitting this cycle's + // rekey msg3; a fresh rekey must not start (it would overwrite the retained + // payload). Mirrors the dampening-suppression case. + let mut p = peer_snapshot(0x17); + p.elapsed_secs = 10_000; + p.rekey_msg3_pending = true; + assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty()); +} + +#[test] +fn rekey_actions_are_phase_grouped_across_peers() { + let fmp = Fmp::new(); + // Peer A: trigger only. Peer B: cutover. Peer C: drain + trigger. + let mut a = peer_snapshot(0x01); + a.elapsed_secs = 200; + let mut b = peer_snapshot(0x02); + b.has_pending = true; + let mut c = peer_snapshot(0x03); + c.is_draining = true; + c.drain_expired = true; + c.counter = 5_000; + let actions = fmp.poll_rekey(vec![a, b, c], &cfg()); + // Order must be: all cutovers, then all drains, then all rekeys. + assert!(matches!(actions[0], ConnAction::Cutover { peer } if peer == make_node_addr(0x02))); + assert!(matches!(actions[1], ConnAction::Drain { peer } if peer == make_node_addr(0x03))); + assert!( + matches!(actions[2], ConnAction::InitiateRekey { peer } if peer == make_node_addr(0x01)) + ); + assert!( + matches!(actions[3], ConnAction::InitiateRekey { peer } if peer == make_node_addr(0x03)) + ); + assert_eq!(actions.len(), 4); +} + +// --- rekey msg1 retransmission decision --- + +#[test] +fn rekey_resend_no_candidates_yields_no_actions() { + let fmp = Fmp::new(); + assert!( + fmp.poll_rekey_resends(Vec::new(), 1_000, 500, 2.0, 5) + .is_empty() + ); +} + +#[test] +fn rekey_resend_over_budget_abandons() { + let fmp = Fmp::new(); + // resend_count == max_resends -> abandon (even though it is "due"). + let c = rekey_resend_snapshot(0x40, 5, true, vec![9]); + let actions = fmp.poll_rekey_resends(vec![c], 1_000, 500, 2.0, 5); + assert_eq!(actions.len(), 1); + assert!( + matches!(actions[0], ConnAction::AbandonRekey { peer } if peer == make_node_addr(0x40)) + ); +} + +#[test] +fn rekey_resend_due_retransmits_with_backoff() { + let fmp = Fmp::new(); + let msg1 = vec![0xaa, 0xbb]; + // prior_count=1 -> exponent 2 -> next = 1000 + 500 * 2^2 = 3000. + let c = rekey_resend_snapshot(0x41, 1, true, msg1.clone()); + let actions = fmp.poll_rekey_resends(vec![c], 1_000, 500, 2.0, 5); + assert_eq!(actions.len(), 1); + match &actions[0] { + ConnAction::ResendRekeyMsg1 { + peer, + bytes, + next_resend_at_ms, + } => { + assert_eq!(*peer, make_node_addr(0x41)); + assert_eq!(bytes, &msg1); + assert_eq!(*next_resend_at_ms, 3_000); + } + _ => panic!("expected ResendRekeyMsg1"), + } +} + +#[test] +fn rekey_resend_not_due_is_skipped() { + let fmp = Fmp::new(); + // Under budget but not due -> no action. + let c = rekey_resend_snapshot(0x42, 1, false, vec![1]); + assert!( + fmp.poll_rekey_resends(vec![c], 1_000, 500, 2.0, 5) + .is_empty() + ); +} + +#[test] +fn rekey_resend_abandons_precede_retransmits() { + let fmp = Fmp::new(); + let due = rekey_resend_snapshot(0x43, 0, true, vec![1]); + let over = rekey_resend_snapshot(0x44, 9, true, vec![2]); + // Input order: due-resend first, then over-budget; output must be + // abandons-first regardless. + let actions = fmp.poll_rekey_resends(vec![due, over], 1_000, 500, 2.0, 5); + assert_eq!(actions.len(), 2); + assert!( + matches!(actions[0], ConnAction::AbandonRekey { peer } if peer == make_node_addr(0x44)) + ); + assert!( + matches!(actions[1], ConnAction::ResendRekeyMsg1 { peer, .. } if peer == make_node_addr(0x43)) + ); +} + +// =========================================================================== +// XX inbound establish classification (`establish_inbound`). +// +// The 11 framed characterization tests in `node::tests::establish_xx_chartests` +// are the behavior oracle; these exercise the pure decision directly over +// hand-built snapshots, one per branch, with deterministic session ages. +// =========================================================================== + +/// No existing peer for this identity -> a net-new promote. +#[test] +fn establish_net_new_promotes() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x05); + snap.has_existing_peer = false; + let wire = wire_outcome(0x02, SAME_EPOCH); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::Promote + )); +} + +/// An existing peer at a different startup epoch -> restart then promote. +#[test] +fn establish_epoch_mismatch_restarts() { + let fmp = Fmp::new(); + let snap = establish_snapshot(0x05); // existing epoch [0x01; 8] + let wire = wire_outcome(0x02, [0x02; 8]); // new epoch differs + match fmp.establish_inbound(&snap, &wire) { + InboundDecision::RestartThenPromote { peer } => { + assert_eq!(peer, make_node_addr(0x02)); + } + other => panic!("expected RestartThenPromote, got {other:?}"), + } +} + +/// Same-epoch, different link, still-fresh session where we are the LARGER node: +/// our inbound wins the inline cross-connection (swap to inbound). +#[test] +fn establish_cross_connection_larger_node_inbound_wins() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x09); // our addr 0x09 + snap.different_link = true; + snap.existing_session_age_secs = 0; // < floor + let wire = wire_outcome(0x02, SAME_EPOCH); // peer 0x02 < our 0x09 + match fmp.establish_inbound(&snap, &wire) { + InboundDecision::CrossConnect { + peer, + our_inbound_wins, + } => { + assert_eq!(peer, make_node_addr(0x02)); + assert!(our_inbound_wins, "larger node's inbound wins"); + } + other => panic!("expected CrossConnect, got {other:?}"), + } +} + +/// Same-epoch, different link, still-fresh session where we are the SMALLER +/// node: our inbound loses (keep the existing outbound session). +#[test] +fn establish_cross_connection_smaller_node_inbound_loses() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x02); // our addr 0x02 + snap.different_link = true; + snap.existing_session_age_secs = 0; + let wire = wire_outcome(0x09, SAME_EPOCH); // peer 0x09 > our 0x02 + match fmp.establish_inbound(&snap, &wire) { + InboundDecision::CrossConnect { + our_inbound_wins, .. + } => assert!(!our_inbound_wins, "smaller node's inbound loses"), + other => panic!("expected CrossConnect, got {other:?}"), + } +} + +/// Same link (never a cross-connection) with a fresh session and rekey enabled: +/// neither cross-connection (same link) nor rekey (age < floor) -> duplicate. +#[test] +fn establish_same_link_fresh_is_duplicate() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x05); + snap.different_link = false; + snap.existing_session_age_secs = 0; + snap.existing_msg2 = Some(vec![0xaa, 0xbb]); + let wire = wire_outcome(0x02, SAME_EPOCH); + match fmp.establish_inbound(&snap, &wire) { + InboundDecision::ResendMsg2 { msg2 } => assert_eq!(msg2, Some(vec![0xaa, 0xbb])), + other => panic!("expected ResendMsg2, got {other:?}"), + } +} + +/// Aged same-epoch session, rekey disabled: not a rekey -> duplicate resend +/// (mirrors the rekey-disabled chartest). +#[test] +fn establish_aged_rekey_disabled_is_duplicate() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x05); + snap.rekey_enabled = false; + snap.existing_session_age_secs = 300; // >= floor, but rekey disabled + let wire = wire_outcome(0x02, SAME_EPOCH); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::ResendMsg2 { .. } + )); +} + +/// Aged, healthy, same-epoch session with no dual-init in flight -> rekey +/// responder, no prior abandon. +#[test] +fn establish_aged_rekey_responds_without_abandon() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x05); + snap.existing_session_age_secs = 300; // >= floor + let wire = wire_outcome(0x02, SAME_EPOCH); + match fmp.establish_inbound(&snap, &wire) { + InboundDecision::RekeyRespond { + peer, + abandon_first, + } => { + assert_eq!(peer, make_node_addr(0x02)); + assert!(!abandon_first); + } + other => panic!("expected RekeyRespond, got {other:?}"), + } +} + +/// Dual-init in the `rekey_in_progress` state, we are the SMALLER node -> +/// tie-break win, drop their msg3. +#[test] +fn establish_dual_init_in_progress_smaller_wins() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x02); // our addr 0x02 (smaller) + snap.existing_session_age_secs = 300; + snap.rekey_in_progress = true; + let wire = wire_outcome(0x09, SAME_EPOCH); // peer 0x09 + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::Reject { + reason: InboundReject::DualRekeyWon + } + )); +} + +/// Dual-init in the `rekey_in_progress` state, we are the LARGER node -> +/// tie-break loss, abandon ours then respond. +#[test] +fn establish_dual_init_in_progress_larger_loses() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x09); // our addr 0x09 (larger) + snap.existing_session_age_secs = 300; + snap.rekey_in_progress = true; + let wire = wire_outcome(0x02, SAME_EPOCH); // peer 0x02 + match fmp.establish_inbound(&snap, &wire) { + InboundDecision::RekeyRespond { abandon_first, .. } => assert!(abandon_first), + other => panic!("expected RekeyRespond{{abandon_first:true}}, got {other:?}"), + } +} + +/// XX-widened dual-init: the `pending_new_session` state (which IK never +/// reached). As the LARGER node we lose -> abandon ours then respond, +/// re-installing pending. +#[test] +fn establish_dual_init_pending_state_larger_loses() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x09); // our addr 0x09 (larger) + snap.existing_session_age_secs = 300; + snap.rekey_in_progress = false; + snap.pending_new_session = true; // the widened window + let wire = wire_outcome(0x02, SAME_EPOCH); + match fmp.establish_inbound(&snap, &wire) { + InboundDecision::RekeyRespond { abandon_first, .. } => assert!(abandon_first), + other => panic!("expected RekeyRespond{{abandon_first:true}}, got {other:?}"), + } +} + +/// XX-widened dual-init `pending_new_session` state as the SMALLER node -> +/// tie-break win, drop their msg3. +#[test] +fn establish_dual_init_pending_state_smaller_wins() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x02); // our addr 0x02 (smaller) + snap.existing_session_age_secs = 300; + snap.pending_new_session = true; + let wire = wire_outcome(0x09, SAME_EPOCH); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::Reject { + reason: InboundReject::DualRekeyWon + } + )); +} + +/// The rekey floor partitions cross-connection from rekey exactly at the +/// boundary: one second below is a cross-connection, at the floor is a rekey. +#[test] +fn establish_rekey_floor_partitions_cross_connection_and_rekey() { + let fmp = Fmp::new(); + let wire = wire_outcome(0x02, SAME_EPOCH); + + let mut below = establish_snapshot(0x09); + below.different_link = true; + below.rekey_age_floor_secs = 100; + below.existing_session_age_secs = 99; + assert!(matches!( + fmp.establish_inbound(&below, &wire), + InboundDecision::CrossConnect { .. } + )); + + let mut at = establish_snapshot(0x09); + at.different_link = true; + at.rekey_age_floor_secs = 100; + at.existing_session_age_secs = 100; + assert!(matches!( + fmp.establish_inbound(&at, &wire), + InboundDecision::RekeyRespond { .. } + )); +} + +/// An unhealthy or session-less aged peer is not a rekey candidate -> duplicate. +#[test] +fn establish_aged_unhealthy_is_duplicate() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(0x05); + snap.existing_session_age_secs = 300; + snap.is_healthy = false; + let wire = wire_outcome(0x02, SAME_EPOCH); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::ResendMsg2 { .. } + )); +} + +// ===== cross_connection_winner tie-break tests ===== + +#[test] +fn test_cross_connection_smaller_node_wins_outbound() { + let node_a = make_node_addr(1); // smaller + let node_b = make_node_addr(2); // larger + + // Node A's perspective + assert!(cross_connection_winner(&node_a, &node_b, true)); // A's outbound wins + assert!(!cross_connection_winner(&node_a, &node_b, false)); // A's inbound loses + + // Node B's perspective + assert!(!cross_connection_winner(&node_b, &node_a, true)); // B's outbound loses + assert!(cross_connection_winner(&node_b, &node_a, false)); // B's inbound wins +} + +#[test] +fn test_cross_connection_symmetric() { + let node_a = make_node_addr(1); + let node_b = make_node_addr(2); + + // A's outbound = B's inbound + let a_outbound_wins = cross_connection_winner(&node_a, &node_b, true); + let b_inbound_wins = cross_connection_winner(&node_b, &node_a, false); + assert_eq!(a_outbound_wins, b_inbound_wins); + + // A's inbound = B's outbound + let a_inbound_wins = cross_connection_winner(&node_a, &node_b, false); + let b_outbound_wins = cross_connection_winner(&node_b, &node_a, true); + assert_eq!(a_inbound_wins, b_outbound_wins); + + // Exactly one survives + assert!(a_outbound_wins != a_inbound_wins); +} + +// ===== Negotiation decision tests (relocated from protocol::negotiation) ===== + +#[test] +fn test_version_agreement_basic() { + let ours = NegotiationPayload::new(1, 3, 0); + let theirs = NegotiationPayload::new(1, 2, 0); + assert_eq!(ours.agree_version(&theirs).unwrap(), 2); +} + +#[test] +fn test_version_agreement_mismatch() { + let ours = NegotiationPayload::new(3, 5, 0); + let theirs = NegotiationPayload::new(1, 2, 0); + assert!(ours.agree_version(&theirs).is_err()); +} + +#[test] +fn test_version_agreement_asymmetric() { + let ours = NegotiationPayload::new(2, 5, 0); + let theirs = NegotiationPayload::new(1, 4, 0); + assert_eq!(ours.agree_version(&theirs).unwrap(), 4); + assert_eq!(theirs.agree_version(&ours).unwrap(), 4); +} + +#[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()); +} + +#[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() { + let p = NegotiationPayload::new(1, 1, 0); + assert_eq!(p.node_profile().unwrap(), NodeProfile::Full); + assert!(!p.provides_sr()); + assert!(!p.wants_sr()); +} + +#[test] +fn test_validate_profiles_valid() { + assert!(NegotiationPayload::validate_profiles(NodeProfile::Full, NodeProfile::Full).is_ok()); + assert!( + NegotiationPayload::validate_profiles(NodeProfile::Full, NodeProfile::NonRouting).is_ok() + ); + assert!( + NegotiationPayload::validate_profiles(NodeProfile::NonRouting, NodeProfile::Full).is_ok() + ); + assert!(NegotiationPayload::validate_profiles(NodeProfile::Full, NodeProfile::Leaf).is_ok()); + assert!(NegotiationPayload::validate_profiles(NodeProfile::Leaf, NodeProfile::Full).is_ok()); +} + +#[test] +fn test_validate_profiles_invalid() { + assert!( + NegotiationPayload::validate_profiles(NodeProfile::NonRouting, NodeProfile::NonRouting) + .is_err() + ); + assert!( + NegotiationPayload::validate_profiles(NodeProfile::NonRouting, NodeProfile::Leaf).is_err() + ); + assert!( + NegotiationPayload::validate_profiles(NodeProfile::Leaf, NodeProfile::NonRouting).is_err() + ); + assert!(NegotiationPayload::validate_profiles(NodeProfile::Leaf, NodeProfile::Leaf).is_err()); +} diff --git a/src/proto/fmp/tests/limits.rs b/src/proto/fmp/tests/limits.rs new file mode 100644 index 0000000..16122c2 --- /dev/null +++ b/src/proto/fmp/tests/limits.rs @@ -0,0 +1,28 @@ +//! Tests for the FMP connection-retry backoff timing helper. + +use crate::proto::fmp::backoff_ms; + +const TEST_MAX_BACKOFF_MS: u64 = 300_000; + +#[test] +fn test_backoff_exponential() { + assert_eq!(backoff_ms(0, 5000, TEST_MAX_BACKOFF_MS), 5000); // 5s * 2^0 + assert_eq!(backoff_ms(1, 5000, TEST_MAX_BACKOFF_MS), 10_000); // 5s * 2^1 + assert_eq!(backoff_ms(2, 5000, TEST_MAX_BACKOFF_MS), 20_000); // 5s * 2^2 + assert_eq!(backoff_ms(3, 5000, TEST_MAX_BACKOFF_MS), 40_000); // 5s * 2^3 + assert_eq!(backoff_ms(4, 5000, TEST_MAX_BACKOFF_MS), 80_000); // 5s * 2^4 +} + +#[test] +fn test_backoff_cap() { + // 2^20 * 5000 would be huge; capped at the max. + assert_eq!( + backoff_ms(20, 5000, TEST_MAX_BACKOFF_MS), + TEST_MAX_BACKOFF_MS + ); +} + +#[test] +fn test_backoff_zero_base() { + assert_eq!(backoff_ms(3, 0, TEST_MAX_BACKOFF_MS), 0); +} diff --git a/src/proto/fmp/tests/mod.rs b/src/proto/fmp/tests/mod.rs new file mode 100644 index 0000000..7bad380 --- /dev/null +++ b/src/proto/fmp/tests/mod.rs @@ -0,0 +1,8 @@ +//! FMP connection-lifecycle subsystem unit tests. Shared helpers live in +//! `util`. + +mod core; +mod limits; +mod state; +mod util; +mod wire; diff --git a/src/proto/fmp/tests/state.rs b/src/proto/fmp/tests/state.rs new file mode 100644 index 0000000..f3be620 --- /dev/null +++ b/src/proto/fmp/tests/state.rs @@ -0,0 +1,198 @@ +//! Unit tests for the pure FMP connection state ([`ConnectionState`]) and its +//! [`HandshakeState`] phase enum. These exercise the extracted bookkeeping +//! directly, with no crypto involved; the crypto-driving transition behavior is +//! covered by the shell `peer::connection` suite. + +use crate::proto::fmp::{ConnectionState, HandshakeState, NodeProfile}; +use crate::transport::{LinkId, TransportAddr, TransportId}; +use crate::utils::index::SessionIndex; +use crate::{Identity, PeerIdentity}; + +fn make_peer_identity() -> PeerIdentity { + PeerIdentity::from_pubkey(Identity::generate().pubkey()) +} + +#[test] +fn handshake_state_predicates() { + assert!(HandshakeState::Initial.is_in_progress()); + assert!(HandshakeState::SentMsg1.is_in_progress()); + assert!(HandshakeState::ReceivedMsg1.is_in_progress()); + assert!(!HandshakeState::Complete.is_in_progress()); + assert!(!HandshakeState::Failed.is_in_progress()); + + assert!(HandshakeState::Complete.is_complete()); + assert!(!HandshakeState::Initial.is_complete()); + + assert!(HandshakeState::Failed.is_failed()); + assert!(!HandshakeState::Complete.is_failed()); +} + +#[test] +fn outbound_initializes_pure_fields() { + let identity = make_peer_identity(); + let state = ConnectionState::outbound(LinkId::new(1), identity, 1000); + + assert!(state.is_outbound()); + assert!(!state.is_inbound()); + assert_eq!(state.handshake_state(), HandshakeState::Initial); + assert!(state.is_in_progress()); + assert!(state.expected_identity().is_some()); + assert_eq!(state.link_id(), LinkId::new(1)); + assert_eq!(state.started_at(), 1000); + assert_eq!(state.last_activity(), 1000); + assert!(state.transport_id().is_none()); + assert!(state.source_addr().is_none()); + assert!(state.remote_epoch().is_none()); + assert_eq!(state.resend_count(), 0); + assert_eq!(state.next_resend_at_ms(), 0); +} + +#[test] +fn inbound_initializes_pure_fields() { + let state = ConnectionState::inbound(LinkId::new(2), 2000); + + assert!(state.is_inbound()); + assert!(!state.is_outbound()); + assert_eq!(state.handshake_state(), HandshakeState::Initial); + assert!(state.expected_identity().is_none()); + assert_eq!(state.started_at(), 2000); +} + +#[test] +fn inbound_with_transport_sets_transport_and_addr() { + let addr = TransportAddr::from_string("192.0.2.1:5000"); + let state = + ConnectionState::inbound_with_transport(LinkId::new(3), TransportId::new(7), addr, 3000); + + assert!(state.is_inbound()); + assert_eq!(state.transport_id(), Some(TransportId::new(7))); + assert_eq!( + state.source_addr().map(|a| a.as_str().unwrap().to_string()), + Some("192.0.2.1:5000".to_string()) + ); +} + +#[test] +fn index_setters_round_trip() { + let mut state = ConnectionState::inbound(LinkId::new(1), 0); + assert!(state.our_index().is_none()); + assert!(state.their_index().is_none()); + + state.set_our_index(SessionIndex::new(0x1111)); + state.set_their_index(SessionIndex::new(0x2222)); + assert_eq!(state.our_index(), Some(SessionIndex::new(0x1111))); + assert_eq!(state.their_index(), Some(SessionIndex::new(0x2222))); +} + +#[test] +fn transport_and_source_setters_round_trip() { + let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 0); + state.set_transport_id(TransportId::new(9)); + state.set_source_addr(TransportAddr::from_string("peer")); + assert_eq!(state.transport_id(), Some(TransportId::new(9))); + assert_eq!(state.source_addr().and_then(|a| a.as_str()), Some("peer")); +} + +#[test] +fn identity_and_epoch_setters() { + let mut state = ConnectionState::inbound(LinkId::new(1), 0); + assert!(state.expected_identity().is_none()); + assert!(state.remote_epoch().is_none()); + + let identity = make_peer_identity(); + let node_addr = *identity.node_addr(); + state.set_expected_identity(identity); + state.set_remote_epoch(Some([9u8; 8])); + + assert_eq!( + state.expected_identity().map(|id| *id.node_addr()), + Some(node_addr) + ); + assert_eq!(state.remote_epoch(), Some([9u8; 8])); +} + +#[test] +fn handshake_state_advance_and_fail() { + let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 0); + assert!(state.is_in_progress()); + + state.set_handshake_state(HandshakeState::SentMsg1); + assert_eq!(state.handshake_state(), HandshakeState::SentMsg1); + assert!(state.is_in_progress()); + assert!(!state.is_complete()); + + state.set_handshake_state(HandshakeState::Complete); + assert!(state.is_complete()); + assert!(!state.is_in_progress()); + + state.mark_failed(); + assert!(state.is_failed()); + assert!(!state.is_in_progress()); + assert!(!state.is_complete()); + assert_eq!(state.handshake_state(), HandshakeState::Failed); +} + +#[test] +fn resend_bookkeeping() { + let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 0); + assert!(state.handshake_msg1().is_none()); + assert!(state.handshake_msg2().is_none()); + + state.set_handshake_msg1(vec![1, 2, 3], 500); + assert_eq!(state.handshake_msg1(), Some(&[1u8, 2, 3][..])); + assert_eq!(state.resend_count(), 0); + assert_eq!(state.next_resend_at_ms(), 500); + + state.record_resend(900); + assert_eq!(state.resend_count(), 1); + assert_eq!(state.next_resend_at_ms(), 900); + + state.record_resend(1300); + assert_eq!(state.resend_count(), 2); + assert_eq!(state.next_resend_at_ms(), 1300); + + // set_handshake_msg1 resets the resend counter. + state.set_handshake_msg1(vec![4, 5], 100); + assert_eq!(state.handshake_msg1(), Some(&[4u8, 5][..])); + assert_eq!(state.resend_count(), 0); + assert_eq!(state.next_resend_at_ms(), 100); + + state.set_handshake_msg2(vec![6, 7, 8]); + assert_eq!(state.handshake_msg2(), Some(&[6u8, 7, 8][..])); +} + +#[test] +fn timing_and_touch() { + let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 1000); + assert_eq!(state.duration(1500), 500); + assert_eq!(state.idle_time(1500), 500); + assert!(!state.is_timed_out(1500, 1000)); + assert!(state.is_timed_out(2500, 1000)); + + // touch resets idle_time but not duration. + state.touch(2000); + assert_eq!(state.last_activity(), 2000); + assert_eq!(state.idle_time(2500), 500); + assert_eq!(state.duration(2500), 1500); + assert!(!state.is_timed_out(2500, 1000)); +} + +#[test] +fn outbound_anonymous_initializes_pure_fields() { + let state = ConnectionState::outbound_anonymous(LinkId::new(5), 4000); + assert!(state.is_outbound()); + assert!(!state.is_inbound()); + assert_eq!(state.handshake_state(), HandshakeState::Initial); + assert!(state.expected_identity().is_none()); + assert_eq!(state.link_id(), LinkId::new(5)); + assert_eq!(state.started_at(), 4000); + assert!(state.peer_profile().is_none()); +} + +#[test] +fn negotiation_results_round_trip() { + let mut state = ConnectionState::inbound(LinkId::new(1), 0); + assert!(state.peer_profile().is_none()); + state.set_negotiation_results(NodeProfile::Full); + assert_eq!(state.peer_profile(), Some(NodeProfile::Full)); +} diff --git a/src/proto/fmp/tests/util.rs b/src/proto/fmp/tests/util.rs new file mode 100644 index 0000000..269d30c --- /dev/null +++ b/src/proto/fmp/tests/util.rs @@ -0,0 +1,103 @@ +//! Shared test helpers for the FMP connection-lifecycle unit tests. + +use crate::proto::fmp::{ + ConnSnapshot, EstablishSnapshot, PeerSnapshot, RekeyResendSnapshot, WireOutcome, +}; +use crate::testutil::make_node_addr; +use crate::transport::LinkId; + +/// Build a `RekeyResendSnapshot` for the given peer byte, prior retransmission +/// count, due-flag, and opaque msg1 bytes. +pub(super) fn rekey_resend_snapshot( + peer_byte: u8, + resend_count: u32, + needs_resend: bool, + msg1: Vec, +) -> RekeyResendSnapshot { + RekeyResendSnapshot { + peer: make_node_addr(peer_byte), + resend_count, + needs_resend, + msg1, + } +} + +/// Build a quiescent `PeerSnapshot` for `addr`: session-healthy but with no +/// pending cutover, no drain, no dampening, zero ages/counter/jitter. Tests set +/// only the fields the case exercises. +pub(super) fn peer_snapshot(addr_byte: u8) -> PeerSnapshot { + PeerSnapshot { + addr: make_node_addr(addr_byte), + has_pending: false, + rekey_in_progress: false, + is_draining: false, + drain_expired: false, + is_dampened: false, + rekey_msg3_pending: false, + elapsed_secs: 0, + counter: 0, + jitter_secs: 0, + } +} + +/// Build a `ConnSnapshot` for the teardown path with the given link, direction, +/// and retry target. Fields the teardown decision ignores are left at their +/// natural defaults. +pub(super) fn stale_snapshot( + link: LinkId, + is_outbound: bool, + retry_addr: Option, +) -> ConnSnapshot { + ConnSnapshot { + link, + is_outbound, + retry_addr, + resend_count: 0, + msg1: Vec::new(), + } +} + +/// Build a `ConnSnapshot` for the msg1-resend path with the given link, prior +/// resend count, and opaque msg1 bytes. Fields the resend decision ignores are +/// left at their natural defaults. +pub(super) fn resend_snapshot(link: LinkId, resend_count: u32, msg1: Vec) -> ConnSnapshot { + ConnSnapshot { + link, + is_outbound: true, + retry_addr: None, + resend_count, + msg1, + } +} + +/// Build an `EstablishSnapshot` describing an existing, healthy, same-epoch peer +/// with a rekey-enabled config and a rekey age floor of 100s, owned by node +/// `our_byte`. The default is a quiescent, still-fresh session (age 0, same +/// link, no in-flight rekey / pending). Tests override only the fields their +/// branch exercises. `existing_peer_epoch` defaults to `[0x01; 8]`. +pub(super) fn establish_snapshot(our_byte: u8) -> EstablishSnapshot { + EstablishSnapshot { + has_existing_peer: true, + existing_peer_epoch: Some([0x01; 8]), + existing_session_age_secs: 0, + has_session: true, + is_healthy: true, + pending_new_session: false, + rekey_in_progress: false, + existing_msg2: None, + different_link: false, + rekey_enabled: true, + rekey_age_floor_secs: 100, + our_node_addr: make_node_addr(our_byte), + } +} + +/// Build a `WireOutcome` naming the initiator `peer_byte` and its captured +/// startup epoch. `[0x01; 8]` matches the `establish_snapshot` default (a +/// same-epoch handshake); a different epoch models a restart. +pub(super) fn wire_outcome(peer_byte: u8, epoch: [u8; 8]) -> WireOutcome { + WireOutcome { + peer_node_addr: make_node_addr(peer_byte), + remote_epoch: Some(epoch), + } +} diff --git a/src/proto/fmp/tests/wire.rs b/src/proto/fmp/tests/wire.rs new file mode 100644 index 0000000..5e31b46 --- /dev/null +++ b/src/proto/fmp/tests/wire.rs @@ -0,0 +1,218 @@ +//! Tests for the FMP wire codec: XX handshake framing, orderly disconnect, +//! and the negotiation payload. + +use super::super::wire::NEGOTIATION_HEADER_SIZE; +use crate::proto::fmp::{ + Disconnect, DisconnectReason, HandshakeMessageType, NegotiationPayload, NodeProfile, +}; + +// ===== HandshakeMessageType Tests ===== + +#[test] +fn test_handshake_message_type_roundtrip() { + let types = [ + HandshakeMessageType::Msg1, + HandshakeMessageType::Msg2, + HandshakeMessageType::Msg3, + ]; + + for ty in types { + let byte = ty.to_byte(); + let restored = HandshakeMessageType::from_byte(byte); + assert_eq!(restored, Some(ty)); + } +} + +#[test] +fn test_handshake_message_type_invalid() { + assert!(HandshakeMessageType::from_byte(0x00).is_none()); + assert!(HandshakeMessageType::from_byte(0x04).is_none()); + assert!(HandshakeMessageType::from_byte(0x10).is_none()); +} + +#[test] +fn test_handshake_message_type_is_handshake() { + assert!(HandshakeMessageType::is_handshake(0x01)); + assert!(HandshakeMessageType::is_handshake(0x02)); + assert!(HandshakeMessageType::is_handshake(0x03)); + assert!(!HandshakeMessageType::is_handshake(0x00)); + assert!(!HandshakeMessageType::is_handshake(0x04)); + assert!(!HandshakeMessageType::is_handshake(0x10)); +} + +// ===== DisconnectReason Tests ===== + +#[test] +fn test_disconnect_reason_roundtrip() { + let reasons = [ + DisconnectReason::Shutdown, + DisconnectReason::Restart, + DisconnectReason::ProtocolError, + DisconnectReason::TransportFailure, + DisconnectReason::ResourceExhaustion, + DisconnectReason::SecurityViolation, + DisconnectReason::ConfigurationChange, + DisconnectReason::Timeout, + DisconnectReason::Other, + ]; + + for reason in reasons { + let byte = reason.to_byte(); + let restored = DisconnectReason::from_byte(byte); + assert_eq!(restored, Some(reason)); + } +} + +#[test] +fn test_disconnect_reason_unknown_byte() { + assert!(DisconnectReason::from_byte(0x08).is_none()); + assert!(DisconnectReason::from_byte(0x80).is_none()); + assert!(DisconnectReason::from_byte(0xFE).is_none()); +} + +// ===== Disconnect Message Tests ===== + +#[test] +fn test_disconnect_encode_decode() { + let msg = Disconnect::new(DisconnectReason::Shutdown); + let encoded = msg.encode(); + + assert_eq!(encoded.len(), 2); + assert_eq!(encoded[0], 0x50); // LinkMessageType::Disconnect + assert_eq!(encoded[1], 0x00); // DisconnectReason::Shutdown + + // Decode from payload (after msg_type byte) + let decoded = Disconnect::decode(&encoded[1..]).unwrap(); + assert_eq!(decoded.reason, DisconnectReason::Shutdown); +} + +#[test] +fn test_disconnect_all_reasons() { + let reasons = [ + DisconnectReason::Shutdown, + DisconnectReason::Restart, + DisconnectReason::ProtocolError, + DisconnectReason::Other, + ]; + + for reason in reasons { + let msg = Disconnect::new(reason); + let encoded = msg.encode(); + let decoded = Disconnect::decode(&encoded[1..]).unwrap(); + assert_eq!(decoded.reason, reason); + } +} + +#[test] +fn test_disconnect_decode_empty_payload() { + let result = Disconnect::decode(&[]); + assert!(result.is_err()); +} + +#[test] +fn test_disconnect_decode_unknown_reason() { + let decoded = Disconnect::decode(&[0x80]).unwrap(); + assert_eq!(decoded.reason, DisconnectReason::Other); +} + +// ===== Negotiation payload codec Tests ===== + +#[test] +fn test_encode_decode_roundtrip() { + let payload = NegotiationPayload::new(1, 3, 0x00000000_0000002A); + let encoded = payload.encode(); + assert_eq!(encoded.len(), NEGOTIATION_HEADER_SIZE); + + let decoded = NegotiationPayload::decode(&encoded).unwrap(); + assert_eq!(decoded, payload); +} + +#[test] +fn test_encode_decode_with_tlv() { + let payload = NegotiationPayload::new(0, 1, 0) + .with_tlv(1, vec![0xAA, 0xBB]) + .with_tlv(256, vec![0x01, 0x02, 0x03, 0x04]); + + let encoded = payload.encode(); + // 10 header + (2+2+2) + (2+2+4) = 10 + 6 + 8 = 24 + assert_eq!(encoded.len(), 24); + + let decoded = NegotiationPayload::decode(&encoded).unwrap(); + assert_eq!(decoded, payload); + assert_eq!(decoded.tlv_entries.len(), 2); + assert_eq!(decoded.tlv_entries[0].field_num, 1); + assert_eq!(decoded.tlv_entries[0].value, vec![0xAA, 0xBB]); + assert_eq!(decoded.tlv_entries[1].field_num, 256); + assert_eq!(decoded.tlv_entries[1].value, vec![0x01, 0x02, 0x03, 0x04]); +} + +#[test] +fn test_unknown_format_rejected() { + let mut data = NegotiationPayload::new(0, 0, 0).encode(); + data[0] = 1; // Set format to 1 + assert!(NegotiationPayload::decode(&data).is_err()); +} + +#[test] +fn test_invalid_version_range() { + let mut data = NegotiationPayload::new(0, 0, 0).encode(); + // Set version_min=5, version_max=3 (invalid: min > max) + data[1] = (5 << 4) | 3; + assert!(NegotiationPayload::decode(&data).is_err()); +} + +#[test] +fn test_unknown_tlv_forward_compat() { + // Unknown field_nums should be preserved through encode/decode + let payload = NegotiationPayload::new(0, 1, 0).with_tlv(9999, vec![0xFF, 0xFE, 0xFD]); + + let encoded = payload.encode(); + let decoded = NegotiationPayload::decode(&encoded).unwrap(); + assert_eq!(decoded.tlv_entries.len(), 1); + assert_eq!(decoded.tlv_entries[0].field_num, 9999); + assert_eq!(decoded.tlv_entries[0].value, vec![0xFF, 0xFE, 0xFD]); +} + +#[test] +fn test_empty_payload() { + let payload = NegotiationPayload::new(0, 0, 0); + let encoded = payload.encode(); + assert_eq!(encoded.len(), NEGOTIATION_HEADER_SIZE); + + let decoded = NegotiationPayload::decode(&encoded).unwrap(); + assert_eq!(decoded.version_min, 0); + assert_eq!(decoded.version_max, 0); + assert_eq!(decoded.features, 0); + assert!(decoded.tlv_entries.is_empty()); +} + +#[test] +fn test_truncated_payload() { + // Less than header size + assert!(NegotiationPayload::decode(&[0u8; 5]).is_err()); + assert!(NegotiationPayload::decode(&[]).is_err()); +} + +#[test] +fn test_truncated_tlv() { + let payload = NegotiationPayload::new(0, 1, 0).with_tlv(1, vec![0xAA, 0xBB, 0xCC]); + let mut encoded = payload.encode(); + + // Truncate the TLV value (remove last byte) + encoded.pop(); + assert!(NegotiationPayload::decode(&encoded).is_err()); + + // Truncate to just partial TLV header (only 2 of 4 header bytes) + let mut partial = NegotiationPayload::new(0, 1, 0).encode(); + partial.extend_from_slice(&[0x01, 0x00]); // Only field_num, no length + assert!(NegotiationPayload::decode(&partial).is_err()); +} + +#[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()); +} diff --git a/src/proto/fmp/wire.rs b/src/proto/fmp/wire.rs new file mode 100644 index 0000000..9f532ba --- /dev/null +++ b/src/proto/fmp/wire.rs @@ -0,0 +1,379 @@ +//! FMP wire codec: XX handshake framing, orderly disconnect, and the +//! protocol-negotiation payload. +//! +//! The Noise XX handshake message-type discriminants and the orderly +//! disconnect codec, relocated from `protocol::link`, plus the +//! negotiation-payload codec relocated from `protocol::negotiation`, per the +//! wire-migrates-with-subsystem policy. `Disconnect::encode` reads the shared +//! `LinkMessageType::Disconnect` catalog variant (a downward `proto -> +//! protocol` dependency); the catalog itself stays in `protocol::link`. The +//! negotiation *decision* logic (version agreement, profile validation, FMP +//! feature helpers) lives in `core.rs`; only the payload codec is here. + +use crate::protocol::{LinkMessageType, ProtocolError}; +use std::fmt; + +/// Handshake message type identifiers. +/// +/// These messages are exchanged during Noise XX handshake before link +/// encryption is established. They use the same TLV framing as link +/// messages but payloads are not encrypted (except Noise-internal encryption). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum HandshakeMessageType { + /// Noise XX message 1: initiator sends ephemeral key. + /// Payload: 33 bytes (ephemeral pubkey). + Msg1 = 0x01, + + /// Noise XX message 2: responder sends ephemeral + encrypted static + epoch. + /// Payload: 106+ bytes (33 ephemeral + 49 encrypted static + 24 encrypted epoch + negotiation). + Msg2 = 0x02, + + /// Noise XX message 3: initiator sends encrypted static + epoch. + /// Payload: 73+ bytes (49 encrypted static + 24 encrypted epoch + negotiation). + Msg3 = 0x03, +} + +impl HandshakeMessageType { + /// Try to convert from a byte. + pub fn from_byte(b: u8) -> Option { + match b { + 0x01 => Some(HandshakeMessageType::Msg1), + 0x02 => Some(HandshakeMessageType::Msg2), + 0x03 => Some(HandshakeMessageType::Msg3), + _ => None, + } + } + + /// Convert to a byte. + pub fn to_byte(self) -> u8 { + self as u8 + } + + /// Check if a byte represents a handshake message type. + pub fn is_handshake(b: u8) -> bool { + matches!(b, 0x01..=0x03) + } +} + +impl fmt::Display for HandshakeMessageType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + HandshakeMessageType::Msg1 => "Msg1", + HandshakeMessageType::Msg2 => "Msg2", + HandshakeMessageType::Msg3 => "Msg3", + }; + write!(f, "{}", name) + } +} + +/// Reason for an orderly disconnect notification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum DisconnectReason { + /// Normal shutdown (operator requested). + Shutdown = 0x00, + /// Restarting (may reconnect soon). + Restart = 0x01, + /// Protocol error encountered. + ProtocolError = 0x02, + /// Transport failure. + TransportFailure = 0x03, + /// Resource exhaustion (memory, connections). + ResourceExhaustion = 0x04, + /// Authentication or security policy violation. + SecurityViolation = 0x05, + /// Configuration change (peer removed from config). + ConfigurationChange = 0x06, + /// Timeout or keepalive failure. + Timeout = 0x07, + /// Unspecified reason. + Other = 0xFF, +} + +impl DisconnectReason { + /// Try to convert from a byte. + pub fn from_byte(b: u8) -> Option { + match b { + 0x00 => Some(DisconnectReason::Shutdown), + 0x01 => Some(DisconnectReason::Restart), + 0x02 => Some(DisconnectReason::ProtocolError), + 0x03 => Some(DisconnectReason::TransportFailure), + 0x04 => Some(DisconnectReason::ResourceExhaustion), + 0x05 => Some(DisconnectReason::SecurityViolation), + 0x06 => Some(DisconnectReason::ConfigurationChange), + 0x07 => Some(DisconnectReason::Timeout), + 0xFF => Some(DisconnectReason::Other), + _ => None, + } + } + + /// Convert to a byte. + pub fn to_byte(self) -> u8 { + self as u8 + } +} + +impl fmt::Display for DisconnectReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + DisconnectReason::Shutdown => "Shutdown", + DisconnectReason::Restart => "Restart", + DisconnectReason::ProtocolError => "ProtocolError", + DisconnectReason::TransportFailure => "TransportFailure", + DisconnectReason::ResourceExhaustion => "ResourceExhaustion", + DisconnectReason::SecurityViolation => "SecurityViolation", + DisconnectReason::ConfigurationChange => "ConfigurationChange", + DisconnectReason::Timeout => "Timeout", + DisconnectReason::Other => "Other", + }; + write!(f, "{}", name) + } +} + +/// Orderly disconnect notification sent before closing a peer link. +/// +/// Sent as a link-layer message (type 0x50) inside an encrypted frame. +/// Allows the receiving peer to immediately clean up state rather than +/// waiting for timeout-based detection. +/// +/// ## Wire Format +/// +/// | Offset | Field | Size | Notes | +/// |--------|----------|--------|------------------------| +/// | 0 | msg_type | 1 byte | 0x50 | +/// | 1 | reason | 1 byte | DisconnectReason value | +#[derive(Clone, Debug)] +pub struct Disconnect { + /// Reason for disconnection. + pub reason: DisconnectReason, +} + +impl Disconnect { + /// Create a new Disconnect message. + pub fn new(reason: DisconnectReason) -> Self { + Self { reason } + } + + /// Encode as link-layer plaintext (msg_type + reason). + pub fn encode(&self) -> [u8; 2] { + [LinkMessageType::Disconnect.to_byte(), self.reason.to_byte()] + } + + /// Decode from link-layer payload (after msg_type byte has been consumed). + pub fn decode(payload: &[u8]) -> Result { + if payload.is_empty() { + return Err(ProtocolError::MessageTooShort { + expected: 1, + got: 0, + }); + } + let reason = DisconnectReason::from_byte(payload[0]).unwrap_or(DisconnectReason::Other); + Ok(Self { reason }) + } +} + +// ============================================================================ +// Protocol Negotiation Payload +// ============================================================================ +// +// Encodes/decodes the negotiation payload embedded in XX handshake +// messages (msg2/msg3). Each layer (FMP, FSP) uses the same wire format with +// layer-specific version ranges and feature catalogs. +// +// Wire Format: +// Byte 0: format (must be 0) +// Byte 1: [version_min:4 high][version_max:4 low] +// Bytes 2-9: feature bitfield (64 bits, LE) +// Bytes 10+: TLV entries, each: [field_num:2 LE][length:2 LE][value:N] + +/// Size of the fixed negotiation header (format + version + features). +pub const NEGOTIATION_HEADER_SIZE: usize = 10; + +/// Format byte value for the initial negotiation format. +pub(crate) 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; + +/// 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 fmt::Display for NodeProfile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Full => write!(f, "full"), + Self::NonRouting => write!(f, "non-routing"), + Self::Leaf => write!(f, "leaf"), + } + } +} + +impl TryFrom for NodeProfile { + type Error = ProtocolError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::Full), + 1 => Ok(Self::NonRouting), + 2 => Ok(Self::Leaf), + _ => Err(ProtocolError::Malformed(format!( + "unknown node profile: {value}" + ))), + } + } +} + +/// A TLV entry in the negotiation payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TlvEntry { + /// Field number identifying this TLV. + pub field_num: u16, + /// Raw value bytes. + pub value: Vec, +} + +/// Protocol negotiation payload. +/// +/// Carried in XX msg2/msg3 encrypted payloads. Shared codec for both +/// FMP and FSP layers, with layer-specific version ranges and feature +/// bit assignments. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegotiationPayload { + /// Format byte (must be 0). + pub format: u8, + /// Minimum protocol version supported (4-bit, 0-15). + pub version_min: u8, + /// Maximum protocol version supported (4-bit, 0-15). + pub version_max: u8, + /// Feature bitfield (64 bits, LE). + pub features: u64, + /// Optional TLV extension entries. + pub tlv_entries: Vec, +} + +impl NegotiationPayload { + /// Create a new negotiation payload. + pub fn new(version_min: u8, version_max: u8, features: u64) -> Self { + Self { + format: NEGOTIATION_FORMAT_V0, + version_min, + version_max, + features, + tlv_entries: Vec::new(), + } + } + + /// Add a TLV entry. + pub fn with_tlv(mut self, field_num: u16, value: Vec) -> Self { + self.tlv_entries.push(TlvEntry { field_num, value }); + self + } + + /// Encode to wire format. + pub fn encode(&self) -> Vec { + let mut buf = Vec::with_capacity(NEGOTIATION_HEADER_SIZE); + + buf.push(self.format); + buf.push((self.version_min << 4) | (self.version_max & 0x0F)); + buf.extend_from_slice(&self.features.to_le_bytes()); + + for entry in &self.tlv_entries { + buf.extend_from_slice(&entry.field_num.to_le_bytes()); + let len = entry.value.len() as u16; + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&entry.value); + } + + buf + } + + /// Decode from wire format. + pub fn decode(data: &[u8]) -> Result { + if data.len() < NEGOTIATION_HEADER_SIZE { + return Err(ProtocolError::MessageTooShort { + expected: NEGOTIATION_HEADER_SIZE, + got: data.len(), + }); + } + + let format = data[0]; + if format != NEGOTIATION_FORMAT_V0 { + return Err(ProtocolError::Malformed(format!( + "unknown negotiation format: {format}" + ))); + } + + let version_min = data[1] >> 4; + let version_max = data[1] & 0x0F; + if version_min > version_max { + return Err(ProtocolError::Malformed(format!( + "version_min ({version_min}) > version_max ({version_max})" + ))); + } + + let features = u64::from_le_bytes(data[2..10].try_into().unwrap()); + + let mut tlv_entries = Vec::new(); + let mut offset = NEGOTIATION_HEADER_SIZE; + while offset < data.len() { + // Need at least 4 bytes for field_num + length + if offset + 4 > data.len() { + return Err(ProtocolError::Malformed("truncated TLV header".to_string())); + } + + let field_num = u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap()); + let length = + u16::from_le_bytes(data[offset + 2..offset + 4].try_into().unwrap()) as usize; + offset += 4; + + if offset + length > data.len() { + return Err(ProtocolError::Malformed(format!( + "TLV field {field_num}: declared length {length} exceeds remaining data {}", + data.len() - offset + ))); + } + + let value = data[offset..offset + length].to_vec(); + offset += length; + + tlv_entries.push(TlvEntry { field_num, value }); + } + + Ok(Self { + format, + version_min, + version_max, + features, + tlv_entries, + }) + } +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 78c2fc8..ccadfce 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -4,4 +4,5 @@ //! I/O adapters remain in `node::handlers`. pub(crate) mod discovery; +pub(crate) mod fmp; pub(crate) mod routing; diff --git a/src/protocol/link.rs b/src/protocol/link.rs index e8044f3..77105c1 100644 --- a/src/protocol/link.rs +++ b/src/protocol/link.rs @@ -1,67 +1,9 @@ -//! Link-layer message types: handshake, link control, disconnect, session datagram. +//! Link-layer message types: the shared frame catalog and session datagram. use super::ProtocolError; use crate::NodeAddr; use std::fmt; -// ============================================================================ -// Handshake Message Types -// ============================================================================ - -/// Handshake message type identifiers. -/// -/// These messages are exchanged during Noise XX handshake before link -/// encryption is established. They use the same TLV framing as link -/// messages but payloads are not encrypted (except Noise-internal encryption). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[repr(u8)] -pub enum HandshakeMessageType { - /// Noise XX message 1: initiator sends ephemeral key. - /// Payload: 33 bytes (ephemeral pubkey). - Msg1 = 0x01, - - /// Noise XX message 2: responder sends ephemeral + encrypted static + epoch. - /// Payload: 106+ bytes (33 ephemeral + 49 encrypted static + 24 encrypted epoch + negotiation). - Msg2 = 0x02, - - /// Noise XX message 3: initiator sends encrypted static + epoch. - /// Payload: 73+ bytes (49 encrypted static + 24 encrypted epoch + negotiation). - Msg3 = 0x03, -} - -impl HandshakeMessageType { - /// Try to convert from a byte. - pub fn from_byte(b: u8) -> Option { - match b { - 0x01 => Some(HandshakeMessageType::Msg1), - 0x02 => Some(HandshakeMessageType::Msg2), - 0x03 => Some(HandshakeMessageType::Msg3), - _ => None, - } - } - - /// Convert to a byte. - pub fn to_byte(self) -> u8 { - self as u8 - } - - /// Check if a byte represents a handshake message type. - pub fn is_handshake(b: u8) -> bool { - matches!(b, 0x01..=0x03) - } -} - -impl fmt::Display for HandshakeMessageType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match self { - HandshakeMessageType::Msg1 => "Msg1", - HandshakeMessageType::Msg2 => "Msg2", - HandshakeMessageType::Msg3 => "Msg3", - }; - write!(f, "{}", name) - } -} - // ============================================================================ // Link-Layer Message Types // ============================================================================ @@ -151,120 +93,6 @@ impl fmt::Display for LinkMessageType { } } -// ============================================================================ -// Disconnect Reason Codes -// ============================================================================ - -/// Reason for an orderly disconnect notification. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[repr(u8)] -pub enum DisconnectReason { - /// Normal shutdown (operator requested). - Shutdown = 0x00, - /// Restarting (may reconnect soon). - Restart = 0x01, - /// Protocol error encountered. - ProtocolError = 0x02, - /// Transport failure. - TransportFailure = 0x03, - /// Resource exhaustion (memory, connections). - ResourceExhaustion = 0x04, - /// Authentication or security policy violation. - SecurityViolation = 0x05, - /// Configuration change (peer removed from config). - ConfigurationChange = 0x06, - /// Timeout or keepalive failure. - Timeout = 0x07, - /// Unspecified reason. - Other = 0xFF, -} - -impl DisconnectReason { - /// Try to convert from a byte. - pub fn from_byte(b: u8) -> Option { - match b { - 0x00 => Some(DisconnectReason::Shutdown), - 0x01 => Some(DisconnectReason::Restart), - 0x02 => Some(DisconnectReason::ProtocolError), - 0x03 => Some(DisconnectReason::TransportFailure), - 0x04 => Some(DisconnectReason::ResourceExhaustion), - 0x05 => Some(DisconnectReason::SecurityViolation), - 0x06 => Some(DisconnectReason::ConfigurationChange), - 0x07 => Some(DisconnectReason::Timeout), - 0xFF => Some(DisconnectReason::Other), - _ => None, - } - } - - /// Convert to a byte. - pub fn to_byte(self) -> u8 { - self as u8 - } -} - -impl fmt::Display for DisconnectReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match self { - DisconnectReason::Shutdown => "Shutdown", - DisconnectReason::Restart => "Restart", - DisconnectReason::ProtocolError => "ProtocolError", - DisconnectReason::TransportFailure => "TransportFailure", - DisconnectReason::ResourceExhaustion => "ResourceExhaustion", - DisconnectReason::SecurityViolation => "SecurityViolation", - DisconnectReason::ConfigurationChange => "ConfigurationChange", - DisconnectReason::Timeout => "Timeout", - DisconnectReason::Other => "Other", - }; - write!(f, "{}", name) - } -} - -// ============================================================================ -// Disconnect Message -// ============================================================================ - -/// Orderly disconnect notification sent before closing a peer link. -/// -/// Sent as a link-layer message (type 0x50) inside an encrypted frame. -/// Allows the receiving peer to immediately clean up state rather than -/// waiting for timeout-based detection. -/// -/// ## Wire Format -/// -/// | Offset | Field | Size | Notes | -/// |--------|----------|--------|------------------------| -/// | 0 | msg_type | 1 byte | 0x50 | -/// | 1 | reason | 1 byte | DisconnectReason value | -#[derive(Clone, Debug)] -pub struct Disconnect { - /// Reason for disconnection. - pub reason: DisconnectReason, -} - -impl Disconnect { - /// Create a new Disconnect message. - pub fn new(reason: DisconnectReason) -> Self { - Self { reason } - } - - /// Encode as link-layer plaintext (msg_type + reason). - pub fn encode(&self) -> [u8; 2] { - [LinkMessageType::Disconnect.to_byte(), self.reason.to_byte()] - } - - /// Decode from link-layer payload (after msg_type byte has been consumed). - pub fn decode(payload: &[u8]) -> Result { - if payload.is_empty() { - return Err(ProtocolError::MessageTooShort { - expected: 1, - got: 0, - }); - } - let reason = DisconnectReason::from_byte(payload[0]).unwrap_or(DisconnectReason::Other); - Ok(Self { reason }) - } -} - // ============================================================================ // Session Datagram (Link-Layer Encapsulation) // ============================================================================ @@ -427,40 +255,6 @@ pub type MessageType = LinkMessageType; mod tests { use super::*; - // ===== HandshakeMessageType Tests ===== - - #[test] - fn test_handshake_message_type_roundtrip() { - let types = [ - HandshakeMessageType::Msg1, - HandshakeMessageType::Msg2, - HandshakeMessageType::Msg3, - ]; - - for ty in types { - let byte = ty.to_byte(); - let restored = HandshakeMessageType::from_byte(byte); - assert_eq!(restored, Some(ty)); - } - } - - #[test] - fn test_handshake_message_type_invalid() { - assert!(HandshakeMessageType::from_byte(0x00).is_none()); - assert!(HandshakeMessageType::from_byte(0x04).is_none()); - assert!(HandshakeMessageType::from_byte(0x10).is_none()); - } - - #[test] - fn test_handshake_message_type_is_handshake() { - assert!(HandshakeMessageType::is_handshake(0x01)); - assert!(HandshakeMessageType::is_handshake(0x02)); - assert!(HandshakeMessageType::is_handshake(0x03)); - assert!(!HandshakeMessageType::is_handshake(0x00)); - assert!(!HandshakeMessageType::is_handshake(0x04)); - assert!(!HandshakeMessageType::is_handshake(0x10)); - } - // ===== LinkMessageType Tests ===== #[test] @@ -490,81 +284,6 @@ mod tests { assert!(LinkMessageType::from_byte(0x40).is_none()); } - // ===== DisconnectReason Tests ===== - - #[test] - fn test_disconnect_reason_roundtrip() { - let reasons = [ - DisconnectReason::Shutdown, - DisconnectReason::Restart, - DisconnectReason::ProtocolError, - DisconnectReason::TransportFailure, - DisconnectReason::ResourceExhaustion, - DisconnectReason::SecurityViolation, - DisconnectReason::ConfigurationChange, - DisconnectReason::Timeout, - DisconnectReason::Other, - ]; - - for reason in reasons { - let byte = reason.to_byte(); - let restored = DisconnectReason::from_byte(byte); - assert_eq!(restored, Some(reason)); - } - } - - #[test] - fn test_disconnect_reason_unknown_byte() { - assert!(DisconnectReason::from_byte(0x08).is_none()); - assert!(DisconnectReason::from_byte(0x80).is_none()); - assert!(DisconnectReason::from_byte(0xFE).is_none()); - } - - // ===== Disconnect Message Tests ===== - - #[test] - fn test_disconnect_encode_decode() { - let msg = Disconnect::new(DisconnectReason::Shutdown); - let encoded = msg.encode(); - - assert_eq!(encoded.len(), 2); - assert_eq!(encoded[0], 0x50); // LinkMessageType::Disconnect - assert_eq!(encoded[1], 0x00); // DisconnectReason::Shutdown - - // Decode from payload (after msg_type byte) - let decoded = Disconnect::decode(&encoded[1..]).unwrap(); - assert_eq!(decoded.reason, DisconnectReason::Shutdown); - } - - #[test] - fn test_disconnect_all_reasons() { - let reasons = [ - DisconnectReason::Shutdown, - DisconnectReason::Restart, - DisconnectReason::ProtocolError, - DisconnectReason::Other, - ]; - - for reason in reasons { - let msg = Disconnect::new(reason); - let encoded = msg.encode(); - let decoded = Disconnect::decode(&encoded[1..]).unwrap(); - assert_eq!(decoded.reason, reason); - } - } - - #[test] - fn test_disconnect_decode_empty_payload() { - let result = Disconnect::decode(&[]); - assert!(result.is_err()); - } - - #[test] - fn test_disconnect_decode_unknown_reason() { - let decoded = Disconnect::decode(&[0x80]).unwrap(); - assert_eq!(decoded.reason, DisconnectReason::Other); - } - // ===== SessionDatagram Tests ===== fn make_node_addr(val: u8) -> NodeAddr { diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 846a4bd..3fbd62a 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -23,7 +23,6 @@ mod error; mod filter; mod link; -mod negotiation; pub(crate) mod session; mod tree; @@ -31,12 +30,7 @@ mod tree; pub use error::ProtocolError; pub use filter::{FilterAnnounce, FilterNack}; pub use link::{ - Disconnect, DisconnectReason, HandshakeMessageType, LinkMessageType, - SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef, -}; -pub use negotiation::{ - FMP_FEAT_PROFILE_MASK, FMP_FEAT_PROVIDES_RR, FMP_FEAT_PROVIDES_SR, FMP_FEAT_WANTS_RR, - FMP_FEAT_WANTS_SR, NEGOTIATION_HEADER_SIZE, NegotiationPayload, NodeProfile, TlvEntry, + LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef, }; pub use session::{ FspFlags, FspInnerFlags, PATH_MTU_NOTIFICATION_SIZE, PathMtuNotification, diff --git a/src/protocol/negotiation.rs b/src/protocol/negotiation.rs deleted file mode 100644 index 7d1c974..0000000 --- a/src/protocol/negotiation.rs +++ /dev/null @@ -1,552 +0,0 @@ -//! Protocol negotiation payload codec. -//! -//! Encodes/decodes the negotiation payload embedded in XX handshake -//! messages (msg2/msg3). Each layer (FMP, FSP) uses the same wire -//! format with layer-specific version ranges and feature catalogs. -//! -//! ## Wire Format -//! -//! ```text -//! Byte 0: format (must be 0) -//! Byte 1: [version_min:4 high][version_max:4 low] -//! Bytes 2-9: feature bitfield (64 bits, LE) -//! Bytes 10+: TLV entries, each: -//! [field_num:2 LE][length:2 LE][value:N] -//! ``` -//! -//! ## Node Profile Decision Tree -//! -//! Profiles are self-declared (bits 0-2 of the feature bitfield): -//! -//! - **Full** (0): Full routing. Combines bloom filters from children, -//! forwards transit traffic, participates in spanning tree. -//! - **NonRouting** (1): Tree participation but no transit forwarding. -//! Receives bloom filters (one-way: F→N) but does not send them. -//! The full peer inserts N's identity via `leaf_dependents`. -//! - **Leaf** (2): Single upstream peer, no tree/bloom/transit. -//! Full peer inserts L's identity via `leaf_dependents`. -//! -//! **Link pairing rule**: at least one side must be Full. Invalid -//! pairings (N↔N, N↔L, L↔L) are rejected during FMP negotiation. -//! -//! **Routing implications**: `forward_lookup_request()` only considers -//! Full peers as transit. `peer_inbound_filters()` excludes non-Full -//! peers from bloom filter merging. - -use super::ProtocolError; - -/// Size of the fixed negotiation header (format + version + features). -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; - -// --- 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 std::fmt::Display for NodeProfile { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Full => write!(f, "full"), - Self::NonRouting => write!(f, "non-routing"), - Self::Leaf => write!(f, "leaf"), - } - } -} - -impl TryFrom for NodeProfile { - type Error = ProtocolError; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(Self::Full), - 1 => Ok(Self::NonRouting), - 2 => Ok(Self::Leaf), - _ => Err(ProtocolError::Malformed(format!( - "unknown node profile: {value}" - ))), - } - } -} - -/// A TLV entry in the negotiation payload. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TlvEntry { - /// Field number identifying this TLV. - pub field_num: u16, - /// Raw value bytes. - pub value: Vec, -} - -/// Protocol negotiation payload. -/// -/// Carried in XX msg2/msg3 encrypted payloads. Shared codec for both -/// FMP and FSP layers, with layer-specific version ranges and feature -/// bit assignments. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NegotiationPayload { - /// Format byte (must be 0). - pub format: u8, - /// Minimum protocol version supported (4-bit, 0-15). - pub version_min: u8, - /// Maximum protocol version supported (4-bit, 0-15). - pub version_max: u8, - /// Feature bitfield (64 bits, LE). - pub features: u64, - /// Optional TLV extension entries. - pub tlv_entries: Vec, -} - -impl NegotiationPayload { - /// Create a new negotiation payload. - pub fn new(version_min: u8, version_max: u8, features: u64) -> Self { - Self { - format: NEGOTIATION_FORMAT_V0, - version_min, - version_max, - features, - tlv_entries: Vec::new(), - } - } - - /// Add a TLV entry. - pub fn with_tlv(mut self, field_num: u16, value: Vec) -> Self { - self.tlv_entries.push(TlvEntry { field_num, value }); - self - } - - /// Encode to wire format. - pub fn encode(&self) -> Vec { - let mut buf = Vec::with_capacity(NEGOTIATION_HEADER_SIZE); - - buf.push(self.format); - buf.push((self.version_min << 4) | (self.version_max & 0x0F)); - buf.extend_from_slice(&self.features.to_le_bytes()); - - for entry in &self.tlv_entries { - buf.extend_from_slice(&entry.field_num.to_le_bytes()); - let len = entry.value.len() as u16; - buf.extend_from_slice(&len.to_le_bytes()); - buf.extend_from_slice(&entry.value); - } - - buf - } - - /// Decode from wire format. - pub fn decode(data: &[u8]) -> Result { - if data.len() < NEGOTIATION_HEADER_SIZE { - return Err(ProtocolError::MessageTooShort { - expected: NEGOTIATION_HEADER_SIZE, - got: data.len(), - }); - } - - let format = data[0]; - if format != NEGOTIATION_FORMAT_V0 { - return Err(ProtocolError::Malformed(format!( - "unknown negotiation format: {format}" - ))); - } - - let version_min = data[1] >> 4; - let version_max = data[1] & 0x0F; - if version_min > version_max { - return Err(ProtocolError::Malformed(format!( - "version_min ({version_min}) > version_max ({version_max})" - ))); - } - - let features = u64::from_le_bytes(data[2..10].try_into().unwrap()); - - let mut tlv_entries = Vec::new(); - let mut offset = NEGOTIATION_HEADER_SIZE; - while offset < data.len() { - // Need at least 4 bytes for field_num + length - if offset + 4 > data.len() { - return Err(ProtocolError::Malformed("truncated TLV header".to_string())); - } - - let field_num = u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap()); - let length = - u16::from_le_bytes(data[offset + 2..offset + 4].try_into().unwrap()) as usize; - offset += 4; - - if offset + length > data.len() { - return Err(ProtocolError::Malformed(format!( - "TLV field {field_num}: declared length {length} exceeds remaining data {}", - data.len() - offset - ))); - } - - let value = data[offset..offset + length].to_vec(); - offset += length; - - tlv_entries.push(TlvEntry { field_num, value }); - } - - Ok(Self { - format, - version_min, - version_max, - features, - tlv_entries, - }) - } - - /// Agree on a protocol version with a peer's negotiation payload. - /// - /// Returns `min(our_max, their_max)`, rejecting if the agreed version - /// is below either side's minimum. - pub fn agree_version(&self, other: &Self) -> Result { - let agreed = self.version_max.min(other.version_max); - if agreed < self.version_min || agreed < other.version_min { - return Err(ProtocolError::Malformed(format!( - "version mismatch: ours [{},{}] theirs [{},{}]", - self.version_min, self.version_max, other.version_min, other.version_max - ))); - } - Ok(agreed) - } - - // --- FMP-specific helpers --- - - /// Build an FMP negotiation payload for the given node profile. - /// - /// Sets the profile bits and MMP wants/provides defaults for the profile. - 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; - } - - Self::new(version_min, version_max, features) - } - - /// Extract the node profile from the FMP feature bitfield. - pub fn node_profile(&self) -> Result { - let raw = (self.features & FMP_FEAT_PROFILE_MASK) as u8; - NodeProfile::try_from(raw) - } - - /// Whether this peer can provide MMP sender reports. - pub fn provides_sr(&self) -> bool { - self.features & FMP_FEAT_PROVIDES_SR != 0 - } - - /// Whether this peer can provide MMP receiver reports. - pub fn provides_rr(&self) -> bool { - self.features & FMP_FEAT_PROVIDES_RR != 0 - } - - /// Whether this peer wants MMP sender reports. - pub fn wants_sr(&self) -> bool { - self.features & FMP_FEAT_WANTS_SR != 0 - } - - /// Whether this peer wants MMP receiver reports. - pub fn wants_rr(&self) -> bool { - self.features & FMP_FEAT_WANTS_RR != 0 - } - - /// 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(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_encode_decode_roundtrip() { - let payload = NegotiationPayload::new(1, 3, 0x00000000_0000002A); - let encoded = payload.encode(); - assert_eq!(encoded.len(), NEGOTIATION_HEADER_SIZE); - - let decoded = NegotiationPayload::decode(&encoded).unwrap(); - assert_eq!(decoded, payload); - } - - #[test] - fn test_encode_decode_with_tlv() { - let payload = NegotiationPayload::new(0, 1, 0) - .with_tlv(1, vec![0xAA, 0xBB]) - .with_tlv(256, vec![0x01, 0x02, 0x03, 0x04]); - - let encoded = payload.encode(); - // 10 header + (2+2+2) + (2+2+4) = 10 + 6 + 8 = 24 - assert_eq!(encoded.len(), 24); - - let decoded = NegotiationPayload::decode(&encoded).unwrap(); - assert_eq!(decoded, payload); - assert_eq!(decoded.tlv_entries.len(), 2); - assert_eq!(decoded.tlv_entries[0].field_num, 1); - assert_eq!(decoded.tlv_entries[0].value, vec![0xAA, 0xBB]); - assert_eq!(decoded.tlv_entries[1].field_num, 256); - assert_eq!(decoded.tlv_entries[1].value, vec![0x01, 0x02, 0x03, 0x04]); - } - - #[test] - fn test_version_agreement_basic() { - let ours = NegotiationPayload::new(1, 3, 0); - let theirs = NegotiationPayload::new(1, 2, 0); - assert_eq!(ours.agree_version(&theirs).unwrap(), 2); - } - - #[test] - fn test_version_agreement_mismatch() { - let ours = NegotiationPayload::new(3, 5, 0); - let theirs = NegotiationPayload::new(1, 2, 0); - assert!(ours.agree_version(&theirs).is_err()); - } - - #[test] - fn test_version_agreement_asymmetric() { - // Ours: [2,5], theirs: [1,4] → agreed = min(5,4) = 4, 4 >= 2 and 4 >= 1 → ok - let ours = NegotiationPayload::new(2, 5, 0); - let theirs = NegotiationPayload::new(1, 4, 0); - assert_eq!(ours.agree_version(&theirs).unwrap(), 4); - - // Ours: [1,4], theirs: [2,5] → agreed = min(4,5) = 4, 4 >= 1 and 4 >= 2 → ok - assert_eq!(theirs.agree_version(&ours).unwrap(), 4); - } - - #[test] - fn test_unknown_format_rejected() { - let mut data = NegotiationPayload::new(0, 0, 0).encode(); - data[0] = 1; // Set format to 1 - assert!(NegotiationPayload::decode(&data).is_err()); - } - - #[test] - fn test_invalid_version_range() { - let mut data = NegotiationPayload::new(0, 0, 0).encode(); - // Set version_min=5, version_max=3 (invalid: min > max) - data[1] = (5 << 4) | 3; - assert!(NegotiationPayload::decode(&data).is_err()); - } - - #[test] - fn test_unknown_tlv_forward_compat() { - // Unknown field_nums should be preserved through encode/decode - let payload = NegotiationPayload::new(0, 1, 0).with_tlv(9999, vec![0xFF, 0xFE, 0xFD]); - - let encoded = payload.encode(); - let decoded = NegotiationPayload::decode(&encoded).unwrap(); - assert_eq!(decoded.tlv_entries.len(), 1); - assert_eq!(decoded.tlv_entries[0].field_num, 9999); - assert_eq!(decoded.tlv_entries[0].value, vec![0xFF, 0xFE, 0xFD]); - } - - #[test] - fn test_empty_payload() { - let payload = NegotiationPayload::new(0, 0, 0); - let encoded = payload.encode(); - assert_eq!(encoded.len(), NEGOTIATION_HEADER_SIZE); - - let decoded = NegotiationPayload::decode(&encoded).unwrap(); - assert_eq!(decoded.version_min, 0); - assert_eq!(decoded.version_max, 0); - assert_eq!(decoded.features, 0); - assert!(decoded.tlv_entries.is_empty()); - } - - #[test] - fn test_truncated_payload() { - // Less than header size - assert!(NegotiationPayload::decode(&[0u8; 5]).is_err()); - assert!(NegotiationPayload::decode(&[]).is_err()); - } - - #[test] - fn test_truncated_tlv() { - let payload = NegotiationPayload::new(0, 1, 0).with_tlv(1, vec![0xAA, 0xBB, 0xCC]); - let mut encoded = payload.encode(); - - // Truncate the TLV value (remove last byte) - encoded.pop(); - assert!(NegotiationPayload::decode(&encoded).is_err()); - - // Truncate to just partial TLV header (only 2 of 4 header bytes) - let mut partial = NegotiationPayload::new(0, 1, 0).encode(); - 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()); - } - - #[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() - ); - } -} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index a29cd34..5128ce8 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -34,6 +34,9 @@ use tor::TorTransport; use tor::control::TorMonitoringInfo; use udp::UdpTransport; +mod types; +pub use types::*; + // ============================================================================ // Packet Channel Types // ============================================================================ @@ -93,54 +96,6 @@ pub fn packet_channel(buffer: usize) -> (PacketTx, PacketRx) { tokio::sync::mpsc::channel(buffer) } -// ============================================================================ -// Transport Identifiers -// ============================================================================ - -/// Unique identifier for a transport instance. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct TransportId(u32); - -impl TransportId { - /// Create a new transport ID. - pub fn new(id: u32) -> Self { - Self(id) - } - - /// Get the raw ID value. - pub fn as_u32(&self) -> u32 { - self.0 - } -} - -impl fmt::Display for TransportId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "transport:{}", self.0) - } -} - -/// Unique identifier for a link instance. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct LinkId(u64); - -impl LinkId { - /// Create a new link ID. - pub fn new(id: u64) -> Self { - Self(id) - } - - /// Get the raw ID value. - pub fn as_u64(&self) -> u64 { - self.0 - } -} - -impl fmt::Display for LinkId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "link:{}", self.0) - } -} - // ============================================================================ // Errors // ============================================================================ @@ -373,208 +328,17 @@ impl fmt::Display for LinkState { } } -/// Direction of link establishment. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LinkDirection { - /// We initiated the connection. - Outbound, - /// They initiated the connection. - Inbound, -} - -impl fmt::Display for LinkDirection { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - LinkDirection::Outbound => "outbound", - LinkDirection::Inbound => "inbound", - }; - write!(f, "{}", s) - } -} - // ============================================================================ -// Transport Address +// Transport Address (std-bound helper; the plain type lives in `types`) // ============================================================================ -/// Opaque transport-specific address. -/// -/// Each transport type interprets this differently: -/// - UDP/TCP: "host:port" (IP address or DNS hostname) -/// - Ethernet: MAC address (6 bytes) -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct TransportAddr(Vec); - impl TransportAddr { - /// Create a transport address from raw bytes. - pub fn new(bytes: Vec) -> Self { - Self(bytes) - } - - /// Create a transport address from a byte slice. - pub fn from_bytes(bytes: &[u8]) -> Self { - Self(bytes.to_vec()) - } - - /// Create a transport address from a string. - pub fn from_string(s: &str) -> Self { - Self(s.as_bytes().to_vec()) - } - /// Create a UDP/TCP transport address directly from a socket address. pub fn from_socket_addr(addr: std::net::SocketAddr) -> Self { use std::io::Write; let mut buf = Vec::with_capacity(56); write!(&mut buf, "{addr}").expect("Vec::write_fmt is infallible"); - Self(buf) - } - - /// Get the raw bytes. - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } - - /// Try to interpret as a UTF-8 string. - pub fn as_str(&self) -> Option<&str> { - std::str::from_utf8(&self.0).ok() - } - - /// Get the length in bytes. - pub fn len(&self) -> usize { - self.0.len() - } - - /// Check if empty. - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl fmt::Debug for TransportAddr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self.as_str() { - Some(s) => write!(f, "TransportAddr(\"{}\")", s), - None => write!(f, "TransportAddr({:?})", self.0), - } - } -} - -impl fmt::Display for TransportAddr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Best-effort display as string if valid UTF-8. Otherwise render a - // 6-byte payload as a colon-separated MAC (standard Unix notation, - // matching BLE addrs, `ip link`/`ip neigh`, and packet logs), and - // any other non-UTF-8 byte string as bare hex. - match self.as_str() { - Some(s) => write!(f, "{}", s), - None if self.0.len() == 6 => { - for (i, byte) in self.0.iter().enumerate() { - if i > 0 { - write!(f, ":")?; - } - write!(f, "{:02x}", byte)?; - } - Ok(()) - } - None => { - for byte in &self.0 { - write!(f, "{:02x}", byte)?; - } - Ok(()) - } - } - } -} - -impl From<&str> for TransportAddr { - fn from(s: &str) -> Self { - Self::from_string(s) - } -} - -impl From for TransportAddr { - fn from(s: String) -> Self { - Self(s.into_bytes()) - } -} - -// ============================================================================ -// Link Statistics -// ============================================================================ - -/// Statistics for a link. -#[derive(Clone, Debug, Default)] -pub struct LinkStats { - /// Total packets sent. - pub packets_sent: u64, - /// Total packets received. - pub packets_recv: u64, - /// Total bytes sent. - pub bytes_sent: u64, - /// Total bytes received. - pub bytes_recv: u64, - /// Timestamp of last received packet (Unix milliseconds). - pub last_recv_ms: u64, - /// Estimated round-trip time. - rtt_estimate: Option, - /// Observed packet loss rate (0.0-1.0). - pub loss_rate: f32, - /// Estimated throughput in bytes/second. - pub throughput_estimate: u64, -} - -impl LinkStats { - /// Create new link statistics. - pub fn new() -> Self { - Self::default() - } - - /// Record a sent packet. - pub fn record_sent(&mut self, bytes: usize) { - self.packets_sent += 1; - self.bytes_sent += bytes as u64; - } - - /// Record a received packet. - pub fn record_recv(&mut self, bytes: usize, timestamp_ms: u64) { - self.packets_recv += 1; - self.bytes_recv += bytes as u64; - self.last_recv_ms = timestamp_ms; - } - - /// Get the RTT estimate, if available. - pub fn rtt_estimate(&self) -> Option { - self.rtt_estimate - } - - /// Update RTT estimate from a probe response. - /// - /// Uses exponential moving average with alpha=0.2. - pub fn update_rtt(&mut self, rtt: Duration) { - match self.rtt_estimate { - Some(old_rtt) => { - let alpha = 0.2; - let new_rtt_nanos = (alpha * rtt.as_nanos() as f64 - + (1.0 - alpha) * old_rtt.as_nanos() as f64) - as u64; - self.rtt_estimate = Some(Duration::from_nanos(new_rtt_nanos)); - } - None => { - self.rtt_estimate = Some(rtt); - } - } - } - - /// Time since last receive (for keepalive/timeout). - pub fn time_since_recv(&self, current_time_ms: u64) -> u64 { - if self.last_recv_ms == 0 { - return u64::MAX; - } - current_time_ms.saturating_sub(self.last_recv_ms) - } - - /// Reset all statistics. - pub fn reset(&mut self) { - *self = Self::default(); + Self::new(buf) } } diff --git a/src/transport/types.rs b/src/transport/types.rs new file mode 100644 index 0000000..4e95f4c --- /dev/null +++ b/src/transport/types.rs @@ -0,0 +1,261 @@ +//! Plain, `no_std`+`alloc`-clean transport primitives. +//! +//! The identifier/address/statistics value types shared across the transport +//! layer, defined free of any `std` dependency so the sans-IO protocol cores +//! (e.g. `proto::fmp`) can name them without pulling in `std`. The +//! `std`-requiring helpers (`TransportAddr::from_socket_addr`, socket-address +//! resolution, `local_addr`) stay in `transport`. Re-exported from `transport` +//! via `pub use types::*`, so existing `crate::transport::{LinkId, ...}` +//! imports are unaffected. + +use core::fmt; +use core::time::Duration; + +// ============================================================================ +// Transport Identifiers +// ============================================================================ + +/// Unique identifier for a transport instance. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TransportId(u32); + +impl TransportId { + /// Create a new transport ID. + pub fn new(id: u32) -> Self { + Self(id) + } + + /// Get the raw ID value. + pub fn as_u32(&self) -> u32 { + self.0 + } +} + +impl fmt::Display for TransportId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "transport:{}", self.0) + } +} + +/// Unique identifier for a link instance. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LinkId(u64); + +impl LinkId { + /// Create a new link ID. + pub fn new(id: u64) -> Self { + Self(id) + } + + /// Get the raw ID value. + pub fn as_u64(&self) -> u64 { + self.0 + } +} + +impl fmt::Display for LinkId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "link:{}", self.0) + } +} + +// ============================================================================ +// Link Direction +// ============================================================================ + +/// Direction of link establishment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LinkDirection { + /// We initiated the connection. + Outbound, + /// They initiated the connection. + Inbound, +} + +impl fmt::Display for LinkDirection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + LinkDirection::Outbound => "outbound", + LinkDirection::Inbound => "inbound", + }; + write!(f, "{}", s) + } +} + +// ============================================================================ +// Transport Address +// ============================================================================ + +/// Opaque transport-specific address. +/// +/// Each transport type interprets this differently: +/// - UDP/TCP: "host:port" (IP address or DNS hostname) +/// - Ethernet: MAC address (6 bytes) +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct TransportAddr(Vec); + +impl TransportAddr { + /// Create a transport address from raw bytes. + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + /// Create a transport address from a byte slice. + pub fn from_bytes(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } + + /// Create a transport address from a string. + pub fn from_string(s: &str) -> Self { + Self(s.as_bytes().to_vec()) + } + + /// Get the raw bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// Try to interpret as a UTF-8 string. + pub fn as_str(&self) -> Option<&str> { + core::str::from_utf8(&self.0).ok() + } + + /// Get the length in bytes. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Check if empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Debug for TransportAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.as_str() { + Some(s) => write!(f, "TransportAddr(\"{}\")", s), + None => write!(f, "TransportAddr({:?})", self.0), + } + } +} + +impl fmt::Display for TransportAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Best-effort display as string if valid UTF-8. Otherwise render a + // 6-byte payload as a colon-separated MAC (standard Unix notation, + // matching BLE addrs, `ip link`/`ip neigh`, and packet logs), and + // any other non-UTF-8 byte string as bare hex. + match self.as_str() { + Some(s) => write!(f, "{}", s), + None if self.0.len() == 6 => { + for (i, byte) in self.0.iter().enumerate() { + if i > 0 { + write!(f, ":")?; + } + write!(f, "{:02x}", byte)?; + } + Ok(()) + } + None => { + for byte in &self.0 { + write!(f, "{:02x}", byte)?; + } + Ok(()) + } + } + } +} + +impl From<&str> for TransportAddr { + fn from(s: &str) -> Self { + Self::from_string(s) + } +} + +impl From for TransportAddr { + fn from(s: String) -> Self { + Self(s.into_bytes()) + } +} + +// ============================================================================ +// Link Statistics +// ============================================================================ + +/// Statistics for a link. +#[derive(Clone, Debug, Default)] +pub struct LinkStats { + /// Total packets sent. + pub packets_sent: u64, + /// Total packets received. + pub packets_recv: u64, + /// Total bytes sent. + pub bytes_sent: u64, + /// Total bytes received. + pub bytes_recv: u64, + /// Timestamp of last received packet (Unix milliseconds). + pub last_recv_ms: u64, + /// Estimated round-trip time in milliseconds (0 = no estimate yet). + rtt_estimate: u64, + /// Observed packet loss rate (0.0-1.0). + pub loss_rate: f32, + /// Estimated throughput in bytes/second. + pub throughput_estimate: u64, +} + +impl LinkStats { + /// Create new link statistics. + pub fn new() -> Self { + Self::default() + } + + /// Record a sent packet. + pub fn record_sent(&mut self, bytes: usize) { + self.packets_sent += 1; + self.bytes_sent += bytes as u64; + } + + /// Record a received packet. + pub fn record_recv(&mut self, bytes: usize, timestamp_ms: u64) { + self.packets_recv += 1; + self.bytes_recv += bytes as u64; + self.last_recv_ms = timestamp_ms; + } + + /// Get the RTT estimate, if available. + pub fn rtt_estimate(&self) -> Option { + if self.rtt_estimate == 0 { + None + } else { + Some(Duration::from_millis(self.rtt_estimate)) + } + } + + /// Update RTT estimate from a probe response. + /// + /// Uses exponential moving average with alpha=0.2. + pub fn update_rtt(&mut self, rtt: Duration) { + let rtt_ms = rtt.as_millis() as u64; + if self.rtt_estimate == 0 { + self.rtt_estimate = rtt_ms; + } else { + let alpha = 0.2; + self.rtt_estimate = + (alpha * rtt_ms as f64 + (1.0 - alpha) * self.rtt_estimate as f64) as u64; + } + } + + /// Time since last receive (for keepalive/timeout). + pub fn time_since_recv(&self, current_time_ms: u64) -> u64 { + if self.last_recv_ms == 0 { + return u64::MAX; + } + current_time_ms.saturating_sub(self.last_recv_ms) + } + + /// Reset all statistics. + pub fn reset(&mut self) { + *self = Self::default(); + } +}