diff --git a/Cargo.lock b/Cargo.lock index 2de006d..c5bb67e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3348,6 +3348,16 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "lumbridge-devices" +version = "0.0.1" +dependencies = [ + "lumbridge-core", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "lumbridge-harness" version = "0.0.1" @@ -3383,7 +3393,9 @@ name = "lumbridge-settings" version = "0.0.1" dependencies = [ "serde", + "tempfile", "toml 0.9.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -5946,6 +5958,14 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "theme-gen" +version = "0.0.1" +dependencies = [ + "lumbridge-theme", + "serde_json", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6232,6 +6252,7 @@ dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index dd1beca..2d64a66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "apps/lumbridge", "crates/lumbridge-buzz", "crates/lumbridge-core", + "crates/lumbridge-devices", "crates/lumbridge-harness", "crates/lumbridge-pty", "crates/lumbridge-runtime", diff --git a/crates/lumbridge-core/src/lib.rs b/crates/lumbridge-core/src/lib.rs index b435ec8..eea7639 100644 --- a/crates/lumbridge-core/src/lib.rs +++ b/crates/lumbridge-core/src/lib.rs @@ -17,12 +17,29 @@ pub use workspace::{ }; /// The transport Lumbridge uses to reach a user-owned remote machine. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// +/// This is the one definition. A second copy briefly existed in +/// `lumbridge-devices` while that crate had no dependency on this one, and two +/// enumerations of the same choice, persisted through the same strings, is a +/// drift waiting to happen: a registration made on the devices page and a +/// `RemoteHost` row written by `lumbridge-storage` describe the same decision +/// and must survive the same round trip. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub enum RemoteTransport { /// The system OpenSSH client, including the user's SSH config, `ProxyJump`, /// agent, and a Tailscale `MagicDNS` name or tailnet IP when supplied. + /// + /// The default, and `docs/ARCHITECTURE.md` says why: it honours setup the + /// user already has, and costs them nothing new. + #[default] OpenSsh, /// The Tailscale CLI's SSH proxy and host-key verification path. + /// + /// An explicit alternative rather than a fallback. It is right for a user + /// governed by tailnet ACLs who wants no SSH key in the loop, and wrong for + /// one whose `ProxyJump` or per-host identity lives in their SSH config, + /// because `tailscale ssh` never reads that file. Only the user knows + /// which they are, so nothing here guesses. TailscaleSsh, } @@ -35,6 +52,13 @@ impl RemoteTransport { } } + /// Reads a persisted transport back, or reports that it is not one we know. + /// + /// Returns `None` rather than falling back to [`Self::OpenSsh`] for an + /// unrecognised string: a value written by a later version means the note + /// was made by software that knew something this one does not, and quietly + /// re-pointing it at OpenSSH would change how a machine is contacted + /// without telling anybody. #[must_use] pub fn from_storage_name(value: &str) -> Option { match value { @@ -43,6 +67,15 @@ impl RemoteTransport { _ => None, } } + + /// The phrase for a picker, naming the client rather than our opinion of it. + #[must_use] + pub const fn describe(self) -> &'static str { + match self { + Self::OpenSsh => "OpenSSH, using your SSH config", + Self::TailscaleSsh => "Tailscale SSH", + } + } } /// A connection profile. It contains routing metadata, never credentials. diff --git a/crates/lumbridge-devices/Cargo.toml b/crates/lumbridge-devices/Cargo.toml new file mode 100644 index 0000000..a402c6f --- /dev/null +++ b/crates/lumbridge-devices/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "lumbridge-devices" +description = "An honest tailnet device roster: reachability from Tailscale, Lumbridge presence from the user" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +# For RemoteTransport. A registration records which client opens the remote +# session, and that choice is defined once, in core, so a devices note and a +# stored RemoteHost cannot drift into two spellings of the same thing. +lumbridge-core = { path = "../lumbridge-core" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" + +[lints] +workspace = true diff --git a/crates/lumbridge-devices/src/lib.rs b/crates/lumbridge-devices/src/lib.rs new file mode 100644 index 0000000..cbcdcf8 --- /dev/null +++ b/crates/lumbridge-devices/src/lib.rs @@ -0,0 +1,74 @@ +//! The tailnet device roster, and the one thing it must never claim. +//! +//! Decision 0003 makes a user's own remote machines equal execution targets, so +//! a devices page is a real product surface: it is where you find the machine +//! you want to work on. Tailscale is the obvious source, because Lumbridge +//! already consumes tailnet connectivity rather than managing it. +//! +//! **Tailscale does not tell us whether Lumbridge is installed on a peer.** It +//! reports nodes, addresses, operating systems, and whether the coordination +//! server currently believes each node is online. There is no field for +//! "software installed on the far side", and there could not be — a node is a +//! `WireGuard` endpoint, not a package manifest. A devices page that showed a +//! green Lumbridge badge next to a hostname would be asserting something no +//! byte of the input supports. +//! +//! So this crate keeps two axes apart and never lets one imply the other: +//! +//! - [`DeviceReachability`] is Tailscale's claim, repeated. Online, offline, or +//! — when the field is missing — unreported. Nothing is inferred. +//! - [`LumbridgePresence`] is a separate axis whose only producible value today +//! is [`LumbridgePresence::Unknown`]. The user can raise a device to +//! [`LumbridgePresence::Registered`] by telling us; nothing raises it to +//! [`LumbridgePresence::Confirmed`], because confirming it means completing a +//! handshake with a `lumbridge-remote` runtime that does not exist yet. That +//! variant is declared so there is a defined shape for the handshake to fill, +//! in the same spirit as the ACP variants of the shell's `AttentionKind`, and +//! a test asserts that no code path in this crate produces it. +//! +//! The same separation decides what "manage" can honestly mean in v1. See +//! [`DeviceAction`]: open a workspace on a device you registered, register one, +//! forget one. Installing, rebooting, or upgrading a peer all require an agent +//! on the far side, and there is no such agent. +//! +//! Nothing here shells out. [`parse_status`] takes the text of +//! `tailscale status --json` as a `&str` and the caller supplies it, which is +//! what lets the whole model be tested from fixtures with no tailnet, no +//! subprocess, and no network. + +#![forbid(unsafe_code)] + +mod manage; +mod model; +mod tailscale; + +pub use manage::{ + DeviceAction, DeviceRegistration, DeviceRegistrations, RemoteTransport, available_actions, +}; +pub use model::{ + BackendState, Device, DeviceOwnership, DeviceReachability, LastSeen, LumbridgePresence, NodeId, + RuntimeIdentity, TailnetRoster, describe, +}; +pub use tailscale::parse_status; + +use thiserror::Error; + +/// Why a roster could not be built. +/// +/// Every variant is [`Copy`] and carries only a static string, so an error can +/// never capture a hostname, a tailnet name, a login address, or any other +/// fragment of the status document on its way into a log or a crash report. +/// `lumbridge-harness`'s `HarnessError` is built the same way and for the same +/// reason. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum DeviceError { + /// The text was not JSON at all — most often `tailscale`'s own error + /// message on stderr, captured by a caller that merged the two streams. + #[error("the tailscale status output is not JSON")] + NotJson, + /// It parsed, but nothing in it looks like a status document. Refused + /// rather than rendered as a tailnet with no devices in it, because an + /// empty roster and an unreadable one are different facts. + #[error("the JSON is not a tailscale status document")] + NotAStatus, +} diff --git a/crates/lumbridge-devices/src/manage.rs b/crates/lumbridge-devices/src/manage.rs new file mode 100644 index 0000000..daa3e91 --- /dev/null +++ b/crates/lumbridge-devices/src/manage.rs @@ -0,0 +1,782 @@ +//! What a devices page is allowed to offer you, and why the list is so short. +//! +//! The roster in [`crate::model`] is two facts side by side: what Tailscale +//! reported, and what the user told us. This module is the third thing a page +//! needs — the buttons — and it is the place where an over-eager list would do +//! the most damage, because a button is a promise that pressing it does the +//! thing it is named after. +//! +//! Almost everything a device manager traditionally offers is a promise this +//! product cannot keep. Installing Lumbridge on a peer, restarting it, +//! upgrading it, reading its disk: every one of those needs code running on the +//! far side that will accept the instruction, and the far side runs +//! `lumbridge-remote`, which does not exist yet. Tailscale gives us a `WireGuard` +//! path to a node and nothing that will answer on it. So [`DeviceAction`] has +//! three variants and no others, and the ones we cannot honour are absent from +//! the type rather than present and returning an error: a variant that exists +//! will eventually be constructed by someone, rendered by a view that matches +//! on it exhaustively, and shipped as a greyed-out button that a user reads as +//! "coming soon" rather than "impossible". +//! +//! The other rule this module carries is the crate's rule, applied to actions: +//! **reachability and presence are separate axes and neither may imply the +//! other.** Concretely, [`available_actions`] does not withhold "open a +//! workspace" from a device Tailscale calls offline, and does not offer it on a +//! device Tailscale calls online. `Online` is the coordination server's belief +//! about a `WireGuard` endpoint; it is neither necessary nor sufficient for an +//! SSH connection to succeed, and gating a button on it would turn a stale flag +//! into a lock on the user's own machine. Whether Lumbridge is there is the +//! registry's question, and the registry only ever holds what the user said. +//! +//! Nothing here connects to anything. Registering a device writes a note in a +//! [`BTreeMap`]; it does not probe, resolve, or dial. That is why the note is a +//! claim and is described as one everywhere it surfaces — see +//! [`crate::LumbridgePresence::Registered`]'s wording — and it is what keeps +//! this module testable with no tailnet, no subprocess, no network, and no +//! clock. + +use std::collections::BTreeMap; + +use crate::model::{Device, LumbridgePresence, NodeId, TailnetRoster}; + +/// How Lumbridge would reach a device it has been told about. +/// +/// Re-exported from `lumbridge-core` rather than declared here. A registration +/// made on this page and a `RemoteHost` row written by `lumbridge-storage` +/// describe the same choice and are persisted through the same strings, so a +/// second enumeration of it in this crate would be two spellings of one +/// decision waiting to disagree. +pub use lumbridge_core::RemoteTransport; + +/// Something a devices page can honestly offer for one device./// Something a devices page can honestly offer for one device. +/// +/// This is the whole set for v1 and it is exhaustive by construction. There is +/// no `Install`, no `Restart`, no `Upgrade`, no `RunCommand`: each of those is +/// an instruction that something on the far side has to receive, and the only +/// thing on the far side today is `tailscaled`, which is not ours to command. +/// When `lumbridge-remote` exists and can answer, the variant that names what +/// it can do gets added here alongside the code that performs it — not before. +/// +/// Two of the three actions never touch the network at all: [`Self::Register`] +/// and [`Self::Forget`] write and erase the user's own note. Only +/// [`Self::OpenWorkspace`] leaves the machine, and even it makes no claim of +/// success — see its documentation. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum DeviceAction { + /// Start a workspace on this device over its registered transport. + /// + /// Offered only for a device the user has registered, because opening a + /// workspace needs a transport to open it over and the registration is the + /// only place that is recorded. Offering it on an unregistered device would + /// mean either inventing a transport or popping a dialog behind a button + /// labelled as though the connection were ready. + /// + /// Offering it is not a prediction that it will work. The registration is + /// the user's claim that Lumbridge is installed there, nothing has verified + /// it, and the first connection is what will — which is exactly what + /// [`crate::LumbridgePresence::Registered`] says in the row. + OpenWorkspace, + /// Record that this device runs Lumbridge, and how to reach it. + /// + /// The one way a device leaves [`crate::LumbridgePresence::Unknown`]. It is + /// offered on every remote device regardless of reachability, because + /// writing down a fact about a machine does not require the machine to be + /// awake. + Register, + /// Erase that note. + /// + /// Always available once a note exists, including for a device that has + /// left the tailnet entirely, so a user is never stuck holding a claim + /// about a machine they no longer have. + Forget, +} + +impl DeviceAction { + /// The button text. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::OpenWorkspace => "Open workspace", + Self::Register => "Register this device", + Self::Forget => "Forget this device", + } + } + + /// The sentence under the button, or in its tooltip. + /// + /// Each one is written to survive being read by somebody who is deciding + /// whether to trust the product: it says what pressing the button does and, + /// where it matters, what it does not do. + #[must_use] + pub const fn explain(self) -> &'static str { + match self { + Self::OpenWorkspace => { + "Opens a workspace over the transport you registered. Nothing has confirmed \ + Lumbridge is installed there; this connection is the check." + } + Self::Register => { + "Records that this device runs Lumbridge and how to reach it. Nothing is \ + contacted, installed, or changed on the device." + } + Self::Forget => { + "Removes what you recorded about this device. The device stays on your tailnet \ + and nothing on it is touched." + } + } + } +} + +/// The user's note that Lumbridge lives on one node, and how to reach it. +/// +/// Keyed on [`NodeId`], for the reason [`NodeId`] gives: a hostname is the +/// owner's to change and a tailnet address is the coordination server's to +/// reassign, so a note filed under either would, sooner or later, be a claim +/// about a machine the user never made a claim about. The node id is opaque and +/// stable for the life of the node. +/// +/// It holds a transport and nothing else. In particular it does not copy the +/// hostname, the `MagicDNS` name, or the addresses out of the roster: those are +/// Tailscale's to report and they are already reported: a copy taken at +/// registration time would go stale silently and would then be a second, older +/// answer to a question the roster answers correctly. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct DeviceRegistration { + node: NodeId, + transport: RemoteTransport, +} + +impl DeviceRegistration { + /// Records a claim about `node`, reached over `transport`. + #[must_use] + pub fn new(node: NodeId, transport: RemoteTransport) -> Self { + Self { node, transport } + } + + /// Records a claim using the default transport, [`RemoteTransport::OpenSsh`]. + /// + /// The convenience a "Register" button with no options behind it wants. It + /// is a real default rather than a guess about the device: OpenSSH works + /// wherever the user's own `ssh` works, which is the assumption the rest of + /// the architecture already makes. + #[must_use] + pub fn openssh(node: NodeId) -> Self { + Self::new(node, RemoteTransport::OpenSsh) + } + + /// Which node this note is about. + #[must_use] + pub const fn node(&self) -> &NodeId { + &self.node + } + + /// How Lumbridge would reach it. + #[must_use] + pub const fn transport(&self) -> RemoteTransport { + self.transport + } + + /// The same note with a different transport. + /// + /// Changing how a machine is contacted is not a new claim about it, so this + /// keeps the node id rather than making the caller rebuild the note and + /// risk filing it under a different one. + #[must_use] + pub fn with_transport(mut self, transport: RemoteTransport) -> Self { + self.transport = transport; + self + } +} + +/// Every device the user has told us about. +/// +/// A [`BTreeMap`] keyed by [`NodeId`], matching the ordered container the +/// status reader uses for the same reason: iteration has to be the same twice +/// running, or a list rendered from it reshuffles between refreshes for no +/// reason a person can see. A hash map would be faster at a size this will +/// never reach — a tailnet is tens of nodes — and would cost determinism. +/// +/// The registry is the user's file, not a cache of the tailnet. It is never +/// pruned against a roster: a device that is off the tailnet today because a +/// laptop is shut or a daemon is stopped would have its note deleted by any +/// such pruning, and the user would find their registration gone after an event +/// that had nothing to do with them. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DeviceRegistrations { + by_node: BTreeMap, +} + +impl DeviceRegistrations { + /// An empty registry: the honest starting state, in which no device is + /// claimed to run anything. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Records a registration, returning the note it replaced. + /// + /// Registering an already-registered device is a transport change, not a + /// duplicate: the map is keyed by node id, so one machine can only ever + /// hold one note. The displaced note is handed back rather than dropped so + /// a caller can undo, or tell the user what the setting used to be. + pub fn register(&mut self, registration: DeviceRegistration) -> Option { + self.by_node.insert(registration.node.clone(), registration) + } + + /// Erases the note for a node, returning it if there was one. + /// + /// Forgetting a device that was never registered is not an error. The user + /// asked for a state — no claim about this machine — and that state is what + /// they get; returning `None` reports that nothing had to change. + pub fn forget(&mut self, node: &NodeId) -> Option { + self.by_node.remove(node) + } + + /// The note for a node, if the user made one. + #[must_use] + pub fn get(&self, node: &NodeId) -> Option<&DeviceRegistration> { + self.by_node.get(node) + } + + /// Whether the user has registered this node. + #[must_use] + pub fn contains(&self, node: &NodeId) -> bool { + self.by_node.contains_key(node) + } + + /// How many devices the user has registered. + #[must_use] + pub fn len(&self) -> usize { + self.by_node.len() + } + + /// Whether the user has registered nothing yet. + #[must_use] + pub fn is_empty(&self) -> bool { + self.by_node.is_empty() + } + + /// The registrations, in node-id order — the same order every time. + pub fn iter(&self) -> impl Iterator { + self.by_node.values() + } + + /// Raises the devices in `roster` that the user has registered. + /// + /// This is the only function that writes to [`Device::presence`] outside + /// the status reader, and it can move a device exactly one step: + /// [`LumbridgePresence::Unknown`] to [`LumbridgePresence::Registered`] when + /// a note exists, and back down when it does not, so that forgetting a + /// device takes effect on a roster that is re-decorated rather than + /// re-parsed. + /// + /// It cannot produce [`LumbridgePresence::Confirmed`], and it does not + /// disturb one either. Confirmation is evidence a runtime answered; the + /// user erasing their own note is not evidence that it stopped answering, + /// and lowering it here would let a bookkeeping operation contradict a + /// measurement. Nothing can hand us such a device today — the variant + /// carries a [`crate::RuntimeIdentity`] that has no constructor — and the + /// branch exists so that when something can, it is already handled. + pub fn apply_to(&self, roster: &mut TailnetRoster) { + for device in &mut roster.devices { + match device.presence { + LumbridgePresence::Confirmed { .. } => {} + LumbridgePresence::Unknown | LumbridgePresence::Registered => { + device.presence = if self.contains(&device.node_id) { + LumbridgePresence::Registered + } else { + LumbridgePresence::Unknown + }; + } + } + } + } +} + +impl<'a> IntoIterator for &'a DeviceRegistrations { + type Item = &'a DeviceRegistration; + type IntoIter = std::collections::btree_map::Values<'a, NodeId, DeviceRegistration>; + + fn into_iter(self) -> Self::IntoIter { + self.by_node.values() + } +} + +impl FromIterator for DeviceRegistrations { + /// Rebuilds a registry from stored notes. + /// + /// Later notes win over earlier ones with the same node id, which is the + /// same rule [`DeviceRegistrations::register`] applies, so a file that + /// somehow carries two notes for one machine loads as the one machine it + /// describes rather than as an error the user cannot act on. + fn from_iter>(notes: I) -> Self { + let mut registrations = Self::new(); + for note in notes { + registrations.register(note); + } + registrations + } +} + +/// The actions a devices page should offer for one device. +/// +/// Pure, and derived from exactly two things: the roster fact (is this the +/// machine we are running on) and the registry (did the user tell us Lumbridge +/// is there). Both are inputs; nothing is measured, and no order of calls +/// changes the answer. +/// +/// What it deliberately does **not** consult is +/// [`crate::DeviceReachability`]. A device Tailscale calls offline keeps +/// [`DeviceAction::OpenWorkspace`], because `Online` is what a coordination +/// server last believed about a `WireGuard` endpoint — on the machine this crate +/// was written against, seven of sixteen peers carried no last-seen record at +/// all — and a user who knows their server is up should not be blocked by our +/// reading of a flag. A device Tailscale calls online does *not* gain the +/// action, because being reachable says nothing about what is installed. That +/// is the crate's whole thesis expressed as a button list. +/// +/// It does not consult [`Device::presence`] either, even though the registered +/// case is visible there. Presence is the rendering of the axis; the registry +/// is the axis, and it is the thing that actually holds the transport a +/// workspace would be opened over. Reading the rendered value would let a +/// roster that was never decorated with [`DeviceRegistrations::apply_to`] +/// silently withdraw the user's own devices. +/// +/// The returned order is the order a page should show: the thing you came for +/// first, then the bookkeeping. +#[must_use] +pub fn available_actions( + device: &Device, + registrations: &DeviceRegistrations, +) -> Vec { + let registered = registrations.contains(&device.node_id); + if device.is_self { + // Workspaces on this machine are local: they need no transport, no SSH + // child, and no registration, so both remote actions would be theatre. + // A note about this machine can still be erased, because a registry + // restored from an older file or another install may carry one and a + // claim the user cannot delete is worse than one they never made. + return if registered { + vec![DeviceAction::Forget] + } else { + Vec::new() + }; + } + if registered { + vec![DeviceAction::OpenWorkspace, DeviceAction::Forget] + } else { + vec![DeviceAction::Register] + } +} + +#[cfg(test)] +mod tests { + use super::{ + DeviceAction, DeviceRegistration, DeviceRegistrations, RemoteTransport, available_actions, + }; + use crate::model::{ + Device, DeviceOwnership, DeviceReachability, LastSeen, LumbridgePresence, NodeId, + TailnetRoster, + }; + use crate::tailscale::{SYNTHETIC_STATUS, parse_status}; + + fn device(id: &str) -> Device { + Device { + node_id: NodeId::new(id), + hostname: Some("attic-server".to_owned()), + dns_name: Some("attic-server.example-tailnet.ts.net".to_owned()), + os: Some("linux".to_owned()), + addresses: vec!["100.64.0.3".to_owned()], + tags: Vec::new(), + ownership: DeviceOwnership::You, + reachability: DeviceReachability::Online { last_seen: None }, + presence: LumbridgePresence::Unknown, + is_self: false, + } + } + + fn roster() -> TailnetRoster { + parse_status(SYNTHETIC_STATUS).expect("a synthetic status parses") + } + + #[test] + fn an_unregistered_device_is_not_offered_a_workspace() { + let registrations = DeviceRegistrations::new(); + assert_eq!( + available_actions(&device("nAttic1CNTRL"), ®istrations), + vec![DeviceAction::Register], + "nothing has said Lumbridge is on this machine, so there is nothing to open" + ); + } + + #[test] + fn registering_a_device_offers_the_workspace_and_the_way_back_out() { + let mut registrations = DeviceRegistrations::new(); + registrations.register(DeviceRegistration::openssh(NodeId::new("nAttic1CNTRL"))); + assert_eq!( + available_actions(&device("nAttic1CNTRL"), ®istrations), + vec![DeviceAction::OpenWorkspace, DeviceAction::Forget], + "the thing you came for leads, and the claim stays reversible" + ); + assert_eq!( + available_actions(&device("nSomeOther1CNTRL"), ®istrations), + vec![DeviceAction::Register], + "registering one machine must not offer a workspace on its neighbour" + ); + } + + #[test] + fn reachability_never_decides_which_actions_a_device_has() { + let mut registrations = DeviceRegistrations::new(); + registrations.register(DeviceRegistration::openssh(NodeId::new("nAttic1CNTRL"))); + let seen = LastSeen::parse("2026-08-28T09:15:00Z"); + let states = [ + DeviceReachability::Online { + last_seen: seen.clone(), + }, + DeviceReachability::Offline { + last_seen: seen.clone(), + }, + DeviceReachability::Unknown { last_seen: seen }, + ]; + for state in states { + let mut registered = device("nAttic1CNTRL"); + registered.reachability = state.clone(); + assert_eq!( + available_actions(®istered, ®istrations), + vec![DeviceAction::OpenWorkspace, DeviceAction::Forget], + "a coordination server's belief about a WireGuard endpoint must not lock a \ + machine the user registered: {}", + state.describe() + ); + + let mut stranger = device("nStranger1CNTRL"); + stranger.reachability = state.clone(); + assert_eq!( + available_actions(&stranger, &DeviceRegistrations::new()), + vec![DeviceAction::Register], + "and being reachable must not imply anything is installed: {}", + state.describe() + ); + } + } + + #[test] + fn nothing_in_this_module_produces_a_confirmed_presence() { + // Every operation this module has, applied to every device in the + // fixture, in the order a user could actually perform them. + let mut roster = roster(); + assert!( + !roster.devices.is_empty(), + "the fixture has devices in it, so the test means something" + ); + let nodes: Vec = roster + .devices + .iter() + .map(|device| device.node_id.clone()) + .collect(); + let mut registrations = DeviceRegistrations::new(); + for node in &nodes { + registrations.register(DeviceRegistration::openssh(node.clone())); + registrations.apply_to(&mut roster); + registrations.register(DeviceRegistration::new( + node.clone(), + RemoteTransport::TailscaleSsh, + )); + registrations.apply_to(&mut roster); + for device in &roster.devices { + assert!( + !matches!(device.presence, LumbridgePresence::Confirmed { .. }), + "{} was confirmed by a module that has never spoken to it; confirming \ + means a handshake with a lumbridge-remote runtime that does not exist", + device.display_name() + ); + } + } + for node in &nodes { + registrations.forget(node); + registrations.apply_to(&mut roster); + for device in &roster.devices { + assert!( + !matches!(device.presence, LumbridgePresence::Confirmed { .. }), + "{} reached Confirmed on the way back down", + device.display_name() + ); + } + } + assert!(registrations.is_empty()); + } + + #[test] + fn registering_every_device_confirms_none_of_them() { + let mut roster = roster(); + let registrations: DeviceRegistrations = roster + .devices + .iter() + .map(|device| DeviceRegistration::openssh(device.node_id.clone())) + .collect(); + registrations.apply_to(&mut roster); + for device in &roster.devices { + assert_eq!( + device.presence, + LumbridgePresence::Registered, + "{} was registered by the user, so it is registered and no more", + device.display_name() + ); + assert!( + !matches!(device.presence, LumbridgePresence::Confirmed { .. }), + "{} reached a state that needs a handshake with a runtime that does not exist", + device.display_name() + ); + } + } + + #[test] + fn applying_registrations_raises_only_the_devices_the_user_named() { + let mut roster = roster(); + let attic = roster + .devices + .iter() + .find(|device| device.display_name() == "attic-server") + .expect("the attic server is in the fixture") + .node_id + .clone(); + let mut registrations = DeviceRegistrations::new(); + registrations.register(DeviceRegistration::openssh(attic.clone())); + registrations.apply_to(&mut roster); + + for device in &roster.devices { + let expected = if device.node_id == attic { + LumbridgePresence::Registered + } else { + LumbridgePresence::Unknown + }; + assert_eq!( + device.presence, + expected, + "{} was decorated with a claim the user did not make", + device.display_name() + ); + } + + registrations.forget(&attic); + registrations.apply_to(&mut roster); + assert_eq!( + roster.find(&attic).expect("still on the tailnet").presence, + LumbridgePresence::Unknown, + "forgetting must take effect on a roster that is re-decorated, not only re-parsed" + ); + } + + #[test] + fn this_machine_is_not_a_device_to_register_but_a_stale_note_can_be_erased() { + let mut me = device("nSelf1CNTRL"); + me.is_self = true; + assert_eq!( + available_actions(&me, &DeviceRegistrations::new()), + Vec::new(), + "a workspace here is local: it needs no transport and no claim" + ); + + let mut registrations = DeviceRegistrations::new(); + registrations.register(DeviceRegistration::openssh(NodeId::new("nSelf1CNTRL"))); + assert_eq!( + available_actions(&me, ®istrations), + vec![DeviceAction::Forget], + "a note restored from an older file must still be deletable" + ); + } + + #[test] + fn registering_twice_changes_the_transport_rather_than_duplicating_the_device() { + let node = NodeId::new("nAttic1CNTRL"); + let mut registrations = DeviceRegistrations::new(); + assert_eq!( + registrations.register(DeviceRegistration::openssh(node.clone())), + None, + "the first note replaces nothing" + ); + let replaced = registrations + .register(DeviceRegistration::new( + node.clone(), + RemoteTransport::TailscaleSsh, + )) + .expect("the second note replaced the first"); + assert_eq!( + replaced.transport(), + RemoteTransport::OpenSsh, + "the displaced setting is handed back so a caller can undo or explain it" + ); + assert_eq!(registrations.len(), 1, "one machine, one note"); + assert_eq!( + registrations + .get(&node) + .expect("still registered") + .transport(), + RemoteTransport::TailscaleSsh + ); + } + + #[test] + fn a_note_is_keyed_on_the_node_id_and_carries_nothing_that_can_go_stale() { + let note = DeviceRegistration::openssh(NodeId::new("nAttic1CNTRL")); + assert_eq!(note.node().as_str(), "nAttic1CNTRL"); + assert_eq!( + note.transport(), + RemoteTransport::OpenSsh, + "the default transport is the one that honours the user's own ssh config" + ); + let moved = note.clone().with_transport(RemoteTransport::TailscaleSsh); + assert_eq!( + moved.node(), + note.node(), + "changing how a machine is reached must not refile the claim under another machine" + ); + + // A renamed device keeps its note, because the note never held the name. + let mut registrations = DeviceRegistrations::new(); + registrations.register(note); + let mut renamed = device("nAttic1CNTRL"); + renamed.hostname = Some("loft-server".to_owned()); + assert_eq!( + available_actions(&renamed, ®istrations), + vec![DeviceAction::OpenWorkspace, DeviceAction::Forget], + "a hostname is the owner's to change; the claim was about the node" + ); + } + + #[test] + fn forgetting_returns_the_note_and_forgetting_twice_is_not_an_error() { + let node = NodeId::new("nAttic1CNTRL"); + let mut registrations = DeviceRegistrations::new(); + registrations.register(DeviceRegistration::new( + node.clone(), + RemoteTransport::TailscaleSsh, + )); + let forgotten = registrations.forget(&node).expect("the note came back"); + assert_eq!(forgotten.transport(), RemoteTransport::TailscaleSsh); + assert!(registrations.is_empty()); + assert_eq!( + registrations.forget(&node), + None, + "the user asked for a state, and that state is what they already have" + ); + assert!(!registrations.contains(&node)); + assert_eq!( + available_actions(&device("nAttic1CNTRL"), ®istrations), + vec![DeviceAction::Register] + ); + } + + #[test] + fn a_registration_survives_the_device_leaving_the_roster() { + let absent = NodeId::new("nRetired1CNTRL"); + let mut registrations = DeviceRegistrations::new(); + registrations.register(DeviceRegistration::openssh(absent.clone())); + let mut roster = roster(); + registrations.apply_to(&mut roster); + assert!( + roster.find(&absent).is_none(), + "the fixture does not contain this machine, which is the point" + ); + assert!( + registrations.contains(&absent), + "a shut laptop or a stopped daemon must not delete what the user wrote down" + ); + } + + #[test] + fn the_registry_iterates_in_one_order_however_it_was_filled() { + let ids = ["nCharlie", "nAlpha", "nBravo"]; + let forwards: Vec = ids + .iter() + .map(|id| DeviceRegistration::openssh(NodeId::new(*id))) + .collect::() + .iter() + .map(|note| note.node().as_str().to_owned()) + .collect(); + let backwards: Vec = ids + .iter() + .rev() + .map(|id| DeviceRegistration::openssh(NodeId::new(*id))) + .collect::() + .into_iter() + .map(|note| note.node().as_str().to_owned()) + .collect(); + assert_eq!( + forwards, + vec!["nAlpha", "nBravo", "nCharlie"], + "a list rendered from the registry must not reshuffle between refreshes" + ); + assert_eq!(forwards, backwards, "insertion order must not survive"); + } + + #[test] + fn transport_storage_names_round_trip_and_an_unknown_one_is_refused() { + for transport in [RemoteTransport::OpenSsh, RemoteTransport::TailscaleSsh] { + assert_eq!( + RemoteTransport::from_storage_name(transport.storage_name()), + Some(transport), + "a note must read back as the transport it was written as" + ); + } + assert_eq!( + RemoteTransport::storage_name(RemoteTransport::OpenSsh), + "openssh", + "the string is shared with lumbridge-core's RemoteHost and is not ours to rename" + ); + assert_eq!( + RemoteTransport::storage_name(RemoteTransport::TailscaleSsh), + "tailscale-ssh" + ); + assert_eq!( + RemoteTransport::from_storage_name("mosh"), + None, + "a transport a later version knew about must not be silently rewritten to OpenSSH" + ); + assert_eq!( + RemoteTransport::default(), + RemoteTransport::OpenSsh, + "the default honours the user's existing ssh config, agent, and ProxyJump" + ); + } + + #[test] + fn the_action_set_promises_only_what_v1_can_actually_do() { + let every = [ + DeviceAction::OpenWorkspace, + DeviceAction::Register, + DeviceAction::Forget, + ]; + for action in every { + let label = action.label(); + let explanation = action.explain(); + assert!(!label.is_empty() && !explanation.is_empty()); + for forbidden in ["install", "reboot", "restart", "upgrade", "update"] { + assert!( + !label.to_lowercase().contains(forbidden), + "{label} names something that needs an agent on the far side" + ); + } + } + assert!( + DeviceAction::Register + .explain() + .contains("Nothing is contacted"), + "registering must never read as installation: {}", + DeviceAction::Register.explain() + ); + assert!( + DeviceAction::OpenWorkspace + .explain() + .contains("this connection is the check"), + "the button must not read as verification it has not done: {}", + DeviceAction::OpenWorkspace.explain() + ); + assert!( + DeviceAction::Forget.explain().contains("nothing on it is"), + "forgetting is bookkeeping and must say so: {}", + DeviceAction::Forget.explain() + ); + } +} diff --git a/crates/lumbridge-devices/src/model.rs b/crates/lumbridge-devices/src/model.rs new file mode 100644 index 0000000..80f820a --- /dev/null +++ b/crates/lumbridge-devices/src/model.rs @@ -0,0 +1,576 @@ +//! What a device is, as data. +//! +//! No renderer types and no IO, so every rule here — what counts as a last-seen +//! time, which of two facts a row leads with, what the unknown case says — is an +//! ordinary function with an ordinary test. + +use std::fmt::Write as _; + +/// A stable identifier for one node on the tailnet. +/// +/// Tailscale's `ID` is an opaque string rather than a number, and it is what the +/// registry in [`crate::DeviceRegistrations`] keys on: a hostname can be changed +/// by its owner and a tailnet address can be reassigned, so keying a "Lumbridge +/// lives here" note on either would silently move the note to another machine. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct NodeId(String); + +impl NodeId { + #[must_use] + pub fn new(raw: impl Into) -> Self { + Self(raw.into()) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// The Go zero time, which Tailscale prints for a node it has no last-seen +/// record for. +/// +/// This is not a hypothetical: on the machine this crate was written against, +/// seven of sixteen peers carried it, and every one of those seven was *online* +/// at the time. Deserialising it as a date would put "last seen 1 January 0001" +/// in a row, which is worse than saying nothing, so [`LastSeen::parse`] rejects +/// it and the field becomes absent. +const ZERO_TIME: &str = "0001-01-01T00:00:00Z"; + +/// When Tailscale last saw a node, exactly as Tailscale wrote it. +/// +/// Held as the reported RFC 3339 text rather than converted to an instant. Any +/// conversion worth showing a person — "11 minutes ago", "yesterday at 14:02" — +/// needs a clock and a time zone, and this crate has neither; inventing one +/// here would mean two places in the product could disagree about what the same +/// timestamp means. The shell formats it. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct LastSeen(String); + +impl LastSeen { + /// Accepts a reported timestamp, or reports that there is not one. + /// + /// Rejects the empty string, the Go zero time, and anything that does not + /// begin with a four-digit year, so that a field Tailscale repurposes in a + /// later release degrades to "unknown" instead of being pasted into a row. + #[must_use] + pub fn parse(raw: &str) -> Option { + if raw == ZERO_TIME || !looks_like_a_timestamp(raw) { + return None; + } + Some(Self(raw.to_owned())) + } + + /// The timestamp as Tailscale reported it. + #[must_use] + pub fn as_rfc3339(&self) -> &str { + &self.0 + } +} + +/// A cheap shape check: `YYYY-` followed by enough characters to be a date and +/// a time. Deliberately not a full parser — the goal is to refuse obvious +/// garbage, not to validate a calendar. +fn looks_like_a_timestamp(raw: &str) -> bool { + let bytes = raw.as_bytes(); + bytes.len() >= 20 && bytes[..4].iter().all(u8::is_ascii_digit) && bytes[4] == b'-' +} + +/// Whether Tailscale currently believes a device is reachable. +/// +/// This is a repetition of Tailscale's `Online` field and nothing else. In +/// particular it says nothing about whether a Lumbridge runtime, an SSH server, +/// or any other service is listening — a node can be online and refuse every +/// connection you make to it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DeviceReachability { + /// Tailscale reported the node as online. + Online { last_seen: Option }, + /// Tailscale reported the node as offline. + Offline { last_seen: Option }, + /// Tailscale did not report an `Online` field for this node. + /// + /// The status schema is explicitly unstable, so a missing field is a case + /// that has to exist. Treating it as offline would be the fabrication this + /// crate is about: a device shown as unreachable when nothing said it was. + Unknown { last_seen: Option }, +} + +impl DeviceReachability { + /// The last-seen time, whatever the state. + #[must_use] + pub const fn last_seen(&self) -> Option<&LastSeen> { + match self { + Self::Online { last_seen } + | Self::Offline { last_seen } + | Self::Unknown { last_seen } => last_seen.as_ref(), + } + } + + /// The row phrase. + /// + /// An online device does not show its last-seen time: it is being seen now, + /// and a date beside "Online" reads as a contradiction. + #[must_use] + pub fn describe(&self) -> String { + match self { + Self::Online { .. } => "Online".to_owned(), + Self::Offline { + last_seen: Some(seen), + } => { + format!("Offline, last seen {}", seen.as_rfc3339()) + } + Self::Offline { last_seen: None } => "Offline, with no last-seen time".to_owned(), + Self::Unknown { + last_seen: Some(seen), + } => { + format!("Reachability unreported, last seen {}", seen.as_rfc3339()) + } + Self::Unknown { last_seen: None } => "Reachability unreported".to_owned(), + } + } +} + +/// Whose device this is, without reading who they are. +/// +/// Tailscale's status carries a `User` table with display names and login +/// addresses. None of that is read. The question a devices page actually asks +/// is "is this one of mine?", and comparing the node's numeric user id against +/// this machine's own answers it without a single e-mail address entering +/// application state. Decision 0016 declined to read the account e-mail for the +/// same reason: the identity is not needed to render the fact. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum DeviceOwnership { + /// Signed in as the same tailnet user as this machine. + You, + /// A different tailnet user: a colleague's machine, or one shared in. + SomeoneElse, + /// Owned by an ACL tag rather than by a person, so "whose" has no answer. + Tagged, + /// No user id was reported, so there is nothing to compare. + #[default] + Unknown, +} + +impl DeviceOwnership { + /// The row fragment, or nothing when it would add no information. + /// + /// "Yours" is the common case and saying it on every row is noise; an + /// unknown owner is a gap the row cannot act on. Both render as nothing. + #[must_use] + pub const fn describe(self) -> Option<&'static str> { + match self { + Self::You | Self::Unknown => None, + Self::SomeoneElse => Some("another user's device"), + Self::Tagged => Some("tagged device"), + } + } +} + +/// Evidence that a Lumbridge runtime answered on the far side. +/// +/// There is no constructor, here or anywhere: producing one means completing a +/// handshake with `lumbridge-remote`, which does not exist. The type is written +/// now so the handshake has a defined place to put its answer, and so +/// [`LumbridgePresence::Confirmed`] cannot be spelled by a caller who merely +/// wants a green badge. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuntimeIdentity { + /// The runtime version string the far side reported about itself. + version: String, + /// The framed-protocol version the two sides negotiated. + protocol: u32, +} + +impl RuntimeIdentity { + /// What the remote runtime called itself. + #[must_use] + pub fn version(&self) -> &str { + &self.version + } + + /// The negotiated protocol version. + #[must_use] + pub const fn protocol(&self) -> u32 { + self.protocol + } +} + +/// Whether Lumbridge is on a device — an axis Tailscale cannot answer. +/// +/// The default is [`Self::Unknown`] and it is the only value this crate +/// produces from a status document, because a status document does not contain +/// the answer. [`Self::Registered`] is the user's own claim, recorded because +/// they told us. [`Self::Confirmed`] is reserved for a runtime that answered. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub enum LumbridgePresence { + /// Nobody has said. The honest state for every device on first sight. + #[default] + Unknown, + /// The user recorded that this device runs Lumbridge. Their claim, not a + /// measurement: the first connection is what will actually test it. + Registered, + /// A Lumbridge runtime on that device completed a handshake. + /// + /// Unreachable today and asserted so by a test. `lumbridge-remote` does not + /// exist, so nothing can produce the [`RuntimeIdentity`] this carries. + Confirmed { runtime: RuntimeIdentity }, +} + +impl LumbridgePresence { + /// The row phrase: short, because it shares a line with a hostname. + #[must_use] + pub const fn describe(&self) -> &'static str { + match self { + Self::Unknown => "Lumbridge unknown", + Self::Registered => "Lumbridge registered", + Self::Confirmed { .. } => "Lumbridge confirmed", + } + } + + /// The full sentence, for a detail line or a tooltip. + /// + /// The unknown case is the one that matters. It does not apologise and it + /// does not hedge: it names the reason there is no answer, which is a fact + /// about Tailscale rather than a failure of ours, and then says what the + /// user can do about it. + #[must_use] + pub const fn explain(&self) -> &'static str { + match self { + Self::Unknown => { + "Tailscale reports which devices are on the tailnet, not what is installed on \ + them. Register this device to open workspaces on it." + } + Self::Registered => { + "You recorded that this device runs Lumbridge. Nothing has checked that yet; \ + the first connection will." + } + Self::Confirmed { .. } => { + "A Lumbridge runtime on this device answered a handshake and named its version." + } + } + } +} + +/// One node on the tailnet. +/// +/// Every field is either something Tailscale reported or something the user +/// told us. There is no field derived by guessing, and the two sources are +/// never mixed into one value. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Device { + pub node_id: NodeId, + /// The short name Tailscale reports. Absent when it reported none. + pub hostname: Option, + /// The `MagicDNS` name, with the trailing dot removed. + pub dns_name: Option, + /// The operating system as Tailscale spells it: `linux`, `macOS`, `iOS`. + /// Kept verbatim rather than mapped onto our own enum, because a value we + /// do not recognise is more useful shown than swallowed. + pub os: Option, + /// The node's tailnet addresses, in the order reported. + pub addresses: Vec, + /// ACL tags, without the `tag:` prefix. + pub tags: Vec, + pub ownership: DeviceOwnership, + pub reachability: DeviceReachability, + pub presence: LumbridgePresence, + /// Whether this is the machine Lumbridge is running on. + pub is_self: bool, +} + +impl Device { + /// The name to show, falling back through real identifiers only. + /// + /// Hostname, then `MagicDNS` name, then the node id. Every one of those came + /// out of the status document; there is no "Unnamed device" here, because a + /// name we made up would be indistinguishable from one the user chose. + #[must_use] + pub fn display_name(&self) -> &str { + self.hostname + .as_deref() + .or(self.dns_name.as_deref()) + .unwrap_or_else(|| self.node_id.as_str()) + } +} + +/// The row text for one device. +/// +/// Written here rather than in a view so that it is testable, and so the words +/// a sighted user reads and the words an assistive technology announces come +/// from one place and cannot drift apart. The order is what a person scans for: +/// which machine, what kind, can I reach it, can I work on it. +#[must_use] +pub fn describe(device: &Device) -> String { + let mut text = device.display_name().to_owned(); + if device.is_self { + text.push_str(" (this machine)"); + } + if let Some(os) = &device.os { + let _ = write!(text, ", {os}"); + } + if let Some(owner) = device.ownership.describe() { + let _ = write!(text, ", {owner}"); + } + let _ = write!( + text, + ", {}, {}", + device.reachability.describe(), + device.presence.describe() + ); + text +} + +/// What `tailscaled` is doing, which decides whether the roster is current. +/// +/// The strings are Tailscale's own state names. An unrecognised one is kept as +/// a variant with no payload rather than as the reported text: a state we do +/// not understand is not a state we should be printing into the interface. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum BackendState { + Running, + Starting, + Stopped, + NeedsLogin, + NeedsMachineAuth, + NoState, + #[default] + Unrecognised, +} + +impl BackendState { + pub(crate) fn parse(raw: &str) -> Self { + match raw { + "Running" => Self::Running, + "Starting" => Self::Starting, + "Stopped" => Self::Stopped, + "NeedsLogin" => Self::NeedsLogin, + "NeedsMachineAuth" => Self::NeedsMachineAuth, + "NoState" => Self::NoState, + _ => Self::Unrecognised, + } + } + + /// Whether reachability in this roster reflects the network right now. + /// + /// A stopped or logged-out `tailscaled` still prints the last network map + /// it held, so the peers and their `Online` flags are yesterday's news + /// rather than today's. The page has to say so; the alternative is a wall + /// of green dots for a tailnet nobody is connected to. + #[must_use] + pub const fn reachability_is_current(self) -> bool { + matches!(self, Self::Running) + } + + /// The banner sentence for the page. + #[must_use] + pub const fn describe(self) -> &'static str { + match self { + Self::Running => "Tailscale is running", + Self::Starting => "Tailscale is starting, so reachability is not settled yet", + Self::Stopped => "Tailscale is stopped, so reachability is what it last knew", + Self::NeedsLogin => "Tailscale is logged out, so reachability is what it last knew", + Self::NeedsMachineAuth => "This device is waiting to be approved onto the tailnet", + Self::NoState => "Tailscale has no tailnet state yet", + Self::Unrecognised => "Tailscale reported a state this version does not recognise", + } + } +} + +/// Every device Tailscale knows about, and the state it knew them in. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TailnetRoster { + pub backend_state: BackendState, + /// The tailnet's name, when the status carried one. + pub tailnet_name: Option, + /// The `MagicDNS` suffix, when the status carried one. + pub magic_dns_suffix: Option, + /// This machine first, then the peers by display name. The peers arrive in + /// a JSON object keyed by node key, whose iteration order is a hash order + /// nobody chose, so an order is imposed here — otherwise rows would move + /// between refreshes for no reason a user could see. + pub devices: Vec, +} + +impl TailnetRoster { + /// Whether the reachability in this roster reflects the network right now. + #[must_use] + pub const fn reachability_is_current(&self) -> bool { + self.backend_state.reachability_is_current() + } + + /// The device with this id, if it is still on the tailnet. + #[must_use] + pub fn find(&self, node: &NodeId) -> Option<&Device> { + self.devices.iter().find(|device| device.node_id == *node) + } +} + +#[cfg(test)] +mod tests { + use super::{ + BackendState, Device, DeviceOwnership, DeviceReachability, LastSeen, LumbridgePresence, + NodeId, describe, + }; + + fn device() -> Device { + Device { + node_id: NodeId::new("nWorkshop1CNTRL"), + hostname: Some("workshop-linux".to_owned()), + dns_name: Some("workshop-linux.example-tailnet.ts.net".to_owned()), + os: Some("linux".to_owned()), + addresses: vec!["100.64.0.1".to_owned()], + tags: Vec::new(), + ownership: DeviceOwnership::You, + reachability: DeviceReachability::Online { last_seen: None }, + presence: LumbridgePresence::Unknown, + is_self: false, + } + } + + #[test] + fn the_go_zero_time_is_no_last_seen_time_at_all() { + assert_eq!( + LastSeen::parse("0001-01-01T00:00:00Z"), + None, + "the zero time means Tailscale has no record, not the year 1" + ); + assert_eq!(LastSeen::parse(""), None, "an empty field is not a time"); + assert_eq!( + LastSeen::parse("recently"), + None, + "text that is not a timestamp must not reach a row verbatim" + ); + let parsed = LastSeen::parse("2026-08-30T14:02:11Z").expect("a real timestamp"); + assert_eq!( + parsed.as_rfc3339(), + "2026-08-30T14:02:11Z", + "the reported text is kept exactly, because this crate has no clock to convert it with" + ); + } + + #[test] + fn an_online_device_does_not_advertise_a_last_seen_time() { + let seen = LastSeen::parse("2026-08-30T14:02:11Z"); + let online = DeviceReachability::Online { + last_seen: seen.clone(), + }; + assert_eq!( + online.describe(), + "Online", + "a date beside Online reads as a contradiction" + ); + assert!( + online.last_seen().is_some(), + "the fact is still available to a caller that wants it" + ); + let offline = DeviceReachability::Offline { last_seen: seen }; + assert_eq!( + offline.describe(), + "Offline, last seen 2026-08-30T14:02:11Z" + ); + } + + #[test] + fn a_missing_online_field_is_unreported_rather_than_offline() { + let unknown = DeviceReachability::Unknown { last_seen: None }; + assert_eq!( + unknown.describe(), + "Reachability unreported", + "nothing said this device was down, so the row must not say it either" + ); + } + + #[test] + fn the_unknown_presence_names_the_reason_instead_of_apologising() { + let unknown = LumbridgePresence::Unknown; + assert_eq!(unknown.describe(), "Lumbridge unknown"); + let explanation = unknown.explain(); + assert!( + explanation.contains("not what is installed"), + "the sentence must say why Tailscale cannot answer: {explanation}" + ); + assert!( + explanation.contains("Register this device"), + "and what the user can do next: {explanation}" + ); + assert!( + !explanation.contains("sorry") && !explanation.contains("unfortunately"), + "an unknown is a fact about the world, not a failure to apologise for" + ); + } + + #[test] + fn a_registered_device_is_described_as_a_claim_not_a_measurement() { + let registered = LumbridgePresence::Registered; + assert!( + registered + .explain() + .contains("Nothing has checked that yet"), + "the user's own claim must never read as verification: {}", + registered.explain() + ); + } + + #[test] + fn a_row_names_the_machine_then_reachability_then_presence() { + assert_eq!( + describe(&device()), + "workshop-linux, linux, Online, Lumbridge unknown" + ); + } + + #[test] + fn a_row_marks_this_machine_and_a_device_that_is_not_yours() { + let mut mine = device(); + mine.is_self = true; + assert!(describe(&mine).starts_with("workshop-linux (this machine)")); + + let mut theirs = device(); + theirs.ownership = DeviceOwnership::SomeoneElse; + assert!( + describe(&theirs).contains("another user's device"), + "a shared-in machine is worth flagging; one of your own is not" + ); + let mut tagged = device(); + tagged.ownership = DeviceOwnership::Tagged; + assert!(describe(&tagged).contains("tagged device")); + assert_eq!( + DeviceOwnership::Unknown.describe(), + None, + "an unreported owner adds nothing to a row" + ); + } + + #[test] + fn a_device_with_no_name_falls_back_to_identifiers_it_really_has() { + let mut anonymous = device(); + anonymous.hostname = None; + assert_eq!( + anonymous.display_name(), + "workshop-linux.example-tailnet.ts.net" + ); + anonymous.dns_name = None; + assert_eq!( + anonymous.display_name(), + "nWorkshop1CNTRL", + "the node id is real; an invented placeholder would not be" + ); + } + + #[test] + fn only_a_running_backend_makes_reachability_current() { + assert!(BackendState::parse("Running").reachability_is_current()); + for state in ["Stopped", "NeedsLogin", "Starting", "NoState", "Wat"] { + assert!( + !BackendState::parse(state).reachability_is_current(), + "{state} still prints the last network map, which is not the network now" + ); + } + assert_eq!( + BackendState::parse("SomethingNew"), + BackendState::Unrecognised, + "an unknown state must not be pasted into the interface verbatim" + ); + } +} diff --git a/crates/lumbridge-devices/src/tailscale.rs b/crates/lumbridge-devices/src/tailscale.rs new file mode 100644 index 0000000..c7f3653 --- /dev/null +++ b/crates/lumbridge-devices/src/tailscale.rs @@ -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, + #[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")); + } +}