Skip to main content

ts_runtime/
status.rs

1//! Netmap status aggregation, WhoIs lookups, and a netmap-change watcher.
2//!
3//! These surface the internal netmap state ([`ts_control::StateUpdate`], consumed by the
4//! [`PeerTracker`](crate::peer_tracker::PeerTracker)) to embedders, mirroring tsnet's
5//! `LocalClient::Status`, `WhoIs`, and `WatchIPNBus`.
6//!
7//! ## Capability / user / online surfacing (do not fabricate)
8//!
9//! tsnet's `Status`/`WhoIs` also carry per-node *online* state, the owning *user* (login/profile),
10//! and a *capability map*. Status of each in this fork:
11//! - **Capabilities** — surfaced: [`WhoIs::capabilities`] is populated from the domain
12//!   [`Node`](ts_control::Node)'s `cap_map` (the control-pushed `CapMap`), which the domain model
13//!   retains.
14//! - **User (login/profile)** — surfaced when the netmap provided it: [`WhoIs::user`] is the owning
15//!   user's login/display name, resolved by joining the node's owning user id against the netmap's
16//!   `UserProfiles` table (accumulated by the [`PeerTracker`](crate::peer_tracker::PeerTracker)
17//!   across delta updates). `None` when control sent no profile for that user.
18//! - **Online state** — surfaced: [`StatusNode::online`] / [`StatusNode::last_seen`] reflect the
19//!   domain [`Node`](ts_control::Node)'s retained `online`/`last_seen`, populated from the netmap
20//!   node and its online deltas (`PeerChange`, `MapResponse.online_change`/`peer_seen_change`).
21//!   `online` stays tri-state (`None` = unknown), never fabricated to `false`.
22
23use std::{
24    collections::BTreeMap,
25    net::{IpAddr, SocketAddr},
26};
27
28use ts_control::{Node, StableNodeId, UserId};
29
30/// A snapshot of the local netmap: this node plus every known peer.
31///
32/// Analogous to tsnet's `ipnstate.Status`. Built by [`Runtime::status`](crate::Runtime::status)
33/// from the self node held by the control runner and the peers held by the peer tracker.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Status {
36    /// This node, if a netmap has been received from control yet.
37    pub self_node: Option<StatusNode>,
38    /// Every peer currently known in the netmap.
39    pub peers: Vec<StatusNode>,
40    /// The stable id of the exit node traffic is **currently** egressing through, if any (Go's
41    /// `Status.ExitNodeStatus.ID`). This is the *resolved + fail-closed* answer from the route
42    /// updater — `None` when no exit node is configured, the configured selector matches no peer, or
43    /// the matched peer no longer advertises a default route — so it reflects what is actually
44    /// engaged, not merely what [`Config::exit_node`](ts_control::Config) requested. Find the peer's
45    /// details by matching this id against [`peers`](Status::peers).
46    pub active_exit_node: Option<StableNodeId>,
47    /// The tailnet's MagicDNS suffix (e.g. `"tail0123.ts.net"`) — Go `ipnstate.Status.MagicDNSSuffix`.
48    /// Derived (like Go's `NetworkMap.MagicDNSSuffix`) from the self node's FQDN minus its host label,
49    /// **not** from the DNS config and **not** from the tailnet `Domain` name. `None` before the first
50    /// netmap, or when the self FQDN has no tailnet component (a bare hostname).
51    pub magic_dns_suffix: Option<String>,
52}
53
54/// A single node entry in a [`Status`] snapshot.
55///
56/// Analogous to tsnet's `ipnstate.PeerStatus`.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct StatusNode {
59    /// The node's stable id (stable across re-registration).
60    pub stable_id: StableNodeId,
61    /// A display name for the node: its fqdn if a tailnet component is known, else its bare
62    /// hostname.
63    pub display_name: String,
64    /// The node's tailnet IPv4 address.
65    pub ipv4: IpAddr,
66    /// The node's tailnet IPv6 address.
67    pub ipv6: IpAddr,
68    /// Whether the node is online, if known (`ipnstate.PeerStatus.Online`). Tri-state: `Some(true)`
69    /// connected to control, `Some(false)` offline, `None` unknown (control sent no online status or
70    /// the local node lacks permission to know). Reflects control's liveness state, retained from the
71    /// netmap node + its online deltas — `None` is *unknown*, never fabricated to `false`.
72    pub online: Option<bool>,
73    /// When control last saw this node online (`ipnstate.PeerStatus.LastSeen`). Per Go, only
74    /// meaningful while the node is not currently online. `None` when unknown or never seen.
75    pub last_seen: Option<chrono::DateTime<chrono::Utc>>,
76    /// The routes this node accepts traffic for (its own `/32` and `/128`, plus any advertised
77    /// subnet routes and possibly the exit-node default route).
78    pub allowed_routes: Vec<ipnet::IpNet>,
79    /// Whether this node advertises a default route (`0.0.0.0/0` or `::/0`), making it eligible to
80    /// be selected as an exit node.
81    pub is_exit_node: bool,
82    /// The current trusted direct UDP endpoint for this peer, if a direct path is confirmed right now
83    /// (Go `ipnstate.PeerStatus.CurAddr`). `Some` ⇒ traffic to this peer flows directly to this
84    /// address; `None` ⇒ it relays via DERP (see [`relay`](Self::relay)). Mutually exclusive with a
85    /// `relay` for a routed peer, mirroring Go's empty-vs-set `CurAddr`/`Relay` strings. A live
86    /// snapshot — the direct path can expire/re-confirm between calls. Always `None` for the self node
87    /// and a whois lookup (no path to oneself; whois is an ownership query).
88    pub cur_addr: Option<SocketAddr>,
89    /// The DERP region code this peer relays through when there is **no** direct path (Go
90    /// `ipnstate.PeerStatus.Relay`, e.g. `"nyc"`). `Some` ⇔ [`cur_addr`](Self::cur_addr) is `None`
91    /// and the peer's home DERP region is known; `None` when a direct path is confirmed, or the
92    /// region code is unknown. Carries the region **code**, not its numeric id.
93    pub relay: Option<String>,
94    /// The node's advertised SSH host public keys in known_hosts format (Go
95    /// `ipnstate.PeerStatus.SSH_HostKeys`), used by `tailscale ssh` to pin the peer's host key.
96    /// Mirrors the domain [`Node::ssh_host_keys`](ts_control::Node::ssh_host_keys); empty when
97    /// control advertised none (never fabricated).
98    pub ssh_host_keys: Vec<String>,
99}
100
101impl StatusNode {
102    /// Build a [`StatusNode`] from a domain [`Node`].
103    pub fn from_node(node: &Node) -> Self {
104        let is_exit_node = node
105            .accepted_routes
106            .iter()
107            .any(|route| route.prefix_len() == 0);
108
109        Self {
110            stable_id: node.stable_id.clone(),
111            display_name: node
112                .fqdn_opt(false)
113                .unwrap_or_else(|| node.hostname.clone()),
114            ipv4: node.tailnet_address.ipv4.addr().into(),
115            ipv6: node.tailnet_address.ipv6.addr().into(),
116            online: node.online,
117            last_seen: node.last_seen,
118            allowed_routes: node.accepted_routes.clone(),
119            is_exit_node,
120            // A bare `Node` carries no live path state, so connectivity is unknown here. The peer
121            // tracker overwrites these in `status_peers` by joining against the direct manager; the
122            // self node and whois lookups (which also use `from_node`) correctly keep `None`.
123            cur_addr: None,
124            relay: None,
125            ssh_host_keys: node.ssh_host_keys.clone(),
126        }
127    }
128}
129
130/// The result of a [`Runtime::whois`](crate::Runtime::whois) lookup: the node that owns a tailnet
131/// source address, plus its user and capabilities.
132///
133/// Analogous to tsnet's `apitype.WhoIsResponse`.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct WhoIs {
136    /// The node that owns the queried source IP.
137    pub node: Node,
138    /// The login/email of the user that owns the node, if known.
139    ///
140    /// Always `None` in this fork: the domain [`Node`] does not retain the
141    /// wire-level user/login mapping (see the module-level capability/user gap note).
142    pub user: Option<String>,
143    /// The node's **node-level** capability map (Go `Node.CapMap` — node attributes like
144    /// `can-funnel`), as `(capability, args)` pairs, populated from the domain
145    /// [`Node`]'s `cap_map`, sorted by capability name. Distinct from
146    /// [`cap_map`](Self::cap_map), which is the flow-scoped *peer-capability* grants.
147    pub capabilities: Vec<(String, Vec<String>)>,
148    /// The **flow-scoped** peer-capability grants for the queried `src -> dst` flow — Go
149    /// `apitype.WhoIsResponse.CapMap` (`tailcfg.PeerCapMap`). The grants control's packet-filter
150    /// application rules authorize for traffic from this node to the queried address, keyed by
151    /// capability name with raw-JSON values. Empty when no grant matches the flow (or no scoped
152    /// query was made). Distinct from the node-level [`capabilities`](Self::capabilities).
153    pub cap_map: BTreeMap<String, Vec<String>>,
154}
155
156impl WhoIs {
157    /// Build a [`WhoIs`] from the owning node and its resolved owner login/display name (if the
158    /// netmap's `UserProfiles` table mapped the node's owning user id to a profile; `None` when
159    /// control sent no profile — e.g. a tagged node with no human owner).
160    ///
161    /// `capabilities` is the node-level cap map; `cap_map` (the flow-scoped grants) is filled
162    /// separately by [`Runtime::whois`](crate::Runtime::whois) and defaults to empty here.
163    pub(crate) fn from_node_with_user(node: Node, user: Option<String>) -> Self {
164        let capabilities = node
165            .cap_map
166            .iter()
167            .map(|(cap, args)| (cap.clone(), args.clone()))
168            .collect();
169        Self {
170            node,
171            user,
172            capabilities,
173            cap_map: BTreeMap::new(),
174        }
175    }
176}
177
178/// Resolve which node owns a tailnet source address, used by WhoIs.
179pub(crate) fn whois_addr(addr: SocketAddr) -> IpAddr {
180    addr.ip()
181}
182
183/// A measured-latency entry for one DERP region in a [`NetcheckReport`].
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct RegionLatency {
186    /// The DERP region id (Go `tailcfg.DERPRegionID`).
187    pub region_id: u32,
188    /// The measured round-trip latency to the region's closest DERP node.
189    pub latency: std::time::Duration,
190}
191
192/// A snapshot of this node's latest network conditions report — the Rust analog of Go's
193/// `netcheck.Report` as `tailscale netcheck` surfaces it.
194///
195/// ## Surfaced subset (do not fabricate)
196/// Go's `netcheck.Report` also carries UDP/IPv4/IPv6 reachability, port-mapping support
197/// (UPnP/PMP/PCP), `MappingVariesByDestIP`, global-address discovery, etc. This fork's net-report
198/// path measures only **DERP-region latency** (the data that drives home-region selection), so the
199/// report carries exactly that — the preferred (lowest-latency) region and the per-region latency
200/// map — rather than inventing fields we never probe. Empty before the first measurement.
201#[derive(Debug, Clone, PartialEq, Eq, Default, kameo::Reply)]
202pub struct NetcheckReport {
203    /// The id of the preferred DERP region — the lowest-latency region this node measured, the one it
204    /// homes to (Go `Report.PreferredDERP`). `None` before the first measurement / when no region
205    /// was reachable.
206    pub preferred_derp: Option<u32>,
207    /// Per-region measured latencies, sorted by latency ascending (Go `Report.RegionLatency`, here as
208    /// an ordered list). The first entry, when present, is the [`preferred_derp`](Self::preferred_derp)
209    /// region.
210    pub region_latencies: Vec<RegionLatency>,
211}
212
213impl NetcheckReport {
214    /// Build a report from the latest DERP-region measurements (the `RegionResult` set the latency
215    /// measurer produces). `results` is expected sorted by latency ascending (the measurer's
216    /// `RegionResult` `Ord` sorts on latency first), so the first entry is the preferred region; we
217    /// do not re-sort beyond trusting that contract for `preferred_derp`, but the list is emitted in
218    /// the order given. An empty `results` yields the default (no preferred region, empty list).
219    pub(crate) fn from_region_results(results: &[ts_netcheck::RegionResult]) -> NetcheckReport {
220        let region_latencies: Vec<RegionLatency> = results
221            .iter()
222            .map(|r| RegionLatency {
223                // `ts_derp::RegionId` is a `NonZeroU32` newtype (its `.0` is the public inner).
224                region_id: r.id.0.get(),
225                latency: r.latency,
226            })
227            .collect();
228        NetcheckReport {
229            preferred_derp: region_latencies.first().map(|r| r.region_id),
230            region_latencies,
231        }
232    }
233}
234
235/// A tailnet peer this node can send a Taildrop file *to*, plus the peerAPI base URL to reach it.
236///
237/// Analogous to tsnet's `apitype.FileTarget`. The set is produced by
238/// [`Runtime::file_targets`](crate::Runtime::file_targets) (exposed as `Device::file_targets`).
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct FileTarget {
241    /// The target peer's node record — pass straight to the Taildrop send path
242    /// (`Device::send_file`), which re-derives the same peerAPI address.
243    pub node: Node,
244    /// The `http://ip:port` base URL of the peer's peerAPI, with no trailing path — the exact shape
245    /// of Go's `apitype.FileTarget.PeerAPIURL`. Derived from
246    /// [`Node::peerapi_addr`](ts_control::Node::peerapi_addr).
247    pub peerapi_url: String,
248}
249
250/// Compute the sorted Taildrop send-target list from the peer set, given the local node's owning
251/// user id. The pure core of [`Runtime::file_targets`](crate::Runtime::file_targets) — separated out
252/// so the eligibility + ordering rules are unit-testable without spinning up the actor graph (the
253/// node-level file-sharing gate is applied by the caller before this runs).
254///
255/// A peer is a target when it advertises a reachable peerAPI (Go `PeerAPIBase(p) != ""`) **and** is
256/// either owned by `self_user_id` **or** carries the file-sharing-target capability — Go's two-way
257/// OR. Sorted by MagicDNS name (Go sorts by `Node.Name`), falling back to the bare hostname.
258pub(crate) fn build_file_targets(peers: Vec<Node>, self_user_id: UserId) -> Vec<FileTarget> {
259    let mut targets: Vec<FileTarget> = peers
260        .into_iter()
261        .filter_map(|peer| {
262            // Must advertise a reachable peerAPI (Go `PeerAPIBase(p) != ""`).
263            let addr = peer.peerapi_addr()?;
264            // Same owner OR explicitly an ACL file-sharing target (Go's two-way OR).
265            let eligible = peer.user_id == self_user_id || peer.is_file_sharing_target();
266            if !eligible {
267                return None;
268            }
269            Some(FileTarget {
270                peerapi_url: format!("http://{addr}"),
271                node: peer,
272            })
273        })
274        .collect();
275    // Sort by MagicDNS name (Go sorts by `Node.Name`), bare hostname as the fallback key.
276    targets.sort_by(|a, b| {
277        let name = |t: &FileTarget| {
278            t.node
279                .fqdn_opt(false)
280                .unwrap_or_else(|| t.node.hostname.clone())
281        };
282        name(a).cmp(&name(b))
283    });
284    targets
285}
286
287#[cfg(test)]
288mod tests {
289    use ts_control::{Node, StableNodeId, TailnetAddress};
290
291    use super::*;
292
293    fn node(stable: &str, hostname: &str, tailnet: Option<&str>, ipv4: &str) -> Node {
294        Node {
295            id: 1,
296            stable_id: StableNodeId(stable.to_string()),
297            hostname: hostname.to_string(),
298            user_id: 0,
299            tailnet: tailnet.map(str::to_string),
300            tags: vec![],
301            tailnet_address: TailnetAddress {
302                ipv4: format!("{ipv4}/32").parse().unwrap(),
303                ipv6: "fd7a::1/128".parse().unwrap(),
304            },
305            node_key: [0u8; 32].into(),
306            node_key_expiry: None,
307            online: None,
308            last_seen: None,
309            key_signature: vec![],
310            machine_key: None,
311            disco_key: None,
312            accepted_routes: vec![],
313            underlay_addresses: vec![],
314            derp_region: None,
315            cap: Default::default(),
316            cap_map: Default::default(),
317            peerapi_port: None,
318            peerapi_dns_proxy: false,
319            is_wireguard_only: false,
320            exit_node_dns_resolvers: vec![],
321            peer_relay: false,
322            ssh_host_keys: vec![],
323            service_vips: Default::default(),
324        }
325    }
326
327    #[test]
328    fn status_node_display_name_prefers_fqdn() {
329        let with_tailnet = node("n1", "host", Some("ts.net"), "100.64.0.1");
330        assert_eq!(
331            StatusNode::from_node(&with_tailnet).display_name,
332            "host.ts.net"
333        );
334
335        let bare = node("n2", "solo", None, "100.64.0.2");
336        assert_eq!(StatusNode::from_node(&bare).display_name, "solo");
337    }
338
339    #[test]
340    fn status_node_addresses_and_online_surfaced() {
341        let n = node("n1", "host", Some("ts.net"), "100.64.0.7");
342        let s = StatusNode::from_node(&n);
343
344        assert_eq!(s.ipv4, "100.64.0.7".parse::<IpAddr>().unwrap());
345        assert_eq!(s.ipv6, "fd7a::1".parse::<IpAddr>().unwrap());
346        // A node with no online data surfaces `None` (unknown) — never a fabricated `false`.
347        assert_eq!(s.online, None);
348        assert_eq!(s.last_seen, None);
349
350        // A node whose domain online state is known surfaces it through StatusNode (no longer
351        // hardwired to None).
352        let mut online = node("n2", "up", Some("ts.net"), "100.64.0.8");
353        online.online = Some(true);
354        assert_eq!(StatusNode::from_node(&online).online, Some(true));
355
356        let mut offline = node("n3", "down", Some("ts.net"), "100.64.0.9");
357        offline.online = Some(false);
358        assert_eq!(StatusNode::from_node(&offline).online, Some(false));
359    }
360
361    #[test]
362    fn status_node_carries_ssh_host_keys() {
363        // Absent on the domain node → empty on StatusNode (never fabricated).
364        let bare = node("n1", "host", Some("ts.net"), "100.64.0.1");
365        assert!(StatusNode::from_node(&bare).ssh_host_keys.is_empty());
366
367        // Present → mirrored verbatim (the keys `tailscale ssh` pins).
368        let mut with_keys = node("n2", "host", Some("ts.net"), "100.64.0.2");
369        with_keys.ssh_host_keys = vec!["ssh-ed25519 AAAAC3Nz host".to_string()];
370        assert_eq!(
371            StatusNode::from_node(&with_keys).ssh_host_keys,
372            vec!["ssh-ed25519 AAAAC3Nz host".to_string()]
373        );
374    }
375
376    #[test]
377    fn status_node_detects_exit_node() {
378        let mut not_exit = node("n1", "a", Some("ts.net"), "100.64.0.1");
379        not_exit.accepted_routes = vec!["100.64.0.1/32".parse().unwrap()];
380        assert!(!StatusNode::from_node(&not_exit).is_exit_node);
381
382        let mut exit = node("n2", "b", Some("ts.net"), "100.64.0.2");
383        exit.accepted_routes = vec![
384            "100.64.0.2/32".parse().unwrap(),
385            "0.0.0.0/0".parse().unwrap(),
386        ];
387        assert!(StatusNode::from_node(&exit).is_exit_node);
388
389        let mut exit6 = node("n3", "c", Some("ts.net"), "100.64.0.3");
390        exit6.accepted_routes = vec!["::/0".parse().unwrap()];
391        assert!(StatusNode::from_node(&exit6).is_exit_node);
392    }
393
394    /// `from_node` carries NO live connectivity: a bare domain `Node` has no path state, so
395    /// `cur_addr`/`relay` default to `None`. `Runtime::status` overwrites `cur_addr` by joining the
396    /// direct manager's `best_addrs`; the self node and whois (which also use `from_node`) keep
397    /// `None`. This pins the default so the enrichment seam stays the single source of connectivity.
398    #[test]
399    fn status_node_from_node_has_no_connectivity_by_default() {
400        let n = node("n1", "host", Some("ts.net"), "100.64.0.7");
401        let s = StatusNode::from_node(&n);
402        assert_eq!(s.cur_addr, None, "a bare Node has no direct endpoint");
403        assert_eq!(s.relay, None, "a bare Node has no resolved relay");
404    }
405
406    #[test]
407    fn whois_caps_empty_when_node_has_none() {
408        // A node with no cap_map surfaces empty capabilities (not fabricated), and no user unless a
409        // profile was joined in.
410        let n = node("n1", "host", Some("ts.net"), "100.64.0.9");
411        let whois = WhoIs::from_node_with_user(n.clone(), None);
412
413        assert_eq!(whois.node, n);
414        assert_eq!(whois.user, None);
415        assert!(whois.capabilities.is_empty());
416    }
417
418    #[test]
419    fn whois_populates_capabilities_from_cap_map() {
420        // WhoIs surfaces the domain Node's cap_map verbatim, sorted by capability name (BTreeMap).
421        let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
422        n.cap_map
423            .insert("https://tailscale.com/cap/is-admin".to_string(), vec![]);
424        n.cap_map.insert(
425            "cap/ssh".to_string(),
426            vec!["root".to_string(), "ubuntu".to_string()],
427        );
428        let whois = WhoIs::from_node_with_user(n, None);
429
430        // BTreeMap iteration is sorted: "cap/ssh" < "https://…".
431        assert_eq!(
432            whois.capabilities,
433            vec![
434                (
435                    "cap/ssh".to_string(),
436                    vec!["root".to_string(), "ubuntu".to_string()]
437                ),
438                ("https://tailscale.com/cap/is-admin".to_string(), vec![]),
439            ]
440        );
441    }
442
443    #[test]
444    fn whois_from_node_with_user_sets_user_and_caps() {
445        let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
446        n.cap_map.insert("cap/x".to_string(), vec!["y".to_string()]);
447        let whois = WhoIs::from_node_with_user(n, Some("alice@example.com".to_string()));
448
449        assert_eq!(whois.user, Some("alice@example.com".to_string()));
450        assert_eq!(
451            whois.capabilities,
452            vec![("cap/x".to_string(), vec!["y".to_string()])]
453        );
454    }
455
456    /// Build a peer with a reachable peerAPI on `ipv4`, owned by `user`.
457    fn peer_with_peerapi(stable: &str, hostname: &str, ipv4: &str, user: UserId) -> Node {
458        let mut n = node(stable, hostname, Some("ts.net"), ipv4);
459        n.user_id = user;
460        n.peerapi_port = Some(8089);
461        n
462    }
463
464    #[test]
465    fn file_targets_includes_same_owner_peer_with_peerapi() {
466        let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
467        let targets = build_file_targets(vec![peer], 42);
468
469        assert_eq!(targets.len(), 1);
470        assert_eq!(targets[0].peerapi_url, "http://100.64.0.5:8089");
471        assert_eq!(targets[0].node.hostname, "host");
472    }
473
474    #[test]
475    fn file_targets_includes_cross_owner_peer_with_target_cap() {
476        // Different owner, but carries the file-sharing-target cap → still a target (Go's OR).
477        let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
478        peer.cap_map
479            .insert("tailscale.com/cap/file-sharing-target".to_string(), vec![]);
480        let targets = build_file_targets(vec![peer], 42);
481
482        assert_eq!(
483            targets.len(),
484            1,
485            "cross-owner peer with the target cap qualifies"
486        );
487    }
488
489    #[test]
490    fn file_targets_excludes_cross_owner_peer_without_cap() {
491        // Different owner and no target cap → excluded.
492        let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
493        let targets = build_file_targets(vec![peer], 42);
494
495        assert!(
496            targets.is_empty(),
497            "a different owner without the cap is not a target"
498        );
499    }
500
501    #[test]
502    fn file_targets_excludes_peer_without_peerapi() {
503        // Same owner, but advertises no peerAPI (no port) → excluded (Go `PeerAPIBase(p) == ""`).
504        let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
505        peer.peerapi_port = None;
506        let targets = build_file_targets(vec![peer], 42);
507
508        assert!(
509            targets.is_empty(),
510            "a peer with no peerAPI cannot be a Taildrop target"
511        );
512    }
513
514    #[test]
515    fn file_targets_sorted_by_magic_dns_name() {
516        // Insert out of order; expect sorted by fqdn ("alpha.ts.net" < "zeta.ts.net").
517        let zeta = peer_with_peerapi("p2", "zeta", "100.64.0.6", 42);
518        let alpha = peer_with_peerapi("p1", "alpha", "100.64.0.5", 42);
519        let targets = build_file_targets(vec![zeta, alpha], 42);
520
521        let names: Vec<_> = targets.iter().map(|t| t.node.hostname.clone()).collect();
522        assert_eq!(names, vec!["alpha", "zeta"]);
523    }
524
525    fn region_result(id: u32, latency_ms: u64) -> ts_netcheck::RegionResult {
526        ts_netcheck::RegionResult {
527            latency: std::time::Duration::from_millis(latency_ms),
528            id: ts_derp::RegionId(std::num::NonZeroU32::new(id).unwrap()),
529            latency_map_key: format!("{id}-v4"),
530            connected_remote: "1.2.3.4:443".parse().unwrap(),
531        }
532    }
533
534    #[test]
535    fn netcheck_report_preferred_is_first_region() {
536        // The measurer hands results sorted by latency ascending, so the first is the preferred
537        // (home) region and every region is surfaced.
538        let results = [
539            region_result(5, 12),
540            region_result(9, 40),
541            region_result(2, 88),
542        ];
543        let report = NetcheckReport::from_region_results(&results);
544        assert_eq!(
545            report.preferred_derp,
546            Some(5),
547            "lowest-latency region is preferred"
548        );
549        assert_eq!(report.region_latencies.len(), 3);
550        assert_eq!(report.region_latencies[0].region_id, 5);
551        assert_eq!(
552            report.region_latencies[0].latency,
553            std::time::Duration::from_millis(12)
554        );
555        // Order is preserved as given (latency-ascending from the measurer).
556        let ids: Vec<u32> = report
557            .region_latencies
558            .iter()
559            .map(|r| r.region_id)
560            .collect();
561        assert_eq!(ids, vec![5, 9, 2]);
562    }
563
564    #[test]
565    fn netcheck_report_empty_when_no_measurements() {
566        // Before any measurement (or when none was reachable): no preferred region, empty list — not
567        // a fabricated value.
568        let report = NetcheckReport::from_region_results(&[]);
569        assert_eq!(report, NetcheckReport::default());
570        assert_eq!(report.preferred_derp, None);
571        assert!(report.region_latencies.is_empty());
572    }
573}