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    pub(in crate::node) fn cache_current_root_coords(
13        &mut self,
14        node_addr: NodeAddr,
15        coords: crate::tree::TreeCoordinate,
16        now_ms: u64,
17    ) -> bool {
18        if coords.node_addr() != &node_addr
19            || coords.root_id() != self.tree_state.my_coords().root_id()
20        {
21            return false;
22        }
23        self.coord_cache.insert(node_addr, coords, now_ms);
24        true
25    }
26
27    /// Check if a peer is a tree neighbor (parent or child in the spanning tree).
28    ///
29    /// Returns true if the peer is our current tree parent, or if the peer
30    /// has declared us as their parent (making them our child).
31    pub(crate) fn is_tree_peer(&self, peer_addr: &NodeAddr) -> bool {
32        // Peer is our parent
33        if !self.tree_state.is_root() && self.tree_state.my_declaration().parent_id() == peer_addr {
34            return true;
35        }
36        // Peer is our child (their declaration names us as parent)
37        if let Some(decl) = self.tree_state.peer_declaration(peer_addr)
38            && decl.parent_id() == self.node_addr()
39        {
40            return true;
41        }
42        false
43    }
44
45    /// Find next hop for a destination node address.
46    ///
47    /// Routing priority:
48    /// 1. Destination is self → `None` (local delivery)
49    /// 2. Destination is a healthy direct peer → that peer. A known fallback
50    ///    next-hop may beat a non-static direct path when it has a meaningful
51    ///    link-quality advantage; operator-configured static UDP peers stay
52    ///    pinned to direct while healthy and endpoint traffic is getting
53    ///    authenticated return traffic.
54    /// 3. Reply-learned routes in `reply_learned` mode. These are locally
55    ///    observed reverse paths, selected with weighted multipath plus
56    ///    periodic coordinate/tree exploration.
57    /// 4. Bloom filter candidates with cached dest coords → among peers whose
58    ///    bloom filter contains the destination, pick the one that minimizes
59    ///    tree distance to the destination, with
60    ///    `(link_cost, tree_distance_to_dest, node_addr)` tie-breaking.
61    ///    The self-distance check ensures only peers strictly closer to the
62    ///    destination than us are considered (prevents routing loops).
63    /// 5. Greedy tree routing fallback (requires cached dest coords)
64    /// 6. No route → `None`
65    ///
66    /// Both the bloom filter and tree routing paths require cached destination
67    /// coordinates (checked in `coord_cache`). Without coordinates, the node
68    /// cannot make loop-free forwarding decisions. The caller should signal
69    /// `CoordsRequired` back to the source when `None` is returned for a
70    /// non-local destination.
71    pub fn find_next_hop(&mut self, dest_node_addr: &NodeAddr) -> Option<&ActivePeer> {
72        // 1. Local delivery
73        if dest_node_addr == self.node_addr() {
74            return None;
75        }
76        let now_ms = Self::now_ms();
77        let failed_learned_routes = self.learned_routes.failed_next_hops(dest_node_addr, now_ms);
78        let direct_path_hard_degraded =
79            self.session_direct_path_is_degraded(dest_node_addr, now_ms);
80        let direct_path_soft_degraded = !direct_path_hard_degraded
81            && self.session_direct_discovered_endpoint_trust_expired(dest_node_addr, now_ms);
82        let fallback_peer_available = self.has_sendable_fallback_lookup_peer(dest_node_addr);
83        let direct_session_degraded =
84            fallback_peer_available && (direct_path_hard_degraded || direct_path_soft_degraded);
85        let direct_session_untrusted = !direct_session_degraded
86            && self.session_direct_path_exclusive_trust_expired(dest_node_addr, now_ms);
87        let stale_traversal_direct_route = self
88            .peers
89            .get(dest_node_addr)
90            .filter(|peer| {
91                !direct_path_hard_degraded
92                    && !direct_session_degraded
93                    && !peer.is_healthy()
94                    && peer.can_send()
95            })
96            .and_then(|_| {
97                self.configured_peer(dest_node_addr)
98                    .and_then(|peer_config| {
99                        (peer_config.is_auto_connect()
100                            && self.active_peer_uses_traversal_path(dest_node_addr, peer_config))
101                        .then_some(*dest_node_addr)
102                    })
103            });
104
105        let healthy_direct_route = self
106            .peers
107            .get(dest_node_addr)
108            .filter(|peer| peer.is_healthy() && !direct_session_degraded)
109            .map(|_| *dest_node_addr);
110        let active_fallback_affinity = (self.config.node.routing.mode == RoutingMode::ReplyLearned)
111            .then(|| {
112                let activity = self.dataplane.fsp_owner_activity(dest_node_addr)?;
113                let next_hop = activity.last_outbound_next_hop()?;
114                (next_hop != *dest_node_addr
115                    && activity.has_recent_outbound_activity(
116                        now_ms,
117                        self.session_direct_path_exclusive_trust_timeout_ms(),
118                    )
119                    && !failed_learned_routes.contains(&next_hop)
120                    && self
121                        .peers
122                        .get(&next_hop)
123                        .is_some_and(|peer| peer.is_healthy() && peer.can_send()))
124                .then_some(next_hop)
125            })
126            .flatten();
127        if let Some(next_hop_addr) = active_fallback_affinity {
128            self.learned_routes
129                .record_selected(dest_node_addr, &next_hop_addr, now_ms);
130            return self.peers.get(&next_hop_addr);
131        }
132        let direct_session_has_recent_data_return =
133            self.session_direct_path_has_recent_data_return(dest_node_addr, now_ms);
134        if let Some(direct_addr) = healthy_direct_route
135            && direct_session_has_recent_data_return
136        {
137            return self.peers.get(&direct_addr);
138        }
139        if let Some(direct_addr) = healthy_direct_route
140            && !direct_session_untrusted
141            && self.dataplane_fmp_link_cost(&direct_addr)
142                <= 1.0 + ROUTING_FALLBACK_MIN_COST_ADVANTAGE
143        {
144            return self.peers.get(&direct_addr);
145        }
146        let direct_payload_eligible = healthy_direct_route.is_some();
147        let payload_candidate_can_send = |addr: &NodeAddr, peer: &ActivePeer| {
148            if addr == dest_node_addr {
149                direct_payload_eligible
150            } else {
151                peer.is_healthy() && !failed_learned_routes.contains(addr)
152            }
153        };
154
155        // A healthy direct path is not automatically the best path. A
156        // hotspot/NAT hairpin can remain sendable with high RTT or mild loss;
157        // in that case a lower-cost mesh next-hop should carry traffic while
158        // direct probes continue in the background.
159        let fallback_beats_direct = |node: &Self, fallback_addr: NodeAddr| {
160            if direct_session_untrusted {
161                return healthy_direct_route != Some(fallback_addr)
162                    && node
163                        .peers
164                        .get(&fallback_addr)
165                        .is_some_and(|peer| peer.is_healthy());
166            }
167            node.route_candidate_beats_direct(healthy_direct_route, fallback_addr)
168        };
169
170        let sendable_learned_peers = if self.config.node.routing.mode == RoutingMode::ReplyLearned {
171            Some(
172                self.peers
173                    .iter()
174                    .filter(|(addr, peer)| payload_candidate_can_send(addr, peer))
175                    .map(|(addr, _)| *addr)
176                    .collect::<HashSet<_>>(),
177            )
178        } else {
179            None
180        };
181
182        let explore_fallback = sendable_learned_peers.as_ref().is_some_and(|sendable| {
183            self.learned_routes.should_explore_fallback(
184                dest_node_addr,
185                now_ms,
186                self.config.node.routing.learned_fallback_explore_interval,
187                |addr| sendable.contains(addr),
188            )
189        });
190        // 3. Optional reply-learned routing. These entries are not peer
191        // claims; they are local observations of which peer carried traffic
192        // or a verified lookup response back from the destination. Most
193        // packets use weighted multipath over learned routes, but periodic
194        // fallback exploration lets coord/bloom/tree routes discover better
195        // candidates.
196        if let Some(sendable) = &sendable_learned_peers
197            && !explore_fallback
198        {
199            let eligible = sendable
200                .iter()
201                .copied()
202                .filter(|addr| fallback_beats_direct(self, *addr))
203                .collect::<HashSet<_>>();
204            if !eligible.is_empty()
205                && let Some(next_hop_addr) =
206                    self.learned_routes
207                        .select_next_hop(dest_node_addr, now_ms, |addr| eligible.contains(addr))
208            {
209                return self.peers.get(&next_hop_addr);
210            }
211        }
212
213        // Look up cached destination coordinates (required by both bloom and tree paths).
214        let Some(dest_coords) = self
215            .coord_cache
216            .get_and_touch(dest_node_addr, now_ms)
217            .cloned()
218        else {
219            if (healthy_direct_route.is_none() || explore_fallback)
220                && let Some(sendable) = &sendable_learned_peers
221                && let Some(next_hop_addr) =
222                    self.learned_routes
223                        .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
224            {
225                return self.peers.get(&next_hop_addr);
226            }
227            if let Some(direct_addr) = healthy_direct_route {
228                return self.peers.get(&direct_addr);
229            }
230            if let Some(direct_addr) = stale_traversal_direct_route {
231                return self.peers.get(&direct_addr);
232            }
233            return None;
234        };
235
236        // 4. Bloom filter candidates — requires dest_coords for loop-free selection.
237        //    If no candidate is strictly closer, fall through to tree routing.
238        let coordinate_route_addr = {
239            let candidates: Vec<&ActivePeer> = self
240                .peers
241                .iter()
242                .filter(|(addr, peer)| {
243                    payload_candidate_can_send(addr, peer) && peer.may_reach(dest_node_addr)
244                })
245                .map(|(_, peer)| peer)
246                .collect();
247            if !candidates.is_empty() {
248                self.select_best_candidate(&candidates, &dest_coords)
249                    .map(|peer| *peer.node_addr())
250            } else {
251                None
252            }
253        };
254        if let Some(next_hop_addr) = coordinate_route_addr
255            && fallback_beats_direct(self, next_hop_addr)
256        {
257            return self.peers.get(&next_hop_addr);
258        }
259
260        // 5. Greedy tree routing fallback
261        let tree_route_addr = self.select_tree_payload_candidate(
262            &dest_coords,
263            dest_node_addr,
264            direct_payload_eligible,
265        );
266        if let Some(next_hop_addr) = tree_route_addr
267            && fallback_beats_direct(self, next_hop_addr)
268        {
269            return self.peers.get(&next_hop_addr);
270        }
271
272        if explore_fallback
273            && let Some(peer) = sendable_learned_peers.as_ref().and_then(|sendable| {
274                self.learned_routes
275                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
276                    .and_then(|next_hop_addr| self.peers.get(&next_hop_addr))
277            })
278        {
279            return Some(peer);
280        }
281
282        if let Some(direct_addr) = healthy_direct_route {
283            return self.peers.get(&direct_addr);
284        }
285
286        if let Some(sendable) = &sendable_learned_peers
287            && let Some(next_hop_addr) =
288                self.learned_routes
289                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
290        {
291            return self.peers.get(&next_hop_addr);
292        }
293
294        if let Some(direct_addr) = stale_traversal_direct_route {
295            return self.peers.get(&direct_addr);
296        }
297
298        None
299    }
300
301    pub(in crate::node) fn plan_transit_next_hop(
302        &mut self,
303        dest_node_addr: &NodeAddr,
304        previous_hop: &NodeAddr,
305    ) -> TransitNextHopPlan {
306        if dest_node_addr == self.node_addr() {
307            return TransitNextHopPlan::NoRoute;
308        }
309
310        if dest_node_addr != previous_hop
311            && self
312                .peers
313                .get(dest_node_addr)
314                .is_some_and(|peer| peer.is_healthy())
315        {
316            return TransitNextHopPlan::Route(*dest_node_addr);
317        }
318
319        // A forwarded LookupResponse proves this direction of the transit
320        // path. Keep an established encrypted flow on that learned path while
321        // it is live; origin-side route exploration must not spray transit
322        // records into an unproven branch. Failure handling removes/decays the
323        // route and the ordinary coordinate/tree fallback remains below.
324        if self.config.node.routing.mode == RoutingMode::ReplyLearned {
325            let sendable = self
326                .peers
327                .iter()
328                .filter(|(addr, peer)| *addr != previous_hop && peer.is_healthy())
329                .map(|(addr, _)| *addr)
330                .collect::<HashSet<_>>();
331            if let Some(next_hop_addr) =
332                self.learned_routes
333                    .select_handshake_route(dest_node_addr, Self::now_ms(), |addr| {
334                        sendable.contains(addr)
335                    })
336            {
337                return TransitNextHopPlan::Route(next_hop_addr);
338            }
339        }
340
341        let Some(next_hop_addr) = self
342            .find_next_hop(dest_node_addr)
343            .map(|peer| *peer.node_addr())
344        else {
345            return TransitNextHopPlan::NoRoute;
346        };
347        if next_hop_addr == *dest_node_addr && &next_hop_addr != previous_hop {
348            return TransitNextHopPlan::Route(next_hop_addr);
349        }
350
351        let now_ms = Self::now_ms();
352        let dest_coords = self
353            .coord_cache
354            .get_and_touch(dest_node_addr, now_ms)
355            .cloned();
356        let selected_strictly_progresses = dest_coords.as_ref().is_some_and(|dest_coords| {
357            self.tree_state.my_coords().root_id() == dest_coords.root_id()
358                && self
359                    .tree_state
360                    .peer_coords(&next_hop_addr)
361                    .is_some_and(|peer_coords| {
362                        peer_coords.distance_to(dest_coords)
363                            < self.tree_state.my_coords().distance_to(dest_coords)
364                    })
365        });
366
367        if &next_hop_addr != previous_hop && (dest_coords.is_none() || selected_strictly_progresses)
368        {
369            return TransitNextHopPlan::Route(next_hop_addr);
370        }
371
372        let coordinate_fallback = dest_coords.and_then(|dest_coords| {
373            self.select_tree_payload_candidate_avoiding(
374                &dest_coords,
375                dest_node_addr,
376                false,
377                Some(previous_hop),
378            )
379        });
380        if let Some(next_hop_addr) = coordinate_fallback {
381            return TransitNextHopPlan::Route(next_hop_addr);
382        }
383        TransitNextHopPlan::Loop(next_hop_addr)
384    }
385
386    #[cfg(test)]
387    pub(in crate::node) fn find_transit_next_hop(
388        &mut self,
389        dest_node_addr: &NodeAddr,
390        previous_hop: &NodeAddr,
391    ) -> Option<NodeAddr> {
392        match self.plan_transit_next_hop(dest_node_addr, previous_hop) {
393            TransitNextHopPlan::Route(next_hop_addr) => Some(next_hop_addr),
394            TransitNextHopPlan::Loop(next_hop_addr) => {
395                self.record_route_failure(*dest_node_addr, next_hop_addr);
396                None
397            }
398            TransitNextHopPlan::NoRoute => None,
399        }
400    }
401
402    pub(super) fn route_candidate_beats_direct(
403        &self,
404        healthy_direct_route: Option<NodeAddr>,
405        candidate_addr: NodeAddr,
406    ) -> bool {
407        let Some(direct_addr) = healthy_direct_route else {
408            return true;
409        };
410        if candidate_addr == direct_addr {
411            return false;
412        }
413
414        if !self.peers.contains_key(&direct_addr) {
415            return true;
416        }
417        if self.active_peer_uses_configured_static_udp_path(&direct_addr) {
418            return false;
419        }
420        let Some(candidate) = self.peers.get(&candidate_addr) else {
421            return false;
422        };
423        if !candidate.is_healthy() {
424            return false;
425        }
426
427        let direct_cost = self.dataplane_fmp_link_cost(&direct_addr);
428        let candidate_cost = self.dataplane_fmp_link_cost(&candidate_addr);
429        candidate_cost + ROUTING_FALLBACK_MIN_COST_ADVANTAGE < direct_cost
430    }
431
432    pub(super) fn select_tree_payload_candidate(
433        &self,
434        dest_coords: &crate::tree::TreeCoordinate,
435        direct_dest: &NodeAddr,
436        direct_payload_eligible: bool,
437    ) -> Option<NodeAddr> {
438        self.select_tree_payload_candidate_avoiding(
439            dest_coords,
440            direct_dest,
441            direct_payload_eligible,
442            None,
443        )
444    }
445
446    fn select_tree_payload_candidate_avoiding(
447        &self,
448        dest_coords: &crate::tree::TreeCoordinate,
449        direct_dest: &NodeAddr,
450        direct_payload_eligible: bool,
451        excluded: Option<&NodeAddr>,
452    ) -> Option<NodeAddr> {
453        if self.tree_state.my_coords().root_id() != dest_coords.root_id() {
454            return None;
455        }
456
457        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
458        let mut best: Option<(NodeAddr, usize)> = None;
459
460        for (peer_addr, peer) in &self.peers {
461            if excluded == Some(peer_addr) {
462                continue;
463            }
464            if peer_addr == direct_dest {
465                if !direct_payload_eligible {
466                    continue;
467                }
468            } else if !peer.is_healthy() {
469                continue;
470            }
471
472            let Some(peer_coords) = self.tree_state.peer_coords(peer_addr) else {
473                continue;
474            };
475            let distance = peer_coords.distance_to(dest_coords);
476            if distance >= my_distance {
477                continue;
478            }
479
480            let dominated = match &best {
481                None => true,
482                Some((best_id, best_dist)) => {
483                    distance < *best_dist || (distance == *best_dist && peer_addr < best_id)
484                }
485            };
486            if dominated {
487                best = Some((*peer_addr, distance));
488            }
489        }
490
491        best.map(|(peer_addr, _)| peer_addr)
492    }
493
494    pub(in crate::node) fn session_direct_path_is_degraded(
495        &mut self,
496        dest: &NodeAddr,
497        now_ms: u64,
498    ) -> bool {
499        self.session_direct_degradation.is_degraded(dest, now_ms)
500    }
501
502    pub(in crate::node) fn session_direct_path_degradation_active(
503        &self,
504        dest: &NodeAddr,
505        now_ms: u64,
506    ) -> bool {
507        self.session_direct_degradation.is_degraded_at(dest, now_ms)
508    }
509
510    pub(in crate::node) fn session_direct_path_blocks_direct_payload(
511        &mut self,
512        dest: &NodeAddr,
513        now_ms: u64,
514    ) -> bool {
515        self.session_direct_path_is_degraded(dest, now_ms)
516            || self.session_direct_discovered_endpoint_trust_expired(dest, now_ms)
517    }
518
519    pub(in crate::node) fn session_direct_path_exclusive_trust_timeout_ms(&self) -> u64 {
520        self.config
521            .node
522            .heartbeat_interval_secs
523            .saturating_mul(1000)
524            .saturating_add(1_500)
525            .max(SESSION_DIRECT_MIN_EXCLUSIVE_TRUST_MS)
526    }
527
528    pub(in crate::node) fn session_direct_path_exclusive_trust_expired(
529        &self,
530        dest: &NodeAddr,
531        now_ms: u64,
532    ) -> bool {
533        if !self
534            .peers
535            .get(dest)
536            .is_some_and(|peer| peer.is_healthy() && peer.can_send())
537        {
538            return false;
539        }
540        let Some(activity) = self.dataplane.fsp_owner_activity(dest) else {
541            return false;
542        };
543        activity.has_recent_outbound_without_data_return_from(
544            dest,
545            now_ms,
546            self.session_direct_path_exclusive_trust_timeout_ms(),
547        )
548    }
549
550    pub(in crate::node) fn session_direct_path_has_recent_data_return(
551        &self,
552        dest: &NodeAddr,
553        now_ms: u64,
554    ) -> bool {
555        self.dataplane
556            .fsp_owner_activity(dest)
557            .is_some_and(|activity| {
558                activity.has_recent_data_return_from(
559                    dest,
560                    now_ms,
561                    self.session_direct_path_exclusive_trust_timeout_ms(),
562                )
563            })
564    }
565
566    fn session_direct_discovered_endpoint_trust_expired(
567        &self,
568        dest: &NodeAddr,
569        now_ms: u64,
570    ) -> bool {
571        self.session_direct_path_exclusive_trust_expired(dest, now_ms)
572            && self.configured_peer(dest).is_some_and(|peer_config| {
573                peer_config.is_auto_connect()
574                    && self.active_peer_uses_traversal_path(dest, peer_config)
575            })
576    }
577
578    pub(in crate::node) fn mark_session_direct_path_degraded(
579        &mut self,
580        dest: NodeAddr,
581        now_ms: u64,
582    ) -> bool {
583        let changed = self.session_direct_degradation.mark_degraded(
584            dest,
585            now_ms,
586            SESSION_DIRECT_DEGRADED_HOLD_MS,
587        );
588        if changed {
589            let _ = self.refresh_dataplane_fsp_owner_routes(&dest);
590        }
591        changed
592    }
593
594    pub(in crate::node) fn clear_session_direct_path_degraded(&mut self, dest: &NodeAddr) -> bool {
595        let changed = self.session_direct_degradation.clear(dest);
596        if changed {
597            let _ = self.refresh_dataplane_fsp_owner_routes(dest);
598        }
599        changed
600    }
601
602    pub(in crate::node) fn clear_session_direct_path_degraded_after_promotion(
603        &mut self,
604        dest: &NodeAddr,
605        now_ms: u64,
606    ) {
607        let direct_was_degraded = self.session_direct_path_degradation_active(dest, now_ms);
608        let active_fallback_next_hop = self
609            .dataplane
610            .fsp_owner_activity(dest)
611            .and_then(|activity| activity.last_outbound_next_hop())
612            .filter(|next_hop| next_hop != dest);
613        if direct_was_degraded || active_fallback_next_hop.is_some() {
614            if let Some(fallback_next_hop) = active_fallback_next_hop {
615                let _ = self
616                    .dataplane
617                    .forget_fsp_data_route(*dest, fallback_next_hop);
618            }
619            debug!(
620                peer = %self.peer_display_name(dest),
621                direct_was_degraded,
622                released_fallback_affinity = active_fallback_next_hop.is_some(),
623                "Authenticated direct-path promotion restored payload eligibility"
624            );
625            if !self.clear_session_direct_path_degraded(dest) {
626                let _ = self.refresh_dataplane_fsp_owner_routes(dest);
627            }
628            return;
629        }
630
631        let keep_degraded = self.session_direct_path_blocks_direct_payload(dest, now_ms);
632        if !keep_degraded {
633            self.clear_session_direct_path_degraded(dest);
634        } else if self.promoted_path_matches_configured_static_peer(dest) {
635            debug!(
636                peer = %self.peer_display_name(dest),
637                "Clearing direct payload degradation after configured direct-path promotion"
638            );
639            self.clear_session_direct_path_degraded(dest);
640        } else {
641            debug!(
642                peer = %self.peer_display_name(dest),
643                "Keeping direct payload degraded after direct-path promotion"
644            );
645        }
646    }
647
648    fn promoted_path_matches_configured_static_peer(&self, peer_node_addr: &NodeAddr) -> bool {
649        self.config
650            .auto_connect_peers()
651            .filter(|peer_config| {
652                PeerIdentity::from_npub(&peer_config.npub)
653                    .ok()
654                    .is_some_and(|identity| identity.node_addr() == peer_node_addr)
655            })
656            .any(|peer_config| {
657                self.static_peer_addresses(peer_config)
658                    .iter()
659                    .any(|candidate| self.active_peer_matches_candidate(peer_node_addr, candidate))
660            })
661    }
662
663    pub(in crate::node) fn learn_reverse_route(
664        &mut self,
665        destination: NodeAddr,
666        next_hop: NodeAddr,
667    ) {
668        if self.config.node.routing.mode != RoutingMode::ReplyLearned
669            || destination == *self.node_addr()
670        {
671            return;
672        }
673        let now_ms = Self::now_ms();
674        self.learned_routes.learn(
675            destination,
676            next_hop,
677            now_ms,
678            self.config.node.routing.learned_ttl_secs,
679            self.config.node.routing.max_learned_routes_per_dest,
680        );
681        // Discovery may return through more than one live seed. Once an FSP
682        // handshake has authenticated one complete path, keep that owner route
683        // stable while its physical next hop remains usable; later learned
684        // candidates stay available for explicit failure/degradation recovery.
685        let _ = self.refresh_dataplane_fsp_owner_routes_retaining_current(&destination);
686    }
687
688    pub(in crate::node) fn pin_handshake_reverse_route(
689        &mut self,
690        destination: NodeAddr,
691        next_hop: NodeAddr,
692    ) {
693        if self.config.node.routing.mode != RoutingMode::ReplyLearned
694            || destination == *self.node_addr()
695        {
696            return;
697        }
698        self.learned_routes.pin_handshake_route(
699            destination,
700            next_hop,
701            Self::now_ms(),
702            self.config.node.routing.learned_ttl_secs,
703            self.config.node.routing.max_learned_routes_per_dest,
704        );
705    }
706
707    pub(in crate::node) fn pin_duplicate_handshake_reverse_route(
708        &mut self,
709        destination: NodeAddr,
710        next_hop: NodeAddr,
711    ) {
712        if self.config.node.routing.mode != RoutingMode::ReplyLearned
713            || destination == *self.node_addr()
714        {
715            return;
716        }
717        let now_ms = Self::now_ms();
718        if self
719            .learned_routes
720            .active_handshake_route(&destination, now_ms)
721            .is_some_and(|pinned_hop| pinned_hop != next_hop)
722        {
723            return;
724        }
725        self.learned_routes.pin_handshake_route(
726            destination,
727            next_hop,
728            now_ms,
729            self.config.node.routing.learned_ttl_secs,
730            self.config.node.routing.max_learned_routes_per_dest,
731        );
732    }
733
734    pub(in crate::node) fn routing_error_matches_active_path(
735        &mut self,
736        destination: &NodeAddr,
737        previous_hop: &NodeAddr,
738    ) -> bool {
739        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
740            return true;
741        }
742
743        // Once established traffic has selected a branch, match feedback to
744        // the branch that actually carried the last outbound payload. The
745        // handshake pin may still name the authenticated msg2 ingress until
746        // its TTL expires, but reverse traffic can legitimately establish a
747        // different outbound route before then.
748        if let Some(last_outbound_next_hop) = self
749            .dataplane
750            .fsp_owner_activity(destination)
751            .and_then(|activity| activity.last_outbound_next_hop())
752        {
753            return last_outbound_next_hop == *previous_hop;
754        }
755
756        if let Some(pinned_hop) = self
757            .learned_routes
758            .active_handshake_route(destination, Self::now_ms())
759        {
760            // The reporter may be any downstream router on a legitimate
761            // multi-hop path. The authenticated adjacent ingress is the part
762            // we can match to the pinned route.
763            return pinned_hop == *previous_hop;
764        }
765
766        // The owner's wrap route can move as reverse traffic teaches a new
767        // branch while an already-transmitted payload and its PathBroken are
768        // still returning on the old branch. Match that explicit feedback to
769        // the branch actually used by the last outbound payload. Clearing the
770        // affinity when the failure is recorded makes later errors from the
771        // same branch stale.
772        // Before any payload has selected a branch, the dataplane owner's wrap
773        // route is the best authenticated local match available.
774        self.dataplane
775            .fsp_owner_next_hop(destination)
776            .is_none_or(|next_hop| next_hop == *previous_hop)
777    }
778
779    pub(in crate::node) fn record_route_failure(
780        &mut self,
781        destination: NodeAddr,
782        next_hop: NodeAddr,
783    ) {
784        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
785            return;
786        }
787        let _ = self.dataplane.forget_fsp_data_route(destination, next_hop);
788        self.learned_routes.record_failure(&destination, &next_hop);
789        let _ = self.refresh_dataplane_fsp_owner_routes(&destination);
790    }
791
792    pub(crate) fn learned_route_table_snapshot(&self, now_ms: u64) -> LearnedRouteTableSnapshot {
793        self.learned_routes.snapshot(now_ms)
794    }
795
796    pub(in crate::node) fn purge_learned_routes(&mut self, now_ms: u64) {
797        self.learned_routes.purge_expired(now_ms);
798    }
799
800    /// Select the best peer from a set of bloom filter candidates.
801    ///
802    /// Uses distance from each candidate's tree coordinates to the destination
803    /// as the primary metric (after link_cost). Only selects peers that are
804    /// strictly closer to the destination than we are (self-distance check
805    /// prevents routing loops).
806    ///
807    /// Ordering: `(link_cost, distance_to_dest, node_addr)`.
808    pub(super) fn select_best_candidate<'a>(
809        &'a self,
810        candidates: &[&'a ActivePeer],
811        dest_coords: &crate::tree::TreeCoordinate,
812    ) -> Option<&'a ActivePeer> {
813        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
814
815        let mut best: Option<(&ActivePeer, f64, usize)> = None;
816
817        for &candidate in candidates {
818            if !candidate.can_send() {
819                continue;
820            }
821
822            let cost = self.dataplane_fmp_link_cost(candidate.node_addr());
823
824            let dist = self
825                .tree_state
826                .peer_coords(candidate.node_addr())
827                .map(|pc| pc.distance_to(dest_coords))
828                .unwrap_or(usize::MAX);
829
830            // Self-distance check: only consider peers strictly closer
831            // to the destination than we are (prevents routing loops)
832            if dist >= my_distance {
833                continue;
834            }
835
836            let dominated = match &best {
837                None => true,
838                Some((_, best_cost, best_dist)) => {
839                    cost < *best_cost
840                        || (cost == *best_cost && dist < *best_dist)
841                        || (cost == *best_cost
842                            && dist == *best_dist
843                            && candidate.node_addr() < best.as_ref().unwrap().0.node_addr())
844                }
845            };
846
847            if dominated {
848                best = Some((candidate, cost, dist));
849            }
850        }
851
852        best.map(|(peer, _, _)| peer)
853    }
854
855    /// Check if a destination is in any peer's bloom filter.
856    pub fn destination_in_filters(&self, dest: &NodeAddr) -> Vec<&ActivePeer> {
857        self.peers.values().filter(|p| p.may_reach(dest)).collect()
858    }
859}