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            // The direct FMP/FSP carrier has now authenticated payload again.
637            // A rekey started only to recover this degraded carrier is no
638            // longer useful; letting it complete later can flip epochs during
639            // a subsequent roam and turn otherwise valid recovery traffic into
640            // replay. Retire it at the same point that retires the direct-path
641            // validation marker.
642            let _ = self.abandon_fmp_rekey_for_peer(
643                dest,
644                "authenticated direct payload made recovery rekey obsolete",
645            );
646            if !self.active_peer_uses_websocket(dest)
647                && !self.active_peer_uses_bootstrap_transport(dest)
648            {
649                // This is authenticated payload on the ordinary direct
650                // carrier. Do not leave a stale retry entry reporting
651                // `direct_probe_pending` or starting another recovery rekey
652                // from older aggregate liveness samples.
653                self.retry_pending.remove(dest);
654            }
655            let _ = self.refresh_dataplane_fsp_owner_routes(dest);
656        }
657        changed
658    }
659
660    pub(in crate::node) fn clear_session_direct_path_degraded_after_promotion(
661        &mut self,
662        dest: &NodeAddr,
663        now_ms: u64,
664    ) {
665        let direct_was_degraded = self.session_direct_path_degradation_active(dest, now_ms);
666        let active_fallback_next_hop = self
667            .dataplane
668            .fsp_owner_activity(dest)
669            .and_then(|activity| activity.last_outbound_next_hop())
670            .filter(|next_hop| next_hop != dest);
671        if direct_was_degraded || active_fallback_next_hop.is_some() {
672            if let Some(fallback_next_hop) = active_fallback_next_hop {
673                let _ = self
674                    .dataplane
675                    .forget_fsp_data_route(*dest, fallback_next_hop);
676            }
677            debug!(
678                peer = %self.peer_display_name(dest),
679                direct_was_degraded,
680                released_fallback_affinity = active_fallback_next_hop.is_some(),
681                "Authenticated direct-path promotion restored payload eligibility"
682            );
683            if !self.clear_session_direct_path_degraded(dest) {
684                let _ = self.refresh_dataplane_fsp_owner_routes(dest);
685            }
686            return;
687        }
688
689        let keep_degraded = self.session_direct_path_blocks_direct_payload(dest, now_ms);
690        if !keep_degraded {
691            self.clear_session_direct_path_degraded(dest);
692        } else if self.promoted_path_matches_configured_static_peer(dest) {
693            debug!(
694                peer = %self.peer_display_name(dest),
695                "Clearing direct payload degradation after configured direct-path promotion"
696            );
697            self.clear_session_direct_path_degraded(dest);
698        } else {
699            debug!(
700                peer = %self.peer_display_name(dest),
701                "Keeping direct payload degraded after direct-path promotion"
702            );
703        }
704    }
705
706    pub(in crate::node) fn make_direct_payload_eligible_for_validation_after_fmp_recovery(
707        &mut self,
708        dest: &NodeAddr,
709    ) {
710        // FMP control proves only that the direct link recovered. It does not
711        // prove that end-to-end FSP payload has returned to the direct path;
712        // routed fallback traffic can remain healthy at the same time. Keep
713        // the degradation marker and retry loop until authenticated direct FSP
714        // receive activity clears them. Continuous payload can recreate
715        // fallback affinity between the rekey cutover and the first direct FMP
716        // return, so every authenticated recovery observation must release that
717        // affinity before refreshing the FSP owner.
718        if !self.promoted_path_matches_configured_static_peer(dest) {
719            return;
720        }
721
722        let fallback_next_hop = self
723            .dataplane
724            .fsp_owner_activity(dest)
725            .and_then(|activity| activity.last_outbound_next_hop())
726            .filter(|next_hop| next_hop != dest);
727        if let Some(next_hop) = fallback_next_hop {
728            let _ = self.dataplane.forget_fsp_data_route(*dest, next_hop);
729        }
730        let _ = self
731            .session_direct_degradation
732            .release_hold_for_validation(dest, Self::now_ms());
733        let refreshed = self.refresh_dataplane_fsp_owner_routes(dest);
734        let pending_payload_validation =
735            self.session_direct_degradation.has_pending_validation(dest);
736        debug!(
737            peer = %self.peer_display_name(dest),
738            released_fallback_affinity = fallback_next_hop.is_some(),
739            refreshed,
740            pending_payload_validation,
741            "Authenticated FMP recovery made direct FSP payload eligible for validation"
742        );
743        if !pending_payload_validation {
744            self.clear_retry_unless_direct_refresh_needed(dest);
745        }
746    }
747
748    fn promoted_path_matches_configured_static_peer(&self, peer_node_addr: &NodeAddr) -> bool {
749        self.config
750            .auto_connect_peers()
751            .filter(|peer_config| {
752                PeerIdentity::from_npub(&peer_config.npub)
753                    .ok()
754                    .is_some_and(|identity| identity.node_addr() == peer_node_addr)
755            })
756            .any(|peer_config| {
757                self.static_peer_addresses(peer_config)
758                    .iter()
759                    .any(|candidate| self.active_peer_matches_candidate(peer_node_addr, candidate))
760            })
761    }
762
763    pub(in crate::node) fn learn_reverse_route(
764        &mut self,
765        destination: NodeAddr,
766        next_hop: NodeAddr,
767    ) {
768        if self.config.node.routing.mode != RoutingMode::ReplyLearned
769            || destination == *self.node_addr()
770        {
771            return;
772        }
773        let now_ms = Self::now_ms();
774        self.learned_routes.learn(
775            destination,
776            next_hop,
777            now_ms,
778            self.config.node.routing.learned_ttl_secs,
779            self.config.node.routing.max_learned_routes_per_dest,
780        );
781        // Discovery may return through more than one live seed. Once an FSP
782        // handshake has authenticated one complete path, keep that owner route
783        // stable while its physical next hop remains usable; later learned
784        // candidates stay available for explicit failure/degradation recovery.
785        let _ = self.refresh_dataplane_fsp_owner_routes_retaining_current(&destination);
786    }
787
788    pub(in crate::node) fn pin_handshake_reverse_route(
789        &mut self,
790        destination: NodeAddr,
791        next_hop: NodeAddr,
792    ) {
793        if self.config.node.routing.mode != RoutingMode::ReplyLearned
794            || destination == *self.node_addr()
795        {
796            return;
797        }
798        self.learned_routes.pin_handshake_route(
799            destination,
800            next_hop,
801            Self::now_ms(),
802            self.config.node.routing.learned_ttl_secs,
803            self.config.node.routing.max_learned_routes_per_dest,
804        );
805    }
806
807    pub(in crate::node) fn pin_duplicate_handshake_reverse_route(
808        &mut self,
809        destination: NodeAddr,
810        next_hop: NodeAddr,
811    ) {
812        if self.config.node.routing.mode != RoutingMode::ReplyLearned
813            || destination == *self.node_addr()
814        {
815            return;
816        }
817        let now_ms = Self::now_ms();
818        if self
819            .learned_routes
820            .active_handshake_route(&destination, now_ms)
821            .is_some_and(|pinned_hop| pinned_hop != next_hop)
822        {
823            return;
824        }
825        self.learned_routes.pin_handshake_route(
826            destination,
827            next_hop,
828            now_ms,
829            self.config.node.routing.learned_ttl_secs,
830            self.config.node.routing.max_learned_routes_per_dest,
831        );
832    }
833
834    pub(in crate::node) fn routing_error_matches_active_path(
835        &mut self,
836        destination: &NodeAddr,
837        previous_hop: &NodeAddr,
838    ) -> bool {
839        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
840            return true;
841        }
842
843        // Once established traffic has selected a branch, match feedback to
844        // the branch that actually carried the last outbound payload. The
845        // handshake pin may still name the authenticated msg2 ingress until
846        // its TTL expires, but reverse traffic can legitimately establish a
847        // different outbound route before then.
848        if let Some(last_outbound_next_hop) = self
849            .dataplane
850            .fsp_owner_activity(destination)
851            .and_then(|activity| activity.last_outbound_next_hop())
852        {
853            return last_outbound_next_hop == *previous_hop;
854        }
855
856        if let Some(pinned_hop) = self
857            .learned_routes
858            .active_handshake_route(destination, Self::now_ms())
859        {
860            // The reporter may be any downstream router on a legitimate
861            // multi-hop path. The authenticated adjacent ingress is the part
862            // we can match to the pinned route.
863            return pinned_hop == *previous_hop;
864        }
865
866        // The owner's wrap route can move as reverse traffic teaches a new
867        // branch while an already-transmitted payload and its PathBroken are
868        // still returning on the old branch. Match that explicit feedback to
869        // the branch actually used by the last outbound payload. Clearing the
870        // affinity when the failure is recorded makes later errors from the
871        // same branch stale.
872        // Before any payload has selected a branch, the dataplane owner's wrap
873        // route is the best authenticated local match available.
874        self.dataplane
875            .fsp_owner_next_hop(destination)
876            .is_none_or(|next_hop| next_hop == *previous_hop)
877    }
878
879    pub(in crate::node) fn record_route_failure(
880        &mut self,
881        destination: NodeAddr,
882        next_hop: NodeAddr,
883    ) {
884        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
885            return;
886        }
887        let _ = self.dataplane.forget_fsp_data_route(destination, next_hop);
888        self.learned_routes.record_failure(&destination, &next_hop);
889        let _ = self.refresh_dataplane_fsp_owner_routes(&destination);
890    }
891
892    pub(crate) fn learned_route_table_snapshot(&self, now_ms: u64) -> LearnedRouteTableSnapshot {
893        self.learned_routes.snapshot(now_ms)
894    }
895
896    pub(in crate::node) fn purge_learned_routes(&mut self, now_ms: u64) {
897        self.learned_routes.purge_expired(now_ms);
898    }
899
900    /// Select the best peer from a set of bloom filter candidates.
901    ///
902    /// Uses distance from each candidate's tree coordinates to the destination
903    /// as the primary metric (after link_cost). Only selects peers that are
904    /// strictly closer to the destination than we are (self-distance check
905    /// prevents routing loops).
906    ///
907    /// Ordering: `(link_cost, distance_to_dest, node_addr)`.
908    pub(super) fn select_best_candidate<'a>(
909        &'a self,
910        candidates: &[&'a ActivePeer],
911        dest_coords: &crate::tree::TreeCoordinate,
912    ) -> Option<&'a ActivePeer> {
913        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
914
915        let mut best: Option<(&ActivePeer, f64, usize)> = None;
916
917        for &candidate in candidates {
918            if !candidate.can_send() {
919                continue;
920            }
921
922            let cost = self.dataplane_fmp_link_cost(candidate.node_addr());
923
924            let dist = self
925                .tree_state
926                .peer_coords(candidate.node_addr())
927                .map(|pc| pc.distance_to(dest_coords))
928                .unwrap_or(usize::MAX);
929
930            // Self-distance check: only consider peers strictly closer
931            // to the destination than we are (prevents routing loops)
932            if dist >= my_distance {
933                continue;
934            }
935
936            let dominated = match &best {
937                None => true,
938                Some((_, best_cost, best_dist)) => {
939                    cost < *best_cost
940                        || (cost == *best_cost && dist < *best_dist)
941                        || (cost == *best_cost
942                            && dist == *best_dist
943                            && candidate.node_addr() < best.as_ref().unwrap().0.node_addr())
944                }
945            };
946
947            if dominated {
948                best = Some((candidate, cost, dist));
949            }
950        }
951
952        best.map(|(peer, _, _)| peer)
953    }
954
955    /// Check if a destination is in any peer's bloom filter.
956    pub fn destination_in_filters(&self, dest: &NodeAddr) -> Vec<&ActivePeer> {
957        self.peers.values().filter(|p| p.may_reach(dest)).collect()
958    }
959}