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,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,
|
||||
}
|
||||
@@ -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<NodeId, DeviceRegistration>,
|
||||
}
|
||||
|
||||
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<DeviceRegistration> {
|
||||
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<DeviceRegistration> {
|
||||
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<Item = &DeviceRegistration> {
|
||||
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<DeviceRegistration> 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<I: IntoIterator<Item = DeviceRegistration>>(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<DeviceAction> {
|
||||
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<NodeId> = 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<String> = ids
|
||||
.iter()
|
||||
.map(|id| DeviceRegistration::openssh(NodeId::new(*id)))
|
||||
.collect::<DeviceRegistrations>()
|
||||
.iter()
|
||||
.map(|note| note.node().as_str().to_owned())
|
||||
.collect();
|
||||
let backwards: Vec<String> = ids
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|id| DeviceRegistration::openssh(NodeId::new(*id)))
|
||||
.collect::<DeviceRegistrations>()
|
||||
.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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> 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<Self> {
|
||||
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<LastSeen> },
|
||||
/// Tailscale reported the node as offline.
|
||||
Offline { last_seen: Option<LastSeen> },
|
||||
/// 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<LastSeen> },
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// The `MagicDNS` name, with the trailing dot removed.
|
||||
pub dns_name: Option<String>,
|
||||
/// 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<String>,
|
||||
/// The node's tailnet addresses, in the order reported.
|
||||
pub addresses: Vec<String>,
|
||||
/// ACL tags, without the `tag:` prefix.
|
||||
pub tags: Vec<String>,
|
||||
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<String>,
|
||||
/// The `MagicDNS` suffix, when the status carried one.
|
||||
pub magic_dns_suffix: Option<String>,
|
||||
/// 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<Device>,
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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