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_profile`] is
15//! the owning user's whole profile (login name, display name, group membership), resolved by
16//! joining the node's owning user id against the netmap's `UserProfiles` table (accumulated by
17//! the [`PeerTracker`](crate::peer_tracker::PeerTracker) across delta updates). `None` when
18//! control sent no profile for that user. [`WhoIs::user`] flattens it to one display label and
19//! [`WhoIs::user_groups`] reaches the groups an embedder authorises on.
20//! - **Online state** — surfaced: [`StatusNode::online`] / [`StatusNode::last_seen`] reflect the
21//! domain [`Node`](ts_control::Node)'s retained `online`/`last_seen`, populated from the netmap
22//! node and its online deltas (`PeerChange`, `MapResponse.online_change`/`peer_seen_change`).
23//! `online` stays tri-state (`None` = unknown), never fabricated to `false`.
24
25use std::{
26 collections::BTreeMap,
27 net::{IpAddr, SocketAddr},
28};
29
30use ts_control::{Node, StableNodeId, UserId, UserProfile};
31
32/// A snapshot of the local netmap: this node plus every known peer.
33///
34/// Analogous to tsnet's `ipnstate.Status`. Built by [`Runtime::status`](crate::Runtime::status)
35/// from the self node held by the control runner and the peers held by the peer tracker.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Status {
38 /// This node, if a netmap has been received from control yet.
39 pub self_node: Option<StatusNode>,
40 /// Every peer currently known in the netmap.
41 pub peers: Vec<StatusNode>,
42 /// The stable id of the exit node traffic is **currently** egressing through, if any (Go's
43 /// `Status.ExitNodeStatus.ID`). This is the *resolved + fail-closed* answer from the route
44 /// updater — `None` when no exit node is configured, the configured selector matches no peer, or
45 /// the matched peer no longer advertises a default route — so it reflects what is actually
46 /// engaged, not merely what [`Config::exit_node`](ts_control::Config) requested. Find the peer's
47 /// details by matching this id against [`peers`](Status::peers).
48 pub active_exit_node: Option<StableNodeId>,
49 /// The tailnet's MagicDNS suffix (e.g. `"tail0123.ts.net"`) — Go `ipnstate.Status.MagicDNSSuffix`.
50 /// Derived (like Go's `NetworkMap.MagicDNSSuffix`) from the self node's FQDN minus its host label,
51 /// **not** from the DNS config and **not** from the tailnet `Domain` name. `None` before the first
52 /// netmap, or when the self FQDN has no tailnet component (a bare hostname).
53 pub magic_dns_suffix: Option<String>,
54}
55
56/// A single node entry in a [`Status`] snapshot.
57///
58/// Analogous to tsnet's `ipnstate.PeerStatus`.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct StatusNode {
61 /// The node's stable id (stable across re-registration).
62 pub stable_id: StableNodeId,
63 /// A display name for the node: its fqdn if a tailnet component is known, else its bare
64 /// hostname.
65 pub display_name: String,
66 /// The node's tailnet IPv4 address: the **first** IPv4 prefix control assigned it (the identity
67 /// projection the overlay, MagicDNS and exit-node selection reason about). See
68 /// [`tailscale_ips`](Self::tailscale_ips) for every address control assigned.
69 pub ipv4: IpAddr,
70 /// The node's tailnet IPv6 address: the **first** IPv6 prefix control assigned it. See
71 /// [`tailscale_ips`](Self::tailscale_ips) for every address control assigned.
72 pub ipv6: IpAddr,
73 /// Every tailnet address control assigned this node (Go `ipnstate.PeerStatus.TailscaleIPs`), in
74 /// wire order.
75 ///
76 /// Normally the same two addresses as [`ipv4`](Self::ipv4) / [`ipv6`](Self::ipv6), but
77 /// `tailcfg.Node.Addresses` is a variable-length list: an IPv6-off tailnet assigns only the v4
78 /// prefix, and nothing in the protocol stops control assigning more than one prefix of a family.
79 /// Built from the domain [`Node::addresses`](ts_control::Node::addresses), keeping each
80 /// single-IP prefix's address exactly as Go's status builder does
81 /// (`ipn/ipnlocal/local.go`). This — not the first-of-family pair — is what
82 /// [`is_router`](Self::is_router) asks "is this route one of my own addresses?" of.
83 pub tailscale_ips: Vec<IpAddr>,
84 /// Whether the node is online, if known (`ipnstate.PeerStatus.Online`). Tri-state: `Some(true)`
85 /// connected to control, `Some(false)` offline, `None` unknown (control sent no online status or
86 /// the local node lacks permission to know). Reflects control's liveness state, retained from the
87 /// netmap node + its online deltas — `None` is *unknown*, never fabricated to `false`.
88 pub online: Option<bool>,
89 /// When control last saw this node online (`ipnstate.PeerStatus.LastSeen`). Per Go, only
90 /// meaningful while the node is not currently online. `None` when unknown or never seen.
91 pub last_seen: Option<chrono::DateTime<chrono::Utc>>,
92 /// The routes this node accepts traffic for (its own `/32` and `/128`, plus any advertised
93 /// subnet routes and possibly the exit-node default route).
94 pub allowed_routes: Vec<ipnet::IpNet>,
95 /// Whether this node advertises a default route (`0.0.0.0/0` or `::/0`), making it eligible to
96 /// be selected as an exit node.
97 pub is_exit_node: bool,
98 /// The current trusted direct UDP endpoint for this peer, if a direct path is confirmed right now
99 /// (Go `ipnstate.PeerStatus.CurAddr`). `Some` ⇒ traffic to this peer flows directly to this
100 /// address; `None` ⇒ it relays via DERP (see [`relay`](Self::relay)). Mutually exclusive with a
101 /// `relay` for a routed peer, mirroring Go's empty-vs-set `CurAddr`/`Relay` strings. A live
102 /// snapshot — the direct path can expire/re-confirm between calls. Always `None` for the self node
103 /// and a whois lookup (no path to oneself; whois is an ownership query).
104 pub cur_addr: Option<SocketAddr>,
105 /// The DERP region code this peer relays through when there is **no** direct path (Go
106 /// `ipnstate.PeerStatus.Relay`, e.g. `"nyc"`). `Some` ⇔ [`cur_addr`](Self::cur_addr) is `None`
107 /// and the peer's home DERP region is known; `None` when a direct path is confirmed, or the
108 /// region code is unknown. Carries the region **code**, not its numeric id.
109 pub relay: Option<String>,
110 /// The node's advertised SSH host public keys in known_hosts format (Go
111 /// `ipnstate.PeerStatus.SSH_HostKeys`), used by `tailscale ssh` to pin the peer's host key.
112 /// Mirrors the domain [`Node::ssh_host_keys`](ts_control::Node::ssh_host_keys); empty when
113 /// control advertised none (never fabricated).
114 pub ssh_host_keys: Vec<String>,
115}
116
117/// Whether `prefix` covers exactly one address (a `/32` or a `/128`) — Go `netip.Prefix.IsSingleIP`.
118fn is_single_ip(prefix: &ipnet::IpNet) -> bool {
119 let host_prefix = match prefix {
120 ipnet::IpNet::V4(_) => 32,
121 ipnet::IpNet::V6(_) => 128,
122 };
123 prefix.prefix_len() == host_prefix
124}
125
126impl StatusNode {
127 /// Report whether this node is a **router**: it routes addresses besides its own. An exit
128 /// node, a subnet router and an app connector are all routers.
129 ///
130 /// Mirrors Go's `ipnstate.PeerStatus.IsRouter` (`ipn/ipnstate/ipnstate.go`, added upstream in
131 /// `8d830599b` alongside `tailcfg.Node.IsRouter`, which
132 /// [`Node::is_router`](ts_control::Node::is_router) mirrors): a route in
133 /// [`allowed_routes`](Self::allowed_routes) that is not a single host IP, or that is a host IP
134 /// other than one of this node's own [`tailscale_ips`](Self::tailscale_ips), makes the node a
135 /// router. Upstream spells both as *methods*, not wire fields — control sends nothing new for
136 /// this, so it is a pure projection of the netmap a peer already gave us.
137 ///
138 /// The comparison is against the **whole** [`tailscale_ips`](Self::tailscale_ips) list, as Go's
139 /// `slices.Contains(ps.TailscaleIPs, r.Addr())` is, and not against the first-of-family
140 /// [`ipv4`](Self::ipv4)/[`ipv6`](Self::ipv6) pair. A node control handed two prefixes of one
141 /// family would otherwise have the second read as a routed address and be misreported as a
142 /// router — the same narrowing [`Node::is_router`](ts_control::Node::is_router) had.
143 ///
144 /// Strictly wider than [`is_exit_node`](Self::is_exit_node), which asks only about the default
145 /// route: every exit node is a router, but a subnet router advertising no `/0` is not an exit
146 /// node.
147 pub fn is_router(&self) -> bool {
148 self.allowed_routes.iter().any(|route| {
149 // Not a single host IP, or a host IP that is not one of this node's own addresses.
150 !is_single_ip(route) || !self.tailscale_ips.contains(&route.addr())
151 })
152 }
153
154 /// Build a [`StatusNode`] from a domain [`Node`].
155 pub fn from_node(node: &Node) -> Self {
156 let is_exit_node = node
157 .accepted_routes
158 .iter()
159 .any(|route| route.prefix_len() == 0);
160
161 Self {
162 stable_id: node.stable_id.clone(),
163 display_name: node
164 .fqdn_opt(false)
165 .unwrap_or_else(|| node.hostname.clone()),
166 ipv4: node.tailnet_address.ipv4.addr().into(),
167 ipv6: node.tailnet_address.ipv6.addr().into(),
168 // Go's status builder fills `PeerStatus.TailscaleIPs` from the node's whole
169 // `Node.Addresses` list, keeping the address of each single-IP prefix
170 // (`ipn/ipnlocal/local.go`). Take the same list, not the first-of-family pair above:
171 // `is_router` compares against it.
172 tailscale_ips: node
173 .addresses
174 .iter()
175 .filter(|prefix| is_single_ip(prefix))
176 .map(|prefix| prefix.addr())
177 .collect(),
178 online: node.online,
179 last_seen: node.last_seen,
180 allowed_routes: node.accepted_routes.clone(),
181 is_exit_node,
182 // A bare `Node` carries no live path state, so connectivity is unknown here. The peer
183 // tracker overwrites these in `status_peers` by joining against the direct manager; the
184 // self node and whois lookups (which also use `from_node`) correctly keep `None`.
185 cur_addr: None,
186 relay: None,
187 ssh_host_keys: node.ssh_host_keys.clone(),
188 }
189 }
190}
191
192/// The result of a [`Runtime::whois`](crate::Runtime::whois) lookup: the node that owns a tailnet
193/// source address, plus its user and capabilities.
194///
195/// Analogous to tsnet's `apitype.WhoIsResponse`.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct WhoIs {
198 /// The node that owns the queried source IP.
199 pub node: Node,
200 /// The profile of the user that owns the node — Go `apitype.WhoIsResponse.UserProfile`.
201 ///
202 /// Resolved by joining the node's owning user id against the netmap's `UserProfiles` table
203 /// (accumulated by the [`PeerTracker`](crate::peer_tracker::PeerTracker) across delta updates).
204 /// `None` when control sent no profile for that user — a tagged node with no human owner, or a
205 /// profile not yet delivered. Carries the whole profile rather than one flattened label so an
206 /// embedder can authorise on [`UserProfile::groups`], which is the one owner attribute a node
207 /// cannot re-derive locally; [`user`](Self::user) is still there for the display case.
208 pub user_profile: Option<UserProfile>,
209 /// The node's **node-level** capability map (Go `Node.CapMap` — node attributes like
210 /// `can-funnel`), as `(capability, args)` pairs, populated from the domain
211 /// [`Node`]'s `cap_map`, sorted by capability name. Distinct from
212 /// [`cap_map`](Self::cap_map), which is the flow-scoped *peer-capability* grants.
213 pub capabilities: Vec<(String, Vec<String>)>,
214 /// The **flow-scoped** peer-capability grants for the queried `src -> dst` flow — Go
215 /// `apitype.WhoIsResponse.CapMap` (`tailcfg.PeerCapMap`). The grants control's packet-filter
216 /// application rules authorize for traffic from this node to the queried address, keyed by
217 /// capability name with raw-JSON values. Empty when no grant matches the flow (or no scoped
218 /// query was made). Distinct from the node-level [`capabilities`](Self::capabilities).
219 pub cap_map: BTreeMap<String, Vec<String>>,
220}
221
222impl WhoIs {
223 /// Build a [`WhoIs`] from the owning node and its resolved owner profile (if the netmap's
224 /// `UserProfiles` table mapped the node's owning user id to one; `None` when control sent no
225 /// profile — e.g. a tagged node with no human owner).
226 ///
227 /// `capabilities` is the node-level cap map; `cap_map` (the flow-scoped grants) is filled
228 /// separately by [`Runtime::whois`](crate::Runtime::whois) and defaults to empty here.
229 pub(crate) fn from_node_with_profile(node: Node, user_profile: Option<UserProfile>) -> Self {
230 let capabilities = node
231 .cap_map
232 .iter()
233 .map(|(cap, args)| (cap.clone(), args.clone()))
234 .collect();
235 Self {
236 node,
237 user_profile,
238 capabilities,
239 cap_map: BTreeMap::new(),
240 }
241 }
242
243 /// The best human-facing label for the owning user: the profile's login name when present,
244 /// else its display name, else `None` (no profile, or a profile with neither).
245 ///
246 /// This is the flattened view [`user_profile`](Self::user_profile) replaced; use the profile
247 /// itself for anything but display.
248 pub fn user(&self) -> Option<String> {
249 self.user_profile.as_ref().and_then(UserProfile::best_label)
250 }
251
252 /// The groups control reported for the owning user — Go
253 /// `apitype.WhoIsResponse.UserProfile.Groups`. SCIM groups (e.g. `engineering@example.com`) or
254 /// tailnet-policy group names (e.g. `group:eng`).
255 ///
256 /// The authorisation shortcut: `whois.user_groups().iter().any(|g| g == "group:eng")`.
257 ///
258 /// **Empty** both when there is no profile at all and when control reported no groups — which
259 /// includes every control server that does not send the field. An empty list is therefore
260 /// "control told this node nothing", not a proof of non-membership: fail closed on it (deny),
261 /// never treat it as a negative assertion.
262 pub fn user_groups(&self) -> &[String] {
263 self.user_profile
264 .as_ref()
265 .map_or(&[], |profile| profile.groups.as_slice())
266 }
267}
268
269/// Resolve which node owns a tailnet source address, used by WhoIs.
270pub(crate) fn whois_addr(addr: SocketAddr) -> IpAddr {
271 addr.ip()
272}
273
274/// A measured-latency entry for one DERP region in a [`NetcheckReport`].
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct RegionLatency {
277 /// The DERP region id (Go `tailcfg.DERPRegionID`).
278 pub region_id: u32,
279 /// The measured round-trip latency to the region's closest DERP node.
280 pub latency: std::time::Duration,
281}
282
283/// A snapshot of this node's latest network conditions report — the Rust analog of Go's
284/// `netcheck.Report` as `tailscale netcheck` surfaces it.
285///
286/// ## Surfaced subset (do not fabricate)
287/// Go's `netcheck.Report` also carries UDP/IPv4/IPv6 reachability, port-mapping support
288/// (UPnP/PMP/PCP), `MappingVariesByDestIP`, global-address discovery, etc. This fork's net-report
289/// path measures only **DERP-region latency** (the data that drives home-region selection), so the
290/// report carries exactly that — the preferred (lowest-latency) region and the per-region latency
291/// map — rather than inventing fields we never probe. Empty before the first measurement.
292#[derive(Debug, Clone, PartialEq, Eq, Default, kameo::Reply)]
293pub struct NetcheckReport {
294 /// The id of the preferred DERP region — the lowest-latency region this node measured, the one it
295 /// homes to (Go `Report.PreferredDERP`). `None` before the first measurement / when no region
296 /// was reachable.
297 pub preferred_derp: Option<u32>,
298 /// Per-region measured latencies, sorted by latency ascending (Go `Report.RegionLatency`, here as
299 /// an ordered list). The first entry, when present, is the [`preferred_derp`](Self::preferred_derp)
300 /// region.
301 pub region_latencies: Vec<RegionLatency>,
302}
303
304impl NetcheckReport {
305 /// Build a report from the latest DERP-region measurements (the `RegionResult` set the latency
306 /// measurer produces). `results` is expected sorted by latency ascending (the measurer's
307 /// `RegionResult` `Ord` sorts on latency first), so the first entry is the preferred region; we
308 /// do not re-sort beyond trusting that contract for `preferred_derp`, but the list is emitted in
309 /// the order given. An empty `results` yields the default (no preferred region, empty list).
310 pub(crate) fn from_region_results(results: &[ts_netcheck::RegionResult]) -> NetcheckReport {
311 let region_latencies: Vec<RegionLatency> = results
312 .iter()
313 .map(|r| RegionLatency {
314 // `ts_derp::RegionId` is a `NonZeroU32` newtype (its `.0` is the public inner).
315 region_id: r.id.0.get(),
316 latency: r.latency,
317 })
318 .collect();
319 NetcheckReport {
320 preferred_derp: region_latencies.first().map(|r| r.region_id),
321 region_latencies,
322 }
323 }
324}
325
326/// A tailnet peer this node can send a Taildrop file *to*, plus the peerAPI base URL to reach it.
327///
328/// Analogous to tsnet's `apitype.FileTarget`. The set is produced by
329/// [`Runtime::file_targets`](crate::Runtime::file_targets) (exposed as `Device::file_targets`).
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct FileTarget {
332 /// The target peer's node record — pass straight to the Taildrop send path
333 /// (`Device::send_file`), which re-derives the same peerAPI address.
334 pub node: Node,
335 /// The `http://ip:port` base URL of the peer's peerAPI, with no trailing path — the exact shape
336 /// of Go's `apitype.FileTarget.PeerAPIURL`. Derived from
337 /// [`Node::peerapi_addr`](ts_control::Node::peerapi_addr).
338 pub peerapi_url: String,
339}
340
341/// Compute the sorted Taildrop send-target list from the peer set, given the local node's owning
342/// user id. The pure core of [`Runtime::file_targets`](crate::Runtime::file_targets) — separated out
343/// so the eligibility + ordering rules are unit-testable without spinning up the actor graph (the
344/// node-level file-sharing gate is applied by the caller before this runs).
345///
346/// A peer is a target when it advertises a reachable peerAPI (Go `PeerAPIBase(p) != ""`) **and** is
347/// either owned by `self_user_id` **or** carries the file-sharing-target capability — Go's two-way
348/// OR. Sorted by MagicDNS name (Go sorts by `Node.Name`), falling back to the bare hostname.
349pub(crate) fn build_file_targets(peers: Vec<Node>, self_user_id: UserId) -> Vec<FileTarget> {
350 let mut targets: Vec<FileTarget> = peers
351 .into_iter()
352 .filter_map(|peer| {
353 // Must advertise a reachable peerAPI (Go `PeerAPIBase(p) != ""`).
354 let addr = peer.peerapi_addr()?;
355 // Same owner OR explicitly an ACL file-sharing target (Go's two-way OR).
356 let eligible = peer.user_id == self_user_id || peer.is_file_sharing_target();
357 if !eligible {
358 return None;
359 }
360 Some(FileTarget {
361 peerapi_url: format!("http://{addr}"),
362 node: peer,
363 })
364 })
365 .collect();
366 // Sort by MagicDNS name (Go sorts by `Node.Name`), bare hostname as the fallback key.
367 targets.sort_by(|a, b| {
368 let name = |t: &FileTarget| {
369 t.node
370 .fqdn_opt(false)
371 .unwrap_or_else(|| t.node.hostname.clone())
372 };
373 name(a).cmp(&name(b))
374 });
375 targets
376}
377
378#[cfg(test)]
379mod tests {
380 use ts_control::{Node, StableNodeId, TailnetAddress};
381
382 use super::*;
383
384 fn node(stable: &str, hostname: &str, tailnet: Option<&str>, ipv4: &str) -> Node {
385 Node {
386 id: 1,
387 stable_id: StableNodeId(stable.to_string()),
388 hostname: hostname.to_string(),
389 user_id: 0,
390 tailnet: tailnet.map(str::to_string),
391 tags: vec![],
392 addresses: vec![
393 format!("{ipv4}/32").parse().unwrap(),
394 "fd7a::1/128".parse().unwrap(),
395 ],
396 tailnet_address: TailnetAddress {
397 ipv4: format!("{ipv4}/32").parse().unwrap(),
398 ipv6: "fd7a::1/128".parse().unwrap(),
399 },
400 node_key: [0u8; 32].into(),
401 node_key_expiry: None,
402 online: None,
403 last_seen: None,
404 key_signature: vec![],
405 machine_key: None,
406 disco_key: None,
407 accepted_routes: vec![],
408 underlay_addresses: vec![],
409 derp_region: None,
410 cap: Default::default(),
411 cap_map: Default::default(),
412 peerapi_port: None,
413 peerapi_dns_proxy: false,
414 is_wireguard_only: false,
415 exit_node_dns_resolvers: vec![],
416 peer_relay: false,
417 ssh_host_keys: vec![],
418 service_vips: Default::default(),
419 unsigned_peer_api_only: false,
420 }
421 }
422
423 #[test]
424 fn status_node_display_name_prefers_fqdn() {
425 let with_tailnet = node("n1", "host", Some("ts.net"), "100.64.0.1");
426 assert_eq!(
427 StatusNode::from_node(&with_tailnet).display_name,
428 "host.ts.net"
429 );
430
431 let bare = node("n2", "solo", None, "100.64.0.2");
432 assert_eq!(StatusNode::from_node(&bare).display_name, "solo");
433 }
434
435 #[test]
436 fn status_node_addresses_and_online_surfaced() {
437 let n = node("n1", "host", Some("ts.net"), "100.64.0.7");
438 let s = StatusNode::from_node(&n);
439
440 assert_eq!(s.ipv4, "100.64.0.7".parse::<IpAddr>().unwrap());
441 assert_eq!(s.ipv6, "fd7a::1".parse::<IpAddr>().unwrap());
442 // A node with no online data surfaces `None` (unknown) — never a fabricated `false`.
443 assert_eq!(s.online, None);
444 assert_eq!(s.last_seen, None);
445
446 // A node whose domain online state is known surfaces it through StatusNode (no longer
447 // hardwired to None).
448 let mut online = node("n2", "up", Some("ts.net"), "100.64.0.8");
449 online.online = Some(true);
450 assert_eq!(StatusNode::from_node(&online).online, Some(true));
451
452 let mut offline = node("n3", "down", Some("ts.net"), "100.64.0.9");
453 offline.online = Some(false);
454 assert_eq!(StatusNode::from_node(&offline).online, Some(false));
455 }
456
457 #[test]
458 fn status_node_carries_ssh_host_keys() {
459 // Absent on the domain node → empty on StatusNode (never fabricated).
460 let bare = node("n1", "host", Some("ts.net"), "100.64.0.1");
461 assert!(StatusNode::from_node(&bare).ssh_host_keys.is_empty());
462
463 // Present → mirrored verbatim (the keys `tailscale ssh` pins).
464 let mut with_keys = node("n2", "host", Some("ts.net"), "100.64.0.2");
465 with_keys.ssh_host_keys = vec!["ssh-ed25519 AAAAC3Nz host".to_string()];
466 assert_eq!(
467 StatusNode::from_node(&with_keys).ssh_host_keys,
468 vec!["ssh-ed25519 AAAAC3Nz host".to_string()]
469 );
470 }
471
472 #[test]
473 fn status_node_detects_exit_node() {
474 let mut not_exit = node("n1", "a", Some("ts.net"), "100.64.0.1");
475 not_exit.accepted_routes = vec!["100.64.0.1/32".parse().unwrap()];
476 assert!(!StatusNode::from_node(¬_exit).is_exit_node);
477
478 let mut exit = node("n2", "b", Some("ts.net"), "100.64.0.2");
479 exit.accepted_routes = vec![
480 "100.64.0.2/32".parse().unwrap(),
481 "0.0.0.0/0".parse().unwrap(),
482 ];
483 assert!(StatusNode::from_node(&exit).is_exit_node);
484
485 let mut exit6 = node("n3", "c", Some("ts.net"), "100.64.0.3");
486 exit6.accepted_routes = vec!["::/0".parse().unwrap()];
487 assert!(StatusNode::from_node(&exit6).is_exit_node);
488 }
489
490 /// Ported from upstream's `TestPeerStatusIsRouter` (`ipn/ipnstate/ipnstate_test.go`,
491 /// `8d830599b`), and cross-checked against [`ts_control::Node::is_router`] the way upstream's
492 /// `TestNodeIsRouter` cross-checks the two definitions: a peer is a router exactly when its
493 /// allowed routes reach past its own tailnet addresses. Both the present and the absent case
494 /// are pinned — a plain peer must keep answering `false`.
495 #[test]
496 fn status_node_is_router_reports_routes_beyond_own_addresses() {
497 let self4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
498 let self6: ipnet::IpNet = "fd7a:115c:a1e0::1/128".parse().unwrap();
499
500 let cases: &[(&str, Vec<ipnet::IpNet>, bool)] = &[
501 ("empty", vec![], false),
502 ("plain-ipv4", vec![self4], false),
503 ("plain-ipv6", vec![self6], false),
504 ("plain-ipv4-ipv6", vec![self4, self6], false),
505 (
506 "exit-node-ipv4",
507 vec![self4, "0.0.0.0/0".parse().unwrap()],
508 true,
509 ),
510 ("exit-node-ipv6", vec![self6, "::/0".parse().unwrap()], true),
511 (
512 "subnet-router-ipv4",
513 vec![self4, "192.0.2.0/24".parse().unwrap()],
514 true,
515 ),
516 (
517 "subnet-router-ipv6",
518 vec![self6, "2001:db8::/32".parse().unwrap()],
519 true,
520 ),
521 (
522 "subnet-router-ipv4-ipv6",
523 vec![
524 self4,
525 self6,
526 "192.0.2.0/24".parse().unwrap(),
527 "2001:db8::/32".parse().unwrap(),
528 ],
529 true,
530 ),
531 // No Tailscale-range exception, matching Go: another peer's /32 is a routed address.
532 (
533 "other-tailnet-host",
534 vec![self4, "100.64.5.5/32".parse().unwrap()],
535 true,
536 ),
537 ];
538
539 for (name, allowed, want) in cases {
540 let mut n = node("n1", "host", Some("ts.net"), "100.64.0.1");
541 n.addresses = vec![self4, self6];
542 n.tailnet_address.ipv6 = "fd7a:115c:a1e0::1/128".parse().unwrap();
543 n.accepted_routes = allowed.clone();
544
545 let s = StatusNode::from_node(&n);
546 assert_eq!(s.is_router(), *want, "{name}");
547 // The status projection and the domain node must agree, as upstream asserts of
548 // `ipnstate.PeerStatus.IsRouter` against `tailcfg.Node.IsRouter`.
549 assert_eq!(
550 s.is_router(),
551 n.is_router(),
552 "{name}: domain/status disagree"
553 );
554 }
555 }
556
557 /// Go's `PeerStatus.IsRouter` tests each allowed prefix against the node's **whole**
558 /// `TailscaleIPs` slice, so every address control assigned is "its own". `Node.Addresses` is a
559 /// variable-length list, not a v4/v6 pair, so a tailnet may hand a node more than one prefix of
560 /// a family; such a node must not be reported as a router on account of the extra one — which
561 /// comparing only against the first-of-family `ipv4`/`ipv6` pair does. The sibling
562 /// `ts_control::Node::is_router` case is pinned in `ts_control`; this pins the status
563 /// projection, which carries the same list one level up.
564 #[test]
565 fn status_node_is_router_tests_every_assigned_address_not_only_the_first_of_each_family() {
566 let first4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
567 let second4: ipnet::IpNet = "100.64.0.9/32".parse().unwrap();
568 let first6: ipnet::IpNet = "fd7a:115c:a1e0::1/128".parse().unwrap();
569 let second6: ipnet::IpNet = "fd7a:115c:a1e0::9/128".parse().unwrap();
570
571 let mut n = node("n1", "host", Some("ts.net"), "100.64.0.1");
572 n.addresses = vec![first4, second4, first6, second6];
573 n.tailnet_address.ipv6 = "fd7a:115c:a1e0::1/128".parse().unwrap();
574 // With `AllowedIPs` absent, control's routes for a node are exactly its addresses.
575 n.accepted_routes = n.addresses.clone();
576
577 let s = StatusNode::from_node(&n);
578
579 // The identity projection is still the first prefix of each family...
580 assert_eq!(s.ipv4, first4.addr());
581 assert_eq!(s.ipv6, first6.addr());
582 // ...and every assigned address is carried, as Go's `TailscaleIPs` is.
583 assert_eq!(
584 s.tailscale_ips,
585 vec![first4.addr(), second4.addr(), first6.addr(), second6.addr()]
586 );
587 assert!(
588 !s.is_router(),
589 "a node whose routes are exactly its own assigned addresses is not a router"
590 );
591 assert_eq!(s.is_router(), n.is_router(), "domain/status disagree");
592
593 // Either second-of-family address on its own is still not a routed address.
594 for extra in [second4, second6] {
595 let mut one = n.clone();
596 one.accepted_routes = vec![extra];
597 let s = StatusNode::from_node(&one);
598 assert!(
599 !s.is_router(),
600 "{extra} is one of this node's own addresses"
601 );
602 assert_eq!(s.is_router(), one.is_router(), "domain/status disagree");
603 }
604
605 // The predicate still fires for a route that does reach past every assigned address.
606 let mut router = n.clone();
607 router.accepted_routes.push("192.0.2.0/24".parse().unwrap());
608 assert!(
609 StatusNode::from_node(&router).is_router(),
610 "a real subnet route makes it a router"
611 );
612 }
613
614 /// An IPv6-off tailnet assigns a node only its IPv4 prefix, and the domain model fills the
615 /// missing family with an unspecified placeholder. `TailscaleIPs` must carry what control
616 /// actually assigned — not the placeholder — and the node must still not read as a router.
617 #[test]
618 fn status_node_tailscale_ips_omits_the_unassigned_family_placeholder() {
619 let only4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
620
621 let mut n = node("n1", "host", Some("ts.net"), "100.64.0.1");
622 n.addresses = vec![only4];
623 n.tailnet_address.ipv6 = "::/128".parse().unwrap();
624 n.accepted_routes = vec![only4];
625
626 let s = StatusNode::from_node(&n);
627 assert_eq!(s.tailscale_ips, vec![only4.addr()]);
628 assert!(!s.is_router(), "its own /32 is not a routed address");
629 assert!(
630 s.ipv6.is_unspecified(),
631 "the unassigned family stays a placeholder on the identity projection"
632 );
633 }
634
635 /// `from_node` carries NO live connectivity: a bare domain `Node` has no path state, so
636 /// `cur_addr`/`relay` default to `None`. `Runtime::status` overwrites `cur_addr` by joining the
637 /// direct manager's `best_addrs`; the self node and whois (which also use `from_node`) keep
638 /// `None`. This pins the default so the enrichment seam stays the single source of connectivity.
639 #[test]
640 fn status_node_from_node_has_no_connectivity_by_default() {
641 let n = node("n1", "host", Some("ts.net"), "100.64.0.7");
642 let s = StatusNode::from_node(&n);
643 assert_eq!(s.cur_addr, None, "a bare Node has no direct endpoint");
644 assert_eq!(s.relay, None, "a bare Node has no resolved relay");
645 }
646
647 #[test]
648 fn whois_caps_empty_when_node_has_none() {
649 // A node with no cap_map surfaces empty capabilities (not fabricated), and no user unless a
650 // profile was joined in.
651 let n = node("n1", "host", Some("ts.net"), "100.64.0.9");
652 let whois = WhoIs::from_node_with_profile(n.clone(), None);
653
654 assert_eq!(whois.node, n);
655 assert_eq!(whois.user_profile, None);
656 assert_eq!(whois.user(), None);
657 assert!(whois.user_groups().is_empty());
658 assert!(whois.capabilities.is_empty());
659 }
660
661 #[test]
662 fn whois_populates_capabilities_from_cap_map() {
663 // WhoIs surfaces the domain Node's cap_map verbatim, sorted by capability name (BTreeMap).
664 let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
665 n.cap_map
666 .insert("https://tailscale.com/cap/is-admin".to_string(), vec![]);
667 n.cap_map.insert(
668 "cap/ssh".to_string(),
669 vec!["root".to_string(), "ubuntu".to_string()],
670 );
671 let whois = WhoIs::from_node_with_profile(n, None);
672
673 // BTreeMap iteration is sorted: "cap/ssh" < "https://…".
674 assert_eq!(
675 whois.capabilities,
676 vec![
677 (
678 "cap/ssh".to_string(),
679 vec!["root".to_string(), "ubuntu".to_string()]
680 ),
681 ("https://tailscale.com/cap/is-admin".to_string(), vec![]),
682 ]
683 );
684 }
685
686 #[test]
687 fn whois_from_node_with_profile_sets_profile_and_caps() {
688 let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
689 n.cap_map.insert("cap/x".to_string(), vec!["y".to_string()]);
690 let profile = UserProfile {
691 id: 42,
692 login_name: "alice@example.com".to_string(),
693 display_name: Some("Alice Smith".to_string()),
694 groups: vec!["group:eng".to_string(), "sre@example.com".to_string()],
695 };
696 let whois = WhoIs::from_node_with_profile(n, Some(profile.clone()));
697
698 assert_eq!(whois.user_profile, Some(profile));
699 // The flattened label the pre-widening `user` field carried is still what `user()` answers.
700 assert_eq!(whois.user(), Some("alice@example.com".to_string()));
701 assert_eq!(whois.user_groups(), ["group:eng", "sre@example.com"]);
702 assert_eq!(
703 whois.capabilities,
704 vec![("cap/x".to_string(), vec!["y".to_string()])]
705 );
706 }
707
708 /// A profile control sent with no `Groups` still resolves — the profile is present, the group
709 /// list is merely empty. The absent case must never collapse to "no profile", because an
710 /// embedder distinguishes "control named this owner but reported no groups" (deny, with a
711 /// known owner) from "control named no owner at all".
712 #[test]
713 fn whois_with_a_groupless_profile_keeps_the_profile_and_reports_no_groups() {
714 let n = node("n1", "host", Some("ts.net"), "100.64.0.9");
715 let whois = WhoIs::from_node_with_profile(
716 n,
717 Some(UserProfile {
718 id: 42,
719 login_name: "alice@example.com".to_string(),
720 display_name: None,
721 groups: Vec::new(),
722 }),
723 );
724
725 assert!(whois.user_profile.is_some(), "the profile itself survives");
726 assert_eq!(whois.user(), Some("alice@example.com".to_string()));
727 assert!(whois.user_groups().is_empty());
728 }
729
730 /// Build a peer with a reachable peerAPI on `ipv4`, owned by `user`.
731 fn peer_with_peerapi(stable: &str, hostname: &str, ipv4: &str, user: UserId) -> Node {
732 let mut n = node(stable, hostname, Some("ts.net"), ipv4);
733 n.user_id = user;
734 n.peerapi_port = Some(8089);
735 n
736 }
737
738 #[test]
739 fn file_targets_includes_same_owner_peer_with_peerapi() {
740 let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
741 let targets = build_file_targets(vec![peer], 42);
742
743 assert_eq!(targets.len(), 1);
744 assert_eq!(targets[0].peerapi_url, "http://100.64.0.5:8089");
745 assert_eq!(targets[0].node.hostname, "host");
746 }
747
748 #[test]
749 fn file_targets_includes_cross_owner_peer_with_target_cap() {
750 // Different owner, but carries the file-sharing-target cap → still a target (Go's OR).
751 let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
752 peer.cap_map
753 .insert("tailscale.com/cap/file-sharing-target".to_string(), vec![]);
754 let targets = build_file_targets(vec![peer], 42);
755
756 assert_eq!(
757 targets.len(),
758 1,
759 "cross-owner peer with the target cap qualifies"
760 );
761 }
762
763 #[test]
764 fn file_targets_excludes_cross_owner_peer_without_cap() {
765 // Different owner and no target cap → excluded.
766 let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
767 let targets = build_file_targets(vec![peer], 42);
768
769 assert!(
770 targets.is_empty(),
771 "a different owner without the cap is not a target"
772 );
773 }
774
775 #[test]
776 fn file_targets_excludes_peer_without_peerapi() {
777 // Same owner, but advertises no peerAPI (no port) → excluded (Go `PeerAPIBase(p) == ""`).
778 let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
779 peer.peerapi_port = None;
780 let targets = build_file_targets(vec![peer], 42);
781
782 assert!(
783 targets.is_empty(),
784 "a peer with no peerAPI cannot be a Taildrop target"
785 );
786 }
787
788 #[test]
789 fn file_targets_sorted_by_magic_dns_name() {
790 // Insert out of order; expect sorted by fqdn ("alpha.ts.net" < "zeta.ts.net").
791 let zeta = peer_with_peerapi("p2", "zeta", "100.64.0.6", 42);
792 let alpha = peer_with_peerapi("p1", "alpha", "100.64.0.5", 42);
793 let targets = build_file_targets(vec![zeta, alpha], 42);
794
795 let names: Vec<_> = targets.iter().map(|t| t.node.hostname.clone()).collect();
796 assert_eq!(names, vec!["alpha", "zeta"]);
797 }
798
799 fn region_result(id: u32, latency_ms: u64) -> ts_netcheck::RegionResult {
800 ts_netcheck::RegionResult {
801 latency: std::time::Duration::from_millis(latency_ms),
802 id: ts_derp::RegionId(std::num::NonZeroU32::new(id).unwrap()),
803 latency_map_key: format!("{id}-v4"),
804 connected_remote: "1.2.3.4:443".parse().unwrap(),
805 }
806 }
807
808 #[test]
809 fn netcheck_report_preferred_is_first_region() {
810 // The measurer hands results sorted by latency ascending, so the first is the preferred
811 // (home) region and every region is surfaced.
812 let results = [
813 region_result(5, 12),
814 region_result(9, 40),
815 region_result(2, 88),
816 ];
817 let report = NetcheckReport::from_region_results(&results);
818 assert_eq!(
819 report.preferred_derp,
820 Some(5),
821 "lowest-latency region is preferred"
822 );
823 assert_eq!(report.region_latencies.len(), 3);
824 assert_eq!(report.region_latencies[0].region_id, 5);
825 assert_eq!(
826 report.region_latencies[0].latency,
827 std::time::Duration::from_millis(12)
828 );
829 // Order is preserved as given (latency-ascending from the measurer).
830 let ids: Vec<u32> = report
831 .region_latencies
832 .iter()
833 .map(|r| r.region_id)
834 .collect();
835 assert_eq!(ids, vec![5, 9, 2]);
836 }
837
838 #[test]
839 fn netcheck_report_empty_when_no_measurements() {
840 // Before any measurement (or when none was reachable): no preferred region, empty list — not
841 // a fabricated value.
842 let report = NetcheckReport::from_region_results(&[]);
843 assert_eq!(report, NetcheckReport::default());
844 assert_eq!(report.preferred_derp, None);
845 assert!(report.region_latencies.is_empty());
846 }
847}