mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 00:04:54 +00:00
proto/fmp: sans-IO connection-lifecycle state machine
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user