Skip to main content

ts_runtime/
status.rs

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