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