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 /// Report whether this node is a **router**: it routes addresses besides its own. An exit
103 /// node, a subnet router and an app connector are all routers.
104 ///
105 /// Mirrors Go's `ipnstate.PeerStatus.IsRouter` (`ipn/ipnstate/ipnstate.go`, added upstream in
106 /// `8d830599b` alongside `tailcfg.Node.IsRouter`, which
107 /// [`Node::is_router`](ts_control::Node::is_router) mirrors): a route in
108 /// [`allowed_routes`](Self::allowed_routes) that is not a single host IP, or that is a host IP
109 /// other than this node's own [`ipv4`](Self::ipv4)/[`ipv6`](Self::ipv6), makes the node a
110 /// router. Upstream spells both as *methods*, not wire fields — control sends nothing new for
111 /// this, so it is a pure projection of the netmap a peer already gave us.
112 ///
113 /// Strictly wider than [`is_exit_node`](Self::is_exit_node), which asks only about the default
114 /// route: every exit node is a router, but a subnet router advertising no `/0` is not an exit
115 /// node.
116 pub fn is_router(&self) -> bool {
117 self.allowed_routes.iter().any(|route| {
118 let host_prefix = match route {
119 ipnet::IpNet::V4(_) => 32,
120 ipnet::IpNet::V6(_) => 128,
121 };
122 // Not a single host IP, or a host IP that is not one of this node's own addresses.
123 route.prefix_len() != host_prefix
124 || (route.addr() != self.ipv4 && route.addr() != self.ipv6)
125 })
126 }
127
128 /// Build a [`StatusNode`] from a domain [`Node`].
129 pub fn from_node(node: &Node) -> Self {
130 let is_exit_node = node
131 .accepted_routes
132 .iter()
133 .any(|route| route.prefix_len() == 0);
134
135 Self {
136 stable_id: node.stable_id.clone(),
137 display_name: node
138 .fqdn_opt(false)
139 .unwrap_or_else(|| node.hostname.clone()),
140 ipv4: node.tailnet_address.ipv4.addr().into(),
141 ipv6: node.tailnet_address.ipv6.addr().into(),
142 online: node.online,
143 last_seen: node.last_seen,
144 allowed_routes: node.accepted_routes.clone(),
145 is_exit_node,
146 // A bare `Node` carries no live path state, so connectivity is unknown here. The peer
147 // tracker overwrites these in `status_peers` by joining against the direct manager; the
148 // self node and whois lookups (which also use `from_node`) correctly keep `None`.
149 cur_addr: None,
150 relay: None,
151 ssh_host_keys: node.ssh_host_keys.clone(),
152 }
153 }
154}
155
156/// The result of a [`Runtime::whois`](crate::Runtime::whois) lookup: the node that owns a tailnet
157/// source address, plus its user and capabilities.
158///
159/// Analogous to tsnet's `apitype.WhoIsResponse`.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct WhoIs {
162 /// The node that owns the queried source IP.
163 pub node: Node,
164 /// The login/email of the user that owns the node, if known.
165 ///
166 /// Always `None` in this fork: the domain [`Node`] does not retain the
167 /// wire-level user/login mapping (see the module-level capability/user gap note).
168 pub user: Option<String>,
169 /// The node's **node-level** capability map (Go `Node.CapMap` — node attributes like
170 /// `can-funnel`), as `(capability, args)` pairs, populated from the domain
171 /// [`Node`]'s `cap_map`, sorted by capability name. Distinct from
172 /// [`cap_map`](Self::cap_map), which is the flow-scoped *peer-capability* grants.
173 pub capabilities: Vec<(String, Vec<String>)>,
174 /// The **flow-scoped** peer-capability grants for the queried `src -> dst` flow — Go
175 /// `apitype.WhoIsResponse.CapMap` (`tailcfg.PeerCapMap`). The grants control's packet-filter
176 /// application rules authorize for traffic from this node to the queried address, keyed by
177 /// capability name with raw-JSON values. Empty when no grant matches the flow (or no scoped
178 /// query was made). Distinct from the node-level [`capabilities`](Self::capabilities).
179 pub cap_map: BTreeMap<String, Vec<String>>,
180}
181
182impl WhoIs {
183 /// Build a [`WhoIs`] from the owning node and its resolved owner login/display name (if the
184 /// netmap's `UserProfiles` table mapped the node's owning user id to a profile; `None` when
185 /// control sent no profile — e.g. a tagged node with no human owner).
186 ///
187 /// `capabilities` is the node-level cap map; `cap_map` (the flow-scoped grants) is filled
188 /// separately by [`Runtime::whois`](crate::Runtime::whois) and defaults to empty here.
189 pub(crate) fn from_node_with_user(node: Node, user: Option<String>) -> Self {
190 let capabilities = node
191 .cap_map
192 .iter()
193 .map(|(cap, args)| (cap.clone(), args.clone()))
194 .collect();
195 Self {
196 node,
197 user,
198 capabilities,
199 cap_map: BTreeMap::new(),
200 }
201 }
202}
203
204/// Resolve which node owns a tailnet source address, used by WhoIs.
205pub(crate) fn whois_addr(addr: SocketAddr) -> IpAddr {
206 addr.ip()
207}
208
209/// A measured-latency entry for one DERP region in a [`NetcheckReport`].
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct RegionLatency {
212 /// The DERP region id (Go `tailcfg.DERPRegionID`).
213 pub region_id: u32,
214 /// The measured round-trip latency to the region's closest DERP node.
215 pub latency: std::time::Duration,
216}
217
218/// A snapshot of this node's latest network conditions report — the Rust analog of Go's
219/// `netcheck.Report` as `tailscale netcheck` surfaces it.
220///
221/// ## Surfaced subset (do not fabricate)
222/// Go's `netcheck.Report` also carries UDP/IPv4/IPv6 reachability, port-mapping support
223/// (UPnP/PMP/PCP), `MappingVariesByDestIP`, global-address discovery, etc. This fork's net-report
224/// path measures only **DERP-region latency** (the data that drives home-region selection), so the
225/// report carries exactly that — the preferred (lowest-latency) region and the per-region latency
226/// map — rather than inventing fields we never probe. Empty before the first measurement.
227#[derive(Debug, Clone, PartialEq, Eq, Default, kameo::Reply)]
228pub struct NetcheckReport {
229 /// The id of the preferred DERP region — the lowest-latency region this node measured, the one it
230 /// homes to (Go `Report.PreferredDERP`). `None` before the first measurement / when no region
231 /// was reachable.
232 pub preferred_derp: Option<u32>,
233 /// Per-region measured latencies, sorted by latency ascending (Go `Report.RegionLatency`, here as
234 /// an ordered list). The first entry, when present, is the [`preferred_derp`](Self::preferred_derp)
235 /// region.
236 pub region_latencies: Vec<RegionLatency>,
237}
238
239impl NetcheckReport {
240 /// Build a report from the latest DERP-region measurements (the `RegionResult` set the latency
241 /// measurer produces). `results` is expected sorted by latency ascending (the measurer's
242 /// `RegionResult` `Ord` sorts on latency first), so the first entry is the preferred region; we
243 /// do not re-sort beyond trusting that contract for `preferred_derp`, but the list is emitted in
244 /// the order given. An empty `results` yields the default (no preferred region, empty list).
245 pub(crate) fn from_region_results(results: &[ts_netcheck::RegionResult]) -> NetcheckReport {
246 let region_latencies: Vec<RegionLatency> = results
247 .iter()
248 .map(|r| RegionLatency {
249 // `ts_derp::RegionId` is a `NonZeroU32` newtype (its `.0` is the public inner).
250 region_id: r.id.0.get(),
251 latency: r.latency,
252 })
253 .collect();
254 NetcheckReport {
255 preferred_derp: region_latencies.first().map(|r| r.region_id),
256 region_latencies,
257 }
258 }
259}
260
261/// A tailnet peer this node can send a Taildrop file *to*, plus the peerAPI base URL to reach it.
262///
263/// Analogous to tsnet's `apitype.FileTarget`. The set is produced by
264/// [`Runtime::file_targets`](crate::Runtime::file_targets) (exposed as `Device::file_targets`).
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct FileTarget {
267 /// The target peer's node record — pass straight to the Taildrop send path
268 /// (`Device::send_file`), which re-derives the same peerAPI address.
269 pub node: Node,
270 /// The `http://ip:port` base URL of the peer's peerAPI, with no trailing path — the exact shape
271 /// of Go's `apitype.FileTarget.PeerAPIURL`. Derived from
272 /// [`Node::peerapi_addr`](ts_control::Node::peerapi_addr).
273 pub peerapi_url: String,
274}
275
276/// Compute the sorted Taildrop send-target list from the peer set, given the local node's owning
277/// user id. The pure core of [`Runtime::file_targets`](crate::Runtime::file_targets) — separated out
278/// so the eligibility + ordering rules are unit-testable without spinning up the actor graph (the
279/// node-level file-sharing gate is applied by the caller before this runs).
280///
281/// A peer is a target when it advertises a reachable peerAPI (Go `PeerAPIBase(p) != ""`) **and** is
282/// either owned by `self_user_id` **or** carries the file-sharing-target capability — Go's two-way
283/// OR. Sorted by MagicDNS name (Go sorts by `Node.Name`), falling back to the bare hostname.
284pub(crate) fn build_file_targets(peers: Vec<Node>, self_user_id: UserId) -> Vec<FileTarget> {
285 let mut targets: Vec<FileTarget> = peers
286 .into_iter()
287 .filter_map(|peer| {
288 // Must advertise a reachable peerAPI (Go `PeerAPIBase(p) != ""`).
289 let addr = peer.peerapi_addr()?;
290 // Same owner OR explicitly an ACL file-sharing target (Go's two-way OR).
291 let eligible = peer.user_id == self_user_id || peer.is_file_sharing_target();
292 if !eligible {
293 return None;
294 }
295 Some(FileTarget {
296 peerapi_url: format!("http://{addr}"),
297 node: peer,
298 })
299 })
300 .collect();
301 // Sort by MagicDNS name (Go sorts by `Node.Name`), bare hostname as the fallback key.
302 targets.sort_by(|a, b| {
303 let name = |t: &FileTarget| {
304 t.node
305 .fqdn_opt(false)
306 .unwrap_or_else(|| t.node.hostname.clone())
307 };
308 name(a).cmp(&name(b))
309 });
310 targets
311}
312
313#[cfg(test)]
314mod tests {
315 use ts_control::{Node, StableNodeId, TailnetAddress};
316
317 use super::*;
318
319 fn node(stable: &str, hostname: &str, tailnet: Option<&str>, ipv4: &str) -> Node {
320 Node {
321 id: 1,
322 stable_id: StableNodeId(stable.to_string()),
323 hostname: hostname.to_string(),
324 user_id: 0,
325 tailnet: tailnet.map(str::to_string),
326 tags: vec![],
327 addresses: vec![
328 format!("{ipv4}/32").parse().unwrap(),
329 "fd7a::1/128".parse().unwrap(),
330 ],
331 tailnet_address: TailnetAddress {
332 ipv4: format!("{ipv4}/32").parse().unwrap(),
333 ipv6: "fd7a::1/128".parse().unwrap(),
334 },
335 node_key: [0u8; 32].into(),
336 node_key_expiry: None,
337 online: None,
338 last_seen: None,
339 key_signature: vec![],
340 machine_key: None,
341 disco_key: None,
342 accepted_routes: vec![],
343 underlay_addresses: vec![],
344 derp_region: None,
345 cap: Default::default(),
346 cap_map: Default::default(),
347 peerapi_port: None,
348 peerapi_dns_proxy: false,
349 is_wireguard_only: false,
350 exit_node_dns_resolvers: vec![],
351 peer_relay: false,
352 ssh_host_keys: vec![],
353 service_vips: Default::default(),
354 unsigned_peer_api_only: false,
355 }
356 }
357
358 #[test]
359 fn status_node_display_name_prefers_fqdn() {
360 let with_tailnet = node("n1", "host", Some("ts.net"), "100.64.0.1");
361 assert_eq!(
362 StatusNode::from_node(&with_tailnet).display_name,
363 "host.ts.net"
364 );
365
366 let bare = node("n2", "solo", None, "100.64.0.2");
367 assert_eq!(StatusNode::from_node(&bare).display_name, "solo");
368 }
369
370 #[test]
371 fn status_node_addresses_and_online_surfaced() {
372 let n = node("n1", "host", Some("ts.net"), "100.64.0.7");
373 let s = StatusNode::from_node(&n);
374
375 assert_eq!(s.ipv4, "100.64.0.7".parse::<IpAddr>().unwrap());
376 assert_eq!(s.ipv6, "fd7a::1".parse::<IpAddr>().unwrap());
377 // A node with no online data surfaces `None` (unknown) — never a fabricated `false`.
378 assert_eq!(s.online, None);
379 assert_eq!(s.last_seen, None);
380
381 // A node whose domain online state is known surfaces it through StatusNode (no longer
382 // hardwired to None).
383 let mut online = node("n2", "up", Some("ts.net"), "100.64.0.8");
384 online.online = Some(true);
385 assert_eq!(StatusNode::from_node(&online).online, Some(true));
386
387 let mut offline = node("n3", "down", Some("ts.net"), "100.64.0.9");
388 offline.online = Some(false);
389 assert_eq!(StatusNode::from_node(&offline).online, Some(false));
390 }
391
392 #[test]
393 fn status_node_carries_ssh_host_keys() {
394 // Absent on the domain node → empty on StatusNode (never fabricated).
395 let bare = node("n1", "host", Some("ts.net"), "100.64.0.1");
396 assert!(StatusNode::from_node(&bare).ssh_host_keys.is_empty());
397
398 // Present → mirrored verbatim (the keys `tailscale ssh` pins).
399 let mut with_keys = node("n2", "host", Some("ts.net"), "100.64.0.2");
400 with_keys.ssh_host_keys = vec!["ssh-ed25519 AAAAC3Nz host".to_string()];
401 assert_eq!(
402 StatusNode::from_node(&with_keys).ssh_host_keys,
403 vec!["ssh-ed25519 AAAAC3Nz host".to_string()]
404 );
405 }
406
407 #[test]
408 fn status_node_detects_exit_node() {
409 let mut not_exit = node("n1", "a", Some("ts.net"), "100.64.0.1");
410 not_exit.accepted_routes = vec!["100.64.0.1/32".parse().unwrap()];
411 assert!(!StatusNode::from_node(¬_exit).is_exit_node);
412
413 let mut exit = node("n2", "b", Some("ts.net"), "100.64.0.2");
414 exit.accepted_routes = vec![
415 "100.64.0.2/32".parse().unwrap(),
416 "0.0.0.0/0".parse().unwrap(),
417 ];
418 assert!(StatusNode::from_node(&exit).is_exit_node);
419
420 let mut exit6 = node("n3", "c", Some("ts.net"), "100.64.0.3");
421 exit6.accepted_routes = vec!["::/0".parse().unwrap()];
422 assert!(StatusNode::from_node(&exit6).is_exit_node);
423 }
424
425 /// Ported from upstream's `TestPeerStatusIsRouter` (`ipn/ipnstate/ipnstate_test.go`,
426 /// `8d830599b`), and cross-checked against [`ts_control::Node::is_router`] the way upstream's
427 /// `TestNodeIsRouter` cross-checks the two definitions: a peer is a router exactly when its
428 /// allowed routes reach past its own tailnet addresses. Both the present and the absent case
429 /// are pinned — a plain peer must keep answering `false`.
430 #[test]
431 fn status_node_is_router_reports_routes_beyond_own_addresses() {
432 let self4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
433 let self6: ipnet::IpNet = "fd7a:115c:a1e0::1/128".parse().unwrap();
434
435 let cases: &[(&str, Vec<ipnet::IpNet>, bool)] = &[
436 ("empty", vec![], false),
437 ("plain-ipv4", vec![self4], false),
438 ("plain-ipv6", vec![self6], false),
439 ("plain-ipv4-ipv6", vec![self4, self6], false),
440 (
441 "exit-node-ipv4",
442 vec![self4, "0.0.0.0/0".parse().unwrap()],
443 true,
444 ),
445 ("exit-node-ipv6", vec![self6, "::/0".parse().unwrap()], true),
446 (
447 "subnet-router-ipv4",
448 vec![self4, "192.0.2.0/24".parse().unwrap()],
449 true,
450 ),
451 (
452 "subnet-router-ipv6",
453 vec![self6, "2001:db8::/32".parse().unwrap()],
454 true,
455 ),
456 (
457 "subnet-router-ipv4-ipv6",
458 vec![
459 self4,
460 self6,
461 "192.0.2.0/24".parse().unwrap(),
462 "2001:db8::/32".parse().unwrap(),
463 ],
464 true,
465 ),
466 // No Tailscale-range exception, matching Go: another peer's /32 is a routed address.
467 (
468 "other-tailnet-host",
469 vec![self4, "100.64.5.5/32".parse().unwrap()],
470 true,
471 ),
472 ];
473
474 for (name, allowed, want) in cases {
475 let mut n = node("n1", "host", Some("ts.net"), "100.64.0.1");
476 n.addresses = vec![self4, self6];
477 n.tailnet_address.ipv6 = "fd7a:115c:a1e0::1/128".parse().unwrap();
478 n.accepted_routes = allowed.clone();
479
480 let s = StatusNode::from_node(&n);
481 assert_eq!(s.is_router(), *want, "{name}");
482 // The status projection and the domain node must agree, as upstream asserts of
483 // `ipnstate.PeerStatus.IsRouter` against `tailcfg.Node.IsRouter`.
484 assert_eq!(
485 s.is_router(),
486 n.is_router(),
487 "{name}: domain/status disagree"
488 );
489 }
490 }
491
492 /// `from_node` carries NO live connectivity: a bare domain `Node` has no path state, so
493 /// `cur_addr`/`relay` default to `None`. `Runtime::status` overwrites `cur_addr` by joining the
494 /// direct manager's `best_addrs`; the self node and whois (which also use `from_node`) keep
495 /// `None`. This pins the default so the enrichment seam stays the single source of connectivity.
496 #[test]
497 fn status_node_from_node_has_no_connectivity_by_default() {
498 let n = node("n1", "host", Some("ts.net"), "100.64.0.7");
499 let s = StatusNode::from_node(&n);
500 assert_eq!(s.cur_addr, None, "a bare Node has no direct endpoint");
501 assert_eq!(s.relay, None, "a bare Node has no resolved relay");
502 }
503
504 #[test]
505 fn whois_caps_empty_when_node_has_none() {
506 // A node with no cap_map surfaces empty capabilities (not fabricated), and no user unless a
507 // profile was joined in.
508 let n = node("n1", "host", Some("ts.net"), "100.64.0.9");
509 let whois = WhoIs::from_node_with_user(n.clone(), None);
510
511 assert_eq!(whois.node, n);
512 assert_eq!(whois.user, None);
513 assert!(whois.capabilities.is_empty());
514 }
515
516 #[test]
517 fn whois_populates_capabilities_from_cap_map() {
518 // WhoIs surfaces the domain Node's cap_map verbatim, sorted by capability name (BTreeMap).
519 let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
520 n.cap_map
521 .insert("https://tailscale.com/cap/is-admin".to_string(), vec![]);
522 n.cap_map.insert(
523 "cap/ssh".to_string(),
524 vec!["root".to_string(), "ubuntu".to_string()],
525 );
526 let whois = WhoIs::from_node_with_user(n, None);
527
528 // BTreeMap iteration is sorted: "cap/ssh" < "https://…".
529 assert_eq!(
530 whois.capabilities,
531 vec![
532 (
533 "cap/ssh".to_string(),
534 vec!["root".to_string(), "ubuntu".to_string()]
535 ),
536 ("https://tailscale.com/cap/is-admin".to_string(), vec![]),
537 ]
538 );
539 }
540
541 #[test]
542 fn whois_from_node_with_user_sets_user_and_caps() {
543 let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
544 n.cap_map.insert("cap/x".to_string(), vec!["y".to_string()]);
545 let whois = WhoIs::from_node_with_user(n, Some("alice@example.com".to_string()));
546
547 assert_eq!(whois.user, Some("alice@example.com".to_string()));
548 assert_eq!(
549 whois.capabilities,
550 vec![("cap/x".to_string(), vec!["y".to_string()])]
551 );
552 }
553
554 /// Build a peer with a reachable peerAPI on `ipv4`, owned by `user`.
555 fn peer_with_peerapi(stable: &str, hostname: &str, ipv4: &str, user: UserId) -> Node {
556 let mut n = node(stable, hostname, Some("ts.net"), ipv4);
557 n.user_id = user;
558 n.peerapi_port = Some(8089);
559 n
560 }
561
562 #[test]
563 fn file_targets_includes_same_owner_peer_with_peerapi() {
564 let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
565 let targets = build_file_targets(vec![peer], 42);
566
567 assert_eq!(targets.len(), 1);
568 assert_eq!(targets[0].peerapi_url, "http://100.64.0.5:8089");
569 assert_eq!(targets[0].node.hostname, "host");
570 }
571
572 #[test]
573 fn file_targets_includes_cross_owner_peer_with_target_cap() {
574 // Different owner, but carries the file-sharing-target cap → still a target (Go's OR).
575 let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
576 peer.cap_map
577 .insert("tailscale.com/cap/file-sharing-target".to_string(), vec![]);
578 let targets = build_file_targets(vec![peer], 42);
579
580 assert_eq!(
581 targets.len(),
582 1,
583 "cross-owner peer with the target cap qualifies"
584 );
585 }
586
587 #[test]
588 fn file_targets_excludes_cross_owner_peer_without_cap() {
589 // Different owner and no target cap → excluded.
590 let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
591 let targets = build_file_targets(vec![peer], 42);
592
593 assert!(
594 targets.is_empty(),
595 "a different owner without the cap is not a target"
596 );
597 }
598
599 #[test]
600 fn file_targets_excludes_peer_without_peerapi() {
601 // Same owner, but advertises no peerAPI (no port) → excluded (Go `PeerAPIBase(p) == ""`).
602 let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
603 peer.peerapi_port = None;
604 let targets = build_file_targets(vec![peer], 42);
605
606 assert!(
607 targets.is_empty(),
608 "a peer with no peerAPI cannot be a Taildrop target"
609 );
610 }
611
612 #[test]
613 fn file_targets_sorted_by_magic_dns_name() {
614 // Insert out of order; expect sorted by fqdn ("alpha.ts.net" < "zeta.ts.net").
615 let zeta = peer_with_peerapi("p2", "zeta", "100.64.0.6", 42);
616 let alpha = peer_with_peerapi("p1", "alpha", "100.64.0.5", 42);
617 let targets = build_file_targets(vec![zeta, alpha], 42);
618
619 let names: Vec<_> = targets.iter().map(|t| t.node.hostname.clone()).collect();
620 assert_eq!(names, vec!["alpha", "zeta"]);
621 }
622
623 fn region_result(id: u32, latency_ms: u64) -> ts_netcheck::RegionResult {
624 ts_netcheck::RegionResult {
625 latency: std::time::Duration::from_millis(latency_ms),
626 id: ts_derp::RegionId(std::num::NonZeroU32::new(id).unwrap()),
627 latency_map_key: format!("{id}-v4"),
628 connected_remote: "1.2.3.4:443".parse().unwrap(),
629 }
630 }
631
632 #[test]
633 fn netcheck_report_preferred_is_first_region() {
634 // The measurer hands results sorted by latency ascending, so the first is the preferred
635 // (home) region and every region is surfaced.
636 let results = [
637 region_result(5, 12),
638 region_result(9, 40),
639 region_result(2, 88),
640 ];
641 let report = NetcheckReport::from_region_results(&results);
642 assert_eq!(
643 report.preferred_derp,
644 Some(5),
645 "lowest-latency region is preferred"
646 );
647 assert_eq!(report.region_latencies.len(), 3);
648 assert_eq!(report.region_latencies[0].region_id, 5);
649 assert_eq!(
650 report.region_latencies[0].latency,
651 std::time::Duration::from_millis(12)
652 );
653 // Order is preserved as given (latency-ascending from the measurer).
654 let ids: Vec<u32> = report
655 .region_latencies
656 .iter()
657 .map(|r| r.region_id)
658 .collect();
659 assert_eq!(ids, vec![5, 9, 2]);
660 }
661
662 #[test]
663 fn netcheck_report_empty_when_no_measurements() {
664 // Before any measurement (or when none was reachable): no preferred region, empty list — not
665 // a fabricated value.
666 let report = NetcheckReport::from_region_results(&[]);
667 assert_eq!(report, NetcheckReport::default());
668 assert_eq!(report.preferred_derp, None);
669 assert!(report.region_latencies.is_empty());
670 }
671}