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** — still a gap: the domain `Node` does not retain the wire-level `online` /
19//!   `last_seen` fields, so `StatusNode::online` is always `None`. We surface what the domain model
20//!   actually holds rather than inventing a value.
21
22use std::net::{IpAddr, SocketAddr};
23
24use ts_control::{Node, StableNodeId};
25
26/// A snapshot of the local netmap: this node plus every known peer.
27///
28/// Analogous to tsnet's `ipnstate.Status`. Built by [`Runtime::status`](crate::Runtime::status)
29/// from the self node held by the control runner and the peers held by the peer tracker.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Status {
32    /// This node, if a netmap has been received from control yet.
33    pub self_node: Option<StatusNode>,
34    /// Every peer currently known in the netmap.
35    pub peers: Vec<StatusNode>,
36    /// The stable id of the exit node traffic is **currently** egressing through, if any (Go's
37    /// `Status.ExitNodeStatus.ID`). This is the *resolved + fail-closed* answer from the route
38    /// updater — `None` when no exit node is configured, the configured selector matches no peer, or
39    /// the matched peer no longer advertises a default route — so it reflects what is actually
40    /// engaged, not merely what [`Config::exit_node`](ts_control::Config) requested. Find the peer's
41    /// details by matching this id against [`peers`](Status::peers).
42    pub active_exit_node: Option<StableNodeId>,
43}
44
45/// A single node entry in a [`Status`] snapshot.
46///
47/// Analogous to tsnet's `ipnstate.PeerStatus`.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct StatusNode {
50    /// The node's stable id (stable across re-registration).
51    pub stable_id: StableNodeId,
52    /// A display name for the node: its fqdn if a tailnet component is known, else its bare
53    /// hostname.
54    pub display_name: String,
55    /// The node's tailnet IPv4 address.
56    pub ipv4: IpAddr,
57    /// The node's tailnet IPv6 address.
58    pub ipv6: IpAddr,
59    /// Whether the node is online, if known.
60    ///
61    /// Always `None` in this fork: the domain [`Node`](ts_control::Node) does not retain the
62    /// wire-level `online` field (see the module-level capability/user gap note).
63    pub online: Option<bool>,
64    /// The routes this node accepts traffic for (its own `/32` and `/128`, plus any advertised
65    /// subnet routes and possibly the exit-node default route).
66    pub allowed_routes: Vec<ipnet::IpNet>,
67    /// Whether this node advertises a default route (`0.0.0.0/0` or `::/0`), making it eligible to
68    /// be selected as an exit node.
69    pub is_exit_node: bool,
70}
71
72impl StatusNode {
73    /// Build a [`StatusNode`] from a domain [`Node`].
74    pub fn from_node(node: &Node) -> Self {
75        let is_exit_node = node
76            .accepted_routes
77            .iter()
78            .any(|route| route.prefix_len() == 0);
79
80        Self {
81            stable_id: node.stable_id.clone(),
82            display_name: node
83                .fqdn_opt(false)
84                .unwrap_or_else(|| node.hostname.clone()),
85            ipv4: node.tailnet_address.ipv4.addr().into(),
86            ipv6: node.tailnet_address.ipv6.addr().into(),
87            // The domain `Node` carries no online state; do not fabricate one.
88            online: None,
89            allowed_routes: node.accepted_routes.clone(),
90            is_exit_node,
91        }
92    }
93}
94
95/// The result of a [`Runtime::whois`](crate::Runtime::whois) lookup: the node that owns a tailnet
96/// source address, plus its user and capabilities.
97///
98/// Analogous to tsnet's `apitype.WhoIsResponse`.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct WhoIs {
101    /// The node that owns the queried source IP.
102    pub node: Node,
103    /// The login/email of the user that owns the node, if known.
104    ///
105    /// Always `None` in this fork: the domain [`Node`](ts_control::Node) does not retain the
106    /// wire-level user/login mapping (see the module-level capability/user gap note).
107    pub user: Option<String>,
108    /// The node's capability map, as `(capability, args)` pairs.
109    ///
110    /// Populated from the domain [`Node`](ts_control::Node)'s `cap_map` (the control-pushed
111    /// `CapMap`), sorted by capability name (the underlying map is a `BTreeMap`). Empty when control
112    /// granted the node no capabilities. Mirrors tsnet's `WhoIsResponse.CapMap`.
113    pub capabilities: Vec<(String, Vec<String>)>,
114}
115
116impl WhoIs {
117    /// Build a [`WhoIs`] from the owning node and its resolved owner login/display name (if the
118    /// netmap's `UserProfiles` table mapped the node's owning user id to a profile; `None` when
119    /// control sent no profile — e.g. a tagged node with no human owner).
120    ///
121    /// `capabilities` is always populated from the node's `cap_map`.
122    pub(crate) fn from_node_with_user(node: Node, user: Option<String>) -> Self {
123        let capabilities = node
124            .cap_map
125            .iter()
126            .map(|(cap, args)| (cap.clone(), args.clone()))
127            .collect();
128        Self {
129            node,
130            user,
131            capabilities,
132        }
133    }
134}
135
136/// Resolve which node owns a tailnet source address, used by WhoIs.
137pub(crate) fn whois_addr(addr: SocketAddr) -> IpAddr {
138    addr.ip()
139}
140
141#[cfg(test)]
142mod tests {
143    use ts_control::{Node, StableNodeId, TailnetAddress};
144
145    use super::*;
146
147    fn node(stable: &str, hostname: &str, tailnet: Option<&str>, ipv4: &str) -> Node {
148        Node {
149            id: 1,
150            stable_id: StableNodeId(stable.to_string()),
151            hostname: hostname.to_string(),
152            user_id: 0,
153            tailnet: tailnet.map(str::to_string),
154            tags: vec![],
155            tailnet_address: TailnetAddress {
156                ipv4: format!("{ipv4}/32").parse().unwrap(),
157                ipv6: "fd7a::1/128".parse().unwrap(),
158            },
159            node_key: [0u8; 32].into(),
160            node_key_expiry: None,
161            key_signature: vec![],
162            machine_key: None,
163            disco_key: None,
164            accepted_routes: vec![],
165            underlay_addresses: vec![],
166            derp_region: None,
167            cap: Default::default(),
168            cap_map: Default::default(),
169            peerapi_port: None,
170            peerapi_dns_proxy: false,
171            is_wireguard_only: false,
172            exit_node_dns_resolvers: vec![],
173            peer_relay: false,
174            service_vips: Default::default(),
175        }
176    }
177
178    #[test]
179    fn status_node_display_name_prefers_fqdn() {
180        let with_tailnet = node("n1", "host", Some("ts.net"), "100.64.0.1");
181        assert_eq!(
182            StatusNode::from_node(&with_tailnet).display_name,
183            "host.ts.net"
184        );
185
186        let bare = node("n2", "solo", None, "100.64.0.2");
187        assert_eq!(StatusNode::from_node(&bare).display_name, "solo");
188    }
189
190    #[test]
191    fn status_node_addresses_and_online_gap() {
192        let n = node("n1", "host", Some("ts.net"), "100.64.0.7");
193        let s = StatusNode::from_node(&n);
194
195        assert_eq!(s.ipv4, "100.64.0.7".parse::<IpAddr>().unwrap());
196        assert_eq!(s.ipv6, "fd7a::1".parse::<IpAddr>().unwrap());
197        // The domain Node carries no online state; we surface the gap as None, never a fabricated
198        // value.
199        assert_eq!(s.online, None);
200    }
201
202    #[test]
203    fn status_node_detects_exit_node() {
204        let mut not_exit = node("n1", "a", Some("ts.net"), "100.64.0.1");
205        not_exit.accepted_routes = vec!["100.64.0.1/32".parse().unwrap()];
206        assert!(!StatusNode::from_node(&not_exit).is_exit_node);
207
208        let mut exit = node("n2", "b", Some("ts.net"), "100.64.0.2");
209        exit.accepted_routes = vec![
210            "100.64.0.2/32".parse().unwrap(),
211            "0.0.0.0/0".parse().unwrap(),
212        ];
213        assert!(StatusNode::from_node(&exit).is_exit_node);
214
215        let mut exit6 = node("n3", "c", Some("ts.net"), "100.64.0.3");
216        exit6.accepted_routes = vec!["::/0".parse().unwrap()];
217        assert!(StatusNode::from_node(&exit6).is_exit_node);
218    }
219
220    #[test]
221    fn whois_caps_empty_when_node_has_none() {
222        // A node with no cap_map surfaces empty capabilities (not fabricated), and no user unless a
223        // profile was joined in.
224        let n = node("n1", "host", Some("ts.net"), "100.64.0.9");
225        let whois = WhoIs::from_node_with_user(n.clone(), None);
226
227        assert_eq!(whois.node, n);
228        assert_eq!(whois.user, None);
229        assert!(whois.capabilities.is_empty());
230    }
231
232    #[test]
233    fn whois_populates_capabilities_from_cap_map() {
234        // WhoIs surfaces the domain Node's cap_map verbatim, sorted by capability name (BTreeMap).
235        let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
236        n.cap_map
237            .insert("https://tailscale.com/cap/is-admin".to_string(), vec![]);
238        n.cap_map.insert(
239            "cap/ssh".to_string(),
240            vec!["root".to_string(), "ubuntu".to_string()],
241        );
242        let whois = WhoIs::from_node_with_user(n, None);
243
244        // BTreeMap iteration is sorted: "cap/ssh" < "https://…".
245        assert_eq!(
246            whois.capabilities,
247            vec![
248                (
249                    "cap/ssh".to_string(),
250                    vec!["root".to_string(), "ubuntu".to_string()]
251                ),
252                ("https://tailscale.com/cap/is-admin".to_string(), vec![]),
253            ]
254        );
255    }
256
257    #[test]
258    fn whois_from_node_with_user_sets_user_and_caps() {
259        let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
260        n.cap_map.insert("cap/x".to_string(), vec!["y".to_string()]);
261        let whois = WhoIs::from_node_with_user(n, Some("alice@example.com".to_string()));
262
263        assert_eq!(whois.user, Some("alice@example.com".to_string()));
264        assert_eq!(
265            whois.capabilities,
266            vec![("cap/x".to_string(), vec!["y".to_string()])]
267        );
268    }
269}