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