Skip to main content

max / alloy_tui

33.5 KB · 970 lines History Blame Raw
1 //! `alloy mesh` — the mesh network view.
2 //!
3 //! See docs/CONTINUITY.md: a mesh VPN and a file sync are what make an Alloy
4 //! machine feel like the same machine as the last one, so the console fronts
5 //! both. This is the mesh half.
6 //!
7 //! # Why the surface is not called Tailscale
8 //!
9 //! The verb, the types, and every string on screen are generic; only the
10 //! [`Tailscale`] backend names a product. Two reasons.
11 //!
12 //! A user who has never heard of Tailscale should still find the screen that
13 //! lists the machines they can reach. "mesh" describes what the thing is;
14 //! "tail" describes who makes it. The backend name stays visible in the title
15 //! so the abstraction never hides which tool is actually running, and the log
16 //! pane still teaches the real `tailscale` commands.
17 //!
18 //! And Headscale is a self-hosted control server for the *same client*: you
19 //! point this same `tailscale` binary at it with `--login-server`. So
20 //! supporting it needs no second backend, only a surface that does not claim
21 //! to be a product page for one vendor, plus [`ControlPlane`] so a self-hosted
22 //! tailnet says so. A genuinely different mesh (Netbird, Nebula, ZeroTier)
23 //! would slot in as another [`Backend`] against this same vocabulary.
24 //!
25 //! One invocation covers the whole screen. `tailscale status --json` is a
26 //! documented contract carrying the local node, every peer, the backend state,
27 //! and any health warnings, so unlike `alloy audio` this view costs a single
28 //! logged line per refresh.
29 //!
30 //! "Exit node" stays as-is throughout, and is not abstracted along with the
31 //! brand. It is the standard term across Tailscale and Headscale alike, it is
32 //! what a user would search for, and it is the word the logged command uses.
33 //! Inventing a friendlier synonym would have the console teach a term nothing
34 //! else in the ecosystem uses.
35
36 use std::collections::HashMap;
37
38 use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text};
39 use anyhow::{Context, Result};
40 use ratatui::Frame;
41 use ratatui::crossterm::event::{KeyCode, KeyEvent};
42 use ratatui::layout::Rect;
43 use ratatui::text::{Line, Span};
44 use serde::Deserialize;
45
46 use crate::cli::{CommandLog, Invocation};
47 use crate::shell::{Flow, View, block_title};
48
49 /// Ticks between background refreshes. Peers come and go on the scale of a
50 /// laptop lid closing, not a keypress, so polling every second would spawn a
51 /// process per second to learn nothing.
52 const POLL_TICKS: u64 = 5;
53
54 /// Go's zero time, which `tailscale status --json` emits for `LastSeen` on any
55 /// peer that is currently online. Rendered literally it reads "last seen in
56 /// year 1".
57 const GO_ZERO_TIME_PREFIX: &str = "0001-01-01";
58
59 #[derive(Debug, Clone)]
60 pub struct Peer {
61 pub hostname: String,
62 pub os: String,
63 /// First tailnet address. Peers can hold both a v4 and a v6; the v4 is
64 /// what people recognize and type.
65 pub ip: Option<String>,
66 pub online: bool,
67 /// This machine.
68 pub is_self: bool,
69 /// Currently carrying this machine's traffic as its exit node.
70 pub is_exit_node: bool,
71 /// Advertises itself as available to be an exit node.
72 pub offers_exit_node: bool,
73 /// Date last seen, absent while online.
74 pub last_seen: Option<String>,
75 }
76
77 impl Peer {
78 fn severity(&self) -> Severity {
79 if self.is_exit_node {
80 Severity::Info
81 } else if self.online {
82 Severity::Healthy
83 } else {
84 Severity::Warn
85 }
86 }
87
88 fn state_label(&self) -> String {
89 let mut parts = Vec::new();
90 if self.is_self {
91 parts.push("this machine".to_string());
92 }
93 if self.is_exit_node {
94 parts.push("exit node".to_string());
95 } else if self.offers_exit_node {
96 parts.push("offers exit".to_string());
97 }
98 if !self.online && let Some(seen) = &self.last_seen {
99 parts.push(format!("seen {seen}"));
100 }
101 parts.join(", ")
102 }
103 }
104
105 /// Which control server the mesh is coordinated by.
106 ///
107 /// The one place the Tailscale/Headscale distinction is user-visible. A
108 /// self-hosted tailnet looks identical in every other respect, and "which
109 /// control plane am I on" is exactly the question someone running Headscale
110 /// wants answered without dropping to a shell.
111 #[derive(Debug, Clone, PartialEq, Eq)]
112 pub enum ControlPlane {
113 /// The vendor's own control plane.
114 Hosted,
115 /// A self-hosted control server, named by host.
116 SelfHosted(String),
117 /// Not determined. The lookup is best-effort (see
118 /// [`Tailscale::control_plane`]), and an unknown control plane is not
119 /// worth a warning — the mesh works either way.
120 Unknown,
121 }
122
123 impl ControlPlane {
124 /// Suffix for the view title, empty when there is nothing worth saying.
125 fn label(&self) -> String {
126 match self {
127 ControlPlane::SelfHosted(host) => format!(" via {host}"),
128 ControlPlane::Hosted | ControlPlane::Unknown => String::new(),
129 }
130 }
131 }
132
133 #[derive(Debug, Clone)]
134 pub struct MeshStatus {
135 /// `Running`, `Stopped`, `NeedsLogin`, and friends. Reported verbatim
136 /// rather than mapped to an enum: it is a Tailscale-owned vocabulary that
137 /// gains members, and showing an unfamiliar one is better than collapsing
138 /// it to "unknown".
139 pub backend_state: String,
140 pub health: Vec<String>,
141 pub peers: Vec<Peer>,
142 }
143
144 impl MeshStatus {
145 fn is_running(&self) -> bool {
146 self.backend_state == "Running"
147 }
148 }
149
150 pub trait Backend {
151 fn name(&self) -> &'static str;
152 fn status(&self, log: &mut CommandLog) -> Result<MeshStatus>;
153
154 /// Which control server coordinates this mesh.
155 ///
156 /// Read once at startup rather than per refresh: changing it requires
157 /// re-authenticating, so it cannot change under a running view.
158 fn control_plane(&self) -> ControlPlane {
159 ControlPlane::Unknown
160 }
161
162 /// Route traffic through `peer`.
163 fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()>;
164
165 /// Stop routing through an exit node.
166 fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()>;
167 }
168
169 /// Pick a backend: `tailscale` when it answers, the mock otherwise.
170 ///
171 /// Tailscale is the only real implementation today, and covers Headscale too
172 /// since Headscale drives this same client. A different mesh would be another
173 /// arm here.
174 pub fn detect() -> Box<dyn Backend> {
175 if Invocation::new("tailscale").arg("version").probe() {
176 Box::new(Tailscale)
177 } else {
178 Box::new(Mock)
179 }
180 }
181
182 pub struct Tailscale;
183
184 impl Backend for Tailscale {
185 fn name(&self) -> &'static str {
186 "tailscale"
187 }
188
189 fn status(&self, log: &mut CommandLog) -> Result<MeshStatus> {
190 let raw = Invocation::new("tailscale")
191 .args(["status", "--json"])
192 .run(log)?;
193 parse_status(&raw)
194 }
195
196 /// Read the control server from `tailscale debug prefs`.
197 ///
198 /// `debug` is explicitly not a stable interface, which is why this is
199 /// best-effort and every failure path lands on [`ControlPlane::Unknown`]:
200 /// the command missing, the output not being JSON, the key being renamed.
201 /// The cost of being wrong is a missing title suffix, so a fragile source
202 /// is acceptable here in a way it would not be for the peer list. It is
203 /// also unlogged and runs once, so a `debug` invocation never appears in a
204 /// pane that teaches commands users should run themselves.
205 ///
206 /// There is no stable equivalent. `status --json` carries the tailnet name
207 /// and MagicDNS suffix but not the control URL, and inferring "self-hosted"
208 /// from a non-`.ts.net` suffix would be a guess about a configurable value.
209 fn control_plane(&self) -> ControlPlane {
210 let Ok(raw) = Invocation::new("tailscale")
211 .args(["debug", "prefs"])
212 .capture_quiet()
213 else {
214 return ControlPlane::Unknown;
215 };
216 let Ok(prefs) = serde_json::from_str::<TsPrefs>(&raw) else {
217 return ControlPlane::Unknown;
218 };
219 classify_control_url(prefs.control_url.as_deref().unwrap_or_default())
220 }
221
222 fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()> {
223 // Addressed by IP rather than hostname: hostnames collide (three
224 // devices on this tailnet answer to "localhost") and MagicDNS may be
225 // off, while the tailnet IP is unique and always resolvable.
226 let ip = peer
227 .ip
228 .as_deref()
229 .context("peer has no mesh address to route through")?;
230 Invocation::new("tailscale")
231 .arg("set")
232 .arg(format!("--exit-node={ip}"))
233 .run(log)
234 .map(drop)
235 }
236
237 fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()> {
238 Invocation::new("tailscale")
239 .arg("set")
240 .arg("--exit-node=")
241 .run(log)
242 .map(drop)
243 }
244 }
245
246 /// Fixed sample state, for machines without Tailscale.
247 pub struct Mock;
248
249 impl Backend for Mock {
250 fn name(&self) -> &'static str {
251 "mock"
252 }
253
254 fn status(&self, log: &mut CommandLog) -> Result<MeshStatus> {
255 log.record("# no mesh client found; showing mock peers", Severity::Warn);
256 Ok(MeshStatus {
257 backend_state: "Running".into(),
258 health: Vec::new(),
259 peers: vec![
260 Peer {
261 hostname: "fw13".into(),
262 os: "linux".into(),
263 ip: Some("100.64.0.1".into()),
264 online: true,
265 is_self: true,
266 is_exit_node: false,
267 offers_exit_node: false,
268 last_seen: None,
269 },
270 Peer {
271 hostname: "astra".into(),
272 os: "linux".into(),
273 ip: Some("100.64.0.2".into()),
274 online: true,
275 is_self: false,
276 is_exit_node: false,
277 offers_exit_node: true,
278 last_seen: None,
279 },
280 Peer {
281 hostname: "phone".into(),
282 os: "iOS".into(),
283 ip: Some("100.64.0.3".into()),
284 online: false,
285 is_self: false,
286 is_exit_node: false,
287 offers_exit_node: false,
288 last_seen: Some("2026-05-21".into()),
289 },
290 ],
291 })
292 }
293
294 // The mock is a display fixture, not a simulator.
295 fn set_exit_node(&self, _peer: &Peer, log: &mut CommandLog) -> Result<()> {
296 log.record("# mock backend: exit node unchanged", Severity::Warn);
297 Ok(())
298 }
299
300 fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()> {
301 log.record("# mock backend: exit node unchanged", Severity::Warn);
302 Ok(())
303 }
304 }
305
306 // ---- tailscale status --json ----
307
308 #[derive(Deserialize)]
309 struct TsStatus {
310 #[serde(default)]
311 #[serde(rename = "BackendState")]
312 backend_state: String,
313 #[serde(default)]
314 #[serde(rename = "Health")]
315 health: Option<Vec<String>>,
316 #[serde(rename = "Self")]
317 self_node: Option<TsPeer>,
318 #[serde(default)]
319 #[serde(rename = "Peer")]
320 peer: HashMap<String, TsPeer>,
321 }
322
323 #[derive(Deserialize)]
324 struct TsPrefs {
325 #[serde(rename = "ControlURL")]
326 control_url: Option<String>,
327 }
328
329 /// Classify a Tailscale `ControlURL` as vendor-hosted or self-hosted.
330 ///
331 /// An empty value means the default, which is how a client that has never had
332 /// one set reports it.
333 fn classify_control_url(url: &str) -> ControlPlane {
334 let url = url.trim();
335 if url.is_empty() {
336 return ControlPlane::Hosted;
337 }
338 // Strip scheme, then any path/port, leaving the host.
339 let host = url
340 .split_once("://")
341 .map_or(url, |(_, rest)| rest)
342 .split(['/', ':'])
343 .next()
344 .unwrap_or_default();
345
346 if host.is_empty() {
347 return ControlPlane::Unknown;
348 }
349 // Matched on a dot-anchored suffix rather than `contains`, so a
350 // self-hosted `headscale.tailscale.com.example.org` is not mistaken for
351 // the vendor's.
352 if host == "tailscale.com" || host.ends_with(".tailscale.com") {
353 ControlPlane::Hosted
354 } else {
355 ControlPlane::SelfHosted(host.to_string())
356 }
357 }
358
359 #[derive(Deserialize)]
360 struct TsPeer {
361 #[serde(default)]
362 #[serde(rename = "HostName")]
363 host_name: String,
364 #[serde(default)]
365 #[serde(rename = "OS")]
366 os: String,
367 #[serde(default)]
368 #[serde(rename = "TailscaleIPs")]
369 tailscale_ips: Option<Vec<String>>,
370 #[serde(default)]
371 #[serde(rename = "Online")]
372 online: bool,
373 #[serde(default)]
374 #[serde(rename = "ExitNode")]
375 exit_node: bool,
376 #[serde(default)]
377 #[serde(rename = "ExitNodeOption")]
378 exit_node_option: bool,
379 #[serde(default)]
380 #[serde(rename = "LastSeen")]
381 last_seen: Option<String>,
382 }
383
384 impl TsPeer {
385 fn into_peer(self, is_self: bool) -> Peer {
386 Peer {
387 // A peer that reports no hostname still needs to occupy an
388 // identifiable row.
389 hostname: if self.host_name.is_empty() {
390 "(unnamed)".to_string()
391 } else {
392 self.host_name
393 },
394 os: self.os,
395 ip: preferred_ip(self.tailscale_ips.as_deref().unwrap_or_default()),
396 // The local node reports `Online: false` in some backend states
397 // even while it is plainly the machine running the command.
398 online: self.online || is_self,
399 is_self,
400 is_exit_node: self.exit_node,
401 offers_exit_node: self.exit_node_option,
402 last_seen: self.last_seen.as_deref().and_then(last_seen_date),
403 }
404 }
405 }
406
407 fn parse_status(raw: &str) -> Result<MeshStatus> {
408 let parsed: TsStatus =
409 serde_json::from_str(raw).context("tailscale emitted invalid JSON")?;
410
411 let mut peers: Vec<Peer> = parsed
412 .peer
413 .into_values()
414 .map(|peer| peer.into_peer(false))
415 .collect();
416
417 // Sorted for a stable screen: this machine first, then online peers, then
418 // by name. `Peer` arrives as a map keyed by public key, so iteration order
419 // is arbitrary and the list would otherwise reshuffle on every refresh —
420 // with the cursor sitting on whatever landed under it.
421 peers.sort_by(|a, b| {
422 b.online
423 .cmp(&a.online)
424 .then_with(|| a.hostname.to_lowercase().cmp(&b.hostname.to_lowercase()))
425 });
426 if let Some(self_node) = parsed.self_node {
427 peers.insert(0, self_node.into_peer(true));
428 }
429
430 Ok(MeshStatus {
431 backend_state: parsed.backend_state,
432 health: parsed.health.unwrap_or_default(),
433 peers,
434 })
435 }
436
437 /// Pick the address to show: IPv4 when there is one.
438 ///
439 /// Tailscale hands out both, v4 first in practice, but ordering is not
440 /// promised. The v4 is the one people recognize and type.
441 fn preferred_ip(ips: &[String]) -> Option<String> {
442 ips.iter()
443 .find(|ip| !ip.contains(':'))
444 .or_else(|| ips.first())
445 .cloned()
446 }
447
448 /// Date portion of a `LastSeen` timestamp, or `None` when it is Go's zero
449 /// time.
450 ///
451 /// Online peers carry the zero value rather than omitting the field, so this
452 /// has to be filtered rather than trusted. Only the date is kept: a relative
453 /// "3 days ago" would need a date library for something a column this narrow
454 /// cannot show anyway.
455 fn last_seen_date(raw: &str) -> Option<String> {
456 if raw.is_empty() || raw.starts_with(GO_ZERO_TIME_PREFIX) {
457 return None;
458 }
459 raw.split('T').next().map(str::to_string)
460 }
461
462 /// The `alloy tail` screen.
463 pub struct MeshView {
464 backend: Box<dyn Backend>,
465 status: Option<MeshStatus>,
466 control_plane: ControlPlane,
467 cursor: Cursor,
468 error: Option<String>,
469 ticks: u64,
470 }
471
472 impl MeshView {
473 pub fn new(log: &mut CommandLog) -> Self {
474 let backend = detect();
475 // Once, at startup: changing the control server requires
476 // re-authenticating, so it cannot change under a running view.
477 let control_plane = backend.control_plane();
478 let mut view = Self {
479 backend,
480 status: None,
481 control_plane,
482 cursor: Cursor::new(),
483 error: None,
484 ticks: 0,
485 };
486 view.refresh(log);
487 view
488 }
489
490 // As in `audio`: a successful refresh does not clear `error`, because
491 // refreshes run on the background tick and would wipe an action's error
492 // before it could be read. Keypresses clear it instead.
493 fn refresh(&mut self, log: &mut CommandLog) {
494 match self.backend.status(log) {
495 Ok(status) => {
496 self.cursor.resize(status.peers.len());
497 self.status = Some(status);
498 }
499 Err(err) => self.error = Some(err.to_string()),
500 }
501 }
502
503 fn peers(&self) -> &[Peer] {
504 self.status.as_ref().map_or(&[], |status| &status.peers)
505 }
506
507 fn selected(&self) -> Option<&Peer> {
508 self.peers().get(self.cursor.selected()?)
509 }
510
511 fn set_exit_node(&mut self, log: &mut CommandLog) {
512 let Some(peer) = self.selected() else {
513 return;
514 };
515 if peer.is_self {
516 self.error = Some("cannot route through this machine".into());
517 return;
518 }
519 // Tailscale rejects this too, but saying it here names the peer and
520 // avoids a failed command in the log for something knowable up front.
521 if !peer.offers_exit_node {
522 self.error = Some(format!("{} does not offer to be an exit node", peer.hostname));
523 return;
524 }
525 let result = self.backend.set_exit_node(peer, log);
526 self.finish(result, log);
527 }
528
529 fn clear_exit_node(&mut self, log: &mut CommandLog) {
530 let result = self.backend.clear_exit_node(log);
531 self.finish(result, log);
532 }
533
534 fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
535 match result {
536 Ok(()) => log.quiet(|log| self.refresh(log)),
537 Err(err) => self.error = Some(err.to_string()),
538 }
539 }
540
541 fn row<'a>(&self, theme: &Theme, peer: &'a Peer) -> Line<'a> {
542 Line::from(vec![
543 text::bold(theme, format!("{:<18}", truncate(&peer.hostname, 17))),
544 text::muted(theme, format!("{:<8}", truncate(&peer.os, 7))),
545 text::secondary(theme, format!("{:<17}", peer.ip.as_deref().unwrap_or("-"))),
546 Span::styled(
547 format!("{:<9}", if peer.online { "online" } else { "offline" }),
548 peer.severity().style(theme),
549 ),
550 text::muted(theme, peer.state_label()),
551 ])
552 }
553 }
554
555 /// Clip to `width` columns, marking the clip. Counts `char`s rather than
556 /// bytes: hostnames carry non-ASCII, and slicing those by byte index panics.
557 fn truncate(text: &str, width: usize) -> String {
558 if text.chars().count() <= width {
559 return text.to_string();
560 }
561 let kept: String = text.chars().take(width.saturating_sub(1)).collect();
562 format!("{kept}")
563 }
564
565 impl View for MeshView {
566 /// "mesh (tailscale)", or "mesh (tailscale via hs.example.org)" on a
567 /// self-hosted control plane.
568 ///
569 /// The backend name stays visible: abstracting the brand off the verb is
570 /// so the screen is findable by someone who does not know the product, not
571 /// so the console conceals what it is driving.
572 fn title(&self) -> String {
573 format!(
574 "mesh ({}{})",
575 self.backend.name(),
576 self.control_plane.label()
577 )
578 }
579
580 fn hints(&self) -> Vec<Hint> {
581 vec![
582 hint("j/k", "select"),
583 hint("e", "exit node"),
584 hint("x", "clear exit"),
585 hint("r", "refresh"),
586 ]
587 }
588
589 fn status(&self) -> Option<(Severity, String)> {
590 if let Some(error) = &self.error {
591 return Some((Severity::Error, error.clone()));
592 }
593 let status = self.status.as_ref()?;
594 // A health warning is Tailscale telling the user something is wrong
595 // that the peer list alone will not show.
596 if let Some(warning) = status.health.first() {
597 return Some((Severity::Warn, warning.clone()));
598 }
599 // Backend state is only worth a line when it is not the normal one.
600 (!status.is_running()).then(|| (Severity::Warn, status.backend_state.clone()))
601 }
602
603 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
604 let block = AlloyBlock::new(theme)
605 .focused(true)
606 .build()
607 .title(block_title(&self.title()));
608 let inner = block.inner(area);
609 frame.render_widget(block, area);
610
611 let peers = self.peers();
612 if peers.is_empty() {
613 frame.render_widget(Line::from(text::muted(theme, "no peers")), inner);
614 return;
615 }
616
617 let rows: Vec<Line> = peers.iter().map(|peer| self.row(theme, peer)).collect();
618 frame.render_widget(
619 AlloyList::new(theme, rows).selected(self.cursor.selected()),
620 inner,
621 );
622 }
623
624 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
625 self.error = None;
626
627 match key.code {
628 KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
629 KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
630 KeyCode::Char('e') => self.set_exit_node(log),
631 KeyCode::Char('x') => self.clear_exit_node(log),
632 KeyCode::Char('r') => self.refresh(log),
633 _ => {}
634 }
635 Flow::Continue
636 }
637
638 fn tick(&mut self, log: &mut CommandLog) {
639 self.ticks += 1;
640 if self.ticks % POLL_TICKS == 0 {
641 log.quiet(|log| self.refresh(log));
642 }
643 }
644 }
645
646 #[cfg(test)]
647 mod tests {
648 use super::*;
649
650 // Shaped from this machine's real `tailscale status --json`, trimmed to
651 // the fields the parser reads. The awkward parts are real: an online peer
652 // carrying Go's zero time for LastSeen, a device named "localhost", and
653 // the Peer map keyed by public key.
654 const STATUS: &str = r#"{
655 "Version": "1.90.0",
656 "BackendState": "Running",
657 "Health": [],
658 "MagicDNSSuffix": "example-tailnet.ts.net",
659 "Self": {
660 "HostName": "fw13", "OS": "linux",
661 "TailscaleIPs": ["100.103.89.95", "fd7a:115c:a1e0::af3b:595f"],
662 "Online": true, "ExitNode": false, "ExitNodeOption": false,
663 "LastSeen": "0001-01-01T00:00:00Z"
664 },
665 "Peer": {
666 "nodekey:aaa": {
667 "HostName": "localhost", "OS": "iOS",
668 "TailscaleIPs": ["100.90.1.2"],
669 "Online": false, "ExitNode": false, "ExitNodeOption": false,
670 "LastSeen": "2026-05-21T23:27:30.1Z"
671 },
672 "nodekey:bbb": {
673 "HostName": "astra", "OS": "linux",
674 "TailscaleIPs": ["100.80.3.4"],
675 "Online": true, "ExitNode": false, "ExitNodeOption": true,
676 "LastSeen": "0001-01-01T00:00:00Z"
677 },
678 "nodekey:ccc": {
679 "HostName": "htpy-1", "OS": "linux",
680 "TailscaleIPs": ["100.70.5.6"],
681 "Online": true, "ExitNode": false, "ExitNodeOption": false,
682 "LastSeen": "0001-01-01T00:00:00Z"
683 }
684 }
685 }"#;
686
687 #[test]
688 fn parses_self_and_peers() {
689 let status = parse_status(STATUS).unwrap();
690 assert_eq!(status.backend_state, "Running");
691 assert!(status.health.is_empty());
692 assert_eq!(status.peers.len(), 4, "self plus three peers");
693 }
694
695 // Self first, then online peers by name, then offline. `Peer` is a map, so
696 // without an explicit sort the list reshuffles on every refresh with the
697 // cursor sitting on whatever lands under it.
698 #[test]
699 fn peers_are_ordered_self_then_online_then_by_name() {
700 let status = parse_status(STATUS).unwrap();
701 let names: Vec<&str> = status.peers.iter().map(|p| p.hostname.as_str()).collect();
702 assert_eq!(names, ["fw13", "astra", "htpy-1", "localhost"]);
703 assert!(status.peers[0].is_self);
704 }
705
706 // Go's zero time means "currently online", not "last seen in year 1".
707 #[test]
708 fn go_zero_time_is_not_a_last_seen_date() {
709 assert_eq!(last_seen_date("0001-01-01T00:00:00Z"), None);
710 assert_eq!(last_seen_date(""), None);
711 assert_eq!(
712 last_seen_date("2026-05-21T23:27:30.1Z").as_deref(),
713 Some("2026-05-21")
714 );
715
716 let status = parse_status(STATUS).unwrap();
717 let astra = &status.peers[1];
718 assert!(astra.online);
719 assert_eq!(astra.last_seen, None, "an online peer shows no last-seen");
720 let phone = &status.peers[3];
721 assert_eq!(phone.last_seen.as_deref(), Some("2026-05-21"));
722 }
723
724 // The state label is what an offline peer's row says. It must not claim a
725 // year-1 sighting, and must stay empty for an unremarkable online peer.
726 #[test]
727 fn state_labels_read_sensibly() {
728 let status = parse_status(STATUS).unwrap();
729 assert_eq!(status.peers[0].state_label(), "this machine");
730 assert_eq!(status.peers[1].state_label(), "offers exit");
731 assert_eq!(status.peers[2].state_label(), "", "nothing notable to say");
732 assert_eq!(status.peers[3].state_label(), "seen 2026-05-21");
733 }
734
735 // Peers hold a v4 and a v6; the v4 is the recognizable one. Taking the
736 // first entry blindly works only while Tailscale keeps ordering them.
737 #[test]
738 fn prefers_the_ipv4_address() {
739 let status = parse_status(STATUS).unwrap();
740 assert_eq!(status.peers[0].ip.as_deref(), Some("100.103.89.95"));
741
742 let v6_first = ["fd7a:115c:a1e0::1".to_string(), "100.1.2.3".to_string()];
743 assert_eq!(preferred_ip(&v6_first).as_deref(), Some("100.1.2.3"));
744 assert_eq!(preferred_ip(&[]), None);
745 // v6-only is better shown than blanked.
746 let v6_only = ["fd7a:115c:a1e0::1".to_string()];
747 assert_eq!(preferred_ip(&v6_only).as_deref(), Some("fd7a:115c:a1e0::1"));
748 }
749
750 #[test]
751 fn a_stopped_backend_is_surfaced() {
752 let raw = r#"{"BackendState":"Stopped","Peer":{}}"#;
753 let status = parse_status(raw).unwrap();
754 assert!(!status.is_running());
755 assert!(status.peers.is_empty(), "no Self key means no rows");
756 }
757
758 // NeedsLogin arrives with no Self and no peers. The screen has to survive
759 // it rather than unwrapping something absent.
760 #[test]
761 fn a_logged_out_tailnet_parses_to_an_empty_list() {
762 let raw = r#"{"BackendState":"NeedsLogin","Health":["not logged in"],"Peer":{}}"#;
763 let status = parse_status(raw).unwrap();
764 assert_eq!(status.peers.len(), 0);
765 assert_eq!(status.health, ["not logged in"]);
766 }
767
768 #[test]
769 fn malformed_json_is_an_error() {
770 assert!(parse_status("not json").is_err());
771 }
772
773 #[test]
774 fn an_unnamed_peer_still_gets_an_identifiable_row() {
775 let raw = r#"{"BackendState":"Running","Peer":{"k":{"OS":"linux","Online":true}}}"#;
776 let status = parse_status(raw).unwrap();
777 assert_eq!(status.peers[0].hostname, "(unnamed)");
778 assert_eq!(status.peers[0].ip, None);
779 }
780
781 // ---- control plane ----
782
783 // An empty ControlURL is how a client that never had one set reports the
784 // default, so it must not read as self-hosted.
785 #[test]
786 fn an_unset_control_url_is_the_hosted_plane() {
787 assert_eq!(classify_control_url(""), ControlPlane::Hosted);
788 assert_eq!(classify_control_url(" "), ControlPlane::Hosted);
789 }
790
791 #[test]
792 fn the_vendor_control_url_is_recognized() {
793 assert_eq!(
794 classify_control_url("https://controlplane.tailscale.com"),
795 ControlPlane::Hosted
796 );
797 assert_eq!(classify_control_url("https://tailscale.com"), ControlPlane::Hosted);
798 }
799
800 #[test]
801 fn a_headscale_url_is_reported_by_host() {
802 assert_eq!(
803 classify_control_url("https://headscale.example.org"),
804 ControlPlane::SelfHosted("headscale.example.org".into())
805 );
806 assert_eq!(
807 classify_control_url("https://hs.example.org:8080/some/path"),
808 ControlPlane::SelfHosted("hs.example.org".into()),
809 "port and path are stripped, leaving the host"
810 );
811 assert_eq!(
812 classify_control_url("http://10.0.0.5:8080"),
813 ControlPlane::SelfHosted("10.0.0.5".into())
814 );
815 }
816
817 // Suffix matching is dot-anchored, so a self-hosted server whose name
818 // merely contains the vendor's domain is not mistaken for it.
819 #[test]
820 fn a_lookalike_host_is_not_mistaken_for_the_vendor() {
821 assert_eq!(
822 classify_control_url("https://headscale.tailscale.com.example.org"),
823 ControlPlane::SelfHosted("headscale.tailscale.com.example.org".into())
824 );
825 assert_eq!(
826 classify_control_url("https://nottailscale.com"),
827 ControlPlane::SelfHosted("nottailscale.com".into())
828 );
829 }
830
831 // Only a self-hosted plane is worth title space; the other two say nothing
832 // rather than "(hosted)" on every screen.
833 #[test]
834 fn only_a_self_hosted_plane_earns_a_title_suffix() {
835 assert_eq!(ControlPlane::Hosted.label(), "");
836 assert_eq!(ControlPlane::Unknown.label(), "");
837 assert_eq!(
838 ControlPlane::SelfHosted("hs.example.org".into()).label(),
839 " via hs.example.org"
840 );
841 }
842
843 #[test]
844 fn the_title_names_the_backend_and_a_self_hosted_plane() {
845 let (mut view, _log) = mock_view();
846 assert_eq!(view.title(), "mesh (mock)");
847 view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
848 assert_eq!(view.title(), "mesh (mock via hs.example.org)");
849 }
850
851 // ---- view behavior ----
852
853 fn mock_view() -> (MeshView, CommandLog) {
854 let mut log = CommandLog::new();
855 let mut view = MeshView {
856 backend: Box::new(Mock),
857 status: None,
858 control_plane: ControlPlane::Unknown,
859 cursor: Cursor::new(),
860 error: None,
861 ticks: 0,
862 };
863 view.refresh(&mut log);
864 (view, log)
865 }
866
867 #[test]
868 fn routing_through_this_machine_is_refused() {
869 let (mut view, mut log) = mock_view();
870 view.set_exit_node(&mut log);
871 assert!(
872 view.error.as_deref().is_some_and(|e| e.contains("this machine")),
873 "got: {:?}",
874 view.error
875 );
876 }
877
878 // Tailscale would reject this too, but naming the peer up front beats a
879 // failed command in the log for something knowable in advance.
880 #[test]
881 fn routing_through_a_peer_that_does_not_offer_is_refused() {
882 let (mut view, mut log) = mock_view();
883 view.cursor.move_by(2); // the phone, which offers nothing
884 view.set_exit_node(&mut log);
885 assert!(
886 view.error.as_deref().is_some_and(|e| e.contains("does not offer")),
887 "got: {:?}",
888 view.error
889 );
890 }
891
892 #[test]
893 fn routing_through_an_offering_peer_is_allowed() {
894 let (mut view, mut log) = mock_view();
895 view.cursor.move_by(1); // astra, which offers
896 view.set_exit_node(&mut log);
897 assert!(view.error.is_none(), "got: {:?}", view.error);
898 }
899
900 #[test]
901 fn ticks_are_silent_and_do_not_clear_errors() {
902 let (mut view, mut log) = mock_view();
903 view.set_exit_node(&mut log); // refused: self
904 assert!(view.error.is_some());
905
906 let before = log.entries().len();
907 for _ in 0..POLL_TICKS * 2 {
908 view.tick(&mut log);
909 }
910 assert_eq!(log.entries().len(), before, "ticks do not log");
911 assert!(view.error.is_some(), "ticks do not wipe an action error");
912 }
913
914 #[test]
915 fn acting_with_no_selection_is_inert() {
916 let mut log = CommandLog::new();
917 let mut view = MeshView {
918 backend: Box::new(Mock),
919 status: None,
920 control_plane: ControlPlane::Unknown,
921 cursor: Cursor::new(),
922 error: None,
923 ticks: 0,
924 };
925 view.set_exit_node(&mut log);
926 assert!(view.error.is_none(), "no selection is not an error");
927 }
928
929 /// Parse this machine's real tailnet.
930 ///
931 /// Ignored by default: needs Tailscale installed and logged in, and what
932 /// it finds depends on the tailnet. Run it when touching the parser.
933 #[test]
934 #[ignore = "requires a logged-in Tailscale"]
935 fn parses_this_machines_real_tailnet() {
936 let mut log = CommandLog::new();
937 let status = Tailscale.status(&mut log).expect("tailscale should answer");
938
939 assert!(!status.peers.is_empty(), "a mesh has at least this machine");
940 assert!(status.peers[0].is_self, "this machine sorts first");
941
942 // The control-plane lookup rides an unstable `debug` interface, so
943 // what matters is that it produced *something* rather than silently
944 // degrading to Unknown on a working client.
945 let control = Tailscale.control_plane();
946 println!("control plane: {control:?}");
947 assert_ne!(
948 control,
949 ControlPlane::Unknown,
950 "`tailscale debug prefs` no longer yields a ControlURL; the lookup \
951 has degraded and the title will silently drop its suffix"
952 );
953 println!("backend: {} health: {:?}", status.backend_state, status.health);
954 for peer in &status.peers {
955 assert!(!peer.hostname.is_empty(), "every row is identifiable");
956 assert!(
957 peer.last_seen.as_deref() != Some("0001-01-01"),
958 "Go zero time leaked into a last-seen date"
959 );
960 println!(
961 "{:<18} {:<8} {:<9} {}",
962 peer.hostname,
963 peer.os,
964 if peer.online { "online" } else { "offline" },
965 peer.state_label()
966 );
967 }
968 }
969 }
970