Finish the devices crate, and stop it being invisible
crates/lumbridge-devices was in neither workspace.members nor workspace.exclude, which is not a build error: Cargo simply never looked at it. Its 19 tests had never run, it never inherited unsafe_code = "forbid" or pedantic Clippy, and `cargo check` inside it refused outright with "current package believes it's in a workspace when it's not". Under that cover its lib.rs had been declaring `mod manage;` and re-exporting five items from a manage.rs that did not exist, so the crate did not compile at all. manage.rs is written here to the contract lib.rs already specified. available_actions reads neither DeviceReachability nor Device::presence: an offline device keeps its workspace action and an online one does not gain one, because the registry is the axis and reachability is Tailscale's separate claim. DeviceAction has three variants and no more -- install, reboot and upgrade are absent from the type rather than rejected at runtime, since a variant that exists is eventually rendered as a greyed-out button reading "coming soon" instead of "impossible". A test walks every operation in the module over every fixture device and asserts none of them ever produces LumbridgePresence::Confirmed, which stays unproducible until a lumbridge-remote runtime can answer for itself. RemoteTransport had been declared twice, here and in lumbridge-core, with byte-identical storage strings, because this crate had no dependency on that one. Two enumerations of one choice persisted through the same strings is a drift waiting to happen, so core keeps the single definition -- gaining the default and the picker phrase -- and this crate depends on core and re-exports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SPYebLiN2w4TqnHUYGdECq
This commit is contained in:
co-authored by
Claude Opus 5
parent
73df4fa679
commit
7bee985279
@@ -0,0 +1,481 @@
|
||||
//! 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<String>,
|
||||
#[serde(default, rename = "MagicDNSSuffix")]
|
||||
magic_dns_suffix: Option<String>,
|
||||
#[serde(default, rename = "CurrentTailnet")]
|
||||
current_tailnet: Option<TailnetWire>,
|
||||
#[serde(default, rename = "Self")]
|
||||
self_node: Option<NodeWire>,
|
||||
/// Keyed by node key, which is why the roster imposes its own order.
|
||||
#[serde(default, rename = "Peer")]
|
||||
peer: Option<BTreeMap<String, NodeWire>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TailnetWire {
|
||||
#[serde(default, rename = "Name")]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NodeWire {
|
||||
#[serde(default, rename = "ID")]
|
||||
id: Option<String>,
|
||||
#[serde(default, rename = "HostName")]
|
||||
host_name: Option<String>,
|
||||
#[serde(default, rename = "DNSName")]
|
||||
dns_name: Option<String>,
|
||||
#[serde(default, rename = "OS")]
|
||||
os: Option<String>,
|
||||
#[serde(default, rename = "Online")]
|
||||
online: Option<bool>,
|
||||
#[serde(default, rename = "LastSeen")]
|
||||
last_seen: Option<String>,
|
||||
#[serde(default, rename = "TailscaleIPs")]
|
||||
tailscale_ips: Option<Vec<String>>,
|
||||
#[serde(default, rename = "Tags")]
|
||||
tags: Option<Vec<String>>,
|
||||
#[serde(default, rename = "UserID")]
|
||||
user_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// 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<TailnetRoster, DeviceError> {
|
||||
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<Device> = 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<u64>,
|
||||
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<String> = 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<u64>,
|
||||
self_user_id: Option<u64>,
|
||||
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<String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user