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 measured-latency entry for one DERP region in a [`NetcheckReport`].
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct RegionLatency {
153 /// The DERP region id (Go `tailcfg.DERPRegionID`).
154 pub region_id: u32,
155 /// The measured round-trip latency to the region's closest DERP node.
156 pub latency: std::time::Duration,
157}
158
159/// A snapshot of this node's latest network conditions report — the Rust analog of Go's
160/// `netcheck.Report` as `tailscale netcheck` surfaces it.
161///
162/// ## Surfaced subset (do not fabricate)
163/// Go's `netcheck.Report` also carries UDP/IPv4/IPv6 reachability, port-mapping support
164/// (UPnP/PMP/PCP), `MappingVariesByDestIP`, global-address discovery, etc. This fork's net-report
165/// path measures only **DERP-region latency** (the data that drives home-region selection), so the
166/// report carries exactly that — the preferred (lowest-latency) region and the per-region latency
167/// map — rather than inventing fields we never probe. Empty before the first measurement.
168#[derive(Debug, Clone, PartialEq, Eq, Default, kameo::Reply)]
169pub struct NetcheckReport {
170 /// The id of the preferred DERP region — the lowest-latency region this node measured, the one it
171 /// homes to (Go `Report.PreferredDERP`). `None` before the first measurement / when no region
172 /// was reachable.
173 pub preferred_derp: Option<u32>,
174 /// Per-region measured latencies, sorted by latency ascending (Go `Report.RegionLatency`, here as
175 /// an ordered list). The first entry, when present, is the [`preferred_derp`](Self::preferred_derp)
176 /// region.
177 pub region_latencies: Vec<RegionLatency>,
178}
179
180impl NetcheckReport {
181 /// Build a report from the latest DERP-region measurements (the `RegionResult` set the latency
182 /// measurer produces). `results` is expected sorted by latency ascending (the measurer's
183 /// `RegionResult` `Ord` sorts on latency first), so the first entry is the preferred region; we
184 /// do not re-sort beyond trusting that contract for `preferred_derp`, but the list is emitted in
185 /// the order given. An empty `results` yields the default (no preferred region, empty list).
186 pub(crate) fn from_region_results(results: &[ts_netcheck::RegionResult]) -> NetcheckReport {
187 let region_latencies: Vec<RegionLatency> = results
188 .iter()
189 .map(|r| RegionLatency {
190 // `ts_derp::RegionId` is a `NonZeroU32` newtype (its `.0` is the public inner).
191 region_id: r.id.0.get(),
192 latency: r.latency,
193 })
194 .collect();
195 NetcheckReport {
196 preferred_derp: region_latencies.first().map(|r| r.region_id),
197 region_latencies,
198 }
199 }
200}
201
202/// A tailnet peer this node can send a Taildrop file *to*, plus the peerAPI base URL to reach it.
203///
204/// Analogous to tsnet's `apitype.FileTarget`. The set is produced by
205/// [`Runtime::file_targets`](crate::Runtime::file_targets) (exposed as `Device::file_targets`).
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct FileTarget {
208 /// The target peer's node record — pass straight to the Taildrop send path
209 /// (`Device::send_file`), which re-derives the same peerAPI address.
210 pub node: Node,
211 /// The `http://ip:port` base URL of the peer's peerAPI, with no trailing path — the exact shape
212 /// of Go's `apitype.FileTarget.PeerAPIURL`. Derived from
213 /// [`Node::peerapi_addr`](ts_control::Node::peerapi_addr).
214 pub peerapi_url: String,
215}
216
217/// Compute the sorted Taildrop send-target list from the peer set, given the local node's owning
218/// user id. The pure core of [`Runtime::file_targets`](crate::Runtime::file_targets) — separated out
219/// so the eligibility + ordering rules are unit-testable without spinning up the actor graph (the
220/// node-level file-sharing gate is applied by the caller before this runs).
221///
222/// A peer is a target when it advertises a reachable peerAPI (Go `PeerAPIBase(p) != ""`) **and** is
223/// either owned by `self_user_id` **or** carries the file-sharing-target capability — Go's two-way
224/// OR. Sorted by MagicDNS name (Go sorts by `Node.Name`), falling back to the bare hostname.
225pub(crate) fn build_file_targets(peers: Vec<Node>, self_user_id: UserId) -> Vec<FileTarget> {
226 let mut targets: Vec<FileTarget> = peers
227 .into_iter()
228 .filter_map(|peer| {
229 // Must advertise a reachable peerAPI (Go `PeerAPIBase(p) != ""`).
230 let addr = peer.peerapi_addr()?;
231 // Same owner OR explicitly an ACL file-sharing target (Go's two-way OR).
232 let eligible = peer.user_id == self_user_id || peer.is_file_sharing_target();
233 if !eligible {
234 return None;
235 }
236 Some(FileTarget {
237 peerapi_url: format!("http://{addr}"),
238 node: peer,
239 })
240 })
241 .collect();
242 // Sort by MagicDNS name (Go sorts by `Node.Name`), bare hostname as the fallback key.
243 targets.sort_by(|a, b| {
244 let name = |t: &FileTarget| {
245 t.node
246 .fqdn_opt(false)
247 .unwrap_or_else(|| t.node.hostname.clone())
248 };
249 name(a).cmp(&name(b))
250 });
251 targets
252}
253
254#[cfg(test)]
255mod tests {
256 use ts_control::{Node, StableNodeId, TailnetAddress};
257
258 use super::*;
259
260 fn node(stable: &str, hostname: &str, tailnet: Option<&str>, ipv4: &str) -> Node {
261 Node {
262 id: 1,
263 stable_id: StableNodeId(stable.to_string()),
264 hostname: hostname.to_string(),
265 user_id: 0,
266 tailnet: tailnet.map(str::to_string),
267 tags: vec![],
268 tailnet_address: TailnetAddress {
269 ipv4: format!("{ipv4}/32").parse().unwrap(),
270 ipv6: "fd7a::1/128".parse().unwrap(),
271 },
272 node_key: [0u8; 32].into(),
273 node_key_expiry: None,
274 online: None,
275 last_seen: None,
276 key_signature: vec![],
277 machine_key: None,
278 disco_key: None,
279 accepted_routes: vec![],
280 underlay_addresses: vec![],
281 derp_region: None,
282 cap: Default::default(),
283 cap_map: Default::default(),
284 peerapi_port: None,
285 peerapi_dns_proxy: false,
286 is_wireguard_only: false,
287 exit_node_dns_resolvers: vec![],
288 peer_relay: false,
289 service_vips: Default::default(),
290 }
291 }
292
293 #[test]
294 fn status_node_display_name_prefers_fqdn() {
295 let with_tailnet = node("n1", "host", Some("ts.net"), "100.64.0.1");
296 assert_eq!(
297 StatusNode::from_node(&with_tailnet).display_name,
298 "host.ts.net"
299 );
300
301 let bare = node("n2", "solo", None, "100.64.0.2");
302 assert_eq!(StatusNode::from_node(&bare).display_name, "solo");
303 }
304
305 #[test]
306 fn status_node_addresses_and_online_surfaced() {
307 let n = node("n1", "host", Some("ts.net"), "100.64.0.7");
308 let s = StatusNode::from_node(&n);
309
310 assert_eq!(s.ipv4, "100.64.0.7".parse::<IpAddr>().unwrap());
311 assert_eq!(s.ipv6, "fd7a::1".parse::<IpAddr>().unwrap());
312 // A node with no online data surfaces `None` (unknown) — never a fabricated `false`.
313 assert_eq!(s.online, None);
314 assert_eq!(s.last_seen, None);
315
316 // A node whose domain online state is known surfaces it through StatusNode (no longer
317 // hardwired to None).
318 let mut online = node("n2", "up", Some("ts.net"), "100.64.0.8");
319 online.online = Some(true);
320 assert_eq!(StatusNode::from_node(&online).online, Some(true));
321
322 let mut offline = node("n3", "down", Some("ts.net"), "100.64.0.9");
323 offline.online = Some(false);
324 assert_eq!(StatusNode::from_node(&offline).online, Some(false));
325 }
326
327 #[test]
328 fn status_node_detects_exit_node() {
329 let mut not_exit = node("n1", "a", Some("ts.net"), "100.64.0.1");
330 not_exit.accepted_routes = vec!["100.64.0.1/32".parse().unwrap()];
331 assert!(!StatusNode::from_node(¬_exit).is_exit_node);
332
333 let mut exit = node("n2", "b", Some("ts.net"), "100.64.0.2");
334 exit.accepted_routes = vec![
335 "100.64.0.2/32".parse().unwrap(),
336 "0.0.0.0/0".parse().unwrap(),
337 ];
338 assert!(StatusNode::from_node(&exit).is_exit_node);
339
340 let mut exit6 = node("n3", "c", Some("ts.net"), "100.64.0.3");
341 exit6.accepted_routes = vec!["::/0".parse().unwrap()];
342 assert!(StatusNode::from_node(&exit6).is_exit_node);
343 }
344
345 #[test]
346 fn whois_caps_empty_when_node_has_none() {
347 // A node with no cap_map surfaces empty capabilities (not fabricated), and no user unless a
348 // profile was joined in.
349 let n = node("n1", "host", Some("ts.net"), "100.64.0.9");
350 let whois = WhoIs::from_node_with_user(n.clone(), None);
351
352 assert_eq!(whois.node, n);
353 assert_eq!(whois.user, None);
354 assert!(whois.capabilities.is_empty());
355 }
356
357 #[test]
358 fn whois_populates_capabilities_from_cap_map() {
359 // WhoIs surfaces the domain Node's cap_map verbatim, sorted by capability name (BTreeMap).
360 let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
361 n.cap_map
362 .insert("https://tailscale.com/cap/is-admin".to_string(), vec![]);
363 n.cap_map.insert(
364 "cap/ssh".to_string(),
365 vec!["root".to_string(), "ubuntu".to_string()],
366 );
367 let whois = WhoIs::from_node_with_user(n, None);
368
369 // BTreeMap iteration is sorted: "cap/ssh" < "https://…".
370 assert_eq!(
371 whois.capabilities,
372 vec![
373 (
374 "cap/ssh".to_string(),
375 vec!["root".to_string(), "ubuntu".to_string()]
376 ),
377 ("https://tailscale.com/cap/is-admin".to_string(), vec![]),
378 ]
379 );
380 }
381
382 #[test]
383 fn whois_from_node_with_user_sets_user_and_caps() {
384 let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
385 n.cap_map.insert("cap/x".to_string(), vec!["y".to_string()]);
386 let whois = WhoIs::from_node_with_user(n, Some("alice@example.com".to_string()));
387
388 assert_eq!(whois.user, Some("alice@example.com".to_string()));
389 assert_eq!(
390 whois.capabilities,
391 vec![("cap/x".to_string(), vec!["y".to_string()])]
392 );
393 }
394
395 /// Build a peer with a reachable peerAPI on `ipv4`, owned by `user`.
396 fn peer_with_peerapi(stable: &str, hostname: &str, ipv4: &str, user: UserId) -> Node {
397 let mut n = node(stable, hostname, Some("ts.net"), ipv4);
398 n.user_id = user;
399 n.peerapi_port = Some(8089);
400 n
401 }
402
403 #[test]
404 fn file_targets_includes_same_owner_peer_with_peerapi() {
405 let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
406 let targets = build_file_targets(vec![peer], 42);
407
408 assert_eq!(targets.len(), 1);
409 assert_eq!(targets[0].peerapi_url, "http://100.64.0.5:8089");
410 assert_eq!(targets[0].node.hostname, "host");
411 }
412
413 #[test]
414 fn file_targets_includes_cross_owner_peer_with_target_cap() {
415 // Different owner, but carries the file-sharing-target cap → still a target (Go's OR).
416 let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
417 peer.cap_map
418 .insert("tailscale.com/cap/file-sharing-target".to_string(), vec![]);
419 let targets = build_file_targets(vec![peer], 42);
420
421 assert_eq!(
422 targets.len(),
423 1,
424 "cross-owner peer with the target cap qualifies"
425 );
426 }
427
428 #[test]
429 fn file_targets_excludes_cross_owner_peer_without_cap() {
430 // Different owner and no target cap → excluded.
431 let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
432 let targets = build_file_targets(vec![peer], 42);
433
434 assert!(
435 targets.is_empty(),
436 "a different owner without the cap is not a target"
437 );
438 }
439
440 #[test]
441 fn file_targets_excludes_peer_without_peerapi() {
442 // Same owner, but advertises no peerAPI (no port) → excluded (Go `PeerAPIBase(p) == ""`).
443 let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
444 peer.peerapi_port = None;
445 let targets = build_file_targets(vec![peer], 42);
446
447 assert!(
448 targets.is_empty(),
449 "a peer with no peerAPI cannot be a Taildrop target"
450 );
451 }
452
453 #[test]
454 fn file_targets_sorted_by_magic_dns_name() {
455 // Insert out of order; expect sorted by fqdn ("alpha.ts.net" < "zeta.ts.net").
456 let zeta = peer_with_peerapi("p2", "zeta", "100.64.0.6", 42);
457 let alpha = peer_with_peerapi("p1", "alpha", "100.64.0.5", 42);
458 let targets = build_file_targets(vec![zeta, alpha], 42);
459
460 let names: Vec<_> = targets.iter().map(|t| t.node.hostname.clone()).collect();
461 assert_eq!(names, vec!["alpha", "zeta"]);
462 }
463
464 fn region_result(id: u32, latency_ms: u64) -> ts_netcheck::RegionResult {
465 ts_netcheck::RegionResult {
466 latency: std::time::Duration::from_millis(latency_ms),
467 id: ts_derp::RegionId(std::num::NonZeroU32::new(id).unwrap()),
468 latency_map_key: format!("{id}-v4"),
469 connected_remote: "1.2.3.4:443".parse().unwrap(),
470 }
471 }
472
473 #[test]
474 fn netcheck_report_preferred_is_first_region() {
475 // The measurer hands results sorted by latency ascending, so the first is the preferred
476 // (home) region and every region is surfaced.
477 let results = [
478 region_result(5, 12),
479 region_result(9, 40),
480 region_result(2, 88),
481 ];
482 let report = NetcheckReport::from_region_results(&results);
483 assert_eq!(
484 report.preferred_derp,
485 Some(5),
486 "lowest-latency region is preferred"
487 );
488 assert_eq!(report.region_latencies.len(), 3);
489 assert_eq!(report.region_latencies[0].region_id, 5);
490 assert_eq!(
491 report.region_latencies[0].latency,
492 std::time::Duration::from_millis(12)
493 );
494 // Order is preserved as given (latency-ascending from the measurer).
495 let ids: Vec<u32> = report
496 .region_latencies
497 .iter()
498 .map(|r| r.region_id)
499 .collect();
500 assert_eq!(ids, vec![5, 9, 2]);
501 }
502
503 #[test]
504 fn netcheck_report_empty_when_no_measurements() {
505 // Before any measurement (or when none was reachable): no preferred region, empty list — not
506 // a fabricated value.
507 let report = NetcheckReport::from_region_results(&[]);
508 assert_eq!(report, NetcheckReport::default());
509 assert_eq!(report.preferred_derp, None);
510 assert!(report.region_latencies.is_empty());
511 }
512}