//! `alloy mesh` — the mesh network view. //! //! See docs/CONTINUITY.md: a mesh VPN and a file sync are what make an Alloy //! machine feel like the same machine as the last one, so the console fronts //! both. This is the mesh half. //! //! # Why the surface is not called Tailscale //! //! The verb, the types, and every string on screen are generic; only the //! [`Tailscale`] backend names a product. Two reasons. //! //! A user who has never heard of Tailscale should still find the screen that //! lists the machines they can reach. "mesh" describes what the thing is; //! "tail" describes who makes it. The backend name stays visible in the title //! so the abstraction never hides which tool is actually running, and the log //! pane still teaches the real `tailscale` commands. //! //! And Headscale is a self-hosted control server for the *same client*: you //! point this same `tailscale` binary at it with `--login-server`. So //! supporting it needs no second backend, only a surface that does not claim //! to be a product page for one vendor, plus [`ControlPlane`] so a self-hosted //! tailnet says so. A genuinely different mesh (Netbird, Nebula, ZeroTier) //! would slot in as another [`Backend`] against this same vocabulary. //! //! One invocation covers the whole screen. `tailscale status --json` is a //! documented contract carrying the local node, every peer, the backend state, //! and any health warnings, so unlike `alloy audio` this view costs a single //! logged line per refresh. //! //! "Exit node" stays as-is throughout, and is not abstracted along with the //! brand. It is the standard term across Tailscale and Headscale alike, it is //! what a user would search for, and it is the word the logged command uses. //! Inventing a friendlier synonym would have the console teach a term nothing //! else in the ecosystem uses. use std::collections::HashMap; use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text}; use anyhow::{Context, Result}; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::Rect; use ratatui::text::{Line, Span}; use serde::Deserialize; use crate::cli::{CommandLog, Invocation}; use crate::shell::{Flow, View, block_title}; /// Ticks between background refreshes. Peers come and go on the scale of a /// laptop lid closing, not a keypress, so polling every second would spawn a /// process per second to learn nothing. const POLL_TICKS: u64 = 5; /// Go's zero time, which `tailscale status --json` emits for `LastSeen` on any /// peer that is currently online. Rendered literally it reads "last seen in /// year 1". const GO_ZERO_TIME_PREFIX: &str = "0001-01-01"; #[derive(Debug, Clone)] pub struct Peer { pub hostname: String, pub os: String, /// First tailnet address. Peers can hold both a v4 and a v6; the v4 is /// what people recognize and type. pub ip: Option, pub online: bool, /// This machine. pub is_self: bool, /// Currently carrying this machine's traffic as its exit node. pub is_exit_node: bool, /// Advertises itself as available to be an exit node. pub offers_exit_node: bool, /// Date last seen, absent while online. pub last_seen: Option, } impl Peer { fn severity(&self) -> Severity { if self.is_exit_node { Severity::Info } else if self.online { Severity::Healthy } else { Severity::Warn } } fn state_label(&self) -> String { let mut parts = Vec::new(); if self.is_self { parts.push("this machine".to_string()); } if self.is_exit_node { parts.push("exit node".to_string()); } else if self.offers_exit_node { parts.push("offers exit".to_string()); } if !self.online && let Some(seen) = &self.last_seen { parts.push(format!("seen {seen}")); } parts.join(", ") } } /// Which control server the mesh is coordinated by. /// /// The one place the Tailscale/Headscale distinction is user-visible. A /// self-hosted tailnet looks identical in every other respect, and "which /// control plane am I on" is exactly the question someone running Headscale /// wants answered without dropping to a shell. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ControlPlane { /// The vendor's own control plane. Hosted, /// A self-hosted control server, named by host. SelfHosted(String), /// Not determined. The lookup is best-effort (see /// [`Tailscale::control_plane`]), and an unknown control plane is not /// worth a warning — the mesh works either way. Unknown, } impl ControlPlane { /// Suffix for the view title, empty when there is nothing worth saying. fn label(&self) -> String { match self { ControlPlane::SelfHosted(host) => format!(" via {host}"), ControlPlane::Hosted | ControlPlane::Unknown => String::new(), } } } #[derive(Debug, Clone)] pub struct MeshStatus { /// `Running`, `Stopped`, `NeedsLogin`, and friends. Reported verbatim /// rather than mapped to an enum: it is a Tailscale-owned vocabulary that /// gains members, and showing an unfamiliar one is better than collapsing /// it to "unknown". pub backend_state: String, pub health: Vec, pub peers: Vec, } impl MeshStatus { fn is_running(&self) -> bool { self.backend_state == "Running" } } pub trait Backend { fn name(&self) -> &'static str; fn status(&self, log: &mut CommandLog) -> Result; /// Which control server coordinates this mesh. /// /// Read once at startup rather than per refresh: changing it requires /// re-authenticating, so it cannot change under a running view. fn control_plane(&self) -> ControlPlane { ControlPlane::Unknown } /// Route traffic through `peer`. fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()>; /// Stop routing through an exit node. fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()>; } /// Pick a backend: `tailscale` when it answers, the mock otherwise. /// /// Tailscale is the only real implementation today, and covers Headscale too /// since Headscale drives this same client. A different mesh would be another /// arm here. pub fn detect() -> Box { if Invocation::new("tailscale").arg("version").probe() { Box::new(Tailscale) } else { Box::new(Mock) } } pub struct Tailscale; impl Backend for Tailscale { fn name(&self) -> &'static str { "tailscale" } fn status(&self, log: &mut CommandLog) -> Result { let raw = Invocation::new("tailscale") .args(["status", "--json"]) .run(log)?; parse_status(&raw) } /// Read the control server from `tailscale debug prefs`. /// /// `debug` is explicitly not a stable interface, which is why this is /// best-effort and every failure path lands on [`ControlPlane::Unknown`]: /// the command missing, the output not being JSON, the key being renamed. /// The cost of being wrong is a missing title suffix, so a fragile source /// is acceptable here in a way it would not be for the peer list. It is /// also unlogged and runs once, so a `debug` invocation never appears in a /// pane that teaches commands users should run themselves. /// /// There is no stable equivalent. `status --json` carries the tailnet name /// and MagicDNS suffix but not the control URL, and inferring "self-hosted" /// from a non-`.ts.net` suffix would be a guess about a configurable value. fn control_plane(&self) -> ControlPlane { let Ok(raw) = Invocation::new("tailscale") .args(["debug", "prefs"]) .capture_quiet() else { return ControlPlane::Unknown; }; let Ok(prefs) = serde_json::from_str::(&raw) else { return ControlPlane::Unknown; }; classify_control_url(prefs.control_url.as_deref().unwrap_or_default()) } fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()> { // Addressed by IP rather than hostname: hostnames collide (three // devices on this tailnet answer to "localhost") and MagicDNS may be // off, while the tailnet IP is unique and always resolvable. let ip = peer .ip .as_deref() .context("peer has no mesh address to route through")?; Invocation::new("tailscale") .arg("set") .arg(format!("--exit-node={ip}")) .run(log) .map(drop) } fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()> { Invocation::new("tailscale") .arg("set") .arg("--exit-node=") .run(log) .map(drop) } } /// Fixed sample state, for machines without Tailscale. pub struct Mock; impl Backend for Mock { fn name(&self) -> &'static str { "mock" } fn status(&self, log: &mut CommandLog) -> Result { log.record("# no mesh client found; showing mock peers", Severity::Warn); Ok(MeshStatus { backend_state: "Running".into(), health: Vec::new(), peers: vec![ Peer { hostname: "fw13".into(), os: "linux".into(), ip: Some("100.64.0.1".into()), online: true, is_self: true, is_exit_node: false, offers_exit_node: false, last_seen: None, }, Peer { hostname: "astra".into(), os: "linux".into(), ip: Some("100.64.0.2".into()), online: true, is_self: false, is_exit_node: false, offers_exit_node: true, last_seen: None, }, Peer { hostname: "phone".into(), os: "iOS".into(), ip: Some("100.64.0.3".into()), online: false, is_self: false, is_exit_node: false, offers_exit_node: false, last_seen: Some("2026-05-21".into()), }, ], }) } // The mock is a display fixture, not a simulator. fn set_exit_node(&self, _peer: &Peer, log: &mut CommandLog) -> Result<()> { log.record("# mock backend: exit node unchanged", Severity::Warn); Ok(()) } fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()> { log.record("# mock backend: exit node unchanged", Severity::Warn); Ok(()) } } // ---- tailscale status --json ---- #[derive(Deserialize)] struct TsStatus { #[serde(default)] #[serde(rename = "BackendState")] backend_state: String, #[serde(default)] #[serde(rename = "Health")] health: Option>, #[serde(rename = "Self")] self_node: Option, #[serde(default)] #[serde(rename = "Peer")] peer: HashMap, } #[derive(Deserialize)] struct TsPrefs { #[serde(rename = "ControlURL")] control_url: Option, } /// Classify a Tailscale `ControlURL` as vendor-hosted or self-hosted. /// /// An empty value means the default, which is how a client that has never had /// one set reports it. fn classify_control_url(url: &str) -> ControlPlane { let url = url.trim(); if url.is_empty() { return ControlPlane::Hosted; } // Strip scheme, then any path/port, leaving the host. let host = url .split_once("://") .map_or(url, |(_, rest)| rest) .split(['/', ':']) .next() .unwrap_or_default(); if host.is_empty() { return ControlPlane::Unknown; } // Matched on a dot-anchored suffix rather than `contains`, so a // self-hosted `headscale.tailscale.com.example.org` is not mistaken for // the vendor's. if host == "tailscale.com" || host.ends_with(".tailscale.com") { ControlPlane::Hosted } else { ControlPlane::SelfHosted(host.to_string()) } } #[derive(Deserialize)] struct TsPeer { #[serde(default)] #[serde(rename = "HostName")] host_name: String, #[serde(default)] #[serde(rename = "OS")] os: String, #[serde(default)] #[serde(rename = "TailscaleIPs")] tailscale_ips: Option>, #[serde(default)] #[serde(rename = "Online")] online: bool, #[serde(default)] #[serde(rename = "ExitNode")] exit_node: bool, #[serde(default)] #[serde(rename = "ExitNodeOption")] exit_node_option: bool, #[serde(default)] #[serde(rename = "LastSeen")] last_seen: Option, } impl TsPeer { fn into_peer(self, is_self: bool) -> Peer { Peer { // A peer that reports no hostname still needs to occupy an // identifiable row. hostname: if self.host_name.is_empty() { "(unnamed)".to_string() } else { self.host_name }, os: self.os, ip: preferred_ip(self.tailscale_ips.as_deref().unwrap_or_default()), // The local node reports `Online: false` in some backend states // even while it is plainly the machine running the command. online: self.online || is_self, is_self, is_exit_node: self.exit_node, offers_exit_node: self.exit_node_option, last_seen: self.last_seen.as_deref().and_then(last_seen_date), } } } fn parse_status(raw: &str) -> Result { let parsed: TsStatus = serde_json::from_str(raw).context("tailscale emitted invalid JSON")?; let mut peers: Vec = parsed .peer .into_values() .map(|peer| peer.into_peer(false)) .collect(); // Sorted for a stable screen: this machine first, then online peers, then // by name. `Peer` arrives as a map keyed by public key, so iteration order // is arbitrary and the list would otherwise reshuffle on every refresh — // with the cursor sitting on whatever landed under it. peers.sort_by(|a, b| { b.online .cmp(&a.online) .then_with(|| a.hostname.to_lowercase().cmp(&b.hostname.to_lowercase())) }); if let Some(self_node) = parsed.self_node { peers.insert(0, self_node.into_peer(true)); } Ok(MeshStatus { backend_state: parsed.backend_state, health: parsed.health.unwrap_or_default(), peers, }) } /// Pick the address to show: IPv4 when there is one. /// /// Tailscale hands out both, v4 first in practice, but ordering is not /// promised. The v4 is the one people recognize and type. fn preferred_ip(ips: &[String]) -> Option { ips.iter() .find(|ip| !ip.contains(':')) .or_else(|| ips.first()) .cloned() } /// Date portion of a `LastSeen` timestamp, or `None` when it is Go's zero /// time. /// /// Online peers carry the zero value rather than omitting the field, so this /// has to be filtered rather than trusted. Only the date is kept: a relative /// "3 days ago" would need a date library for something a column this narrow /// cannot show anyway. fn last_seen_date(raw: &str) -> Option { if raw.is_empty() || raw.starts_with(GO_ZERO_TIME_PREFIX) { return None; } raw.split('T').next().map(str::to_string) } /// The `alloy tail` screen. pub struct MeshView { backend: Box, status: Option, control_plane: ControlPlane, cursor: Cursor, error: Option, ticks: u64, } impl MeshView { pub fn new(log: &mut CommandLog) -> Self { let backend = detect(); // Once, at startup: changing the control server requires // re-authenticating, so it cannot change under a running view. let control_plane = backend.control_plane(); let mut view = Self { backend, status: None, control_plane, cursor: Cursor::new(), error: None, ticks: 0, }; view.refresh(log); view } // As in `audio`: a successful refresh does not clear `error`, because // refreshes run on the background tick and would wipe an action's error // before it could be read. Keypresses clear it instead. fn refresh(&mut self, log: &mut CommandLog) { match self.backend.status(log) { Ok(status) => { self.cursor.resize(status.peers.len()); self.status = Some(status); } Err(err) => self.error = Some(err.to_string()), } } fn peers(&self) -> &[Peer] { self.status.as_ref().map_or(&[], |status| &status.peers) } fn selected(&self) -> Option<&Peer> { self.peers().get(self.cursor.selected()?) } fn set_exit_node(&mut self, log: &mut CommandLog) { let Some(peer) = self.selected() else { return; }; if peer.is_self { self.error = Some("cannot route through this machine".into()); return; } // Tailscale rejects this too, but saying it here names the peer and // avoids a failed command in the log for something knowable up front. if !peer.offers_exit_node { self.error = Some(format!("{} does not offer to be an exit node", peer.hostname)); return; } let result = self.backend.set_exit_node(peer, log); self.finish(result, log); } fn clear_exit_node(&mut self, log: &mut CommandLog) { let result = self.backend.clear_exit_node(log); self.finish(result, log); } fn finish(&mut self, result: Result<()>, log: &mut CommandLog) { match result { Ok(()) => log.quiet(|log| self.refresh(log)), Err(err) => self.error = Some(err.to_string()), } } fn row<'a>(&self, theme: &Theme, peer: &'a Peer) -> Line<'a> { Line::from(vec![ text::bold(theme, format!("{:<18}", truncate(&peer.hostname, 17))), text::muted(theme, format!("{:<8}", truncate(&peer.os, 7))), text::secondary(theme, format!("{:<17}", peer.ip.as_deref().unwrap_or("-"))), Span::styled( format!("{:<9}", if peer.online { "online" } else { "offline" }), peer.severity().style(theme), ), text::muted(theme, peer.state_label()), ]) } } /// Clip to `width` columns, marking the clip. Counts `char`s rather than /// bytes: hostnames carry non-ASCII, and slicing those by byte index panics. fn truncate(text: &str, width: usize) -> String { if text.chars().count() <= width { return text.to_string(); } let kept: String = text.chars().take(width.saturating_sub(1)).collect(); format!("{kept}…") } impl View for MeshView { /// "mesh (tailscale)", or "mesh (tailscale via hs.example.org)" on a /// self-hosted control plane. /// /// The backend name stays visible: abstracting the brand off the verb is /// so the screen is findable by someone who does not know the product, not /// so the console conceals what it is driving. fn title(&self) -> String { format!( "mesh ({}{})", self.backend.name(), self.control_plane.label() ) } fn hints(&self) -> Vec { vec![ hint("j/k", "select"), hint("e", "exit node"), hint("x", "clear exit"), hint("r", "refresh"), ] } fn status(&self) -> Option<(Severity, String)> { if let Some(error) = &self.error { return Some((Severity::Error, error.clone())); } let status = self.status.as_ref()?; // A health warning is Tailscale telling the user something is wrong // that the peer list alone will not show. if let Some(warning) = status.health.first() { return Some((Severity::Warn, warning.clone())); } // Backend state is only worth a line when it is not the normal one. (!status.is_running()).then(|| (Severity::Warn, status.backend_state.clone())) } fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { let block = AlloyBlock::new(theme) .focused(true) .build() .title(block_title(&self.title())); let inner = block.inner(area); frame.render_widget(block, area); let peers = self.peers(); if peers.is_empty() { frame.render_widget(Line::from(text::muted(theme, "no peers")), inner); return; } let rows: Vec = peers.iter().map(|peer| self.row(theme, peer)).collect(); frame.render_widget( AlloyList::new(theme, rows).selected(self.cursor.selected()), inner, ); } fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow { self.error = None; match key.code { KeyCode::Char('j') | KeyCode::Down => self.cursor.next(), KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(), KeyCode::Char('e') => self.set_exit_node(log), KeyCode::Char('x') => self.clear_exit_node(log), KeyCode::Char('r') => self.refresh(log), _ => {} } Flow::Continue } fn tick(&mut self, log: &mut CommandLog) { self.ticks += 1; if self.ticks % POLL_TICKS == 0 { log.quiet(|log| self.refresh(log)); } } } #[cfg(test)] mod tests { use super::*; // Shaped from this machine's real `tailscale status --json`, trimmed to // the fields the parser reads. The awkward parts are real: an online peer // carrying Go's zero time for LastSeen, a device named "localhost", and // the Peer map keyed by public key. const STATUS: &str = r#"{ "Version": "1.90.0", "BackendState": "Running", "Health": [], "MagicDNSSuffix": "example-tailnet.ts.net", "Self": { "HostName": "fw13", "OS": "linux", "TailscaleIPs": ["100.103.89.95", "fd7a:115c:a1e0::af3b:595f"], "Online": true, "ExitNode": false, "ExitNodeOption": false, "LastSeen": "0001-01-01T00:00:00Z" }, "Peer": { "nodekey:aaa": { "HostName": "localhost", "OS": "iOS", "TailscaleIPs": ["100.90.1.2"], "Online": false, "ExitNode": false, "ExitNodeOption": false, "LastSeen": "2026-05-21T23:27:30.1Z" }, "nodekey:bbb": { "HostName": "astra", "OS": "linux", "TailscaleIPs": ["100.80.3.4"], "Online": true, "ExitNode": false, "ExitNodeOption": true, "LastSeen": "0001-01-01T00:00:00Z" }, "nodekey:ccc": { "HostName": "htpy-1", "OS": "linux", "TailscaleIPs": ["100.70.5.6"], "Online": true, "ExitNode": false, "ExitNodeOption": false, "LastSeen": "0001-01-01T00:00:00Z" } } }"#; #[test] fn parses_self_and_peers() { let status = parse_status(STATUS).unwrap(); assert_eq!(status.backend_state, "Running"); assert!(status.health.is_empty()); assert_eq!(status.peers.len(), 4, "self plus three peers"); } // Self first, then online peers by name, then offline. `Peer` is a map, so // without an explicit sort the list reshuffles on every refresh with the // cursor sitting on whatever lands under it. #[test] fn peers_are_ordered_self_then_online_then_by_name() { let status = parse_status(STATUS).unwrap(); let names: Vec<&str> = status.peers.iter().map(|p| p.hostname.as_str()).collect(); assert_eq!(names, ["fw13", "astra", "htpy-1", "localhost"]); assert!(status.peers[0].is_self); } // Go's zero time means "currently online", not "last seen in year 1". #[test] fn go_zero_time_is_not_a_last_seen_date() { assert_eq!(last_seen_date("0001-01-01T00:00:00Z"), None); assert_eq!(last_seen_date(""), None); assert_eq!( last_seen_date("2026-05-21T23:27:30.1Z").as_deref(), Some("2026-05-21") ); let status = parse_status(STATUS).unwrap(); let astra = &status.peers[1]; assert!(astra.online); assert_eq!(astra.last_seen, None, "an online peer shows no last-seen"); let phone = &status.peers[3]; assert_eq!(phone.last_seen.as_deref(), Some("2026-05-21")); } // The state label is what an offline peer's row says. It must not claim a // year-1 sighting, and must stay empty for an unremarkable online peer. #[test] fn state_labels_read_sensibly() { let status = parse_status(STATUS).unwrap(); assert_eq!(status.peers[0].state_label(), "this machine"); assert_eq!(status.peers[1].state_label(), "offers exit"); assert_eq!(status.peers[2].state_label(), "", "nothing notable to say"); assert_eq!(status.peers[3].state_label(), "seen 2026-05-21"); } // Peers hold a v4 and a v6; the v4 is the recognizable one. Taking the // first entry blindly works only while Tailscale keeps ordering them. #[test] fn prefers_the_ipv4_address() { let status = parse_status(STATUS).unwrap(); assert_eq!(status.peers[0].ip.as_deref(), Some("100.103.89.95")); let v6_first = ["fd7a:115c:a1e0::1".to_string(), "100.1.2.3".to_string()]; assert_eq!(preferred_ip(&v6_first).as_deref(), Some("100.1.2.3")); assert_eq!(preferred_ip(&[]), None); // v6-only is better shown than blanked. let v6_only = ["fd7a:115c:a1e0::1".to_string()]; assert_eq!(preferred_ip(&v6_only).as_deref(), Some("fd7a:115c:a1e0::1")); } #[test] fn a_stopped_backend_is_surfaced() { let raw = r#"{"BackendState":"Stopped","Peer":{}}"#; let status = parse_status(raw).unwrap(); assert!(!status.is_running()); assert!(status.peers.is_empty(), "no Self key means no rows"); } // NeedsLogin arrives with no Self and no peers. The screen has to survive // it rather than unwrapping something absent. #[test] fn a_logged_out_tailnet_parses_to_an_empty_list() { let raw = r#"{"BackendState":"NeedsLogin","Health":["not logged in"],"Peer":{}}"#; let status = parse_status(raw).unwrap(); assert_eq!(status.peers.len(), 0); assert_eq!(status.health, ["not logged in"]); } #[test] fn malformed_json_is_an_error() { assert!(parse_status("not json").is_err()); } #[test] fn an_unnamed_peer_still_gets_an_identifiable_row() { let raw = r#"{"BackendState":"Running","Peer":{"k":{"OS":"linux","Online":true}}}"#; let status = parse_status(raw).unwrap(); assert_eq!(status.peers[0].hostname, "(unnamed)"); assert_eq!(status.peers[0].ip, None); } // ---- control plane ---- // An empty ControlURL is how a client that never had one set reports the // default, so it must not read as self-hosted. #[test] fn an_unset_control_url_is_the_hosted_plane() { assert_eq!(classify_control_url(""), ControlPlane::Hosted); assert_eq!(classify_control_url(" "), ControlPlane::Hosted); } #[test] fn the_vendor_control_url_is_recognized() { assert_eq!( classify_control_url("https://controlplane.tailscale.com"), ControlPlane::Hosted ); assert_eq!(classify_control_url("https://tailscale.com"), ControlPlane::Hosted); } #[test] fn a_headscale_url_is_reported_by_host() { assert_eq!( classify_control_url("https://headscale.example.org"), ControlPlane::SelfHosted("headscale.example.org".into()) ); assert_eq!( classify_control_url("https://hs.example.org:8080/some/path"), ControlPlane::SelfHosted("hs.example.org".into()), "port and path are stripped, leaving the host" ); assert_eq!( classify_control_url("http://10.0.0.5:8080"), ControlPlane::SelfHosted("10.0.0.5".into()) ); } // Suffix matching is dot-anchored, so a self-hosted server whose name // merely contains the vendor's domain is not mistaken for it. #[test] fn a_lookalike_host_is_not_mistaken_for_the_vendor() { assert_eq!( classify_control_url("https://headscale.tailscale.com.example.org"), ControlPlane::SelfHosted("headscale.tailscale.com.example.org".into()) ); assert_eq!( classify_control_url("https://nottailscale.com"), ControlPlane::SelfHosted("nottailscale.com".into()) ); } // Only a self-hosted plane is worth title space; the other two say nothing // rather than "(hosted)" on every screen. #[test] fn only_a_self_hosted_plane_earns_a_title_suffix() { assert_eq!(ControlPlane::Hosted.label(), ""); assert_eq!(ControlPlane::Unknown.label(), ""); assert_eq!( ControlPlane::SelfHosted("hs.example.org".into()).label(), " via hs.example.org" ); } #[test] fn the_title_names_the_backend_and_a_self_hosted_plane() { let (mut view, _log) = mock_view(); assert_eq!(view.title(), "mesh (mock)"); view.control_plane = ControlPlane::SelfHosted("hs.example.org".into()); assert_eq!(view.title(), "mesh (mock via hs.example.org)"); } // ---- view behavior ---- fn mock_view() -> (MeshView, CommandLog) { let mut log = CommandLog::new(); let mut view = MeshView { backend: Box::new(Mock), status: None, control_plane: ControlPlane::Unknown, cursor: Cursor::new(), error: None, ticks: 0, }; view.refresh(&mut log); (view, log) } #[test] fn routing_through_this_machine_is_refused() { let (mut view, mut log) = mock_view(); view.set_exit_node(&mut log); assert!( view.error.as_deref().is_some_and(|e| e.contains("this machine")), "got: {:?}", view.error ); } // Tailscale would reject this too, but naming the peer up front beats a // failed command in the log for something knowable in advance. #[test] fn routing_through_a_peer_that_does_not_offer_is_refused() { let (mut view, mut log) = mock_view(); view.cursor.move_by(2); // the phone, which offers nothing view.set_exit_node(&mut log); assert!( view.error.as_deref().is_some_and(|e| e.contains("does not offer")), "got: {:?}", view.error ); } #[test] fn routing_through_an_offering_peer_is_allowed() { let (mut view, mut log) = mock_view(); view.cursor.move_by(1); // astra, which offers view.set_exit_node(&mut log); assert!(view.error.is_none(), "got: {:?}", view.error); } #[test] fn ticks_are_silent_and_do_not_clear_errors() { let (mut view, mut log) = mock_view(); view.set_exit_node(&mut log); // refused: self assert!(view.error.is_some()); let before = log.entries().len(); for _ in 0..POLL_TICKS * 2 { view.tick(&mut log); } assert_eq!(log.entries().len(), before, "ticks do not log"); assert!(view.error.is_some(), "ticks do not wipe an action error"); } #[test] fn acting_with_no_selection_is_inert() { let mut log = CommandLog::new(); let mut view = MeshView { backend: Box::new(Mock), status: None, control_plane: ControlPlane::Unknown, cursor: Cursor::new(), error: None, ticks: 0, }; view.set_exit_node(&mut log); assert!(view.error.is_none(), "no selection is not an error"); } /// Parse this machine's real tailnet. /// /// Ignored by default: needs Tailscale installed and logged in, and what /// it finds depends on the tailnet. Run it when touching the parser. #[test] #[ignore = "requires a logged-in Tailscale"] fn parses_this_machines_real_tailnet() { let mut log = CommandLog::new(); let status = Tailscale.status(&mut log).expect("tailscale should answer"); assert!(!status.peers.is_empty(), "a mesh has at least this machine"); assert!(status.peers[0].is_self, "this machine sorts first"); // The control-plane lookup rides an unstable `debug` interface, so // what matters is that it produced *something* rather than silently // degrading to Unknown on a working client. let control = Tailscale.control_plane(); println!("control plane: {control:?}"); assert_ne!( control, ControlPlane::Unknown, "`tailscale debug prefs` no longer yields a ControlURL; the lookup \ has degraded and the title will silently drop its suffix" ); println!("backend: {} health: {:?}", status.backend_state, status.health); for peer in &status.peers { assert!(!peer.hostname.is_empty(), "every row is identifiable"); assert!( peer.last_seen.as_deref() != Some("0001-01-01"), "Go zero time leaked into a last-seen date" ); println!( "{:<18} {:<8} {:<9} {}", peer.hostname, peer.os, if peer.online { "online" } else { "offline" }, peer.state_label() ); } } }