diff --git a/src/lib.rs b/src/lib.rs index 1d23993..2113599 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,19 @@ 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 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/handlers/dispatch.rs b/src/node/handlers/dispatch.rs index 9b99cad..89ddf14 100644 --- a/src/node/handlers/dispatch.rs +++ b/src/node/handlers/dispatch.rs @@ -73,7 +73,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 df46000..7efc80d 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -1,15 +1,62 @@ //! Handshake handlers and connection promotion. +use crate::NodeAddr; use crate::PeerIdentity; use crate::node::acl::PeerAclContext; use crate::node::reject::{HandshakeReject, RejectReason}; use crate::node::wire::{Msg1Header, Msg2Header, build_msg2}; use crate::node::{Node, NodeError}; -use crate::peer::{ActivePeer, PeerConnection, PromotionResult, cross_connection_winner}; +use crate::peer::{ActivePeer, PeerConnection, PromotionResult}; +use crate::proto::fmp::{ + ConnAction, EstablishSnapshot, EstablishView, InboundDecision, InboundReject, OutboundDecision, + OutboundSnapshot, WireOutcome, cross_connection_winner, +}; use crate::transport::{Link, LinkDirection, LinkId, ReceivedPacket}; use std::time::Duration; use tracing::{debug, info, warn}; +impl EstablishView for Node { + fn establish_snapshot(&self, peer_addr: &NodeAddr) -> EstablishSnapshot { + let existing = self.peers.get(peer_addr); + let max_peers = self.max_peers(); + EstablishSnapshot { + has_existing_peer: existing.is_some(), + existing_peer_epoch: existing.and_then(|p| p.remote_epoch()), + existing_session_age_secs: existing + .map(|p| p.session_established_at().elapsed().as_secs()) + .unwrap_or(0), + has_session: existing.map(|p| p.has_session()).unwrap_or(false), + is_healthy: existing.map(|p| p.is_healthy()).unwrap_or(false), + pending_new_session: existing + .map(|p| p.pending_new_session().is_some()) + .unwrap_or(false), + rekey_in_progress: existing.map(|p| p.rekey_in_progress()).unwrap_or(false), + existing_msg2: existing.and_then(|p| p.handshake_msg2().map(|m| m.to_vec())), + at_max_peers: max_peers > 0 && self.peers.len() >= max_peers, + has_pending_outbound_to_peer: self.connections.values().any(|conn| { + conn.expected_identity() + .map(|id| id.node_addr() == peer_addr) + .unwrap_or(false) + }), + rekey_enabled: self.config().node.rekey.enabled, + our_node_addr: *self.identity().node_addr(), + } + } + + fn outbound_snapshot(&self, peer_addr: &NodeAddr) -> OutboundSnapshot { + OutboundSnapshot { + has_existing_peer: self.peers.contains_key(peer_addr), + // Tie-break for THIS outbound connection (`is_outbound = true`), + // pre-evaluated here so the core stays free of the peer helper. + our_outbound_wins: cross_connection_winner( + self.identity().node_addr(), + peer_addr, + true, + ), + } + } +} + impl Node { /// Returns true if an inbound msg1 should be admitted past the /// `accept_connections` gate. @@ -96,32 +143,23 @@ impl Node { } }; - // Check for existing connection from this address. - // - // If we already have an *inbound* link from this address, this could be: - // 1. A duplicate msg1 (our msg2 was lost) — resend msg2 - // 2. A restarted peer (different epoch) — tear down and reprocess - // - // If we have an *outbound* link to this address (we initiated to them - // AND they initiated to us), this is a cross-connection — allow it. - // - // Epoch-based restart detection: if the sender already has an inbound - // link AND is an active peer in self.peers, fall through to decrypt - // the msg1 and check the epoch. Otherwise, treat as duplicate. + // Pre-crypto duplicate short-circuit. An *inbound* link from this + // address that is not (yet) a promoted peer means our earlier msg2 was + // lost: resend the stored msg2 without paying the crypto cost and + // return. An inbound link that DOES belong to an active peer (a possible + // restart/rekey) or an *outbound* link (a cross-connection) falls + // through to the wire step and the structured classification below — + // the pre-refactor `possible_restart` flag is no longer needed because + // that classification now gates on `has_existing_peer` (identity), which + // subsumes it. let addr_key = (packet.transport_id, packet.remote_addr.clone()); - let mut possible_restart = false; if let Some(&existing_link_id) = self.addr_to_link.get(&addr_key) && let Some(link) = self.links.get(&existing_link_id) { if link.direction() == LinkDirection::Inbound { - // Check if this link belongs to an already-promoted active peer let is_active_peer = self.peers.values().any(|p| p.link_id() == existing_link_id); - - if is_active_peer { - // Possible restart — fall through to decrypt and check epoch - possible_restart = true; - } else { - // Genuinely pending handshake — resend msg2 + if !is_active_peer { + // Genuinely pending handshake — resend msg2. let msg2_bytes = self.find_stored_msg2(existing_link_id); if let Some(msg2) = msg2_bytes { if let Some(transport) = self.transports.get(&packet.transport_id) { @@ -150,14 +188,11 @@ impl Node { return; } } else { - // Outbound link to this address. If it belongs to an active - // peer, this may be a rekey msg1 (same epoch) or a - // restart (different epoch). Set possible_restart to enable - // the epoch/rekey check below. + // Outbound link to this address with no active peer yet: a + // cross-connection. Just log; it is classified as a net-new + // inbound below. let is_active_peer = self.peers.values().any(|p| p.link_id() == existing_link_id); - if is_active_peer { - possible_restart = true; - } else { + if !is_active_peer { debug!( transport_id = %packet.transport_id, remote_addr = %packet.remote_addr, @@ -212,247 +247,184 @@ impl Node { let peer_node_addr = *peer_identity.node_addr(); - // Identity-based restart/rekey detection: if the peer is already - // active but addr_to_link didn't match (different source address, e.g., - // TCP from a different port), we still need to check for restart/rekey. - if !possible_restart && self.peers.contains_key(&peer_node_addr) { - possible_restart = true; - } + // === PHASE B result === + // Bundle the Noise wire-step outputs (identity, remote epoch, sender + // index, opaque msg2 payload). The wire step touched no `Node` registry + // state; from here the decision reads only `wire` and the snapshot. + let wire = WireOutcome { + peer_identity, + remote_epoch: conn.remote_epoch(), + their_index: header.sender_idx, + msg2_payload: msg2_response, + }; - // Early cap check: at max_peers and this is a net-new identity? - // Bypass for known peers (reconnect / cross-connection) — admitting - // them doesn't grow peers.len(). This silent-drops the Msg1 before - // the Msg2 build/send and index allocation, avoiding wasted wire - // bytes and giving the peer cleaner semantics (no fake-completed - // handshake whose data frames subsequently fail decryption here). - // The late cap check inside promote_connection() is intentionally - // left in place as defense-in-depth. - if self.max_peers() > 0 && self.peers.len() >= self.max_peers() { - let is_known_active = self.peers.contains_key(&peer_node_addr); - let is_pending_outbound = self.connections.iter().any(|(_, conn)| { - conn.expected_identity() - .map(|id| *id.node_addr() == peer_node_addr) - .unwrap_or(false) - }); - if !is_known_active && !is_pending_outbound { - debug!( - peer = %self.peer_display_name(&peer_node_addr), - max = self.max_peers(), - "Silent-dropping Msg1 at max_peers cap (early gate; no Msg2 sent)" - ); - // `link_id` was allocated above but `conn` is still a local - // (not yet inserted into self.connections / self.links / - // self.addr_to_link), so the local drop suffices. + // === PHASE C input === + // Snapshot the registry state the inbound classification reads about + // this peer identity (existing epoch/session/rekey state with the + // session age resolved here, the max-peers cap, our own address for the + // tie-break). Taken before this connection is inserted into the + // registry, matching the pre-refactor read points. + let est = self.establish_snapshot(&peer_node_addr); + + // === PHASE C: structured classification (pure core) === + // The decision reads only the snapshot + wire outcome; the shell below + // drives the effects. `Promote`/`RestartThenPromote` fall through to the + // shared authorize → allocate → send-msg2 → promote tail; the other + // variants complete the rate-limiter and return here. + match self.fmp.establish_inbound(&est, &wire) { + InboundDecision::Reject { reason } => { + match reason { + InboundReject::AtMaxPeers => debug!( + peer = %self.peer_display_name(&peer_node_addr), + max = self.max_peers(), + "Silent-dropping Msg1 at max_peers cap (early gate; no Msg2 sent)" + ), + InboundReject::PendingSession => debug!( + peer = %self.peer_display_name(&peer_node_addr), + "Rekey msg1 received but already have pending session, dropping" + ), + InboundReject::DualRekeyWon => debug!( + peer = %self.peer_display_name(&peer_node_addr), + "Dual rekey initiation: we win (smaller addr), dropping their msg1" + ), + } + // `conn`/`link_id` were never inserted into the registry, so the + // local drop suffices — no cleanup needed. self.msg1_rate_limiter.complete_handshake(); self.stats_mut() .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); return; } - } - - // Epoch-based restart detection and duplicate msg1 handling. - // - // If we fell through from the addr_to_link check above with - // possible_restart=true, we now have the decrypted epoch from msg1. - // Compare it against the stored epoch for this peer. - if possible_restart && let Some(existing_peer) = self.peers.get(&peer_node_addr) { - let new_epoch = conn.remote_epoch(); - let existing_epoch = existing_peer.remote_epoch(); - - 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 + InboundDecision::ResendMsg2 { msg2 } => { + if let Some(msg2) = msg2.as_deref() + && 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 msg1 (same epoch)" + ), + Err(e) => debug!( + peer = %self.peer_display_name(&peer_node_addr), + error = %e, + "Failed to resend msg2" + ), + } } - _ => { - // Same epoch (or no epoch stored). - // If the peer has an active session and rekey is enabled, - // this is a rekey msg1 (not a duplicate initial msg1). - // Guard: the session must be at least 30s old to avoid - // misidentifying a cross-connection msg1 as a rekey. - // During simultaneous connection, both sides promote - // within the same tick and the peer's msg1 arrives - // immediately — a genuine rekey can't fire that fast. - let session_age_secs = - existing_peer.session_established_at().elapsed().as_secs(); - if self.config().node.rekey.enabled - && existing_peer.has_session() - && existing_peer.is_healthy() - && session_age_secs >= 30 + self.msg1_rate_limiter.complete_handshake(); + return; + } + InboundDecision::RekeyRespond { + peer, + abandon_first, + } => { + if abandon_first { + // Dual-initiation loser: abandon our own in-flight rekey and + // free its index before responding as the rekey responder. + debug!( + peer = %self.peer_display_name(&peer), + "Dual rekey initiation: we lose (larger addr), abandoning ours" + ); + if let Some(existing) = self.peers.get_mut(&peer) + && let Some(idx) = existing.abandon_rekey() { - // Guard: already have a pending session from a completed - // rekey (waiting for K-bit cutover). Don't overwrite it - // with a new handshake — drop this msg1. - if existing_peer.pending_new_session().is_some() { + if let Some(tid) = existing.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 = conn.take_session(); + let our_new_index = match self.index_allocator.allocate() { + Ok(idx) => idx, + Err(e) => { + warn!(error = %e, "Failed to allocate index for rekey"); + self.msg1_rate_limiter.complete_handshake(); + self.stats_mut() + .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); + return; + } + }; + + let noise_session = match noise_session { + Some(s) => s, + None => { + warn!("Rekey msg1: no session from handshake"); + let _ = self.index_allocator.free(our_new_index); + self.msg1_rate_limiter.complete_handshake(); + self.stats_mut() + .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); + return; + } + }; + + // Send msg2 response using the new handshake. + let wire_msg2 = build_msg2(our_new_index, wire.their_index, &wire.msg2_payload); + if let Some(transport) = self.transports.get(&packet.transport_id) { + match transport.send(&packet.remote_addr, &wire_msg2).await { + Ok(_) => { debug!( - peer = %self.peer_display_name(&peer_node_addr), - "Rekey msg1 received but already have pending session, dropping" + peer = %self.peer_display_name(&peer), + new_our_index = %our_new_index, + "Sent rekey msg2 response" ); - self.connections.remove(&link_id); - self.links.remove(&link_id); + } + Err(e) => { + warn!( + peer = %self.peer_display_name(&peer), + error = %e, + "Failed to send rekey msg2" + ); + let _ = self.index_allocator.free(our_new_index); self.msg1_rate_limiter.complete_handshake(); self.stats_mut() .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); return; } - - // Dual-initiation detection: both sides sent msg1 - // simultaneously. Apply tie-breaker — smaller NodeAddr - // wins as initiator (same as cross-connection resolution). - if existing_peer.rekey_in_progress() { - let our_addr = self.identity().node_addr(); - if our_addr < &peer_node_addr { - // We win as initiator — drop their msg1. - // Our msg2 will arrive at peer, who completes - // as our responder. - debug!( - peer = %self.peer_display_name(&peer_node_addr), - "Dual rekey initiation: we win (smaller addr), dropping their msg1" - ); - self.connections.remove(&link_id); - self.links.remove(&link_id); - self.msg1_rate_limiter.complete_handshake(); - self.stats_mut().record_reject(RejectReason::Handshake( - HandshakeReject::BadState, - )); - return; - } - // We lose — abandon our rekey, become responder below. - debug!( - peer = %self.peer_display_name(&peer_node_addr), - "Dual rekey initiation: we lose (larger addr), abandoning 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 - } - - // Rekey: process as responder, store new session as pending - let noise_session = conn.take_session(); - let our_new_index = match self.index_allocator.allocate() { - Ok(idx) => idx, - Err(e) => { - warn!(error = %e, "Failed to allocate index for rekey"); - self.msg1_rate_limiter.complete_handshake(); - self.stats_mut().record_reject(RejectReason::Handshake( - HandshakeReject::BadState, - )); - return; - } - }; - - let noise_session = match noise_session { - Some(s) => s, - None => { - warn!("Rekey msg1: no session from handshake"); - let _ = self.index_allocator.free(our_new_index); - self.msg1_rate_limiter.complete_handshake(); - self.stats_mut().record_reject(RejectReason::Handshake( - HandshakeReject::BadState, - )); - return; - } - }; - - // Send msg2 response using the new handshake - let wire_msg2 = - build_msg2(our_new_index, header.sender_idx, &msg2_response); - if let Some(transport) = self.transports.get(&packet.transport_id) { - match transport.send(&packet.remote_addr, &wire_msg2).await { - Ok(_) => { - debug!( - peer = %self.peer_display_name(&peer_node_addr), - new_our_index = %our_new_index, - "Sent rekey msg2 response" - ); - } - Err(e) => { - warn!( - peer = %self.peer_display_name(&peer_node_addr), - error = %e, - "Failed to send rekey msg2" - ); - let _ = self.index_allocator.free(our_new_index); - self.msg1_rate_limiter.complete_handshake(); - 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(); - } - - // 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 we created. - // Do NOT remove addr_to_link — the entry must remain pointing - // to the original link so future msg1s from this address are - // recognized as rekeys (not new connections). - self.connections.remove(&link_id); - self.links.remove(&link_id); - - self.msg1_rate_limiter.complete_handshake(); - return; } - - // Not a rekey — duplicate msg1. 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 msg1 (same epoch)" - ), - Err(e) => debug!( - peer = %self.peer_display_name(&peer_node_addr), - error = %e, - "Failed to resend msg2" - ), - } - } - self.msg1_rate_limiter.complete_handshake(); - return; } + + // Store pending session on the existing peer. + if let Some(existing) = self.peers.get_mut(&peer) { + existing.set_pending_session(noise_session, our_new_index, wire.their_index); + existing.record_peer_rekey(); + } + + // Register new index in peers_by_index. + self.peers_by_index + .insert((packet.transport_id, our_new_index.as_u32()), peer); + + // Do NOT touch addr_to_link — the entry must keep pointing at the + // original link so future msg1s from this address are recognized + // as rekeys (not new connections). The temporary `conn`/`link_id` + // were never inserted into the registry, so no cleanup is needed. + self.msg1_rate_limiter.complete_handshake(); + return; } + InboundDecision::RestartThenPromote { peer } => { + // Epoch mismatch — peer restarted. Tear down the stale session + // and schedule a reconnect, then fall through to promote the + // fresh handshake as a new connection. + 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); + } + InboundDecision::Promote => {} } - // If possible_restart was true but peer is no longer in self.peers - // (removed by another path), fall through to process as new connection. if self .authorize_peer( - &peer_identity, + &wire.peer_identity, PeerAclContext::InboundHandshake, packet.transport_id, &packet.remote_addr, @@ -481,7 +453,7 @@ impl Node { }; conn.set_our_index(our_index); - conn.set_their_index(header.sender_idx); + conn.set_their_index(wire.their_index); // Create link let link = Link::connectionless( @@ -497,7 +469,7 @@ impl Node { self.connections.insert(link_id, conn); // Build and send msg2 response, storing for potential resend - let wire_msg2 = build_msg2(our_index, header.sender_idx, &msg2_response); + let wire_msg2 = build_msg2(our_index, wire.their_index, &wire.msg2_payload); if let Some(conn) = self.connections.get_mut(&link_id) { conn.set_handshake_msg2(wire_msg2.clone()); } @@ -508,7 +480,7 @@ impl Node { debug!( link_id = %link_id, our_index = %our_index, - their_index = %header.sender_idx, + their_index = %wire.their_index, bytes, "Sent msg2 response" ); @@ -536,7 +508,8 @@ impl Node { // Responder handshake is complete after receive_handshake_init (Noise IK // pattern: responder processes msg1 and generates msg2 in one step). // Promote the connection to active peer now. - match self.promote_connection(link_id, peer_identity, packet.timestamp_ms) { + let promote = ConnAction::PromoteToActive { link: link_id }; + match self.drive_promote_to_active(promote, wire.peer_identity, packet.timestamp_ms) { Ok(result) => { match result { PromotionResult::Promoted(node_addr) => { @@ -841,13 +814,12 @@ impl Node { // // This ensures both nodes use the same Noise handshake (the winner's // outbound = the loser's inbound). - if self.peers.contains_key(&peer_node_addr) { - let our_outbound_wins = cross_connection_winner( - self.identity().node_addr(), - &peer_node_addr, - true, // this IS our outbound - ); - + // Structured classification (pure core): cross-connection swap/keep, or + // a net-new promote. The tie-break is pre-evaluated in the snapshot; the + // effect bodies below are unchanged. + let out_snap = self.outbound_snapshot(&peer_node_addr); + let out_decision = self.fmp.establish_outbound(&out_snap); + if out_decision != OutboundDecision::Promote { // Extract the outbound connection let mut conn = match self.connections.remove(&link_id) { Some(c) => c, @@ -859,7 +831,7 @@ impl Node { } }; - if our_outbound_wins { + if out_decision == OutboundDecision::CrossConnectionSwap { // We're the smaller node. Swap to outbound session + indices. // The peer will keep their inbound session (complement of ours). let outbound_our_index = conn.our_index(); @@ -961,7 +933,8 @@ impl Node { } // Normal path: promote to active peer - match self.promote_connection(link_id, peer_identity, packet.timestamp_ms) { + let promote = ConnAction::PromoteToActive { link: link_id }; + match self.drive_promote_to_active(promote, peer_identity, packet.timestamp_ms) { Ok(result) => { // Clean up pending_outbound self.pending_outbound.remove(&key); @@ -1041,6 +1014,30 @@ impl Node { } } + /// Execute a [`ConnAction::PromoteToActive`] from the establish machine. + /// + /// The decision to promote is made by the establish handlers (and, from the + /// establish-core stage on, the pure decision in `proto::fmp`); this is the + /// executor half of the seam. It runs the promotion through + /// [`Self::promote_connection`], resolving the verified identity and + /// promotion timestamp from the ambient wire context, and returns the + /// [`PromotionResult`] so the caller can drive the site-specific + /// post-promotion tail (TreeAnnounce, bloom mark, discovery-backoff reset, + /// loser-link cleanup). + fn drive_promote_to_active( + &mut self, + action: ConnAction, + verified_identity: PeerIdentity, + current_time_ms: u64, + ) -> Result { + match action { + ConnAction::PromoteToActive { link } => { + self.promote_connection(link, verified_identity, current_time_ms) + } + _ => unreachable!("drive_promote_to_active requires a PromoteToActive action"), + } + } + /// Promote a connection to active peer after successful authentication. /// /// Handles cross-connection detection and resolution using tie-breaker rules. diff --git a/src/node/handlers/rekey.rs b/src/node/handlers/rekey.rs index 73e367a..04f672c 100644 --- a/src/node/handlers/rekey.rs +++ b/src/node/handlers/rekey.rs @@ -9,6 +9,7 @@ use crate::NodeAddr; use crate::node::Node; 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, trace, warn}; @@ -41,121 +42,112 @@ 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.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). - 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" - ); - debug!( - peer = %self.peer_display_name(&node_addr), - "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" + ); + debug!( + peer = %self.peer_display_name(&node_addr), + "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), + 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. @@ -260,60 +252,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) { - to_resend.push((*node_addr, peer.rekey_msg1().unwrap().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 FSP rekey msg3 until the responder is confirmed on the diff --git a/src/node/handlers/timeout.rs b/src/node/handlers/timeout.rs index e59c4e8..add58bf 100644 --- a/src/node/handlers/timeout.rs +++ b/src/node/handlers/timeout.rs @@ -3,14 +3,69 @@ 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() + }) + .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 +74,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); } } @@ -97,26 +145,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. - 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() - }) - .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, @@ -126,11 +172,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" ); @@ -141,13 +187,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 be9a4d5..dd7f89e 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 50d7b44..b00d0be 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -45,6 +45,7 @@ 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::routing::{self, Router, RoutingErrorRateLimiter}; #[cfg(unix)] use crate::transport::ethernet::EthernetTransport; @@ -416,6 +417,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, @@ -658,6 +662,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, ), @@ -817,6 +822,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 5323e90..2866c4a 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. /// @@ -295,7 +295,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/establish_chartests.rs b/src/node/tests/establish_chartests.rs new file mode 100644 index 0000000..21cba6b --- /dev/null +++ b/src/node/tests/establish_chartests.rs @@ -0,0 +1,998 @@ +//! Characterization tests for the inbound-handshake (`handle_msg1`) establish +//! branches. +//! +//! These lock in the *current* observable behavior of the undertested inbound +//! classification paths by driving a REAL framed msg1 into `handle_msg1` +//! (constructing a genuine Noise IK msg1 and delivering it as a +//! `ReceivedPacket`), rather than poking rekey state directly the way the +//! `arm_rekey` helper does. They are an oracle for a later behavior-neutral +//! refactor: assertions capture what happens today, surprising or not. +//! +//! Coverage map (branch → test): +//! * epoch-restart → `chartest_msg1_epoch_restart_replaces_active_peer` +//! * duplicate (pre-crypto) → `chartest_msg1_duplicate_pending_resends_stored_msg2` +//! * duplicate (post-crypto) → `chartest_msg1_duplicate_active_same_epoch_resends_stored_msg2` +//! * cross-connection precedence→ `chartest_msg1_inbound_promote_defers_pending_outbound_to_same_identity` +//! * max-peers cap (bypass) → `chartest_msg1_at_cap_with_pending_outbound_bypasses_early_gate` +//! * tie-break (winner+loser) → `chartest_cross_connection_tiebreak_winner_and_loser` +//! * rekey-responder → `chartest_msg1_rekey_responder_stores_pending_session` +//! * rekey dual-init (we win) → `chartest_msg1_rekey_dual_init_we_win_drops_their_msg1` +//! * rekey dual-init (we lose) → `chartest_msg1_rekey_dual_init_we_lose_becomes_responder` +//! +//! The three rekey branches sit behind the hardcoded +//! `existing_session_age_secs >= 30` guard in `handle_msg1`, resolved from +//! `ActivePeer::session_established_at()` (a monotonic `std::time::Instant` +//! with no natural test seam — the field is private and `tokio::time` cannot +//! advance a std `Instant`). They are unblocked by the sole `#[cfg(test)]` +//! production seam `ActivePeer::test_backdate_session_established(age)`, which +//! only shifts that private timestamp — it changes no decision logic and no +//! threshold, and is compiled out of release builds. + +use super::*; +use crate::config::UdpConfig; +use crate::noise::HandshakeState; +use crate::peer::ActivePeer; +use crate::transport::udp::UdpTransport; +use crate::transport::{TransportHandle, packet_channel}; +use tokio::time::timeout; + +/// Build a genuine wire-format Noise IK msg1 addressed to `node`, carrying a +/// chosen startup `epoch` and `sender_index`, from `sender`'s identity. Returns +/// the opaque wire bytes ready to place in a `ReceivedPacket`. +fn craft_msg1_wire( + node: &Node, + sender: &Identity, + epoch: [u8; 8], + sender_index: SessionIndex, + ts: u64, +) -> Vec { + use crate::node::wire::build_msg1; + let peer_b_identity = PeerIdentity::from_pubkey_full(node.identity().pubkey_full()); + let link_id = LinkId::new(0x0BAD_C0DE); + let mut conn = PeerConnection::outbound(link_id, peer_b_identity, ts); + let noise_msg1 = conn + .start_handshake(sender.keypair(), epoch, ts) + .expect("start_handshake produces noise msg1"); + build_msg1(sender_index, &noise_msg1) +} + +/// Register a real UDP transport on `node` and return an independent socket +/// (plus its addr) that plays the peer: the node's msg2 responses are sent to +/// this addr, so a test can observe wire-level output. +async fn register_udp_with_peer_socket( + node: &mut Node, + transport_id: TransportId, +) -> (tokio::net::UdpSocket, TransportAddr) { + let peer_sock = tokio::net::UdpSocket::bind("127.0.0.1:0") + .await + .expect("bind peer socket"); + let peer_addr = TransportAddr::from_string(&peer_sock.local_addr().unwrap().to_string()); + + let cfg = UdpConfig { + bind_addr: Some("127.0.0.1:0".to_string()), + mtu: Some(1280), + ..Default::default() + }; + let (tx, _rx) = packet_channel(64); + let mut transport = UdpTransport::new(transport_id, None, cfg, tx); + transport.start_async().await.unwrap(); + node.transports + .insert(transport_id, TransportHandle::Udp(transport)); + (peer_sock, peer_addr) +} + +/// Local re-impl of the `unit.rs` dummy-peer injector (that one is private to +/// its module). Fills the peer table with distinct identities so cap tests can +/// reach saturation. +fn inject_dummy_peers(node: &mut Node, count: usize) { + for i in 0..count { + let identity = make_peer_identity(); + let addr = *identity.node_addr(); + let peer = ActivePeer::new(identity, LinkId::new((i + 1) as u64), 0); + node.peers.insert(addr, peer); + } +} + +/// Epoch-restart: an inbound msg1 from an already-active peer that carries a +/// DIFFERENT startup epoch is treated as a peer restart. The stale peer is torn +/// down and the fresh handshake is promoted in its place. +/// +/// Oracle: after the push, the identity is still present but is a NEW peer — +/// it now holds a live Noise session (the stale one had none), its stored +/// remote epoch has advanced to the restart value, and it occupies a fresh link +/// and session index. `schedule_reconnect` is a no-op under a bare test config +/// (no auto-connect peer configured), so `retry_pending` stays empty. +#[tokio::test] +async fn chartest_msg1_epoch_restart_replaces_active_peer() { + let mut node = make_node(); + let transport_id = TransportId::new(1); + let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await; + + let sender = Identity::generate(); + let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full()); + let sender_addr = *sender_pid.node_addr(); + + let old_epoch = [1u8; 8]; + let new_epoch = [2u8; 8]; + let old_link = LinkId::new(4242); + + // Pre-existing active peer at the OLD epoch, sessionless. `current_addr` + // makes `should_admit_msg1` recognize the source as an established peer. + let mut old_peer = ActivePeer::new(sender_pid, old_link, 1000); + old_peer.set_remote_epoch(Some(old_epoch)); + old_peer.set_current_addr(transport_id, peer_addr.clone()); + node.peers.insert(sender_addr, old_peer); + assert!(!node.get_peer(&sender_addr).unwrap().has_session()); + assert_eq!( + node.get_peer(&sender_addr).unwrap().remote_epoch(), + Some(old_epoch) + ); + + // Real msg1 carrying the NEW (restart) epoch. + let data = craft_msg1_wire(&node, &sender, new_epoch, SessionIndex::new(0x77), 2000); + let packet = ReceivedPacket { + transport_id, + remote_addr: peer_addr.clone(), + data, + timestamp_ms: 2000, + }; + node.handle_msg1(packet).await; + + let peer = node + .get_peer(&sender_addr) + .expect("restarted peer must remain present (replaced, not dropped)"); + assert!( + peer.has_session(), + "restart promotes a fresh handshake, so the new peer holds a session" + ); + assert_eq!( + peer.remote_epoch(), + Some(new_epoch), + "stored remote epoch advances to the restart value" + ); + assert_ne!( + peer.link_id(), + old_link, + "restart replaces the link with the freshly allocated one" + ); + let our_index = peer.our_index().expect("promoted peer has our_index"); + assert!( + node.peers_by_index + .contains_key(&(transport_id, our_index.as_u32())), + "fresh session index registered in peers_by_index" + ); + assert_eq!(node.peer_count(), 1, "old peer removed, new peer added"); + assert!( + node.retry_pending.is_empty(), + "schedule_reconnect is a no-op with no auto-connect config" + ); + + // A msg2 response was emitted to the restarting peer. + let mut buf = [0u8; 2048]; + let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await; + assert!( + got.is_ok(), + "restart path must emit a msg2 response to the peer" + ); +} + +/// Duplicate msg1, pre-crypto short-circuit: a second msg1 from an address that +/// already has a genuinely-pending (not yet promoted) inbound link resends the +/// stored msg2 without paying the crypto cost or touching registry state. +/// +/// Oracle: the exact stored msg2 bytes are resent verbatim, nothing is +/// promoted, the pending connection is left intact, and the msg1 rate limiter +/// rebalances (start then complete) to baseline. +#[tokio::test] +async fn chartest_msg1_duplicate_pending_resends_stored_msg2() { + let mut node = make_node(); + let transport_id = TransportId::new(1); + let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await; + + // A pending inbound connection with a stored msg2, keyed in addr_to_link, + // NOT promoted to an active peer. + let link_id = node.allocate_link_id(); + let mut conn = + PeerConnection::inbound_with_transport(link_id, transport_id, peer_addr.clone(), 1000); + let stored_msg2 = vec![0xC1, 0xC2, 0xC3, 0xC4, 0xC5]; + conn.set_handshake_msg2(stored_msg2.clone()); + let link = Link::connectionless( + link_id, + transport_id, + peer_addr.clone(), + LinkDirection::Inbound, + Duration::from_millis(100), + ); + node.links.insert(link_id, link); + node.addr_to_link + .insert((transport_id, peer_addr.clone()), link_id); + node.connections.insert(link_id, conn); + assert_eq!(node.peer_count(), 0); + + let before_pending = node.msg1_rate_limiter.pending_count(); + + // Duplicate msg1 from the same address. Content is irrelevant past a valid + // header — the pre-crypto branch fires before any decrypt. + let sender = Identity::generate(); + let data = craft_msg1_wire(&node, &sender, [9u8; 8], SessionIndex::new(5), 2000); + let packet = ReceivedPacket { + transport_id, + remote_addr: peer_addr.clone(), + data, + timestamp_ms: 2000, + }; + node.handle_msg1(packet).await; + + let mut buf = [0u8; 2048]; + let (n, _) = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)) + .await + .expect("stored msg2 must be resent") + .expect("recv_from"); + assert_eq!( + &buf[..n], + &stored_msg2[..], + "the exact stored msg2 is resent for a duplicate msg1" + ); + assert_eq!(node.peer_count(), 0, "duplicate msg1 promotes nothing"); + assert!( + node.connections.contains_key(&link_id), + "pending connection is left intact" + ); + assert_eq!( + node.msg1_rate_limiter.pending_count(), + before_pending, + "rate limiter rebalances to baseline" + ); +} + +/// Duplicate msg1, post-crypto same-epoch path: an inbound msg1 from an active +/// peer at the SAME epoch, on a session too young (< 30s) to be a rekey, is a +/// duplicate. The peer's stored msg2 is resent. +/// +/// Oracle: the active peer's stored msg2 is resent, no new peer or session +/// index is allocated, and the existing peer is untouched. +#[tokio::test] +async fn chartest_msg1_duplicate_active_same_epoch_resends_stored_msg2() { + let mut node = make_node(); + let transport_id = TransportId::new(1); + let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await; + + let sender = Identity::generate(); + let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full()); + let sender_addr = *sender_pid.node_addr(); + + let epoch = [7u8; 8]; + let stored_msg2 = vec![0xD0, 0xD1, 0xD2, 0xD3]; + let link_id = LinkId::new(555); + let mut peer = ActivePeer::new(sender_pid, link_id, 1000); + peer.set_remote_epoch(Some(epoch)); + peer.set_current_addr(transport_id, peer_addr.clone()); + peer.set_handshake_msg2(stored_msg2.clone()); + node.peers.insert(sender_addr, peer); + // Session age is ~0 (< 30s) → the rekey gate is false → a same-epoch msg1 + // classifies as a duplicate, not a rekey initiation. + assert!(!node.get_peer(&sender_addr).unwrap().has_session()); + + let data = craft_msg1_wire(&node, &sender, epoch, SessionIndex::new(0x33), 2000); + let packet = ReceivedPacket { + transport_id, + remote_addr: peer_addr.clone(), + data, + timestamp_ms: 2000, + }; + node.handle_msg1(packet).await; + + let mut buf = [0u8; 2048]; + let (n, _) = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)) + .await + .expect("stored msg2 must be resent") + .expect("recv_from"); + assert_eq!(&buf[..n], &stored_msg2[..]); + assert_eq!(node.peer_count(), 1); + assert_eq!( + node.get_peer(&sender_addr).unwrap().link_id(), + link_id, + "existing peer untouched by a duplicate msg1" + ); + assert!( + node.peers_by_index.is_empty(), + "no new session index allocated on the duplicate path" + ); +} + +/// Cross-connection precedence: an inbound establish that promotes a peer while +/// a concurrent PENDING OUTBOUND connection to the SAME identity exists must NOT +/// tear that outbound down — it is deferred (kept alive so its later msg2 can +/// update `their_index` on the promoted peer). +/// +/// Oracle: the inbound msg1 promotes the peer (with a live session), and both +/// the pending outbound connection and its `pending_outbound` index entry are +/// preserved. +#[tokio::test] +async fn chartest_msg1_inbound_promote_defers_pending_outbound_to_same_identity() { + let mut node = make_node(); + let transport_id = TransportId::new(1); + let (_peer_sock, inbound_addr) = register_udp_with_peer_socket(&mut node, transport_id).await; + + let sender = Identity::generate(); + let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full()); + let sender_addr = *sender_pid.node_addr(); + + // A concurrent pending OUTBOUND connection to the same identity, at a + // different source address. + let out_link = node.allocate_link_id(); + let out_addr = TransportAddr::from_string("10.0.0.9:2121"); + let mut out_conn = PeerConnection::outbound(out_link, sender_pid, 1000); + let our_keypair = node.identity().keypair(); + let _ = out_conn + .start_handshake(our_keypair, node.startup_epoch(), 1000) + .unwrap(); + let out_index = node.index_allocator.allocate().unwrap(); + out_conn.set_our_index(out_index); + out_conn.set_transport_id(transport_id); + out_conn.set_source_addr(out_addr.clone()); + let out_l = Link::connectionless( + out_link, + transport_id, + out_addr.clone(), + LinkDirection::Outbound, + Duration::from_millis(100), + ); + node.links.insert(out_link, out_l); + node.addr_to_link + .insert((transport_id, out_addr.clone()), out_link); + node.connections.insert(out_link, out_conn); + node.pending_outbound + .insert((transport_id, out_index.as_u32()), out_link); + assert_eq!(node.peer_count(), 0); + + // Inbound msg1 from the same identity, different source addr. + let data = craft_msg1_wire(&node, &sender, [3u8; 8], SessionIndex::new(0x22), 2000); + let packet = ReceivedPacket { + transport_id, + remote_addr: inbound_addr.clone(), + data, + timestamp_ms: 2000, + }; + node.handle_msg1(packet).await; + + let peer = node + .get_peer(&sender_addr) + .expect("inbound establish must promote the peer"); + assert!(peer.has_session()); + assert_eq!(node.peer_count(), 1); + assert!( + node.connections.contains_key(&out_link), + "pending outbound to the same identity must be preserved (deferred cleanup)" + ); + assert!( + node.pending_outbound + .contains_key(&(transport_id, out_index.as_u32())), + "the outbound pending_outbound entry is preserved for msg2 index-learning" + ); +} + +/// Max-peers cap, pending-outbound bypass: at saturation, a msg1 from a NEW +/// identity that already has a pending outbound to it is NOT silent-dropped by +/// the early cap gate — it proceeds far enough to emit a msg2, then the late gate +/// inside `promote_connection` rejects it (peer table is full). +/// +/// Oracle discriminator vs. the plain new-peer silent-drop: a msg2 IS observed +/// on the wire (the early gate was bypassed), yet the peer is NOT promoted (the +/// late gate rejects). This locks in the asymmetry between the two cap gates. +#[tokio::test] +async fn chartest_msg1_at_cap_with_pending_outbound_bypasses_early_gate() { + let mut node = make_node_with_max_peers(2); + let transport_id = TransportId::new(1); + let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await; + + inject_dummy_peers(&mut node, 2); + assert_eq!(node.peer_count(), 2, "precondition: at cap"); + + let sender = Identity::generate(); + let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full()); + let sender_addr = *sender_pid.node_addr(); + + // A pending outbound to the (new) sender identity — this sets + // `has_pending_outbound_to_peer`, which turns off the early silent-drop. + let out_link = node.allocate_link_id(); + let out_addr = TransportAddr::from_string("10.0.0.9:2121"); + let mut out_conn = PeerConnection::outbound(out_link, sender_pid, 1000); + let our_keypair = node.identity().keypair(); + let _ = out_conn + .start_handshake(our_keypair, node.startup_epoch(), 1000) + .unwrap(); + let out_index = node.index_allocator.allocate().unwrap(); + out_conn.set_our_index(out_index); + out_conn.set_transport_id(transport_id); + out_conn.set_source_addr(out_addr.clone()); + node.connections.insert(out_link, out_conn); + node.pending_outbound + .insert((transport_id, out_index.as_u32()), out_link); + + let data = craft_msg1_wire(&node, &sender, [4u8; 8], SessionIndex::new(0x44), 2000); + let packet = ReceivedPacket { + transport_id, + remote_addr: peer_addr.clone(), + data, + timestamp_ms: 2000, + }; + node.handle_msg1(packet).await; + + // Late gate rejects: still at cap, sender not promoted. + assert_eq!( + node.peer_count(), + 2, + "late cap gate rejects the new identity" + ); + assert!( + !node.peers.contains_key(&sender_addr), + "new identity is not adopted at capacity" + ); + + // But the early gate was bypassed: a msg2 WAS put on the wire before the + // late-gate rejection (the discriminator against the plain silent-drop). + let mut buf = [0u8; 2048]; + let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await; + assert!( + got.is_ok() && got.unwrap().is_ok(), + "pending-outbound identity bypasses the early silent-drop, so a msg2 \ + is emitted before the late cap gate rejects" + ); +} + +/// Cross-connection tie-break, winner AND loser in one deterministic run: both +/// nodes initiate to each other (simultaneous cross-connection). The rule is +/// "the smaller node_addr's OUTBOUND wins" (`cross_connection_winner`). After +/// both sides exchange msg1 (promote inbound) and msg2 (resolve), the winner has +/// swapped to its outbound session index while the loser keeps the inbound index +/// it assigned during its own msg1 handling. +/// +/// Oracle: the smaller-addr node's peer.our_index equals the OUTBOUND index it +/// allocated at setup; the larger-addr node's peer.our_index equals the INBOUND +/// index it assigned while promoting the peer's msg1. +#[tokio::test] +async fn chartest_cross_connection_tiebreak_winner_and_loser() { + use crate::node::wire::build_msg1; + + let mut node_a = make_node(); + let mut node_b = make_node(); + + let transport_id_a = TransportId::new(1); + let transport_id_b = TransportId::new(1); + + let udp_config = UdpConfig { + bind_addr: Some("127.0.0.1:0".to_string()), + mtu: Some(1280), + ..Default::default() + }; + + let (packet_tx_a, mut packet_rx_a) = packet_channel(64); + let (packet_tx_b, mut packet_rx_b) = packet_channel(64); + + let mut transport_a = UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a); + let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b); + transport_a.start_async().await.unwrap(); + transport_b.start_async().await.unwrap(); + + let addr_a = transport_a.local_addr().unwrap(); + let addr_b = transport_b.local_addr().unwrap(); + let remote_addr_b = TransportAddr::from_string(&addr_b.to_string()); + let remote_addr_a = TransportAddr::from_string(&addr_a.to_string()); + + node_a + .transports + .insert(transport_id_a, TransportHandle::Udp(transport_a)); + node_b + .transports + .insert(transport_id_b, TransportHandle::Udp(transport_b)); + + let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); + let peer_a_identity = PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full()); + let node_a_addr = *node_a.node_addr(); + let node_b_addr = *node_b.node_addr(); + + // A initiates to B. + let link_a_out = node_a.allocate_link_id(); + let mut conn_a = PeerConnection::outbound(link_a_out, peer_b_identity, 1000); + let out_index_a = node_a.index_allocator.allocate().unwrap(); + let noise_msg1_a = conn_a + .start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000) + .unwrap(); + conn_a.set_our_index(out_index_a); + conn_a.set_transport_id(transport_id_a); + conn_a.set_source_addr(remote_addr_b.clone()); + let wire_msg1_a = build_msg1(out_index_a, &noise_msg1_a); + node_a.links.insert( + link_a_out, + Link::connectionless( + link_a_out, + transport_id_a, + remote_addr_b.clone(), + LinkDirection::Outbound, + Duration::from_millis(100), + ), + ); + node_a + .addr_to_link + .insert((transport_id_a, remote_addr_b.clone()), link_a_out); + node_a.connections.insert(link_a_out, conn_a); + node_a + .pending_outbound + .insert((transport_id_a, out_index_a.as_u32()), link_a_out); + + // B initiates to A. + let link_b_out = node_b.allocate_link_id(); + let mut conn_b = PeerConnection::outbound(link_b_out, peer_a_identity, 1000); + let out_index_b = node_b.index_allocator.allocate().unwrap(); + let noise_msg1_b = conn_b + .start_handshake(node_b.identity().keypair(), node_b.startup_epoch(), 1000) + .unwrap(); + conn_b.set_our_index(out_index_b); + conn_b.set_transport_id(transport_id_b); + conn_b.set_source_addr(remote_addr_a.clone()); + let wire_msg1_b = build_msg1(out_index_b, &noise_msg1_b); + node_b.links.insert( + link_b_out, + Link::connectionless( + link_b_out, + transport_id_b, + remote_addr_a.clone(), + LinkDirection::Outbound, + Duration::from_millis(100), + ), + ); + node_b + .addr_to_link + .insert((transport_id_b, remote_addr_a.clone()), link_b_out); + node_b.connections.insert(link_b_out, conn_b); + node_b + .pending_outbound + .insert((transport_id_b, out_index_b.as_u32()), link_b_out); + + // Both put msg1 on the wire. + node_a + .transports + .get(&transport_id_a) + .unwrap() + .send(&remote_addr_b, &wire_msg1_a) + .await + .unwrap(); + node_b + .transports + .get(&transport_id_b) + .unwrap() + .send(&remote_addr_a, &wire_msg1_b) + .await + .unwrap(); + + // Each processes the other's msg1 (promotes inbound, assigns an inbound index). + let pkt_at_b = timeout(Duration::from_secs(1), packet_rx_b.recv()) + .await + .unwrap() + .unwrap(); + node_b.handle_msg1(pkt_at_b).await; + let pkt_at_a = timeout(Duration::from_secs(1), packet_rx_a.recv()) + .await + .unwrap() + .unwrap(); + node_a.handle_msg1(pkt_at_a).await; + + // Inbound indices assigned during promotion (before resolution). + let inbound_index_a = node_a.get_peer(&node_b_addr).unwrap().our_index().unwrap(); + let inbound_index_b = node_b.get_peer(&node_a_addr).unwrap().our_index().unwrap(); + + // Each processes the other's msg2 (cross-connection resolution). + let msg2_at_a = timeout(Duration::from_secs(1), packet_rx_a.recv()) + .await + .unwrap() + .unwrap(); + node_a.handle_msg2(msg2_at_a).await; + let msg2_at_b = timeout(Duration::from_secs(1), packet_rx_b.recv()) + .await + .unwrap() + .unwrap(); + node_b.handle_msg2(msg2_at_b).await; + + // Rule: smaller node_addr's OUTBOUND wins → that node swaps to its outbound + // index; the larger node keeps the inbound index from its own msg1 handling. + let a_is_winner = node_a_addr < node_b_addr; + let final_our_index_a = node_a.get_peer(&node_b_addr).unwrap().our_index().unwrap(); + let final_our_index_b = node_b.get_peer(&node_a_addr).unwrap().our_index().unwrap(); + + if a_is_winner { + assert_eq!( + final_our_index_a, out_index_a, + "winner (smaller addr) swaps to its outbound session index" + ); + assert_eq!( + final_our_index_b, inbound_index_b, + "loser (larger addr) keeps the inbound index from its msg1 handling" + ); + } else { + assert_eq!( + final_our_index_b, out_index_b, + "winner (smaller addr) swaps to its outbound session index" + ); + assert_eq!( + final_our_index_a, inbound_index_a, + "loser (larger addr) keeps the inbound index from its msg1 handling" + ); + } + + // Both remain single, sendable peers after resolution. + assert_eq!(node_a.peer_count(), 1); + assert_eq!(node_b.peer_count(), 1); + assert!(node_a.get_peer(&node_b_addr).unwrap().can_send()); + assert!(node_b.get_peer(&node_a_addr).unwrap().can_send()); + + for (_, t) in node_a.transports.iter_mut() { + t.stop().await.ok(); + } + for (_, t) in node_b.transports.iter_mut() { + t.stop().await.ok(); + } +} + +// =========================================================================== +// Rekey establish branches (unblocked by the `#[cfg(test)]` +// `ActivePeer::test_backdate_session_established` seam that lets a test age a +// real session past the hardcoded 30s rekey gate in `handle_msg1`). +// =========================================================================== + +/// Drive a real inbound msg1 through `handle_msg1` so `node` promotes an active +/// peer for `sender` at startup `epoch`, draining the msg2 the promotion emits. +/// Returns the sender's NodeAddr. +async fn establish_active_peer_via_msg1( + node: &mut Node, + sender: &Identity, + epoch: [u8; 8], + transport_id: TransportId, + peer_addr: &TransportAddr, + peer_sock: &tokio::net::UdpSocket, + ts: u64, +) -> NodeAddr { + let sender_addr = *PeerIdentity::from_pubkey_full(sender.pubkey_full()).node_addr(); + let data = craft_msg1_wire(node, sender, epoch, SessionIndex::new(0x01), ts); + let packet = ReceivedPacket { + transport_id, + remote_addr: peer_addr.clone(), + data, + timestamp_ms: ts, + }; + node.handle_msg1(packet).await; + // Promotion emits a msg2 AND an initial TreeAnnounce; drain every queued + // datagram so a later recv observes only the rekey response (or its + // absence), never a leftover from establishment. + let mut buf = [0u8; 2048]; + while timeout(Duration::from_millis(150), peer_sock.recv_from(&mut buf)) + .await + .is_ok() + {} + sender_addr +} + +/// Draw a fresh sender identity whose NodeAddr is greater-than (`want_greater`) +/// or less-than the node's own NodeAddr, so the dual-init tie-break outcome is +/// deterministic. The comparison invariant is enforced, so the test outcome is +/// deterministic even though the identity draw is random. +fn sender_with_addr_relation(node: &Node, want_greater: bool) -> Identity { + let node_addr = *node.node_addr(); + loop { + let s = Identity::generate(); + let a = *PeerIdentity::from_pubkey_full(s.pubkey_full()).node_addr(); + if a != node_addr && (a > node_addr) == want_greater { + return s; + } + } +} + +/// Arm a local in-flight (initiator) rekey on `node`'s peer for `sender`, with a +/// real allocated index registered in `peers_by_index`/`pending_outbound` (as a +/// genuine in-flight rekey would be). Returns the armed rekey index. +fn arm_local_rekey( + node: &mut Node, + sender: &Identity, + sender_addr: &NodeAddr, + transport_id: TransportId, +) -> SessionIndex { + let rekey_index = node.index_allocator.allocate().unwrap(); + node.peers_by_index + .insert((transport_id, rekey_index.as_u32()), *sender_addr); + node.pending_outbound + .insert((transport_id, rekey_index.as_u32()), LinkId::new(0xF00D)); + let local = Identity::generate(); + let hs = HandshakeState::new_initiator(local.keypair(), sender.pubkey_full()); + node.get_peer_mut(sender_addr) + .unwrap() + .set_rekey_state(hs, rekey_index, vec![0xAB; 64], 0); + rekey_index +} + +/// Rekey-responder: a genuine rekey msg1 (a fresh IK handshake at the SAME +/// epoch) arriving for an active peer whose session is past the 30s gate is +/// processed as a rekey. The responder extracts the new session and holds it as +/// PENDING (awaiting K-bit cutover) without disturbing the live session. +/// +/// Oracle: the new session lands in `pending_new_session` with a freshly +/// allocated `pending_our_index` and `pending_their_index` == the rekey msg1 +/// sender index; the current session/index stay live and registered; the new +/// index is additionally registered in `peers_by_index`; and a rekey msg2 is +/// emitted. The peer is neither replaced nor left `rekey_in_progress`. +#[tokio::test] +async fn chartest_msg1_rekey_responder_stores_pending_session() { + let mut node = make_node(); + let transport_id = TransportId::new(1); + let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await; + + let sender = Identity::generate(); + let epoch = [5u8; 8]; + let sender_addr = establish_active_peer_via_msg1( + &mut node, + &sender, + epoch, + transport_id, + &peer_addr, + &peer_sock, + 1000, + ) + .await; + + let old_index = { + let p = node.get_peer(&sender_addr).expect("peer established"); + assert!(p.has_session()); + assert!(p.is_healthy()); + assert_eq!(p.remote_epoch(), Some(epoch)); + assert!(!p.rekey_in_progress()); + assert!(p.pending_new_session().is_none()); + p.our_index().unwrap() + }; + assert!( + node.peers_by_index + .contains_key(&(transport_id, old_index.as_u32())) + ); + let index_count_before = node.index_allocator.count(); + + // Age the live session past the 30s rekey gate (test-only seam). + node.get_peer_mut(&sender_addr) + .unwrap() + .test_backdate_session_established(Duration::from_secs(31)); + + // A genuine rekey msg1 (fresh IK handshake, SAME epoch) arrives. + let rekey_sender_index = SessionIndex::new(0xBEEF); + let data = craft_msg1_wire(&node, &sender, epoch, rekey_sender_index, 2000); + let packet = ReceivedPacket { + transport_id, + remote_addr: peer_addr.clone(), + data, + timestamp_ms: 2000, + }; + node.handle_msg1(packet).await; + + let p = node + .get_peer(&sender_addr) + .expect("peer still present (not replaced)"); + assert_eq!( + node.peer_count(), + 1, + "rekey neither adds nor replaces the peer" + ); + assert!( + p.pending_new_session().is_some(), + "new session held as pending" + ); + let new_index = p.pending_our_index().expect("pending our_index allocated"); + assert_eq!( + p.pending_their_index(), + Some(rekey_sender_index), + "pending their_index = the rekey msg1 sender index" + ); + assert!( + !p.rekey_in_progress(), + "set_pending_session clears rekey_in_progress" + ); + assert_eq!( + p.our_index(), + Some(old_index), + "current session index stays live until cutover" + ); + assert!(p.has_session(), "current session remains live"); + + assert!( + node.peers_by_index + .contains_key(&(transport_id, old_index.as_u32())), + "current index still registered" + ); + assert!( + node.peers_by_index + .contains_key(&(transport_id, new_index.as_u32())), + "new pending index registered" + ); + assert_eq!( + node.index_allocator.count(), + index_count_before + 1, + "exactly one extra index allocated for the pending session" + ); + + let mut buf = [0u8; 2048]; + let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await; + assert!( + got.is_ok() && got.unwrap().is_ok(), + "rekey responder emits a rekey msg2" + ); +} + +/// Rekey dual-init, WE WIN: with a local rekey in flight, a simultaneous rekey +/// msg1 arrives from a peer whose NodeAddr is larger than ours. The tie-break +/// ("smaller NodeAddr wins as initiator") makes us the winner, so we DROP their +/// msg1 and keep driving our own rekey. +/// +/// Oracle: our in-flight rekey is untouched (`rekey_in_progress` stays true, our +/// rekey index retained and still registered), no responder session is stored, +/// no responder index is allocated, and no rekey msg2 is emitted. +#[tokio::test] +async fn chartest_msg1_rekey_dual_init_we_win_drops_their_msg1() { + let mut node = make_node(); + let transport_id = TransportId::new(1); + let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await; + + // We win when our node_addr < peer's → pick a sender greater than us. + let sender = sender_with_addr_relation(&node, true); + let epoch = [6u8; 8]; + let sender_addr = establish_active_peer_via_msg1( + &mut node, + &sender, + epoch, + transport_id, + &peer_addr, + &peer_sock, + 1000, + ) + .await; + assert!( + *node.node_addr() < sender_addr, + "precondition: node wins the tie-break" + ); + + node.get_peer_mut(&sender_addr) + .unwrap() + .test_backdate_session_established(Duration::from_secs(31)); + let rekey_index = arm_local_rekey(&mut node, &sender, &sender_addr, transport_id); + assert!(node.get_peer(&sender_addr).unwrap().rekey_in_progress()); + let index_count_before = node.index_allocator.count(); + + // Their simultaneous rekey msg1 arrives. + let data = craft_msg1_wire(&node, &sender, epoch, SessionIndex::new(0xAAAA), 2000); + let packet = ReceivedPacket { + transport_id, + remote_addr: peer_addr.clone(), + data, + timestamp_ms: 2000, + }; + node.handle_msg1(packet).await; + + let p = node.get_peer(&sender_addr).expect("peer present"); + assert!( + p.rekey_in_progress(), + "our rekey survives; the winner does not abandon it" + ); + assert!( + p.pending_new_session().is_none(), + "no responder session stored on the winner path" + ); + assert_eq!( + p.rekey_our_index(), + Some(rekey_index), + "our rekey index retained" + ); + assert!( + node.peers_by_index + .contains_key(&(transport_id, rekey_index.as_u32())), + "our rekey index still registered in peers_by_index" + ); + assert!( + node.pending_outbound + .contains_key(&(transport_id, rekey_index.as_u32())), + "our rekey pending_outbound entry retained" + ); + assert_eq!( + node.index_allocator.count(), + index_count_before, + "no responder index allocated on the winner path" + ); + + let mut buf = [0u8; 2048]; + let got = timeout(Duration::from_millis(300), peer_sock.recv_from(&mut buf)).await; + assert!( + got.is_err(), + "winner emits no msg2 in response to the dropped rekey msg1" + ); +} + +/// Rekey dual-init, WE LOSE: with a local rekey in flight, a simultaneous rekey +/// msg1 arrives from a peer whose NodeAddr is smaller than ours. The tie-break +/// makes us the loser, so we ABANDON our own rekey and respond as the rekey +/// responder. +/// +/// Oracle: our in-flight rekey is abandoned (`rekey_in_progress` cleared, the +/// abandoned index freed and unregistered from `pending_outbound`), the new +/// session is stored as pending with `pending_their_index` == the rekey msg1 +/// sender index, the pending index is registered, and a rekey msg2 is emitted. +#[tokio::test] +async fn chartest_msg1_rekey_dual_init_we_lose_becomes_responder() { + let mut node = make_node(); + let transport_id = TransportId::new(1); + let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await; + + // We lose when our node_addr > peer's → pick a sender smaller than us. + let sender = sender_with_addr_relation(&node, false); + let epoch = [6u8; 8]; + let sender_addr = establish_active_peer_via_msg1( + &mut node, + &sender, + epoch, + transport_id, + &peer_addr, + &peer_sock, + 1000, + ) + .await; + assert!( + *node.node_addr() > sender_addr, + "precondition: node loses the tie-break" + ); + + node.get_peer_mut(&sender_addr) + .unwrap() + .test_backdate_session_established(Duration::from_secs(31)); + let rekey_index = arm_local_rekey(&mut node, &sender, &sender_addr, transport_id); + assert!(node.get_peer(&sender_addr).unwrap().rekey_in_progress()); + + // Their simultaneous rekey msg1 arrives. + let rekey_sender_index = SessionIndex::new(0xCCCC); + let data = craft_msg1_wire(&node, &sender, epoch, rekey_sender_index, 2000); + let packet = ReceivedPacket { + transport_id, + remote_addr: peer_addr.clone(), + data, + timestamp_ms: 2000, + }; + node.handle_msg1(packet).await; + + let p = node.get_peer(&sender_addr).expect("peer present"); + assert!( + !p.rekey_in_progress(), + "we abandoned our rekey and became responder" + ); + assert!( + p.pending_new_session().is_some(), + "responder stores the new session as pending" + ); + assert_eq!( + p.pending_their_index(), + Some(rekey_sender_index), + "pending their_index = the rekey msg1 sender index" + ); + let new_index = p + .pending_our_index() + .expect("responder allocated a pending index"); + + assert!( + !node + .pending_outbound + .contains_key(&(transport_id, rekey_index.as_u32())), + "abandoned rekey pending_outbound entry removed" + ); + assert!( + node.peers_by_index + .contains_key(&(transport_id, new_index.as_u32())), + "new pending index registered" + ); + + let mut buf = [0u8; 2048]; + let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await; + assert!( + got.is_ok() && got.unwrap().is_ok(), + "loser (now responder) emits a rekey msg2" + ); +} diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index c6484ec..54f7f98 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -13,6 +13,7 @@ mod bootstrap; mod decrypt_failure; mod disconnect; mod discovery; +mod establish_chartests; #[cfg(target_os = "linux")] mod ethernet; mod forwarding; 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/unit.rs b/src/node/tests/unit.rs index b2f78ee..b8af260 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -1260,7 +1260,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, @@ -1296,7 +1296,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); } @@ -1308,7 +1308,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/peer/active.rs b/src/peer/active.rs index 1ae58fd..3c08b25 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -880,6 +880,18 @@ impl ActivePeer { self.session_established_at } + /// Test-only seam: backdate the session-established instant so a test can + /// construct a session that reads as `age`-old. This only shifts the + /// private timestamp field; it changes no decision logic, no threshold, and + /// is compiled out of release builds. + #[cfg(test)] + pub(crate) fn test_backdate_session_established(&mut self, age: std::time::Duration) { + self.session_established_at = self + .session_established_at + .checked_sub(age) + .unwrap_or_else(Instant::now); + } + /// Per-session symmetric rekey-timer jitter offset (seconds). /// /// Drawn at session construction and at each rekey cutover; uniform diff --git a/src/peer/connection.rs b/src/peer/connection.rs index bc6c6e2..a0c87eb 100644 --- a/src/peer/connection.rs +++ b/src/peer/connection.rs @@ -6,133 +6,37 @@ use crate::PeerIdentity; use crate::noise::{self, NoiseError, NoiseSession}; +use crate::proto::fmp::ConnectionState; 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 IK pattern: -/// - Initiator: Initial → SentMsg1 → Complete -/// - Responder: Initial → ReceivedMsg1 → Complete -#[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]>, - - // === 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 { @@ -146,25 +50,9 @@ 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, - handshake_msg1: None, - handshake_msg2: None, - resend_count: 0, - next_resend_at_ms: 0, } } @@ -174,25 +62,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, - handshake_msg1: None, - handshake_msg2: None, - resend_count: 0, - next_resend_at_ms: 0, } } @@ -206,195 +78,181 @@ 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, - 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() } // === 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 === + // === Noise Handshake Operations (shell: drives crypto, updates pure state) === /// Start the handshake as initiator and generate message 1. /// @@ -406,23 +264,23 @@ 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(), }); } let remote_static = self - .expected_identity - .as_ref() + .state + .expected_identity() .expect("outbound must have expected identity") .pubkey_full(); @@ -431,8 +289,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) } @@ -448,17 +306,17 @@ impl PeerConnection { message: &[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(), }); } @@ -468,14 +326,16 @@ impl PeerConnection { // Process message 1 (this reveals the initiator's identity and epoch) hs.read_message_1(message)?; - // Extract the discovered identity + // Extract the discovered identity from the crypto and record it as + // pure data on the state. let remote_static = *hs .remote_static() .expect("remote static available after msg1"); - 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 msg1 - self.remote_epoch = hs.remote_epoch(); + self.state.set_remote_epoch(hs.remote_epoch()); // Generate message 2 let msg2 = hs.write_message_2()?; @@ -483,8 +343,8 @@ impl PeerConnection { // Handshake is 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(msg2) } @@ -497,10 +357,10 @@ impl PeerConnection { message: &[u8], current_time_ms: u64, ) -> Result<(), 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(), }); } @@ -512,12 +372,12 @@ impl PeerConnection { hs.read_message_2(message)?; // Capture remote epoch from msg2 - self.remote_epoch = hs.remote_epoch(); + self.state.set_remote_epoch(hs.remote_epoch()); 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(()) } @@ -527,7 +387,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 @@ -536,44 +396,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/fmp/core.rs b/src/proto/fmp/core.rs new file mode 100644 index 0000000..0ae419c --- /dev/null +++ b/src/proto/fmp/core.rs @@ -0,0 +1,644 @@ +//! 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 **inbound classification** decision, however, is modelled here: +//! [`Fmp::establish_inbound`] maps an [`EstablishSnapshot`] + [`WireOutcome`] +//! onto an [`InboundDecision`] the shell dispatches (E3). The outbound +//! (`handle_msg2`) classification and the born-on-next `handle_msg3` leaf remain +//! shell-side. + +use super::state::Fmp; +use crate::transport::LinkId; +use crate::utils::index::SessionIndex; +use crate::{NodeAddr, PeerIdentity}; + +/// 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, + /// 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 result of the shell-side Noise wire step (Phase B) for one inbound +/// handshake msg1, handed to the establish decision core. +/// +/// The Noise step (`receive_handshake_init`) runs shell-side on the +/// `PeerConnection`: it reads **no** `Node` registry state — the load-bearing +/// invariant of this decomposition — and yields the learned peer identity, the +/// remote startup epoch, the sender's session index, and the opaque msg2 noise +/// payload to frame and send. The core never parses or builds Noise bytes; the +/// payload is an opaque blob. +pub(crate) struct WireOutcome { + /// Peer identity learned from the handshake (msg1 static key). + pub peer_identity: PeerIdentity, + /// The peer's startup epoch captured from msg1, if present. + pub remote_epoch: Option<[u8; 8]>, + /// The sender's session index from the msg1 header (becomes our + /// `receiver_idx`/`their_index` in the msg2 response and the promotion). + pub their_index: SessionIndex, + /// The opaque Noise msg2 payload the responder produced (empty only if no + /// msg2 is to be sent). + pub msg2_payload: Vec, +} + +/// A snapshot of the `Node` registry state the inbound establish decision reads +/// about the peer identified in a just-processed msg1, taken by the shell so the +/// core decides without touching live `Node` state or reading a clock. +/// +/// Produced by the [`EstablishView`] read-seam. Every clock read +/// (`existing_session_age_secs`) is resolved shell-side into a plain `u64`, the +/// same monotonic-ages asymmetry the rekey snapshot uses. +pub(crate) struct EstablishSnapshot { + /// The peer is already an active peer in the registry. + 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. + pub pending_new_session: bool, + /// The existing peer has a rekey handshake in flight. + pub rekey_in_progress: bool, + /// The existing peer's stored msg2 wire bytes (an opaque blob), resent on a + /// same-epoch duplicate msg1. `None` when there is no existing peer or it + /// has no stored msg2. + pub existing_msg2: Option>, + /// Admitting this peer as a net-new identity would exceed `max_peers` + /// (pre-evaluated `max_peers > 0 && peers.len() >= max_peers`). + pub at_max_peers: bool, + /// A pending outbound connection to this same peer identity already exists + /// (a cross-connection in progress); bypasses the max-peers cap. + pub has_pending_outbound_to_peer: bool, + /// Whether the local rekey trigger is enabled in config (gates treating a + /// same-epoch msg1 from an established peer as a rekey rather than a + /// duplicate). + pub rekey_enabled: bool, + /// This node's own address, for the dual-initiation tie-break. + pub our_node_addr: NodeAddr, +} + +/// A snapshot of the registry state the *outbound* establish decision reads +/// about the peer whose msg2 just completed our handshake, taken by the shell. +/// +/// Both fields are pre-evaluated shell-side (the tie-break is a pure function of +/// the two node addresses, resolved into a plain `bool` here) so the core never +/// touches live `Node` state or the `crate::peer` tie-break helper. +pub(crate) struct OutboundSnapshot { + /// The peer identity is already a promoted active peer — i.e. this outbound + /// completion is a cross-connection (we also processed their msg1). + pub has_existing_peer: bool, + /// Pre-evaluated cross-connection tie-break: our *outbound* connection wins + /// (we are the smaller NodeAddr). Only meaningful when `has_existing_peer`. + pub our_outbound_wins: bool, +} + +/// A registry/transport effect the async shell performs on the core's behalf. +/// +/// The maintain/teardown subset (`Teardown`..`ResendRekeyMsg1`) covers the +/// tick-poll half of the lifecycle. The establish-machine subset +/// (`PromoteToActive`..) is the master-side IK handshake decision; the shell +/// executes each, resolving the ambient identity/time/wire payload it needs. +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, + }, + /// Promote the completed handshake connection on `link` to an active peer + /// (`promote_connection`): moves the Noise session out of the + /// `PeerConnection`, resolves cross-connection precedence via the + /// tie-breaker, and installs the `ActivePeer`. The shell executes the + /// promotion (resolving the verified identity and promotion timestamp from + /// the ambient wire context) and then runs the post-promotion tail + /// (TreeAnnounce, bloom mark, discovery-backoff reset, loser-link cleanup). + PromoteToActive { link: LinkId }, +} + +/// 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 handshake msg1, 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. +/// +/// The variants map one-to-one onto the pre-refactor inline branches of +/// `handle_msg1`'s post-crypto classification. There is deliberately **no** +/// inbound cross-connection won/lost variant: an existing same-identity peer is +/// always intercepted here first (restart / rekey / duplicate), and a net-new +/// [`Promote`](InboundDecision::Promote) reaches `promote_connection` with no +/// existing peer — so on the inbound path the tie-break never fires. The real +/// cross-connection resolution lives in `handle_msg2` (outbound completion). +#[derive(Debug)] +pub(crate) enum InboundDecision { + /// No existing peer for this identity: authorize, allocate our index, send + /// msg2, and promote. Everything the shell needs (verified identity, their + /// index, opaque msg2 payload) is in the `WireOutcome` it still holds, so + /// the variant carries nothing. + Promote, + /// Existing peer at a *different* startup epoch — a peer restart. The shell + /// tears down the stale peer and schedules its reconnect, then runs the same + /// authorize → … → promote sequence as [`Promote`](InboundDecision::Promote). + /// `peer` is the teardown / reconnect target. + RestartThenPromote { peer: NodeAddr }, + /// Same-epoch rekey msg1 on an aged, healthy session: respond as the rekey + /// responder. The shell extracts the fresh Noise session from the live + /// connection, allocates a new index, sends the rekey msg2, and stores the + /// session as the peer's pending (post-rekey) session. `abandon_first` is set + /// only on the dual-initiation *loser* path, where we first abandon our own + /// in-flight rekey. `peer` is the rekey target. + RekeyRespond { peer: NodeAddr, abandon_first: bool }, + /// Same-epoch duplicate msg1 (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). + ResendMsg2 { msg2: Option> }, + /// Drop this msg1 with a handshake reject (`HandshakeReject::BadState`) and + /// no promotion. `reason` selects only the diagnostic log line — every reject + /// records the same stat and completes the rate-limiter identically. + Reject { reason: InboundReject }, +} + +/// Why an inbound msg1 was rejected. Distinguishes only the diagnostic log +/// message; all three reject identically (BadState stat, rate-limiter complete, +/// the local not-yet-registered connection dropped). +#[derive(Debug)] +pub(crate) enum InboundReject { + /// At `max_peers` and this is a net-new identity with no pending outbound to + /// bypass the cap: silent-drop before any msg2 build/send. + AtMaxPeers, + /// The peer already holds a pending post-rekey session awaiting K-bit + /// cutover; a second rekey msg1 must not overwrite it. + PendingSession, + /// Dual rekey initiation and we are the tie-break *winner* (smaller + /// NodeAddr): drop the peer's msg1 and keep driving our own rekey. + DualRekeyWon, +} + +/// The classification outcome for one outbound `handle_msg2` completion, decided +/// purely from the [`OutboundSnapshot`]. The shell matches on this and drives +/// the effects; the core consumes nothing and touches no live state. +/// +/// Only the case where the peer is *not* yet a promoted active peer is a plain +/// promotion; when it is, this msg2 completes the outbound half of a +/// cross-connection and the tie-break decides whether we swap our session to the +/// (winning) outbound one or keep our existing inbound session. The rekey-msg2 +/// completion path is handled by a separate shell driver (it mutates +/// `ActivePeer`, not a `PeerConnection`) and never reaches this decision. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum OutboundDecision { + /// No existing peer for this identity: promote the completed outbound + /// connection to an active peer via the normal promotion path. + Promote, + /// Cross-connection and our outbound wins (smaller NodeAddr): swap the peer + /// to the outbound session + indices, freeing the old inbound index. + CrossConnectionSwap, + /// Cross-connection and our outbound loses (larger NodeAddr): keep the + /// existing inbound session and original `their_index`, freeing the unused + /// outbound index. + CrossConnectionKeep, +} + +/// Minimum session age (seconds) before a same-epoch msg1 from an established +/// peer is treated as a rekey rather than a duplicate. Guards against +/// misreading a simultaneous cross-connection msg1 as a rekey (both sides +/// promote within a tick, so a genuine rekey cannot fire that fast). Unchanged +/// from the pre-refactor literal. +const REKEY_MIN_SESSION_AGE_SECS: u64 = 30; + +/// Read-only view of the `Node` registry state the inbound establish decision +/// needs about a peer whose msg1 has just been processed. +/// +/// The core defines this interface; the async shell (`node`) implements it over +/// the live `peers`/`connections` maps, resolving every clock read into a plain +/// `u64` before the [`EstablishSnapshot`] reaches the core. Keeping it a trait +/// keeps `proto` free of a `node` dependency and lets the establish decision be +/// unit-tested against hand-built snapshots. +pub(crate) trait EstablishView { + /// Snapshot the registry state relevant to classifying an inbound msg1 from + /// `peer_addr`: the existing peer's epoch/session/rekey state (with the + /// session age resolved shell-side), the max-peers cap, and this node's own + /// address for the tie-break. + fn establish_snapshot(&self, peer_addr: &NodeAddr) -> EstablishSnapshot; + + /// Snapshot the registry state relevant to classifying an outbound msg2 + /// completion for `peer_addr`: whether the identity is already an active + /// peer, and the pre-evaluated cross-connection tie-break. + fn outbound_snapshot(&self, peer_addr: &NodeAddr) -> OutboundSnapshot; +} + +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 { + 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 handshake msg1 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 `handle_msg1` post-crypto branch order exactly: + /// the early max-peers cap gate, then — for an existing same-identity peer — + /// the epoch-restart / rekey / duplicate classification, else a net-new + /// promote. The pre-refactor `possible_restart` flag is folded away: it was + /// forced true whenever `has_existing_peer` held, so gating the block on + /// `has_existing_peer` alone is behavior-identical. + pub(crate) fn establish_inbound( + &self, + snap: &EstablishSnapshot, + wire: &WireOutcome, + ) -> InboundDecision { + // Early cap gate: at capacity and a net-new identity (no existing peer, + // no pending outbound to bypass) → silent-drop before any msg2. + if snap.at_max_peers && !snap.has_existing_peer && !snap.has_pending_outbound_to_peer { + return InboundDecision::Reject { + reason: InboundReject::AtMaxPeers, + }; + } + + if snap.has_existing_peer { + let peer_addr = *wire.peer_identity.node_addr(); + match (snap.existing_peer_epoch, wire.remote_epoch) { + (Some(existing), Some(new)) if existing != new => { + // Epoch mismatch → peer restart. + InboundDecision::RestartThenPromote { peer: peer_addr } + } + _ => { + // Same epoch (or no epoch captured on either side). + let is_rekey = snap.rekey_enabled + && snap.has_session + && snap.is_healthy + && snap.existing_session_age_secs >= REKEY_MIN_SESSION_AGE_SECS; + if !is_rekey { + // Duplicate msg1 — resend the stored msg2. + return InboundDecision::ResendMsg2 { + msg2: snap.existing_msg2.clone(), + }; + } + if snap.pending_new_session { + // A completed rekey is already pending cutover. + return InboundDecision::Reject { + reason: InboundReject::PendingSession, + }; + } + if snap.rekey_in_progress { + // Dual initiation — smaller NodeAddr wins as initiator. + // Our own rekey is the outbound/initiator side, so reuse + // the shared tie-break with `this_is_outbound = true`. + if cross_connection_winner(&snap.our_node_addr, &peer_addr, true) { + return InboundDecision::Reject { + reason: InboundReject::DualRekeyWon, + }; + } + // We lose → abandon ours, then respond as responder. + return InboundDecision::RekeyRespond { + peer: peer_addr, + abandon_first: true, + }; + } + InboundDecision::RekeyRespond { + peer: peer_addr, + abandon_first: false, + } + } + } + } else { + // No existing peer for this identity → net-new promote. + InboundDecision::Promote + } + } + + /// Classify one outbound `handle_msg2` completion from the outbound snapshot. + /// Pure: reads only `snap`, mutates nothing. + /// + /// Mirrors the pre-refactor branch exactly: an existing same-identity peer + /// makes this a cross-connection resolved by the (pre-evaluated) tie-break — + /// swap on a win, keep on a loss — otherwise a net-new promote. + pub(crate) fn establish_outbound(&self, snap: &OutboundSnapshot) -> OutboundDecision { + if !snap.has_existing_peer { + return OutboundDecision::Promote; + } + if snap.our_outbound_wins { + OutboundDecision::CrossConnectionSwap + } else { + OutboundDecision::CrossConnectionKeep + } + } +} + +/// 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 +} 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..b59c070 --- /dev/null +++ b/src/proto/fmp/mod.rs @@ -0,0 +1,44 @@ +//! 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 inbound-msg1 classification decision +//! ([`Fmp::establish_inbound`]). Handshake message bytes are opaque blobs +//! throughout — the Noise wire construction and `promote_connection` effects, +//! the outbound `handle_msg2` classification, and the born-on-next +//! `handle_msg3` leaf stay in `node/`. +//! +//! - `core.rs` — the [`LifecycleView`] read-seam trait, the [`ConnAction`] +//! effect vocabulary, the snapshot types, the pure `poll_*` decisions, and +//! the [`cross_connection_winner`] tie-break helper. +//! - `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 link-framing codec: handshake message types, +//! disconnect reasons, and the orderly disconnect message. + +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, EstablishView, InboundDecision, InboundReject, + LifecycleView, OutboundDecision, OutboundSnapshot, PeerSnapshot, RekeyCfg, RekeyResendSnapshot, + WireOutcome, +}; +pub(crate) use limits::backoff_ms; +pub use state::HandshakeState; +pub(crate) use state::{ConnectionState, Fmp}; +pub use wire::HandshakeMessageType; +pub(crate) use wire::{Disconnect, DisconnectReason}; diff --git a/src/proto/fmp/state.rs b/src/proto/fmp/state.rs new file mode 100644 index 0000000..6fda549 --- /dev/null +++ b/src/proto/fmp/state.rs @@ -0,0 +1,465 @@ +//! 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 crate::PeerIdentity; +use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId}; +use crate::utils::index::SessionIndex; +use core::fmt; + +/// Handshake protocol state machine. +/// +/// For Noise IK pattern: +/// - Initiator: Initial → SentMsg1 → Complete +/// - Responder: Initial → ReceivedMsg1 → Complete +#[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]>, + + // === 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, + 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, + 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, + 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); + } + + // === 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..be9a3ab --- /dev/null +++ b/src/proto/fmp/tests/core.rs @@ -0,0 +1,592 @@ +//! 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::NodeAddr; +use crate::proto::fmp::{ + ConnAction, Fmp, InboundDecision, InboundReject, OutboundDecision, OutboundSnapshot, RekeyCfg, + cross_connection_winner, +}; +use crate::testutil::make_node_addr; +use crate::transport::LinkId; + +/// 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_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)) + ); +} + +// =========================================================================== +// establish_inbound — inbound msg1 classification (E3) +// =========================================================================== + +/// All-0xFF NodeAddr: strictly greater than any pubkey-derived peer addr, so a +/// tie-break with `our_node_addr` set to this makes us the larger side. +fn max_node_addr() -> NodeAddr { + NodeAddr::from_bytes([0xFF; 16]) +} + +#[test] +fn establish_inbound_net_new_promotes() { + let fmp = Fmp::new(); + let snap = establish_snapshot(); + let wire = wire_outcome(Some([1u8; 8])); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::Promote + )); +} + +#[test] +fn establish_inbound_at_cap_net_new_rejects() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.at_max_peers = true; + let wire = wire_outcome(Some([1u8; 8])); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::Reject { + reason: InboundReject::AtMaxPeers + } + )); +} + +#[test] +fn establish_inbound_at_cap_with_pending_outbound_bypasses_and_promotes() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.at_max_peers = true; + snap.has_pending_outbound_to_peer = true; + let wire = wire_outcome(Some([1u8; 8])); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::Promote + )); +} + +#[test] +fn establish_inbound_at_cap_existing_peer_not_capped() { + // At cap but the identity is already a peer → the cap gate is bypassed and + // the same-epoch classification runs (here: a duplicate resend). + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.at_max_peers = true; + snap.has_existing_peer = true; + snap.existing_peer_epoch = Some([9u8; 8]); + snap.existing_msg2 = Some(vec![0xAB, 0xCD]); + let wire = wire_outcome(Some([9u8; 8])); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::ResendMsg2 { .. } + )); +} + +#[test] +fn establish_inbound_epoch_mismatch_restarts() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.has_existing_peer = true; + snap.existing_peer_epoch = Some([1u8; 8]); + let wire = wire_outcome(Some([2u8; 8])); + let peer = *wire.peer_identity.node_addr(); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::RestartThenPromote { peer: p } if p == peer + )); +} + +#[test] +fn establish_inbound_same_epoch_young_session_resends() { + // Same epoch but session younger than the rekey gate → duplicate resend, + // carrying the stored msg2 bytes verbatim. + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.has_existing_peer = true; + snap.existing_peer_epoch = Some([7u8; 8]); + snap.has_session = true; + snap.is_healthy = true; + snap.existing_session_age_secs = 5; + snap.existing_msg2 = Some(vec![0x01, 0x02, 0x03]); + let wire = wire_outcome(Some([7u8; 8])); + match fmp.establish_inbound(&snap, &wire) { + InboundDecision::ResendMsg2 { msg2 } => { + assert_eq!(msg2.as_deref(), Some(&[0x01, 0x02, 0x03][..])); + } + other => panic!("expected ResendMsg2, got a different variant: {other:?}"), + } +} + +#[test] +fn establish_inbound_aged_session_rekey_responds() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.has_existing_peer = true; + snap.existing_peer_epoch = Some([7u8; 8]); + snap.has_session = true; + snap.is_healthy = true; + snap.existing_session_age_secs = 31; + let wire = wire_outcome(Some([7u8; 8])); + let peer = *wire.peer_identity.node_addr(); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::RekeyRespond { peer: p, abandon_first: false } if p == peer + )); +} + +#[test] +fn establish_inbound_rekey_gate_requires_enabled() { + // Aged healthy session but rekey disabled → same-epoch msg1 is a duplicate, + // not a rekey. + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.has_existing_peer = true; + snap.existing_peer_epoch = Some([7u8; 8]); + snap.has_session = true; + snap.is_healthy = true; + snap.existing_session_age_secs = 31; + snap.rekey_enabled = false; + let wire = wire_outcome(Some([7u8; 8])); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::ResendMsg2 { .. } + )); +} + +#[test] +fn establish_inbound_rekey_gate_boundary_at_30s() { + // Exactly 30s satisfies `>= 30` → rekey; 29s does not → duplicate. + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.has_existing_peer = true; + snap.existing_peer_epoch = Some([7u8; 8]); + snap.has_session = true; + snap.is_healthy = true; + let wire = wire_outcome(Some([7u8; 8])); + + snap.existing_session_age_secs = 30; + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::RekeyRespond { .. } + )); + + snap.existing_session_age_secs = 29; + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::ResendMsg2 { .. } + )); +} + +#[test] +fn establish_inbound_pending_session_rejects() { + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.has_existing_peer = true; + snap.existing_peer_epoch = Some([7u8; 8]); + snap.has_session = true; + snap.is_healthy = true; + snap.existing_session_age_secs = 31; + snap.pending_new_session = true; + let wire = wire_outcome(Some([7u8; 8])); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::Reject { + reason: InboundReject::PendingSession + } + )); +} + +#[test] +fn establish_inbound_dual_init_we_win_rejects() { + // rekey in progress + our addr < peer addr (our = 0x10, peer = pubkey-derived + // non-zero) → we win, drop theirs. + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.has_existing_peer = true; + snap.existing_peer_epoch = Some([7u8; 8]); + snap.has_session = true; + snap.is_healthy = true; + snap.existing_session_age_secs = 31; + snap.rekey_in_progress = true; + snap.our_node_addr = make_node_addr(0x00); // minimal → strictly smaller + let wire = wire_outcome(Some([7u8; 8])); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::Reject { + reason: InboundReject::DualRekeyWon + } + )); +} + +#[test] +fn establish_inbound_dual_init_we_lose_responds_with_abandon() { + // rekey in progress + our addr > peer addr → we lose, abandon ours and + // respond as responder. + let fmp = Fmp::new(); + let mut snap = establish_snapshot(); + snap.has_existing_peer = true; + snap.existing_peer_epoch = Some([7u8; 8]); + snap.has_session = true; + snap.is_healthy = true; + snap.existing_session_age_secs = 31; + snap.rekey_in_progress = true; + snap.our_node_addr = max_node_addr(); // strictly larger than any peer addr + let wire = wire_outcome(Some([7u8; 8])); + let peer = *wire.peer_identity.node_addr(); + assert!(matches!( + fmp.establish_inbound(&snap, &wire), + InboundDecision::RekeyRespond { peer: p, abandon_first: true } if p == peer + )); +} + +// =========================================================================== +// establish_outbound — outbound msg2 classification (E4) +// =========================================================================== + +#[test] +fn establish_outbound_no_existing_peer_promotes() { + let fmp = Fmp::new(); + // our_outbound_wins is irrelevant when there is no existing peer. + let snap = OutboundSnapshot { + has_existing_peer: false, + our_outbound_wins: true, + }; + assert_eq!(fmp.establish_outbound(&snap), OutboundDecision::Promote); +} + +#[test] +fn establish_outbound_cross_connection_win_swaps() { + let fmp = Fmp::new(); + let snap = OutboundSnapshot { + has_existing_peer: true, + our_outbound_wins: true, + }; + assert_eq!( + fmp.establish_outbound(&snap), + OutboundDecision::CrossConnectionSwap + ); +} + +#[test] +fn establish_outbound_cross_connection_loss_keeps() { + let fmp = Fmp::new(); + let snap = OutboundSnapshot { + has_existing_peer: true, + our_outbound_wins: false, + }; + assert_eq!( + fmp.establish_outbound(&snap), + OutboundDecision::CrossConnectionKeep + ); +} + +#[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); +} 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..390ba71 --- /dev/null +++ b/src/proto/fmp/tests/state.rs @@ -0,0 +1,178 @@ +//! 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}; +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)); +} diff --git a/src/proto/fmp/tests/util.rs b/src/proto/fmp/tests/util.rs new file mode 100644 index 0000000..f8c583a --- /dev/null +++ b/src/proto/fmp/tests/util.rs @@ -0,0 +1,105 @@ +//! 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; +use crate::utils::index::SessionIndex; +use crate::{Identity, PeerIdentity}; + +/// 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, + 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 a quiescent `EstablishSnapshot` for a net-new inbound msg1: no existing +/// peer, not at cap, rekey enabled, our node addr fixed. Tests override only the +/// fields the case exercises. +pub(super) fn establish_snapshot() -> EstablishSnapshot { + 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, + at_max_peers: false, + has_pending_outbound_to_peer: false, + rekey_enabled: true, + our_node_addr: make_node_addr(0x10), + } +} + +/// Build a `WireOutcome` carrying a freshly generated peer identity and the +/// given remote epoch (empty msg2 payload, fixed sender index). Callers read the +/// peer's NodeAddr back via `wire.peer_identity.node_addr()` when they need it +/// for the tie-break. +pub(super) fn wire_outcome(remote_epoch: Option<[u8; 8]>) -> WireOutcome { + WireOutcome { + peer_identity: PeerIdentity::from_pubkey_full(Identity::generate().pubkey_full()), + remote_epoch, + their_index: SessionIndex::new(0x1234), + msg2_payload: Vec::new(), + } +} diff --git a/src/proto/fmp/tests/wire.rs b/src/proto/fmp/tests/wire.rs new file mode 100644 index 0000000..986d51a --- /dev/null +++ b/src/proto/fmp/tests/wire.rs @@ -0,0 +1,109 @@ +//! Tests for the FMP link-framing wire codec. + +use crate::proto::fmp::{Disconnect, DisconnectReason, HandshakeMessageType}; + +// ===== HandshakeMessageType Tests ===== + +#[test] +fn test_handshake_message_type_roundtrip() { + let types = [ + HandshakeMessageType::NoiseIKMsg1, + HandshakeMessageType::NoiseIKMsg2, + ]; + + 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(0x03).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(0x00)); + 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); +} diff --git a/src/proto/fmp/wire.rs b/src/proto/fmp/wire.rs new file mode 100644 index 0000000..93b42f7 --- /dev/null +++ b/src/proto/fmp/wire.rs @@ -0,0 +1,164 @@ +//! FMP link-framing messages: handshake message types and orderly disconnect. +//! +//! The Noise IK handshake message-type discriminants and the orderly +//! disconnect codec, relocated from `protocol::link` 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`. + +use crate::protocol::{LinkMessageType, ProtocolError}; +use std::fmt; + +/// Handshake message type identifiers. +/// +/// These messages are exchanged during Noise IK 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 IK message 1: initiator sends ephemeral + encrypted static. + /// Payload: 82 bytes (33 ephemeral + 33 static + 16 tag). + NoiseIKMsg1 = 0x01, + + /// Noise IK message 2: responder sends ephemeral. + /// Payload: 33 bytes (ephemeral pubkey only). + NoiseIKMsg2 = 0x02, +} + +impl HandshakeMessageType { + /// Try to convert from a byte. + pub fn from_byte(b: u8) -> Option { + match b { + 0x01 => Some(HandshakeMessageType::NoiseIKMsg1), + 0x02 => Some(HandshakeMessageType::NoiseIKMsg2), + _ => 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 | 0x02) + } +} + +impl fmt::Display for HandshakeMessageType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + HandshakeMessageType::NoiseIKMsg1 => "NoiseIKMsg1", + HandshakeMessageType::NoiseIKMsg2 => "NoiseIKMsg2", + }; + 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 }) + } +} 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 102f814..4920414 100644 --- a/src/protocol/link.rs +++ b/src/protocol/link.rs @@ -1,61 +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 IK 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 IK message 1: initiator sends ephemeral + encrypted static. - /// Payload: 82 bytes (33 ephemeral + 33 static + 16 tag). - NoiseIKMsg1 = 0x01, - - /// Noise IK message 2: responder sends ephemeral. - /// Payload: 33 bytes (ephemeral pubkey only). - NoiseIKMsg2 = 0x02, -} - -impl HandshakeMessageType { - /// Try to convert from a byte. - pub fn from_byte(b: u8) -> Option { - match b { - 0x01 => Some(HandshakeMessageType::NoiseIKMsg1), - 0x02 => Some(HandshakeMessageType::NoiseIKMsg2), - _ => 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 | 0x02) - } -} - -impl fmt::Display for HandshakeMessageType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match self { - HandshakeMessageType::NoiseIKMsg1 => "NoiseIKMsg1", - HandshakeMessageType::NoiseIKMsg2 => "NoiseIKMsg2", - }; - write!(f, "{}", name) - } -} - // ============================================================================ // Link-Layer Message Types // ============================================================================ @@ -141,120 +89,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) // ============================================================================ @@ -417,37 +251,6 @@ pub type MessageType = LinkMessageType; mod tests { use super::*; - // ===== HandshakeMessageType Tests ===== - - #[test] - fn test_handshake_message_type_roundtrip() { - let types = [ - HandshakeMessageType::NoiseIKMsg1, - HandshakeMessageType::NoiseIKMsg2, - ]; - - 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(0x03).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(0x00)); - assert!(!HandshakeMessageType::is_handshake(0x10)); - } - // ===== LinkMessageType Tests ===== #[test] @@ -476,81 +279,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 85e53ad..9feabf1 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -30,8 +30,7 @@ mod tree; pub use error::ProtocolError; pub use filter::FilterAnnounce; pub use link::{ - Disconnect, DisconnectReason, HandshakeMessageType, LinkMessageType, - SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef, + LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef, }; pub use session::{ FspFlags, FspInnerFlags, PATH_MTU_NOTIFICATION_SIZE, PathMtuNotification, 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(); + } +}