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.
32    /// 3. Reply-learned routes in `reply_learned` mode. These are locally
33    ///    observed reverse paths, selected with weighted multipath plus
34    ///    periodic coordinate/tree exploration.
35    /// 4. Bloom filter candidates with cached dest coords → among peers whose
36    ///    bloom filter contains the destination, pick the one that minimizes
37    ///    tree distance to the destination, with
38    ///    `(link_cost, tree_distance_to_dest, node_addr)` tie-breaking.
39    ///    The self-distance check ensures only peers strictly closer to the
40    ///    destination than us are considered (prevents routing loops).
41    /// 5. Greedy tree routing fallback (requires cached dest coords)
42    /// 6. No route → `None`
43    ///
44    /// Both the bloom filter and tree routing paths require cached destination
45    /// coordinates (checked in `coord_cache`). Without coordinates, the node
46    /// cannot make loop-free forwarding decisions. The caller should signal
47    /// `CoordsRequired` back to the source when `None` is returned for a
48    /// non-local destination.
49    pub fn find_next_hop(&mut self, dest_node_addr: &NodeAddr) -> Option<&ActivePeer> {
50        // 1. Local delivery
51        if dest_node_addr == self.node_addr() {
52            return None;
53        }
54        let now_ms = Self::now_ms();
55        let direct_session_degraded =
56            self.session_direct_path_blocks_direct_payload(dest_node_addr, now_ms);
57
58        let healthy_direct_route = self
59            .peers
60            .get(dest_node_addr)
61            .filter(|peer| peer.is_healthy() && !direct_session_degraded)
62            .map(|_| *dest_node_addr);
63        if let Some(direct_addr) = healthy_direct_route {
64            let low_cost_direct = self
65                .peers
66                .get(&direct_addr)
67                .is_some_and(|peer| peer.link_cost() <= 1.0 + ROUTING_FALLBACK_MIN_COST_ADVANTAGE);
68            let configured_static_direct =
69                !low_cost_direct && self.active_peer_uses_configured_static_udp_path(&direct_addr);
70            if low_cost_direct || configured_static_direct {
71                return self.peers.get(&direct_addr);
72            }
73        }
74        let direct_payload_eligible = healthy_direct_route.is_some();
75        let payload_candidate_can_send = |addr: &NodeAddr, peer: &ActivePeer| {
76            if addr == dest_node_addr {
77                direct_payload_eligible
78            } else {
79                peer.is_healthy()
80            }
81        };
82
83        // A healthy direct path is not automatically the best path. A
84        // hotspot/NAT hairpin can remain sendable with high RTT or mild loss;
85        // in that case a lower-cost mesh next-hop should carry traffic while
86        // direct probes continue in the background.
87        let fallback_beats_direct = |node: &Self, fallback_addr: NodeAddr| {
88            node.route_candidate_beats_direct(healthy_direct_route, fallback_addr)
89        };
90
91        let sendable_learned_peers = if self.config.node.routing.mode == RoutingMode::ReplyLearned {
92            Some(
93                self.peers
94                    .iter()
95                    .filter(|(addr, peer)| payload_candidate_can_send(addr, peer))
96                    .map(|(addr, _)| *addr)
97                    .collect::<HashSet<_>>(),
98            )
99        } else {
100            None
101        };
102
103        // 3. Optional reply-learned routing. These entries are not peer
104        // claims; they are local observations of which peer carried traffic
105        // or a verified lookup response back from the destination. Most
106        // packets use weighted multipath over learned routes, but periodic
107        // fallback exploration lets coord/bloom/tree routes discover better
108        // candidates.
109        let explore_fallback = sendable_learned_peers.as_ref().is_some_and(|sendable| {
110            self.learned_routes.should_explore_fallback(
111                dest_node_addr,
112                now_ms,
113                self.config.node.routing.learned_fallback_explore_interval,
114                |addr| sendable.contains(addr),
115            )
116        });
117        if let Some(sendable) = &sendable_learned_peers
118            && !explore_fallback
119        {
120            let eligible = sendable
121                .iter()
122                .copied()
123                .filter(|addr| fallback_beats_direct(self, *addr))
124                .collect::<HashSet<_>>();
125            if !eligible.is_empty()
126                && let Some(next_hop_addr) =
127                    self.learned_routes
128                        .select_next_hop(dest_node_addr, now_ms, |addr| eligible.contains(addr))
129            {
130                return self.peers.get(&next_hop_addr);
131            }
132        }
133
134        // Look up cached destination coordinates (required by both bloom and tree paths).
135        let Some(dest_coords) = self
136            .coord_cache
137            .get_and_touch(dest_node_addr, now_ms)
138            .cloned()
139        else {
140            if (healthy_direct_route.is_none() || explore_fallback)
141                && let Some(sendable) = &sendable_learned_peers
142                && let Some(next_hop_addr) =
143                    self.learned_routes
144                        .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
145            {
146                return self.peers.get(&next_hop_addr);
147            }
148            if let Some(direct_addr) = healthy_direct_route {
149                return self.peers.get(&direct_addr);
150            }
151            return None;
152        };
153
154        // 4. Bloom filter candidates — requires dest_coords for loop-free selection.
155        //    If no candidate is strictly closer, fall through to tree routing.
156        let coordinate_route_addr = {
157            let candidates: Vec<&ActivePeer> = self
158                .peers
159                .iter()
160                .filter(|(addr, peer)| {
161                    payload_candidate_can_send(addr, peer) && peer.may_reach(dest_node_addr)
162                })
163                .map(|(_, peer)| peer)
164                .collect();
165            if !candidates.is_empty() {
166                self.select_best_candidate(&candidates, &dest_coords)
167                    .map(|peer| *peer.node_addr())
168            } else {
169                None
170            }
171        };
172        if let Some(next_hop_addr) = coordinate_route_addr
173            && fallback_beats_direct(self, next_hop_addr)
174        {
175            return self.peers.get(&next_hop_addr);
176        }
177
178        // 5. Greedy tree routing fallback
179        let tree_route_addr = self.select_tree_payload_candidate(
180            &dest_coords,
181            dest_node_addr,
182            direct_payload_eligible,
183        );
184        if let Some(next_hop_addr) = tree_route_addr
185            && fallback_beats_direct(self, next_hop_addr)
186        {
187            return self.peers.get(&next_hop_addr);
188        }
189
190        if explore_fallback {
191            return sendable_learned_peers.as_ref().and_then(|sendable| {
192                self.learned_routes
193                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
194                    .and_then(|next_hop_addr| self.peers.get(&next_hop_addr))
195            });
196        }
197
198        if let Some(direct_addr) = healthy_direct_route {
199            return self.peers.get(&direct_addr);
200        }
201
202        if let Some(sendable) = &sendable_learned_peers
203            && let Some(next_hop_addr) =
204                self.learned_routes
205                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
206        {
207            return self.peers.get(&next_hop_addr);
208        }
209
210        None
211    }
212
213    pub(in crate::node) fn find_transit_next_hop(
214        &mut self,
215        dest_node_addr: &NodeAddr,
216        previous_hop: &NodeAddr,
217    ) -> Option<NodeAddr> {
218        if dest_node_addr == self.node_addr() {
219            return None;
220        }
221
222        if dest_node_addr != previous_hop
223            && self
224                .peers
225                .get(dest_node_addr)
226                .is_some_and(|peer| peer.is_healthy())
227        {
228            return Some(*dest_node_addr);
229        }
230
231        let next_hop_addr = *self.find_next_hop(dest_node_addr)?.node_addr();
232        if &next_hop_addr == previous_hop {
233            self.record_route_failure(*dest_node_addr, next_hop_addr);
234            return None;
235        }
236        Some(next_hop_addr)
237    }
238
239    pub(super) fn route_candidate_beats_direct(
240        &self,
241        healthy_direct_route: Option<NodeAddr>,
242        candidate_addr: NodeAddr,
243    ) -> bool {
244        let Some(direct_addr) = healthy_direct_route else {
245            return true;
246        };
247        if candidate_addr == direct_addr {
248            return false;
249        }
250
251        let Some(direct) = self.peers.get(&direct_addr) else {
252            return true;
253        };
254        if self.active_peer_uses_configured_static_udp_path(&direct_addr) {
255            return false;
256        }
257        let Some(candidate) = self.peers.get(&candidate_addr) else {
258            return false;
259        };
260        if !candidate.is_healthy() {
261            return false;
262        }
263
264        let direct_cost = direct.link_cost();
265        let candidate_cost = candidate.link_cost();
266        candidate_cost + ROUTING_FALLBACK_MIN_COST_ADVANTAGE < direct_cost
267    }
268
269    pub(super) fn select_tree_payload_candidate(
270        &self,
271        dest_coords: &crate::tree::TreeCoordinate,
272        direct_dest: &NodeAddr,
273        direct_payload_eligible: bool,
274    ) -> Option<NodeAddr> {
275        if self.tree_state.my_coords().root_id() != dest_coords.root_id() {
276            return None;
277        }
278
279        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
280        let mut best: Option<(NodeAddr, usize)> = None;
281
282        for (peer_addr, peer) in &self.peers {
283            if peer_addr == direct_dest {
284                if !direct_payload_eligible {
285                    continue;
286                }
287            } else if !peer.is_healthy() {
288                continue;
289            }
290
291            let Some(peer_coords) = self.tree_state.peer_coords(peer_addr) else {
292                continue;
293            };
294            let distance = peer_coords.distance_to(dest_coords);
295            if distance >= my_distance {
296                continue;
297            }
298
299            let dominated = match &best {
300                None => true,
301                Some((best_id, best_dist)) => {
302                    distance < *best_dist || (distance == *best_dist && peer_addr < best_id)
303                }
304            };
305            if dominated {
306                best = Some((*peer_addr, distance));
307            }
308        }
309
310        best.map(|(peer_addr, _)| peer_addr)
311    }
312
313    pub(in crate::node) fn session_direct_path_is_degraded(
314        &mut self,
315        dest: &NodeAddr,
316        now_ms: u64,
317    ) -> bool {
318        self.session_direct_degradation.is_degraded(dest, now_ms)
319    }
320
321    pub(in crate::node) fn session_direct_path_blocks_direct_payload(
322        &mut self,
323        dest: &NodeAddr,
324        now_ms: u64,
325    ) -> bool {
326        self.session_direct_path_is_degraded(dest, now_ms)
327            && !self.active_peer_uses_configured_static_udp_path(dest)
328    }
329
330    pub(in crate::node) fn mark_session_direct_path_degraded(
331        &mut self,
332        dest: NodeAddr,
333        now_ms: u64,
334    ) -> bool {
335        self.session_direct_degradation
336            .mark_degraded(dest, now_ms, SESSION_DIRECT_DEGRADED_HOLD_MS)
337    }
338
339    pub(in crate::node) fn clear_session_direct_path_degraded(&mut self, dest: &NodeAddr) -> bool {
340        self.session_direct_degradation.clear(dest)
341    }
342
343    pub(in crate::node) fn learn_reverse_route(
344        &mut self,
345        destination: NodeAddr,
346        next_hop: NodeAddr,
347    ) {
348        if self.config.node.routing.mode != RoutingMode::ReplyLearned
349            || destination == *self.node_addr()
350        {
351            return;
352        }
353        let now_ms = Self::now_ms();
354        self.learned_routes.learn(
355            destination,
356            next_hop,
357            now_ms,
358            self.config.node.routing.learned_ttl_secs,
359            self.config.node.routing.max_learned_routes_per_dest,
360        );
361    }
362
363    pub(in crate::node) fn record_route_failure(
364        &mut self,
365        destination: NodeAddr,
366        next_hop: NodeAddr,
367    ) {
368        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
369            return;
370        }
371        self.learned_routes.record_failure(&destination, &next_hop);
372    }
373
374    pub(crate) fn learned_route_table_snapshot(&self, now_ms: u64) -> LearnedRouteTableSnapshot {
375        self.learned_routes.snapshot(now_ms)
376    }
377
378    pub(in crate::node) fn purge_learned_routes(&mut self, now_ms: u64) {
379        self.learned_routes.purge_expired(now_ms);
380    }
381
382    /// Select the best peer from a set of bloom filter candidates.
383    ///
384    /// Uses distance from each candidate's tree coordinates to the destination
385    /// as the primary metric (after link_cost). Only selects peers that are
386    /// strictly closer to the destination than we are (self-distance check
387    /// prevents routing loops).
388    ///
389    /// Ordering: `(link_cost, distance_to_dest, node_addr)`.
390    pub(super) fn select_best_candidate<'a>(
391        &'a self,
392        candidates: &[&'a ActivePeer],
393        dest_coords: &crate::tree::TreeCoordinate,
394    ) -> Option<&'a ActivePeer> {
395        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
396
397        let mut best: Option<(&ActivePeer, f64, usize)> = None;
398
399        for &candidate in candidates {
400            if !candidate.can_send() {
401                continue;
402            }
403
404            let cost = candidate.link_cost();
405
406            let dist = self
407                .tree_state
408                .peer_coords(candidate.node_addr())
409                .map(|pc| pc.distance_to(dest_coords))
410                .unwrap_or(usize::MAX);
411
412            // Self-distance check: only consider peers strictly closer
413            // to the destination than we are (prevents routing loops)
414            if dist >= my_distance {
415                continue;
416            }
417
418            let dominated = match &best {
419                None => true,
420                Some((_, best_cost, best_dist)) => {
421                    cost < *best_cost
422                        || (cost == *best_cost && dist < *best_dist)
423                        || (cost == *best_cost
424                            && dist == *best_dist
425                            && candidate.node_addr() < best.as_ref().unwrap().0.node_addr())
426                }
427            };
428
429            if dominated {
430                best = Some((candidate, cost, dist));
431            }
432        }
433
434        best.map(|(peer, _, _)| peer)
435    }
436
437    /// Check if a destination is in any peer's bloom filter.
438    pub fn destination_in_filters(&self, dest: &NodeAddr) -> Vec<&ActivePeer> {
439        self.peers.values().filter(|p| p.may_reach(dest)).collect()
440    }
441}