ts_runtime/exit_node_suggest.rs
1//! Exit-node suggestion: pick a reasonably good exit node from the netmap + latest netcheck report.
2//!
3//! This is the Rust port of Go `ipnlocal`'s `suggestExitNodeUsingDERP` (the classic DERP-region
4//! -latency path; tailscale v1.100.0 `ipn/ipnlocal/local.go`), surfaced as
5//! [`Runtime::suggest_exit_node`](crate::Runtime::suggest_exit_node) and consumed by the daemon's
6//! `tnet exit-node suggest`. The traffic-steering path (`NodeAttrTrafficSteering`) and the Mullvad
7//! geo-distance path are **Phase 2** and deliberately not ported here (see `suggest_exit_node`).
8//!
9//! ## Determinism contract (this corrects a common misconception)
10//!
11//! There is **no** seed/hash tiebreak. Determinism comes from exactly two places, mirroring Go:
12//! 1. the lowest-latency region wins, with the lowest region id as the tiebreak
13//! (`min_latency_derp_region`); and
14//! 2. `prev_suggestion` **stickiness** — if the previously-suggested node is still among the
15//! region's candidates it is kept (see `random_node`).
16//!
17//! The final pick among equally-good ties is *uniform random* and varies run-to-run (Go's own doc
18//! says "the result is not stable"). So that the algorithm stays unit-testable, the region pick and
19//! the node pick are taken as **injected closures** ([`SelectRegion`](crate::exit_node_suggest::SelectRegion)
20//! / [`SelectNode`](crate::exit_node_suggest::SelectNode)); production passes the uniform-random
21//! `random_region` / `random_node`, and tests pass deterministic stubs. This is a direct port of
22//! Go's `selectRegionFunc` / `selectNodeFunc` parameters.
23
24use ts_control::StableNodeId;
25use ts_derp::RegionId;
26
27use crate::status::NetcheckReport;
28
29/// A peer being considered as an exit-node suggestion, carrying exactly the inputs the suggestion
30/// algorithm reads. Built (in [`Runtime::suggest_exit_node`](crate::Runtime::suggest_exit_node))
31/// from a domain [`Node`](ts_control::Node); kept as a small standalone struct so the algorithm is a
32/// pure function over its inputs (unit-testable without the actor graph, mirroring how the runtime's
33/// `build_file_targets` factors out the file-target rules).
34///
35/// The eligibility predicate (`is_eligible`) is applied *inside* the `suggest_exit_node` function,
36/// so callers pass every peer and the pure function does the filtering — this keeps the predicate
37/// itself covered by the same tests as the selection logic.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ExitNodeCandidate {
40 /// The peer's stable node id (`Node.StableID()`), the identity returned in the suggestion and
41 /// matched against `prev_suggestion` for stickiness.
42 pub stable_id: StableNodeId,
43 /// The peer's display name (`Node.Name()`), echoed into the suggestion.
44 pub name: String,
45 /// The peer's home DERP region (`Node.HomeDERP()`), or `None` when it has no DERP home (Go
46 /// `HomeDERP == 0`; typically a Mullvad node). A region-less candidate is only ever selected
47 /// when *no* DERP-homed candidate exists (the Phase-2 geo path), so under Phase 1 it falls back
48 /// to a region-less [`SelectNode`] pick. Mirrors the domain
49 /// [`Node::derp_region`](ts_control::Node::derp_region).
50 pub derp_region: Option<RegionId>,
51 /// Whether control reports the peer online (`Node.Online == Some(true)`). The default
52 /// reachability gate (Go `PeerIsReachable` without `NodeAttrClientSideReachability`) is
53 /// `online == Some(true)`; a tri-state `None`/`Some(false)` is treated as *not reachable*
54 /// (fail-closed — never suggest a peer control has not asserted is up).
55 pub online: Option<bool>,
56 /// Whether the peer advertises an exit route. Per the IPv4-only fork parity decision (see the
57 /// `suggest_exit_node` function) this is `true` when the peer advertises `0.0.0.0/0`
58 /// (`prefix_len == 0`), matching the fork's family-agnostic
59 /// [`StatusNode::is_exit_node`](crate::status::StatusNode::is_exit_node) check — *not* Go's
60 /// strict both-`0.0.0.0/0`-and-`::/0` `tsaddr.ContainsExitRoutes`.
61 pub advertises_exit_route: bool,
62 /// Whether the peer carries the `suggest-exit-node` node-capability
63 /// ([`NODE_ATTR_SUGGEST_EXIT_NODE`](ts_control::NODE_ATTR_SUGGEST_EXIT_NODE)) in its `CapMap` —
64 /// control's marker that the peer may be auto-suggested. Checked via
65 /// [`Node::has_node_attr`](ts_control::Node::has_node_attr).
66 pub has_suggest_cap: bool,
67}
68
69impl ExitNodeCandidate {
70 /// Whether this peer is eligible to be suggested, mirroring Go's `AppendMatchingPeers` predicate
71 /// in `suggestExitNodeUsingDERP`: it must be reachable (online), carry the `suggest-exit-node`
72 /// cap, and advertise an exit route. (Go also requires `peer.Valid()` and an allow-list
73 /// membership check; a domain [`Node`](ts_control::Node) we hold is always valid, and this fork
74 /// has no `AllowedSuggestedExitNodes` policy yet, so that gate is allow-all — both are noted on
75 /// the `suggest_exit_node` function.) Fail-closed: any missing condition excludes the peer.
76 fn is_eligible(&self) -> bool {
77 self.online == Some(true) && self.has_suggest_cap && self.advertises_exit_route
78 }
79}
80
81/// The result of an exit-node suggestion — the Rust analog of Go
82/// `apitype.ExitNodeSuggestionResponse`.
83///
84/// Carries the suggested peer's [`stable id`](Self::id) and [`name`](Self::name). Go also carries a
85/// `Location` (`omitempty`); this fork's domain [`Node`](ts_control::Node) does not retain a peer
86/// location yet, so **Location is deferred to Phase 2** (when the Mullvad geo path lands) and is
87/// omitted here. A `None` suggestion (no eligible candidate) is represented by the caller returning
88/// `Ok(None)`, exactly as Go returns an empty response with a nil error.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct ExitNodeSuggestion {
91 /// The suggested exit node's stable id (`apitype.ExitNodeSuggestionResponse.ID`). Pass this to
92 /// [`Config::exit_node`](ts_control::Config) / `set_exit_node` as a
93 /// [`StableId`](ts_control::ExitNodeSelector::StableId) selector to engage it.
94 pub id: StableNodeId,
95 /// The suggested exit node's display name (`apitype.ExitNodeSuggestionResponse.Name`), for
96 /// surfacing to the user (the daemon prints it with a `--exit-node=` hint).
97 pub name: String,
98}
99
100/// Why an exit-node suggestion could not be produced — the Rust analog of Go's `ErrNoPreferredDERP`.
101///
102/// This is distinct from "no suggestion": an empty result (no eligible candidate) is `Ok(None)`,
103/// not an error (mirroring Go returning an empty response with a nil error). The only error state in
104/// the Phase-1 DERP path is the precondition failure below.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum SuggestExitNodeError {
107 /// No usable netcheck report yet: there is no measured preferred DERP region
108 /// ([`NetcheckReport::preferred_derp`] is `None`/`0`), so the latency-based region ranking can't
109 /// run. Go returns `ErrNoPreferredDERP` ("no preferred DERP, try again later"); callers tolerate
110 /// it and retry once a netcheck has completed.
111 NoPreferredDerp,
112}
113
114impl core::fmt::Display for SuggestExitNodeError {
115 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
116 match self {
117 Self::NoPreferredDerp => write!(f, "no preferred DERP, try again later"),
118 }
119 }
120}
121
122impl core::error::Error for SuggestExitNodeError {}
123
124/// A region-selection closure: given the candidate regions (those with at least one DERP-homed
125/// candidate), return one to draw the suggestion from. Port of Go's `selectRegionFunc`. Only invoked
126/// as the fallback when no region has a usable measured latency (`min_latency_derp_region` returns
127/// `None`); production passes the uniform `random_region`, tests pass a deterministic stub.
128pub type SelectRegion<'a> = dyn Fn(&[RegionId]) -> RegionId + 'a;
129
130/// A node-selection closure: given a region's candidates and the previous suggestion, return the
131/// chosen one. Port of Go's `selectNodeFunc`. Encapsulates the `prev_suggestion` **stickiness** plus
132/// the uniform-random fallback; production passes `random_node`, tests pass a deterministic stub.
133/// The slice is always non-empty when invoked.
134pub type SelectNode<'a> =
135 dyn Fn(&[ExitNodeCandidate], Option<&StableNodeId>) -> ExitNodeCandidate + 'a;
136
137/// Suggest an exit node from `candidates` given the latest netcheck `report` and the previous
138/// suggestion (for stickiness). Pure port of Go `suggestExitNodeUsingDERP` (v1.100.0), the classic
139/// DERP-region-latency path.
140///
141/// `select_region` / `select_node` are injected (Go's `selectRegionFunc` / `selectNodeFunc`) so the
142/// algorithm is deterministic under test; production passes `random_region` / `random_node`.
143///
144/// Returns:
145/// - `Err(`[`SuggestExitNodeError::NoPreferredDerp`]`)` when `report.preferred_derp` is `None`/`0`
146/// (Go's `ErrNoPreferredDERP` precondition — no netcheck yet).
147/// - `Ok(None)` when no candidate is eligible (Go's empty response + nil error — *not* an error).
148/// - `Ok(Some(suggestion))` otherwise.
149///
150/// ## Algorithm (faithful to Go)
151/// 1. Precondition: a preferred DERP region must exist, else `NoPreferredDerp`.
152/// 2. Filter to eligible candidates (`ExitNodeCandidate::is_eligible`). 0 ⇒ `Ok(None)`.
153/// 3. Exactly 1 ⇒ return it directly (no region/RNG logic), as Go does.
154/// 4. 2+ ⇒ partition by home DERP region. If any candidate is DERP-homed: `min_region` =
155/// lowest-latency region (tiebreak lowest id); if no region has a usable latency, fall back to
156/// `select_region`. Then `select_node` picks within `min_region` (stickiness or random). A
157/// region-less candidate is only considered when *no* DERP-homed candidate exists.
158///
159/// ## Phase-1 scope / deviations from Go (documented, deliberate)
160/// - **IPv4-only exit-route check.** Go's candidate predicate requires `tsaddr.ContainsExitRoutes`
161/// = advertising *both* `0.0.0.0/0` **and** `::/0`. This fork is IPv4-only (a SACRED invariant),
162/// so its peers advertise only `0.0.0.0/0`; a verbatim port would suggest nothing on every fork
163/// tailnet. Per the resolved parity decision (`docs/DEFERRED-QUESTIONS.md`), a candidate is
164/// accepted on `0.0.0.0/0` alone ([`ExitNodeCandidate::advertises_exit_route`]), matching the
165/// fork's family-agnostic exit-node check.
166/// - **No traffic-steering path.** Go's `suggestExitNode` first dispatches to
167/// `suggestExitNodeUsingTrafficSteering` when the tailnet sets `NodeAttrTrafficSteering`; that
168/// path is Phase 2 and not ported (this is the `else` branch only).
169/// - **No Mullvad geo path / no `AllowedSuggestedExitNodes` policy.** When *no* candidate has a DERP
170/// home, Go ranks region-less (Mullvad) candidates by geographic distance + location priority.
171/// This fork's domain node carries no location, so the geo weighting is deferred to Phase 2;
172/// under Phase 1 a purely region-less candidate set falls back to `select_node` over all
173/// candidates *without* geo weighting (the simplest faithful behavior). The allow-list gate is
174/// likewise absent (allow-all) since the fork has no such policy yet.
175/// - **`Location` omitted** from the result (the domain node has none yet) — see
176/// [`ExitNodeSuggestion`].
177pub(crate) fn suggest_exit_node(
178 report: &NetcheckReport,
179 candidates: &[ExitNodeCandidate],
180 prev_suggestion: Option<&StableNodeId>,
181 select_region: &SelectRegion<'_>,
182 select_node: &SelectNode<'_>,
183) -> Result<Option<ExitNodeSuggestion>, SuggestExitNodeError> {
184 // 1. Precondition: a measured preferred DERP region must exist (Go: report == nil ||
185 // report.PreferredDERP == 0 || no DERPMap ⇒ ErrNoPreferredDERP). The fork's report carries no
186 // DERPMap (it isn't needed for the DERP-latency path), so the gate is "preferred_derp set and
187 // non-zero". A `Some(0)` is impossible (region ids are NonZeroU32-derived) but guarded anyway.
188 match report.preferred_derp {
189 None | Some(0) => return Err(SuggestExitNodeError::NoPreferredDerp),
190 Some(_) => {}
191 }
192
193 // 2. Filter to eligible candidates (Go's AppendMatchingPeers predicate).
194 let eligible: Vec<&ExitNodeCandidate> = candidates.iter().filter(|c| c.is_eligible()).collect();
195
196 // 3. 0 ⇒ no suggestion (Go: empty response, nil error). 1 ⇒ return it directly (no RNG).
197 match eligible.as_slice() {
198 [] => return Ok(None),
199 [only] => {
200 return Ok(Some(ExitNodeSuggestion {
201 id: only.stable_id.clone(),
202 name: only.name.clone(),
203 }));
204 }
205 _ => {}
206 }
207
208 // 4. Partition the 2+ eligible candidates by home DERP region. Region-less candidates are held
209 // separately and only used when NO DERP-homed candidate exists (Go: "never select a candidate
210 // without a DERP home if there is a candidate available with a DERP home").
211 let mut by_region: std::collections::BTreeMap<RegionId, Vec<ExitNodeCandidate>> =
212 std::collections::BTreeMap::new();
213 let mut region_less: Vec<ExitNodeCandidate> = Vec::new();
214 for c in eligible {
215 match c.derp_region {
216 Some(region) => by_region.entry(region).or_default().push(c.clone()),
217 None => region_less.push(c.clone()),
218 }
219 }
220
221 if !by_region.is_empty() {
222 // DERP-homed path (the Phase-1 common case). Pick the lowest-latency region (tiebreak lowest
223 // id); if none has a usable latency, fall back to the injected region selector.
224 let regions: Vec<RegionId> = by_region.keys().copied().collect();
225 let min_region = match min_latency_derp_region(®ions, report) {
226 Some(region) => region,
227 None => select_region(®ions),
228 };
229 // `min_region` is always a key of `by_region` (it came from `regions`, the key set, whether
230 // via the latency ranking or the selector restricted to those keys). The selectors never
231 // invent a region — Go treats a miss here as "this is a bug".
232 let region_candidates = by_region
233 .get(&min_region)
234 .expect("selected region must be a candidate region");
235 let chosen = select_node(region_candidates, prev_suggestion);
236 return Ok(Some(ExitNodeSuggestion {
237 id: chosen.stable_id,
238 name: chosen.name,
239 }));
240 }
241
242 // No DERP-homed candidate: Phase-1 fallback over the region-less set without geo weighting (the
243 // Mullvad geo-distance + priority ranking is Phase 2 — see the doc comment). `region_less` is
244 // non-empty here (we had 2+ eligible candidates and none was DERP-homed).
245 let chosen = select_node(®ion_less, prev_suggestion);
246 Ok(Some(ExitNodeSuggestion {
247 id: chosen.stable_id,
248 name: chosen.name,
249 }))
250}
251
252/// The region with the lowest measured latency in `report`, tiebroken by the lowest region id;
253/// `None` when the winner has no usable latency. Pure port of Go `minLatencyDERPRegion`.
254///
255/// Mirrors Go's `slices.MinFunc` semantics exactly: a region missing from the report's latency map
256/// is treated as the maximum latency (so a region with *any* measurement always beats one with
257/// none), ties on latency break to the lower region id, and if the winning region's latency is
258/// missing *or* exactly zero the function returns `None` (Go returns `0`) — signalling the caller to
259/// fall back to a uniform region pick. `regions` is the candidate region set and is never empty when
260/// called.
261fn min_latency_derp_region(regions: &[RegionId], report: &NetcheckReport) -> Option<RegionId> {
262 // Latency lookup keyed by region id. The report stores an ordered Vec (sorted ascending), but we
263 // index by id to mirror Go's `report.RegionLatency[region]` map access.
264 let latency_of = |region: RegionId| -> Option<core::time::Duration> {
265 report
266 .region_latencies
267 .iter()
268 .find(|rl| rl.region_id == region.0.get())
269 .map(|rl| rl.latency)
270 };
271
272 // `slices.MinFunc`: a missing latency sorts as the largest possible value; ties break to the
273 // lower region id. Using `core::cmp::max` as the "missing" sentinel matches Go's
274 // `largeDuration = math.MaxInt64` semantics (any real measurement is smaller).
275 let max_duration = core::time::Duration::MAX;
276 let min = regions.iter().copied().min_by(|&i, &j| {
277 let il = latency_of(i).unwrap_or(max_duration);
278 let jl = latency_of(j).unwrap_or(max_duration);
279 il.cmp(&jl).then_with(|| i.0.get().cmp(&j.0.get()))
280 })?;
281
282 // Go: if the winner's latency is missing or 0, return 0 (⇒ caller does a uniform pick).
283 match latency_of(min) {
284 Some(latency) if !latency.is_zero() => Some(min),
285 _ => None,
286 }
287}
288
289/// A uniformly-random region from `regions` — the production [`SelectRegion`](crate::exit_node_suggest::SelectRegion).
290/// Port of Go `randomRegion`. `regions` must be non-empty (it always is when the algorithm invokes
291/// the selector).
292pub(crate) fn random_region(regions: &[RegionId]) -> RegionId {
293 regions[rand::random_range(0..regions.len())]
294}
295
296/// A node from `nodes`, preferring `prefer` (the previous suggestion) when it is still present —
297/// otherwise a uniformly-random node. The production
298/// [`SelectNode`](crate::exit_node_suggest::SelectNode) and a verbatim port of Go `randomNode`: this
299/// is where `prev_suggestion` **stickiness** lives. `nodes` must be non-empty.
300pub(crate) fn random_node(
301 nodes: &[ExitNodeCandidate],
302 prefer: Option<&StableNodeId>,
303) -> ExitNodeCandidate {
304 // Go `randomNode` guards `if !prefer.IsZero()` — an empty StableNodeID is "no preference", never
305 // a match target. `prev_suggestion` is only ever set from a real peer's id, so an empty id is
306 // unreachable in practice, but mirror Go's guard exactly so a stray empty id can't stick.
307 if let Some(prefer) = prefer.filter(|p| !p.0.is_empty())
308 && let Some(found) = nodes.iter().find(|n| &n.stable_id == prefer)
309 {
310 return found.clone();
311 }
312 nodes[rand::random_range(0..nodes.len())].clone()
313}
314
315/// Compute the next sticky `prev_suggestion` value from the previous one and a suggestion outcome,
316/// mirroring Go `suggestExitNodeLocked` (`ipn/ipnlocal/local.go`): it assigns `b.lastSuggestedExitNode
317/// = res.ID` on **every** no-error return, so a successful suggestion sets the sticky id, an empty
318/// result (`res.ID == ""`) clears it, and only an error returns before the assignment (leaving the
319/// prior value in place). Pure + testable so the [`Runtime`](crate::Runtime)-level stickiness
320/// lifecycle is covered without standing up an actor.
321pub(crate) fn next_sticky(
322 prev: Option<StableNodeId>,
323 outcome: &Result<Option<ExitNodeSuggestion>, SuggestExitNodeError>,
324) -> Option<StableNodeId> {
325 match outcome {
326 // No-error path (Go: `lastSuggestedExitNode = res.ID`). `Some` sets it; `None` clears it.
327 Ok(maybe) => maybe.as_ref().map(|s| s.id.clone()),
328 // Go returns before the assignment on `ErrNoPreferredDERP` — keep the prior sticky value.
329 Err(_) => prev,
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::status::RegionLatency;
337
338 fn region(id: u32) -> RegionId {
339 RegionId(core::num::NonZeroU32::new(id).unwrap())
340 }
341
342 /// Build a netcheck report from `(region_id, latency_ms)` pairs with the given preferred region.
343 /// The first pair is treated as preferred only via the explicit `preferred` arg (the latency map
344 /// drives ranking, not order).
345 fn report(preferred: Option<u32>, latencies: &[(u32, u64)]) -> NetcheckReport {
346 NetcheckReport {
347 preferred_derp: preferred,
348 region_latencies: latencies
349 .iter()
350 .map(|&(region_id, ms)| RegionLatency {
351 region_id,
352 latency: core::time::Duration::from_millis(ms),
353 })
354 .collect(),
355 }
356 }
357
358 /// An eligible candidate (online + suggest-cap + exit-route) in `derp` region, named `peer<id>`
359 /// with stable id `stable<id>`. Mirrors Go's `makePeer(id, withExitRoutes(), withSuggest())`
360 /// where `HomeDERP` defaults to the id unless overridden.
361 fn candidate(id: u32, derp: Option<u32>) -> ExitNodeCandidate {
362 ExitNodeCandidate {
363 stable_id: StableNodeId(format!("stable{id}")),
364 name: format!("peer{id}"),
365 derp_region: derp.map(region),
366 online: Some(true),
367 advertises_exit_route: true,
368 has_suggest_cap: true,
369 }
370 }
371
372 /// A deterministic [`SelectRegion`] stub asserting the offered region set equals `want` (any
373 /// order) and returning `use_region`. Port of Go's `deterministicRegionForTest`.
374 fn pick_region(want: Vec<RegionId>, use_region: RegionId) -> impl Fn(&[RegionId]) -> RegionId {
375 move |got: &[RegionId]| {
376 let mut got_sorted = got.to_vec();
377 got_sorted.sort();
378 let mut want_sorted = want.clone();
379 want_sorted.sort();
380 assert_eq!(got_sorted, want_sorted, "candidate regions mismatch");
381 assert!(want.contains(&use_region), "use_region must be in want");
382 use_region
383 }
384 }
385
386 /// A deterministic [`SelectNode`] stub asserting the offered candidate id set equals `want` (any
387 /// order) and that `last` equals `want_last`, then returning the candidate whose id is `use_id`.
388 /// Port of Go's `deterministicNodeForTest` (which also calls the real `randomNode` and checks it
389 /// returns a member — replicated here to exercise the production selector).
390 fn pick_node(
391 want: Vec<&'static str>,
392 want_last: Option<&'static str>,
393 use_id: &'static str,
394 ) -> impl Fn(&[ExitNodeCandidate], Option<&StableNodeId>) -> ExitNodeCandidate {
395 move |got: &[ExitNodeCandidate], last: Option<&StableNodeId>| {
396 // Exercise the real uniform selector and confirm it returns a member (Go does this too).
397 let via_random = random_node(got, last);
398 assert!(
399 got.iter().any(|c| c.stable_id == via_random.stable_id),
400 "random_node returned a non-member"
401 );
402
403 let got_ids: Vec<String> = got.iter().map(|c| c.stable_id.0.clone()).collect();
404 let mut got_sorted = got_ids.clone();
405 got_sorted.sort();
406 let mut want_sorted: Vec<String> = want.iter().map(|s| s.to_string()).collect();
407 want_sorted.sort();
408 assert_eq!(got_sorted, want_sorted, "candidate nodes mismatch");
409
410 let last_str = last.map(|s| s.0.as_str());
411 assert_eq!(last_str, want_last, "last (prev suggestion) mismatch");
412
413 got.iter()
414 .find(|c| c.stable_id.0 == use_id)
415 .cloned()
416 .expect("use_id must be among candidates")
417 }
418 }
419
420 /// A selector that must never be called (the path under test bypasses it). Panics if invoked.
421 fn unused_region() -> impl Fn(&[RegionId]) -> RegionId {
422 |_: &[RegionId]| panic!("select_region must not be called on this path")
423 }
424 fn unused_node() -> impl Fn(&[ExitNodeCandidate], Option<&StableNodeId>) -> ExitNodeCandidate {
425 |_: &[ExitNodeCandidate], _: Option<&StableNodeId>| {
426 panic!("select_node must not be called on this path")
427 }
428 }
429
430 /// `preferred_derp == None` ⇒ `ErrNoPreferredDERP` (Go's nil-report / no-preferred-DERP cases).
431 #[test]
432 fn no_preferred_derp_errors() {
433 let r = report(None, &[(1, 10)]);
434 let cands = [candidate(1, Some(1)), candidate(2, Some(2))];
435 let err = suggest_exit_node(&r, &cands, None, &unused_region(), &unused_node())
436 .expect_err("no preferred DERP must error");
437 assert_eq!(err, SuggestExitNodeError::NoPreferredDerp);
438
439 // `Some(0)` is likewise the no-preferred-DERP precondition (Go `PreferredDERP == 0`).
440 let r0 = report(Some(0), &[(1, 10)]);
441 assert_eq!(
442 suggest_exit_node(&r0, &cands, None, &unused_region(), &unused_node()),
443 Err(SuggestExitNodeError::NoPreferredDerp)
444 );
445 }
446
447 /// 0 eligible candidates ⇒ `Ok(None)` (Go: empty response, nil error — NOT an error).
448 #[test]
449 fn no_candidates_returns_none() {
450 let r = report(Some(1), &[(1, 10)]);
451 assert_eq!(
452 suggest_exit_node(&r, &[], None, &unused_region(), &unused_node()),
453 Ok(None)
454 );
455 }
456
457 /// Exactly 1 eligible candidate ⇒ returned directly, no region/node selector invoked.
458 #[test]
459 fn single_candidate_returned_directly() {
460 let r = report(Some(1), &[(1, 10)]);
461 let cands = [candidate(7, Some(2))];
462 let got = suggest_exit_node(&r, &cands, None, &unused_region(), &unused_node())
463 .expect("ok")
464 .expect("some");
465 assert_eq!(got.id, StableNodeId("stable7".into()));
466 assert_eq!(got.name, "peer7");
467 }
468
469 /// 2 candidates in different regions, region 1 lower latency ⇒ the region-1 candidate wins.
470 /// (Go `large-netmap`-style: lowest-latency region selected, then the sole node in it.)
471 #[test]
472 fn two_regions_lower_latency_wins() {
473 // peer2 in region 1 (10ms), peer4 in region 3 (30ms) ⇒ region 1 wins ⇒ peer2.
474 let r = report(Some(1), &[(1, 10), (2, 20), (3, 30)]);
475 let cands = [candidate(2, Some(1)), candidate(4, Some(3))];
476 let select_node = pick_node(vec!["stable2"], None, "stable2");
477 let got = suggest_exit_node(&r, &cands, None, &unused_region(), &select_node)
478 .expect("ok")
479 .expect("some");
480 assert_eq!(got.id, StableNodeId("stable2".into()));
481 assert_eq!(got.name, "peer2");
482 }
483
484 /// 2 candidates in the same region ⇒ `select_node` picks deterministically among both.
485 /// (Go `2-exits-same-region`.)
486 #[test]
487 fn two_candidates_same_region_select_node_picks() {
488 let r = report(Some(1), &[(1, 10), (2, 20), (3, 30)]);
489 let cands = [candidate(1, Some(1)), candidate(2, Some(1))];
490 let select_node = pick_node(vec!["stable1", "stable2"], None, "stable1");
491 let got = suggest_exit_node(&r, &cands, None, &unused_region(), &select_node)
492 .expect("ok")
493 .expect("some");
494 assert_eq!(got.id, StableNodeId("stable1".into()));
495 assert_eq!(got.name, "peer1");
496 }
497
498 /// `prev_suggestion` stickiness: prev is in the winning region's list ⇒ it is returned (the prev
499 /// id is threaded to `select_node` as `last`). Go `prefer-last-node`.
500 #[test]
501 fn prev_suggestion_sticky_when_present() {
502 let r = report(Some(1), &[(1, 10), (2, 20), (3, 30)]);
503 let cands = [candidate(1, Some(1)), candidate(2, Some(1))];
504 let prev = StableNodeId("stable2".into());
505 // select_node sees both, `last == stable2`, and (via real random_node stickiness) returns it.
506 let select_node = pick_node(vec!["stable1", "stable2"], Some("stable2"), "stable2");
507 let got = suggest_exit_node(&r, &cands, Some(&prev), &unused_region(), &select_node)
508 .expect("ok")
509 .expect("some");
510 assert_eq!(got.id, StableNodeId("stable2".into()));
511 assert_eq!(got.name, "peer2");
512 }
513
514 /// Stickiness does NOT override a better region: prev suggestion is in a higher-latency region,
515 /// so the lower-latency region still wins (prev isn't even offered to `select_node`). Go
516 /// `found-better-derp-node` (lastSuggestion stable3 in region 3, but region 1 wins ⇒ stable2).
517 #[test]
518 fn better_region_beats_stale_prev_suggestion() {
519 let r = report(Some(1), &[(1, 10), (2, 20), (3, 30)]);
520 // peer2 region 1 (10ms), peer3 region 3 (30ms). prev = stable3 (region 3, higher latency).
521 let cands = [candidate(2, Some(1)), candidate(3, Some(3))];
522 let prev = StableNodeId("stable3".into());
523 // Region 1 wins; only peer2 is in it; `last` is still threaded through as stable3.
524 let select_node = pick_node(vec!["stable2"], Some("stable3"), "stable2");
525 let got = suggest_exit_node(&r, &cands, Some(&prev), &unused_region(), &select_node)
526 .expect("ok")
527 .expect("some");
528 assert_eq!(got.id, StableNodeId("stable2".into()));
529 }
530
531 /// Region latency tiebreak: two regions with equal latency ⇒ the lower region id wins (no
532 /// selector fallback, since the latencies are usable/non-zero). Go
533 /// `2-derp-exits-different-regions-equal-latency` (regions 1 & 3 both 10 ⇒ region 1).
534 #[test]
535 fn equal_latency_lower_region_id_wins() {
536 // peer1 region 1, peer3 region 3, both 10ms ⇒ region 1 (lower id) ⇒ peer1.
537 let r = report(Some(1), &[(1, 10), (2, 20), (3, 10)]);
538 let cands = [candidate(1, Some(1)), candidate(3, Some(3))];
539 let select_node = pick_node(vec!["stable1"], None, "stable1");
540 let got = suggest_exit_node(&r, &cands, None, &unused_region(), &select_node)
541 .expect("ok")
542 .expect("some");
543 assert_eq!(got.id, StableNodeId("stable1".into()));
544 assert_eq!(got.name, "peer1");
545 }
546
547 /// All candidate regions have zero/unknown latency ⇒ `min_latency_derp_region` returns `None`
548 /// and `select_region` is the fallback (uniform region pick), then `select_node` within it. Go
549 /// `2-exits-different-regions-unknown-latency` (regions 1 & 3, all-zero latencies ⇒ selectRegion).
550 #[test]
551 fn no_usable_latency_falls_back_to_select_region() {
552 // peer2 region 1, peer4 region 3, all latencies 0 ⇒ region ranking unusable ⇒ select_region
553 // (offered {1,3}, returns 1) ⇒ peer2.
554 let r = report(Some(1), &[(1, 0), (2, 0), (3, 0)]);
555 let cands = [candidate(2, Some(1)), candidate(4, Some(3))];
556 let select_region = pick_region(vec![region(1), region(3)], region(1));
557 let select_node = pick_node(vec!["stable2"], None, "stable2");
558 let got = suggest_exit_node(&r, &cands, None, &select_region, &select_node)
559 .expect("ok")
560 .expect("some");
561 assert_eq!(got.id, StableNodeId("stable2".into()));
562 assert_eq!(got.name, "peer2");
563 }
564
565 /// A region missing from the latency map is treated as max latency, so a region WITH a
566 /// measurement always wins — even a higher id beats a missing one only when... no: lower latency
567 /// wins. Here region 3 has 10ms, region 1 is missing ⇒ region 3 wins despite the higher id.
568 #[test]
569 fn missing_latency_loses_to_measured_region() {
570 let r = report(Some(3), &[(3, 10)]); // region 1 absent from the map
571 let cands = [candidate(1, Some(1)), candidate(3, Some(3))];
572 let select_node = pick_node(vec!["stable3"], None, "stable3");
573 let got = suggest_exit_node(&r, &cands, None, &unused_region(), &select_node)
574 .expect("ok")
575 .expect("some");
576 assert_eq!(got.id, StableNodeId("stable3".into()));
577 }
578
579 /// Candidate predicate — a peer WITHOUT the suggest-exit-node cap is excluded. With only one
580 /// other eligible peer left, that one is returned directly (proves the non-eligible one was
581 /// dropped before the count check).
582 #[test]
583 fn predicate_excludes_missing_suggest_cap() {
584 let r = report(Some(1), &[(1, 10)]);
585 let mut no_cap = candidate(1, Some(1));
586 no_cap.has_suggest_cap = false;
587 let cands = [no_cap, candidate(2, Some(2))];
588 // Only peer2 is eligible ⇒ single-candidate direct return (no selector).
589 let got = suggest_exit_node(&r, &cands, None, &unused_region(), &unused_node())
590 .expect("ok")
591 .expect("some");
592 assert_eq!(got.id, StableNodeId("stable2".into()));
593 }
594
595 /// Candidate predicate — a peer NOT advertising an exit route (`0.0.0.0/0`) is excluded.
596 #[test]
597 fn predicate_excludes_no_exit_route() {
598 let r = report(Some(1), &[(1, 10)]);
599 let mut no_route = candidate(1, Some(1));
600 no_route.advertises_exit_route = false;
601 let cands = [no_route, candidate(2, Some(2))];
602 let got = suggest_exit_node(&r, &cands, None, &unused_region(), &unused_node())
603 .expect("ok")
604 .expect("some");
605 assert_eq!(got.id, StableNodeId("stable2".into()));
606 }
607
608 /// Candidate predicate — an offline peer (online != Some(true)) is excluded; a tri-state `None`
609 /// is also excluded (fail-closed).
610 #[test]
611 fn predicate_excludes_offline_and_unknown() {
612 let r = report(Some(1), &[(1, 10)]);
613 let mut offline = candidate(1, Some(1));
614 offline.online = Some(false);
615 let mut unknown = candidate(3, Some(3));
616 unknown.online = None;
617 let cands = [offline, unknown, candidate(2, Some(2))];
618 // Only peer2 survives ⇒ direct return.
619 let got = suggest_exit_node(&r, &cands, None, &unused_region(), &unused_node())
620 .expect("ok")
621 .expect("some");
622 assert_eq!(got.id, StableNodeId("stable2".into()));
623
624 // If the ONLY candidate is offline ⇒ no eligible candidates ⇒ Ok(None).
625 let r2 = report(Some(1), &[(1, 10)]);
626 let mut lone_offline = candidate(9, Some(1));
627 lone_offline.online = Some(false);
628 assert_eq!(
629 suggest_exit_node(&r2, &[lone_offline], None, &unused_region(), &unused_node()),
630 Ok(None)
631 );
632 }
633
634 /// All eligible candidates are region-less (no DERP home) ⇒ Phase-1 fallback selects over the
635 /// whole region-less set via `select_node` (no geo weighting; geo is Phase 2). `select_region`
636 /// is never called.
637 #[test]
638 fn all_region_less_falls_back_to_select_node() {
639 let r = report(Some(1), &[(1, 10)]);
640 let cands = [candidate(5, None), candidate(6, None)];
641 let select_node = pick_node(vec!["stable5", "stable6"], None, "stable5");
642 let got = suggest_exit_node(&r, &cands, None, &unused_region(), &select_node)
643 .expect("ok")
644 .expect("some");
645 assert_eq!(got.id, StableNodeId("stable5".into()));
646 assert_eq!(got.name, "peer5");
647 }
648
649 /// A region-less candidate is NOT selected when a DERP-homed candidate exists (Go: "never select
650 /// a candidate without a DERP home if there is a candidate available with a DERP home"). Here a
651 /// region-less peer6 + a DERP-homed peer2 ⇒ only peer2's region is considered.
652 #[test]
653 fn region_less_skipped_when_derp_homed_exists() {
654 let r = report(Some(1), &[(1, 10)]);
655 let cands = [candidate(6, None), candidate(2, Some(1))];
656 // Only region 1 (peer2) is offered to select_node; peer6 (region-less) is dropped.
657 let select_node = pick_node(vec!["stable2"], None, "stable2");
658 let got = suggest_exit_node(&r, &cands, None, &unused_region(), &select_node)
659 .expect("ok")
660 .expect("some");
661 assert_eq!(got.id, StableNodeId("stable2".into()));
662 }
663
664 /// `random_node` stickiness in isolation: prefer present ⇒ returned; prefer absent ⇒ a member is
665 /// still returned.
666 #[test]
667 fn random_node_prefers_then_falls_back() {
668 let cands = [candidate(1, Some(1)), candidate(2, Some(1))];
669 let prefer = StableNodeId("stable2".into());
670 assert_eq!(random_node(&cands, Some(&prefer)).stable_id, prefer);
671
672 // Absent prefer ⇒ a uniform pick that is still one of the candidates.
673 let absent = StableNodeId("stableX".into());
674 let got = random_node(&cands, Some(&absent));
675 assert!(cands.iter().any(|c| c.stable_id == got.stable_id));
676
677 // No prefer ⇒ likewise a member.
678 let got2 = random_node(&cands, None);
679 assert!(cands.iter().any(|c| c.stable_id == got2.stable_id));
680 }
681
682 /// `min_latency_derp_region` direct unit checks: lowest wins, equal ⇒ lower id, all-zero ⇒ None,
683 /// missing-on-winner ⇒ None.
684 #[test]
685 fn min_latency_region_semantics() {
686 let r = report(Some(1), &[(1, 30), (2, 10), (3, 20)]);
687 assert_eq!(
688 min_latency_derp_region(&[region(1), region(2), region(3)], &r),
689 Some(region(2))
690 );
691 // Equal latency ⇒ lower id.
692 let req = report(Some(1), &[(1, 10), (2, 10)]);
693 assert_eq!(
694 min_latency_derp_region(&[region(1), region(2)], &req),
695 Some(region(1))
696 );
697 // All zero ⇒ None (caller falls back to select_region).
698 let rz = report(Some(1), &[(1, 0), (2, 0)]);
699 assert_eq!(min_latency_derp_region(&[region(1), region(2)], &rz), None);
700 // Winner missing from map ⇒ None. (region 5 not in the map; it's the only candidate.)
701 let rm = report(Some(1), &[(1, 10)]);
702 assert_eq!(min_latency_derp_region(&[region(5)], &rm), None);
703 }
704
705 /// `next_sticky` mirrors Go `suggestExitNodeLocked`'s `lastSuggestedExitNode = res.ID` on every
706 /// no-error return: a suggestion SETS the sticky id, an empty result CLEARS it, and an error
707 /// leaves the prior value untouched. This covers the `Runtime`-level stickiness lifecycle (the
708 /// actor reads `prev`, calls `suggest_exit_node`, then stores `next_sticky(prev, &outcome)`).
709 #[test]
710 fn next_sticky_matches_go_last_suggested() {
711 let sugg = ExitNodeSuggestion {
712 id: StableNodeId("stable2".to_owned()),
713 name: "peer2".to_owned(),
714 };
715 let prev = || Some(StableNodeId("stable1".to_owned()));
716
717 // Ok(Some) ⇒ take the new id (overwrites any prior).
718 assert_eq!(
719 next_sticky(prev(), &Ok(Some(sugg.clone()))),
720 Some(StableNodeId("stable2".to_owned()))
721 );
722 assert_eq!(
723 next_sticky(None, &Ok(Some(sugg))),
724 Some(StableNodeId("stable2".to_owned()))
725 );
726
727 // Ok(None) ⇒ CLEAR (Go assigns res.ID == ""), even with a prior sticky value.
728 assert_eq!(next_sticky(prev(), &Ok(None)), None);
729
730 // Err ⇒ keep the prior (Go returns before the assignment).
731 assert_eq!(
732 next_sticky(prev(), &Err(SuggestExitNodeError::NoPreferredDerp)),
733 prev()
734 );
735 assert_eq!(
736 next_sticky(None, &Err(SuggestExitNodeError::NoPreferredDerp)),
737 None
738 );
739 }
740
741 /// The empty-id guard in `random_node` (Go's `!prefer.IsZero()`): an empty `prefer` is never a
742 /// match target — selection falls through to the uniform pick (here a single-element list).
743 #[test]
744 fn random_node_ignores_empty_prefer_id() {
745 let only = candidate(7, Some(1));
746 let empty = StableNodeId(String::new());
747 // Empty prefer ⇒ no sticky match; with one candidate the uniform pick returns it.
748 let picked = random_node(std::slice::from_ref(&only), Some(&empty));
749 assert_eq!(picked.stable_id, only.stable_id);
750 }
751}