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