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