mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-06 03:28:24 +00:00
proto/fmp: sans-IO connection-lifecycle state machine
This commit is contained in:
+9
-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,19 @@ pub use proto::routing::{
|
||||
COORDS_REQUIRED_SIZE, CoordsRequired, MTU_EXCEEDED_SIZE, MtuExceeded, PathBroken,
|
||||
};
|
||||
|
||||
// Re-export FMP link-framing wire type (relocated from protocol:: to proto::fmp)
|
||||
pub use proto::fmp::HandshakeMessageType;
|
||||
|
||||
// Re-export cache types
|
||||
pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache};
|
||||
|
||||
// Re-export FMP tie-break helper (relocated from peer:: to proto::fmp)
|
||||
pub use proto::fmp::cross_connection_winner;
|
||||
|
||||
// Re-export peer types
|
||||
pub use peer::{
|
||||
ActivePeer, ConnectivityState, HandshakeState, PeerConnection, PeerError, PeerSlot,
|
||||
PromotionResult, cross_connection_winner,
|
||||
PromotionResult,
|
||||
};
|
||||
|
||||
// Re-export node types
|
||||
|
||||
@@ -73,7 +73,7 @@ impl Node {
|
||||
/// entries — other removal paths (link-dead, decrypt failure, peer
|
||||
/// restart) all schedule reconnect.
|
||||
pub(in crate::node) fn handle_disconnect(&mut self, from: &NodeAddr, payload: &[u8]) {
|
||||
let disconnect = match crate::protocol::Disconnect::decode(payload) {
|
||||
let disconnect = match crate::proto::fmp::Disconnect::decode(payload) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
debug!(from = %self.peer_display_name(from), error = %e, "Malformed disconnect message");
|
||||
|
||||
+257
-260
@@ -1,15 +1,62 @@
|
||||
//! Handshake handlers and connection promotion.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::PeerIdentity;
|
||||
use crate::node::acl::PeerAclContext;
|
||||
use crate::node::reject::{HandshakeReject, RejectReason};
|
||||
use crate::node::wire::{Msg1Header, Msg2Header, build_msg2};
|
||||
use crate::node::{Node, NodeError};
|
||||
use crate::peer::{ActivePeer, PeerConnection, PromotionResult, cross_connection_winner};
|
||||
use crate::peer::{ActivePeer, PeerConnection, PromotionResult};
|
||||
use crate::proto::fmp::{
|
||||
ConnAction, EstablishSnapshot, EstablishView, InboundDecision, InboundReject, OutboundDecision,
|
||||
OutboundSnapshot, WireOutcome, cross_connection_winner,
|
||||
};
|
||||
use crate::transport::{Link, LinkDirection, LinkId, ReceivedPacket};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
impl EstablishView for Node {
|
||||
fn establish_snapshot(&self, peer_addr: &NodeAddr) -> EstablishSnapshot {
|
||||
let existing = self.peers.get(peer_addr);
|
||||
let max_peers = self.max_peers();
|
||||
EstablishSnapshot {
|
||||
has_existing_peer: existing.is_some(),
|
||||
existing_peer_epoch: existing.and_then(|p| p.remote_epoch()),
|
||||
existing_session_age_secs: existing
|
||||
.map(|p| p.session_established_at().elapsed().as_secs())
|
||||
.unwrap_or(0),
|
||||
has_session: existing.map(|p| p.has_session()).unwrap_or(false),
|
||||
is_healthy: existing.map(|p| p.is_healthy()).unwrap_or(false),
|
||||
pending_new_session: existing
|
||||
.map(|p| p.pending_new_session().is_some())
|
||||
.unwrap_or(false),
|
||||
rekey_in_progress: existing.map(|p| p.rekey_in_progress()).unwrap_or(false),
|
||||
existing_msg2: existing.and_then(|p| p.handshake_msg2().map(|m| m.to_vec())),
|
||||
at_max_peers: max_peers > 0 && self.peers.len() >= max_peers,
|
||||
has_pending_outbound_to_peer: self.connections.values().any(|conn| {
|
||||
conn.expected_identity()
|
||||
.map(|id| id.node_addr() == peer_addr)
|
||||
.unwrap_or(false)
|
||||
}),
|
||||
rekey_enabled: self.config().node.rekey.enabled,
|
||||
our_node_addr: *self.identity().node_addr(),
|
||||
}
|
||||
}
|
||||
|
||||
fn outbound_snapshot(&self, peer_addr: &NodeAddr) -> OutboundSnapshot {
|
||||
OutboundSnapshot {
|
||||
has_existing_peer: self.peers.contains_key(peer_addr),
|
||||
// Tie-break for THIS outbound connection (`is_outbound = true`),
|
||||
// pre-evaluated here so the core stays free of the peer helper.
|
||||
our_outbound_wins: cross_connection_winner(
|
||||
self.identity().node_addr(),
|
||||
peer_addr,
|
||||
true,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Returns true if an inbound msg1 should be admitted past the
|
||||
/// `accept_connections` gate.
|
||||
@@ -96,32 +143,23 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
// Check for existing connection from this address.
|
||||
//
|
||||
// If we already have an *inbound* link from this address, this could be:
|
||||
// 1. A duplicate msg1 (our msg2 was lost) — resend msg2
|
||||
// 2. A restarted peer (different epoch) — tear down and reprocess
|
||||
//
|
||||
// If we have an *outbound* link to this address (we initiated to them
|
||||
// AND they initiated to us), this is a cross-connection — allow it.
|
||||
//
|
||||
// Epoch-based restart detection: if the sender already has an inbound
|
||||
// link AND is an active peer in self.peers, fall through to decrypt
|
||||
// the msg1 and check the epoch. Otherwise, treat as duplicate.
|
||||
// Pre-crypto duplicate short-circuit. An *inbound* link from this
|
||||
// address that is not (yet) a promoted peer means our earlier msg2 was
|
||||
// lost: resend the stored msg2 without paying the crypto cost and
|
||||
// return. An inbound link that DOES belong to an active peer (a possible
|
||||
// restart/rekey) or an *outbound* link (a cross-connection) falls
|
||||
// through to the wire step and the structured classification below —
|
||||
// the pre-refactor `possible_restart` flag is no longer needed because
|
||||
// that classification now gates on `has_existing_peer` (identity), which
|
||||
// subsumes it.
|
||||
let addr_key = (packet.transport_id, packet.remote_addr.clone());
|
||||
let mut possible_restart = false;
|
||||
if let Some(&existing_link_id) = self.addr_to_link.get(&addr_key)
|
||||
&& let Some(link) = self.links.get(&existing_link_id)
|
||||
{
|
||||
if link.direction() == LinkDirection::Inbound {
|
||||
// Check if this link belongs to an already-promoted active peer
|
||||
let is_active_peer = self.peers.values().any(|p| p.link_id() == existing_link_id);
|
||||
|
||||
if is_active_peer {
|
||||
// Possible restart — fall through to decrypt and check epoch
|
||||
possible_restart = true;
|
||||
} else {
|
||||
// Genuinely pending handshake — resend msg2
|
||||
if !is_active_peer {
|
||||
// Genuinely pending handshake — resend msg2.
|
||||
let msg2_bytes = self.find_stored_msg2(existing_link_id);
|
||||
if let Some(msg2) = msg2_bytes {
|
||||
if let Some(transport) = self.transports.get(&packet.transport_id) {
|
||||
@@ -150,14 +188,11 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Outbound link to this address. If it belongs to an active
|
||||
// peer, this may be a rekey msg1 (same epoch) or a
|
||||
// restart (different epoch). Set possible_restart to enable
|
||||
// the epoch/rekey check below.
|
||||
// Outbound link to this address with no active peer yet: a
|
||||
// cross-connection. Just log; it is classified as a net-new
|
||||
// inbound below.
|
||||
let is_active_peer = self.peers.values().any(|p| p.link_id() == existing_link_id);
|
||||
if is_active_peer {
|
||||
possible_restart = true;
|
||||
} else {
|
||||
if !is_active_peer {
|
||||
debug!(
|
||||
transport_id = %packet.transport_id,
|
||||
remote_addr = %packet.remote_addr,
|
||||
@@ -212,247 +247,184 @@ impl Node {
|
||||
|
||||
let peer_node_addr = *peer_identity.node_addr();
|
||||
|
||||
// Identity-based restart/rekey detection: if the peer is already
|
||||
// active but addr_to_link didn't match (different source address, e.g.,
|
||||
// TCP from a different port), we still need to check for restart/rekey.
|
||||
if !possible_restart && self.peers.contains_key(&peer_node_addr) {
|
||||
possible_restart = true;
|
||||
}
|
||||
// === PHASE B result ===
|
||||
// Bundle the Noise wire-step outputs (identity, remote epoch, sender
|
||||
// index, opaque msg2 payload). The wire step touched no `Node` registry
|
||||
// state; from here the decision reads only `wire` and the snapshot.
|
||||
let wire = WireOutcome {
|
||||
peer_identity,
|
||||
remote_epoch: conn.remote_epoch(),
|
||||
their_index: header.sender_idx,
|
||||
msg2_payload: msg2_response,
|
||||
};
|
||||
|
||||
// Early cap check: at max_peers and this is a net-new identity?
|
||||
// Bypass for known peers (reconnect / cross-connection) — admitting
|
||||
// them doesn't grow peers.len(). This silent-drops the Msg1 before
|
||||
// the Msg2 build/send and index allocation, avoiding wasted wire
|
||||
// bytes and giving the peer cleaner semantics (no fake-completed
|
||||
// handshake whose data frames subsequently fail decryption here).
|
||||
// The late cap check inside promote_connection() is intentionally
|
||||
// left in place as defense-in-depth.
|
||||
if self.max_peers() > 0 && self.peers.len() >= self.max_peers() {
|
||||
let is_known_active = self.peers.contains_key(&peer_node_addr);
|
||||
let is_pending_outbound = self.connections.iter().any(|(_, conn)| {
|
||||
conn.expected_identity()
|
||||
.map(|id| *id.node_addr() == peer_node_addr)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !is_known_active && !is_pending_outbound {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
max = self.max_peers(),
|
||||
"Silent-dropping Msg1 at max_peers cap (early gate; no Msg2 sent)"
|
||||
);
|
||||
// `link_id` was allocated above but `conn` is still a local
|
||||
// (not yet inserted into self.connections / self.links /
|
||||
// self.addr_to_link), so the local drop suffices.
|
||||
// === PHASE C input ===
|
||||
// Snapshot the registry state the inbound classification reads about
|
||||
// this peer identity (existing epoch/session/rekey state with the
|
||||
// session age resolved here, the max-peers cap, our own address for the
|
||||
// tie-break). Taken before this connection is inserted into the
|
||||
// registry, matching the pre-refactor read points.
|
||||
let est = self.establish_snapshot(&peer_node_addr);
|
||||
|
||||
// === PHASE C: structured classification (pure core) ===
|
||||
// The decision reads only the snapshot + wire outcome; the shell below
|
||||
// drives the effects. `Promote`/`RestartThenPromote` fall through to the
|
||||
// shared authorize → allocate → send-msg2 → promote tail; the other
|
||||
// variants complete the rate-limiter and return here.
|
||||
match self.fmp.establish_inbound(&est, &wire) {
|
||||
InboundDecision::Reject { reason } => {
|
||||
match reason {
|
||||
InboundReject::AtMaxPeers => debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
max = self.max_peers(),
|
||||
"Silent-dropping Msg1 at max_peers cap (early gate; no Msg2 sent)"
|
||||
),
|
||||
InboundReject::PendingSession => debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
"Rekey msg1 received but already have pending session, dropping"
|
||||
),
|
||||
InboundReject::DualRekeyWon => debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
"Dual rekey initiation: we win (smaller addr), dropping their msg1"
|
||||
),
|
||||
}
|
||||
// `conn`/`link_id` were never inserted into the registry, so the
|
||||
// local drop suffices — no cleanup needed.
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
self.stats_mut()
|
||||
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Epoch-based restart detection and duplicate msg1 handling.
|
||||
//
|
||||
// If we fell through from the addr_to_link check above with
|
||||
// possible_restart=true, we now have the decrypted epoch from msg1.
|
||||
// Compare it against the stored epoch for this peer.
|
||||
if possible_restart && let Some(existing_peer) = self.peers.get(&peer_node_addr) {
|
||||
let new_epoch = conn.remote_epoch();
|
||||
let existing_epoch = existing_peer.remote_epoch();
|
||||
|
||||
match (existing_epoch, new_epoch) {
|
||||
(Some(existing), Some(new)) if existing != new => {
|
||||
// Epoch mismatch — peer restarted. Tear down stale session.
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
"Peer restart detected (epoch mismatch), removing stale session"
|
||||
);
|
||||
self.remove_active_peer(&peer_node_addr);
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
self.schedule_reconnect(peer_node_addr, now_ms);
|
||||
// Fall through to process as new connection
|
||||
InboundDecision::ResendMsg2 { msg2 } => {
|
||||
if let Some(msg2) = msg2.as_deref()
|
||||
&& let Some(transport) = self.transports.get(&packet.transport_id)
|
||||
{
|
||||
match transport.send(&packet.remote_addr, msg2).await {
|
||||
Ok(_) => debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
"Resent msg2 for duplicate msg1 (same epoch)"
|
||||
),
|
||||
Err(e) => debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
error = %e,
|
||||
"Failed to resend msg2"
|
||||
),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Same epoch (or no epoch stored).
|
||||
// If the peer has an active session and rekey is enabled,
|
||||
// this is a rekey msg1 (not a duplicate initial msg1).
|
||||
// Guard: the session must be at least 30s old to avoid
|
||||
// misidentifying a cross-connection msg1 as a rekey.
|
||||
// During simultaneous connection, both sides promote
|
||||
// within the same tick and the peer's msg1 arrives
|
||||
// immediately — a genuine rekey can't fire that fast.
|
||||
let session_age_secs =
|
||||
existing_peer.session_established_at().elapsed().as_secs();
|
||||
if self.config().node.rekey.enabled
|
||||
&& existing_peer.has_session()
|
||||
&& existing_peer.is_healthy()
|
||||
&& session_age_secs >= 30
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
return;
|
||||
}
|
||||
InboundDecision::RekeyRespond {
|
||||
peer,
|
||||
abandon_first,
|
||||
} => {
|
||||
if abandon_first {
|
||||
// Dual-initiation loser: abandon our own in-flight rekey and
|
||||
// free its index before responding as the rekey responder.
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer),
|
||||
"Dual rekey initiation: we lose (larger addr), abandoning ours"
|
||||
);
|
||||
if let Some(existing) = self.peers.get_mut(&peer)
|
||||
&& let Some(idx) = existing.abandon_rekey()
|
||||
{
|
||||
// Guard: already have a pending session from a completed
|
||||
// rekey (waiting for K-bit cutover). Don't overwrite it
|
||||
// with a new handshake — drop this msg1.
|
||||
if existing_peer.pending_new_session().is_some() {
|
||||
if let Some(tid) = existing.transport_id() {
|
||||
self.peers_by_index.remove(&(tid, idx.as_u32()));
|
||||
self.pending_outbound.remove(&(tid, idx.as_u32()));
|
||||
}
|
||||
let _ = self.index_allocator.free(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Rekey: process as responder, store new session as pending.
|
||||
let noise_session = conn.take_session();
|
||||
let our_new_index = match self.index_allocator.allocate() {
|
||||
Ok(idx) => idx,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Failed to allocate index for rekey");
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
self.stats_mut()
|
||||
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let noise_session = match noise_session {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
warn!("Rekey msg1: no session from handshake");
|
||||
let _ = self.index_allocator.free(our_new_index);
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
self.stats_mut()
|
||||
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Send msg2 response using the new handshake.
|
||||
let wire_msg2 = build_msg2(our_new_index, wire.their_index, &wire.msg2_payload);
|
||||
if let Some(transport) = self.transports.get(&packet.transport_id) {
|
||||
match transport.send(&packet.remote_addr, &wire_msg2).await {
|
||||
Ok(_) => {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
"Rekey msg1 received but already have pending session, dropping"
|
||||
peer = %self.peer_display_name(&peer),
|
||||
new_our_index = %our_new_index,
|
||||
"Sent rekey msg2 response"
|
||||
);
|
||||
self.connections.remove(&link_id);
|
||||
self.links.remove(&link_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
peer = %self.peer_display_name(&peer),
|
||||
error = %e,
|
||||
"Failed to send rekey msg2"
|
||||
);
|
||||
let _ = self.index_allocator.free(our_new_index);
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
self.stats_mut()
|
||||
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
|
||||
return;
|
||||
}
|
||||
|
||||
// Dual-initiation detection: both sides sent msg1
|
||||
// simultaneously. Apply tie-breaker — smaller NodeAddr
|
||||
// wins as initiator (same as cross-connection resolution).
|
||||
if existing_peer.rekey_in_progress() {
|
||||
let our_addr = self.identity().node_addr();
|
||||
if our_addr < &peer_node_addr {
|
||||
// We win as initiator — drop their msg1.
|
||||
// Our msg2 will arrive at peer, who completes
|
||||
// as our responder.
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
"Dual rekey initiation: we win (smaller addr), dropping their msg1"
|
||||
);
|
||||
self.connections.remove(&link_id);
|
||||
self.links.remove(&link_id);
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
self.stats_mut().record_reject(RejectReason::Handshake(
|
||||
HandshakeReject::BadState,
|
||||
));
|
||||
return;
|
||||
}
|
||||
// We lose — abandon our rekey, become responder below.
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
"Dual rekey initiation: we lose (larger addr), abandoning ours"
|
||||
);
|
||||
if let Some(peer) = self.peers.get_mut(&peer_node_addr)
|
||||
&& let Some(idx) = peer.abandon_rekey()
|
||||
{
|
||||
if let Some(tid) = peer.transport_id() {
|
||||
self.peers_by_index.remove(&(tid, idx.as_u32()));
|
||||
self.pending_outbound.remove(&(tid, idx.as_u32()));
|
||||
}
|
||||
let _ = self.index_allocator.free(idx);
|
||||
}
|
||||
// Fall through to respond as responder
|
||||
}
|
||||
|
||||
// Rekey: process as responder, store new session as pending
|
||||
let noise_session = conn.take_session();
|
||||
let our_new_index = match self.index_allocator.allocate() {
|
||||
Ok(idx) => idx,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Failed to allocate index for rekey");
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
self.stats_mut().record_reject(RejectReason::Handshake(
|
||||
HandshakeReject::BadState,
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let noise_session = match noise_session {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
warn!("Rekey msg1: no session from handshake");
|
||||
let _ = self.index_allocator.free(our_new_index);
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
self.stats_mut().record_reject(RejectReason::Handshake(
|
||||
HandshakeReject::BadState,
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Send msg2 response using the new handshake
|
||||
let wire_msg2 =
|
||||
build_msg2(our_new_index, header.sender_idx, &msg2_response);
|
||||
if let Some(transport) = self.transports.get(&packet.transport_id) {
|
||||
match transport.send(&packet.remote_addr, &wire_msg2).await {
|
||||
Ok(_) => {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
new_our_index = %our_new_index,
|
||||
"Sent rekey msg2 response"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
error = %e,
|
||||
"Failed to send rekey msg2"
|
||||
);
|
||||
let _ = self.index_allocator.free(our_new_index);
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
self.stats_mut().record_reject(RejectReason::Handshake(
|
||||
HandshakeReject::BadState,
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store pending session on the existing peer
|
||||
if let Some(peer) = self.peers.get_mut(&peer_node_addr) {
|
||||
peer.set_pending_session(
|
||||
noise_session,
|
||||
our_new_index,
|
||||
header.sender_idx,
|
||||
);
|
||||
peer.record_peer_rekey();
|
||||
}
|
||||
|
||||
// Register new index in peers_by_index
|
||||
self.peers_by_index.insert(
|
||||
(packet.transport_id, our_new_index.as_u32()),
|
||||
peer_node_addr,
|
||||
);
|
||||
|
||||
// Clean up: remove the temporary connection/link we created.
|
||||
// Do NOT remove addr_to_link — the entry must remain pointing
|
||||
// to the original link so future msg1s from this address are
|
||||
// recognized as rekeys (not new connections).
|
||||
self.connections.remove(&link_id);
|
||||
self.links.remove(&link_id);
|
||||
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
return;
|
||||
}
|
||||
|
||||
// Not a rekey — duplicate msg1. Resend stored msg2.
|
||||
if let Some(msg2) = existing_peer.handshake_msg2().map(|m| m.to_vec())
|
||||
&& let Some(transport) = self.transports.get(&packet.transport_id)
|
||||
{
|
||||
match transport.send(&packet.remote_addr, &msg2).await {
|
||||
Ok(_) => debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
"Resent msg2 for duplicate msg1 (same epoch)"
|
||||
),
|
||||
Err(e) => debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
error = %e,
|
||||
"Failed to resend msg2"
|
||||
),
|
||||
}
|
||||
}
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
return;
|
||||
}
|
||||
|
||||
// Store pending session on the existing peer.
|
||||
if let Some(existing) = self.peers.get_mut(&peer) {
|
||||
existing.set_pending_session(noise_session, our_new_index, wire.their_index);
|
||||
existing.record_peer_rekey();
|
||||
}
|
||||
|
||||
// Register new index in peers_by_index.
|
||||
self.peers_by_index
|
||||
.insert((packet.transport_id, our_new_index.as_u32()), peer);
|
||||
|
||||
// Do NOT touch addr_to_link — the entry must keep pointing at the
|
||||
// original link so future msg1s from this address are recognized
|
||||
// as rekeys (not new connections). The temporary `conn`/`link_id`
|
||||
// were never inserted into the registry, so no cleanup is needed.
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
return;
|
||||
}
|
||||
InboundDecision::RestartThenPromote { peer } => {
|
||||
// Epoch mismatch — peer restarted. Tear down the stale session
|
||||
// and schedule a reconnect, then fall through to promote the
|
||||
// fresh handshake as a new connection.
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer),
|
||||
"Peer restart detected (epoch mismatch), removing stale session"
|
||||
);
|
||||
self.remove_active_peer(&peer);
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
self.schedule_reconnect(peer, now_ms);
|
||||
}
|
||||
InboundDecision::Promote => {}
|
||||
}
|
||||
// If possible_restart was true but peer is no longer in self.peers
|
||||
// (removed by another path), fall through to process as new connection.
|
||||
|
||||
if self
|
||||
.authorize_peer(
|
||||
&peer_identity,
|
||||
&wire.peer_identity,
|
||||
PeerAclContext::InboundHandshake,
|
||||
packet.transport_id,
|
||||
&packet.remote_addr,
|
||||
@@ -481,7 +453,7 @@ impl Node {
|
||||
};
|
||||
|
||||
conn.set_our_index(our_index);
|
||||
conn.set_their_index(header.sender_idx);
|
||||
conn.set_their_index(wire.their_index);
|
||||
|
||||
// Create link
|
||||
let link = Link::connectionless(
|
||||
@@ -497,7 +469,7 @@ impl Node {
|
||||
self.connections.insert(link_id, conn);
|
||||
|
||||
// Build and send msg2 response, storing for potential resend
|
||||
let wire_msg2 = build_msg2(our_index, header.sender_idx, &msg2_response);
|
||||
let wire_msg2 = build_msg2(our_index, wire.their_index, &wire.msg2_payload);
|
||||
if let Some(conn) = self.connections.get_mut(&link_id) {
|
||||
conn.set_handshake_msg2(wire_msg2.clone());
|
||||
}
|
||||
@@ -508,7 +480,7 @@ impl Node {
|
||||
debug!(
|
||||
link_id = %link_id,
|
||||
our_index = %our_index,
|
||||
their_index = %header.sender_idx,
|
||||
their_index = %wire.their_index,
|
||||
bytes,
|
||||
"Sent msg2 response"
|
||||
);
|
||||
@@ -536,7 +508,8 @@ impl Node {
|
||||
// Responder handshake is complete after receive_handshake_init (Noise IK
|
||||
// pattern: responder processes msg1 and generates msg2 in one step).
|
||||
// Promote the connection to active peer now.
|
||||
match self.promote_connection(link_id, peer_identity, packet.timestamp_ms) {
|
||||
let promote = ConnAction::PromoteToActive { link: link_id };
|
||||
match self.drive_promote_to_active(promote, wire.peer_identity, packet.timestamp_ms) {
|
||||
Ok(result) => {
|
||||
match result {
|
||||
PromotionResult::Promoted(node_addr) => {
|
||||
@@ -841,13 +814,12 @@ impl Node {
|
||||
//
|
||||
// This ensures both nodes use the same Noise handshake (the winner's
|
||||
// outbound = the loser's inbound).
|
||||
if self.peers.contains_key(&peer_node_addr) {
|
||||
let our_outbound_wins = cross_connection_winner(
|
||||
self.identity().node_addr(),
|
||||
&peer_node_addr,
|
||||
true, // this IS our outbound
|
||||
);
|
||||
|
||||
// Structured classification (pure core): cross-connection swap/keep, or
|
||||
// a net-new promote. The tie-break is pre-evaluated in the snapshot; the
|
||||
// effect bodies below are unchanged.
|
||||
let out_snap = self.outbound_snapshot(&peer_node_addr);
|
||||
let out_decision = self.fmp.establish_outbound(&out_snap);
|
||||
if out_decision != OutboundDecision::Promote {
|
||||
// Extract the outbound connection
|
||||
let mut conn = match self.connections.remove(&link_id) {
|
||||
Some(c) => c,
|
||||
@@ -859,7 +831,7 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
if our_outbound_wins {
|
||||
if out_decision == OutboundDecision::CrossConnectionSwap {
|
||||
// We're the smaller node. Swap to outbound session + indices.
|
||||
// The peer will keep their inbound session (complement of ours).
|
||||
let outbound_our_index = conn.our_index();
|
||||
@@ -961,7 +933,8 @@ impl Node {
|
||||
}
|
||||
|
||||
// Normal path: promote to active peer
|
||||
match self.promote_connection(link_id, peer_identity, packet.timestamp_ms) {
|
||||
let promote = ConnAction::PromoteToActive { link: link_id };
|
||||
match self.drive_promote_to_active(promote, peer_identity, packet.timestamp_ms) {
|
||||
Ok(result) => {
|
||||
// Clean up pending_outbound
|
||||
self.pending_outbound.remove(&key);
|
||||
@@ -1041,6 +1014,30 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a [`ConnAction::PromoteToActive`] from the establish machine.
|
||||
///
|
||||
/// The decision to promote is made by the establish handlers (and, from the
|
||||
/// establish-core stage on, the pure decision in `proto::fmp`); this is the
|
||||
/// executor half of the seam. It runs the promotion through
|
||||
/// [`Self::promote_connection`], resolving the verified identity and
|
||||
/// promotion timestamp from the ambient wire context, and returns the
|
||||
/// [`PromotionResult`] so the caller can drive the site-specific
|
||||
/// post-promotion tail (TreeAnnounce, bloom mark, discovery-backoff reset,
|
||||
/// loser-link cleanup).
|
||||
fn drive_promote_to_active(
|
||||
&mut self,
|
||||
action: ConnAction,
|
||||
verified_identity: PeerIdentity,
|
||||
current_time_ms: u64,
|
||||
) -> Result<PromotionResult, NodeError> {
|
||||
match action {
|
||||
ConnAction::PromoteToActive { link } => {
|
||||
self.promote_connection(link, verified_identity, current_time_ms)
|
||||
}
|
||||
_ => unreachable!("drive_promote_to_active requires a PromoteToActive action"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Promote a connection to active peer after successful authentication.
|
||||
///
|
||||
/// Handles cross-connection detection and resolution using tie-breaker rules.
|
||||
|
||||
+165
-159
@@ -9,6 +9,7 @@ use crate::NodeAddr;
|
||||
use crate::node::Node;
|
||||
use crate::node::wire::build_msg1;
|
||||
use crate::noise::HandshakeState;
|
||||
use crate::proto::fmp::{ConnAction, LifecycleView, PeerSnapshot, RekeyCfg, RekeyResendSnapshot};
|
||||
use crate::protocol::{SessionDatagram, SessionSetup};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
@@ -41,121 +42,112 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
let rekey_after_secs = self.config().node.rekey.after_secs;
|
||||
let rekey_after_messages = self.config().node.rekey.after_messages;
|
||||
let cfg = RekeyCfg {
|
||||
after_secs: self.config().node.rekey.after_secs,
|
||||
after_messages: self.config().node.rekey.after_messages,
|
||||
};
|
||||
|
||||
// Collect peers that need action (to avoid borrow conflicts)
|
||||
let mut peers_to_cutover: Vec<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.is_rekey_dampened(REKEY_DAMPENING_SECS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let elapsed = peer.session_established_at().elapsed().as_secs();
|
||||
let counter = peer
|
||||
.noise_session()
|
||||
.map(|s| s.current_send_counter())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Apply per-session symmetric jitter to desynchronize
|
||||
// dual-initiation in symmetric-start meshes.
|
||||
let effective_after_secs =
|
||||
rekey_after_secs.saturating_add_signed(peer.rekey_jitter_secs());
|
||||
if elapsed >= effective_after_secs || counter >= rekey_after_messages {
|
||||
peers_to_rekey.push(*node_addr);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute cutover for initiator side
|
||||
for node_addr in peers_to_cutover {
|
||||
let did_cutover = if let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
if let Some(_old_our_index) = peer.cutover_to_new_session() {
|
||||
// New index was pre-registered in peers_by_index
|
||||
// during msg2 handling (handshake.rs).
|
||||
debug_assert!(
|
||||
peer.transport_id().is_some()
|
||||
&& peer.our_index().is_some()
|
||||
&& self.peers_by_index.contains_key(&(
|
||||
peer.transport_id().unwrap(),
|
||||
peer.our_index().unwrap().as_u32()
|
||||
)),
|
||||
"peers_by_index should contain pre-registered new index after cutover"
|
||||
);
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Rekey cutover complete (initiator), K-bit flipped"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Re-register the new session with the decrypt worker — the
|
||||
// cache_key (transport_id, our_index) just changed, so the
|
||||
// old worker entry is stale and every packet on the new
|
||||
// session would miss the worker's HashMap lookup.
|
||||
#[cfg(unix)]
|
||||
if did_cutover {
|
||||
self.register_decrypt_worker_session(&node_addr);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = did_cutover;
|
||||
}
|
||||
|
||||
// Execute drain completion
|
||||
for node_addr in peers_to_drain {
|
||||
// Extract the old index and transport_id under the peer
|
||||
// borrow, then drop the borrow so the cache_key cleanup
|
||||
// below can take &mut self for unregister_decrypt_worker_session.
|
||||
let drained = self
|
||||
.peers
|
||||
.get_mut(&node_addr)
|
||||
.and_then(|peer| peer.complete_drain().map(|idx| (idx, peer.transport_id())));
|
||||
if let Some((old_our_index, transport_id)) = drained {
|
||||
if let Some(tid) = transport_id {
|
||||
let cache_key = (tid, old_our_index.as_u32());
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
// The shell snapshots each healthy peer's rekey ages/flags (every clock
|
||||
// read resolved here); the core decides cutover/drain/trigger with no
|
||||
// clock, phase-grouped to preserve the pre-refactor execution order.
|
||||
let snapshots = self.rekey_peers();
|
||||
for action in self.fmp.poll_rekey(snapshots, &cfg) {
|
||||
match action {
|
||||
// Execute cutover for initiator side.
|
||||
ConnAction::Cutover { peer: node_addr } => {
|
||||
let did_cutover = if let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
if let Some(_old_our_index) = peer.cutover_to_new_session() {
|
||||
// New index was pre-registered in peers_by_index
|
||||
// during msg2 handling (handshake.rs).
|
||||
debug_assert!(
|
||||
peer.transport_id().is_some()
|
||||
&& peer.our_index().is_some()
|
||||
&& self.peers_by_index.contains_key(&(
|
||||
peer.transport_id().unwrap(),
|
||||
peer.our_index().unwrap().as_u32()
|
||||
)),
|
||||
"peers_by_index should contain pre-registered new index after cutover"
|
||||
);
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Rekey cutover complete (initiator), K-bit flipped"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Re-register the new session with the decrypt worker — the
|
||||
// cache_key (transport_id, our_index) just changed, so the
|
||||
// old worker entry is stale and every packet on the new
|
||||
// session would miss the worker's HashMap lookup.
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
if did_cutover {
|
||||
self.register_decrypt_worker_session(&node_addr);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = did_cutover;
|
||||
}
|
||||
let _ = self.index_allocator.free(old_our_index);
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
old_index = %old_our_index,
|
||||
"Drain complete, previous session erased"
|
||||
);
|
||||
// Execute drain completion.
|
||||
ConnAction::Drain { peer: node_addr } => {
|
||||
// Extract the old index and transport_id under the peer
|
||||
// borrow, then drop the borrow so the cache_key cleanup
|
||||
// below can take &mut self for unregister_decrypt_worker_session.
|
||||
let drained = self.peers.get_mut(&node_addr).and_then(|peer| {
|
||||
peer.complete_drain().map(|idx| (idx, peer.transport_id()))
|
||||
});
|
||||
if let Some((old_our_index, transport_id)) = drained {
|
||||
if let Some(tid) = transport_id {
|
||||
let cache_key = (tid, old_our_index.as_u32());
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
}
|
||||
let _ = self.index_allocator.free(old_our_index);
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
old_index = %old_our_index,
|
||||
"Drain complete, previous session erased"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Initiate a new rekey.
|
||||
ConnAction::InitiateRekey { peer: node_addr } => {
|
||||
self.initiate_rekey(&node_addr).await;
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initiate new rekeys
|
||||
for node_addr in peers_to_rekey {
|
||||
self.initiate_rekey(&node_addr).await;
|
||||
}
|
||||
/// Snapshot every healthy peer with a session for the rekey decision,
|
||||
/// pre-computing its monotonic ages and timer predicates so the pure core
|
||||
/// applies the thresholds without reading a clock (see [`PeerSnapshot`]).
|
||||
///
|
||||
/// Lives here, beside the drain/dampening constants and the FSP analog, so
|
||||
/// the forward-merge onto `next` reconciles rekey timing in one place.
|
||||
pub(in crate::node) fn rekey_peer_snapshots(&self) -> Vec<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),
|
||||
elapsed_secs: peer.session_established_at().elapsed().as_secs(),
|
||||
counter: peer
|
||||
.noise_session()
|
||||
.map(|s| s.current_send_counter())
|
||||
.unwrap_or(0),
|
||||
jitter_secs: peer.rekey_jitter_secs(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Initiate an outbound rekey to a peer.
|
||||
@@ -260,60 +252,74 @@ impl Node {
|
||||
let backoff = self.config().node.rate_limit.handshake_resend_backoff;
|
||||
let max_resends = self.config().node.rate_limit.handshake_max_resends;
|
||||
|
||||
// Collect peers needing action
|
||||
let mut to_resend: Vec<(NodeAddr, Vec<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) {
|
||||
to_resend.push((*node_addr, peer.rekey_msg1().unwrap().to_vec()));
|
||||
let sent = if let Some(transport) = self.transports.get(&transport_id) {
|
||||
transport.send(&remote_addr, &bytes).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if sent && let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
peer.record_rekey_msg1_resend(next_resend_at_ms);
|
||||
let count = peer.rekey_msg1_resend_count();
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
resend = count,
|
||||
"Resent rekey msg1"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Abandon rekey cycles that exhausted their retransmission budget.
|
||||
for node_addr in to_abandon {
|
||||
if let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
peer.abandon_rekey();
|
||||
}
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"FMP rekey aborted: msg1 unconfirmed after max retransmissions, abandoning cycle"
|
||||
);
|
||||
}
|
||||
|
||||
for (node_addr, msg1_bytes) in to_resend {
|
||||
let (transport_id, remote_addr) = match self.peers.get(&node_addr) {
|
||||
Some(p) => match (p.transport_id(), p.current_addr()) {
|
||||
(Some(tid), Some(addr)) => (tid, addr.clone()),
|
||||
_ => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let sent = if let Some(transport) = self.transports.get(&transport_id) {
|
||||
transport.send(&remote_addr, &msg1_bytes).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if sent && let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
let count = peer.rekey_msg1_resend_count() + 1;
|
||||
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
|
||||
peer.record_rekey_msg1_resend(next);
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
resend = count,
|
||||
"Resent rekey msg1"
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Snapshot every peer with a rekey handshake in flight (and a stored
|
||||
/// msg1) for the retransmission decision, pre-evaluating the resend-due
|
||||
/// predicate against `now_ms` so the core reads no clock.
|
||||
pub(in crate::node) fn rekey_resend_snapshots(&self, now_ms: u64) -> Vec<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 FSP rekey msg3 until the responder is confirmed on the
|
||||
|
||||
+103
-59
@@ -3,14 +3,69 @@
|
||||
|
||||
use crate::node::Node;
|
||||
use crate::peer::HandshakeState;
|
||||
use crate::proto::fmp::{
|
||||
ConnAction, ConnSnapshot, LifecycleView, PeerSnapshot, RekeyResendSnapshot,
|
||||
};
|
||||
use crate::transport::LinkId;
|
||||
use tracing::{debug, info};
|
||||
|
||||
impl LifecycleView for Node {
|
||||
fn stale_connections(&self, now_ms: u64, timeout_ms: u64) -> Vec<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()
|
||||
})
|
||||
.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 +74,34 @@ impl Node {
|
||||
let now_ms = Self::now_ms();
|
||||
let timeout_ms = self.config().node.rate_limit.handshake_timeout_secs * 1000;
|
||||
|
||||
let stale: Vec<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,26 +145,24 @@ impl Node {
|
||||
let interval_ms = self.config().node.rate_limit.handshake_resend_interval_ms;
|
||||
let backoff = self.config().node.rate_limit.handshake_resend_backoff;
|
||||
|
||||
// Collect resend candidates: outbound, in SentMsg1, with stored msg1,
|
||||
// under max resends, and past the scheduled time.
|
||||
let candidates: Vec<(LinkId, Vec<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()
|
||||
})
|
||||
.filter_map(|(link_id, conn)| {
|
||||
conn.handshake_msg1().map(|msg1| (*link_id, msg1.to_vec()))
|
||||
})
|
||||
.collect();
|
||||
// The shell resolves the resend-candidate predicate and copies the
|
||||
// opaque msg1 bytes; the core computes the backoff schedule.
|
||||
let candidates = self.resend_candidates(now_ms, max_resends);
|
||||
for action in self
|
||||
.fmp
|
||||
.poll_resends(candidates, now_ms, interval_ms, backoff)
|
||||
{
|
||||
let ConnAction::ResendMsg1 {
|
||||
link,
|
||||
bytes,
|
||||
next_resend_at_ms,
|
||||
} = action
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for (link_id, msg1_bytes) in candidates {
|
||||
// Get transport and address info from the connection
|
||||
let (transport_id, remote_addr) = match self.connections.get(&link_id) {
|
||||
let (transport_id, remote_addr) = match self.connections.get(&link) {
|
||||
Some(conn) => match (conn.transport_id(), conn.source_addr()) {
|
||||
(Some(tid), Some(addr)) => (tid, addr.clone()),
|
||||
_ => continue,
|
||||
@@ -126,11 +172,11 @@ impl Node {
|
||||
|
||||
// Send the stored msg1
|
||||
let sent = if let Some(transport) = self.transports.get(&transport_id) {
|
||||
match transport.send(&remote_addr, &msg1_bytes).await {
|
||||
match transport.send(&remote_addr, &bytes).await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
link_id = %link_id,
|
||||
link_id = %link,
|
||||
error = %e,
|
||||
"Handshake msg1 resend failed"
|
||||
);
|
||||
@@ -141,13 +187,11 @@ impl Node {
|
||||
false
|
||||
};
|
||||
|
||||
if sent && let Some(conn) = self.connections.get_mut(&link_id) {
|
||||
let count = conn.resend_count() + 1;
|
||||
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
|
||||
conn.record_resend(next);
|
||||
if sent && let Some(conn) = self.connections.get_mut(&link) {
|
||||
conn.record_resend(next_resend_at_ms);
|
||||
debug!(
|
||||
link_id = %link_id,
|
||||
resend = count,
|
||||
link_id = %link,
|
||||
resend = conn.resend_count(),
|
||||
"Resent handshake msg1"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -45,6 +45,7 @@ use crate::cache::CoordCache;
|
||||
use crate::node::session::SessionEntry;
|
||||
use crate::peer::{ActivePeer, PeerConnection};
|
||||
use crate::proto::discovery::{Discovery, DiscoveryBackoff, DiscoveryForwardRateLimiter};
|
||||
use crate::proto::fmp::Fmp;
|
||||
use crate::proto::routing::{self, Router, RoutingErrorRateLimiter};
|
||||
#[cfg(unix)]
|
||||
use crate::transport::ethernet::EthernetTransport;
|
||||
@@ -416,6 +417,9 @@ pub struct Node {
|
||||
icmp_rate_limiter: IcmpRateLimiter,
|
||||
/// Routing-subsystem state (routing error-signal rate limiter).
|
||||
routing: Router,
|
||||
/// FMP connection-lifecycle decision anchor (stateless; drives the
|
||||
/// tick-poll maintain/teardown decisions).
|
||||
fmp: Fmp,
|
||||
/// Rate limiter for source-side CoordsRequired/PathBroken responses.
|
||||
coords_response_rate_limiter: RoutingErrorRateLimiter,
|
||||
|
||||
@@ -658,6 +662,7 @@ impl Node {
|
||||
msg1_rate_limiter,
|
||||
icmp_rate_limiter: IcmpRateLimiter::new(),
|
||||
routing: Router::new(),
|
||||
fmp: Fmp::new(),
|
||||
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms(
|
||||
coords_response_interval_ms,
|
||||
),
|
||||
@@ -817,6 +822,7 @@ impl Node {
|
||||
msg1_rate_limiter,
|
||||
icmp_rate_limiter: IcmpRateLimiter::new(),
|
||||
routing: Router::new(),
|
||||
fmp: Fmp::new(),
|
||||
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms(
|
||||
coords_response_interval_ms,
|
||||
),
|
||||
|
||||
+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.
|
||||
///
|
||||
@@ -295,7 +295,7 @@ async fn test_disconnect_clears_session() {
|
||||
);
|
||||
|
||||
// Node 0 sends Disconnect to node 1.
|
||||
let disconnect = crate::protocol::Disconnect::new(DisconnectReason::Shutdown);
|
||||
let disconnect = crate::proto::fmp::Disconnect::new(DisconnectReason::Shutdown);
|
||||
nodes[0]
|
||||
.node
|
||||
.send_encrypted_link_message(&node1_addr, &disconnect.encode())
|
||||
|
||||
@@ -0,0 +1,998 @@
|
||||
//! Characterization tests for the inbound-handshake (`handle_msg1`) establish
|
||||
//! branches.
|
||||
//!
|
||||
//! These lock in the *current* observable behavior of the undertested inbound
|
||||
//! classification paths by driving a REAL framed msg1 into `handle_msg1`
|
||||
//! (constructing a genuine Noise IK msg1 and delivering it as a
|
||||
//! `ReceivedPacket`), rather than poking rekey state directly the way the
|
||||
//! `arm_rekey` helper does. They are an oracle for a later behavior-neutral
|
||||
//! refactor: assertions capture what happens today, surprising or not.
|
||||
//!
|
||||
//! Coverage map (branch → test):
|
||||
//! * epoch-restart → `chartest_msg1_epoch_restart_replaces_active_peer`
|
||||
//! * duplicate (pre-crypto) → `chartest_msg1_duplicate_pending_resends_stored_msg2`
|
||||
//! * duplicate (post-crypto) → `chartest_msg1_duplicate_active_same_epoch_resends_stored_msg2`
|
||||
//! * cross-connection precedence→ `chartest_msg1_inbound_promote_defers_pending_outbound_to_same_identity`
|
||||
//! * max-peers cap (bypass) → `chartest_msg1_at_cap_with_pending_outbound_bypasses_early_gate`
|
||||
//! * tie-break (winner+loser) → `chartest_cross_connection_tiebreak_winner_and_loser`
|
||||
//! * rekey-responder → `chartest_msg1_rekey_responder_stores_pending_session`
|
||||
//! * rekey dual-init (we win) → `chartest_msg1_rekey_dual_init_we_win_drops_their_msg1`
|
||||
//! * rekey dual-init (we lose) → `chartest_msg1_rekey_dual_init_we_lose_becomes_responder`
|
||||
//!
|
||||
//! The three rekey branches sit behind the hardcoded
|
||||
//! `existing_session_age_secs >= 30` guard in `handle_msg1`, resolved from
|
||||
//! `ActivePeer::session_established_at()` (a monotonic `std::time::Instant`
|
||||
//! with no natural test seam — the field is private and `tokio::time` cannot
|
||||
//! advance a std `Instant`). They are unblocked by the sole `#[cfg(test)]`
|
||||
//! production seam `ActivePeer::test_backdate_session_established(age)`, which
|
||||
//! only shifts that private timestamp — it changes no decision logic and no
|
||||
//! threshold, and is compiled out of release builds.
|
||||
|
||||
use super::*;
|
||||
use crate::config::UdpConfig;
|
||||
use crate::noise::HandshakeState;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::transport::udp::UdpTransport;
|
||||
use crate::transport::{TransportHandle, packet_channel};
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Build a genuine wire-format Noise IK msg1 addressed to `node`, carrying a
|
||||
/// chosen startup `epoch` and `sender_index`, from `sender`'s identity. Returns
|
||||
/// the opaque wire bytes ready to place in a `ReceivedPacket`.
|
||||
fn craft_msg1_wire(
|
||||
node: &Node,
|
||||
sender: &Identity,
|
||||
epoch: [u8; 8],
|
||||
sender_index: SessionIndex,
|
||||
ts: u64,
|
||||
) -> Vec<u8> {
|
||||
use crate::node::wire::build_msg1;
|
||||
let peer_b_identity = PeerIdentity::from_pubkey_full(node.identity().pubkey_full());
|
||||
let link_id = LinkId::new(0x0BAD_C0DE);
|
||||
let mut conn = PeerConnection::outbound(link_id, peer_b_identity, ts);
|
||||
let noise_msg1 = conn
|
||||
.start_handshake(sender.keypair(), epoch, ts)
|
||||
.expect("start_handshake produces noise msg1");
|
||||
build_msg1(sender_index, &noise_msg1)
|
||||
}
|
||||
|
||||
/// Register a real UDP transport on `node` and return an independent socket
|
||||
/// (plus its addr) that plays the peer: the node's msg2 responses are sent to
|
||||
/// this addr, so a test can observe wire-level output.
|
||||
async fn register_udp_with_peer_socket(
|
||||
node: &mut Node,
|
||||
transport_id: TransportId,
|
||||
) -> (tokio::net::UdpSocket, TransportAddr) {
|
||||
let peer_sock = tokio::net::UdpSocket::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind peer socket");
|
||||
let peer_addr = TransportAddr::from_string(&peer_sock.local_addr().unwrap().to_string());
|
||||
|
||||
let cfg = UdpConfig {
|
||||
bind_addr: Some("127.0.0.1:0".to_string()),
|
||||
mtu: Some(1280),
|
||||
..Default::default()
|
||||
};
|
||||
let (tx, _rx) = packet_channel(64);
|
||||
let mut transport = UdpTransport::new(transport_id, None, cfg, tx);
|
||||
transport.start_async().await.unwrap();
|
||||
node.transports
|
||||
.insert(transport_id, TransportHandle::Udp(transport));
|
||||
(peer_sock, peer_addr)
|
||||
}
|
||||
|
||||
/// Local re-impl of the `unit.rs` dummy-peer injector (that one is private to
|
||||
/// its module). Fills the peer table with distinct identities so cap tests can
|
||||
/// reach saturation.
|
||||
fn inject_dummy_peers(node: &mut Node, count: usize) {
|
||||
for i in 0..count {
|
||||
let identity = make_peer_identity();
|
||||
let addr = *identity.node_addr();
|
||||
let peer = ActivePeer::new(identity, LinkId::new((i + 1) as u64), 0);
|
||||
node.peers.insert(addr, peer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Epoch-restart: an inbound msg1 from an already-active peer that carries a
|
||||
/// DIFFERENT startup epoch is treated as a peer restart. The stale peer is torn
|
||||
/// down and the fresh handshake is promoted in its place.
|
||||
///
|
||||
/// Oracle: after the push, the identity is still present but is a NEW peer —
|
||||
/// it now holds a live Noise session (the stale one had none), its stored
|
||||
/// remote epoch has advanced to the restart value, and it occupies a fresh link
|
||||
/// and session index. `schedule_reconnect` is a no-op under a bare test config
|
||||
/// (no auto-connect peer configured), so `retry_pending` stays empty.
|
||||
#[tokio::test]
|
||||
async fn chartest_msg1_epoch_restart_replaces_active_peer() {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
|
||||
|
||||
let sender = Identity::generate();
|
||||
let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full());
|
||||
let sender_addr = *sender_pid.node_addr();
|
||||
|
||||
let old_epoch = [1u8; 8];
|
||||
let new_epoch = [2u8; 8];
|
||||
let old_link = LinkId::new(4242);
|
||||
|
||||
// Pre-existing active peer at the OLD epoch, sessionless. `current_addr`
|
||||
// makes `should_admit_msg1` recognize the source as an established peer.
|
||||
let mut old_peer = ActivePeer::new(sender_pid, old_link, 1000);
|
||||
old_peer.set_remote_epoch(Some(old_epoch));
|
||||
old_peer.set_current_addr(transport_id, peer_addr.clone());
|
||||
node.peers.insert(sender_addr, old_peer);
|
||||
assert!(!node.get_peer(&sender_addr).unwrap().has_session());
|
||||
assert_eq!(
|
||||
node.get_peer(&sender_addr).unwrap().remote_epoch(),
|
||||
Some(old_epoch)
|
||||
);
|
||||
|
||||
// Real msg1 carrying the NEW (restart) epoch.
|
||||
let data = craft_msg1_wire(&node, &sender, new_epoch, SessionIndex::new(0x77), 2000);
|
||||
let packet = ReceivedPacket {
|
||||
transport_id,
|
||||
remote_addr: peer_addr.clone(),
|
||||
data,
|
||||
timestamp_ms: 2000,
|
||||
};
|
||||
node.handle_msg1(packet).await;
|
||||
|
||||
let peer = node
|
||||
.get_peer(&sender_addr)
|
||||
.expect("restarted peer must remain present (replaced, not dropped)");
|
||||
assert!(
|
||||
peer.has_session(),
|
||||
"restart promotes a fresh handshake, so the new peer holds a session"
|
||||
);
|
||||
assert_eq!(
|
||||
peer.remote_epoch(),
|
||||
Some(new_epoch),
|
||||
"stored remote epoch advances to the restart value"
|
||||
);
|
||||
assert_ne!(
|
||||
peer.link_id(),
|
||||
old_link,
|
||||
"restart replaces the link with the freshly allocated one"
|
||||
);
|
||||
let our_index = peer.our_index().expect("promoted peer has our_index");
|
||||
assert!(
|
||||
node.peers_by_index
|
||||
.contains_key(&(transport_id, our_index.as_u32())),
|
||||
"fresh session index registered in peers_by_index"
|
||||
);
|
||||
assert_eq!(node.peer_count(), 1, "old peer removed, new peer added");
|
||||
assert!(
|
||||
node.retry_pending.is_empty(),
|
||||
"schedule_reconnect is a no-op with no auto-connect config"
|
||||
);
|
||||
|
||||
// A msg2 response was emitted to the restarting peer.
|
||||
let mut buf = [0u8; 2048];
|
||||
let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await;
|
||||
assert!(
|
||||
got.is_ok(),
|
||||
"restart path must emit a msg2 response to the peer"
|
||||
);
|
||||
}
|
||||
|
||||
/// Duplicate msg1, pre-crypto short-circuit: a second msg1 from an address that
|
||||
/// already has a genuinely-pending (not yet promoted) inbound link resends the
|
||||
/// stored msg2 without paying the crypto cost or touching registry state.
|
||||
///
|
||||
/// Oracle: the exact stored msg2 bytes are resent verbatim, nothing is
|
||||
/// promoted, the pending connection is left intact, and the msg1 rate limiter
|
||||
/// rebalances (start then complete) to baseline.
|
||||
#[tokio::test]
|
||||
async fn chartest_msg1_duplicate_pending_resends_stored_msg2() {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
|
||||
|
||||
// A pending inbound connection with a stored msg2, keyed in addr_to_link,
|
||||
// NOT promoted to an active peer.
|
||||
let link_id = node.allocate_link_id();
|
||||
let mut conn =
|
||||
PeerConnection::inbound_with_transport(link_id, transport_id, peer_addr.clone(), 1000);
|
||||
let stored_msg2 = vec![0xC1, 0xC2, 0xC3, 0xC4, 0xC5];
|
||||
conn.set_handshake_msg2(stored_msg2.clone());
|
||||
let link = Link::connectionless(
|
||||
link_id,
|
||||
transport_id,
|
||||
peer_addr.clone(),
|
||||
LinkDirection::Inbound,
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
node.links.insert(link_id, link);
|
||||
node.addr_to_link
|
||||
.insert((transport_id, peer_addr.clone()), link_id);
|
||||
node.connections.insert(link_id, conn);
|
||||
assert_eq!(node.peer_count(), 0);
|
||||
|
||||
let before_pending = node.msg1_rate_limiter.pending_count();
|
||||
|
||||
// Duplicate msg1 from the same address. Content is irrelevant past a valid
|
||||
// header — the pre-crypto branch fires before any decrypt.
|
||||
let sender = Identity::generate();
|
||||
let data = craft_msg1_wire(&node, &sender, [9u8; 8], SessionIndex::new(5), 2000);
|
||||
let packet = ReceivedPacket {
|
||||
transport_id,
|
||||
remote_addr: peer_addr.clone(),
|
||||
data,
|
||||
timestamp_ms: 2000,
|
||||
};
|
||||
node.handle_msg1(packet).await;
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
let (n, _) = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf))
|
||||
.await
|
||||
.expect("stored msg2 must be resent")
|
||||
.expect("recv_from");
|
||||
assert_eq!(
|
||||
&buf[..n],
|
||||
&stored_msg2[..],
|
||||
"the exact stored msg2 is resent for a duplicate msg1"
|
||||
);
|
||||
assert_eq!(node.peer_count(), 0, "duplicate msg1 promotes nothing");
|
||||
assert!(
|
||||
node.connections.contains_key(&link_id),
|
||||
"pending connection is left intact"
|
||||
);
|
||||
assert_eq!(
|
||||
node.msg1_rate_limiter.pending_count(),
|
||||
before_pending,
|
||||
"rate limiter rebalances to baseline"
|
||||
);
|
||||
}
|
||||
|
||||
/// Duplicate msg1, post-crypto same-epoch path: an inbound msg1 from an active
|
||||
/// peer at the SAME epoch, on a session too young (< 30s) to be a rekey, is a
|
||||
/// duplicate. The peer's stored msg2 is resent.
|
||||
///
|
||||
/// Oracle: the active peer's stored msg2 is resent, no new peer or session
|
||||
/// index is allocated, and the existing peer is untouched.
|
||||
#[tokio::test]
|
||||
async fn chartest_msg1_duplicate_active_same_epoch_resends_stored_msg2() {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
|
||||
|
||||
let sender = Identity::generate();
|
||||
let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full());
|
||||
let sender_addr = *sender_pid.node_addr();
|
||||
|
||||
let epoch = [7u8; 8];
|
||||
let stored_msg2 = vec![0xD0, 0xD1, 0xD2, 0xD3];
|
||||
let link_id = LinkId::new(555);
|
||||
let mut peer = ActivePeer::new(sender_pid, link_id, 1000);
|
||||
peer.set_remote_epoch(Some(epoch));
|
||||
peer.set_current_addr(transport_id, peer_addr.clone());
|
||||
peer.set_handshake_msg2(stored_msg2.clone());
|
||||
node.peers.insert(sender_addr, peer);
|
||||
// Session age is ~0 (< 30s) → the rekey gate is false → a same-epoch msg1
|
||||
// classifies as a duplicate, not a rekey initiation.
|
||||
assert!(!node.get_peer(&sender_addr).unwrap().has_session());
|
||||
|
||||
let data = craft_msg1_wire(&node, &sender, epoch, SessionIndex::new(0x33), 2000);
|
||||
let packet = ReceivedPacket {
|
||||
transport_id,
|
||||
remote_addr: peer_addr.clone(),
|
||||
data,
|
||||
timestamp_ms: 2000,
|
||||
};
|
||||
node.handle_msg1(packet).await;
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
let (n, _) = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf))
|
||||
.await
|
||||
.expect("stored msg2 must be resent")
|
||||
.expect("recv_from");
|
||||
assert_eq!(&buf[..n], &stored_msg2[..]);
|
||||
assert_eq!(node.peer_count(), 1);
|
||||
assert_eq!(
|
||||
node.get_peer(&sender_addr).unwrap().link_id(),
|
||||
link_id,
|
||||
"existing peer untouched by a duplicate msg1"
|
||||
);
|
||||
assert!(
|
||||
node.peers_by_index.is_empty(),
|
||||
"no new session index allocated on the duplicate path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cross-connection precedence: an inbound establish that promotes a peer while
|
||||
/// a concurrent PENDING OUTBOUND connection to the SAME identity exists must NOT
|
||||
/// tear that outbound down — it is deferred (kept alive so its later msg2 can
|
||||
/// update `their_index` on the promoted peer).
|
||||
///
|
||||
/// Oracle: the inbound msg1 promotes the peer (with a live session), and both
|
||||
/// the pending outbound connection and its `pending_outbound` index entry are
|
||||
/// preserved.
|
||||
#[tokio::test]
|
||||
async fn chartest_msg1_inbound_promote_defers_pending_outbound_to_same_identity() {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let (_peer_sock, inbound_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
|
||||
|
||||
let sender = Identity::generate();
|
||||
let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full());
|
||||
let sender_addr = *sender_pid.node_addr();
|
||||
|
||||
// A concurrent pending OUTBOUND connection to the same identity, at a
|
||||
// different source address.
|
||||
let out_link = node.allocate_link_id();
|
||||
let out_addr = TransportAddr::from_string("10.0.0.9:2121");
|
||||
let mut out_conn = PeerConnection::outbound(out_link, sender_pid, 1000);
|
||||
let our_keypair = node.identity().keypair();
|
||||
let _ = out_conn
|
||||
.start_handshake(our_keypair, node.startup_epoch(), 1000)
|
||||
.unwrap();
|
||||
let out_index = node.index_allocator.allocate().unwrap();
|
||||
out_conn.set_our_index(out_index);
|
||||
out_conn.set_transport_id(transport_id);
|
||||
out_conn.set_source_addr(out_addr.clone());
|
||||
let out_l = Link::connectionless(
|
||||
out_link,
|
||||
transport_id,
|
||||
out_addr.clone(),
|
||||
LinkDirection::Outbound,
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
node.links.insert(out_link, out_l);
|
||||
node.addr_to_link
|
||||
.insert((transport_id, out_addr.clone()), out_link);
|
||||
node.connections.insert(out_link, out_conn);
|
||||
node.pending_outbound
|
||||
.insert((transport_id, out_index.as_u32()), out_link);
|
||||
assert_eq!(node.peer_count(), 0);
|
||||
|
||||
// Inbound msg1 from the same identity, different source addr.
|
||||
let data = craft_msg1_wire(&node, &sender, [3u8; 8], SessionIndex::new(0x22), 2000);
|
||||
let packet = ReceivedPacket {
|
||||
transport_id,
|
||||
remote_addr: inbound_addr.clone(),
|
||||
data,
|
||||
timestamp_ms: 2000,
|
||||
};
|
||||
node.handle_msg1(packet).await;
|
||||
|
||||
let peer = node
|
||||
.get_peer(&sender_addr)
|
||||
.expect("inbound establish must promote the peer");
|
||||
assert!(peer.has_session());
|
||||
assert_eq!(node.peer_count(), 1);
|
||||
assert!(
|
||||
node.connections.contains_key(&out_link),
|
||||
"pending outbound to the same identity must be preserved (deferred cleanup)"
|
||||
);
|
||||
assert!(
|
||||
node.pending_outbound
|
||||
.contains_key(&(transport_id, out_index.as_u32())),
|
||||
"the outbound pending_outbound entry is preserved for msg2 index-learning"
|
||||
);
|
||||
}
|
||||
|
||||
/// Max-peers cap, pending-outbound bypass: at saturation, a msg1 from a NEW
|
||||
/// identity that already has a pending outbound to it is NOT silent-dropped by
|
||||
/// the early cap gate — it proceeds far enough to emit a msg2, then the late gate
|
||||
/// inside `promote_connection` rejects it (peer table is full).
|
||||
///
|
||||
/// Oracle discriminator vs. the plain new-peer silent-drop: a msg2 IS observed
|
||||
/// on the wire (the early gate was bypassed), yet the peer is NOT promoted (the
|
||||
/// late gate rejects). This locks in the asymmetry between the two cap gates.
|
||||
#[tokio::test]
|
||||
async fn chartest_msg1_at_cap_with_pending_outbound_bypasses_early_gate() {
|
||||
let mut node = make_node_with_max_peers(2);
|
||||
let transport_id = TransportId::new(1);
|
||||
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
|
||||
|
||||
inject_dummy_peers(&mut node, 2);
|
||||
assert_eq!(node.peer_count(), 2, "precondition: at cap");
|
||||
|
||||
let sender = Identity::generate();
|
||||
let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full());
|
||||
let sender_addr = *sender_pid.node_addr();
|
||||
|
||||
// A pending outbound to the (new) sender identity — this sets
|
||||
// `has_pending_outbound_to_peer`, which turns off the early silent-drop.
|
||||
let out_link = node.allocate_link_id();
|
||||
let out_addr = TransportAddr::from_string("10.0.0.9:2121");
|
||||
let mut out_conn = PeerConnection::outbound(out_link, sender_pid, 1000);
|
||||
let our_keypair = node.identity().keypair();
|
||||
let _ = out_conn
|
||||
.start_handshake(our_keypair, node.startup_epoch(), 1000)
|
||||
.unwrap();
|
||||
let out_index = node.index_allocator.allocate().unwrap();
|
||||
out_conn.set_our_index(out_index);
|
||||
out_conn.set_transport_id(transport_id);
|
||||
out_conn.set_source_addr(out_addr.clone());
|
||||
node.connections.insert(out_link, out_conn);
|
||||
node.pending_outbound
|
||||
.insert((transport_id, out_index.as_u32()), out_link);
|
||||
|
||||
let data = craft_msg1_wire(&node, &sender, [4u8; 8], SessionIndex::new(0x44), 2000);
|
||||
let packet = ReceivedPacket {
|
||||
transport_id,
|
||||
remote_addr: peer_addr.clone(),
|
||||
data,
|
||||
timestamp_ms: 2000,
|
||||
};
|
||||
node.handle_msg1(packet).await;
|
||||
|
||||
// Late gate rejects: still at cap, sender not promoted.
|
||||
assert_eq!(
|
||||
node.peer_count(),
|
||||
2,
|
||||
"late cap gate rejects the new identity"
|
||||
);
|
||||
assert!(
|
||||
!node.peers.contains_key(&sender_addr),
|
||||
"new identity is not adopted at capacity"
|
||||
);
|
||||
|
||||
// But the early gate was bypassed: a msg2 WAS put on the wire before the
|
||||
// late-gate rejection (the discriminator against the plain silent-drop).
|
||||
let mut buf = [0u8; 2048];
|
||||
let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await;
|
||||
assert!(
|
||||
got.is_ok() && got.unwrap().is_ok(),
|
||||
"pending-outbound identity bypasses the early silent-drop, so a msg2 \
|
||||
is emitted before the late cap gate rejects"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cross-connection tie-break, winner AND loser in one deterministic run: both
|
||||
/// nodes initiate to each other (simultaneous cross-connection). The rule is
|
||||
/// "the smaller node_addr's OUTBOUND wins" (`cross_connection_winner`). After
|
||||
/// both sides exchange msg1 (promote inbound) and msg2 (resolve), the winner has
|
||||
/// swapped to its outbound session index while the loser keeps the inbound index
|
||||
/// it assigned during its own msg1 handling.
|
||||
///
|
||||
/// Oracle: the smaller-addr node's peer.our_index equals the OUTBOUND index it
|
||||
/// allocated at setup; the larger-addr node's peer.our_index equals the INBOUND
|
||||
/// index it assigned while promoting the peer's msg1.
|
||||
#[tokio::test]
|
||||
async fn chartest_cross_connection_tiebreak_winner_and_loser() {
|
||||
use crate::node::wire::build_msg1;
|
||||
|
||||
let mut node_a = make_node();
|
||||
let mut node_b = make_node();
|
||||
|
||||
let transport_id_a = TransportId::new(1);
|
||||
let transport_id_b = TransportId::new(1);
|
||||
|
||||
let udp_config = UdpConfig {
|
||||
bind_addr: Some("127.0.0.1:0".to_string()),
|
||||
mtu: Some(1280),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (packet_tx_a, mut packet_rx_a) = packet_channel(64);
|
||||
let (packet_tx_b, mut packet_rx_b) = packet_channel(64);
|
||||
|
||||
let mut transport_a = UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
|
||||
let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
|
||||
transport_a.start_async().await.unwrap();
|
||||
transport_b.start_async().await.unwrap();
|
||||
|
||||
let addr_a = transport_a.local_addr().unwrap();
|
||||
let addr_b = transport_b.local_addr().unwrap();
|
||||
let remote_addr_b = TransportAddr::from_string(&addr_b.to_string());
|
||||
let remote_addr_a = TransportAddr::from_string(&addr_a.to_string());
|
||||
|
||||
node_a
|
||||
.transports
|
||||
.insert(transport_id_a, TransportHandle::Udp(transport_a));
|
||||
node_b
|
||||
.transports
|
||||
.insert(transport_id_b, TransportHandle::Udp(transport_b));
|
||||
|
||||
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full());
|
||||
let peer_a_identity = PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full());
|
||||
let node_a_addr = *node_a.node_addr();
|
||||
let node_b_addr = *node_b.node_addr();
|
||||
|
||||
// A initiates to B.
|
||||
let link_a_out = node_a.allocate_link_id();
|
||||
let mut conn_a = PeerConnection::outbound(link_a_out, peer_b_identity, 1000);
|
||||
let out_index_a = node_a.index_allocator.allocate().unwrap();
|
||||
let noise_msg1_a = conn_a
|
||||
.start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000)
|
||||
.unwrap();
|
||||
conn_a.set_our_index(out_index_a);
|
||||
conn_a.set_transport_id(transport_id_a);
|
||||
conn_a.set_source_addr(remote_addr_b.clone());
|
||||
let wire_msg1_a = build_msg1(out_index_a, &noise_msg1_a);
|
||||
node_a.links.insert(
|
||||
link_a_out,
|
||||
Link::connectionless(
|
||||
link_a_out,
|
||||
transport_id_a,
|
||||
remote_addr_b.clone(),
|
||||
LinkDirection::Outbound,
|
||||
Duration::from_millis(100),
|
||||
),
|
||||
);
|
||||
node_a
|
||||
.addr_to_link
|
||||
.insert((transport_id_a, remote_addr_b.clone()), link_a_out);
|
||||
node_a.connections.insert(link_a_out, conn_a);
|
||||
node_a
|
||||
.pending_outbound
|
||||
.insert((transport_id_a, out_index_a.as_u32()), link_a_out);
|
||||
|
||||
// B initiates to A.
|
||||
let link_b_out = node_b.allocate_link_id();
|
||||
let mut conn_b = PeerConnection::outbound(link_b_out, peer_a_identity, 1000);
|
||||
let out_index_b = node_b.index_allocator.allocate().unwrap();
|
||||
let noise_msg1_b = conn_b
|
||||
.start_handshake(node_b.identity().keypair(), node_b.startup_epoch(), 1000)
|
||||
.unwrap();
|
||||
conn_b.set_our_index(out_index_b);
|
||||
conn_b.set_transport_id(transport_id_b);
|
||||
conn_b.set_source_addr(remote_addr_a.clone());
|
||||
let wire_msg1_b = build_msg1(out_index_b, &noise_msg1_b);
|
||||
node_b.links.insert(
|
||||
link_b_out,
|
||||
Link::connectionless(
|
||||
link_b_out,
|
||||
transport_id_b,
|
||||
remote_addr_a.clone(),
|
||||
LinkDirection::Outbound,
|
||||
Duration::from_millis(100),
|
||||
),
|
||||
);
|
||||
node_b
|
||||
.addr_to_link
|
||||
.insert((transport_id_b, remote_addr_a.clone()), link_b_out);
|
||||
node_b.connections.insert(link_b_out, conn_b);
|
||||
node_b
|
||||
.pending_outbound
|
||||
.insert((transport_id_b, out_index_b.as_u32()), link_b_out);
|
||||
|
||||
// Both put msg1 on the wire.
|
||||
node_a
|
||||
.transports
|
||||
.get(&transport_id_a)
|
||||
.unwrap()
|
||||
.send(&remote_addr_b, &wire_msg1_a)
|
||||
.await
|
||||
.unwrap();
|
||||
node_b
|
||||
.transports
|
||||
.get(&transport_id_b)
|
||||
.unwrap()
|
||||
.send(&remote_addr_a, &wire_msg1_b)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Each processes the other's msg1 (promotes inbound, assigns an inbound index).
|
||||
let pkt_at_b = timeout(Duration::from_secs(1), packet_rx_b.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
node_b.handle_msg1(pkt_at_b).await;
|
||||
let pkt_at_a = timeout(Duration::from_secs(1), packet_rx_a.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
node_a.handle_msg1(pkt_at_a).await;
|
||||
|
||||
// Inbound indices assigned during promotion (before resolution).
|
||||
let inbound_index_a = node_a.get_peer(&node_b_addr).unwrap().our_index().unwrap();
|
||||
let inbound_index_b = node_b.get_peer(&node_a_addr).unwrap().our_index().unwrap();
|
||||
|
||||
// Each processes the other's msg2 (cross-connection resolution).
|
||||
let msg2_at_a = timeout(Duration::from_secs(1), packet_rx_a.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
node_a.handle_msg2(msg2_at_a).await;
|
||||
let msg2_at_b = timeout(Duration::from_secs(1), packet_rx_b.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
node_b.handle_msg2(msg2_at_b).await;
|
||||
|
||||
// Rule: smaller node_addr's OUTBOUND wins → that node swaps to its outbound
|
||||
// index; the larger node keeps the inbound index from its own msg1 handling.
|
||||
let a_is_winner = node_a_addr < node_b_addr;
|
||||
let final_our_index_a = node_a.get_peer(&node_b_addr).unwrap().our_index().unwrap();
|
||||
let final_our_index_b = node_b.get_peer(&node_a_addr).unwrap().our_index().unwrap();
|
||||
|
||||
if a_is_winner {
|
||||
assert_eq!(
|
||||
final_our_index_a, out_index_a,
|
||||
"winner (smaller addr) swaps to its outbound session index"
|
||||
);
|
||||
assert_eq!(
|
||||
final_our_index_b, inbound_index_b,
|
||||
"loser (larger addr) keeps the inbound index from its msg1 handling"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
final_our_index_b, out_index_b,
|
||||
"winner (smaller addr) swaps to its outbound session index"
|
||||
);
|
||||
assert_eq!(
|
||||
final_our_index_a, inbound_index_a,
|
||||
"loser (larger addr) keeps the inbound index from its msg1 handling"
|
||||
);
|
||||
}
|
||||
|
||||
// Both remain single, sendable peers after resolution.
|
||||
assert_eq!(node_a.peer_count(), 1);
|
||||
assert_eq!(node_b.peer_count(), 1);
|
||||
assert!(node_a.get_peer(&node_b_addr).unwrap().can_send());
|
||||
assert!(node_b.get_peer(&node_a_addr).unwrap().can_send());
|
||||
|
||||
for (_, t) in node_a.transports.iter_mut() {
|
||||
t.stop().await.ok();
|
||||
}
|
||||
for (_, t) in node_b.transports.iter_mut() {
|
||||
t.stop().await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Rekey establish branches (unblocked by the `#[cfg(test)]`
|
||||
// `ActivePeer::test_backdate_session_established` seam that lets a test age a
|
||||
// real session past the hardcoded 30s rekey gate in `handle_msg1`).
|
||||
// ===========================================================================
|
||||
|
||||
/// Drive a real inbound msg1 through `handle_msg1` so `node` promotes an active
|
||||
/// peer for `sender` at startup `epoch`, draining the msg2 the promotion emits.
|
||||
/// Returns the sender's NodeAddr.
|
||||
async fn establish_active_peer_via_msg1(
|
||||
node: &mut Node,
|
||||
sender: &Identity,
|
||||
epoch: [u8; 8],
|
||||
transport_id: TransportId,
|
||||
peer_addr: &TransportAddr,
|
||||
peer_sock: &tokio::net::UdpSocket,
|
||||
ts: u64,
|
||||
) -> NodeAddr {
|
||||
let sender_addr = *PeerIdentity::from_pubkey_full(sender.pubkey_full()).node_addr();
|
||||
let data = craft_msg1_wire(node, sender, epoch, SessionIndex::new(0x01), ts);
|
||||
let packet = ReceivedPacket {
|
||||
transport_id,
|
||||
remote_addr: peer_addr.clone(),
|
||||
data,
|
||||
timestamp_ms: ts,
|
||||
};
|
||||
node.handle_msg1(packet).await;
|
||||
// Promotion emits a msg2 AND an initial TreeAnnounce; drain every queued
|
||||
// datagram so a later recv observes only the rekey response (or its
|
||||
// absence), never a leftover from establishment.
|
||||
let mut buf = [0u8; 2048];
|
||||
while timeout(Duration::from_millis(150), peer_sock.recv_from(&mut buf))
|
||||
.await
|
||||
.is_ok()
|
||||
{}
|
||||
sender_addr
|
||||
}
|
||||
|
||||
/// Draw a fresh sender identity whose NodeAddr is greater-than (`want_greater`)
|
||||
/// or less-than the node's own NodeAddr, so the dual-init tie-break outcome is
|
||||
/// deterministic. The comparison invariant is enforced, so the test outcome is
|
||||
/// deterministic even though the identity draw is random.
|
||||
fn sender_with_addr_relation(node: &Node, want_greater: bool) -> Identity {
|
||||
let node_addr = *node.node_addr();
|
||||
loop {
|
||||
let s = Identity::generate();
|
||||
let a = *PeerIdentity::from_pubkey_full(s.pubkey_full()).node_addr();
|
||||
if a != node_addr && (a > node_addr) == want_greater {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm a local in-flight (initiator) rekey on `node`'s peer for `sender`, with a
|
||||
/// real allocated index registered in `peers_by_index`/`pending_outbound` (as a
|
||||
/// genuine in-flight rekey would be). Returns the armed rekey index.
|
||||
fn arm_local_rekey(
|
||||
node: &mut Node,
|
||||
sender: &Identity,
|
||||
sender_addr: &NodeAddr,
|
||||
transport_id: TransportId,
|
||||
) -> SessionIndex {
|
||||
let rekey_index = node.index_allocator.allocate().unwrap();
|
||||
node.peers_by_index
|
||||
.insert((transport_id, rekey_index.as_u32()), *sender_addr);
|
||||
node.pending_outbound
|
||||
.insert((transport_id, rekey_index.as_u32()), LinkId::new(0xF00D));
|
||||
let local = Identity::generate();
|
||||
let hs = HandshakeState::new_initiator(local.keypair(), sender.pubkey_full());
|
||||
node.get_peer_mut(sender_addr)
|
||||
.unwrap()
|
||||
.set_rekey_state(hs, rekey_index, vec![0xAB; 64], 0);
|
||||
rekey_index
|
||||
}
|
||||
|
||||
/// Rekey-responder: a genuine rekey msg1 (a fresh IK handshake at the SAME
|
||||
/// epoch) arriving for an active peer whose session is past the 30s gate is
|
||||
/// processed as a rekey. The responder extracts the new session and holds it as
|
||||
/// PENDING (awaiting K-bit cutover) without disturbing the live session.
|
||||
///
|
||||
/// Oracle: the new session lands in `pending_new_session` with a freshly
|
||||
/// allocated `pending_our_index` and `pending_their_index` == the rekey msg1
|
||||
/// sender index; the current session/index stay live and registered; the new
|
||||
/// index is additionally registered in `peers_by_index`; and a rekey msg2 is
|
||||
/// emitted. The peer is neither replaced nor left `rekey_in_progress`.
|
||||
#[tokio::test]
|
||||
async fn chartest_msg1_rekey_responder_stores_pending_session() {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
|
||||
|
||||
let sender = Identity::generate();
|
||||
let epoch = [5u8; 8];
|
||||
let sender_addr = establish_active_peer_via_msg1(
|
||||
&mut node,
|
||||
&sender,
|
||||
epoch,
|
||||
transport_id,
|
||||
&peer_addr,
|
||||
&peer_sock,
|
||||
1000,
|
||||
)
|
||||
.await;
|
||||
|
||||
let old_index = {
|
||||
let p = node.get_peer(&sender_addr).expect("peer established");
|
||||
assert!(p.has_session());
|
||||
assert!(p.is_healthy());
|
||||
assert_eq!(p.remote_epoch(), Some(epoch));
|
||||
assert!(!p.rekey_in_progress());
|
||||
assert!(p.pending_new_session().is_none());
|
||||
p.our_index().unwrap()
|
||||
};
|
||||
assert!(
|
||||
node.peers_by_index
|
||||
.contains_key(&(transport_id, old_index.as_u32()))
|
||||
);
|
||||
let index_count_before = node.index_allocator.count();
|
||||
|
||||
// Age the live session past the 30s rekey gate (test-only seam).
|
||||
node.get_peer_mut(&sender_addr)
|
||||
.unwrap()
|
||||
.test_backdate_session_established(Duration::from_secs(31));
|
||||
|
||||
// A genuine rekey msg1 (fresh IK handshake, SAME epoch) arrives.
|
||||
let rekey_sender_index = SessionIndex::new(0xBEEF);
|
||||
let data = craft_msg1_wire(&node, &sender, epoch, rekey_sender_index, 2000);
|
||||
let packet = ReceivedPacket {
|
||||
transport_id,
|
||||
remote_addr: peer_addr.clone(),
|
||||
data,
|
||||
timestamp_ms: 2000,
|
||||
};
|
||||
node.handle_msg1(packet).await;
|
||||
|
||||
let p = node
|
||||
.get_peer(&sender_addr)
|
||||
.expect("peer still present (not replaced)");
|
||||
assert_eq!(
|
||||
node.peer_count(),
|
||||
1,
|
||||
"rekey neither adds nor replaces the peer"
|
||||
);
|
||||
assert!(
|
||||
p.pending_new_session().is_some(),
|
||||
"new session held as pending"
|
||||
);
|
||||
let new_index = p.pending_our_index().expect("pending our_index allocated");
|
||||
assert_eq!(
|
||||
p.pending_their_index(),
|
||||
Some(rekey_sender_index),
|
||||
"pending their_index = the rekey msg1 sender index"
|
||||
);
|
||||
assert!(
|
||||
!p.rekey_in_progress(),
|
||||
"set_pending_session clears rekey_in_progress"
|
||||
);
|
||||
assert_eq!(
|
||||
p.our_index(),
|
||||
Some(old_index),
|
||||
"current session index stays live until cutover"
|
||||
);
|
||||
assert!(p.has_session(), "current session remains live");
|
||||
|
||||
assert!(
|
||||
node.peers_by_index
|
||||
.contains_key(&(transport_id, old_index.as_u32())),
|
||||
"current index still registered"
|
||||
);
|
||||
assert!(
|
||||
node.peers_by_index
|
||||
.contains_key(&(transport_id, new_index.as_u32())),
|
||||
"new pending index registered"
|
||||
);
|
||||
assert_eq!(
|
||||
node.index_allocator.count(),
|
||||
index_count_before + 1,
|
||||
"exactly one extra index allocated for the pending session"
|
||||
);
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await;
|
||||
assert!(
|
||||
got.is_ok() && got.unwrap().is_ok(),
|
||||
"rekey responder emits a rekey msg2"
|
||||
);
|
||||
}
|
||||
|
||||
/// Rekey dual-init, WE WIN: with a local rekey in flight, a simultaneous rekey
|
||||
/// msg1 arrives from a peer whose NodeAddr is larger than ours. The tie-break
|
||||
/// ("smaller NodeAddr wins as initiator") makes us the winner, so we DROP their
|
||||
/// msg1 and keep driving our own rekey.
|
||||
///
|
||||
/// Oracle: our in-flight rekey is untouched (`rekey_in_progress` stays true, our
|
||||
/// rekey index retained and still registered), no responder session is stored,
|
||||
/// no responder index is allocated, and no rekey msg2 is emitted.
|
||||
#[tokio::test]
|
||||
async fn chartest_msg1_rekey_dual_init_we_win_drops_their_msg1() {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
|
||||
|
||||
// We win when our node_addr < peer's → pick a sender greater than us.
|
||||
let sender = sender_with_addr_relation(&node, true);
|
||||
let epoch = [6u8; 8];
|
||||
let sender_addr = establish_active_peer_via_msg1(
|
||||
&mut node,
|
||||
&sender,
|
||||
epoch,
|
||||
transport_id,
|
||||
&peer_addr,
|
||||
&peer_sock,
|
||||
1000,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
*node.node_addr() < sender_addr,
|
||||
"precondition: node wins the tie-break"
|
||||
);
|
||||
|
||||
node.get_peer_mut(&sender_addr)
|
||||
.unwrap()
|
||||
.test_backdate_session_established(Duration::from_secs(31));
|
||||
let rekey_index = arm_local_rekey(&mut node, &sender, &sender_addr, transport_id);
|
||||
assert!(node.get_peer(&sender_addr).unwrap().rekey_in_progress());
|
||||
let index_count_before = node.index_allocator.count();
|
||||
|
||||
// Their simultaneous rekey msg1 arrives.
|
||||
let data = craft_msg1_wire(&node, &sender, epoch, SessionIndex::new(0xAAAA), 2000);
|
||||
let packet = ReceivedPacket {
|
||||
transport_id,
|
||||
remote_addr: peer_addr.clone(),
|
||||
data,
|
||||
timestamp_ms: 2000,
|
||||
};
|
||||
node.handle_msg1(packet).await;
|
||||
|
||||
let p = node.get_peer(&sender_addr).expect("peer present");
|
||||
assert!(
|
||||
p.rekey_in_progress(),
|
||||
"our rekey survives; the winner does not abandon it"
|
||||
);
|
||||
assert!(
|
||||
p.pending_new_session().is_none(),
|
||||
"no responder session stored on the winner path"
|
||||
);
|
||||
assert_eq!(
|
||||
p.rekey_our_index(),
|
||||
Some(rekey_index),
|
||||
"our rekey index retained"
|
||||
);
|
||||
assert!(
|
||||
node.peers_by_index
|
||||
.contains_key(&(transport_id, rekey_index.as_u32())),
|
||||
"our rekey index still registered in peers_by_index"
|
||||
);
|
||||
assert!(
|
||||
node.pending_outbound
|
||||
.contains_key(&(transport_id, rekey_index.as_u32())),
|
||||
"our rekey pending_outbound entry retained"
|
||||
);
|
||||
assert_eq!(
|
||||
node.index_allocator.count(),
|
||||
index_count_before,
|
||||
"no responder index allocated on the winner path"
|
||||
);
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
let got = timeout(Duration::from_millis(300), peer_sock.recv_from(&mut buf)).await;
|
||||
assert!(
|
||||
got.is_err(),
|
||||
"winner emits no msg2 in response to the dropped rekey msg1"
|
||||
);
|
||||
}
|
||||
|
||||
/// Rekey dual-init, WE LOSE: with a local rekey in flight, a simultaneous rekey
|
||||
/// msg1 arrives from a peer whose NodeAddr is smaller than ours. The tie-break
|
||||
/// makes us the loser, so we ABANDON our own rekey and respond as the rekey
|
||||
/// responder.
|
||||
///
|
||||
/// Oracle: our in-flight rekey is abandoned (`rekey_in_progress` cleared, the
|
||||
/// abandoned index freed and unregistered from `pending_outbound`), the new
|
||||
/// session is stored as pending with `pending_their_index` == the rekey msg1
|
||||
/// sender index, the pending index is registered, and a rekey msg2 is emitted.
|
||||
#[tokio::test]
|
||||
async fn chartest_msg1_rekey_dual_init_we_lose_becomes_responder() {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
|
||||
|
||||
// We lose when our node_addr > peer's → pick a sender smaller than us.
|
||||
let sender = sender_with_addr_relation(&node, false);
|
||||
let epoch = [6u8; 8];
|
||||
let sender_addr = establish_active_peer_via_msg1(
|
||||
&mut node,
|
||||
&sender,
|
||||
epoch,
|
||||
transport_id,
|
||||
&peer_addr,
|
||||
&peer_sock,
|
||||
1000,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
*node.node_addr() > sender_addr,
|
||||
"precondition: node loses the tie-break"
|
||||
);
|
||||
|
||||
node.get_peer_mut(&sender_addr)
|
||||
.unwrap()
|
||||
.test_backdate_session_established(Duration::from_secs(31));
|
||||
let rekey_index = arm_local_rekey(&mut node, &sender, &sender_addr, transport_id);
|
||||
assert!(node.get_peer(&sender_addr).unwrap().rekey_in_progress());
|
||||
|
||||
// Their simultaneous rekey msg1 arrives.
|
||||
let rekey_sender_index = SessionIndex::new(0xCCCC);
|
||||
let data = craft_msg1_wire(&node, &sender, epoch, rekey_sender_index, 2000);
|
||||
let packet = ReceivedPacket {
|
||||
transport_id,
|
||||
remote_addr: peer_addr.clone(),
|
||||
data,
|
||||
timestamp_ms: 2000,
|
||||
};
|
||||
node.handle_msg1(packet).await;
|
||||
|
||||
let p = node.get_peer(&sender_addr).expect("peer present");
|
||||
assert!(
|
||||
!p.rekey_in_progress(),
|
||||
"we abandoned our rekey and became responder"
|
||||
);
|
||||
assert!(
|
||||
p.pending_new_session().is_some(),
|
||||
"responder stores the new session as pending"
|
||||
);
|
||||
assert_eq!(
|
||||
p.pending_their_index(),
|
||||
Some(rekey_sender_index),
|
||||
"pending their_index = the rekey msg1 sender index"
|
||||
);
|
||||
let new_index = p
|
||||
.pending_our_index()
|
||||
.expect("responder allocated a pending index");
|
||||
|
||||
assert!(
|
||||
!node
|
||||
.pending_outbound
|
||||
.contains_key(&(transport_id, rekey_index.as_u32())),
|
||||
"abandoned rekey pending_outbound entry removed"
|
||||
);
|
||||
assert!(
|
||||
node.peers_by_index
|
||||
.contains_key(&(transport_id, new_index.as_u32())),
|
||||
"new pending index registered"
|
||||
);
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await;
|
||||
assert!(
|
||||
got.is_ok() && got.unwrap().is_ok(),
|
||||
"loser (now responder) emits a rekey msg2"
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod bootstrap;
|
||||
mod decrypt_failure;
|
||||
mod disconnect;
|
||||
mod discovery;
|
||||
mod establish_chartests;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ethernet;
|
||||
mod forwarding;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1260,7 +1260,7 @@ fn test_schedule_reconnect_preserves_backoff() {
|
||||
// With count=3, backoff should be 5s * 2^3 = 40s.
|
||||
let base_ms = node.config().node.retry.base_interval_secs * 1000;
|
||||
let max_ms = node.config().node.retry.max_backoff_secs * 1000;
|
||||
let expected_delay = state.backoff_ms(base_ms, max_ms);
|
||||
let expected_delay = crate::proto::fmp::backoff_ms(state.retry_count, base_ms, max_ms);
|
||||
assert_eq!(
|
||||
state.retry_after_ms,
|
||||
31_000 + expected_delay,
|
||||
@@ -1296,7 +1296,7 @@ fn test_schedule_reconnect_fresh_state() {
|
||||
// Base delay: 5s * 2^0 = 5s
|
||||
let base_ms = node.config().node.retry.base_interval_secs * 1000;
|
||||
let max_ms = node.config().node.retry.max_backoff_secs * 1000;
|
||||
let expected_delay = state.backoff_ms(base_ms, max_ms);
|
||||
let expected_delay = crate::proto::fmp::backoff_ms(state.retry_count, base_ms, max_ms);
|
||||
assert_eq!(state.retry_after_ms, 1_000 + expected_delay);
|
||||
}
|
||||
|
||||
@@ -1308,7 +1308,7 @@ fn test_schedule_reconnect_fresh_state() {
|
||||
/// decrypt failure, peer restart) all schedule reconnect.
|
||||
#[test]
|
||||
fn test_disconnect_schedules_reconnect() {
|
||||
use crate::protocol::{Disconnect, DisconnectReason};
|
||||
use crate::proto::fmp::{Disconnect, DisconnectReason};
|
||||
|
||||
let peer_identity = Identity::generate();
|
||||
let peer_npub = peer_identity.npub();
|
||||
|
||||
@@ -880,6 +880,18 @@ impl ActivePeer {
|
||||
self.session_established_at
|
||||
}
|
||||
|
||||
/// Test-only seam: backdate the session-established instant so a test can
|
||||
/// construct a session that reads as `age`-old. This only shifts the
|
||||
/// private timestamp field; it changes no decision logic, no threshold, and
|
||||
/// is compiled out of release builds.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_backdate_session_established(&mut self, age: std::time::Duration) {
|
||||
self.session_established_at = self
|
||||
.session_established_at
|
||||
.checked_sub(age)
|
||||
.unwrap_or_else(Instant::now);
|
||||
}
|
||||
|
||||
/// Per-session symmetric rekey-timer jitter offset (seconds).
|
||||
///
|
||||
/// Drawn at session construction and at each rekey cutover; uniform
|
||||
|
||||
+93
-232
@@ -6,133 +6,37 @@
|
||||
|
||||
use crate::PeerIdentity;
|
||||
use crate::noise::{self, NoiseError, NoiseSession};
|
||||
use crate::proto::fmp::ConnectionState;
|
||||
use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId};
|
||||
use crate::utils::index::SessionIndex;
|
||||
use secp256k1::Keypair;
|
||||
use std::fmt;
|
||||
|
||||
/// Handshake protocol state machine.
|
||||
///
|
||||
/// For Noise IK pattern:
|
||||
/// - Initiator: Initial → SentMsg1 → Complete
|
||||
/// - Responder: Initial → ReceivedMsg1 → Complete
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HandshakeState {
|
||||
/// Initial state, ready to start handshake.
|
||||
Initial,
|
||||
/// Initiator: Sent message 1, awaiting message 2.
|
||||
SentMsg1,
|
||||
/// Responder: Received message 1, ready to send message 2.
|
||||
ReceivedMsg1,
|
||||
/// Handshake completed successfully.
|
||||
Complete,
|
||||
/// Handshake failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl HandshakeState {
|
||||
/// Check if handshake is still in progress.
|
||||
pub fn is_in_progress(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
HandshakeState::Initial | HandshakeState::SentMsg1 | HandshakeState::ReceivedMsg1
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if handshake completed successfully.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
matches!(self, HandshakeState::Complete)
|
||||
}
|
||||
|
||||
/// Check if handshake failed.
|
||||
pub fn is_failed(&self) -> bool {
|
||||
matches!(self, HandshakeState::Failed)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HandshakeState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = match self {
|
||||
HandshakeState::Initial => "initial",
|
||||
HandshakeState::SentMsg1 => "sent_msg1",
|
||||
HandshakeState::ReceivedMsg1 => "received_msg1",
|
||||
HandshakeState::Complete => "complete",
|
||||
HandshakeState::Failed => "failed",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
// The pure handshake-phase bookkeeping (`ConnectionState`) and its
|
||||
// `HandshakeState` phase enum now live in `proto::fmp::state`. Re-export
|
||||
// `HandshakeState` here so its original public path
|
||||
// (`crate::peer::HandshakeState`) is preserved for existing call sites.
|
||||
pub use crate::proto::fmp::HandshakeState;
|
||||
|
||||
/// A connection in the handshake phase, before authentication completes.
|
||||
///
|
||||
/// For outbound connections, we know the expected peer identity from config.
|
||||
/// For inbound connections, we learn the identity during the Noise handshake.
|
||||
///
|
||||
/// This is the shell holder for the FMP crypto/state split: the pure
|
||||
/// connection bookkeeping lives in [`ConnectionState`] (`proto::fmp::state`),
|
||||
/// and the two Noise crypto handles stay here beside it. Pure public methods
|
||||
/// delegate to `self.state`; the XX transition methods drive the crypto and
|
||||
/// write results back through `self.state`'s setters.
|
||||
pub struct PeerConnection {
|
||||
// === Link Reference ===
|
||||
/// The link carrying this connection.
|
||||
link_id: LinkId,
|
||||
|
||||
/// Connection direction (we initiated or they initiated).
|
||||
direction: LinkDirection,
|
||||
|
||||
// === Handshake State ===
|
||||
/// Current handshake state.
|
||||
handshake_state: HandshakeState,
|
||||
|
||||
/// Expected peer identity (known for outbound, learned for inbound).
|
||||
/// Updated after receiving their static key in the handshake.
|
||||
expected_identity: Option<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]>,
|
||||
|
||||
// === 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 {
|
||||
@@ -146,25 +50,9 @@ impl PeerConnection {
|
||||
current_time_ms: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
link_id,
|
||||
direction: LinkDirection::Outbound,
|
||||
handshake_state: HandshakeState::Initial,
|
||||
expected_identity: Some(expected_identity),
|
||||
state: ConnectionState::outbound(link_id, expected_identity, current_time_ms),
|
||||
noise_handshake: None,
|
||||
noise_session: None,
|
||||
started_at: current_time_ms,
|
||||
last_activity: current_time_ms,
|
||||
|
||||
link_stats: LinkStats::new(),
|
||||
our_index: None,
|
||||
their_index: None,
|
||||
transport_id: None,
|
||||
source_addr: None,
|
||||
remote_epoch: None,
|
||||
handshake_msg1: None,
|
||||
handshake_msg2: None,
|
||||
resend_count: 0,
|
||||
next_resend_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,25 +62,9 @@ impl PeerConnection {
|
||||
/// identity from Noise message 1.
|
||||
pub fn inbound(link_id: LinkId, current_time_ms: u64) -> Self {
|
||||
Self {
|
||||
link_id,
|
||||
direction: LinkDirection::Inbound,
|
||||
handshake_state: HandshakeState::Initial,
|
||||
expected_identity: None,
|
||||
state: ConnectionState::inbound(link_id, current_time_ms),
|
||||
noise_handshake: None,
|
||||
noise_session: None,
|
||||
started_at: current_time_ms,
|
||||
last_activity: current_time_ms,
|
||||
|
||||
link_stats: LinkStats::new(),
|
||||
our_index: None,
|
||||
their_index: None,
|
||||
transport_id: None,
|
||||
source_addr: None,
|
||||
remote_epoch: None,
|
||||
handshake_msg1: None,
|
||||
handshake_msg2: None,
|
||||
resend_count: 0,
|
||||
next_resend_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,195 +78,181 @@ impl PeerConnection {
|
||||
current_time_ms: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
link_id,
|
||||
direction: LinkDirection::Inbound,
|
||||
handshake_state: HandshakeState::Initial,
|
||||
expected_identity: None,
|
||||
state: ConnectionState::inbound_with_transport(
|
||||
link_id,
|
||||
transport_id,
|
||||
source_addr,
|
||||
current_time_ms,
|
||||
),
|
||||
noise_handshake: None,
|
||||
noise_session: None,
|
||||
started_at: current_time_ms,
|
||||
last_activity: current_time_ms,
|
||||
|
||||
link_stats: LinkStats::new(),
|
||||
our_index: None,
|
||||
their_index: None,
|
||||
transport_id: Some(transport_id),
|
||||
source_addr: Some(source_addr),
|
||||
remote_epoch: None,
|
||||
handshake_msg1: None,
|
||||
handshake_msg2: None,
|
||||
resend_count: 0,
|
||||
next_resend_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// === Accessors ===
|
||||
// === Accessors (delegated to the pure ConnectionState) ===
|
||||
|
||||
/// Get the link ID.
|
||||
pub fn link_id(&self) -> LinkId {
|
||||
self.link_id
|
||||
self.state.link_id()
|
||||
}
|
||||
|
||||
/// Get the connection direction.
|
||||
pub fn direction(&self) -> LinkDirection {
|
||||
self.direction
|
||||
self.state.direction()
|
||||
}
|
||||
|
||||
/// Get the handshake state.
|
||||
pub fn handshake_state(&self) -> HandshakeState {
|
||||
self.handshake_state
|
||||
self.state.handshake_state()
|
||||
}
|
||||
|
||||
/// Get the expected/learned peer identity, if known.
|
||||
pub fn expected_identity(&self) -> Option<&PeerIdentity> {
|
||||
self.expected_identity.as_ref()
|
||||
self.state.expected_identity()
|
||||
}
|
||||
|
||||
/// Check if this is an outbound connection.
|
||||
pub fn is_outbound(&self) -> bool {
|
||||
self.direction == LinkDirection::Outbound
|
||||
self.state.is_outbound()
|
||||
}
|
||||
|
||||
/// Check if this is an inbound connection.
|
||||
pub fn is_inbound(&self) -> bool {
|
||||
self.direction == LinkDirection::Inbound
|
||||
self.state.is_inbound()
|
||||
}
|
||||
|
||||
/// Check if handshake is in progress.
|
||||
pub fn is_in_progress(&self) -> bool {
|
||||
self.handshake_state.is_in_progress()
|
||||
self.state.is_in_progress()
|
||||
}
|
||||
|
||||
/// Check if handshake completed.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.handshake_state.is_complete()
|
||||
self.state.is_complete()
|
||||
}
|
||||
|
||||
/// Check if handshake failed.
|
||||
pub fn is_failed(&self) -> bool {
|
||||
self.handshake_state.is_failed()
|
||||
self.state.is_failed()
|
||||
}
|
||||
|
||||
/// When the connection started.
|
||||
pub fn started_at(&self) -> u64 {
|
||||
self.started_at
|
||||
self.state.started_at()
|
||||
}
|
||||
|
||||
/// When the last activity occurred.
|
||||
pub fn last_activity(&self) -> u64 {
|
||||
self.last_activity
|
||||
self.state.last_activity()
|
||||
}
|
||||
|
||||
/// Connection duration so far.
|
||||
pub fn duration(&self, current_time_ms: u64) -> u64 {
|
||||
current_time_ms.saturating_sub(self.started_at)
|
||||
self.state.duration(current_time_ms)
|
||||
}
|
||||
|
||||
/// Time since last activity.
|
||||
pub fn idle_time(&self, current_time_ms: u64) -> u64 {
|
||||
current_time_ms.saturating_sub(self.last_activity)
|
||||
self.state.idle_time(current_time_ms)
|
||||
}
|
||||
|
||||
/// Get link statistics.
|
||||
pub fn link_stats(&self) -> &LinkStats {
|
||||
&self.link_stats
|
||||
self.state.link_stats()
|
||||
}
|
||||
|
||||
/// Get mutable link statistics.
|
||||
pub fn link_stats_mut(&mut self) -> &mut LinkStats {
|
||||
&mut self.link_stats
|
||||
self.state.link_stats_mut()
|
||||
}
|
||||
|
||||
// === Index Accessors ===
|
||||
|
||||
/// Get our session index (if set).
|
||||
pub fn our_index(&self) -> Option<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()
|
||||
}
|
||||
|
||||
// === 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 ===
|
||||
// === Noise Handshake Operations (shell: drives crypto, updates pure state) ===
|
||||
|
||||
/// Start the handshake as initiator and generate message 1.
|
||||
///
|
||||
@@ -406,23 +264,23 @@ impl PeerConnection {
|
||||
epoch: [u8; 8],
|
||||
current_time_ms: u64,
|
||||
) -> Result<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(),
|
||||
});
|
||||
}
|
||||
|
||||
let remote_static = self
|
||||
.expected_identity
|
||||
.as_ref()
|
||||
.state
|
||||
.expected_identity()
|
||||
.expect("outbound must have expected identity")
|
||||
.pubkey_full();
|
||||
|
||||
@@ -431,8 +289,8 @@ impl PeerConnection {
|
||||
let msg1 = hs.write_message_1()?;
|
||||
|
||||
self.noise_handshake = Some(hs);
|
||||
self.handshake_state = HandshakeState::SentMsg1;
|
||||
self.last_activity = current_time_ms;
|
||||
self.state.set_handshake_state(HandshakeState::SentMsg1);
|
||||
self.state.touch(current_time_ms);
|
||||
|
||||
Ok(msg1)
|
||||
}
|
||||
@@ -448,17 +306,17 @@ impl PeerConnection {
|
||||
message: &[u8],
|
||||
current_time_ms: u64,
|
||||
) -> Result<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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -468,14 +326,16 @@ impl PeerConnection {
|
||||
// Process message 1 (this reveals the initiator's identity and epoch)
|
||||
hs.read_message_1(message)?;
|
||||
|
||||
// Extract the discovered identity
|
||||
// Extract the discovered identity from the crypto and record it as
|
||||
// pure data on the state.
|
||||
let remote_static = *hs
|
||||
.remote_static()
|
||||
.expect("remote static available after msg1");
|
||||
self.expected_identity = Some(PeerIdentity::from_pubkey_full(remote_static));
|
||||
self.state
|
||||
.set_expected_identity(PeerIdentity::from_pubkey_full(remote_static));
|
||||
|
||||
// Capture remote epoch from msg1
|
||||
self.remote_epoch = hs.remote_epoch();
|
||||
self.state.set_remote_epoch(hs.remote_epoch());
|
||||
|
||||
// Generate message 2
|
||||
let msg2 = hs.write_message_2()?;
|
||||
@@ -483,8 +343,8 @@ impl PeerConnection {
|
||||
// Handshake is complete for responder
|
||||
let session = hs.into_session()?;
|
||||
self.noise_session = Some(session);
|
||||
self.handshake_state = HandshakeState::Complete;
|
||||
self.last_activity = current_time_ms;
|
||||
self.state.set_handshake_state(HandshakeState::Complete);
|
||||
self.state.touch(current_time_ms);
|
||||
|
||||
Ok(msg2)
|
||||
}
|
||||
@@ -497,10 +357,10 @@ impl PeerConnection {
|
||||
message: &[u8],
|
||||
current_time_ms: u64,
|
||||
) -> Result<(), NoiseError> {
|
||||
if self.handshake_state != HandshakeState::SentMsg1 {
|
||||
if self.state.handshake_state() != HandshakeState::SentMsg1 {
|
||||
return Err(NoiseError::WrongState {
|
||||
expected: "sent_msg1 state".to_string(),
|
||||
got: self.handshake_state.to_string(),
|
||||
got: self.state.handshake_state().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -512,12 +372,12 @@ impl PeerConnection {
|
||||
hs.read_message_2(message)?;
|
||||
|
||||
// Capture remote epoch from msg2
|
||||
self.remote_epoch = hs.remote_epoch();
|
||||
self.state.set_remote_epoch(hs.remote_epoch());
|
||||
|
||||
let session = hs.into_session()?;
|
||||
self.noise_session = Some(session);
|
||||
self.handshake_state = HandshakeState::Complete;
|
||||
self.last_activity = current_time_ms;
|
||||
self.state.set_handshake_state(HandshakeState::Complete);
|
||||
self.state.touch(current_time_ms);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -527,7 +387,7 @@ impl PeerConnection {
|
||||
/// Returns the NoiseSession for use in ActivePeer. Can only be called
|
||||
/// once after handshake completes.
|
||||
pub fn take_session(&mut self) -> Option<NoiseSession> {
|
||||
if self.handshake_state == HandshakeState::Complete {
|
||||
if self.state.handshake_state() == HandshakeState::Complete {
|
||||
self.noise_session.take()
|
||||
} else {
|
||||
None
|
||||
@@ -536,44 +396,45 @@ impl PeerConnection {
|
||||
|
||||
/// Check if we have a completed session ready to take.
|
||||
pub fn has_session(&self) -> bool {
|
||||
self.handshake_state == HandshakeState::Complete && self.noise_session.is_some()
|
||||
self.state.handshake_state() == HandshakeState::Complete && self.noise_session.is_some()
|
||||
}
|
||||
|
||||
// === State Transitions (for manual control if needed) ===
|
||||
|
||||
/// Mark handshake as failed.
|
||||
/// Mark handshake as failed. Sets the pure lifecycle state and drops the
|
||||
/// shell-owned crypto handshake handle.
|
||||
pub fn mark_failed(&mut self) {
|
||||
self.handshake_state = HandshakeState::Failed;
|
||||
self.state.mark_failed();
|
||||
self.noise_handshake = None;
|
||||
}
|
||||
|
||||
/// Update last activity timestamp.
|
||||
pub fn touch(&mut self, current_time_ms: u64) {
|
||||
self.last_activity = current_time_ms;
|
||||
self.state.touch(current_time_ms);
|
||||
}
|
||||
|
||||
// === Validation ===
|
||||
|
||||
/// Check if the connection has timed out.
|
||||
pub fn is_timed_out(&self, current_time_ms: u64, timeout_ms: u64) -> bool {
|
||||
self.idle_time(current_time_ms) > timeout_ms
|
||||
self.state.is_timed_out(current_time_ms, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for PeerConnection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PeerConnection")
|
||||
.field("link_id", &self.link_id)
|
||||
.field("direction", &self.direction)
|
||||
.field("handshake_state", &self.handshake_state)
|
||||
.field("expected_identity", &self.expected_identity)
|
||||
.field("link_id", &self.state.link_id())
|
||||
.field("direction", &self.state.direction())
|
||||
.field("handshake_state", &self.state.handshake_state())
|
||||
.field("expected_identity", &self.state.expected_identity())
|
||||
.field("has_noise_handshake", &self.noise_handshake.is_some())
|
||||
.field("has_noise_session", &self.noise_session.is_some())
|
||||
.field("our_index", &self.our_index)
|
||||
.field("their_index", &self.their_index)
|
||||
.field("transport_id", &self.transport_id)
|
||||
.field("started_at", &self.started_at)
|
||||
.field("last_activity", &self.last_activity)
|
||||
.field("our_index", &self.state.our_index())
|
||||
.field("their_index", &self.state.their_index())
|
||||
.field("transport_id", &self.state.transport_id())
|
||||
.field("started_at", &self.state.started_at())
|
||||
.field("last_activity", &self.state.last_activity())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
//! Sans-IO FMP connection-lifecycle decision core.
|
||||
//!
|
||||
//! Pure, runtime-agnostic maintain/teardown decisions for the FMP peer
|
||||
//! connection lifecycle: handshake-connection timeout/teardown and outbound
|
||||
//! msg1 resend scheduling. The async I/O adapters in `node::handlers::timeout`
|
||||
//! build a [`LifecycleView`] over live node state (pre-computing every clock
|
||||
//! read into plain `u64`/`bool` snapshot fields), call the `poll_*` decisions,
|
||||
//! and drive the returned [`ConnAction`]s — the actual sends, registry
|
||||
//! mutations, metrics, and logging. No I/O, no clock, no metrics, no logging
|
||||
//! here.
|
||||
//!
|
||||
//! The establish leaf's Noise wire construction and `promote_connection`
|
||||
//! effects stay shell-side; handshake message bytes are carried as opaque blobs
|
||||
//! only. The **inbound classification** decision, however, is modelled here:
|
||||
//! [`Fmp::establish_inbound`] maps an [`EstablishSnapshot`] + [`WireOutcome`]
|
||||
//! onto an [`InboundDecision`] the shell dispatches (E3). The outbound
|
||||
//! (`handle_msg2`) classification and the born-on-next `handle_msg3` leaf remain
|
||||
//! shell-side.
|
||||
|
||||
use super::state::Fmp;
|
||||
use crate::transport::LinkId;
|
||||
use crate::utils::index::SessionIndex;
|
||||
use crate::{NodeAddr, PeerIdentity};
|
||||
|
||||
/// Determine winner of cross-connection tie-breaker.
|
||||
///
|
||||
/// Rule: The node with the smaller node_addr prefers its OUTBOUND connection.
|
||||
/// This is deterministic and symmetric: both nodes will reach the same conclusion.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `our_node_addr` - Our node's ID
|
||||
/// * `their_node_addr` - The peer's node ID
|
||||
/// * `this_is_outbound` - Whether the connection being evaluated is our outbound
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` if this connection should win (survive), `false` if it should close.
|
||||
pub fn cross_connection_winner(
|
||||
our_node_addr: &NodeAddr,
|
||||
their_node_addr: &NodeAddr,
|
||||
this_is_outbound: bool,
|
||||
) -> bool {
|
||||
let we_are_smaller = our_node_addr < their_node_addr;
|
||||
|
||||
// Smaller node's outbound wins
|
||||
// If we're smaller: our outbound wins, our inbound loses
|
||||
// If they're smaller: our outbound loses, our inbound wins
|
||||
if we_are_smaller {
|
||||
this_is_outbound
|
||||
} else {
|
||||
!this_is_outbound
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of one handshake connection's lifecycle-relevant state, taken by
|
||||
/// the shell so the core decides without touching live `Node` state or reading
|
||||
/// a clock.
|
||||
///
|
||||
/// Produced by the [`LifecycleView`] read-seam. Each `poll_*` decision only
|
||||
/// reads the subset of fields relevant to it; the producing view method leaves
|
||||
/// the rest at their defaults.
|
||||
pub(crate) struct ConnSnapshot {
|
||||
/// The connection's link identifier (teardown/resend target).
|
||||
pub link: LinkId,
|
||||
/// Teardown path: is this an outbound connection? Drives retry scheduling
|
||||
/// (only outbound auto-connect peers are retried).
|
||||
pub is_outbound: bool,
|
||||
/// Teardown path: the retry target learned from the connection's expected
|
||||
/// identity, if any. `None` when no identity is known.
|
||||
pub retry_addr: Option<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,
|
||||
/// 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 result of the shell-side Noise wire step (Phase B) for one inbound
|
||||
/// handshake msg1, handed to the establish decision core.
|
||||
///
|
||||
/// The Noise step (`receive_handshake_init`) runs shell-side on the
|
||||
/// `PeerConnection`: it reads **no** `Node` registry state — the load-bearing
|
||||
/// invariant of this decomposition — and yields the learned peer identity, the
|
||||
/// remote startup epoch, the sender's session index, and the opaque msg2 noise
|
||||
/// payload to frame and send. The core never parses or builds Noise bytes; the
|
||||
/// payload is an opaque blob.
|
||||
pub(crate) struct WireOutcome {
|
||||
/// Peer identity learned from the handshake (msg1 static key).
|
||||
pub peer_identity: PeerIdentity,
|
||||
/// The peer's startup epoch captured from msg1, if present.
|
||||
pub remote_epoch: Option<[u8; 8]>,
|
||||
/// The sender's session index from the msg1 header (becomes our
|
||||
/// `receiver_idx`/`their_index` in the msg2 response and the promotion).
|
||||
pub their_index: SessionIndex,
|
||||
/// The opaque Noise msg2 payload the responder produced (empty only if no
|
||||
/// msg2 is to be sent).
|
||||
pub msg2_payload: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A snapshot of the `Node` registry state the inbound establish decision reads
|
||||
/// about the peer identified in a just-processed msg1, taken by the shell so the
|
||||
/// core decides without touching live `Node` state or reading a clock.
|
||||
///
|
||||
/// Produced by the [`EstablishView`] read-seam. Every clock read
|
||||
/// (`existing_session_age_secs`) is resolved shell-side into a plain `u64`, the
|
||||
/// same monotonic-ages asymmetry the rekey snapshot uses.
|
||||
pub(crate) struct EstablishSnapshot {
|
||||
/// The peer is already an active peer in the registry.
|
||||
pub has_existing_peer: bool,
|
||||
/// The existing active peer's captured remote startup epoch, if any.
|
||||
pub existing_peer_epoch: Option<[u8; 8]>,
|
||||
/// Monotonic age in seconds of the existing peer's session
|
||||
/// (`session_established_at().elapsed()`), resolved shell-side. `0` when
|
||||
/// there is no existing peer.
|
||||
pub existing_session_age_secs: u64,
|
||||
/// The existing peer has an established Noise session.
|
||||
pub has_session: bool,
|
||||
/// The existing peer's session is healthy.
|
||||
pub is_healthy: bool,
|
||||
/// The existing peer already holds a pending post-rekey session awaiting
|
||||
/// K-bit cutover.
|
||||
pub pending_new_session: bool,
|
||||
/// The existing peer has a rekey handshake in flight.
|
||||
pub rekey_in_progress: bool,
|
||||
/// The existing peer's stored msg2 wire bytes (an opaque blob), resent on a
|
||||
/// same-epoch duplicate msg1. `None` when there is no existing peer or it
|
||||
/// has no stored msg2.
|
||||
pub existing_msg2: Option<Vec<u8>>,
|
||||
/// Admitting this peer as a net-new identity would exceed `max_peers`
|
||||
/// (pre-evaluated `max_peers > 0 && peers.len() >= max_peers`).
|
||||
pub at_max_peers: bool,
|
||||
/// A pending outbound connection to this same peer identity already exists
|
||||
/// (a cross-connection in progress); bypasses the max-peers cap.
|
||||
pub has_pending_outbound_to_peer: bool,
|
||||
/// Whether the local rekey trigger is enabled in config (gates treating a
|
||||
/// same-epoch msg1 from an established peer as a rekey rather than a
|
||||
/// duplicate).
|
||||
pub rekey_enabled: bool,
|
||||
/// This node's own address, for the dual-initiation tie-break.
|
||||
pub our_node_addr: NodeAddr,
|
||||
}
|
||||
|
||||
/// A snapshot of the registry state the *outbound* establish decision reads
|
||||
/// about the peer whose msg2 just completed our handshake, taken by the shell.
|
||||
///
|
||||
/// Both fields are pre-evaluated shell-side (the tie-break is a pure function of
|
||||
/// the two node addresses, resolved into a plain `bool` here) so the core never
|
||||
/// touches live `Node` state or the `crate::peer` tie-break helper.
|
||||
pub(crate) struct OutboundSnapshot {
|
||||
/// The peer identity is already a promoted active peer — i.e. this outbound
|
||||
/// completion is a cross-connection (we also processed their msg1).
|
||||
pub has_existing_peer: bool,
|
||||
/// Pre-evaluated cross-connection tie-break: our *outbound* connection wins
|
||||
/// (we are the smaller NodeAddr). Only meaningful when `has_existing_peer`.
|
||||
pub our_outbound_wins: bool,
|
||||
}
|
||||
|
||||
/// A registry/transport effect the async shell performs on the core's behalf.
|
||||
///
|
||||
/// The maintain/teardown subset (`Teardown`..`ResendRekeyMsg1`) covers the
|
||||
/// tick-poll half of the lifecycle. The establish-machine subset
|
||||
/// (`PromoteToActive`..) is the master-side IK handshake decision; the shell
|
||||
/// executes each, resolving the ambient identity/time/wire payload it needs.
|
||||
pub(crate) enum ConnAction {
|
||||
/// Tear down and free the handshake connection on `link`
|
||||
/// (`cleanup_stale_connection`): frees the session index, removes the
|
||||
/// `pending_outbound` entry, and cleans up the link + address mapping.
|
||||
Teardown { link: LinkId },
|
||||
/// Schedule an auto-connect retry toward `peer` (`schedule_retry`) before
|
||||
/// its failed/stale outbound connection is torn down.
|
||||
ScheduleRetry { peer: NodeAddr },
|
||||
/// Resend the stored handshake msg1 `bytes` on `link`, then (on a
|
||||
/// successful send) record the resend and reschedule the next one at
|
||||
/// `next_resend_at_ms`. The shell resolves the transport + remote address
|
||||
/// from the live connection and performs the send; `bytes` is an opaque
|
||||
/// blob the core neither parses nor builds.
|
||||
ResendMsg1 {
|
||||
link: LinkId,
|
||||
bytes: Vec<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,
|
||||
},
|
||||
/// Promote the completed handshake connection on `link` to an active peer
|
||||
/// (`promote_connection`): moves the Noise session out of the
|
||||
/// `PeerConnection`, resolves cross-connection precedence via the
|
||||
/// tie-breaker, and installs the `ActivePeer`. The shell executes the
|
||||
/// promotion (resolving the verified identity and promotion timestamp from
|
||||
/// the ambient wire context) and then runs the post-promotion tail
|
||||
/// (TreeAnnounce, bloom mark, discovery-backoff reset, loser-link cleanup).
|
||||
PromoteToActive { link: LinkId },
|
||||
}
|
||||
|
||||
/// Read-only view of FMP connection/peer state the lifecycle core needs.
|
||||
///
|
||||
/// The core defines this interface; the async shell (`node`) implements it over
|
||||
/// the live `connections`/`peers` maps. It is a **snapshot-iterator** seam:
|
||||
/// each method returns owned snapshot vectors with all clock reads already
|
||||
/// resolved shell-side, so the pure decisions never borrow `Node` and never
|
||||
/// read a clock. Keeping it a trait keeps `proto` free of a `node` dependency
|
||||
/// and lets the decisions be unit-tested against hand-built snapshots.
|
||||
pub(crate) trait LifecycleView {
|
||||
/// Snapshot every handshake connection that is stale (idle past
|
||||
/// `timeout_ms`) or failed, as of `now_ms`. The shell resolves the
|
||||
/// timeout/failed predicate; the core decides retry-then-teardown.
|
||||
fn stale_connections(&self, now_ms: u64, timeout_ms: u64) -> Vec<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 handshake msg1, decided purely
|
||||
/// from the [`EstablishSnapshot`] and [`WireOutcome`]. The shell matches on this
|
||||
/// and drives the effects; the core consumes nothing and touches no live state.
|
||||
///
|
||||
/// The variants map one-to-one onto the pre-refactor inline branches of
|
||||
/// `handle_msg1`'s post-crypto classification. There is deliberately **no**
|
||||
/// inbound cross-connection won/lost variant: an existing same-identity peer is
|
||||
/// always intercepted here first (restart / rekey / duplicate), and a net-new
|
||||
/// [`Promote`](InboundDecision::Promote) reaches `promote_connection` with no
|
||||
/// existing peer — so on the inbound path the tie-break never fires. The real
|
||||
/// cross-connection resolution lives in `handle_msg2` (outbound completion).
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum InboundDecision {
|
||||
/// No existing peer for this identity: authorize, allocate our index, send
|
||||
/// msg2, and promote. Everything the shell needs (verified identity, their
|
||||
/// index, opaque msg2 payload) is in the `WireOutcome` it still holds, so
|
||||
/// the variant carries nothing.
|
||||
Promote,
|
||||
/// Existing peer at a *different* startup epoch — a peer restart. The shell
|
||||
/// tears down the stale peer and schedules its reconnect, then runs the same
|
||||
/// authorize → … → promote sequence as [`Promote`](InboundDecision::Promote).
|
||||
/// `peer` is the teardown / reconnect target.
|
||||
RestartThenPromote { peer: NodeAddr },
|
||||
/// Same-epoch rekey msg1 on an aged, healthy session: respond as the rekey
|
||||
/// responder. The shell extracts the fresh Noise session from the live
|
||||
/// connection, allocates a new index, sends the rekey msg2, and stores the
|
||||
/// session as the peer's pending (post-rekey) session. `abandon_first` is set
|
||||
/// only on the dual-initiation *loser* path, where we first abandon our own
|
||||
/// in-flight rekey. `peer` is the rekey target.
|
||||
RekeyRespond { peer: NodeAddr, abandon_first: bool },
|
||||
/// Same-epoch duplicate msg1 (not a rekey): resend the existing peer's stored
|
||||
/// msg2. `msg2` is the opaque stored bytes (`None` → nothing to resend, the
|
||||
/// silent no-op preserved from the pre-refactor path).
|
||||
ResendMsg2 { msg2: Option<Vec<u8>> },
|
||||
/// Drop this msg1 with a handshake reject (`HandshakeReject::BadState`) and
|
||||
/// no promotion. `reason` selects only the diagnostic log line — every reject
|
||||
/// records the same stat and completes the rate-limiter identically.
|
||||
Reject { reason: InboundReject },
|
||||
}
|
||||
|
||||
/// Why an inbound msg1 was rejected. Distinguishes only the diagnostic log
|
||||
/// message; all three reject identically (BadState stat, rate-limiter complete,
|
||||
/// the local not-yet-registered connection dropped).
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum InboundReject {
|
||||
/// At `max_peers` and this is a net-new identity with no pending outbound to
|
||||
/// bypass the cap: silent-drop before any msg2 build/send.
|
||||
AtMaxPeers,
|
||||
/// The peer already holds a pending post-rekey session awaiting K-bit
|
||||
/// cutover; a second rekey msg1 must not overwrite it.
|
||||
PendingSession,
|
||||
/// Dual rekey initiation and we are the tie-break *winner* (smaller
|
||||
/// NodeAddr): drop the peer's msg1 and keep driving our own rekey.
|
||||
DualRekeyWon,
|
||||
}
|
||||
|
||||
/// The classification outcome for one outbound `handle_msg2` completion, decided
|
||||
/// purely from the [`OutboundSnapshot`]. The shell matches on this and drives
|
||||
/// the effects; the core consumes nothing and touches no live state.
|
||||
///
|
||||
/// Only the case where the peer is *not* yet a promoted active peer is a plain
|
||||
/// promotion; when it is, this msg2 completes the outbound half of a
|
||||
/// cross-connection and the tie-break decides whether we swap our session to the
|
||||
/// (winning) outbound one or keep our existing inbound session. The rekey-msg2
|
||||
/// completion path is handled by a separate shell driver (it mutates
|
||||
/// `ActivePeer`, not a `PeerConnection`) and never reaches this decision.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum OutboundDecision {
|
||||
/// No existing peer for this identity: promote the completed outbound
|
||||
/// connection to an active peer via the normal promotion path.
|
||||
Promote,
|
||||
/// Cross-connection and our outbound wins (smaller NodeAddr): swap the peer
|
||||
/// to the outbound session + indices, freeing the old inbound index.
|
||||
CrossConnectionSwap,
|
||||
/// Cross-connection and our outbound loses (larger NodeAddr): keep the
|
||||
/// existing inbound session and original `their_index`, freeing the unused
|
||||
/// outbound index.
|
||||
CrossConnectionKeep,
|
||||
}
|
||||
|
||||
/// Minimum session age (seconds) before a same-epoch msg1 from an established
|
||||
/// peer is treated as a rekey rather than a duplicate. Guards against
|
||||
/// misreading a simultaneous cross-connection msg1 as a rekey (both sides
|
||||
/// promote within a tick, so a genuine rekey cannot fire that fast). Unchanged
|
||||
/// from the pre-refactor literal.
|
||||
const REKEY_MIN_SESSION_AGE_SECS: u64 = 30;
|
||||
|
||||
/// Read-only view of the `Node` registry state the inbound establish decision
|
||||
/// needs about a peer whose msg1 has just been processed.
|
||||
///
|
||||
/// The core defines this interface; the async shell (`node`) implements it over
|
||||
/// the live `peers`/`connections` maps, resolving every clock read into a plain
|
||||
/// `u64` before the [`EstablishSnapshot`] reaches the core. Keeping it a trait
|
||||
/// keeps `proto` free of a `node` dependency and lets the establish decision be
|
||||
/// unit-tested against hand-built snapshots.
|
||||
pub(crate) trait EstablishView {
|
||||
/// Snapshot the registry state relevant to classifying an inbound msg1 from
|
||||
/// `peer_addr`: the existing peer's epoch/session/rekey state (with the
|
||||
/// session age resolved shell-side), the max-peers cap, and this node's own
|
||||
/// address for the tie-break.
|
||||
fn establish_snapshot(&self, peer_addr: &NodeAddr) -> EstablishSnapshot;
|
||||
|
||||
/// Snapshot the registry state relevant to classifying an outbound msg2
|
||||
/// completion for `peer_addr`: whether the identity is already an active
|
||||
/// peer, and the pre-evaluated cross-connection tie-break.
|
||||
fn outbound_snapshot(&self, peer_addr: &NodeAddr) -> OutboundSnapshot;
|
||||
}
|
||||
|
||||
impl Fmp {
|
||||
/// Decide the teardown choreography for the stale/failed connections the
|
||||
/// shell snapshotted. For each connection, an outbound one with a known
|
||||
/// identity first gets an auto-connect retry scheduled, then every
|
||||
/// connection is torn down. Pure over the snapshots.
|
||||
///
|
||||
/// Preserves the pre-refactor per-connection order (retry before teardown).
|
||||
pub(crate) fn poll_timeouts(&self, stale: Vec<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 {
|
||||
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 handshake msg1 from the establish snapshot and the
|
||||
/// Noise wire outcome. Pure: reads only `snap` and `wire`, mutates nothing,
|
||||
/// consumes nothing. The returned [`InboundDecision`] tells the shell which
|
||||
/// effect sequence to drive.
|
||||
///
|
||||
/// Mirrors the pre-refactor `handle_msg1` post-crypto branch order exactly:
|
||||
/// the early max-peers cap gate, then — for an existing same-identity peer —
|
||||
/// the epoch-restart / rekey / duplicate classification, else a net-new
|
||||
/// promote. The pre-refactor `possible_restart` flag is folded away: it was
|
||||
/// forced true whenever `has_existing_peer` held, so gating the block on
|
||||
/// `has_existing_peer` alone is behavior-identical.
|
||||
pub(crate) fn establish_inbound(
|
||||
&self,
|
||||
snap: &EstablishSnapshot,
|
||||
wire: &WireOutcome,
|
||||
) -> InboundDecision {
|
||||
// Early cap gate: at capacity and a net-new identity (no existing peer,
|
||||
// no pending outbound to bypass) → silent-drop before any msg2.
|
||||
if snap.at_max_peers && !snap.has_existing_peer && !snap.has_pending_outbound_to_peer {
|
||||
return InboundDecision::Reject {
|
||||
reason: InboundReject::AtMaxPeers,
|
||||
};
|
||||
}
|
||||
|
||||
if snap.has_existing_peer {
|
||||
let peer_addr = *wire.peer_identity.node_addr();
|
||||
match (snap.existing_peer_epoch, wire.remote_epoch) {
|
||||
(Some(existing), Some(new)) if existing != new => {
|
||||
// Epoch mismatch → peer restart.
|
||||
InboundDecision::RestartThenPromote { peer: peer_addr }
|
||||
}
|
||||
_ => {
|
||||
// Same epoch (or no epoch captured on either side).
|
||||
let is_rekey = snap.rekey_enabled
|
||||
&& snap.has_session
|
||||
&& snap.is_healthy
|
||||
&& snap.existing_session_age_secs >= REKEY_MIN_SESSION_AGE_SECS;
|
||||
if !is_rekey {
|
||||
// Duplicate msg1 — resend the stored msg2.
|
||||
return InboundDecision::ResendMsg2 {
|
||||
msg2: snap.existing_msg2.clone(),
|
||||
};
|
||||
}
|
||||
if snap.pending_new_session {
|
||||
// A completed rekey is already pending cutover.
|
||||
return InboundDecision::Reject {
|
||||
reason: InboundReject::PendingSession,
|
||||
};
|
||||
}
|
||||
if snap.rekey_in_progress {
|
||||
// Dual initiation — smaller NodeAddr wins as initiator.
|
||||
// Our own rekey is the outbound/initiator side, so reuse
|
||||
// the shared tie-break with `this_is_outbound = true`.
|
||||
if cross_connection_winner(&snap.our_node_addr, &peer_addr, true) {
|
||||
return InboundDecision::Reject {
|
||||
reason: InboundReject::DualRekeyWon,
|
||||
};
|
||||
}
|
||||
// We lose → abandon ours, then respond as responder.
|
||||
return InboundDecision::RekeyRespond {
|
||||
peer: peer_addr,
|
||||
abandon_first: true,
|
||||
};
|
||||
}
|
||||
InboundDecision::RekeyRespond {
|
||||
peer: peer_addr,
|
||||
abandon_first: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No existing peer for this identity → net-new promote.
|
||||
InboundDecision::Promote
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify one outbound `handle_msg2` completion from the outbound snapshot.
|
||||
/// Pure: reads only `snap`, mutates nothing.
|
||||
///
|
||||
/// Mirrors the pre-refactor branch exactly: an existing same-identity peer
|
||||
/// makes this a cross-connection resolved by the (pre-evaluated) tie-break —
|
||||
/// swap on a win, keep on a loss — otherwise a net-new promote.
|
||||
pub(crate) fn establish_outbound(&self, snap: &OutboundSnapshot) -> OutboundDecision {
|
||||
if !snap.has_existing_peer {
|
||||
return OutboundDecision::Promote;
|
||||
}
|
||||
if snap.our_outbound_wins {
|
||||
OutboundDecision::CrossConnectionSwap
|
||||
} else {
|
||||
OutboundDecision::CrossConnectionKeep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential-backoff schedule for the next handshake/rekey msg1 resend:
|
||||
/// `now_ms + interval_ms * backoff^(prior_count + 1)`. Matches the pre-refactor
|
||||
/// arithmetic (the exponent is the resend count *after* this attempt).
|
||||
fn next_resend_at_ms(now_ms: u64, interval_ms: u64, backoff: f64, prior_count: u32) -> u64 {
|
||||
let count = prior_count + 1;
|
||||
now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64
|
||||
}
|
||||
@@ -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,44 @@
|
||||
//! Sans-IO FMP connection-lifecycle state machine.
|
||||
//!
|
||||
//! Pure, runtime-agnostic maintain/teardown decisions for the FMP peer
|
||||
//! connection lifecycle, migrated out of the async node shell. The async I/O
|
||||
//! adapters remain in `node::handlers::{timeout,rekey}`.
|
||||
//!
|
||||
//! This covers the four tick-poll maintain/teardown drivers (handshake
|
||||
//! timeout/teardown, msg1 resend, rekey cutover/drain/trigger, rekey-msg1
|
||||
//! resend) plus the inbound-msg1 classification decision
|
||||
//! ([`Fmp::establish_inbound`]). Handshake message bytes are opaque blobs
|
||||
//! throughout — the Noise wire construction and `promote_connection` effects,
|
||||
//! the outbound `handle_msg2` classification, and the born-on-next
|
||||
//! `handle_msg3` leaf stay in `node/`.
|
||||
//!
|
||||
//! - `core.rs` — the [`LifecycleView`] read-seam trait, the [`ConnAction`]
|
||||
//! effect vocabulary, the snapshot types, the pure `poll_*` decisions, and
|
||||
//! the [`cross_connection_winner`] tie-break helper.
|
||||
//! - `limits.rs` — the pure connection-retry backoff math.
|
||||
//! - `state.rs` — [`ConnectionState`], the pure handshake-phase connection
|
||||
//! bookkeeping (owned by the shell `PeerConnection` beside its Noise crypto
|
||||
//! handles) and its [`HandshakeState`] phase enum, plus [`Fmp`], the
|
||||
//! (stateless) lifecycle anchor owned by `Node`.
|
||||
//! - `wire.rs` — the FMP link-framing codec: handshake message types,
|
||||
//! disconnect reasons, and the orderly disconnect message.
|
||||
|
||||
mod core;
|
||||
mod limits;
|
||||
mod state;
|
||||
mod wire;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use core::cross_connection_winner;
|
||||
pub(crate) use core::{
|
||||
ConnAction, ConnSnapshot, EstablishSnapshot, EstablishView, InboundDecision, InboundReject,
|
||||
LifecycleView, OutboundDecision, OutboundSnapshot, PeerSnapshot, RekeyCfg, RekeyResendSnapshot,
|
||||
WireOutcome,
|
||||
};
|
||||
pub(crate) use limits::backoff_ms;
|
||||
pub use state::HandshakeState;
|
||||
pub(crate) use state::{ConnectionState, Fmp};
|
||||
pub use wire::HandshakeMessageType;
|
||||
pub(crate) use wire::{Disconnect, DisconnectReason};
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Sans-IO FMP connection-lifecycle state.
|
||||
//!
|
||||
//! The pure, runtime-agnostic bookkeeping for an in-progress FMP peer
|
||||
//! connection — link/direction identity, the handshake-phase enum, learned
|
||||
//! peer identity and epoch, index/transport/address tracking, handshake-resend
|
||||
//! scheduling, and link statistics — extracted out of the async node shell.
|
||||
//!
|
||||
//! [`ConnectionState`] owns every **pure** field of the handshake-phase
|
||||
//! connection. The Noise crypto handles (`noise::HandshakeState`,
|
||||
//! `NoiseSession`) stay shell-owned in
|
||||
//! [`PeerConnection`](crate::peer::PeerConnection), which holds a
|
||||
//! `ConnectionState` alongside them and drives the two halves side by side. The
|
||||
//! shell's XX transition methods validate against the pure phase, drive the
|
||||
//! Noise objects, then write learned results back through the pure setters here
|
||||
//! (`set_handshake_state`, `set_expected_identity`, `set_remote_epoch`,
|
||||
//! `touch`).
|
||||
//!
|
||||
//! This state is `no_std`+`alloc`-clean with respect to transport: the
|
||||
//! identifier/address/statistics value types are the plain-data `transport`
|
||||
//! primitives (defined in the `no_std` `transport::types` module, named here
|
||||
//! via their `crate::transport` re-export). Two `std`-tethers remain —
|
||||
//! [`PeerIdentity`] and [`SessionIndex`] — plain-data types whose defining
|
||||
//! modules are not yet `no_std`. They are named here as data only (never
|
||||
//! driving crypto) and mirror the tether already carried by the sibling
|
||||
//! decision [`core`](super::core).
|
||||
//!
|
||||
//! [`Fmp`] is the separate, stateless lifecycle anchor owned by
|
||||
//! [`Node`](crate::node::Node); see its doc below.
|
||||
|
||||
use crate::PeerIdentity;
|
||||
use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId};
|
||||
use crate::utils::index::SessionIndex;
|
||||
use core::fmt;
|
||||
|
||||
/// Handshake protocol state machine.
|
||||
///
|
||||
/// For Noise IK pattern:
|
||||
/// - Initiator: Initial → SentMsg1 → Complete
|
||||
/// - Responder: Initial → ReceivedMsg1 → Complete
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HandshakeState {
|
||||
/// Initial state, ready to start handshake.
|
||||
Initial,
|
||||
/// Initiator: Sent message 1, awaiting message 2.
|
||||
SentMsg1,
|
||||
/// Responder: Received message 1, ready to send message 2.
|
||||
ReceivedMsg1,
|
||||
/// Handshake completed successfully.
|
||||
Complete,
|
||||
/// Handshake failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl HandshakeState {
|
||||
/// Check if handshake is still in progress.
|
||||
pub fn is_in_progress(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
HandshakeState::Initial | HandshakeState::SentMsg1 | HandshakeState::ReceivedMsg1
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if handshake completed successfully.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
matches!(self, HandshakeState::Complete)
|
||||
}
|
||||
|
||||
/// Check if handshake failed.
|
||||
pub fn is_failed(&self) -> bool {
|
||||
matches!(self, HandshakeState::Failed)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HandshakeState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = match self {
|
||||
HandshakeState::Initial => "initial",
|
||||
HandshakeState::SentMsg1 => "sent_msg1",
|
||||
HandshakeState::ReceivedMsg1 => "received_msg1",
|
||||
HandshakeState::Complete => "complete",
|
||||
HandshakeState::Failed => "failed",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure, runtime-agnostic bookkeeping for a connection in the handshake phase.
|
||||
///
|
||||
/// Owns every non-crypto field of the handshake-phase connection. The Noise
|
||||
/// crypto handles live beside it in the shell
|
||||
/// [`PeerConnection`](crate::peer::PeerConnection); this struct is written only
|
||||
/// as plain data — the shell extracts learned identity/epoch out of the crypto
|
||||
/// objects and sets them here through the setters.
|
||||
#[derive(Debug)]
|
||||
pub struct ConnectionState {
|
||||
// === Link Reference ===
|
||||
/// The link carrying this connection.
|
||||
link_id: LinkId,
|
||||
|
||||
/// Connection direction (we initiated or they initiated).
|
||||
direction: LinkDirection,
|
||||
|
||||
// === Handshake State ===
|
||||
/// Current handshake state.
|
||||
handshake_state: HandshakeState,
|
||||
|
||||
/// Expected peer identity (known for outbound, learned for inbound).
|
||||
/// Updated after receiving their static key in the handshake.
|
||||
expected_identity: Option<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]>,
|
||||
|
||||
// === 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,
|
||||
handshake_msg1: None,
|
||||
handshake_msg2: None,
|
||||
resend_count: 0,
|
||||
next_resend_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the pure state for a new inbound connection (they initiate).
|
||||
///
|
||||
/// For inbound, we don't know who they are until we decrypt their identity
|
||||
/// from Noise message 1.
|
||||
pub fn inbound(link_id: LinkId, current_time_ms: u64) -> Self {
|
||||
Self {
|
||||
link_id,
|
||||
direction: LinkDirection::Inbound,
|
||||
handshake_state: HandshakeState::Initial,
|
||||
expected_identity: None,
|
||||
started_at: current_time_ms,
|
||||
last_activity: current_time_ms,
|
||||
link_stats: LinkStats::new(),
|
||||
our_index: None,
|
||||
their_index: None,
|
||||
transport_id: None,
|
||||
source_addr: None,
|
||||
remote_epoch: None,
|
||||
handshake_msg1: None,
|
||||
handshake_msg2: None,
|
||||
resend_count: 0,
|
||||
next_resend_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the pure state for a new inbound connection with transport info.
|
||||
///
|
||||
/// Used when processing msg1 where we know the transport and source address.
|
||||
pub fn inbound_with_transport(
|
||||
link_id: LinkId,
|
||||
transport_id: TransportId,
|
||||
source_addr: TransportAddr,
|
||||
current_time_ms: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
link_id,
|
||||
direction: LinkDirection::Inbound,
|
||||
handshake_state: HandshakeState::Initial,
|
||||
expected_identity: None,
|
||||
started_at: current_time_ms,
|
||||
last_activity: current_time_ms,
|
||||
link_stats: LinkStats::new(),
|
||||
our_index: None,
|
||||
their_index: None,
|
||||
transport_id: Some(transport_id),
|
||||
source_addr: Some(source_addr),
|
||||
remote_epoch: None,
|
||||
handshake_msg1: None,
|
||||
handshake_msg2: None,
|
||||
resend_count: 0,
|
||||
next_resend_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// === Accessors ===
|
||||
|
||||
/// Get the link ID.
|
||||
pub fn link_id(&self) -> LinkId {
|
||||
self.link_id
|
||||
}
|
||||
|
||||
/// Get the connection direction.
|
||||
pub fn direction(&self) -> LinkDirection {
|
||||
self.direction
|
||||
}
|
||||
|
||||
/// Get the handshake state.
|
||||
pub fn handshake_state(&self) -> HandshakeState {
|
||||
self.handshake_state
|
||||
}
|
||||
|
||||
/// Get the expected/learned peer identity, if known.
|
||||
pub fn expected_identity(&self) -> Option<&PeerIdentity> {
|
||||
self.expected_identity.as_ref()
|
||||
}
|
||||
|
||||
/// Check if this is an outbound connection.
|
||||
pub fn is_outbound(&self) -> bool {
|
||||
self.direction == LinkDirection::Outbound
|
||||
}
|
||||
|
||||
/// Check if this is an inbound connection.
|
||||
pub fn is_inbound(&self) -> bool {
|
||||
self.direction == LinkDirection::Inbound
|
||||
}
|
||||
|
||||
/// Check if handshake is in progress.
|
||||
pub fn is_in_progress(&self) -> bool {
|
||||
self.handshake_state.is_in_progress()
|
||||
}
|
||||
|
||||
/// Check if handshake completed.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.handshake_state.is_complete()
|
||||
}
|
||||
|
||||
/// Check if handshake failed.
|
||||
pub fn is_failed(&self) -> bool {
|
||||
self.handshake_state.is_failed()
|
||||
}
|
||||
|
||||
/// When the connection started.
|
||||
pub fn started_at(&self) -> u64 {
|
||||
self.started_at
|
||||
}
|
||||
|
||||
/// When the last activity occurred.
|
||||
pub fn last_activity(&self) -> u64 {
|
||||
self.last_activity
|
||||
}
|
||||
|
||||
/// Connection duration so far.
|
||||
pub fn duration(&self, current_time_ms: u64) -> u64 {
|
||||
current_time_ms.saturating_sub(self.started_at)
|
||||
}
|
||||
|
||||
/// Time since last activity.
|
||||
pub fn idle_time(&self, current_time_ms: u64) -> u64 {
|
||||
current_time_ms.saturating_sub(self.last_activity)
|
||||
}
|
||||
|
||||
/// Get link statistics.
|
||||
pub fn link_stats(&self) -> &LinkStats {
|
||||
&self.link_stats
|
||||
}
|
||||
|
||||
/// Get mutable link statistics.
|
||||
pub fn link_stats_mut(&mut self) -> &mut LinkStats {
|
||||
&mut self.link_stats
|
||||
}
|
||||
|
||||
// === Index Accessors ===
|
||||
|
||||
/// Get our session index (if set).
|
||||
pub fn our_index(&self) -> Option<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);
|
||||
}
|
||||
|
||||
// === 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,592 @@
|
||||
//! Tests for the sans-IO FMP connection-lifecycle decision core.
|
||||
|
||||
use super::util::{
|
||||
establish_snapshot, peer_snapshot, rekey_resend_snapshot, resend_snapshot, stale_snapshot,
|
||||
wire_outcome,
|
||||
};
|
||||
use crate::NodeAddr;
|
||||
use crate::proto::fmp::{
|
||||
ConnAction, Fmp, InboundDecision, InboundReject, OutboundDecision, OutboundSnapshot, RekeyCfg,
|
||||
cross_connection_winner,
|
||||
};
|
||||
use crate::testutil::make_node_addr;
|
||||
use crate::transport::LinkId;
|
||||
|
||||
/// Threshold config used across the rekey decision tests: rekey at 100s of
|
||||
/// session age or 1000 sent messages.
|
||||
fn cfg() -> RekeyCfg {
|
||||
RekeyCfg {
|
||||
after_secs: 100,
|
||||
after_messages: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_stale_set_yields_no_actions() {
|
||||
let fmp = Fmp::new();
|
||||
assert!(fmp.poll_timeouts(Vec::new()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inbound_stale_connection_is_torn_down_without_retry() {
|
||||
let fmp = Fmp::new();
|
||||
let link = LinkId::new(7);
|
||||
let actions = fmp.poll_timeouts(vec![stale_snapshot(
|
||||
link,
|
||||
false,
|
||||
Some(make_node_addr(0x22)),
|
||||
)]);
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert!(matches!(actions[0], ConnAction::Teardown { link: l } if l == link));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbound_stale_with_identity_schedules_retry_then_tears_down() {
|
||||
let fmp = Fmp::new();
|
||||
let link = LinkId::new(9);
|
||||
let peer = make_node_addr(0x33);
|
||||
let actions = fmp.poll_timeouts(vec![stale_snapshot(link, true, Some(peer))]);
|
||||
assert_eq!(actions.len(), 2);
|
||||
// Retry is scheduled before teardown, matching the pre-refactor order.
|
||||
assert!(matches!(actions[0], ConnAction::ScheduleRetry { peer: p } if p == peer));
|
||||
assert!(matches!(actions[1], ConnAction::Teardown { link: l } if l == link));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbound_stale_without_identity_only_tears_down() {
|
||||
let fmp = Fmp::new();
|
||||
let link = LinkId::new(11);
|
||||
let actions = fmp.poll_timeouts(vec![stale_snapshot(link, true, None)]);
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert!(matches!(actions[0], ConnAction::Teardown { link: l } if l == link));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_resend_candidates_yields_no_actions() {
|
||||
let fmp = Fmp::new();
|
||||
assert!(fmp.poll_resends(Vec::new(), 1_000, 500, 2.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resend_emits_bytes_and_backoff_schedule() {
|
||||
let fmp = Fmp::new();
|
||||
let link = LinkId::new(5);
|
||||
let msg1 = vec![0xde, 0xad, 0xbe, 0xef];
|
||||
// now=1000, interval=500, backoff=2.0, prior_count=0 -> exponent 1 ->
|
||||
// next = 1000 + 500 * 2^1 = 2000.
|
||||
let actions = fmp.poll_resends(
|
||||
vec![resend_snapshot(link, 0, msg1.clone())],
|
||||
1_000,
|
||||
500,
|
||||
2.0,
|
||||
);
|
||||
assert_eq!(actions.len(), 1);
|
||||
match &actions[0] {
|
||||
ConnAction::ResendMsg1 {
|
||||
link: l,
|
||||
bytes,
|
||||
next_resend_at_ms,
|
||||
} => {
|
||||
assert_eq!(*l, link);
|
||||
assert_eq!(bytes, &msg1);
|
||||
assert_eq!(*next_resend_at_ms, 2_000);
|
||||
}
|
||||
_ => panic!("expected ResendMsg1"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resend_backoff_exponent_uses_count_plus_one() {
|
||||
let fmp = Fmp::new();
|
||||
let link = LinkId::new(6);
|
||||
// prior_count=2 -> exponent 3 -> next = 0 + 100 * 2^3 = 800.
|
||||
let actions = fmp.poll_resends(vec![resend_snapshot(link, 2, vec![1])], 0, 100, 2.0);
|
||||
match &actions[0] {
|
||||
ConnAction::ResendMsg1 {
|
||||
next_resend_at_ms, ..
|
||||
} => assert_eq!(*next_resend_at_ms, 800),
|
||||
_ => panic!("expected ResendMsg1"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- rekey decision (synthetic clock: elapsed_secs/counter fed directly) ---
|
||||
|
||||
#[test]
|
||||
fn rekey_no_peers_yields_no_actions() {
|
||||
let fmp = Fmp::new();
|
||||
assert!(fmp.poll_rekey(Vec::new(), &cfg()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_cutover_takes_precedence_over_trigger() {
|
||||
let fmp = Fmp::new();
|
||||
let mut p = peer_snapshot(0x10);
|
||||
p.has_pending = true;
|
||||
// Wildly over the time threshold, but cutover wins and nothing else fires.
|
||||
p.elapsed_secs = 10_000;
|
||||
p.counter = 10_000;
|
||||
let actions = fmp.poll_rekey(vec![p], &cfg());
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert!(matches!(actions[0], ConnAction::Cutover { peer } if peer == make_node_addr(0x10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_pending_with_inflight_rekey_does_not_cut_over() {
|
||||
let fmp = Fmp::new();
|
||||
let mut p = peer_snapshot(0x11);
|
||||
p.has_pending = true;
|
||||
p.rekey_in_progress = true;
|
||||
p.elapsed_secs = 10_000;
|
||||
// has_pending gated by !rekey_in_progress -> no cutover; in-progress -> no
|
||||
// trigger either.
|
||||
assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_expired_drain_and_trigger_both_fire() {
|
||||
let fmp = Fmp::new();
|
||||
let mut p = peer_snapshot(0x12);
|
||||
p.is_draining = true;
|
||||
p.drain_expired = true;
|
||||
p.elapsed_secs = 150; // past 100s threshold
|
||||
let actions = fmp.poll_rekey(vec![p], &cfg());
|
||||
// Draining does not preclude re-triggering in the same tick.
|
||||
assert_eq!(actions.len(), 2);
|
||||
assert!(matches!(actions[0], ConnAction::Drain { peer } if peer == make_node_addr(0x12)));
|
||||
assert!(
|
||||
matches!(actions[1], ConnAction::InitiateRekey { peer } if peer == make_node_addr(0x12))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_triggers_on_counter() {
|
||||
let fmp = Fmp::new();
|
||||
let mut p = peer_snapshot(0x13);
|
||||
p.counter = 1_000; // == after_messages
|
||||
let actions = fmp.poll_rekey(vec![p], &cfg());
|
||||
assert!(matches!(actions[0], ConnAction::InitiateRekey { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_negative_jitter_lowers_time_threshold() {
|
||||
let fmp = Fmp::new();
|
||||
let mut p = peer_snapshot(0x14);
|
||||
p.elapsed_secs = 90;
|
||||
p.jitter_secs = -15; // effective threshold 85 -> 90 >= 85 fires
|
||||
assert!(matches!(
|
||||
fmp.poll_rekey(vec![p], &cfg())[0],
|
||||
ConnAction::InitiateRekey { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_positive_jitter_raises_time_threshold() {
|
||||
let fmp = Fmp::new();
|
||||
let mut p = peer_snapshot(0x15);
|
||||
p.elapsed_secs = 105;
|
||||
p.jitter_secs = 10; // effective threshold 110 -> 105 < 110, no time trigger
|
||||
assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_dampening_suppresses_trigger() {
|
||||
let fmp = Fmp::new();
|
||||
let mut p = peer_snapshot(0x16);
|
||||
p.elapsed_secs = 10_000;
|
||||
p.is_dampened = true;
|
||||
assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_actions_are_phase_grouped_across_peers() {
|
||||
let fmp = Fmp::new();
|
||||
// Peer A: trigger only. Peer B: cutover. Peer C: drain + trigger.
|
||||
let mut a = peer_snapshot(0x01);
|
||||
a.elapsed_secs = 200;
|
||||
let mut b = peer_snapshot(0x02);
|
||||
b.has_pending = true;
|
||||
let mut c = peer_snapshot(0x03);
|
||||
c.is_draining = true;
|
||||
c.drain_expired = true;
|
||||
c.counter = 5_000;
|
||||
let actions = fmp.poll_rekey(vec![a, b, c], &cfg());
|
||||
// Order must be: all cutovers, then all drains, then all rekeys.
|
||||
assert!(matches!(actions[0], ConnAction::Cutover { peer } if peer == make_node_addr(0x02)));
|
||||
assert!(matches!(actions[1], ConnAction::Drain { peer } if peer == make_node_addr(0x03)));
|
||||
assert!(
|
||||
matches!(actions[2], ConnAction::InitiateRekey { peer } if peer == make_node_addr(0x01))
|
||||
);
|
||||
assert!(
|
||||
matches!(actions[3], ConnAction::InitiateRekey { peer } if peer == make_node_addr(0x03))
|
||||
);
|
||||
assert_eq!(actions.len(), 4);
|
||||
}
|
||||
|
||||
// --- rekey msg1 retransmission decision ---
|
||||
|
||||
#[test]
|
||||
fn rekey_resend_no_candidates_yields_no_actions() {
|
||||
let fmp = Fmp::new();
|
||||
assert!(
|
||||
fmp.poll_rekey_resends(Vec::new(), 1_000, 500, 2.0, 5)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_resend_over_budget_abandons() {
|
||||
let fmp = Fmp::new();
|
||||
// resend_count == max_resends -> abandon (even though it is "due").
|
||||
let c = rekey_resend_snapshot(0x40, 5, true, vec![9]);
|
||||
let actions = fmp.poll_rekey_resends(vec![c], 1_000, 500, 2.0, 5);
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert!(
|
||||
matches!(actions[0], ConnAction::AbandonRekey { peer } if peer == make_node_addr(0x40))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_resend_due_retransmits_with_backoff() {
|
||||
let fmp = Fmp::new();
|
||||
let msg1 = vec![0xaa, 0xbb];
|
||||
// prior_count=1 -> exponent 2 -> next = 1000 + 500 * 2^2 = 3000.
|
||||
let c = rekey_resend_snapshot(0x41, 1, true, msg1.clone());
|
||||
let actions = fmp.poll_rekey_resends(vec![c], 1_000, 500, 2.0, 5);
|
||||
assert_eq!(actions.len(), 1);
|
||||
match &actions[0] {
|
||||
ConnAction::ResendRekeyMsg1 {
|
||||
peer,
|
||||
bytes,
|
||||
next_resend_at_ms,
|
||||
} => {
|
||||
assert_eq!(*peer, make_node_addr(0x41));
|
||||
assert_eq!(bytes, &msg1);
|
||||
assert_eq!(*next_resend_at_ms, 3_000);
|
||||
}
|
||||
_ => panic!("expected ResendRekeyMsg1"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_resend_not_due_is_skipped() {
|
||||
let fmp = Fmp::new();
|
||||
// Under budget but not due -> no action.
|
||||
let c = rekey_resend_snapshot(0x42, 1, false, vec![1]);
|
||||
assert!(
|
||||
fmp.poll_rekey_resends(vec![c], 1_000, 500, 2.0, 5)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_resend_abandons_precede_retransmits() {
|
||||
let fmp = Fmp::new();
|
||||
let due = rekey_resend_snapshot(0x43, 0, true, vec![1]);
|
||||
let over = rekey_resend_snapshot(0x44, 9, true, vec![2]);
|
||||
// Input order: due-resend first, then over-budget; output must be
|
||||
// abandons-first regardless.
|
||||
let actions = fmp.poll_rekey_resends(vec![due, over], 1_000, 500, 2.0, 5);
|
||||
assert_eq!(actions.len(), 2);
|
||||
assert!(
|
||||
matches!(actions[0], ConnAction::AbandonRekey { peer } if peer == make_node_addr(0x44))
|
||||
);
|
||||
assert!(
|
||||
matches!(actions[1], ConnAction::ResendRekeyMsg1 { peer, .. } if peer == make_node_addr(0x43))
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// establish_inbound — inbound msg1 classification (E3)
|
||||
// ===========================================================================
|
||||
|
||||
/// All-0xFF NodeAddr: strictly greater than any pubkey-derived peer addr, so a
|
||||
/// tie-break with `our_node_addr` set to this makes us the larger side.
|
||||
fn max_node_addr() -> NodeAddr {
|
||||
NodeAddr::from_bytes([0xFF; 16])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_net_new_promotes() {
|
||||
let fmp = Fmp::new();
|
||||
let snap = establish_snapshot();
|
||||
let wire = wire_outcome(Some([1u8; 8]));
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::Promote
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_at_cap_net_new_rejects() {
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.at_max_peers = true;
|
||||
let wire = wire_outcome(Some([1u8; 8]));
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::Reject {
|
||||
reason: InboundReject::AtMaxPeers
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_at_cap_with_pending_outbound_bypasses_and_promotes() {
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.at_max_peers = true;
|
||||
snap.has_pending_outbound_to_peer = true;
|
||||
let wire = wire_outcome(Some([1u8; 8]));
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::Promote
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_at_cap_existing_peer_not_capped() {
|
||||
// At cap but the identity is already a peer → the cap gate is bypassed and
|
||||
// the same-epoch classification runs (here: a duplicate resend).
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.at_max_peers = true;
|
||||
snap.has_existing_peer = true;
|
||||
snap.existing_peer_epoch = Some([9u8; 8]);
|
||||
snap.existing_msg2 = Some(vec![0xAB, 0xCD]);
|
||||
let wire = wire_outcome(Some([9u8; 8]));
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::ResendMsg2 { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_epoch_mismatch_restarts() {
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.has_existing_peer = true;
|
||||
snap.existing_peer_epoch = Some([1u8; 8]);
|
||||
let wire = wire_outcome(Some([2u8; 8]));
|
||||
let peer = *wire.peer_identity.node_addr();
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::RestartThenPromote { peer: p } if p == peer
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_same_epoch_young_session_resends() {
|
||||
// Same epoch but session younger than the rekey gate → duplicate resend,
|
||||
// carrying the stored msg2 bytes verbatim.
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.has_existing_peer = true;
|
||||
snap.existing_peer_epoch = Some([7u8; 8]);
|
||||
snap.has_session = true;
|
||||
snap.is_healthy = true;
|
||||
snap.existing_session_age_secs = 5;
|
||||
snap.existing_msg2 = Some(vec![0x01, 0x02, 0x03]);
|
||||
let wire = wire_outcome(Some([7u8; 8]));
|
||||
match fmp.establish_inbound(&snap, &wire) {
|
||||
InboundDecision::ResendMsg2 { msg2 } => {
|
||||
assert_eq!(msg2.as_deref(), Some(&[0x01, 0x02, 0x03][..]));
|
||||
}
|
||||
other => panic!("expected ResendMsg2, got a different variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_aged_session_rekey_responds() {
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.has_existing_peer = true;
|
||||
snap.existing_peer_epoch = Some([7u8; 8]);
|
||||
snap.has_session = true;
|
||||
snap.is_healthy = true;
|
||||
snap.existing_session_age_secs = 31;
|
||||
let wire = wire_outcome(Some([7u8; 8]));
|
||||
let peer = *wire.peer_identity.node_addr();
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::RekeyRespond { peer: p, abandon_first: false } if p == peer
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_rekey_gate_requires_enabled() {
|
||||
// Aged healthy session but rekey disabled → same-epoch msg1 is a duplicate,
|
||||
// not a rekey.
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.has_existing_peer = true;
|
||||
snap.existing_peer_epoch = Some([7u8; 8]);
|
||||
snap.has_session = true;
|
||||
snap.is_healthy = true;
|
||||
snap.existing_session_age_secs = 31;
|
||||
snap.rekey_enabled = false;
|
||||
let wire = wire_outcome(Some([7u8; 8]));
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::ResendMsg2 { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_rekey_gate_boundary_at_30s() {
|
||||
// Exactly 30s satisfies `>= 30` → rekey; 29s does not → duplicate.
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.has_existing_peer = true;
|
||||
snap.existing_peer_epoch = Some([7u8; 8]);
|
||||
snap.has_session = true;
|
||||
snap.is_healthy = true;
|
||||
let wire = wire_outcome(Some([7u8; 8]));
|
||||
|
||||
snap.existing_session_age_secs = 30;
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::RekeyRespond { .. }
|
||||
));
|
||||
|
||||
snap.existing_session_age_secs = 29;
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::ResendMsg2 { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_pending_session_rejects() {
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.has_existing_peer = true;
|
||||
snap.existing_peer_epoch = Some([7u8; 8]);
|
||||
snap.has_session = true;
|
||||
snap.is_healthy = true;
|
||||
snap.existing_session_age_secs = 31;
|
||||
snap.pending_new_session = true;
|
||||
let wire = wire_outcome(Some([7u8; 8]));
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::Reject {
|
||||
reason: InboundReject::PendingSession
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_dual_init_we_win_rejects() {
|
||||
// rekey in progress + our addr < peer addr (our = 0x10, peer = pubkey-derived
|
||||
// non-zero) → we win, drop theirs.
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.has_existing_peer = true;
|
||||
snap.existing_peer_epoch = Some([7u8; 8]);
|
||||
snap.has_session = true;
|
||||
snap.is_healthy = true;
|
||||
snap.existing_session_age_secs = 31;
|
||||
snap.rekey_in_progress = true;
|
||||
snap.our_node_addr = make_node_addr(0x00); // minimal → strictly smaller
|
||||
let wire = wire_outcome(Some([7u8; 8]));
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::Reject {
|
||||
reason: InboundReject::DualRekeyWon
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_inbound_dual_init_we_lose_responds_with_abandon() {
|
||||
// rekey in progress + our addr > peer addr → we lose, abandon ours and
|
||||
// respond as responder.
|
||||
let fmp = Fmp::new();
|
||||
let mut snap = establish_snapshot();
|
||||
snap.has_existing_peer = true;
|
||||
snap.existing_peer_epoch = Some([7u8; 8]);
|
||||
snap.has_session = true;
|
||||
snap.is_healthy = true;
|
||||
snap.existing_session_age_secs = 31;
|
||||
snap.rekey_in_progress = true;
|
||||
snap.our_node_addr = max_node_addr(); // strictly larger than any peer addr
|
||||
let wire = wire_outcome(Some([7u8; 8]));
|
||||
let peer = *wire.peer_identity.node_addr();
|
||||
assert!(matches!(
|
||||
fmp.establish_inbound(&snap, &wire),
|
||||
InboundDecision::RekeyRespond { peer: p, abandon_first: true } if p == peer
|
||||
));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// establish_outbound — outbound msg2 classification (E4)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn establish_outbound_no_existing_peer_promotes() {
|
||||
let fmp = Fmp::new();
|
||||
// our_outbound_wins is irrelevant when there is no existing peer.
|
||||
let snap = OutboundSnapshot {
|
||||
has_existing_peer: false,
|
||||
our_outbound_wins: true,
|
||||
};
|
||||
assert_eq!(fmp.establish_outbound(&snap), OutboundDecision::Promote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_outbound_cross_connection_win_swaps() {
|
||||
let fmp = Fmp::new();
|
||||
let snap = OutboundSnapshot {
|
||||
has_existing_peer: true,
|
||||
our_outbound_wins: true,
|
||||
};
|
||||
assert_eq!(
|
||||
fmp.establish_outbound(&snap),
|
||||
OutboundDecision::CrossConnectionSwap
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establish_outbound_cross_connection_loss_keeps() {
|
||||
let fmp = Fmp::new();
|
||||
let snap = OutboundSnapshot {
|
||||
has_existing_peer: true,
|
||||
our_outbound_wins: false,
|
||||
};
|
||||
assert_eq!(
|
||||
fmp.establish_outbound(&snap),
|
||||
OutboundDecision::CrossConnectionKeep
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_connection_smaller_node_wins_outbound() {
|
||||
let node_a = make_node_addr(1); // smaller
|
||||
let node_b = make_node_addr(2); // larger
|
||||
|
||||
// Node A's perspective
|
||||
assert!(cross_connection_winner(&node_a, &node_b, true)); // A's outbound wins
|
||||
assert!(!cross_connection_winner(&node_a, &node_b, false)); // A's inbound loses
|
||||
|
||||
// Node B's perspective
|
||||
assert!(!cross_connection_winner(&node_b, &node_a, true)); // B's outbound loses
|
||||
assert!(cross_connection_winner(&node_b, &node_a, false)); // B's inbound wins
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_connection_symmetric() {
|
||||
let node_a = make_node_addr(1);
|
||||
let node_b = make_node_addr(2);
|
||||
|
||||
// A's outbound = B's inbound
|
||||
let a_outbound_wins = cross_connection_winner(&node_a, &node_b, true);
|
||||
let b_inbound_wins = cross_connection_winner(&node_b, &node_a, false);
|
||||
assert_eq!(a_outbound_wins, b_inbound_wins);
|
||||
|
||||
// A's inbound = B's outbound
|
||||
let a_inbound_wins = cross_connection_winner(&node_a, &node_b, false);
|
||||
let b_outbound_wins = cross_connection_winner(&node_b, &node_a, true);
|
||||
assert_eq!(a_inbound_wins, b_outbound_wins);
|
||||
|
||||
// Exactly one survives
|
||||
assert!(a_outbound_wins != a_inbound_wins);
|
||||
}
|
||||
@@ -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,178 @@
|
||||
//! Unit tests for the pure FMP connection state ([`ConnectionState`]) and its
|
||||
//! [`HandshakeState`] phase enum. These exercise the extracted bookkeeping
|
||||
//! directly, with no crypto involved; the crypto-driving transition behavior is
|
||||
//! covered by the shell `peer::connection` suite.
|
||||
|
||||
use crate::proto::fmp::{ConnectionState, HandshakeState};
|
||||
use crate::transport::{LinkId, TransportAddr, TransportId};
|
||||
use crate::utils::index::SessionIndex;
|
||||
use crate::{Identity, PeerIdentity};
|
||||
|
||||
fn make_peer_identity() -> PeerIdentity {
|
||||
PeerIdentity::from_pubkey(Identity::generate().pubkey())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_state_predicates() {
|
||||
assert!(HandshakeState::Initial.is_in_progress());
|
||||
assert!(HandshakeState::SentMsg1.is_in_progress());
|
||||
assert!(HandshakeState::ReceivedMsg1.is_in_progress());
|
||||
assert!(!HandshakeState::Complete.is_in_progress());
|
||||
assert!(!HandshakeState::Failed.is_in_progress());
|
||||
|
||||
assert!(HandshakeState::Complete.is_complete());
|
||||
assert!(!HandshakeState::Initial.is_complete());
|
||||
|
||||
assert!(HandshakeState::Failed.is_failed());
|
||||
assert!(!HandshakeState::Complete.is_failed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbound_initializes_pure_fields() {
|
||||
let identity = make_peer_identity();
|
||||
let state = ConnectionState::outbound(LinkId::new(1), identity, 1000);
|
||||
|
||||
assert!(state.is_outbound());
|
||||
assert!(!state.is_inbound());
|
||||
assert_eq!(state.handshake_state(), HandshakeState::Initial);
|
||||
assert!(state.is_in_progress());
|
||||
assert!(state.expected_identity().is_some());
|
||||
assert_eq!(state.link_id(), LinkId::new(1));
|
||||
assert_eq!(state.started_at(), 1000);
|
||||
assert_eq!(state.last_activity(), 1000);
|
||||
assert!(state.transport_id().is_none());
|
||||
assert!(state.source_addr().is_none());
|
||||
assert!(state.remote_epoch().is_none());
|
||||
assert_eq!(state.resend_count(), 0);
|
||||
assert_eq!(state.next_resend_at_ms(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inbound_initializes_pure_fields() {
|
||||
let state = ConnectionState::inbound(LinkId::new(2), 2000);
|
||||
|
||||
assert!(state.is_inbound());
|
||||
assert!(!state.is_outbound());
|
||||
assert_eq!(state.handshake_state(), HandshakeState::Initial);
|
||||
assert!(state.expected_identity().is_none());
|
||||
assert_eq!(state.started_at(), 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inbound_with_transport_sets_transport_and_addr() {
|
||||
let addr = TransportAddr::from_string("192.0.2.1:5000");
|
||||
let state =
|
||||
ConnectionState::inbound_with_transport(LinkId::new(3), TransportId::new(7), addr, 3000);
|
||||
|
||||
assert!(state.is_inbound());
|
||||
assert_eq!(state.transport_id(), Some(TransportId::new(7)));
|
||||
assert_eq!(
|
||||
state.source_addr().map(|a| a.as_str().unwrap().to_string()),
|
||||
Some("192.0.2.1:5000".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_setters_round_trip() {
|
||||
let mut state = ConnectionState::inbound(LinkId::new(1), 0);
|
||||
assert!(state.our_index().is_none());
|
||||
assert!(state.their_index().is_none());
|
||||
|
||||
state.set_our_index(SessionIndex::new(0x1111));
|
||||
state.set_their_index(SessionIndex::new(0x2222));
|
||||
assert_eq!(state.our_index(), Some(SessionIndex::new(0x1111)));
|
||||
assert_eq!(state.their_index(), Some(SessionIndex::new(0x2222)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_and_source_setters_round_trip() {
|
||||
let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 0);
|
||||
state.set_transport_id(TransportId::new(9));
|
||||
state.set_source_addr(TransportAddr::from_string("peer"));
|
||||
assert_eq!(state.transport_id(), Some(TransportId::new(9)));
|
||||
assert_eq!(state.source_addr().and_then(|a| a.as_str()), Some("peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_and_epoch_setters() {
|
||||
let mut state = ConnectionState::inbound(LinkId::new(1), 0);
|
||||
assert!(state.expected_identity().is_none());
|
||||
assert!(state.remote_epoch().is_none());
|
||||
|
||||
let identity = make_peer_identity();
|
||||
let node_addr = *identity.node_addr();
|
||||
state.set_expected_identity(identity);
|
||||
state.set_remote_epoch(Some([9u8; 8]));
|
||||
|
||||
assert_eq!(
|
||||
state.expected_identity().map(|id| *id.node_addr()),
|
||||
Some(node_addr)
|
||||
);
|
||||
assert_eq!(state.remote_epoch(), Some([9u8; 8]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_state_advance_and_fail() {
|
||||
let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 0);
|
||||
assert!(state.is_in_progress());
|
||||
|
||||
state.set_handshake_state(HandshakeState::SentMsg1);
|
||||
assert_eq!(state.handshake_state(), HandshakeState::SentMsg1);
|
||||
assert!(state.is_in_progress());
|
||||
assert!(!state.is_complete());
|
||||
|
||||
state.set_handshake_state(HandshakeState::Complete);
|
||||
assert!(state.is_complete());
|
||||
assert!(!state.is_in_progress());
|
||||
|
||||
state.mark_failed();
|
||||
assert!(state.is_failed());
|
||||
assert!(!state.is_in_progress());
|
||||
assert!(!state.is_complete());
|
||||
assert_eq!(state.handshake_state(), HandshakeState::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resend_bookkeeping() {
|
||||
let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 0);
|
||||
assert!(state.handshake_msg1().is_none());
|
||||
assert!(state.handshake_msg2().is_none());
|
||||
|
||||
state.set_handshake_msg1(vec![1, 2, 3], 500);
|
||||
assert_eq!(state.handshake_msg1(), Some(&[1u8, 2, 3][..]));
|
||||
assert_eq!(state.resend_count(), 0);
|
||||
assert_eq!(state.next_resend_at_ms(), 500);
|
||||
|
||||
state.record_resend(900);
|
||||
assert_eq!(state.resend_count(), 1);
|
||||
assert_eq!(state.next_resend_at_ms(), 900);
|
||||
|
||||
state.record_resend(1300);
|
||||
assert_eq!(state.resend_count(), 2);
|
||||
assert_eq!(state.next_resend_at_ms(), 1300);
|
||||
|
||||
// set_handshake_msg1 resets the resend counter.
|
||||
state.set_handshake_msg1(vec![4, 5], 100);
|
||||
assert_eq!(state.handshake_msg1(), Some(&[4u8, 5][..]));
|
||||
assert_eq!(state.resend_count(), 0);
|
||||
assert_eq!(state.next_resend_at_ms(), 100);
|
||||
|
||||
state.set_handshake_msg2(vec![6, 7, 8]);
|
||||
assert_eq!(state.handshake_msg2(), Some(&[6u8, 7, 8][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_and_touch() {
|
||||
let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 1000);
|
||||
assert_eq!(state.duration(1500), 500);
|
||||
assert_eq!(state.idle_time(1500), 500);
|
||||
assert!(!state.is_timed_out(1500, 1000));
|
||||
assert!(state.is_timed_out(2500, 1000));
|
||||
|
||||
// touch resets idle_time but not duration.
|
||||
state.touch(2000);
|
||||
assert_eq!(state.last_activity(), 2000);
|
||||
assert_eq!(state.idle_time(2500), 500);
|
||||
assert_eq!(state.duration(2500), 1500);
|
||||
assert!(!state.is_timed_out(2500, 1000));
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Shared test helpers for the FMP connection-lifecycle unit tests.
|
||||
|
||||
use crate::proto::fmp::{
|
||||
ConnSnapshot, EstablishSnapshot, PeerSnapshot, RekeyResendSnapshot, WireOutcome,
|
||||
};
|
||||
use crate::testutil::make_node_addr;
|
||||
use crate::transport::LinkId;
|
||||
use crate::utils::index::SessionIndex;
|
||||
use crate::{Identity, PeerIdentity};
|
||||
|
||||
/// Build a `RekeyResendSnapshot` for the given peer byte, prior retransmission
|
||||
/// count, due-flag, and opaque msg1 bytes.
|
||||
pub(super) fn rekey_resend_snapshot(
|
||||
peer_byte: u8,
|
||||
resend_count: u32,
|
||||
needs_resend: bool,
|
||||
msg1: Vec<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,
|
||||
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 a quiescent `EstablishSnapshot` for a net-new inbound msg1: no existing
|
||||
/// peer, not at cap, rekey enabled, our node addr fixed. Tests override only the
|
||||
/// fields the case exercises.
|
||||
pub(super) fn establish_snapshot() -> EstablishSnapshot {
|
||||
EstablishSnapshot {
|
||||
has_existing_peer: false,
|
||||
existing_peer_epoch: None,
|
||||
existing_session_age_secs: 0,
|
||||
has_session: false,
|
||||
is_healthy: false,
|
||||
pending_new_session: false,
|
||||
rekey_in_progress: false,
|
||||
existing_msg2: None,
|
||||
at_max_peers: false,
|
||||
has_pending_outbound_to_peer: false,
|
||||
rekey_enabled: true,
|
||||
our_node_addr: make_node_addr(0x10),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `WireOutcome` carrying a freshly generated peer identity and the
|
||||
/// given remote epoch (empty msg2 payload, fixed sender index). Callers read the
|
||||
/// peer's NodeAddr back via `wire.peer_identity.node_addr()` when they need it
|
||||
/// for the tie-break.
|
||||
pub(super) fn wire_outcome(remote_epoch: Option<[u8; 8]>) -> WireOutcome {
|
||||
WireOutcome {
|
||||
peer_identity: PeerIdentity::from_pubkey_full(Identity::generate().pubkey_full()),
|
||||
remote_epoch,
|
||||
their_index: SessionIndex::new(0x1234),
|
||||
msg2_payload: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Tests for the FMP link-framing wire codec.
|
||||
|
||||
use crate::proto::fmp::{Disconnect, DisconnectReason, HandshakeMessageType};
|
||||
|
||||
// ===== HandshakeMessageType Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_handshake_message_type_roundtrip() {
|
||||
let types = [
|
||||
HandshakeMessageType::NoiseIKMsg1,
|
||||
HandshakeMessageType::NoiseIKMsg2,
|
||||
];
|
||||
|
||||
for ty in types {
|
||||
let byte = ty.to_byte();
|
||||
let restored = HandshakeMessageType::from_byte(byte);
|
||||
assert_eq!(restored, Some(ty));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handshake_message_type_invalid() {
|
||||
assert!(HandshakeMessageType::from_byte(0x00).is_none());
|
||||
assert!(HandshakeMessageType::from_byte(0x03).is_none());
|
||||
assert!(HandshakeMessageType::from_byte(0x10).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handshake_message_type_is_handshake() {
|
||||
assert!(HandshakeMessageType::is_handshake(0x01));
|
||||
assert!(HandshakeMessageType::is_handshake(0x02));
|
||||
assert!(!HandshakeMessageType::is_handshake(0x00));
|
||||
assert!(!HandshakeMessageType::is_handshake(0x10));
|
||||
}
|
||||
|
||||
// ===== DisconnectReason Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_reason_roundtrip() {
|
||||
let reasons = [
|
||||
DisconnectReason::Shutdown,
|
||||
DisconnectReason::Restart,
|
||||
DisconnectReason::ProtocolError,
|
||||
DisconnectReason::TransportFailure,
|
||||
DisconnectReason::ResourceExhaustion,
|
||||
DisconnectReason::SecurityViolation,
|
||||
DisconnectReason::ConfigurationChange,
|
||||
DisconnectReason::Timeout,
|
||||
DisconnectReason::Other,
|
||||
];
|
||||
|
||||
for reason in reasons {
|
||||
let byte = reason.to_byte();
|
||||
let restored = DisconnectReason::from_byte(byte);
|
||||
assert_eq!(restored, Some(reason));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_reason_unknown_byte() {
|
||||
assert!(DisconnectReason::from_byte(0x08).is_none());
|
||||
assert!(DisconnectReason::from_byte(0x80).is_none());
|
||||
assert!(DisconnectReason::from_byte(0xFE).is_none());
|
||||
}
|
||||
|
||||
// ===== Disconnect Message Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_encode_decode() {
|
||||
let msg = Disconnect::new(DisconnectReason::Shutdown);
|
||||
let encoded = msg.encode();
|
||||
|
||||
assert_eq!(encoded.len(), 2);
|
||||
assert_eq!(encoded[0], 0x50); // LinkMessageType::Disconnect
|
||||
assert_eq!(encoded[1], 0x00); // DisconnectReason::Shutdown
|
||||
|
||||
// Decode from payload (after msg_type byte)
|
||||
let decoded = Disconnect::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(decoded.reason, DisconnectReason::Shutdown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_all_reasons() {
|
||||
let reasons = [
|
||||
DisconnectReason::Shutdown,
|
||||
DisconnectReason::Restart,
|
||||
DisconnectReason::ProtocolError,
|
||||
DisconnectReason::Other,
|
||||
];
|
||||
|
||||
for reason in reasons {
|
||||
let msg = Disconnect::new(reason);
|
||||
let encoded = msg.encode();
|
||||
let decoded = Disconnect::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(decoded.reason, reason);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_decode_empty_payload() {
|
||||
let result = Disconnect::decode(&[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_decode_unknown_reason() {
|
||||
let decoded = Disconnect::decode(&[0x80]).unwrap();
|
||||
assert_eq!(decoded.reason, DisconnectReason::Other);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! FMP link-framing messages: handshake message types and orderly disconnect.
|
||||
//!
|
||||
//! The Noise IK handshake message-type discriminants and the orderly
|
||||
//! disconnect codec, relocated from `protocol::link` per the
|
||||
//! wire-migrates-with-subsystem policy. `Disconnect::encode` reads the shared
|
||||
//! `LinkMessageType::Disconnect` catalog variant (a downward `proto ->
|
||||
//! protocol` dependency); the catalog itself stays in `protocol::link`.
|
||||
|
||||
use crate::protocol::{LinkMessageType, ProtocolError};
|
||||
use std::fmt;
|
||||
|
||||
/// Handshake message type identifiers.
|
||||
///
|
||||
/// These messages are exchanged during Noise IK handshake before link
|
||||
/// encryption is established. They use the same TLV framing as link
|
||||
/// messages but payloads are not encrypted (except Noise-internal encryption).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum HandshakeMessageType {
|
||||
/// Noise IK message 1: initiator sends ephemeral + encrypted static.
|
||||
/// Payload: 82 bytes (33 ephemeral + 33 static + 16 tag).
|
||||
NoiseIKMsg1 = 0x01,
|
||||
|
||||
/// Noise IK message 2: responder sends ephemeral.
|
||||
/// Payload: 33 bytes (ephemeral pubkey only).
|
||||
NoiseIKMsg2 = 0x02,
|
||||
}
|
||||
|
||||
impl HandshakeMessageType {
|
||||
/// Try to convert from a byte.
|
||||
pub fn from_byte(b: u8) -> Option<Self> {
|
||||
match b {
|
||||
0x01 => Some(HandshakeMessageType::NoiseIKMsg1),
|
||||
0x02 => Some(HandshakeMessageType::NoiseIKMsg2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to a byte.
|
||||
pub fn to_byte(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
/// Check if a byte represents a handshake message type.
|
||||
pub fn is_handshake(b: u8) -> bool {
|
||||
matches!(b, 0x01 | 0x02)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HandshakeMessageType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let name = match self {
|
||||
HandshakeMessageType::NoiseIKMsg1 => "NoiseIKMsg1",
|
||||
HandshakeMessageType::NoiseIKMsg2 => "NoiseIKMsg2",
|
||||
};
|
||||
write!(f, "{}", name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reason for an orderly disconnect notification.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum DisconnectReason {
|
||||
/// Normal shutdown (operator requested).
|
||||
Shutdown = 0x00,
|
||||
/// Restarting (may reconnect soon).
|
||||
Restart = 0x01,
|
||||
/// Protocol error encountered.
|
||||
ProtocolError = 0x02,
|
||||
/// Transport failure.
|
||||
TransportFailure = 0x03,
|
||||
/// Resource exhaustion (memory, connections).
|
||||
ResourceExhaustion = 0x04,
|
||||
/// Authentication or security policy violation.
|
||||
SecurityViolation = 0x05,
|
||||
/// Configuration change (peer removed from config).
|
||||
ConfigurationChange = 0x06,
|
||||
/// Timeout or keepalive failure.
|
||||
Timeout = 0x07,
|
||||
/// Unspecified reason.
|
||||
Other = 0xFF,
|
||||
}
|
||||
|
||||
impl DisconnectReason {
|
||||
/// Try to convert from a byte.
|
||||
pub fn from_byte(b: u8) -> Option<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 })
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,5 @@
|
||||
//! I/O adapters remain in `node::handlers`.
|
||||
|
||||
pub(crate) mod discovery;
|
||||
pub(crate) mod fmp;
|
||||
pub(crate) mod routing;
|
||||
|
||||
+1
-273
@@ -1,61 +1,9 @@
|
||||
//! Link-layer message types: handshake, link control, disconnect, session datagram.
|
||||
//! Link-layer message types: the shared frame catalog and session datagram.
|
||||
|
||||
use super::ProtocolError;
|
||||
use crate::NodeAddr;
|
||||
use std::fmt;
|
||||
|
||||
// ============================================================================
|
||||
// Handshake Message Types
|
||||
// ============================================================================
|
||||
|
||||
/// Handshake message type identifiers.
|
||||
///
|
||||
/// These messages are exchanged during Noise IK handshake before link
|
||||
/// encryption is established. They use the same TLV framing as link
|
||||
/// messages but payloads are not encrypted (except Noise-internal encryption).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum HandshakeMessageType {
|
||||
/// Noise IK message 1: initiator sends ephemeral + encrypted static.
|
||||
/// Payload: 82 bytes (33 ephemeral + 33 static + 16 tag).
|
||||
NoiseIKMsg1 = 0x01,
|
||||
|
||||
/// Noise IK message 2: responder sends ephemeral.
|
||||
/// Payload: 33 bytes (ephemeral pubkey only).
|
||||
NoiseIKMsg2 = 0x02,
|
||||
}
|
||||
|
||||
impl HandshakeMessageType {
|
||||
/// Try to convert from a byte.
|
||||
pub fn from_byte(b: u8) -> Option<Self> {
|
||||
match b {
|
||||
0x01 => Some(HandshakeMessageType::NoiseIKMsg1),
|
||||
0x02 => Some(HandshakeMessageType::NoiseIKMsg2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to a byte.
|
||||
pub fn to_byte(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
/// Check if a byte represents a handshake message type.
|
||||
pub fn is_handshake(b: u8) -> bool {
|
||||
matches!(b, 0x01 | 0x02)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HandshakeMessageType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let name = match self {
|
||||
HandshakeMessageType::NoiseIKMsg1 => "NoiseIKMsg1",
|
||||
HandshakeMessageType::NoiseIKMsg2 => "NoiseIKMsg2",
|
||||
};
|
||||
write!(f, "{}", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Link-Layer Message Types
|
||||
// ============================================================================
|
||||
@@ -141,120 +89,6 @@ impl fmt::Display for LinkMessageType {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Disconnect Reason Codes
|
||||
// ============================================================================
|
||||
|
||||
/// Reason for an orderly disconnect notification.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum DisconnectReason {
|
||||
/// Normal shutdown (operator requested).
|
||||
Shutdown = 0x00,
|
||||
/// Restarting (may reconnect soon).
|
||||
Restart = 0x01,
|
||||
/// Protocol error encountered.
|
||||
ProtocolError = 0x02,
|
||||
/// Transport failure.
|
||||
TransportFailure = 0x03,
|
||||
/// Resource exhaustion (memory, connections).
|
||||
ResourceExhaustion = 0x04,
|
||||
/// Authentication or security policy violation.
|
||||
SecurityViolation = 0x05,
|
||||
/// Configuration change (peer removed from config).
|
||||
ConfigurationChange = 0x06,
|
||||
/// Timeout or keepalive failure.
|
||||
Timeout = 0x07,
|
||||
/// Unspecified reason.
|
||||
Other = 0xFF,
|
||||
}
|
||||
|
||||
impl DisconnectReason {
|
||||
/// Try to convert from a byte.
|
||||
pub fn from_byte(b: u8) -> Option<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)
|
||||
// ============================================================================
|
||||
@@ -417,37 +251,6 @@ pub type MessageType = LinkMessageType;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ===== HandshakeMessageType Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_handshake_message_type_roundtrip() {
|
||||
let types = [
|
||||
HandshakeMessageType::NoiseIKMsg1,
|
||||
HandshakeMessageType::NoiseIKMsg2,
|
||||
];
|
||||
|
||||
for ty in types {
|
||||
let byte = ty.to_byte();
|
||||
let restored = HandshakeMessageType::from_byte(byte);
|
||||
assert_eq!(restored, Some(ty));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handshake_message_type_invalid() {
|
||||
assert!(HandshakeMessageType::from_byte(0x00).is_none());
|
||||
assert!(HandshakeMessageType::from_byte(0x03).is_none());
|
||||
assert!(HandshakeMessageType::from_byte(0x10).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handshake_message_type_is_handshake() {
|
||||
assert!(HandshakeMessageType::is_handshake(0x01));
|
||||
assert!(HandshakeMessageType::is_handshake(0x02));
|
||||
assert!(!HandshakeMessageType::is_handshake(0x00));
|
||||
assert!(!HandshakeMessageType::is_handshake(0x10));
|
||||
}
|
||||
|
||||
// ===== LinkMessageType Tests =====
|
||||
|
||||
#[test]
|
||||
@@ -476,81 +279,6 @@ mod tests {
|
||||
assert!(LinkMessageType::from_byte(0x40).is_none());
|
||||
}
|
||||
|
||||
// ===== DisconnectReason Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_reason_roundtrip() {
|
||||
let reasons = [
|
||||
DisconnectReason::Shutdown,
|
||||
DisconnectReason::Restart,
|
||||
DisconnectReason::ProtocolError,
|
||||
DisconnectReason::TransportFailure,
|
||||
DisconnectReason::ResourceExhaustion,
|
||||
DisconnectReason::SecurityViolation,
|
||||
DisconnectReason::ConfigurationChange,
|
||||
DisconnectReason::Timeout,
|
||||
DisconnectReason::Other,
|
||||
];
|
||||
|
||||
for reason in reasons {
|
||||
let byte = reason.to_byte();
|
||||
let restored = DisconnectReason::from_byte(byte);
|
||||
assert_eq!(restored, Some(reason));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_reason_unknown_byte() {
|
||||
assert!(DisconnectReason::from_byte(0x08).is_none());
|
||||
assert!(DisconnectReason::from_byte(0x80).is_none());
|
||||
assert!(DisconnectReason::from_byte(0xFE).is_none());
|
||||
}
|
||||
|
||||
// ===== Disconnect Message Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_encode_decode() {
|
||||
let msg = Disconnect::new(DisconnectReason::Shutdown);
|
||||
let encoded = msg.encode();
|
||||
|
||||
assert_eq!(encoded.len(), 2);
|
||||
assert_eq!(encoded[0], 0x50); // LinkMessageType::Disconnect
|
||||
assert_eq!(encoded[1], 0x00); // DisconnectReason::Shutdown
|
||||
|
||||
// Decode from payload (after msg_type byte)
|
||||
let decoded = Disconnect::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(decoded.reason, DisconnectReason::Shutdown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_all_reasons() {
|
||||
let reasons = [
|
||||
DisconnectReason::Shutdown,
|
||||
DisconnectReason::Restart,
|
||||
DisconnectReason::ProtocolError,
|
||||
DisconnectReason::Other,
|
||||
];
|
||||
|
||||
for reason in reasons {
|
||||
let msg = Disconnect::new(reason);
|
||||
let encoded = msg.encode();
|
||||
let decoded = Disconnect::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(decoded.reason, reason);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_decode_empty_payload() {
|
||||
let result = Disconnect::decode(&[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disconnect_decode_unknown_reason() {
|
||||
let decoded = Disconnect::decode(&[0x80]).unwrap();
|
||||
assert_eq!(decoded.reason, DisconnectReason::Other);
|
||||
}
|
||||
|
||||
// ===== SessionDatagram Tests =====
|
||||
|
||||
fn make_node_addr(val: u8) -> NodeAddr {
|
||||
|
||||
+1
-2
@@ -30,8 +30,7 @@ mod tree;
|
||||
pub use error::ProtocolError;
|
||||
pub use filter::FilterAnnounce;
|
||||
pub use link::{
|
||||
Disconnect, DisconnectReason, HandshakeMessageType, LinkMessageType,
|
||||
SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef,
|
||||
LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef,
|
||||
};
|
||||
pub use session::{
|
||||
FspFlags, FspInnerFlags, PATH_MTU_NOTIFICATION_SIZE, PathMtuNotification,
|
||||
|
||||
+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