Skip to main content

ts_runtime/
exit_node_suggest.rs

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