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::net::{IpAddr, SocketAddr};
24
25use ts_control::{Node, StableNodeId, UserId};
26
27/// A snapshot of the local netmap: this node plus every known peer.
28///
29/// Analogous to tsnet's `ipnstate.Status`. Built by [`Runtime::status`](crate::Runtime::status)
30/// from the self node held by the control runner and the peers held by the peer tracker.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Status {
33    /// This node, if a netmap has been received from control yet.
34    pub self_node: Option<StatusNode>,
35    /// Every peer currently known in the netmap.
36    pub peers: Vec<StatusNode>,
37    /// The stable id of the exit node traffic is **currently** egressing through, if any (Go's
38    /// `Status.ExitNodeStatus.ID`). This is the *resolved + fail-closed* answer from the route
39    /// updater — `None` when no exit node is configured, the configured selector matches no peer, or
40    /// the matched peer no longer advertises a default route — so it reflects what is actually
41    /// engaged, not merely what [`Config::exit_node`](ts_control::Config) requested. Find the peer's
42    /// details by matching this id against [`peers`](Status::peers).
43    pub active_exit_node: Option<StableNodeId>,
44    /// The tailnet's MagicDNS suffix (e.g. `"tail0123.ts.net"`) — Go `ipnstate.Status.MagicDNSSuffix`.
45    /// Derived (like Go's `NetworkMap.MagicDNSSuffix`) from the self node's FQDN minus its host label,
46    /// **not** from the DNS config and **not** from the tailnet `Domain` name. `None` before the first
47    /// netmap, or when the self FQDN has no tailnet component (a bare hostname).
48    pub magic_dns_suffix: Option<String>,
49}
50
51/// A single node entry in a [`Status`] snapshot.
52///
53/// Analogous to tsnet's `ipnstate.PeerStatus`.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct StatusNode {
56    /// The node's stable id (stable across re-registration).
57    pub stable_id: StableNodeId,
58    /// A display name for the node: its fqdn if a tailnet component is known, else its bare
59    /// hostname.
60    pub display_name: String,
61    /// The node's tailnet IPv4 address.
62    pub ipv4: IpAddr,
63    /// The node's tailnet IPv6 address.
64    pub ipv6: IpAddr,
65    /// Whether the node is online, if known (`ipnstate.PeerStatus.Online`). Tri-state: `Some(true)`
66    /// connected to control, `Some(false)` offline, `None` unknown (control sent no online status or
67    /// the local node lacks permission to know). Reflects control's liveness state, retained from the
68    /// netmap node + its online deltas — `None` is *unknown*, never fabricated to `false`.
69    pub online: Option<bool>,
70    /// When control last saw this node online (`ipnstate.PeerStatus.LastSeen`). Per Go, only
71    /// meaningful while the node is not currently online. `None` when unknown or never seen.
72    pub last_seen: Option<chrono::DateTime<chrono::Utc>>,
73    /// The routes this node accepts traffic for (its own `/32` and `/128`, plus any advertised
74    /// subnet routes and possibly the exit-node default route).
75    pub allowed_routes: Vec<ipnet::IpNet>,
76    /// Whether this node advertises a default route (`0.0.0.0/0` or `::/0`), making it eligible to
77    /// be selected as an exit node.
78    pub is_exit_node: bool,
79}
80
81impl StatusNode {
82    /// Build a [`StatusNode`] from a domain [`Node`].
83    pub fn from_node(node: &Node) -> Self {
84        let is_exit_node = node
85            .accepted_routes
86            .iter()
87            .any(|route| route.prefix_len() == 0);
88
89        Self {
90            stable_id: node.stable_id.clone(),
91            display_name: node
92                .fqdn_opt(false)
93                .unwrap_or_else(|| node.hostname.clone()),
94            ipv4: node.tailnet_address.ipv4.addr().into(),
95            ipv6: node.tailnet_address.ipv6.addr().into(),
96            online: node.online,
97            last_seen: node.last_seen,
98            allowed_routes: node.accepted_routes.clone(),
99            is_exit_node,
100        }
101    }
102}
103
104/// The result of a [`Runtime::whois`](crate::Runtime::whois) lookup: the node that owns a tailnet
105/// source address, plus its user and capabilities.
106///
107/// Analogous to tsnet's `apitype.WhoIsResponse`.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct WhoIs {
110    /// The node that owns the queried source IP.
111    pub node: Node,
112    /// The login/email of the user that owns the node, if known.
113    ///
114    /// Always `None` in this fork: the domain [`Node`](ts_control::Node) does not retain the
115    /// wire-level user/login mapping (see the module-level capability/user gap note).
116    pub user: Option<String>,
117    /// The node's capability map, as `(capability, args)` pairs.
118    ///
119    /// Populated from the domain [`Node`](ts_control::Node)'s `cap_map` (the control-pushed
120    /// `CapMap`), sorted by capability name (the underlying map is a `BTreeMap`). Empty when control
121    /// granted the node no capabilities. Mirrors tsnet's `WhoIsResponse.CapMap`.
122    pub capabilities: Vec<(String, Vec<String>)>,
123}
124
125impl WhoIs {
126    /// Build a [`WhoIs`] from the owning node and its resolved owner login/display name (if the
127    /// netmap's `UserProfiles` table mapped the node's owning user id to a profile; `None` when
128    /// control sent no profile — e.g. a tagged node with no human owner).
129    ///
130    /// `capabilities` is always populated from the node's `cap_map`.
131    pub(crate) fn from_node_with_user(node: Node, user: Option<String>) -> Self {
132        let capabilities = node
133            .cap_map
134            .iter()
135            .map(|(cap, args)| (cap.clone(), args.clone()))
136            .collect();
137        Self {
138            node,
139            user,
140            capabilities,
141        }
142    }
143}
144
145/// Resolve which node owns a tailnet source address, used by WhoIs.
146pub(crate) fn whois_addr(addr: SocketAddr) -> IpAddr {
147    addr.ip()
148}
149
150/// A tailnet peer this node can send a Taildrop file *to*, plus the peerAPI base URL to reach it.
151///
152/// Analogous to tsnet's `apitype.FileTarget`. The set is produced by
153/// [`Runtime::file_targets`](crate::Runtime::file_targets) (exposed as `Device::file_targets`).
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct FileTarget {
156    /// The target peer's node record — pass straight to the Taildrop send path
157    /// (`Device::send_file`), which re-derives the same peerAPI address.
158    pub node: Node,
159    /// The `http://ip:port` base URL of the peer's peerAPI, with no trailing path — the exact shape
160    /// of Go's `apitype.FileTarget.PeerAPIURL`. Derived from
161    /// [`Node::peerapi_addr`](ts_control::Node::peerapi_addr).
162    pub peerapi_url: String,
163}
164
165/// Compute the sorted Taildrop send-target list from the peer set, given the local node's owning
166/// user id. The pure core of [`Runtime::file_targets`](crate::Runtime::file_targets) — separated out
167/// so the eligibility + ordering rules are unit-testable without spinning up the actor graph (the
168/// node-level file-sharing gate is applied by the caller before this runs).
169///
170/// A peer is a target when it advertises a reachable peerAPI (Go `PeerAPIBase(p) != ""`) **and** is
171/// either owned by `self_user_id` **or** carries the file-sharing-target capability — Go's two-way
172/// OR. Sorted by MagicDNS name (Go sorts by `Node.Name`), falling back to the bare hostname.
173pub(crate) fn build_file_targets(peers: Vec<Node>, self_user_id: UserId) -> Vec<FileTarget> {
174    let mut targets: Vec<FileTarget> = peers
175        .into_iter()
176        .filter_map(|peer| {
177            // Must advertise a reachable peerAPI (Go `PeerAPIBase(p) != ""`).
178            let addr = peer.peerapi_addr()?;
179            // Same owner OR explicitly an ACL file-sharing target (Go's two-way OR).
180            let eligible = peer.user_id == self_user_id || peer.is_file_sharing_target();
181            if !eligible {
182                return None;
183            }
184            Some(FileTarget {
185                peerapi_url: format!("http://{addr}"),
186                node: peer,
187            })
188        })
189        .collect();
190    // Sort by MagicDNS name (Go sorts by `Node.Name`), bare hostname as the fallback key.
191    targets.sort_by(|a, b| {
192        let name = |t: &FileTarget| {
193            t.node
194                .fqdn_opt(false)
195                .unwrap_or_else(|| t.node.hostname.clone())
196        };
197        name(a).cmp(&name(b))
198    });
199    targets
200}
201
202#[cfg(test)]
203mod tests {
204    use ts_control::{Node, StableNodeId, TailnetAddress};
205
206    use super::*;
207
208    fn node(stable: &str, hostname: &str, tailnet: Option<&str>, ipv4: &str) -> Node {
209        Node {
210            id: 1,
211            stable_id: StableNodeId(stable.to_string()),
212            hostname: hostname.to_string(),
213            user_id: 0,
214            tailnet: tailnet.map(str::to_string),
215            tags: vec![],
216            tailnet_address: TailnetAddress {
217                ipv4: format!("{ipv4}/32").parse().unwrap(),
218                ipv6: "fd7a::1/128".parse().unwrap(),
219            },
220            node_key: [0u8; 32].into(),
221            node_key_expiry: None,
222            online: None,
223            last_seen: None,
224            key_signature: vec![],
225            machine_key: None,
226            disco_key: None,
227            accepted_routes: vec![],
228            underlay_addresses: vec![],
229            derp_region: None,
230            cap: Default::default(),
231            cap_map: Default::default(),
232            peerapi_port: None,
233            peerapi_dns_proxy: false,
234            is_wireguard_only: false,
235            exit_node_dns_resolvers: vec![],
236            peer_relay: false,
237            service_vips: Default::default(),
238        }
239    }
240
241    #[test]
242    fn status_node_display_name_prefers_fqdn() {
243        let with_tailnet = node("n1", "host", Some("ts.net"), "100.64.0.1");
244        assert_eq!(
245            StatusNode::from_node(&with_tailnet).display_name,
246            "host.ts.net"
247        );
248
249        let bare = node("n2", "solo", None, "100.64.0.2");
250        assert_eq!(StatusNode::from_node(&bare).display_name, "solo");
251    }
252
253    #[test]
254    fn status_node_addresses_and_online_surfaced() {
255        let n = node("n1", "host", Some("ts.net"), "100.64.0.7");
256        let s = StatusNode::from_node(&n);
257
258        assert_eq!(s.ipv4, "100.64.0.7".parse::<IpAddr>().unwrap());
259        assert_eq!(s.ipv6, "fd7a::1".parse::<IpAddr>().unwrap());
260        // A node with no online data surfaces `None` (unknown) — never a fabricated `false`.
261        assert_eq!(s.online, None);
262        assert_eq!(s.last_seen, None);
263
264        // A node whose domain online state is known surfaces it through StatusNode (no longer
265        // hardwired to None).
266        let mut online = node("n2", "up", Some("ts.net"), "100.64.0.8");
267        online.online = Some(true);
268        assert_eq!(StatusNode::from_node(&online).online, Some(true));
269
270        let mut offline = node("n3", "down", Some("ts.net"), "100.64.0.9");
271        offline.online = Some(false);
272        assert_eq!(StatusNode::from_node(&offline).online, Some(false));
273    }
274
275    #[test]
276    fn status_node_detects_exit_node() {
277        let mut not_exit = node("n1", "a", Some("ts.net"), "100.64.0.1");
278        not_exit.accepted_routes = vec!["100.64.0.1/32".parse().unwrap()];
279        assert!(!StatusNode::from_node(&not_exit).is_exit_node);
280
281        let mut exit = node("n2", "b", Some("ts.net"), "100.64.0.2");
282        exit.accepted_routes = vec![
283            "100.64.0.2/32".parse().unwrap(),
284            "0.0.0.0/0".parse().unwrap(),
285        ];
286        assert!(StatusNode::from_node(&exit).is_exit_node);
287
288        let mut exit6 = node("n3", "c", Some("ts.net"), "100.64.0.3");
289        exit6.accepted_routes = vec!["::/0".parse().unwrap()];
290        assert!(StatusNode::from_node(&exit6).is_exit_node);
291    }
292
293    #[test]
294    fn whois_caps_empty_when_node_has_none() {
295        // A node with no cap_map surfaces empty capabilities (not fabricated), and no user unless a
296        // profile was joined in.
297        let n = node("n1", "host", Some("ts.net"), "100.64.0.9");
298        let whois = WhoIs::from_node_with_user(n.clone(), None);
299
300        assert_eq!(whois.node, n);
301        assert_eq!(whois.user, None);
302        assert!(whois.capabilities.is_empty());
303    }
304
305    #[test]
306    fn whois_populates_capabilities_from_cap_map() {
307        // WhoIs surfaces the domain Node's cap_map verbatim, sorted by capability name (BTreeMap).
308        let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
309        n.cap_map
310            .insert("https://tailscale.com/cap/is-admin".to_string(), vec![]);
311        n.cap_map.insert(
312            "cap/ssh".to_string(),
313            vec!["root".to_string(), "ubuntu".to_string()],
314        );
315        let whois = WhoIs::from_node_with_user(n, None);
316
317        // BTreeMap iteration is sorted: "cap/ssh" < "https://…".
318        assert_eq!(
319            whois.capabilities,
320            vec![
321                (
322                    "cap/ssh".to_string(),
323                    vec!["root".to_string(), "ubuntu".to_string()]
324                ),
325                ("https://tailscale.com/cap/is-admin".to_string(), vec![]),
326            ]
327        );
328    }
329
330    #[test]
331    fn whois_from_node_with_user_sets_user_and_caps() {
332        let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
333        n.cap_map.insert("cap/x".to_string(), vec!["y".to_string()]);
334        let whois = WhoIs::from_node_with_user(n, Some("alice@example.com".to_string()));
335
336        assert_eq!(whois.user, Some("alice@example.com".to_string()));
337        assert_eq!(
338            whois.capabilities,
339            vec![("cap/x".to_string(), vec!["y".to_string()])]
340        );
341    }
342
343    /// Build a peer with a reachable peerAPI on `ipv4`, owned by `user`.
344    fn peer_with_peerapi(stable: &str, hostname: &str, ipv4: &str, user: UserId) -> Node {
345        let mut n = node(stable, hostname, Some("ts.net"), ipv4);
346        n.user_id = user;
347        n.peerapi_port = Some(8089);
348        n
349    }
350
351    #[test]
352    fn file_targets_includes_same_owner_peer_with_peerapi() {
353        let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
354        let targets = build_file_targets(vec![peer], 42);
355
356        assert_eq!(targets.len(), 1);
357        assert_eq!(targets[0].peerapi_url, "http://100.64.0.5:8089");
358        assert_eq!(targets[0].node.hostname, "host");
359    }
360
361    #[test]
362    fn file_targets_includes_cross_owner_peer_with_target_cap() {
363        // Different owner, but carries the file-sharing-target cap → still a target (Go's OR).
364        let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
365        peer.cap_map
366            .insert("tailscale.com/cap/file-sharing-target".to_string(), vec![]);
367        let targets = build_file_targets(vec![peer], 42);
368
369        assert_eq!(
370            targets.len(),
371            1,
372            "cross-owner peer with the target cap qualifies"
373        );
374    }
375
376    #[test]
377    fn file_targets_excludes_cross_owner_peer_without_cap() {
378        // Different owner and no target cap → excluded.
379        let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
380        let targets = build_file_targets(vec![peer], 42);
381
382        assert!(
383            targets.is_empty(),
384            "a different owner without the cap is not a target"
385        );
386    }
387
388    #[test]
389    fn file_targets_excludes_peer_without_peerapi() {
390        // Same owner, but advertises no peerAPI (no port) → excluded (Go `PeerAPIBase(p) == ""`).
391        let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
392        peer.peerapi_port = None;
393        let targets = build_file_targets(vec![peer], 42);
394
395        assert!(
396            targets.is_empty(),
397            "a peer with no peerAPI cannot be a Taildrop target"
398        );
399    }
400
401    #[test]
402    fn file_targets_sorted_by_magic_dns_name() {
403        // Insert out of order; expect sorted by fqdn ("alpha.ts.net" < "zeta.ts.net").
404        let zeta = peer_with_peerapi("p2", "zeta", "100.64.0.6", 42);
405        let alpha = peer_with_peerapi("p1", "alpha", "100.64.0.5", 42);
406        let targets = build_file_targets(vec![zeta, alpha], 42);
407
408        let names: Vec<_> = targets.iter().map(|t| t.node.hostname.clone()).collect();
409        assert_eq!(names, vec!["alpha", "zeta"]);
410    }
411}