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