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_path_hard_degraded =
57            self.session_direct_path_is_degraded(dest_node_addr, now_ms);
58        let direct_path_soft_degraded = !direct_path_hard_degraded
59            && self.session_direct_discovered_endpoint_trust_expired(dest_node_addr, now_ms);
60        let fallback_peer_available = self.has_sendable_fallback_lookup_peer(dest_node_addr);
61        let direct_session_degraded =
62            fallback_peer_available && (direct_path_hard_degraded || direct_path_soft_degraded);
63        let direct_session_untrusted = !direct_session_degraded
64            && self.session_direct_path_exclusive_trust_expired(dest_node_addr, now_ms);
65        let stale_traversal_direct_route = self
66            .peers
67            .get(dest_node_addr)
68            .filter(|peer| {
69                !direct_path_hard_degraded
70                    && !direct_session_degraded
71                    && !peer.is_healthy()
72                    && peer.can_send()
73            })
74            .and_then(|_| {
75                self.configured_peer(dest_node_addr)
76                    .and_then(|peer_config| {
77                        (peer_config.is_auto_connect()
78                            && self.active_peer_uses_traversal_path(dest_node_addr, peer_config))
79                        .then_some(*dest_node_addr)
80                    })
81            });
82
83        let healthy_direct_route = self
84            .peers
85            .get(dest_node_addr)
86            .filter(|peer| peer.is_healthy() && !direct_session_degraded)
87            .map(|_| *dest_node_addr);
88        let direct_session_has_recent_data_return =
89            self.session_direct_path_has_recent_data_return(dest_node_addr, now_ms);
90        if let Some(direct_addr) = healthy_direct_route
91            && direct_session_has_recent_data_return
92        {
93            return self.peers.get(&direct_addr);
94        }
95        if let Some(direct_addr) = healthy_direct_route
96            && !direct_session_untrusted
97            && self.dataplane_fmp_link_cost(&direct_addr)
98                <= 1.0 + ROUTING_FALLBACK_MIN_COST_ADVANTAGE
99        {
100            return self.peers.get(&direct_addr);
101        }
102        let direct_payload_eligible = healthy_direct_route.is_some();
103        let payload_candidate_can_send = |addr: &NodeAddr, peer: &ActivePeer| {
104            if addr == dest_node_addr {
105                direct_payload_eligible
106            } else {
107                peer.is_healthy()
108            }
109        };
110
111        // A healthy direct path is not automatically the best path. A
112        // hotspot/NAT hairpin can remain sendable with high RTT or mild loss;
113        // in that case a lower-cost mesh next-hop should carry traffic while
114        // direct probes continue in the background.
115        let fallback_beats_direct = |node: &Self, fallback_addr: NodeAddr| {
116            if direct_session_untrusted {
117                return healthy_direct_route != Some(fallback_addr)
118                    && node
119                        .peers
120                        .get(&fallback_addr)
121                        .is_some_and(|peer| peer.is_healthy());
122            }
123            node.route_candidate_beats_direct(healthy_direct_route, fallback_addr)
124        };
125
126        let sendable_learned_peers = if self.config.node.routing.mode == RoutingMode::ReplyLearned {
127            Some(
128                self.peers
129                    .iter()
130                    .filter(|(addr, peer)| payload_candidate_can_send(addr, peer))
131                    .map(|(addr, _)| *addr)
132                    .collect::<HashSet<_>>(),
133            )
134        } else {
135            None
136        };
137
138        let explore_fallback = sendable_learned_peers.as_ref().is_some_and(|sendable| {
139            self.learned_routes.should_explore_fallback(
140                dest_node_addr,
141                now_ms,
142                self.config.node.routing.learned_fallback_explore_interval,
143                |addr| sendable.contains(addr),
144            )
145        });
146        if let Some(next_hop_addr) = sendable_learned_peers.as_ref().and_then(|sendable| {
147            let activity = self.dataplane.fsp_owner_activity(dest_node_addr)?;
148            let next_hop_addr = activity.last_outbound_next_hop()?;
149            if next_hop_addr == *dest_node_addr
150                || !activity.has_recent_outbound_activity(
151                    now_ms,
152                    self.session_direct_path_exclusive_trust_timeout_ms(),
153                )
154                || !sendable.contains(&next_hop_addr)
155                || !fallback_beats_direct(self, next_hop_addr)
156            {
157                return None;
158            }
159            Some(next_hop_addr)
160        }) {
161            self.learned_routes
162                .record_selected(dest_node_addr, &next_hop_addr, now_ms);
163            return self.peers.get(&next_hop_addr);
164        }
165
166        // 3. Optional reply-learned routing. These entries are not peer
167        // claims; they are local observations of which peer carried traffic
168        // or a verified lookup response back from the destination. Most
169        // packets use weighted multipath over learned routes, but periodic
170        // fallback exploration lets coord/bloom/tree routes discover better
171        // candidates.
172        if let Some(sendable) = &sendable_learned_peers
173            && !explore_fallback
174        {
175            let eligible = sendable
176                .iter()
177                .copied()
178                .filter(|addr| fallback_beats_direct(self, *addr))
179                .collect::<HashSet<_>>();
180            if !eligible.is_empty()
181                && let Some(next_hop_addr) =
182                    self.learned_routes
183                        .select_next_hop(dest_node_addr, now_ms, |addr| eligible.contains(addr))
184            {
185                return self.peers.get(&next_hop_addr);
186            }
187        }
188
189        // Look up cached destination coordinates (required by both bloom and tree paths).
190        let Some(dest_coords) = self
191            .coord_cache
192            .get_and_touch(dest_node_addr, now_ms)
193            .cloned()
194        else {
195            if (healthy_direct_route.is_none() || explore_fallback)
196                && let Some(sendable) = &sendable_learned_peers
197                && let Some(next_hop_addr) =
198                    self.learned_routes
199                        .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
200            {
201                return self.peers.get(&next_hop_addr);
202            }
203            if let Some(direct_addr) = healthy_direct_route {
204                return self.peers.get(&direct_addr);
205            }
206            if let Some(direct_addr) = stale_traversal_direct_route {
207                return self.peers.get(&direct_addr);
208            }
209            return None;
210        };
211
212        // 4. Bloom filter candidates — requires dest_coords for loop-free selection.
213        //    If no candidate is strictly closer, fall through to tree routing.
214        let coordinate_route_addr = {
215            let candidates: Vec<&ActivePeer> = self
216                .peers
217                .iter()
218                .filter(|(addr, peer)| {
219                    payload_candidate_can_send(addr, peer) && peer.may_reach(dest_node_addr)
220                })
221                .map(|(_, peer)| peer)
222                .collect();
223            if !candidates.is_empty() {
224                self.select_best_candidate(&candidates, &dest_coords)
225                    .map(|peer| *peer.node_addr())
226            } else {
227                None
228            }
229        };
230        if let Some(next_hop_addr) = coordinate_route_addr
231            && fallback_beats_direct(self, next_hop_addr)
232        {
233            return self.peers.get(&next_hop_addr);
234        }
235
236        // 5. Greedy tree routing fallback
237        let tree_route_addr = self.select_tree_payload_candidate(
238            &dest_coords,
239            dest_node_addr,
240            direct_payload_eligible,
241        );
242        if let Some(next_hop_addr) = tree_route_addr
243            && fallback_beats_direct(self, next_hop_addr)
244        {
245            return self.peers.get(&next_hop_addr);
246        }
247
248        if explore_fallback
249            && let Some(peer) = sendable_learned_peers.as_ref().and_then(|sendable| {
250                self.learned_routes
251                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
252                    .and_then(|next_hop_addr| self.peers.get(&next_hop_addr))
253            })
254        {
255            return Some(peer);
256        }
257
258        if let Some(direct_addr) = healthy_direct_route {
259            return self.peers.get(&direct_addr);
260        }
261
262        if let Some(sendable) = &sendable_learned_peers
263            && let Some(next_hop_addr) =
264                self.learned_routes
265                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
266        {
267            return self.peers.get(&next_hop_addr);
268        }
269
270        if let Some(direct_addr) = stale_traversal_direct_route {
271            return self.peers.get(&direct_addr);
272        }
273
274        None
275    }
276
277    pub(in crate::node) fn find_transit_next_hop(
278        &mut self,
279        dest_node_addr: &NodeAddr,
280        previous_hop: &NodeAddr,
281    ) -> Option<NodeAddr> {
282        if dest_node_addr == self.node_addr() {
283            return None;
284        }
285
286        if dest_node_addr != previous_hop
287            && self
288                .peers
289                .get(dest_node_addr)
290                .is_some_and(|peer| peer.is_healthy())
291        {
292            return Some(*dest_node_addr);
293        }
294
295        let next_hop_addr = *self.find_next_hop(dest_node_addr)?.node_addr();
296        if &next_hop_addr == previous_hop {
297            self.record_route_failure(*dest_node_addr, next_hop_addr);
298            return None;
299        }
300        Some(next_hop_addr)
301    }
302
303    pub(super) fn route_candidate_beats_direct(
304        &self,
305        healthy_direct_route: Option<NodeAddr>,
306        candidate_addr: NodeAddr,
307    ) -> bool {
308        let Some(direct_addr) = healthy_direct_route else {
309            return true;
310        };
311        if candidate_addr == direct_addr {
312            return false;
313        }
314
315        if !self.peers.contains_key(&direct_addr) {
316            return true;
317        }
318        if self.active_peer_uses_configured_static_udp_path(&direct_addr) {
319            return false;
320        }
321        let Some(candidate) = self.peers.get(&candidate_addr) else {
322            return false;
323        };
324        if !candidate.is_healthy() {
325            return false;
326        }
327
328        let direct_cost = self.dataplane_fmp_link_cost(&direct_addr);
329        let candidate_cost = self.dataplane_fmp_link_cost(&candidate_addr);
330        candidate_cost + ROUTING_FALLBACK_MIN_COST_ADVANTAGE < direct_cost
331    }
332
333    pub(super) fn select_tree_payload_candidate(
334        &self,
335        dest_coords: &crate::tree::TreeCoordinate,
336        direct_dest: &NodeAddr,
337        direct_payload_eligible: bool,
338    ) -> Option<NodeAddr> {
339        if self.tree_state.my_coords().root_id() != dest_coords.root_id() {
340            return None;
341        }
342
343        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
344        let mut best: Option<(NodeAddr, usize)> = None;
345
346        for (peer_addr, peer) in &self.peers {
347            if peer_addr == direct_dest {
348                if !direct_payload_eligible {
349                    continue;
350                }
351            } else if !peer.is_healthy() {
352                continue;
353            }
354
355            let Some(peer_coords) = self.tree_state.peer_coords(peer_addr) else {
356                continue;
357            };
358            let distance = peer_coords.distance_to(dest_coords);
359            if distance >= my_distance {
360                continue;
361            }
362
363            let dominated = match &best {
364                None => true,
365                Some((best_id, best_dist)) => {
366                    distance < *best_dist || (distance == *best_dist && peer_addr < best_id)
367                }
368            };
369            if dominated {
370                best = Some((*peer_addr, distance));
371            }
372        }
373
374        best.map(|(peer_addr, _)| peer_addr)
375    }
376
377    pub(in crate::node) fn session_direct_path_is_degraded(
378        &mut self,
379        dest: &NodeAddr,
380        now_ms: u64,
381    ) -> bool {
382        self.session_direct_degradation.is_degraded(dest, now_ms)
383    }
384
385    pub(in crate::node) fn session_direct_path_degradation_active(
386        &self,
387        dest: &NodeAddr,
388        now_ms: u64,
389    ) -> bool {
390        self.session_direct_degradation.is_degraded_at(dest, now_ms)
391    }
392
393    pub(in crate::node) fn session_direct_path_blocks_direct_payload(
394        &mut self,
395        dest: &NodeAddr,
396        now_ms: u64,
397    ) -> bool {
398        self.session_direct_path_is_degraded(dest, now_ms)
399            || self.session_direct_discovered_endpoint_trust_expired(dest, now_ms)
400    }
401
402    pub(in crate::node) fn session_direct_path_exclusive_trust_timeout_ms(&self) -> u64 {
403        self.config
404            .node
405            .heartbeat_interval_secs
406            .saturating_mul(1000)
407            .saturating_add(1_500)
408            .max(SESSION_DIRECT_MIN_EXCLUSIVE_TRUST_MS)
409    }
410
411    pub(in crate::node) fn session_direct_path_exclusive_trust_expired(
412        &self,
413        dest: &NodeAddr,
414        now_ms: u64,
415    ) -> bool {
416        if !self
417            .peers
418            .get(dest)
419            .is_some_and(|peer| peer.is_healthy() && peer.can_send())
420        {
421            return false;
422        }
423        let Some(activity) = self.dataplane.fsp_owner_activity(dest) else {
424            return false;
425        };
426        activity.has_recent_outbound_without_data_return_from(
427            dest,
428            now_ms,
429            self.session_direct_path_exclusive_trust_timeout_ms(),
430        )
431    }
432
433    pub(in crate::node) fn session_direct_path_has_recent_data_return(
434        &self,
435        dest: &NodeAddr,
436        now_ms: u64,
437    ) -> bool {
438        self.dataplane
439            .fsp_owner_activity(dest)
440            .is_some_and(|activity| {
441                activity.has_recent_data_return_from(
442                    dest,
443                    now_ms,
444                    self.session_direct_path_exclusive_trust_timeout_ms(),
445                )
446            })
447    }
448
449    fn session_direct_discovered_endpoint_trust_expired(
450        &self,
451        dest: &NodeAddr,
452        now_ms: u64,
453    ) -> bool {
454        self.session_direct_path_exclusive_trust_expired(dest, now_ms)
455            && self.configured_peer(dest).is_some_and(|peer_config| {
456                peer_config.is_auto_connect()
457                    && self.active_peer_uses_traversal_path(dest, peer_config)
458            })
459    }
460
461    pub(in crate::node) fn mark_session_direct_path_degraded(
462        &mut self,
463        dest: NodeAddr,
464        now_ms: u64,
465    ) -> bool {
466        let changed = self.session_direct_degradation.mark_degraded(
467            dest,
468            now_ms,
469            SESSION_DIRECT_DEGRADED_HOLD_MS,
470        );
471        if changed {
472            let _ = self.refresh_dataplane_fsp_owner_routes(&dest);
473        }
474        changed
475    }
476
477    pub(in crate::node) fn clear_session_direct_path_degraded(&mut self, dest: &NodeAddr) -> bool {
478        let changed = self.session_direct_degradation.clear(dest);
479        if changed {
480            let _ = self.refresh_dataplane_fsp_owner_routes(dest);
481        }
482        changed
483    }
484
485    pub(in crate::node) fn clear_session_direct_path_degraded_after_promotion(
486        &mut self,
487        dest: &NodeAddr,
488        now_ms: u64,
489    ) {
490        let keep_degraded = self.session_direct_path_blocks_direct_payload(dest, now_ms);
491        if !keep_degraded {
492            self.clear_session_direct_path_degraded(dest);
493        } else if self.promoted_path_matches_configured_static_peer(dest) {
494            debug!(
495                peer = %self.peer_display_name(dest),
496                "Clearing direct payload degradation after configured direct-path promotion"
497            );
498            self.clear_session_direct_path_degraded(dest);
499        } else {
500            debug!(
501                peer = %self.peer_display_name(dest),
502                "Keeping direct payload degraded after direct-path promotion"
503            );
504        }
505    }
506
507    fn promoted_path_matches_configured_static_peer(&self, peer_node_addr: &NodeAddr) -> bool {
508        self.config
509            .auto_connect_peers()
510            .filter(|peer_config| {
511                PeerIdentity::from_npub(&peer_config.npub)
512                    .ok()
513                    .is_some_and(|identity| identity.node_addr() == peer_node_addr)
514            })
515            .any(|peer_config| {
516                self.static_peer_addresses(peer_config)
517                    .iter()
518                    .any(|candidate| self.active_peer_matches_candidate(peer_node_addr, candidate))
519            })
520    }
521
522    pub(in crate::node) fn learn_reverse_route(
523        &mut self,
524        destination: NodeAddr,
525        next_hop: NodeAddr,
526    ) {
527        if self.config.node.routing.mode != RoutingMode::ReplyLearned
528            || destination == *self.node_addr()
529        {
530            return;
531        }
532        let now_ms = Self::now_ms();
533        self.learned_routes.learn(
534            destination,
535            next_hop,
536            now_ms,
537            self.config.node.routing.learned_ttl_secs,
538            self.config.node.routing.max_learned_routes_per_dest,
539        );
540        let _ = self.refresh_dataplane_fsp_owner_routes(&destination);
541    }
542
543    pub(in crate::node) fn record_route_failure(
544        &mut self,
545        destination: NodeAddr,
546        next_hop: NodeAddr,
547    ) {
548        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
549            return;
550        }
551        self.learned_routes.record_failure(&destination, &next_hop);
552        let _ = self.refresh_dataplane_fsp_owner_routes(&destination);
553    }
554
555    pub(crate) fn learned_route_table_snapshot(&self, now_ms: u64) -> LearnedRouteTableSnapshot {
556        self.learned_routes.snapshot(now_ms)
557    }
558
559    pub(in crate::node) fn purge_learned_routes(&mut self, now_ms: u64) {
560        self.learned_routes.purge_expired(now_ms);
561    }
562
563    /// Select the best peer from a set of bloom filter candidates.
564    ///
565    /// Uses distance from each candidate's tree coordinates to the destination
566    /// as the primary metric (after link_cost). Only selects peers that are
567    /// strictly closer to the destination than we are (self-distance check
568    /// prevents routing loops).
569    ///
570    /// Ordering: `(link_cost, distance_to_dest, node_addr)`.
571    pub(super) fn select_best_candidate<'a>(
572        &'a self,
573        candidates: &[&'a ActivePeer],
574        dest_coords: &crate::tree::TreeCoordinate,
575    ) -> Option<&'a ActivePeer> {
576        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
577
578        let mut best: Option<(&ActivePeer, f64, usize)> = None;
579
580        for &candidate in candidates {
581            if !candidate.can_send() {
582                continue;
583            }
584
585            let cost = self.dataplane_fmp_link_cost(candidate.node_addr());
586
587            let dist = self
588                .tree_state
589                .peer_coords(candidate.node_addr())
590                .map(|pc| pc.distance_to(dest_coords))
591                .unwrap_or(usize::MAX);
592
593            // Self-distance check: only consider peers strictly closer
594            // to the destination than we are (prevents routing loops)
595            if dist >= my_distance {
596                continue;
597            }
598
599            let dominated = match &best {
600                None => true,
601                Some((_, best_cost, best_dist)) => {
602                    cost < *best_cost
603                        || (cost == *best_cost && dist < *best_dist)
604                        || (cost == *best_cost
605                            && dist == *best_dist
606                            && candidate.node_addr() < best.as_ref().unwrap().0.node_addr())
607                }
608            };
609
610            if dominated {
611                best = Some((candidate, cost, dist));
612            }
613        }
614
615        best.map(|(peer, _, _)| peer)
616    }
617
618    /// Check if a destination is in any peer's bloom filter.
619    pub fn destination_in_filters(&self, dest: &NodeAddr) -> Vec<&ActivePeer> {
620        self.peers.values().filter(|p| p.may_reach(dest)).collect()
621    }
622}