Skip to main content

fips_core/node/
route_impl.rs

1use super::*;
2
3impl Node {
4    // === Routing ===
5
6    /// Check if a peer is a tree neighbor (parent or child in the spanning tree).
7    ///
8    /// Returns true if the peer is our current tree parent, or if the peer
9    /// has declared us as their parent (making them our child).
10    pub(crate) fn is_tree_peer(&self, peer_addr: &NodeAddr) -> bool {
11        // Peer is our parent
12        if !self.tree_state.is_root() && self.tree_state.my_declaration().parent_id() == peer_addr {
13            return true;
14        }
15        // Peer is our child (their declaration names us as parent)
16        if let Some(decl) = self.tree_state.peer_declaration(peer_addr)
17            && decl.parent_id() == self.node_addr()
18        {
19            return true;
20        }
21        false
22    }
23
24    /// Find next hop for a destination node address.
25    ///
26    /// Routing priority:
27    /// 1. Destination is self → `None` (local delivery)
28    /// 2. Destination is a healthy direct peer → that peer. A known fallback
29    ///    next-hop may beat a non-static direct path when it has a meaningful
30    ///    link-quality advantage; operator-configured static UDP peers stay
31    ///    pinned to direct while healthy and endpoint traffic is getting
32    ///    authenticated return traffic.
33    /// 3. Reply-learned routes in `reply_learned` mode. These are locally
34    ///    observed reverse paths, selected with weighted multipath plus
35    ///    periodic coordinate/tree exploration.
36    /// 4. Bloom filter candidates with cached dest coords → among peers whose
37    ///    bloom filter contains the destination, pick the one that minimizes
38    ///    tree distance to the destination, with
39    ///    `(link_cost, tree_distance_to_dest, node_addr)` tie-breaking.
40    ///    The self-distance check ensures only peers strictly closer to the
41    ///    destination than us are considered (prevents routing loops).
42    /// 5. Greedy tree routing fallback (requires cached dest coords)
43    /// 6. No route → `None`
44    ///
45    /// Both the bloom filter and tree routing paths require cached destination
46    /// coordinates (checked in `coord_cache`). Without coordinates, the node
47    /// cannot make loop-free forwarding decisions. The caller should signal
48    /// `CoordsRequired` back to the source when `None` is returned for a
49    /// non-local destination.
50    pub fn find_next_hop(&mut self, dest_node_addr: &NodeAddr) -> Option<&ActivePeer> {
51        // 1. Local delivery
52        if dest_node_addr == self.node_addr() {
53            return None;
54        }
55        let now_ms = Self::now_ms();
56        let direct_session_degraded =
57            self.session_direct_path_blocks_direct_payload(dest_node_addr, now_ms);
58        let direct_session_untrusted = !direct_session_degraded
59            && self.session_direct_path_exclusive_trust_expired(dest_node_addr, now_ms);
60        let stale_traversal_direct_route = self
61            .peers
62            .get(dest_node_addr)
63            .filter(|peer| !direct_session_degraded && !peer.is_healthy() && peer.can_send())
64            .and_then(|_| {
65                self.configured_peer(dest_node_addr)
66                    .and_then(|peer_config| {
67                        (peer_config.is_auto_connect()
68                            && self.active_peer_uses_traversal_path(dest_node_addr, peer_config))
69                        .then_some(*dest_node_addr)
70                    })
71            });
72
73        let healthy_direct_route = self
74            .peers
75            .get(dest_node_addr)
76            .filter(|peer| peer.is_healthy() && !direct_session_degraded)
77            .map(|_| *dest_node_addr);
78        if let Some(direct_addr) = healthy_direct_route
79            && !direct_session_untrusted
80            && self
81                .peers
82                .get(&direct_addr)
83                .is_some_and(|peer| peer.link_cost() <= 1.0 + ROUTING_FALLBACK_MIN_COST_ADVANTAGE)
84        {
85            return self.peers.get(&direct_addr);
86        }
87        let direct_payload_eligible = healthy_direct_route.is_some();
88        let payload_candidate_can_send = |addr: &NodeAddr, peer: &ActivePeer| {
89            if addr == dest_node_addr {
90                direct_payload_eligible
91            } else {
92                peer.is_healthy()
93            }
94        };
95
96        // A healthy direct path is not automatically the best path. A
97        // hotspot/NAT hairpin can remain sendable with high RTT or mild loss;
98        // in that case a lower-cost mesh next-hop should carry traffic while
99        // direct probes continue in the background.
100        let fallback_beats_direct = |node: &Self, fallback_addr: NodeAddr| {
101            if direct_session_untrusted {
102                return healthy_direct_route != Some(fallback_addr)
103                    && node
104                        .peers
105                        .get(&fallback_addr)
106                        .is_some_and(|peer| peer.is_healthy());
107            }
108            node.route_candidate_beats_direct(healthy_direct_route, fallback_addr)
109        };
110
111        let sendable_learned_peers = if self.config.node.routing.mode == RoutingMode::ReplyLearned {
112            Some(
113                self.peers
114                    .iter()
115                    .filter(|(addr, peer)| payload_candidate_can_send(addr, peer))
116                    .map(|(addr, _)| *addr)
117                    .collect::<HashSet<_>>(),
118            )
119        } else {
120            None
121        };
122
123        // 3. Optional reply-learned routing. These entries are not peer
124        // claims; they are local observations of which peer carried traffic
125        // or a verified lookup response back from the destination. Most
126        // packets use weighted multipath over learned routes, but periodic
127        // fallback exploration lets coord/bloom/tree routes discover better
128        // candidates.
129        let explore_fallback = sendable_learned_peers.as_ref().is_some_and(|sendable| {
130            self.learned_routes.should_explore_fallback(
131                dest_node_addr,
132                now_ms,
133                self.config.node.routing.learned_fallback_explore_interval,
134                |addr| sendable.contains(addr),
135            )
136        });
137        if let Some(sendable) = &sendable_learned_peers
138            && !explore_fallback
139        {
140            let eligible = sendable
141                .iter()
142                .copied()
143                .filter(|addr| fallback_beats_direct(self, *addr))
144                .collect::<HashSet<_>>();
145            if !eligible.is_empty()
146                && let Some(next_hop_addr) =
147                    self.learned_routes
148                        .select_next_hop(dest_node_addr, now_ms, |addr| eligible.contains(addr))
149            {
150                return self.peers.get(&next_hop_addr);
151            }
152        }
153
154        // Look up cached destination coordinates (required by both bloom and tree paths).
155        let Some(dest_coords) = self
156            .coord_cache
157            .get_and_touch(dest_node_addr, now_ms)
158            .cloned()
159        else {
160            if (healthy_direct_route.is_none() || explore_fallback)
161                && let Some(sendable) = &sendable_learned_peers
162                && let Some(next_hop_addr) =
163                    self.learned_routes
164                        .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
165            {
166                return self.peers.get(&next_hop_addr);
167            }
168            if let Some(direct_addr) = healthy_direct_route {
169                return self.peers.get(&direct_addr);
170            }
171            if let Some(direct_addr) = stale_traversal_direct_route {
172                return self.peers.get(&direct_addr);
173            }
174            return None;
175        };
176
177        // 4. Bloom filter candidates — requires dest_coords for loop-free selection.
178        //    If no candidate is strictly closer, fall through to tree routing.
179        let coordinate_route_addr = {
180            let candidates: Vec<&ActivePeer> = self
181                .peers
182                .iter()
183                .filter(|(addr, peer)| {
184                    payload_candidate_can_send(addr, peer) && peer.may_reach(dest_node_addr)
185                })
186                .map(|(_, peer)| peer)
187                .collect();
188            if !candidates.is_empty() {
189                self.select_best_candidate(&candidates, &dest_coords)
190                    .map(|peer| *peer.node_addr())
191            } else {
192                None
193            }
194        };
195        if let Some(next_hop_addr) = coordinate_route_addr
196            && fallback_beats_direct(self, next_hop_addr)
197        {
198            return self.peers.get(&next_hop_addr);
199        }
200
201        // 5. Greedy tree routing fallback
202        let tree_route_addr = self.select_tree_payload_candidate(
203            &dest_coords,
204            dest_node_addr,
205            direct_payload_eligible,
206        );
207        if let Some(next_hop_addr) = tree_route_addr
208            && fallback_beats_direct(self, next_hop_addr)
209        {
210            return self.peers.get(&next_hop_addr);
211        }
212
213        if explore_fallback
214            && let Some(peer) = sendable_learned_peers.as_ref().and_then(|sendable| {
215                self.learned_routes
216                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
217                    .and_then(|next_hop_addr| self.peers.get(&next_hop_addr))
218            })
219        {
220            return Some(peer);
221        }
222
223        if let Some(direct_addr) = healthy_direct_route {
224            return self.peers.get(&direct_addr);
225        }
226
227        if let Some(sendable) = &sendable_learned_peers
228            && let Some(next_hop_addr) =
229                self.learned_routes
230                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
231        {
232            return self.peers.get(&next_hop_addr);
233        }
234
235        if let Some(direct_addr) = stale_traversal_direct_route {
236            return self.peers.get(&direct_addr);
237        }
238
239        None
240    }
241
242    pub(in crate::node) fn find_transit_next_hop(
243        &mut self,
244        dest_node_addr: &NodeAddr,
245        previous_hop: &NodeAddr,
246    ) -> Option<NodeAddr> {
247        if dest_node_addr == self.node_addr() {
248            return None;
249        }
250
251        if dest_node_addr != previous_hop
252            && self
253                .peers
254                .get(dest_node_addr)
255                .is_some_and(|peer| peer.is_healthy())
256        {
257            return Some(*dest_node_addr);
258        }
259
260        let next_hop_addr = *self.find_next_hop(dest_node_addr)?.node_addr();
261        if &next_hop_addr == previous_hop {
262            self.record_route_failure(*dest_node_addr, next_hop_addr);
263            return None;
264        }
265        Some(next_hop_addr)
266    }
267
268    pub(super) fn route_candidate_beats_direct(
269        &self,
270        healthy_direct_route: Option<NodeAddr>,
271        candidate_addr: NodeAddr,
272    ) -> bool {
273        let Some(direct_addr) = healthy_direct_route else {
274            return true;
275        };
276        if candidate_addr == direct_addr {
277            return false;
278        }
279
280        let Some(direct) = self.peers.get(&direct_addr) else {
281            return true;
282        };
283        if self.active_peer_uses_configured_static_udp_path(&direct_addr) {
284            return false;
285        }
286        let Some(candidate) = self.peers.get(&candidate_addr) else {
287            return false;
288        };
289        if !candidate.is_healthy() {
290            return false;
291        }
292
293        let direct_cost = direct.link_cost();
294        let candidate_cost = candidate.link_cost();
295        candidate_cost + ROUTING_FALLBACK_MIN_COST_ADVANTAGE < direct_cost
296    }
297
298    pub(super) fn select_tree_payload_candidate(
299        &self,
300        dest_coords: &crate::tree::TreeCoordinate,
301        direct_dest: &NodeAddr,
302        direct_payload_eligible: bool,
303    ) -> Option<NodeAddr> {
304        if self.tree_state.my_coords().root_id() != dest_coords.root_id() {
305            return None;
306        }
307
308        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
309        let mut best: Option<(NodeAddr, usize)> = None;
310
311        for (peer_addr, peer) in &self.peers {
312            if peer_addr == direct_dest {
313                if !direct_payload_eligible {
314                    continue;
315                }
316            } else if !peer.is_healthy() {
317                continue;
318            }
319
320            let Some(peer_coords) = self.tree_state.peer_coords(peer_addr) else {
321                continue;
322            };
323            let distance = peer_coords.distance_to(dest_coords);
324            if distance >= my_distance {
325                continue;
326            }
327
328            let dominated = match &best {
329                None => true,
330                Some((best_id, best_dist)) => {
331                    distance < *best_dist || (distance == *best_dist && peer_addr < best_id)
332                }
333            };
334            if dominated {
335                best = Some((*peer_addr, distance));
336            }
337        }
338
339        best.map(|(peer_addr, _)| peer_addr)
340    }
341
342    pub(in crate::node) fn session_direct_path_is_degraded(
343        &mut self,
344        dest: &NodeAddr,
345        now_ms: u64,
346    ) -> bool {
347        self.session_direct_degradation.is_degraded(dest, now_ms)
348    }
349
350    pub(in crate::node) fn session_direct_path_blocks_direct_payload(
351        &mut self,
352        dest: &NodeAddr,
353        now_ms: u64,
354    ) -> bool {
355        self.session_direct_path_is_degraded(dest, now_ms)
356    }
357
358    pub(in crate::node) fn session_direct_path_exclusive_trust_timeout_ms(&self) -> u64 {
359        self.config
360            .node
361            .heartbeat_interval_secs
362            .saturating_mul(1000)
363            .saturating_add(1_500)
364            .max(SESSION_DIRECT_MIN_EXCLUSIVE_TRUST_MS)
365    }
366
367    pub(in crate::node) fn session_direct_path_exclusive_trust_expired(
368        &self,
369        dest: &NodeAddr,
370        now_ms: u64,
371    ) -> bool {
372        if !self
373            .peers
374            .get(dest)
375            .is_some_and(|peer| peer.is_healthy() && peer.can_send())
376        {
377            return false;
378        }
379        let Some(session) = self.sessions.get(dest) else {
380            return false;
381        };
382        if !session.is_established() {
383            return false;
384        }
385        session.has_recent_outbound_without_inbound(
386            now_ms,
387            self.session_direct_path_exclusive_trust_timeout_ms(),
388        )
389    }
390
391    pub(in crate::node) fn mark_session_direct_path_degraded(
392        &mut self,
393        dest: NodeAddr,
394        now_ms: u64,
395    ) -> bool {
396        let changed = self.session_direct_degradation.mark_degraded(
397            dest,
398            now_ms,
399            SESSION_DIRECT_DEGRADED_HOLD_MS,
400        );
401        #[cfg(unix)]
402        if changed && let Some(runtime) = &self.endpoint_bulk_send_runtime {
403            runtime.invalidate(&dest);
404        }
405        changed
406    }
407
408    pub(in crate::node) fn clear_session_direct_path_degraded(&mut self, dest: &NodeAddr) -> bool {
409        let changed = self.session_direct_degradation.clear(dest);
410        #[cfg(unix)]
411        if changed && let Some(runtime) = &self.endpoint_bulk_send_runtime {
412            runtime.invalidate(dest);
413        }
414        changed
415    }
416
417    pub(in crate::node) fn clear_session_direct_path_degraded_after_promotion(
418        &mut self,
419        dest: &NodeAddr,
420        now_ms: u64,
421    ) {
422        let keep_degraded = self.session_direct_path_blocks_direct_payload(dest, now_ms);
423        if !keep_degraded {
424            self.clear_session_direct_path_degraded(dest);
425        } else if self.promoted_path_matches_configured_static_peer(dest) {
426            debug!(
427                peer = %self.peer_display_name(dest),
428                "Clearing direct payload degradation after configured direct-path promotion"
429            );
430            self.clear_session_direct_path_degraded(dest);
431        } else {
432            debug!(
433                peer = %self.peer_display_name(dest),
434                "Keeping direct payload degraded after direct-path promotion"
435            );
436        }
437    }
438
439    fn promoted_path_matches_configured_static_peer(&self, peer_node_addr: &NodeAddr) -> bool {
440        self.config
441            .auto_connect_peers()
442            .filter(|peer_config| {
443                PeerIdentity::from_npub(&peer_config.npub)
444                    .ok()
445                    .is_some_and(|identity| identity.node_addr() == peer_node_addr)
446            })
447            .any(|peer_config| {
448                self.static_peer_addresses(peer_config)
449                    .iter()
450                    .any(|candidate| self.active_peer_matches_candidate(peer_node_addr, candidate))
451            })
452    }
453
454    pub(in crate::node) fn learn_reverse_route(
455        &mut self,
456        destination: NodeAddr,
457        next_hop: NodeAddr,
458    ) {
459        if self.config.node.routing.mode != RoutingMode::ReplyLearned
460            || destination == *self.node_addr()
461        {
462            return;
463        }
464        let now_ms = Self::now_ms();
465        self.learned_routes.learn(
466            destination,
467            next_hop,
468            now_ms,
469            self.config.node.routing.learned_ttl_secs,
470            self.config.node.routing.max_learned_routes_per_dest,
471        );
472    }
473
474    pub(in crate::node) fn record_route_failure(
475        &mut self,
476        destination: NodeAddr,
477        next_hop: NodeAddr,
478    ) {
479        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
480            return;
481        }
482        self.learned_routes.record_failure(&destination, &next_hop);
483    }
484
485    pub(crate) fn learned_route_table_snapshot(&self, now_ms: u64) -> LearnedRouteTableSnapshot {
486        self.learned_routes.snapshot(now_ms)
487    }
488
489    pub(in crate::node) fn purge_learned_routes(&mut self, now_ms: u64) {
490        self.learned_routes.purge_expired(now_ms);
491    }
492
493    /// Select the best peer from a set of bloom filter candidates.
494    ///
495    /// Uses distance from each candidate's tree coordinates to the destination
496    /// as the primary metric (after link_cost). Only selects peers that are
497    /// strictly closer to the destination than we are (self-distance check
498    /// prevents routing loops).
499    ///
500    /// Ordering: `(link_cost, distance_to_dest, node_addr)`.
501    pub(super) fn select_best_candidate<'a>(
502        &'a self,
503        candidates: &[&'a ActivePeer],
504        dest_coords: &crate::tree::TreeCoordinate,
505    ) -> Option<&'a ActivePeer> {
506        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
507
508        let mut best: Option<(&ActivePeer, f64, usize)> = None;
509
510        for &candidate in candidates {
511            if !candidate.can_send() {
512                continue;
513            }
514
515            let cost = candidate.link_cost();
516
517            let dist = self
518                .tree_state
519                .peer_coords(candidate.node_addr())
520                .map(|pc| pc.distance_to(dest_coords))
521                .unwrap_or(usize::MAX);
522
523            // Self-distance check: only consider peers strictly closer
524            // to the destination than we are (prevents routing loops)
525            if dist >= my_distance {
526                continue;
527            }
528
529            let dominated = match &best {
530                None => true,
531                Some((_, best_cost, best_dist)) => {
532                    cost < *best_cost
533                        || (cost == *best_cost && dist < *best_dist)
534                        || (cost == *best_cost
535                            && dist == *best_dist
536                            && candidate.node_addr() < best.as_ref().unwrap().0.node_addr())
537                }
538            };
539
540            if dominated {
541                best = Some((candidate, cost, dist));
542            }
543        }
544
545        best.map(|(peer, _, _)| peer)
546    }
547
548    /// Check if a destination is in any peer's bloom filter.
549    pub fn destination_in_filters(&self, dest: &NodeAddr) -> Vec<&ActivePeer> {
550        self.peers.values().filter(|p| p.may_reach(dest)).collect()
551    }
552}