Files
fips/src/node/handlers/discovery.rs
T
Johnathan Corgan 04d9fd625d FSP wire format revision and session-layer MMP implementation
FSP wire format revision (TASK-2026-0007):

Introduce the FIPS Session Protocol (FSP) wire format with a 4-byte
common prefix [ver_phase:1][flags:1][payload_len:2 LE] replacing the
old 1-byte msg_type dispatch. All session messages share this prefix
with phase-based dispatch (Established, Setup, Ack, Unencrypted).

- New session_wire.rs: FSP constants, header types, parse/build helpers
- SessionMessageType enum: DataPacket (0x10), SenderReport (0x11),
  ReceiverReport (0x12), PathMtuNotification (0x13)
- FspFlags (CP/K/U) and FspInnerFlags (SP) for flag management
- SessionSenderReport, SessionReceiverReport, PathMtuNotification
  message structs with encode/decode
- FSP send pipeline: 12-byte header as AAD, 6-byte inner header
  (timestamp + msg_type + inner_flags), encrypt_with_aad()
- FSP receive pipeline: parse header, extract cleartext coords (CP),
  AEAD decrypt with AAD, strip inner header, msg_type dispatch
- Forwarding: transit nodes parse cleartext coords without decryption
- Removed DataPacket struct and associated types
- SessionEntry: session_start_ms, mark_established(), session_timestamp()
- FIPS_OVERHEAD: 144 → 150 bytes (+6 for FSP inner header)
- Design docs updated for new wire format

Session-layer MMP implementation (TASK-2026-0008):

Implement complete session-layer MMP reusing the link-layer algorithm
modules (SenderState, ReceiverState, MmpMetrics, SpinBitState) with
independent configuration and higher report interval clamps.

- SessionMmpConfig: separate config section (node.session_mmp.*)
- MmpSessionState: session-specific wrapper with PathMtuState tracking
- Session-layer constants (500ms-10s report intervals, 1s cold start)
- Parameterized interval methods (new_with_cold_start,
  update_report_interval_with_bounds) on SenderState/ReceiverState
- Bidirectional From conversions between link/session report types
- SessionEntry: mmp and is_initiator fields, initialized on Established
- send_session_msg() for reports/notifications
- Per-message RX recording with spin bit state tracking
- Handlers for SenderReport, ReceiverReport, PathMtuNotification
- path_mtu threaded from SessionDatagram envelope through to handlers
- check_session_mmp_reports() tick handler with collect-then-send pattern
- Periodic and teardown operator logging for session metrics
- PathMtuState: destination observes incoming MTU on all session messages,
  source seeded from outbound transport MTU, decrease-immediate /
  increase-requires-3-consecutive rules

Link-layer MMP fix:

- Stop feeding spin bit RTT samples into SRTT estimator; inter-frame
  timing in the mesh is irregular, inflating spin-bit RTT by variable
  processing delays; timestamp-echo provides accurate RTT

29 files changed, 602 tests pass, 0 clippy warnings.
2026-02-19 03:15:05 +00:00

360 lines
13 KiB
Rust

//! LookupRequest/LookupResponse discovery protocol handlers.
//!
//! Handles coordinate discovery requests: flood-based lookup with TTL,
//! visited filter for loop prevention, and reverse-path forwarding for
//! responses.
use crate::node::{Node, RecentRequest};
use crate::protocol::{LookupRequest, LookupResponse};
use crate::NodeAddr;
use tracing::{debug, trace};
impl Node {
/// Handle an incoming LookupRequest from a peer.
///
/// Processing steps:
/// 1. Decode and validate
/// 2. Check request_id for duplicates (dedup)
/// 3. Record request for reverse-path forwarding
/// 4. Lazy purge expired entries
/// 5. Check visited filter (loop prevention)
/// 6. If we're the target, generate and send response
/// 7. If TTL > 0, forward to peers not in visited filter
pub(in crate::node) async fn handle_lookup_request(
&mut self,
from: &NodeAddr,
payload: &[u8],
) {
let request = match LookupRequest::decode(payload) {
Ok(req) => req,
Err(e) => {
debug!(from = %from, error = %e, "Malformed LookupRequest");
return;
}
};
let now_ms = Self::now_ms();
// Dedup: drop if we've already seen this request_id
if self.recent_requests.contains_key(&request.request_id) {
trace!(
request_id = request.request_id,
from = %from,
"Duplicate LookupRequest, dropping"
);
return;
}
// Record for reverse-path forwarding and dedup
self.recent_requests.insert(
request.request_id,
RecentRequest::new(*from, now_ms),
);
// Lazy purge expired entries
self.purge_expired_requests(now_ms);
// Loop prevention: drop if we've already been visited
if request.was_visited(self.node_addr()) {
trace!(
request_id = request.request_id,
target = %request.target,
"Already visited, dropping LookupRequest"
);
return;
}
// Are we the target?
if request.target == *self.node_addr() {
debug!(
request_id = request.request_id,
origin = %request.origin,
"We are the lookup target, generating response"
);
self.send_lookup_response(&request).await;
return;
}
// Forward if TTL permits
if request.can_forward() {
self.forward_lookup_request(request).await;
} else {
trace!(
request_id = request.request_id,
target = %request.target,
"LookupRequest TTL exhausted, not forwarding"
);
}
}
/// Handle an incoming LookupResponse from a peer.
///
/// Processing steps:
/// 1. Decode and validate
/// 2. Check recent_requests to determine if we originated or are forwarding
/// 3. If originator: cache target_coords in coord_cache
/// 4. If transit: reverse-path forward to from_peer
pub(in crate::node) async fn handle_lookup_response(
&mut self,
from: &NodeAddr,
payload: &[u8],
) {
let response = match LookupResponse::decode(payload) {
Ok(resp) => resp,
Err(e) => {
debug!(from = %from, error = %e, "Malformed LookupResponse");
return;
}
};
let now_ms = Self::now_ms();
// Check if we forwarded this request (transit node) or originated it
if let Some(recent) = self.recent_requests.get(&response.request_id) {
// Transit node: reverse-path forward
let from_peer = recent.from_peer;
debug!(
request_id = response.request_id,
target = %response.target,
next_hop = %from_peer,
"Reverse-path forwarding LookupResponse"
);
let encoded = response.encode();
if let Err(e) = self.send_encrypted_link_message(&from_peer, &encoded).await {
debug!(
next_hop = %from_peer,
error = %e,
"Failed to forward LookupResponse"
);
}
} else {
// We originated this request — cache the discovered coordinates
debug!(
request_id = response.request_id,
target = %response.target,
depth = response.target_coords.depth(),
"Received LookupResponse, caching route"
);
let target = response.target;
self.coord_cache.insert(
target,
response.target_coords,
now_ms,
);
// Clean up pending lookup tracking
self.pending_lookups.remove(&target);
// If an established session exists, reset the warmup counter.
// Discovery has completed and transit nodes along the response
// path now have fresh coords. Reset warmup so the next N
// data packets include COORDS_PRESENT to re-warm the forward path.
if let Some(entry) = self.sessions.get_mut(&target)
&& entry.is_established()
{
let n = self.config.node.session.coords_warmup_packets;
entry.set_coords_warmup_remaining(n);
debug!(
dest = %target,
warmup_packets = n,
"Reset coords warmup after discovery for existing session"
);
}
// If we have pending TUN packets for this target, retry session
// initiation. The coord_cache now has coords, so find_next_hop()
// should succeed.
if self.pending_tun_packets.contains_key(&target) {
self.retry_session_after_discovery(target).await;
}
}
}
/// Generate and send a LookupResponse when we are the target.
///
/// Signs a proof using our identity and routes the response toward
/// the origin. The first hop uses find_next_hop; subsequent hops use
/// reverse-path forwarding via recent_requests.
async fn send_lookup_response(&mut self, request: &LookupRequest) {
let our_coords = self.tree_state().my_coords().clone();
// Sign proof: Identity::sign hashes with SHA-256 internally
let proof_data = LookupResponse::proof_bytes(request.request_id, &request.target);
let proof = self.identity().sign(&proof_data);
let response = LookupResponse::new(
request.request_id,
request.target,
our_coords,
proof,
);
// Route toward origin
let next_hop_addr = match self.find_next_hop(&request.origin) {
Some(peer) => *peer.node_addr(),
None => {
// Origin might be our direct peer that sent us the request
// Check if origin == the peer we received from
if let Some(recent) = self.recent_requests.get(&request.request_id) {
recent.from_peer
} else {
debug!(
origin = %request.origin,
"Cannot route LookupResponse: no path to origin"
);
return;
}
}
};
debug!(
request_id = request.request_id,
origin = %request.origin,
next_hop = %next_hop_addr,
"Sending LookupResponse"
);
let encoded = response.encode();
if let Err(e) = self.send_encrypted_link_message(&next_hop_addr, &encoded).await {
debug!(
next_hop = %next_hop_addr,
error = %e,
"Failed to send LookupResponse"
);
}
}
/// Forward a LookupRequest to peers not in the visited filter.
///
/// Decrements TTL, adds self to visited, and sends to all eligible peers.
async fn forward_lookup_request(&mut self, mut request: LookupRequest) {
if !request.forward(self.node_addr()) {
return;
}
// Collect peers not in visited filter
let forward_to: Vec<NodeAddr> = self
.peers
.keys()
.filter(|addr| !request.was_visited(addr))
.copied()
.collect();
if forward_to.is_empty() {
trace!(
request_id = request.request_id,
"No eligible peers to forward LookupRequest"
);
return;
}
debug!(
request_id = request.request_id,
target = %request.target,
ttl = request.ttl,
peer_count = forward_to.len(),
"Forwarding LookupRequest"
);
let encoded = request.encode();
for peer_addr in forward_to {
if let Err(e) = self.send_encrypted_link_message(&peer_addr, &encoded).await {
debug!(
peer = %peer_addr,
error = %e,
"Failed to forward LookupRequest to peer"
);
}
}
}
/// Initiate a discovery lookup for a target node.
///
/// Creates a LookupRequest and floods it to all peers. The originator
/// does NOT record the request_id in recent_requests, so when the
/// response arrives, it's recognized as "our request" and the
/// target's coordinates are cached in coord_cache.
pub(in crate::node) async fn initiate_lookup(&mut self, target: &NodeAddr, ttl: u8) {
let origin = *self.node_addr();
let origin_coords = self.tree_state().my_coords().clone();
let mut request = LookupRequest::generate(*target, origin, origin_coords, ttl);
// Add ourselves to the visited filter so forwarding nodes
// won't send the request back to us
request.visited.insert(&origin);
debug!(
request_id = request.request_id,
target = %target,
ttl = ttl,
"Initiating LookupRequest"
);
// Send to all peers (flood)
let peer_addrs: Vec<NodeAddr> = self.peers.keys().copied().collect();
let encoded = request.encode();
for peer_addr in peer_addrs {
if let Err(e) = self.send_encrypted_link_message(&peer_addr, &encoded).await {
debug!(
peer = %peer_addr,
error = %e,
"Failed to send LookupRequest to peer"
);
}
}
}
/// Initiate a discovery lookup if one is not already pending for this target.
///
/// Deduplicates lookups using `pending_lookups` with a timeout. If a
/// lookup was recently initiated and hasn't timed out, this is a no-op.
pub(in crate::node) async fn maybe_initiate_lookup(&mut self, dest: &NodeAddr) {
let now_ms = Self::now_ms();
let lookup_timeout_ms = self.config.node.discovery.timeout_secs * 1000;
if let Some(&initiated_at) = self.pending_lookups.get(dest)
&& now_ms.saturating_sub(initiated_at) < lookup_timeout_ms
{
return;
}
self.pending_lookups.insert(*dest, now_ms);
let ttl = self.config.node.discovery.ttl;
self.initiate_lookup(dest, ttl).await;
}
/// Remove timed-out pending lookups and drain their queued packets.
///
/// Called periodically from the tick handler. For each timed-out lookup,
/// sends ICMPv6 Destination Unreachable for any queued TUN packets and
/// removes them from the pending queue.
pub(in crate::node) fn purge_stale_lookups(&mut self, now_ms: u64) {
let timed_out: Vec<NodeAddr> = self
.pending_lookups
.iter()
.filter(|&(_, &ts)| now_ms.saturating_sub(ts) >= self.config.node.discovery.timeout_secs * 1000)
.map(|(addr, _)| *addr)
.collect();
for addr in timed_out {
self.pending_lookups.remove(&addr);
if let Some(packets) = self.pending_tun_packets.remove(&addr) {
for pkt in &packets {
self.send_icmpv6_dest_unreachable(pkt);
}
}
}
}
/// Remove expired entries from the recent_requests cache.
fn purge_expired_requests(&mut self, current_time_ms: u64) {
let expiry_ms = self.config.node.discovery.recent_expiry_secs * 1000;
self.recent_requests
.retain(|_, entry| !entry.is_expired(current_time_ms, expiry_ms));
}
}