Skip to main content

fips_core/node/
route_impl.rs

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