//! Reading `tailscale status --json`. //! //! Tailscale documents this output as unstable — the CLI's own help says the //! JSON schema is subject to change and not to be depended on — so every field //! below is optional, unknown fields are ignored rather than rejected, and a //! field whose shape stops making sense degrades to "unknown" rather than //! failing the whole roster. A devices page that goes blank because a Tailscale //! release added a key would be worse than one that shows a device with a gap //! in it. //! //! What is deliberately **not** modelled, though the document carries it: //! byte counters, relay and DERP details, endpoint addresses, key expiry, //! creation time, exit-node status, Taildrop targets, SSH host keys, the health //! message list, and the `User` table's display names and login addresses. //! None of them answer "which machine do I want to work on", and the last of //! them is an account identity this crate has no reason to hold — see //! [`crate::DeviceOwnership`]. use std::collections::BTreeMap; use serde::Deserialize; use serde_json::error::Category; use crate::DeviceError; use crate::model::{ BackendState, Device, DeviceOwnership, DeviceReachability, LastSeen, LumbridgePresence, NodeId, TailnetRoster, }; /// The status document, as much of it as we read. /// /// No `deny_unknown_fields`: the point of this struct is to survive a schema /// that is allowed to grow. #[derive(Debug, Deserialize)] struct StatusWire { #[serde(default, rename = "BackendState")] backend_state: Option, #[serde(default, rename = "MagicDNSSuffix")] magic_dns_suffix: Option, #[serde(default, rename = "CurrentTailnet")] current_tailnet: Option, #[serde(default, rename = "Self")] self_node: Option, /// Keyed by node key, which is why the roster imposes its own order. #[serde(default, rename = "Peer")] peer: Option>, } #[derive(Debug, Deserialize)] struct TailnetWire { #[serde(default, rename = "Name")] name: Option, } #[derive(Debug, Deserialize)] struct NodeWire { #[serde(default, rename = "ID")] id: Option, #[serde(default, rename = "HostName")] host_name: Option, #[serde(default, rename = "DNSName")] dns_name: Option, #[serde(default, rename = "OS")] os: Option, #[serde(default, rename = "Online")] online: Option, #[serde(default, rename = "LastSeen")] last_seen: Option, #[serde(default, rename = "TailscaleIPs")] tailscale_ips: Option>, #[serde(default, rename = "Tags")] tags: Option>, #[serde(default, rename = "UserID")] user_id: Option, } /// Parses `tailscale status --json` into a roster. /// /// The JSON arrives as a `&str` from the caller rather than from a subprocess /// this crate spawns. That keeps process execution on the runtime side of the /// architecture's boundaries, and it is what lets every rule in this crate be /// tested against a fixture with no tailnet present. /// /// Every device comes back with [`LumbridgePresence::Unknown`]: a status /// document cannot say what software a peer is running. Apply the user's own /// registrations with [`crate::DeviceRegistrations::apply_to`] to raise the /// ones they have told us about. /// /// # Errors /// /// Returns [`DeviceError::NotJson`] when the text is not JSON at all — the /// usual cause is a caller that captured `tailscale`'s stderr — and /// [`DeviceError::NotAStatus`] when it is JSON that carries none of the fields /// a status document has. An empty tailnet and an unreadable one are different /// facts and this function refuses to collapse them. pub fn parse_status(json: &str) -> Result { let wire: StatusWire = serde_json::from_str(json).map_err(|error| match error.classify() { Category::Syntax | Category::Eof => DeviceError::NotJson, Category::Data | Category::Io => DeviceError::NotAStatus, })?; if wire.backend_state.is_none() && wire.self_node.is_none() && wire.peer.is_none() { return Err(DeviceError::NotAStatus); } let self_user_id = wire.self_node.as_ref().and_then(|node| node.user_id); let mut devices = Vec::new(); if let Some(node) = &wire.self_node { devices.push(device_from(node, None, self_user_id, true)); } let mut peers: Vec = wire .peer .iter() .flatten() .map(|(node_key, node)| device_from(node, Some(node_key), self_user_id, false)) .collect(); // Case-insensitive by name, then by id, so a refresh cannot shuffle two // devices whose names differ only in case. peers.sort_by(|left, right| { left.display_name() .to_lowercase() .cmp(&right.display_name().to_lowercase()) .then_with(|| left.node_id.cmp(&right.node_id)) }); devices.extend(peers); Ok(TailnetRoster { backend_state: wire .backend_state .as_deref() .map_or(BackendState::Unrecognised, BackendState::parse), tailnet_name: wire .current_tailnet .and_then(|tailnet| tailnet.name) .and_then(non_empty), magic_dns_suffix: wire.magic_dns_suffix.and_then(non_empty), devices, }) } /// Builds one device, using the peer map's key as the identifier of last resort. /// /// The map key is the node's public key: a real identifier Tailscale gave us, /// unlike a synthesised "device-3" would be. fn device_from( node: &NodeWire, node_key: Option<&str>, self_user_id: Option, is_self: bool, ) -> Device { let id = node .id .clone() .and_then(non_empty) .or_else(|| node_key.map(ToOwned::to_owned)) .unwrap_or_default(); let tags: Vec = node .tags .iter() .flatten() .map(|tag| tag.strip_prefix("tag:").unwrap_or(tag).to_owned()) .collect(); let last_seen = node.last_seen.as_deref().and_then(LastSeen::parse); Device { node_id: NodeId::new(id), hostname: node.host_name.clone().and_then(non_empty), // The MagicDNS name is reported fully qualified, with the root dot a // resolver wants and a reader does not. dns_name: node .dns_name .clone() .and_then(non_empty) .map(|name| name.trim_end_matches('.').to_owned()) .and_then(non_empty), os: node.os.clone().and_then(non_empty), addresses: node.tailscale_ips.clone().unwrap_or_default(), ownership: ownership_of(&tags, node.user_id, self_user_id, is_self), tags, reachability: match node.online { Some(true) => DeviceReachability::Online { last_seen }, Some(false) => DeviceReachability::Offline { last_seen }, None => DeviceReachability::Unknown { last_seen }, }, // A status document cannot answer this. Nothing in this function looks // at anything that could make it say otherwise. presence: LumbridgePresence::Unknown, is_self, } } /// Whose device this is, decided without reading anybody's name. /// /// Tags are checked first because a tagged node's `UserID` is the tag owner's, /// so comparing ids would report a CI runner as "yours" on the strength of who /// happened to authorise it. fn ownership_of( tags: &[String], user_id: Option, self_user_id: Option, is_self: bool, ) -> DeviceOwnership { if !tags.is_empty() { return DeviceOwnership::Tagged; } if is_self { return DeviceOwnership::You; } match (user_id, self_user_id) { (Some(theirs), Some(mine)) if theirs == mine => DeviceOwnership::You, (Some(_), Some(_)) => DeviceOwnership::SomeoneElse, _ => DeviceOwnership::Unknown, } } /// Tailscale writes an absent string as `""` rather than omitting it, and an /// empty name rendered in a row is indistinguishable from a device called /// nothing. Both become `None`. fn non_empty(value: String) -> Option { if value.is_empty() { None } else { Some(value) } } /// A synthetic status document with the shapes that actually turn up. /// /// Hostnames, addresses, node ids, and the tailnet name are invented. It /// carries: an online self with the Go zero last-seen time; an online peer with /// a real one; an offline peer; a peer with no `Online` field at all; a tagged /// peer; a peer belonging to another user; a peer with no `HostName`; and a /// field this crate has never heard of. #[cfg(test)] pub(crate) const SYNTHETIC_STATUS: &str = r#"{ "Version": "1.98.4-tsynthetic", "BackendState": "Running", "MagicDNSSuffix": "example-tailnet.ts.net", "CurrentTailnet": { "Name": "example-tailnet.ts.net", "MagicDNSEnabled": true }, "TailscaleIPs": ["100.64.0.1"], "Self": { "ID": "nSelf1CNTRL", "PublicKey": "nodekey:0000", "HostName": "workshop-linux", "DNSName": "workshop-linux.example-tailnet.ts.net.", "OS": "linux", "UserID": 11, "Online": true, "LastSeen": "0001-01-01T00:00:00Z", "TailscaleIPs": ["100.64.0.1", "fd7a:115c:a1e0::1"] }, "Peer": { "nodekey:aaaa": { "ID": "nLaptop1CNTRL", "HostName": "kitchen-laptop", "DNSName": "kitchen-laptop.example-tailnet.ts.net.", "OS": "macOS", "UserID": 11, "Online": true, "LastSeen": "2026-08-30T14:02:11Z", "TailscaleIPs": ["100.64.0.2"], "SomeFieldFromALaterRelease": { "nested": true } }, "nodekey:bbbb": { "ID": "nAttic1CNTRL", "HostName": "attic-server", "DNSName": "attic-server.example-tailnet.ts.net.", "OS": "linux", "UserID": 11, "Online": false, "LastSeen": "2026-08-28T09:15:00Z", "TailscaleIPs": ["100.64.0.3"] }, "nodekey:cccc": { "ID": "nPocket1CNTRL", "HostName": "pocket-phone", "DNSName": "pocket-phone.example-tailnet.ts.net.", "OS": "iOS", "UserID": 11, "LastSeen": "0001-01-01T00:00:00Z", "TailscaleIPs": ["100.64.0.4"] }, "nodekey:dddd": { "ID": "nRunner1CNTRL", "HostName": "build-runner", "DNSName": "build-runner.example-tailnet.ts.net.", "OS": "linux", "UserID": 11, "Tags": ["tag:ci"], "Online": true, "TailscaleIPs": ["100.64.0.5"] }, "nodekey:eeee": { "ID": "nShared1CNTRL", "HostName": "colleague-desktop", "DNSName": "colleague-desktop.example-tailnet.ts.net.", "OS": "windows", "UserID": 22, "Online": false, "LastSeen": "2026-08-01T00:00:00Z", "TailscaleIPs": ["100.64.0.6"] }, "nodekey:ffff": { "ID": "", "HostName": "", "DNSName": "", "OS": "", "Online": true, "TailscaleIPs": [] } } }"#; #[cfg(test)] mod tests { use super::{SYNTHETIC_STATUS, parse_status}; use crate::model::{ BackendState, Device, DeviceOwnership, DeviceReachability, LastSeen, LumbridgePresence, }; use crate::{DeviceError, describe}; #[test] fn a_status_document_never_says_lumbridge_is_installed_anywhere() { let roster = parse_status(SYNTHETIC_STATUS).expect("a synthetic status parses"); assert!( !roster.devices.is_empty(), "the fixture has devices in it, so the test means something" ); for device in &roster.devices { assert_eq!( device.presence, LumbridgePresence::Unknown, "{} came back with a presence Tailscale cannot have reported", device.display_name() ); } } #[test] fn an_unrelated_json_document_is_refused_rather_than_shown_as_an_empty_tailnet() { assert_eq!(parse_status("not json at all"), Err(DeviceError::NotJson)); assert_eq!(parse_status(""), Err(DeviceError::NotJson)); assert_eq!(parse_status("[1, 2, 3]"), Err(DeviceError::NotAStatus)); assert_eq!( parse_status(r#"{"unrelated": true}"#), Err(DeviceError::NotAStatus), "an empty roster and an unreadable one are different facts" ); } #[test] fn a_status_with_no_peers_is_a_tailnet_of_one_not_an_error() { let roster = parse_status(r#"{"BackendState":"Running","Peer":{}}"#) .expect("a peerless status is still a status"); assert!(roster.devices.is_empty()); assert!(roster.reachability_is_current()); } #[test] fn unknown_fields_are_ignored_because_the_schema_is_documented_as_unstable() { let roster = parse_status(SYNTHETIC_STATUS).expect("a status with a novel field parses"); let laptop = roster .devices .iter() .find(|device| device.display_name() == "kitchen-laptop") .expect("the peer carrying the unknown field survived"); assert_eq!(laptop.os.as_deref(), Some("macOS")); } #[test] fn this_machine_sorts_first_and_the_peers_sort_by_name() { let roster = parse_status(SYNTHETIC_STATUS).expect("a synthetic status parses"); let names: Vec<&str> = roster.devices.iter().map(Device::display_name).collect(); assert_eq!(names[0], "workshop-linux", "this machine leads the list"); assert!( roster.devices[0].is_self, "and is the one marked as this machine" ); let peers = &names[1..]; let mut sorted = peers.to_vec(); sorted.sort_by_key(|name| name.to_lowercase()); assert_eq!( peers, &sorted[..], "peers arrive from a hash-ordered map, so the roster must impose an order" ); } #[test] fn reachability_is_repeated_from_the_online_field_and_never_inferred() { let roster = parse_status(SYNTHETIC_STATUS).expect("a synthetic status parses"); let find = |name: &str| { roster .devices .iter() .find(|device| device.display_name() == name) .unwrap_or_else(|| panic!("{name} is in the fixture")) .reachability .clone() }; assert!(matches!( find("kitchen-laptop"), DeviceReachability::Online { .. } )); assert!(matches!( find("attic-server"), DeviceReachability::Offline { .. } )); assert!( matches!(find("pocket-phone"), DeviceReachability::Unknown { .. }), "a peer with no Online field is unreported, not offline" ); assert_eq!( find("workshop-linux").last_seen(), None, "the Go zero time is not a last-seen time" ); assert_eq!( find("attic-server").last_seen().map(LastSeen::as_rfc3339), Some("2026-08-28T09:15:00Z") ); } #[test] fn ownership_is_decided_without_reading_anyones_name() { let roster = parse_status(SYNTHETIC_STATUS).expect("a synthetic status parses"); let ownership = |name: &str| { roster .devices .iter() .find(|device| device.display_name() == name) .unwrap_or_else(|| panic!("{name} is in the fixture")) .ownership }; assert_eq!(ownership("kitchen-laptop"), DeviceOwnership::You); assert_eq!(ownership("colleague-desktop"), DeviceOwnership::SomeoneElse); assert_eq!( ownership("build-runner"), DeviceOwnership::Tagged, "a tagged node's user id is the tag owner's, so ids must not decide it" ); } #[test] fn an_empty_string_is_a_missing_field_and_the_node_key_names_the_device() { let roster = parse_status(SYNTHETIC_STATUS).expect("a synthetic status parses"); let anonymous = roster .devices .iter() .find(|device| device.node_id.as_str() == "nodekey:ffff") .expect("the peer with no id fell back to its node key"); assert_eq!(anonymous.hostname, None, "\"\" is not a hostname"); assert_eq!(anonymous.os, None); assert_eq!( anonymous.display_name(), "nodekey:ffff", "an identifier Tailscale gave us, not one we made up" ); assert!( describe(anonymous).starts_with("nodekey:ffff, "), "the row still renders rather than being dropped" ); } #[test] fn the_magic_dns_root_dot_is_trimmed_but_the_name_is_otherwise_untouched() { let roster = parse_status(SYNTHETIC_STATUS).expect("a synthetic status parses"); let laptop = roster .devices .iter() .find(|device| device.display_name() == "kitchen-laptop") .expect("the laptop is in the fixture"); assert_eq!( laptop.dns_name.as_deref(), Some("kitchen-laptop.example-tailnet.ts.net") ); } #[test] fn a_stopped_backend_says_its_roster_is_not_the_network_now() { let roster = parse_status(r#"{"BackendState":"Stopped","Peer":{}}"#) .expect("a stopped daemon still prints a status"); assert_eq!(roster.backend_state, BackendState::Stopped); assert!(!roster.reachability_is_current()); assert!(roster.backend_state.describe().contains("last knew")); } }