mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-11 09:07:44 +00:00
node: establish dataplane/ and session/ concept homes (behavior-neutral)
Reorganize the node module tree by concept rather than by
message-handling verb, as the first step of the node runtime
decomposition. Pure relocation: no wire, config, metric, or log
semantics change; the lib test count is unchanged (1577 passed).
Moves (git mv, 100% rename similarity):
- handlers/{forwarding,rx_loop,connected_udp,dispatch,encrypted}.rs
-> node/dataplane/ — the whole RX hot path (the select! run loop,
transit/local forwarding, the link-message router, the RX decrypt
path with responder K-bit cutover + roam writes, and connected-UDP
fast-path activation) now lives in one home.
- node/session.rs -> node/session/mod.rs — establishes the session
concept home for the data/state types. The message-behavior file
handlers/session.rs stays put for now (folds in with the later FSP
session step).
The IK/XX-divergent establishment files (handlers/{handshake,rekey,
timeout}.rs) and the deferred-home files (handlers/{mmp,lookup}.rs)
deliberately stay in handlers/, to move once rather than twice.
Every module is reached through impl Node methods, so no call site or
re-export shim was needed. Updated in lockstep with the moves: the
module_path!-derived tracing targets in the two mesh-lab compose-trace
overlays, a structural test's include_str! source path, doc-comments
in proto/routing and the mesh-lab docs, and the stale source-location
citations (node/handlers/{forwarding,rx_loop,encrypted}.rs and
node/session.rs) in doc-comments and the discovery design doc.
This commit is contained in:
@@ -1,236 +0,0 @@
|
||||
//! Lifecycle for per-peer connected UDP sockets.
|
||||
//!
|
||||
//! Tick-driven, idempotent, **on by default** for established UDP peers on
|
||||
//! Linux and macOS:
|
||||
//!
|
||||
//! - **Tick-driven:** every node tick, scan established UDP peers
|
||||
//! that don't yet have a connected socket installed and try to
|
||||
//! open one. No need to thread an activation call through every
|
||||
//! handshake-completion code path.
|
||||
//! - **Idempotent:** if `peer.connected_udp()` is already `Some`,
|
||||
//! skip. Replaces stale sockets lazily by clearing them on
|
||||
//! address change / rekey from elsewhere (see
|
||||
//! `deregister_session_index` and the rekey handler).
|
||||
//!
|
||||
//! Implementation note: only the **listen socket → wildcard** demux
|
||||
//! path delivers the very first packets of a session (handshakes).
|
||||
//! Once the peer's session is established, Linux/macOS install the connected
|
||||
//! socket; from that moment on the kernel routes that peer's traffic
|
||||
//! to it (most-specific 5-tuple match wins under `SO_REUSEPORT`), and
|
||||
//! the drain thread feeds the existing `packet_tx` just like the
|
||||
//! wildcard listen socket does. The rx_loop dispatch sees no
|
||||
//! difference.
|
||||
//!
|
||||
//! macOS originally defaulted to the wildcard UDP socket because early
|
||||
//! Darwin tests found liveness regressions under load. Later testing
|
||||
//! showed the problem was mismatched listener/peer `SO_REUSE*` state:
|
||||
//! with the live listener and connected sibling in the same reuse group,
|
||||
//! the connected `send(2)` path improves the MacBook Wi-Fi sender case
|
||||
//! and is now the default. Operators can still disable it with
|
||||
//! `FIPS_MACOS_CONNECTED_UDP=0` or `FIPS_CONNECTED_UDP=0` for A/B tests.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::node::Node;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use crate::transport::TransportHandle;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use tracing::{debug, warn};
|
||||
|
||||
impl Node {
|
||||
/// Tick-driven activation of per-peer connected UDP sockets.
|
||||
/// Scans established UDP peers that don't yet have a connected
|
||||
/// socket and opens one. No-op when there are no eligible peers
|
||||
/// (e.g. only non-UDP transports). Enabled on Linux and macOS:
|
||||
/// both kernels route a matching peer 5-tuple to the connected
|
||||
/// socket when it shares the wildcard listen port via SO_REUSEPORT.
|
||||
pub(in crate::node) async fn activate_connected_udp_sessions(&mut self) {
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
// No-op on platforms without the connected-UDP fast path.
|
||||
}
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
if !connected_udp_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect candidate NodeAddrs first so we can iterate
|
||||
// without holding the &mut on self.peers across awaits.
|
||||
let candidates: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter_map(|(addr, peer)| {
|
||||
let has_session = peer.noise_session().is_some();
|
||||
let has_transport = peer.transport_id().is_some();
|
||||
let has_addr = peer.current_addr().is_some();
|
||||
let already_active = peer.connected_udp().is_some();
|
||||
if has_session && has_transport && has_addr && !already_active {
|
||||
Some(*addr)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for addr in candidates {
|
||||
if let Err(e) = self.activate_connected_udp_for_peer(&addr).await {
|
||||
static FAILURES: AtomicU64 = AtomicU64::new(0);
|
||||
crate::perf_profile::record_event(
|
||||
crate::perf_profile::Event::ConnectedUdpActivationFailed,
|
||||
);
|
||||
let n = FAILURES.fetch_add(1, Relaxed);
|
||||
if n < 8 || n.is_multiple_of(1000) {
|
||||
warn!(peer = %addr, error = %e, failures = n + 1, "connected UDP activation deferred");
|
||||
} else {
|
||||
debug!(peer = %addr, error = %e, "connected UDP activation deferred");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the connected UDP socket + spawn its drain thread for
|
||||
/// one peer. Idempotent — re-checks the eligibility conditions
|
||||
/// inside the &mut so a race with peer drop doesn't install on a
|
||||
/// freshly-removed peer. Returns `Ok(())` on success or if the
|
||||
/// peer is no longer eligible (treated as benign).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
async fn activate_connected_udp_for_peer(
|
||||
&mut self,
|
||||
node_addr: &NodeAddr,
|
||||
) -> Result<(), String> {
|
||||
// Read-only pass: figure out which transport + remote addr we need.
|
||||
let (transport_id, peer_transport_addr) = {
|
||||
let Some(peer) = self.peers.get(node_addr) else {
|
||||
return Ok(());
|
||||
};
|
||||
if peer.connected_udp().is_some() {
|
||||
return Ok(()); // already activated
|
||||
}
|
||||
let Some(tid) = peer.transport_id() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(addr) = peer.current_addr().cloned() else {
|
||||
return Ok(());
|
||||
};
|
||||
(tid, addr)
|
||||
};
|
||||
|
||||
// Resolve the peer's TransportAddr → kernel SocketAddr via
|
||||
// the UDP transport's DNS cache. This may await on a DNS
|
||||
// lookup the very first time we see a hostname; subsequent
|
||||
// calls hit the cache.
|
||||
let (peer_socket_addr, local_addr, recv_buf, send_buf, packet_tx) = {
|
||||
let Some(transport) = self.transports.get(&transport_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let udp = match transport {
|
||||
TransportHandle::Udp(u) => u,
|
||||
_ => return Ok(()), // not a UDP transport — feature N/A
|
||||
};
|
||||
let peer_sa = udp
|
||||
.resolve_for_off_task(&peer_transport_addr)
|
||||
.await
|
||||
.map_err(|e| format!("address resolve: {e}"))?;
|
||||
let local = udp
|
||||
.local_addr()
|
||||
.ok_or_else(|| "udp transport not started".to_string())?;
|
||||
let recv_buf = udp.recv_buf_size();
|
||||
let send_buf = udp.send_buf_size();
|
||||
let tx = udp.clone_packet_tx();
|
||||
(peer_sa, local, recv_buf, send_buf, tx)
|
||||
};
|
||||
|
||||
// Open the connected socket on the kernel side, then adopt the
|
||||
// fd into the owning handle.
|
||||
let owned = crate::transport::udp::open_connected_fd(
|
||||
local_addr,
|
||||
peer_socket_addr,
|
||||
recv_buf,
|
||||
send_buf,
|
||||
)
|
||||
.map_err(|e| format!("open_connected_fd: {e}"))?;
|
||||
let socket = std::sync::Arc::new(crate::peer::connected_udp::ConnectedPeerSocket::from_fd(
|
||||
owned,
|
||||
peer_socket_addr,
|
||||
local_addr,
|
||||
));
|
||||
|
||||
// Spawn the drain thread. It feeds `packet_tx` exactly like
|
||||
// the wildcard listen socket — rx_loop dispatches identically.
|
||||
let drain = crate::peer::connected_udp::PeerRecvDrain::spawn(
|
||||
socket.clone(),
|
||||
transport_id,
|
||||
peer_socket_addr,
|
||||
packet_tx,
|
||||
)
|
||||
.map_err(|e| format!("PeerRecvDrain::spawn: {e}"))?;
|
||||
|
||||
// Install on the peer, idempotent re-check.
|
||||
if let Some(peer) = self.peers.get_mut(node_addr) {
|
||||
if peer.connected_udp().is_some() {
|
||||
// Lost the race — somebody else activated us first.
|
||||
// Drop the new socket + drain so we don't leak.
|
||||
drop(drain);
|
||||
drop(socket);
|
||||
return Ok(());
|
||||
}
|
||||
peer.set_connected_udp(socket, drain);
|
||||
crate::perf_profile::record_event(crate::perf_profile::Event::ConnectedUdpInstalled);
|
||||
debug!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
peer_addr = %peer_socket_addr,
|
||||
"connected UDP socket installed"
|
||||
);
|
||||
} else {
|
||||
// Peer disappeared between read-only pass and now.
|
||||
drop(drain);
|
||||
drop(socket);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the per-peer connected UDP socket + drain for a peer.
|
||||
/// Called on peer disconnect / removal. The drain thread exits
|
||||
/// via self-pipe; the kernel fd closes when the last `Arc`
|
||||
/// drops.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[allow(dead_code)] // wired by session-deregister + rekey teardown follow-up
|
||||
pub(in crate::node) fn clear_connected_udp_for_peer(&mut self, node_addr: &NodeAddr) {
|
||||
if let Some(peer) = self.peers.get_mut(node_addr)
|
||||
&& peer.connected_udp().is_some()
|
||||
{
|
||||
peer.clear_connected_udp();
|
||||
debug!(peer = %self.peer_display_name(node_addr), "connected UDP socket cleared");
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op shim for non-Linux builds so the rx_loop tick site can
|
||||
/// call us unconditionally.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
#[allow(dead_code)] // wired by session-deregister + rekey teardown follow-up
|
||||
pub(in crate::node) fn clear_connected_udp_for_peer(&mut self, _node_addr: &NodeAddr) {}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn connected_udp_enabled() -> bool {
|
||||
env_flag("FIPS_CONNECTED_UDP").unwrap_or(true)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn connected_udp_enabled() -> bool {
|
||||
env_flag("FIPS_MACOS_CONNECTED_UDP")
|
||||
.or_else(|| env_flag("FIPS_CONNECTED_UDP"))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn env_flag(name: &str) -> Option<bool> {
|
||||
let value = std::env::var(name).ok()?;
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
//! Link message dispatch and peer removal.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::node::Node;
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
impl Node {
|
||||
/// Dispatch a decrypted link message to the appropriate handler.
|
||||
///
|
||||
/// Link messages are protocol messages exchanged between authenticated peers.
|
||||
pub(in crate::node) async fn dispatch_link_message(
|
||||
&mut self,
|
||||
from: &NodeAddr,
|
||||
plaintext: &[u8],
|
||||
ce_flag: bool,
|
||||
) {
|
||||
if plaintext.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let msg_type = plaintext[0];
|
||||
let payload = &plaintext[1..];
|
||||
|
||||
match msg_type {
|
||||
0x00 => {
|
||||
// SessionDatagram
|
||||
self.handle_session_datagram(from, payload, ce_flag).await;
|
||||
}
|
||||
0x01 => {
|
||||
// SenderReport
|
||||
self.handle_sender_report(from, payload);
|
||||
}
|
||||
0x02 => {
|
||||
// ReceiverReport
|
||||
self.handle_receiver_report(from, payload).await;
|
||||
}
|
||||
0x10 => {
|
||||
// TreeAnnounce
|
||||
self.handle_tree_announce(from, payload).await;
|
||||
}
|
||||
0x20 => {
|
||||
// FilterAnnounce
|
||||
self.handle_filter_announce(from, payload).await;
|
||||
}
|
||||
0x30 => {
|
||||
// LookupRequest
|
||||
self.handle_lookup_request(from, payload).await;
|
||||
}
|
||||
0x31 => {
|
||||
// LookupResponse
|
||||
self.handle_lookup_response(from, payload).await;
|
||||
}
|
||||
0x50 => {
|
||||
// Disconnect
|
||||
self.handle_disconnect(from, payload);
|
||||
}
|
||||
0x51 => {
|
||||
// Heartbeat — no-op, last_recv_time already updated by record_recv()
|
||||
trace!(peer = %self.peer_display_name(from), "Received heartbeat");
|
||||
}
|
||||
_ => {
|
||||
debug!(msg_type = msg_type, "Unknown link message type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a Disconnect notification from a peer.
|
||||
///
|
||||
/// The peer is signaling an orderly departure. We immediately remove
|
||||
/// them from all state rather than waiting for timeout detection, and
|
||||
/// schedule a reconnect if the peer is configured as auto-connect.
|
||||
/// Without this, a graceful upstream shutdown orphans auto-connect
|
||||
/// 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::proto::fmp::Disconnect::decode(payload) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
debug!(from = %self.peer_display_name(from), error = %e, "Malformed disconnect message");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
peer = %self.peer_display_name(from),
|
||||
reason = %disconnect.reason,
|
||||
"Peer sent disconnect notification"
|
||||
);
|
||||
|
||||
let addr = *from;
|
||||
self.remove_active_peer(from);
|
||||
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(addr, now_ms);
|
||||
}
|
||||
|
||||
/// Remove an active peer and clean up all associated state.
|
||||
///
|
||||
/// Frees session index, removes link and address mappings. Used for
|
||||
/// both graceful disconnect and timeout-based eviction.
|
||||
///
|
||||
/// Also handles tree state cleanup: if the removed peer was our parent,
|
||||
/// selects an alternative or becomes root, and marks remaining peers
|
||||
/// for pending tree announce (delivered on next tick).
|
||||
pub(in crate::node) fn remove_active_peer(&mut self, node_addr: &NodeAddr) {
|
||||
let peer = match self.peers.remove(node_addr) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
debug!(peer = %self.peer_display_name(node_addr), "Peer already removed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Log suppressed replay detection summary before teardown
|
||||
let suppressed = peer.replay_suppressed_count();
|
||||
if suppressed > 0 {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
count = suppressed,
|
||||
"Suppressed replay detections during link transition"
|
||||
);
|
||||
}
|
||||
|
||||
// MMP teardown log (before we drop the peer)
|
||||
let peer_name = self
|
||||
.peer_aliases
|
||||
.get(node_addr)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| peer.identity().short_npub());
|
||||
if let Some(mmp) = peer.mmp() {
|
||||
Self::log_mmp_teardown(&peer_name, mmp);
|
||||
}
|
||||
|
||||
// Remove any end-to-end session associated with this peer.
|
||||
//
|
||||
// Sessions are tracked separately from peers (self.sessions vs self.peers).
|
||||
// Leaving a stale session alive after removing the peer causes:
|
||||
// 1. check_session_mmp_reports() keeps logging stale "MMP session metrics"
|
||||
// with frozen counters until purge_idle_sessions() eventually fires.
|
||||
// 2. initiate_session() finds is_established() == true on the stale entry
|
||||
// and silently returns Ok(()), preventing a new session from being
|
||||
// established even after the link layer reconnects successfully.
|
||||
if let Some(session_entry) = self.sessions.remove(node_addr)
|
||||
&& let Some(mmp) = session_entry.mmp()
|
||||
{
|
||||
Self::log_session_mmp_teardown(&peer_name, mmp);
|
||||
}
|
||||
self.pending_tun_packets.remove(node_addr);
|
||||
|
||||
let link_id = peer.link_id();
|
||||
let transport_id = peer.transport_id();
|
||||
|
||||
// Free session indices (current, rekey, pending, previous)
|
||||
if let Some(tid) = transport_id {
|
||||
if let Some(idx) = peer.our_index() {
|
||||
let cache_key = (tid, idx.as_u32());
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
let _ = self.index_allocator.free(idx);
|
||||
}
|
||||
if let Some(idx) = peer.rekey_our_index() {
|
||||
let cache_key = (tid, idx.as_u32());
|
||||
self.pending_outbound.remove(&cache_key);
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
let _ = self.index_allocator.free(idx);
|
||||
}
|
||||
if let Some(idx) = peer.pending_our_index() {
|
||||
let cache_key = (tid, idx.as_u32());
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
let _ = self.index_allocator.free(idx);
|
||||
}
|
||||
if let Some(idx) = peer.previous_our_index() {
|
||||
let cache_key = (tid, idx.as_u32());
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
let _ = self.index_allocator.free(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove link and address mapping
|
||||
self.remove_link(&link_id);
|
||||
if let Some(transport_id) = transport_id {
|
||||
self.cleanup_bootstrap_transport_if_unused(transport_id);
|
||||
}
|
||||
|
||||
// Tree state cleanup
|
||||
let tree_changed = self.handle_peer_removal_tree_cleanup(node_addr);
|
||||
if tree_changed {
|
||||
// Mark all remaining peers for pending tree announce.
|
||||
// These will be sent on the next tick via check_tree_state().
|
||||
for peer in self.peers.values_mut() {
|
||||
peer.mark_tree_announce_pending();
|
||||
}
|
||||
}
|
||||
|
||||
// Bloom filter cleanup: clear state for removed peer, mark all remaining peers
|
||||
self.bloom_state.remove_peer_state(node_addr);
|
||||
let remaining_peers: Vec<NodeAddr> = self.peers.keys().copied().collect();
|
||||
self.bloom_state.mark_all_updates_needed(remaining_peers);
|
||||
|
||||
debug!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
link_id = %link_id,
|
||||
tree_changed = tree_changed,
|
||||
"Peer removed and state cleaned up"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,541 +0,0 @@
|
||||
//! Encrypted frame handling (hot path).
|
||||
|
||||
use crate::node::Node;
|
||||
use crate::noise::NoiseError;
|
||||
use crate::proto::fmp::wire::{
|
||||
EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, strip_inner_header,
|
||||
};
|
||||
use crate::transport::ReceivedPacket;
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
/// Force-remove a peer after this many consecutive decryption failures.
|
||||
const DECRYPT_FAILURE_THRESHOLD: u32 = 20;
|
||||
|
||||
impl Node {
|
||||
/// Handle an encrypted frame (phase 0x0).
|
||||
///
|
||||
/// This is the hot path for established sessions. We use O(1)
|
||||
/// index-based lookup to find the session, then decrypt.
|
||||
///
|
||||
/// K-bit handling: when the peer flips the K-bit after a rekey,
|
||||
/// we promote the pending new session to current and demote the old
|
||||
/// session to previous for a drain window. During drain, we try the
|
||||
/// current session first, then fall back to the previous session.
|
||||
pub(in crate::node) async fn handle_encrypted_frame(&mut self, packet: ReceivedPacket) {
|
||||
// Parse header (fail fast)
|
||||
let header = match EncryptedHeader::parse(&packet.data) {
|
||||
Some(h) => h,
|
||||
None => return, // Malformed, drop silently
|
||||
};
|
||||
|
||||
// O(1) session lookup by our receiver index
|
||||
let key = (packet.transport_id, header.receiver_idx.as_u32());
|
||||
let node_addr = match self.peers_by_index.get(&key) {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
trace!(
|
||||
receiver_idx = %header.receiver_idx,
|
||||
transport_id = %packet.transport_id,
|
||||
"Unknown session index, dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !self.peers.contains_key(&node_addr) {
|
||||
self.peers_by_index.remove(&key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract K-bit from flags
|
||||
let received_k_bit = header.flags & FLAG_KEY_EPOCH != 0;
|
||||
|
||||
// K-bit flip detection: peer has cut over to the new session.
|
||||
//
|
||||
// The header K-bit is NOT a sufficient gating event on its own.
|
||||
// Under jitter the FMP rekey interval shrinks and the two
|
||||
// directions' rekeys interleave, so a node can hold a `pending`
|
||||
// session from rekey N while the peer's observed K-bit flip
|
||||
// actually belongs to rekey N+1. Promoting on the bare bit then
|
||||
// installs the WRONG Noise session as current — the two endpoints
|
||||
// diverge, every subsequent frame fails AEAD on the far side, the
|
||||
// receiver starves, and the link is declared dead at the heartbeat
|
||||
// timeout (routing failure, green crypto). This mirrors the FSP fix
|
||||
// (node/session.rs / node/handlers/session.rs): the authenticated
|
||||
// decrypt, not the header bit, is the cutover signal. Trial-decrypt
|
||||
// the frame against `pending` first; only promote if it
|
||||
// authenticates. On success the same frame is delivered via
|
||||
// `process_authentic_fmp_plaintext` and we return — it must not
|
||||
// fall through to a second decrypt, which would be rejected as a
|
||||
// replay (the trial-decrypt already advanced `pending`'s window).
|
||||
{
|
||||
let Some(peer) = self.peers.get(&node_addr) else {
|
||||
return;
|
||||
};
|
||||
let k_bit_flipped =
|
||||
received_k_bit != peer.current_k_bit() && peer.pending_new_session().is_some();
|
||||
|
||||
if k_bit_flipped {
|
||||
let ciphertext = &packet.data[header.ciphertext_offset()..];
|
||||
let display_name = self.peer_display_name(&node_addr);
|
||||
let Some(peer) = self.peers.get_mut(&node_addr) else {
|
||||
return;
|
||||
};
|
||||
// Authenticate the frame against the pending session.
|
||||
// Trial-decrypt mutates `pending`'s replay window only on
|
||||
// success, so a failed trial leaves it untouched.
|
||||
let pending_plaintext = peer.pending_new_session_mut().and_then(|pending| {
|
||||
pending
|
||||
.decrypt_with_replay_check_and_aad(
|
||||
ciphertext,
|
||||
header.counter,
|
||||
&header.header_bytes,
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
|
||||
if let Some(plaintext) = pending_plaintext {
|
||||
debug!(
|
||||
peer = %display_name,
|
||||
"Peer new-epoch frame authenticated, K-bit flip promoting new session"
|
||||
);
|
||||
// The trial-decrypt already advanced the pending
|
||||
// session's replay window; `handle_peer_kbit_flip`
|
||||
// moves that same session object to `current`, so no
|
||||
// re-decrypt.
|
||||
let did_flip = peer.handle_peer_kbit_flip().is_some();
|
||||
if did_flip {
|
||||
// New index was pre-registered in peers_by_index
|
||||
// during msg1 handling (handshake.rs). Verify,
|
||||
// don't duplicate.
|
||||
debug_assert!(
|
||||
peer.transport_id().is_some()
|
||||
&& peer.our_index().is_some()
|
||||
&& self.peers_by_index.contains_key(&(
|
||||
peer.transport_id().unwrap(),
|
||||
peer.our_index().unwrap().as_u32()
|
||||
)),
|
||||
"peers_by_index should contain pre-registered new index after K-bit flip"
|
||||
);
|
||||
}
|
||||
// Re-register the (now-promoted) session with the
|
||||
// decrypt worker: cache_key = (transport_id, our_index)
|
||||
// changed at the flip, so the old worker entry is
|
||||
// stranded and every packet on the new session would
|
||||
// miss the worker's HashMap lookup. Without this,
|
||||
// throughput drops back to the inline-decrypt path
|
||||
// after each rekey.
|
||||
#[cfg(unix)]
|
||||
if did_flip {
|
||||
self.register_decrypt_worker_session(&node_addr);
|
||||
}
|
||||
|
||||
// Deliver the frame we just authenticated via the
|
||||
// canonical post-decrypt path, then return — it must
|
||||
// not fall through to a second decrypt attempt.
|
||||
let ce_flag = header.flags & FLAG_CE != 0;
|
||||
let sp_flag = header.flags & FLAG_SP != 0;
|
||||
self.process_authentic_fmp_plaintext(
|
||||
&node_addr,
|
||||
packet.transport_id,
|
||||
&packet.remote_addr,
|
||||
packet.timestamp_ms,
|
||||
packet.data.len(),
|
||||
header.counter,
|
||||
ce_flag,
|
||||
sp_flag,
|
||||
&plaintext,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
// Pending did NOT authenticate this frame: the flip belongs
|
||||
// to a different rekey epoch (stale pending). Do not
|
||||
// promote. Fall through to the normal current/previous
|
||||
// decrypt; the genuine cutover is recognized when a frame
|
||||
// that authenticates against `pending` arrives.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decrypt-worker fast path (unix) ─────────────────────────
|
||||
// Once the session has been registered with a decrypt shard
|
||||
// (at FMP-establishment in `promote_connection`), the worker
|
||||
// owns the FMP recv cipher + replay window. Dispatch the
|
||||
// packet and return; the worker will run AEAD off-task and
|
||||
// bounce the plaintext back via `decrypt_fallback_tx` for
|
||||
// rx_loop to do the post-decrypt side-effects.
|
||||
//
|
||||
// The in-line decrypt below is the **synchronous test-mode
|
||||
// path** for unit tests that construct `Node` without
|
||||
// `lifecycle::start_async`; in production every established
|
||||
// session is dispatched to the worker.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let cache_key = (packet.transport_id, header.receiver_idx.as_u32());
|
||||
if let Some(workers) = self.decrypt_workers.as_ref().cloned()
|
||||
&& self.decrypt_registered_sessions.contains(&cache_key)
|
||||
{
|
||||
let job = crate::node::decrypt_worker::DecryptJob {
|
||||
packet_data: packet.data,
|
||||
cache_key,
|
||||
_transport_id: packet.transport_id,
|
||||
_remote_addr: packet.remote_addr,
|
||||
timestamp_ms: packet.timestamp_ms,
|
||||
source_node_addr: node_addr,
|
||||
fmp_counter: header.counter,
|
||||
fmp_flags: header.flags,
|
||||
fmp_header: header.header_bytes,
|
||||
fmp_ciphertext_offset: header.ciphertext_offset(),
|
||||
fallback_tx: self.decrypt_fallback_tx.clone(),
|
||||
};
|
||||
workers.dispatch_job(job);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt: try current session first, then previous (drain fallback)
|
||||
let ciphertext = &packet.data[header.ciphertext_offset()..];
|
||||
let plaintext = {
|
||||
let peer = self.peers.get_mut(&node_addr).unwrap();
|
||||
let session = match peer.noise_session_mut() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
warn!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Peer in index map has no session"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match session.decrypt_with_replay_check_and_aad(
|
||||
ciphertext,
|
||||
header.counter,
|
||||
&header.header_bytes,
|
||||
) {
|
||||
Ok(p) => {
|
||||
peer.reset_decrypt_failures();
|
||||
p
|
||||
}
|
||||
Err(e) => {
|
||||
// Current session failed — try previous session (drain window)
|
||||
if let Some(prev_session) = peer.previous_session_mut() {
|
||||
match prev_session.decrypt_with_replay_check_and_aad(
|
||||
ciphertext,
|
||||
header.counter,
|
||||
&header.header_bytes,
|
||||
) {
|
||||
Ok(p) => {
|
||||
peer.reset_decrypt_failures();
|
||||
p
|
||||
}
|
||||
Err(_) => {
|
||||
self.log_decrypt_failure(&node_addr, &header, &e);
|
||||
self.handle_decrypt_failure(&node_addr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.log_decrypt_failure(&node_addr, &header, &e);
|
||||
self.handle_decrypt_failure(&node_addr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// === PACKET IS AUTHENTIC ===
|
||||
|
||||
// Strip inner header (4-byte timestamp + msg_type)
|
||||
let (timestamp, link_message) = match strip_inner_header(&plaintext) {
|
||||
Some(parts) => parts,
|
||||
None => {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
len = plaintext.len(),
|
||||
"Decrypted payload too short for inner header"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// MMP per-frame processing and statistics
|
||||
let now_ms = crate::time::mono_ms();
|
||||
let ce_flag = header.flags & FLAG_CE != 0;
|
||||
let sp_flag = header.flags & FLAG_SP != 0;
|
||||
|
||||
if let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
if let Some(mmp) = peer.mmp_mut() {
|
||||
mmp.receiver.record_recv(
|
||||
header.counter,
|
||||
timestamp,
|
||||
packet.data.len(),
|
||||
ce_flag,
|
||||
now_ms,
|
||||
);
|
||||
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now_ms);
|
||||
}
|
||||
peer.set_current_addr(packet.transport_id, packet.remote_addr.clone());
|
||||
peer.link_stats_mut()
|
||||
.record_recv(packet.data.len(), packet.timestamp_ms);
|
||||
peer.touch(packet.timestamp_ms);
|
||||
}
|
||||
|
||||
// Dispatch to link message handler
|
||||
self.dispatch_link_message(&node_addr, link_message, ce_flag)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Log a decryption failure with replay suppression.
|
||||
fn log_decrypt_failure(
|
||||
&mut self,
|
||||
node_addr: &crate::NodeAddr,
|
||||
header: &EncryptedHeader,
|
||||
error: &NoiseError,
|
||||
) {
|
||||
if matches!(error, NoiseError::ReplayDetected(_)) {
|
||||
if let Some(peer) = self.peers.get_mut(node_addr) {
|
||||
let count = peer.increment_replay_suppressed();
|
||||
if count <= 3 {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
counter = header.counter,
|
||||
error = %error,
|
||||
"Decryption failed"
|
||||
);
|
||||
} else if count == 4 {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
"Suppressing further replay detection messages"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
counter = header.counter,
|
||||
error = %error,
|
||||
"Decryption failed"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
counter = header.counter,
|
||||
error = %error,
|
||||
"Decryption failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical post-FMP-decrypt side-effect site. Used by both the
|
||||
/// inline rx_loop decrypt path and the decrypt-worker bounce path
|
||||
/// so the per-peer bookkeeping (stats, MMP, spin-bit RTT, ECN
|
||||
/// propagation, address-rotation handling, link-message dispatch)
|
||||
/// happens in exactly one place.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(in crate::node) async fn process_authentic_fmp_plaintext(
|
||||
&mut self,
|
||||
node_addr: &crate::NodeAddr,
|
||||
transport_id: crate::transport::TransportId,
|
||||
remote_addr: &crate::transport::TransportAddr,
|
||||
packet_timestamp_ms: u64,
|
||||
packet_len: usize,
|
||||
fmp_counter: u64,
|
||||
ce_flag: bool,
|
||||
sp_flag: bool,
|
||||
fmp_plaintext: &[u8],
|
||||
) {
|
||||
const INNER_TIMESTAMP_LEN: usize = 4;
|
||||
let inner_ts = if fmp_plaintext.len() >= INNER_TIMESTAMP_LEN {
|
||||
u32::from_le_bytes([
|
||||
fmp_plaintext[0],
|
||||
fmp_plaintext[1],
|
||||
fmp_plaintext[2],
|
||||
fmp_plaintext[3],
|
||||
])
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let now_ms = crate::time::mono_ms();
|
||||
let mut address_changed = false;
|
||||
if let Some(peer) = self.peers.get_mut(node_addr) {
|
||||
peer.reset_decrypt_failures();
|
||||
address_changed = peer.set_current_addr(transport_id, remote_addr.clone());
|
||||
peer.link_stats_mut()
|
||||
.record_recv(packet_len, packet_timestamp_ms);
|
||||
peer.touch(packet_timestamp_ms);
|
||||
if let Some(mmp) = peer.mmp_mut() {
|
||||
mmp.receiver
|
||||
.record_recv(fmp_counter, inner_ts, packet_len, ce_flag, now_ms);
|
||||
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, fmp_counter, now_ms);
|
||||
}
|
||||
}
|
||||
// Address rotation invalidates the per-peer connect()-ed UDP
|
||||
// socket. Drop the connected socket + drain so the wildcard
|
||||
// listen socket takes over until the new 5-tuple settles.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if address_changed {
|
||||
self.clear_connected_udp_for_peer(node_addr);
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
let _ = address_changed;
|
||||
}
|
||||
let link_message = &fmp_plaintext[INNER_TIMESTAMP_LEN..];
|
||||
self.dispatch_link_message(node_addr, link_message, ce_flag)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Process a decrypt-worker bounce (FMP plaintext only — the
|
||||
/// worker has already done the AEAD + replay check).
|
||||
#[cfg(unix)]
|
||||
pub(in crate::node) async fn process_decrypt_fallback(
|
||||
&mut self,
|
||||
fallback: crate::node::decrypt_worker::DecryptFallback,
|
||||
) {
|
||||
let ce_flag = fallback.fmp_flags & FLAG_CE != 0;
|
||||
let sp_flag = fallback.fmp_flags & FLAG_SP != 0;
|
||||
let plaintext = &fallback.packet_data[fallback.fmp_plaintext_offset
|
||||
..fallback.fmp_plaintext_offset + fallback.fmp_plaintext_len];
|
||||
self.process_authentic_fmp_plaintext(
|
||||
&fallback.source_node_addr,
|
||||
fallback.transport_id,
|
||||
&fallback.remote_addr,
|
||||
fallback.timestamp_ms,
|
||||
fallback.packet_len,
|
||||
fallback.fmp_counter,
|
||||
ce_flag,
|
||||
sp_flag,
|
||||
plaintext,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Process a decrypt-worker failure event.
|
||||
#[cfg(unix)]
|
||||
pub(in crate::node) async fn process_decrypt_failure_report(
|
||||
&mut self,
|
||||
report: crate::node::decrypt_worker::DecryptFailureReport,
|
||||
) {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&report.source_node_addr),
|
||||
counter = report.fmp_counter,
|
||||
replay_highest = report.fmp_replay_highest,
|
||||
"Worker FMP AEAD decryption failed"
|
||||
);
|
||||
self.handle_decrypt_failure(&report.source_node_addr);
|
||||
}
|
||||
|
||||
/// Dispatch a decrypt-worker event (plaintext bounce or failure
|
||||
/// report) to the appropriate handler.
|
||||
#[cfg(unix)]
|
||||
pub(in crate::node) async fn process_decrypt_worker_event(
|
||||
&mut self,
|
||||
event: crate::node::decrypt_worker::DecryptWorkerEvent,
|
||||
) {
|
||||
match event {
|
||||
crate::node::decrypt_worker::DecryptWorkerEvent::Plaintext(fallback) => {
|
||||
self.process_decrypt_fallback(fallback).await;
|
||||
}
|
||||
crate::node::decrypt_worker::DecryptWorkerEvent::DecryptFailure(report) => {
|
||||
self.process_decrypt_failure_report(report).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a session's FMP recv cipher + replay window off to a shard
|
||||
/// of the decrypt worker pool. Idempotent on rekey: re-registering
|
||||
/// the same cache_key overwrites the worker's entry. Gates the
|
||||
/// `decrypt_registered_sessions` insert on actual worker acceptance
|
||||
/// so a `TrySendError::Full` on the per-worker channel doesn't
|
||||
/// black-hole the session.
|
||||
#[cfg(unix)]
|
||||
pub(in crate::node) fn register_decrypt_worker_session(&mut self, node_addr: &crate::NodeAddr) {
|
||||
let Some(workers) = self.decrypt_workers.as_ref().cloned() else {
|
||||
return;
|
||||
};
|
||||
let (cache_key, state) = {
|
||||
let Some(peer) = self.peers.get(node_addr) else {
|
||||
return;
|
||||
};
|
||||
let Some(transport_id) = peer.transport_id() else {
|
||||
return;
|
||||
};
|
||||
let Some(our_index) = peer.our_index() else {
|
||||
return;
|
||||
};
|
||||
let cache_key = (transport_id, our_index.as_u32());
|
||||
let Some(state) = self.build_owned_session_state(node_addr) else {
|
||||
return;
|
||||
};
|
||||
(cache_key, state)
|
||||
};
|
||||
if workers.register_session(cache_key, state) {
|
||||
self.decrypt_registered_sessions.insert(cache_key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a session from the decrypt worker pool. Mirror of
|
||||
/// `register_decrypt_worker_session`. Idempotent: safe to call on a
|
||||
/// `cache_key` that isn't registered, and safe to call when the
|
||||
/// worker pool is disabled (`FIPS_DECRYPT_WORKERS=0`).
|
||||
///
|
||||
/// Called from two sites that already iterate `peers_by_index`:
|
||||
/// the rekey drain-completion block (after the drain window has
|
||||
/// expired, the old `our_index` is unreachable to any in-flight
|
||||
/// OLD-K packet) and `remove_active_peer` (terminal peer cleanup).
|
||||
/// Without these callers, the per-worker `sessions` HashMap and
|
||||
/// the Node's `decrypt_registered_sessions` set would grow
|
||||
/// monotonically per rekey on long-lived peers.
|
||||
#[cfg(unix)]
|
||||
pub(in crate::node) fn unregister_decrypt_worker_session(
|
||||
&mut self,
|
||||
cache_key: (crate::transport::TransportId, u32),
|
||||
) {
|
||||
if let Some(workers) = self.decrypt_workers.as_ref() {
|
||||
workers.unregister_session(cache_key);
|
||||
}
|
||||
self.decrypt_registered_sessions.remove(&cache_key);
|
||||
}
|
||||
|
||||
/// Snapshot the per-peer FMP recv cipher + replay window for the
|
||||
/// decrypt worker. Returns `None` if the peer / session isn't
|
||||
/// ready. After hand-off the worker is the sole FMP replay-window
|
||||
/// authority for this session.
|
||||
#[cfg(unix)]
|
||||
fn build_owned_session_state(
|
||||
&self,
|
||||
node_addr: &crate::NodeAddr,
|
||||
) -> Option<crate::node::decrypt_worker::OwnedSessionState> {
|
||||
let peer = self.peers.get(node_addr)?;
|
||||
let fmp_session = peer.noise_session()?;
|
||||
let fmp_cipher = fmp_session.recv_cipher_clone()?;
|
||||
let fmp_replay = fmp_session.recv_replay_snapshot_owned();
|
||||
Some(crate::node::decrypt_worker::OwnedSessionState {
|
||||
fmp_cipher,
|
||||
fmp_replay,
|
||||
source_npub: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Increment decrypt failure counter and force-remove peer if threshold exceeded.
|
||||
pub(in crate::node) fn handle_decrypt_failure(&mut self, node_addr: &crate::NodeAddr) {
|
||||
if let Some(peer) = self.peers.get_mut(node_addr) {
|
||||
let count = peer.increment_decrypt_failures();
|
||||
if count >= DECRYPT_FAILURE_THRESHOLD {
|
||||
warn!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
consecutive_failures = count,
|
||||
"Excessive decryption failures, removing peer"
|
||||
);
|
||||
let addr = *node_addr;
|
||||
self.remove_active_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(addr, now_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
//! SessionDatagram forwarding handler.
|
||||
//!
|
||||
//! Handles incoming SessionDatagram (0x00) link messages: decodes the
|
||||
//! envelope, enforces hop limits, performs coordinate cache warming from
|
||||
//! plaintext session-layer headers, routes to the next hop or delivers
|
||||
//! locally, and generates error signals on routing failure.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::node::reject::ForwardingReject;
|
||||
use crate::node::{Node, NodeError, NodeRoutingView};
|
||||
use crate::proto::fsp::wire::{
|
||||
FSP_COMMON_PREFIX_SIZE, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2,
|
||||
FspCommonPrefix, parse_encrypted_coords,
|
||||
};
|
||||
use crate::proto::fsp::{SessionAck, SessionSetup};
|
||||
use crate::proto::link::{SessionDatagram, SessionDatagramRef};
|
||||
use crate::proto::routing::{DropReason, NextHop, RouteAction, RouteOutcome};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
impl Node {
|
||||
/// Handle an incoming SessionDatagram from a peer.
|
||||
///
|
||||
/// Called by `dispatch_link_message` for msg_type 0x00. The payload
|
||||
/// has already had its msg_type byte stripped by dispatch.
|
||||
pub(in crate::node) async fn handle_session_datagram(
|
||||
&mut self,
|
||||
_from: &NodeAddr,
|
||||
payload: &[u8],
|
||||
incoming_ce: bool,
|
||||
) {
|
||||
self.metrics().forwarding.record_received(payload.len());
|
||||
|
||||
let datagram_ref = match SessionDatagramRef::decode(payload) {
|
||||
Ok(dg) => dg,
|
||||
Err(e) => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::DecodeError, payload.len());
|
||||
debug!(error = %e, "Malformed SessionDatagram");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let my_addr = *self.node_addr();
|
||||
|
||||
// Coordinate cache warming from plaintext session-layer headers. Gated
|
||||
// on a non-exhausted TTL so a datagram the core will drop as
|
||||
// TTL-exhausted does not warm the cache, matching the pre-refactor
|
||||
// ordering (warming ran only after the TTL early-return).
|
||||
if datagram_ref.ttl != 0 {
|
||||
self.try_warm_coord_cache_ref(&datagram_ref);
|
||||
}
|
||||
|
||||
// Pre-resolve the next hop only for genuine transit packets (TTL > 0
|
||||
// and not locally destined) so `find_next_hop`'s coord-cache LRU-touch
|
||||
// side effect keeps the same scope it had inline. Warming above has
|
||||
// already run, so the resolution observes freshly cached coords.
|
||||
let next_hop = if datagram_ref.ttl != 0 && datagram_ref.dest_addr != my_addr {
|
||||
self.resolve_next_hop(&datagram_ref.dest_addr)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Read local congestion once and reuse it for both the CE decision
|
||||
// (via the view) and the congestion metric/log below, keeping
|
||||
// `detect_congestion` the single source of truth.
|
||||
let congested = next_hop
|
||||
.as_ref()
|
||||
.map(|nh| self.detect_congestion(&nh.addr))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Borrow the routing tables disjointly from `&mut self.routing` for
|
||||
// the pure decision, then release both before driving the outcome.
|
||||
let outcome = {
|
||||
let view = NodeRoutingView {
|
||||
coord_cache: &self.coord_cache,
|
||||
peers: &self.peers,
|
||||
tree_state: &self.tree_state,
|
||||
congested,
|
||||
};
|
||||
self.routing
|
||||
.route(&datagram_ref, &my_addr, incoming_ce, next_hop, &view)
|
||||
};
|
||||
|
||||
match outcome {
|
||||
RouteOutcome::Drop {
|
||||
reason: DropReason::TtlExhausted,
|
||||
} => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::TtlExhausted, payload.len());
|
||||
debug!(
|
||||
src = %datagram_ref.src_addr,
|
||||
dest = %datagram_ref.dest_addr,
|
||||
"SessionDatagram TTL exhausted, dropping"
|
||||
);
|
||||
}
|
||||
RouteOutcome::DeliverLocal => {
|
||||
// Local delivery: dispatch to session layer handlers without
|
||||
// materializing an owned SessionDatagram payload Vec.
|
||||
self.metrics().forwarding.record_delivered(payload.len());
|
||||
self.handle_session_payload(
|
||||
&datagram_ref.src_addr,
|
||||
datagram_ref.payload,
|
||||
datagram_ref.path_mtu,
|
||||
incoming_ce,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
RouteOutcome::NoRoute => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::NoRoute, payload.len());
|
||||
let original = datagram_ref.into_owned();
|
||||
debug!(
|
||||
src = %self.peer_display_name(&original.src_addr),
|
||||
dest = %self.peer_display_name(&original.dest_addr),
|
||||
bytes = payload.len(),
|
||||
"Dropping transit SessionDatagram: no route to destination"
|
||||
);
|
||||
self.send_routing_error(&original).await;
|
||||
}
|
||||
RouteOutcome::Forward {
|
||||
next_hop,
|
||||
bytes,
|
||||
outgoing_ce,
|
||||
} => {
|
||||
let dest = datagram_ref.dest_addr;
|
||||
|
||||
// ECN CE relay: congestion was detected locally above; emit the
|
||||
// metric and rate-limited log at the transit chokepoint.
|
||||
if congested {
|
||||
self.metrics().congestion.congestion_detected.inc();
|
||||
let now = Instant::now();
|
||||
let should_log = self
|
||||
.last_congestion_log
|
||||
.map(|t| now.duration_since(t) >= Duration::from_secs(5))
|
||||
.unwrap_or(true);
|
||||
if should_log {
|
||||
self.last_congestion_log = Some(now);
|
||||
debug!(next_hop = %next_hop, "Congestion detected, CE flag set on forwarded packet");
|
||||
}
|
||||
}
|
||||
|
||||
match self
|
||||
.send_encrypted_link_message_with_ce(&next_hop, &bytes, outgoing_ce)
|
||||
.await
|
||||
{
|
||||
Err(NodeError::MtuExceeded { mtu, .. }) => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::MtuExceeded, payload.len());
|
||||
self.send_mtu_exceeded_error(dest, datagram_ref.src_addr, mtu)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::SendError, payload.len());
|
||||
debug!(
|
||||
next_hop = %next_hop,
|
||||
dest = %dest,
|
||||
error = %e,
|
||||
"Failed to forward SessionDatagram"
|
||||
);
|
||||
}
|
||||
Ok(()) => {
|
||||
self.metrics().forwarding.record_forwarded(bytes.len());
|
||||
// Classify this transit forward by route class (partition
|
||||
// of forwarded_packets). Done here, at the data-plane
|
||||
// chokepoint, so the error-signal routing callers of
|
||||
// find_next_hop are excluded.
|
||||
let class = self.classify_forward(&dest, &next_hop);
|
||||
self.metrics().forwarding.record_route_class(class);
|
||||
if outgoing_ce {
|
||||
self.metrics().congestion.ce_forwarded.inc();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the next hop toward `dest` into its address plus the outgoing
|
||||
/// link's transport MTU. Returns `None` when there is no route.
|
||||
///
|
||||
/// The MTU defaults to `u16::MAX` (a no-op min-fold) when the peer's
|
||||
/// transport is not resolvable, matching the pre-refactor inline behavior
|
||||
/// where the MTU `if let` chain simply did not fire.
|
||||
fn resolve_next_hop(&mut self, dest: &NodeAddr) -> Option<NextHop> {
|
||||
let addr = *self.find_next_hop(dest)?.node_addr();
|
||||
let link_mtu = if let Some(peer) = self.peers.get(&addr)
|
||||
&& let Some(tid) = peer.transport_id()
|
||||
&& let Some(transport) = self.transports.get(&tid)
|
||||
{
|
||||
match peer.current_addr() {
|
||||
Some(link_addr) => transport.link_mtu(link_addr),
|
||||
None => transport.mtu(),
|
||||
}
|
||||
} else {
|
||||
u16::MAX
|
||||
};
|
||||
Some(NextHop { addr, link_mtu })
|
||||
}
|
||||
|
||||
/// Attempt to warm the coordinate cache from session-layer payload headers.
|
||||
///
|
||||
/// Transit routers parse the 4-byte FSP common prefix to identify message
|
||||
/// type, then extract plaintext coordinate fields from:
|
||||
/// - SessionSetup (phase 0x1): src_coords + dest_coords
|
||||
/// - SessionAck (phase 0x2): src_coords
|
||||
/// - Encrypted with CP flag (phase 0x0): cleartext coords between header and ciphertext
|
||||
///
|
||||
/// Decode failures are logged and silently ignored — they don't block
|
||||
/// forwarding.
|
||||
fn try_warm_coord_cache_ref(&mut self, datagram: &SessionDatagramRef<'_>) {
|
||||
let prefix = match FspCommonPrefix::parse(datagram.payload) {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let inner = &datagram.payload[FSP_COMMON_PREFIX_SIZE..];
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
match prefix.phase {
|
||||
FSP_PHASE_MSG1 => match SessionSetup::decode(inner) {
|
||||
Ok(setup) => {
|
||||
self.coord_cache_mut()
|
||||
.insert(datagram.src_addr, setup.src_coords, now_ms);
|
||||
self.coord_cache_mut()
|
||||
.insert(datagram.dest_addr, setup.dest_coords, now_ms);
|
||||
debug!(
|
||||
src = %datagram.src_addr,
|
||||
dest = %datagram.dest_addr,
|
||||
"Cached coords from SessionSetup"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, "Failed to decode SessionSetup for cache warming");
|
||||
}
|
||||
},
|
||||
FSP_PHASE_MSG2 => match SessionAck::decode(inner) {
|
||||
Ok(ack) => {
|
||||
self.coord_cache_mut()
|
||||
.insert(datagram.src_addr, ack.src_coords, now_ms);
|
||||
self.coord_cache_mut()
|
||||
.insert(datagram.dest_addr, ack.dest_coords, now_ms);
|
||||
debug!(
|
||||
src = %datagram.src_addr,
|
||||
dest = %datagram.dest_addr,
|
||||
"Cached coords from SessionAck"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, "Failed to decode SessionAck for cache warming");
|
||||
}
|
||||
},
|
||||
FSP_PHASE_ESTABLISHED if prefix.has_coords() => {
|
||||
// CP flag set: coords in cleartext between header and ciphertext.
|
||||
// Parse coords from the cleartext section after the 12-byte header.
|
||||
// inner starts after the 4-byte prefix, so we need 8 more bytes
|
||||
// for the counter (header is 12 total = 4 prefix + 8 counter).
|
||||
let coord_data = &datagram.payload[FSP_HEADER_SIZE..];
|
||||
match parse_encrypted_coords(coord_data) {
|
||||
Ok((src_coords, dest_coords, _bytes_consumed)) => {
|
||||
if let Some(coords) = src_coords {
|
||||
self.coord_cache_mut()
|
||||
.insert(datagram.src_addr, coords, now_ms);
|
||||
}
|
||||
if let Some(coords) = dest_coords {
|
||||
self.coord_cache_mut()
|
||||
.insert(datagram.dest_addr, coords, now_ms);
|
||||
}
|
||||
debug!(
|
||||
src = %datagram.src_addr,
|
||||
dest = %datagram.dest_addr,
|
||||
"Cached coords from encrypted message"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, "Failed to parse coords for cache warming");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Phase 0x0 without CP, error signals, unknown: no coords to cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate and send a routing error signal back to the datagram's source.
|
||||
///
|
||||
/// If we have cached coords for the destination, send PathBroken (we know
|
||||
/// where it is but can't reach it). Otherwise send CoordsRequired (we
|
||||
/// don't know where it is).
|
||||
///
|
||||
/// If we can't route the error back to the source either, drop silently.
|
||||
/// No cascading errors.
|
||||
async fn send_routing_error(&mut self, original: &SessionDatagram) {
|
||||
let my_addr = *self.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);
|
||||
let default_ttl = self.config().node.session.default_ttl;
|
||||
|
||||
// Pure decision: rate-limit gate + PathBroken/CoordsRequired choice +
|
||||
// error-PDU encode. Borrow the routing tables disjointly from
|
||||
// `&mut self.routing`, then release them before the reverse-hop lookup.
|
||||
let action = {
|
||||
let view = NodeRoutingView {
|
||||
coord_cache: &self.coord_cache,
|
||||
peers: &self.peers,
|
||||
tree_state: &self.tree_state,
|
||||
congested: false,
|
||||
};
|
||||
self.routing.synth_routing_error(
|
||||
&original.dest_addr,
|
||||
&original.src_addr,
|
||||
&my_addr,
|
||||
&view,
|
||||
now_ms,
|
||||
default_ttl,
|
||||
)
|
||||
};
|
||||
let RouteAction::SendError { toward, bytes } = match action {
|
||||
Some(action) => action,
|
||||
// Rate limited: drop silently. No cascading errors.
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Resolve the reverse link hop only now, after the gate passed, so
|
||||
// `find_next_hop`'s coord-cache touch keeps its pre-refactor scope.
|
||||
let next_hop_addr = match self.find_next_hop(&toward) {
|
||||
Some(peer) => *peer.node_addr(),
|
||||
None => {
|
||||
debug!(
|
||||
src = %original.src_addr,
|
||||
dest = %original.dest_addr,
|
||||
"Cannot route error signal back to source, dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = self
|
||||
.send_encrypted_link_message(&next_hop_addr, &bytes)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
next_hop = %next_hop_addr,
|
||||
error = %e,
|
||||
"Failed to send routing error signal"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
original_dest = %original.dest_addr,
|
||||
error_dest = %original.src_addr,
|
||||
"Sent routing error signal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate and send an MtuExceeded error signal back to the datagram's source.
|
||||
///
|
||||
/// Called when `send_encrypted_link_message()` fails with
|
||||
/// `NodeError::MtuExceeded` during forwarding. The signal tells the
|
||||
/// source the bottleneck MTU so it can immediately reduce its path MTU.
|
||||
///
|
||||
/// `dest` is the failed datagram's destination (rate-limit key); `toward`
|
||||
/// is its source, where the signal is routed back.
|
||||
async fn send_mtu_exceeded_error(
|
||||
&mut self,
|
||||
dest: NodeAddr,
|
||||
toward: NodeAddr,
|
||||
bottleneck_mtu: u16,
|
||||
) {
|
||||
let my_addr = *self.node_addr();
|
||||
let now_ms = Self::now_ms();
|
||||
let default_ttl = self.config().node.session.default_ttl;
|
||||
|
||||
// Pure decision: rate-limit gate + MtuExceeded PDU + encode.
|
||||
let action = self.routing.synth_mtu_exceeded(
|
||||
&dest,
|
||||
&toward,
|
||||
&my_addr,
|
||||
bottleneck_mtu,
|
||||
now_ms,
|
||||
default_ttl,
|
||||
);
|
||||
let RouteAction::SendError { toward, bytes } = match action {
|
||||
Some(action) => action,
|
||||
// Rate limited: drop silently. No cascading errors.
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Resolve the reverse link hop only now, after the gate passed, so
|
||||
// `find_next_hop`'s coord-cache touch keeps its pre-refactor scope.
|
||||
let next_hop_addr = match self.find_next_hop(&toward) {
|
||||
Some(peer) => *peer.node_addr(),
|
||||
None => {
|
||||
debug!(
|
||||
src = %toward,
|
||||
dest = %dest,
|
||||
"Cannot route MtuExceeded signal back to source, dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = self
|
||||
.send_encrypted_link_message(&next_hop_addr, &bytes)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
next_hop = %next_hop_addr,
|
||||
error = %e,
|
||||
"Failed to send MtuExceeded error signal"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
original_dest = %dest,
|
||||
error_dest = %toward,
|
||||
bottleneck_mtu,
|
||||
"Sent MtuExceeded error signal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect congestion for CE marking on forwarded datagrams.
|
||||
///
|
||||
/// Checks two signal sources:
|
||||
/// 1. Outgoing link MMP metrics (loss rate, ETX) against configured thresholds
|
||||
/// 2. Local transport congestion (kernel drops on any transport)
|
||||
///
|
||||
/// Returns `true` if any signal indicates congestion.
|
||||
pub(in crate::node) fn detect_congestion(&self, next_hop: &NodeAddr) -> bool {
|
||||
if !self.config().node.ecn.enabled {
|
||||
return false;
|
||||
}
|
||||
// Outgoing link MMP metrics
|
||||
if let Some(peer) = self.peers.get(next_hop)
|
||||
&& let Some(mmp) = peer.mmp()
|
||||
{
|
||||
let metrics = &mmp.metrics;
|
||||
if metrics.loss_rate() >= self.config().node.ecn.loss_threshold
|
||||
|| metrics.etx >= self.config().node.ecn.etx_threshold
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Local transport congestion (kernel drops)
|
||||
self.transport_drops.values().any(|s| s.dropping)
|
||||
}
|
||||
|
||||
/// Sample transport congestion indicators.
|
||||
///
|
||||
/// Called from the tick handler (1s interval). For each transport,
|
||||
/// queries the cumulative kernel drop counter and sets the `dropping`
|
||||
/// flag if new drops occurred since the previous sample.
|
||||
pub(in crate::node) fn sample_transport_congestion(&mut self) {
|
||||
let mut new_drop_events = Vec::new();
|
||||
for (&tid, transport) in &self.transports {
|
||||
let congestion = transport.congestion();
|
||||
let state = self.transport_drops.entry(tid).or_default();
|
||||
if let Some(current) = congestion.recv_drops {
|
||||
let new_drops = current > state.prev_drops;
|
||||
if new_drops && !state.dropping {
|
||||
new_drop_events.push(tid);
|
||||
}
|
||||
state.dropping = new_drops;
|
||||
state.prev_drops = current;
|
||||
}
|
||||
}
|
||||
for tid in new_drop_events {
|
||||
self.metrics().congestion.kernel_drop_events.inc();
|
||||
warn!(
|
||||
transport_id = tid.as_u32(),
|
||||
"Kernel recv drops first observed on transport"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
//! RX event loop and message handlers.
|
||||
//! Message handlers: per-message-type behavior on `impl Node`.
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod connected_udp;
|
||||
mod dispatch;
|
||||
mod encrypted;
|
||||
mod forwarding;
|
||||
mod handshake;
|
||||
pub(crate) mod lookup;
|
||||
mod mmp;
|
||||
mod rekey;
|
||||
mod rx_loop;
|
||||
pub(in crate::node) mod session;
|
||||
mod timeout;
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
//! RX event loop and packet dispatch.
|
||||
|
||||
use crate::control::{ControlSocket, commands};
|
||||
use crate::node::{Node, NodeError};
|
||||
use crate::proto::fmp::wire::{
|
||||
COMMON_PREFIX_SIZE, CommonPrefix, FMP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2,
|
||||
};
|
||||
use crate::transport::ReceivedPacket;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Inside the packet_rx burst drain, run a fallback drain every
|
||||
/// N packets so bounced FMP plaintexts can't sit behind a full
|
||||
/// 256-packet UDP burst. Used on unix; on Windows the decrypt-worker
|
||||
/// pool isn't spawned so the fallback channel is always empty —
|
||||
/// hold the constants at module scope anyway so the burst-loop
|
||||
/// dispatch in `run_rx_loop` doesn't need a `#[cfg]` on every site.
|
||||
const FALLBACK_INTERLEAVE_EVERY: usize = 32;
|
||||
/// How many fallback events to drain per interleave step. Bounded so
|
||||
/// the inner loop can keep making forward progress on packet_rx.
|
||||
#[cfg(unix)]
|
||||
const FALLBACK_INTERLEAVE_BUDGET: usize = 32;
|
||||
|
||||
impl Node {
|
||||
/// Run the receive event loop.
|
||||
///
|
||||
/// Processes packets from all transports, dispatching based on
|
||||
/// the phase field in the 4-byte common prefix:
|
||||
/// - Phase 0x0: Encrypted frame (session data)
|
||||
/// - Phase 0x1: Handshake message 1 (initiator -> responder)
|
||||
/// - Phase 0x2: Handshake message 2 (responder -> initiator)
|
||||
///
|
||||
/// Also processes outbound IPv6 packets from the TUN reader for session
|
||||
/// encapsulation and routing through the mesh.
|
||||
///
|
||||
/// Also processes DNS-resolved identities for identity cache population.
|
||||
///
|
||||
/// Also runs a periodic tick (1s) to clean up stale handshake connections
|
||||
/// that never received a response. This prevents resource leaks when peers
|
||||
/// are unreachable.
|
||||
///
|
||||
/// This method takes ownership of the packet_rx channel and runs
|
||||
/// until the channel is closed (typically when stop() is called).
|
||||
pub async fn run_rx_loop(&mut self) -> Result<(), NodeError> {
|
||||
let mut packet_rx = self.packet_rx.take().ok_or(NodeError::NotStarted)?;
|
||||
|
||||
// Take the TUN outbound receiver, or create a dummy channel that never
|
||||
// produces messages (when TUN is disabled). Holding the sender prevents
|
||||
// the channel from closing.
|
||||
let (mut tun_outbound_rx, _tun_guard) = match self.tun_outbound_rx.take() {
|
||||
Some(rx) => (rx, None),
|
||||
None => {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
(rx, Some(tx))
|
||||
}
|
||||
};
|
||||
|
||||
// Take the DNS identity receiver, or create a dummy channel (when DNS
|
||||
// is disabled). Same pattern as TUN outbound.
|
||||
let (mut dns_identity_rx, _dns_guard) = match self.dns_identity_rx.take() {
|
||||
Some(rx) => (rx, None),
|
||||
None => {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
(rx, Some(tx))
|
||||
}
|
||||
};
|
||||
|
||||
let mut tick =
|
||||
tokio::time::interval(Duration::from_secs(self.config().node.tick_interval_secs));
|
||||
|
||||
// Set up control socket channel
|
||||
let (control_tx, mut control_rx) =
|
||||
tokio::sync::mpsc::channel::<crate::control::ControlMessage>(32);
|
||||
|
||||
if self.config().node.control.enabled {
|
||||
let config = self.config().node.control.clone();
|
||||
let tx = control_tx.clone();
|
||||
let read_handle = self.control_read_handle();
|
||||
tokio::spawn(async move {
|
||||
match ControlSocket::bind(&config) {
|
||||
Ok(socket) => {
|
||||
socket.accept_loop(tx, read_handle).await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Failed to bind control socket");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Drop unused sender to avoid keeping channel open if control is disabled
|
||||
drop(control_tx);
|
||||
|
||||
// Decrypt-worker fallback receiver. The worker pushes each
|
||||
// authenticated FMP plaintext here so rx_loop can finish the
|
||||
// per-peer side-effects (stats, MMP, ECN, link dispatch).
|
||||
// Always declared so the `tokio::select!` arm doesn't need
|
||||
// a `cfg` (which the macro doesn't support); on Windows the
|
||||
// channel just never sees events.
|
||||
let (mut decrypt_fallback_rx, _decrypt_fallback_guard) = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
match self.decrypt_fallback_rx.take() {
|
||||
Some(rx) => (rx, None),
|
||||
None => {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(rx, Some(tx))
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// On non-unix nothing ever sends, but the macro arm
|
||||
// still needs an existing rx. Keep the sender alive to
|
||||
// avoid the channel closing into an Err loop.
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<()>();
|
||||
(rx, Some(tx))
|
||||
}
|
||||
};
|
||||
|
||||
info!("RX event loop started");
|
||||
// Optional per-stage perf profiler (FIPS_PERF=1). No-op otherwise.
|
||||
crate::perf_profile::maybe_spawn_reporter();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
// Decrypt-worker fallback drains FIRST. Under sustained
|
||||
// inbound bursts the packet_rx drain (up to 256 packets)
|
||||
// can starve fallback work for tens of ms — TCP doesn't
|
||||
// tolerate that (late ACKs → dup-ACK fast retransmits →
|
||||
// cwnd collapse). Promoting fallback gives the kernel's
|
||||
// TCP machinery a fair chance to ACK in time.
|
||||
Some(event) = decrypt_fallback_rx.recv() => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
self.process_decrypt_worker_event(event).await;
|
||||
let mut drained = 0;
|
||||
while drained < 255 {
|
||||
match decrypt_fallback_rx.try_recv() {
|
||||
Ok(ev) => {
|
||||
self.process_decrypt_worker_event(ev).await;
|
||||
drained += 1;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = event;
|
||||
}
|
||||
packet = packet_rx.recv() => {
|
||||
match packet {
|
||||
Some(p) => self.process_packet(p).await,
|
||||
None => break, // channel closed
|
||||
}
|
||||
// Drain remaining ready inbound packets in a tight loop
|
||||
// before yielding back to select! — every yield is a
|
||||
// futex hop on tokio's multi-thread scheduler, and at
|
||||
// line rate the kernel UDP queue typically has several
|
||||
// datagrams available per wake. Caps at a batch
|
||||
// boundary so other branches (tick, control) eventually
|
||||
// get a turn even under sustained load.
|
||||
//
|
||||
// **Interleave fallback drain** every N packets so
|
||||
// bounced FMP plaintexts (heartbeats, post-FMP-
|
||||
// decrypt forwarding payloads, control frames) don't
|
||||
// sit in the fallback queue for a full 256-packet
|
||||
// burst. Even with the priority-first ordering of
|
||||
// the outer select!, once we're inside this inner
|
||||
// loop only this interleave can free queued
|
||||
// fallbacks. On multihop forwarding paths this is
|
||||
// the difference between back-to-back encrypt-
|
||||
// worker dispatches happening promptly vs piling up
|
||||
// behind the rx burst.
|
||||
let mut drained: usize = 1; // count the packet processed above
|
||||
while drained < 256 {
|
||||
if drained.is_multiple_of(FALLBACK_INTERLEAVE_EVERY) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut fb_drained = 0;
|
||||
while fb_drained < FALLBACK_INTERLEAVE_BUDGET {
|
||||
match decrypt_fallback_rx.try_recv() {
|
||||
Ok(ev) => {
|
||||
self.process_decrypt_worker_event(ev).await;
|
||||
fb_drained += 1;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match packet_rx.try_recv() {
|
||||
Ok(p) => {
|
||||
self.process_packet(p).await;
|
||||
drained += 1;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
// Trailing fallback drain so the last bounced
|
||||
// packets of the burst aren't held up by the
|
||||
// next select! iteration.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut fb_drained = 0;
|
||||
while fb_drained < 256 {
|
||||
match decrypt_fallback_rx.try_recv() {
|
||||
Ok(ev) => {
|
||||
self.process_decrypt_worker_event(ev).await;
|
||||
fb_drained += 1;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ipv6_packet) = tun_outbound_rx.recv() => {
|
||||
self.handle_tun_outbound(ipv6_packet).await;
|
||||
let mut drained = 0;
|
||||
while drained < 256 {
|
||||
match tun_outbound_rx.try_recv() {
|
||||
Ok(p) => {
|
||||
self.handle_tun_outbound(p).await;
|
||||
drained += 1;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(identity) = dns_identity_rx.recv() => {
|
||||
debug!(
|
||||
node_addr = %identity.node_addr,
|
||||
"Registering identity from DNS resolution"
|
||||
);
|
||||
self.register_identity(identity.node_addr, identity.pubkey);
|
||||
}
|
||||
Some((request, response_tx)) = control_rx.recv() => {
|
||||
// Only mutating COMMAND requests (`connect` / `disconnect`)
|
||||
// reach the rx_loop now. Every pure-read `show_*` query is
|
||||
// served off-loop from the read handle in the control accept
|
||||
// task (`snapshot_dispatch`), so it never round-trips here —
|
||||
// the data-plane dispatch path carries no `show_*` arm. A
|
||||
// `show_*` that somehow arrives (none does) falls through to
|
||||
// `commands::dispatch`, which returns "unknown command".
|
||||
let response = commands::dispatch(
|
||||
self,
|
||||
&request.command,
|
||||
request.params.as_ref(),
|
||||
).await;
|
||||
let _ = response_tx.send(response);
|
||||
}
|
||||
_ = tick.tick() => {
|
||||
self.check_timeouts();
|
||||
let now_ms = Self::now_ms();
|
||||
self.reload_peer_acl().await;
|
||||
// The host map hot-reloads on the same tick as the ACL. It
|
||||
// is polled separately from `reload_peer_acl` because the
|
||||
// ACL's embedded alias reloader and this snapshot are
|
||||
// distinct resources; the `path_mtu_lookup` cache and the
|
||||
// `nostr_rendezvous` subsystem are deliberately excluded
|
||||
// from `Reloadable` since neither reloads from a backing
|
||||
// file (see `node::reloadable`).
|
||||
self.reload_host_map().await;
|
||||
self.poll_pending_connects().await;
|
||||
self.poll_nostr_rendezvous().await;
|
||||
self.poll_lan_rendezvous().await;
|
||||
self.resend_pending_handshakes(now_ms).await;
|
||||
self.resend_pending_rekeys(now_ms).await;
|
||||
self.resend_pending_session_handshakes(now_ms).await;
|
||||
self.resend_pending_session_msg3(now_ms).await;
|
||||
self.purge_idle_sessions(now_ms);
|
||||
self.process_pending_retries(now_ms).await;
|
||||
self.check_tree_state().await;
|
||||
self.check_bloom_state().await;
|
||||
self.compute_mesh_size();
|
||||
self.record_stats_history();
|
||||
self.check_mmp_reports().await;
|
||||
self.check_session_mmp_reports().await;
|
||||
self.check_link_heartbeats().await;
|
||||
self.check_rekey().await;
|
||||
self.check_session_rekey().await;
|
||||
self.check_pending_lookups(now_ms).await;
|
||||
self.poll_transport_discovery().await;
|
||||
self.sample_transport_congestion();
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
self.activate_connected_udp_sessions().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("RX event loop stopped (channel closed)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process a single received packet.
|
||||
///
|
||||
/// Dispatches based on the phase field in the 4-byte common prefix.
|
||||
async fn process_packet(&mut self, packet: ReceivedPacket) {
|
||||
if packet.data.len() < COMMON_PREFIX_SIZE {
|
||||
return; // Drop packets too short for common prefix
|
||||
}
|
||||
|
||||
let prefix = match CommonPrefix::parse(&packet.data) {
|
||||
Some(p) => p,
|
||||
None => return, // Malformed prefix
|
||||
};
|
||||
|
||||
if prefix.version != FMP_VERSION {
|
||||
debug!(
|
||||
version = prefix.version,
|
||||
transport_id = %packet.transport_id,
|
||||
"Unknown FMP version, dropping"
|
||||
);
|
||||
|
||||
// If the packet arrived on an adopted Nostr-NAT bootstrap
|
||||
// transport, the originating peer is necessarily on a
|
||||
// different FMP-protocol version than us — the discovery
|
||||
// sweep would otherwise re-traverse them every cycle even
|
||||
// though no msg1/msg2 exchange can ever succeed. Bump the
|
||||
// discovery-layer cooldown to the long protocol-mismatch
|
||||
// window and emit a single WARN per fresh observation.
|
||||
if self
|
||||
.nostr_rendezvous
|
||||
.is_bootstrap_transport(&packet.transport_id)
|
||||
&& let Some(npub) = self
|
||||
.nostr_rendezvous
|
||||
.bootstrap_transport_npub(&packet.transport_id)
|
||||
.cloned()
|
||||
&& let Some(handle) = self.nostr_rendezvous_handle()
|
||||
{
|
||||
let now_ms = Self::now_ms();
|
||||
let cooldown_secs = handle.protocol_mismatch_cooldown_secs();
|
||||
if handle.record_protocol_mismatch(&npub, now_ms) {
|
||||
warn!(
|
||||
peer_npub = %npub,
|
||||
transport_id = %packet.transport_id,
|
||||
peer_version = prefix.version,
|
||||
our_version = FMP_VERSION,
|
||||
cooldown_secs,
|
||||
"Nostr-discovered peer speaks a different FMP version; suppressing retraversal"
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match prefix.phase {
|
||||
PHASE_ESTABLISHED => {
|
||||
self.handle_encrypted_frame(packet).await;
|
||||
}
|
||||
PHASE_MSG1 => {
|
||||
self.handle_msg1(packet).await;
|
||||
}
|
||||
PHASE_MSG2 => {
|
||||
self.handle_msg2(packet).await;
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
phase = prefix.phase,
|
||||
transport_id = %packet.transport_id,
|
||||
"Unknown FMP phase, dropping"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user