Skip to main content

fips_core/node/
route_impl.rs

1use super::*;
2
3impl Node {
4    // === Routing ===
5
6    /// Check if a peer is a tree neighbor (parent or child in the spanning tree).
7    ///
8    /// Returns true if the peer is our current tree parent, or if the peer
9    /// has declared us as their parent (making them our child).
10    pub(crate) fn is_tree_peer(&self, peer_addr: &NodeAddr) -> bool {
11        // Peer is our parent
12        if !self.tree_state.is_root() && self.tree_state.my_declaration().parent_id() == peer_addr {
13            return true;
14        }
15        // Peer is our child (their declaration names us as parent)
16        if let Some(decl) = self.tree_state.peer_declaration(peer_addr)
17            && decl.parent_id() == self.node_addr()
18        {
19            return true;
20        }
21        false
22    }
23
24    /// Find next hop for a destination node address.
25    ///
26    /// Routing priority:
27    /// 1. Destination is self → `None` (local delivery)
28    /// 2. Destination is a healthy direct peer → that peer. A known fallback
29    ///    next-hop may beat a non-static direct path when it has a meaningful
30    ///    link-quality advantage; operator-configured static UDP peers stay
31    ///    pinned to direct while healthy and endpoint traffic is getting
32    ///    authenticated return traffic.
33    /// 3. Reply-learned routes in `reply_learned` mode. These are locally
34    ///    observed reverse paths, selected with weighted multipath plus
35    ///    periodic coordinate/tree exploration.
36    /// 4. Bloom filter candidates with cached dest coords → among peers whose
37    ///    bloom filter contains the destination, pick the one that minimizes
38    ///    tree distance to the destination, with
39    ///    `(link_cost, tree_distance_to_dest, node_addr)` tie-breaking.
40    ///    The self-distance check ensures only peers strictly closer to the
41    ///    destination than us are considered (prevents routing loops).
42    /// 5. Greedy tree routing fallback (requires cached dest coords)
43    /// 6. No route → `None`
44    ///
45    /// Both the bloom filter and tree routing paths require cached destination
46    /// coordinates (checked in `coord_cache`). Without coordinates, the node
47    /// cannot make loop-free forwarding decisions. The caller should signal
48    /// `CoordsRequired` back to the source when `None` is returned for a
49    /// non-local destination.
50    pub fn find_next_hop(&mut self, dest_node_addr: &NodeAddr) -> Option<&ActivePeer> {
51        // 1. Local delivery
52        if dest_node_addr == self.node_addr() {
53            return None;
54        }
55        let now_ms = Self::now_ms();
56        let direct_probe_blocks_payload = self.retry_pending.contains_key(dest_node_addr)
57            && !self.active_peer_uses_configured_static_udp_path(dest_node_addr);
58        let direct_session_degraded = direct_probe_blocks_payload
59            || self.session_direct_path_blocks_direct_payload(dest_node_addr, now_ms);
60        let direct_session_untrusted = !direct_session_degraded
61            && self.session_direct_path_exclusive_trust_expired(dest_node_addr, now_ms);
62
63        let healthy_direct_route = self
64            .peers
65            .get(dest_node_addr)
66            .filter(|peer| peer.is_healthy() && !direct_session_degraded)
67            .map(|_| *dest_node_addr);
68        if let Some(direct_addr) = healthy_direct_route
69            && !direct_session_untrusted
70            && self
71                .peers
72                .get(&direct_addr)
73                .is_some_and(|peer| peer.link_cost() <= 1.0 + ROUTING_FALLBACK_MIN_COST_ADVANTAGE)
74        {
75            return self.peers.get(&direct_addr);
76        }
77        let direct_payload_eligible = healthy_direct_route.is_some();
78        let payload_candidate_can_send = |addr: &NodeAddr, peer: &ActivePeer| {
79            if addr == dest_node_addr {
80                direct_payload_eligible
81            } else {
82                peer.is_healthy()
83            }
84        };
85
86        // A healthy direct path is not automatically the best path. A
87        // hotspot/NAT hairpin can remain sendable with high RTT or mild loss;
88        // in that case a lower-cost mesh next-hop should carry traffic while
89        // direct probes continue in the background.
90        let fallback_beats_direct = |node: &Self, fallback_addr: NodeAddr| {
91            if direct_session_untrusted {
92                return healthy_direct_route != Some(fallback_addr)
93                    && node
94                        .peers
95                        .get(&fallback_addr)
96                        .is_some_and(|peer| peer.is_healthy());
97            }
98            node.route_candidate_beats_direct(healthy_direct_route, fallback_addr)
99        };
100
101        let sendable_learned_peers = if self.config.node.routing.mode == RoutingMode::ReplyLearned {
102            Some(
103                self.peers
104                    .iter()
105                    .filter(|(addr, peer)| payload_candidate_can_send(addr, peer))
106                    .map(|(addr, _)| *addr)
107                    .collect::<HashSet<_>>(),
108            )
109        } else {
110            None
111        };
112
113        // 3. Optional reply-learned routing. These entries are not peer
114        // claims; they are local observations of which peer carried traffic
115        // or a verified lookup response back from the destination. Most
116        // packets use weighted multipath over learned routes, but periodic
117        // fallback exploration lets coord/bloom/tree routes discover better
118        // candidates.
119        let explore_fallback = sendable_learned_peers.as_ref().is_some_and(|sendable| {
120            self.learned_routes.should_explore_fallback(
121                dest_node_addr,
122                now_ms,
123                self.config.node.routing.learned_fallback_explore_interval,
124                |addr| sendable.contains(addr),
125            )
126        });
127        if let Some(sendable) = &sendable_learned_peers
128            && !explore_fallback
129        {
130            let eligible = sendable
131                .iter()
132                .copied()
133                .filter(|addr| fallback_beats_direct(self, *addr))
134                .collect::<HashSet<_>>();
135            if !eligible.is_empty()
136                && let Some(next_hop_addr) =
137                    self.learned_routes
138                        .select_next_hop(dest_node_addr, now_ms, |addr| eligible.contains(addr))
139            {
140                return self.peers.get(&next_hop_addr);
141            }
142        }
143
144        // Look up cached destination coordinates (required by both bloom and tree paths).
145        let Some(dest_coords) = self
146            .coord_cache
147            .get_and_touch(dest_node_addr, now_ms)
148            .cloned()
149        else {
150            if (healthy_direct_route.is_none() || explore_fallback)
151                && let Some(sendable) = &sendable_learned_peers
152                && let Some(next_hop_addr) =
153                    self.learned_routes
154                        .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
155            {
156                return self.peers.get(&next_hop_addr);
157            }
158            if let Some(direct_addr) = healthy_direct_route
159                && !direct_session_untrusted
160            {
161                return self.peers.get(&direct_addr);
162            }
163            return None;
164        };
165
166        // 4. Bloom filter candidates — requires dest_coords for loop-free selection.
167        //    If no candidate is strictly closer, fall through to tree routing.
168        let coordinate_route_addr = {
169            let candidates: Vec<&ActivePeer> = self
170                .peers
171                .iter()
172                .filter(|(addr, peer)| {
173                    payload_candidate_can_send(addr, peer) && peer.may_reach(dest_node_addr)
174                })
175                .map(|(_, peer)| peer)
176                .collect();
177            if !candidates.is_empty() {
178                self.select_best_candidate(&candidates, &dest_coords)
179                    .map(|peer| *peer.node_addr())
180            } else {
181                None
182            }
183        };
184        if let Some(next_hop_addr) = coordinate_route_addr
185            && fallback_beats_direct(self, next_hop_addr)
186        {
187            return self.peers.get(&next_hop_addr);
188        }
189
190        // 5. Greedy tree routing fallback
191        let tree_route_addr = self.select_tree_payload_candidate(
192            &dest_coords,
193            dest_node_addr,
194            direct_payload_eligible,
195        );
196        if let Some(next_hop_addr) = tree_route_addr
197            && fallback_beats_direct(self, next_hop_addr)
198        {
199            return self.peers.get(&next_hop_addr);
200        }
201
202        if explore_fallback {
203            return sendable_learned_peers.as_ref().and_then(|sendable| {
204                self.learned_routes
205                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
206                    .and_then(|next_hop_addr| self.peers.get(&next_hop_addr))
207            });
208        }
209
210        if let Some(direct_addr) = healthy_direct_route
211            && !direct_session_untrusted
212        {
213            return self.peers.get(&direct_addr);
214        }
215
216        if let Some(sendable) = &sendable_learned_peers
217            && let Some(next_hop_addr) =
218                self.learned_routes
219                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
220        {
221            return self.peers.get(&next_hop_addr);
222        }
223
224        None
225    }
226
227    pub(in crate::node) fn find_transit_next_hop(
228        &mut self,
229        dest_node_addr: &NodeAddr,
230        previous_hop: &NodeAddr,
231    ) -> Option<NodeAddr> {
232        if dest_node_addr == self.node_addr() {
233            return None;
234        }
235
236        if dest_node_addr != previous_hop
237            && self
238                .peers
239                .get(dest_node_addr)
240                .is_some_and(|peer| peer.is_healthy())
241        {
242            return Some(*dest_node_addr);
243        }
244
245        let next_hop_addr = *self.find_next_hop(dest_node_addr)?.node_addr();
246        if &next_hop_addr == previous_hop {
247            self.record_route_failure(*dest_node_addr, next_hop_addr);
248            return None;
249        }
250        Some(next_hop_addr)
251    }
252
253    pub(super) fn route_candidate_beats_direct(
254        &self,
255        healthy_direct_route: Option<NodeAddr>,
256        candidate_addr: NodeAddr,
257    ) -> bool {
258        let Some(direct_addr) = healthy_direct_route else {
259            return true;
260        };
261        if candidate_addr == direct_addr {
262            return false;
263        }
264
265        let Some(direct) = self.peers.get(&direct_addr) else {
266            return true;
267        };
268        if self.active_peer_uses_configured_static_udp_path(&direct_addr) {
269            return false;
270        }
271        let Some(candidate) = self.peers.get(&candidate_addr) else {
272            return false;
273        };
274        if !candidate.is_healthy() {
275            return false;
276        }
277
278        let direct_cost = direct.link_cost();
279        let candidate_cost = candidate.link_cost();
280        candidate_cost + ROUTING_FALLBACK_MIN_COST_ADVANTAGE < direct_cost
281    }
282
283    pub(super) fn select_tree_payload_candidate(
284        &self,
285        dest_coords: &crate::tree::TreeCoordinate,
286        direct_dest: &NodeAddr,
287        direct_payload_eligible: bool,
288    ) -> Option<NodeAddr> {
289        if self.tree_state.my_coords().root_id() != dest_coords.root_id() {
290            return None;
291        }
292
293        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
294        let mut best: Option<(NodeAddr, usize)> = None;
295
296        for (peer_addr, peer) in &self.peers {
297            if peer_addr == direct_dest {
298                if !direct_payload_eligible {
299                    continue;
300                }
301            } else if !peer.is_healthy() {
302                continue;
303            }
304
305            let Some(peer_coords) = self.tree_state.peer_coords(peer_addr) else {
306                continue;
307            };
308            let distance = peer_coords.distance_to(dest_coords);
309            if distance >= my_distance {
310                continue;
311            }
312
313            let dominated = match &best {
314                None => true,
315                Some((best_id, best_dist)) => {
316                    distance < *best_dist || (distance == *best_dist && peer_addr < best_id)
317                }
318            };
319            if dominated {
320                best = Some((*peer_addr, distance));
321            }
322        }
323
324        best.map(|(peer_addr, _)| peer_addr)
325    }
326
327    pub(in crate::node) fn session_direct_path_is_degraded(
328        &mut self,
329        dest: &NodeAddr,
330        now_ms: u64,
331    ) -> bool {
332        self.session_direct_degradation.is_degraded(dest, now_ms)
333    }
334
335    pub(in crate::node) fn session_direct_path_blocks_direct_payload(
336        &mut self,
337        dest: &NodeAddr,
338        now_ms: u64,
339    ) -> bool {
340        self.session_direct_path_is_degraded(dest, now_ms)
341            && !self.active_peer_uses_configured_static_udp_path(dest)
342    }
343
344    pub(in crate::node) fn session_direct_path_exclusive_trust_timeout_ms(&self) -> u64 {
345        self.config
346            .node
347            .heartbeat_interval_secs
348            .saturating_mul(1000)
349            .saturating_add(1_500)
350            .max(SESSION_DIRECT_MIN_EXCLUSIVE_TRUST_MS)
351    }
352
353    pub(in crate::node) fn session_direct_path_exclusive_trust_expired(
354        &self,
355        dest: &NodeAddr,
356        now_ms: u64,
357    ) -> bool {
358        if !self
359            .peers
360            .get(dest)
361            .is_some_and(|peer| peer.is_healthy() && peer.can_send())
362        {
363            return false;
364        }
365        let Some(session) = self.sessions.get(dest) else {
366            return false;
367        };
368        if !session.is_established() {
369            return false;
370        }
371        session.has_recent_outbound_without_inbound(
372            now_ms,
373            self.session_direct_path_exclusive_trust_timeout_ms(),
374        )
375    }
376
377    pub(in crate::node) fn mark_session_direct_path_degraded(
378        &mut self,
379        dest: NodeAddr,
380        now_ms: u64,
381    ) -> bool {
382        self.session_direct_degradation
383            .mark_degraded(dest, now_ms, SESSION_DIRECT_DEGRADED_HOLD_MS)
384    }
385
386    pub(in crate::node) fn clear_session_direct_path_degraded(&mut self, dest: &NodeAddr) -> bool {
387        self.session_direct_degradation.clear(dest)
388    }
389
390    pub(in crate::node) fn clear_session_direct_path_degraded_after_promotion(
391        &mut self,
392        dest: &NodeAddr,
393        now_ms: u64,
394    ) {
395        let keep_degraded = self.session_direct_path_blocks_direct_payload(dest, now_ms);
396        if keep_degraded {
397            debug!(
398                peer = %self.peer_display_name(dest),
399                "Keeping direct payload degraded after direct-path promotion"
400            );
401        } else {
402            self.clear_session_direct_path_degraded(dest);
403        }
404    }
405
406    pub(in crate::node) fn learn_reverse_route(
407        &mut self,
408        destination: NodeAddr,
409        next_hop: NodeAddr,
410    ) {
411        if self.config.node.routing.mode != RoutingMode::ReplyLearned
412            || destination == *self.node_addr()
413        {
414            return;
415        }
416        let now_ms = Self::now_ms();
417        self.learned_routes.learn(
418            destination,
419            next_hop,
420            now_ms,
421            self.config.node.routing.learned_ttl_secs,
422            self.config.node.routing.max_learned_routes_per_dest,
423        );
424    }
425
426    pub(in crate::node) fn record_route_failure(
427        &mut self,
428        destination: NodeAddr,
429        next_hop: NodeAddr,
430    ) {
431        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
432            return;
433        }
434        self.learned_routes.record_failure(&destination, &next_hop);
435    }
436
437    pub(crate) fn learned_route_table_snapshot(&self, now_ms: u64) -> LearnedRouteTableSnapshot {
438        self.learned_routes.snapshot(now_ms)
439    }
440
441    pub(in crate::node) fn purge_learned_routes(&mut self, now_ms: u64) {
442        self.learned_routes.purge_expired(now_ms);
443    }
444
445    /// Select the best peer from a set of bloom filter candidates.
446    ///
447    /// Uses distance from each candidate's tree coordinates to the destination
448    /// as the primary metric (after link_cost). Only selects peers that are
449    /// strictly closer to the destination than we are (self-distance check
450    /// prevents routing loops).
451    ///
452    /// Ordering: `(link_cost, distance_to_dest, node_addr)`.
453    pub(super) fn select_best_candidate<'a>(
454        &'a self,
455        candidates: &[&'a ActivePeer],
456        dest_coords: &crate::tree::TreeCoordinate,
457    ) -> Option<&'a ActivePeer> {
458        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
459
460        let mut best: Option<(&ActivePeer, f64, usize)> = None;
461
462        for &candidate in candidates {
463            if !candidate.can_send() {
464                continue;
465            }
466
467            let cost = candidate.link_cost();
468
469            let dist = self
470                .tree_state
471                .peer_coords(candidate.node_addr())
472                .map(|pc| pc.distance_to(dest_coords))
473                .unwrap_or(usize::MAX);
474
475            // Self-distance check: only consider peers strictly closer
476            // to the destination than we are (prevents routing loops)
477            if dist >= my_distance {
478                continue;
479            }
480
481            let dominated = match &best {
482                None => true,
483                Some((_, best_cost, best_dist)) => {
484                    cost < *best_cost
485                        || (cost == *best_cost && dist < *best_dist)
486                        || (cost == *best_cost
487                            && dist == *best_dist
488                            && candidate.node_addr() < best.as_ref().unwrap().0.node_addr())
489                }
490            };
491
492            if dominated {
493                best = Some((candidate, cost, dist));
494            }
495        }
496
497        best.map(|(peer, _, _)| peer)
498    }
499
500    /// Check if a destination is in any peer's bloom filter.
501    pub fn destination_in_filters(&self, dest: &NodeAddr) -> Vec<&ActivePeer> {
502        self.peers.values().filter(|p| p.may_reach(dest)).collect()
503    }
504}