Skip to main content

max / alloy

Give mesh a module layer, so the vendor sits behind a seam in a file mesh.rs held the peer model, the Tailscale front, the JSON readers and the peer list with its enrollment overlay in one file. The layers are files now: model, parse, backend, view. The facade re-exports what setup.rs and main.rs name, with the test-only names carrying the allow the sync facade already uses for the same reason. Tests move with their subject.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-08 17:10 UTC
Signed with PGP, not checked
Commit: 17bb28180fdfafc7567cb9f4f7664532ac2ed74b
Parent: d6eaab3
9 files changed, +1758 insertions, -997 deletions
@@ -61,1049 +61,25 @@
61 61 //! still is not selectable, the polkit prompt still has nowhere to go, and a
62 62 //! spawned `tailscale up` nobody waits on has to be reaped by hand.
63 63
64 - use std::collections::HashMap;
65 -
66 - use alloy_tui::{
67 - AlloyBlock, AlloyList, Cursor, Hint, Severity, TextField, Theme, hint, layout, text,
68 - };
69 - use anyhow::{Context, Result};
70 - use ratatui::Frame;
71 - use ratatui::crossterm::event::{KeyCode, KeyEvent};
72 - use ratatui::layout::Rect;
73 - use ratatui::style::{Modifier, Style};
74 - use ratatui::text::{Line, Span};
75 - use serde::Deserialize;
76 -
77 - use crate::cli::{CommandLog, Invocation};
78 - use crate::shell::{Flow, View, block_title, truncate};
79 -
80 - /// Ticks between background refreshes. Peers come and go on the scale of a
81 - /// laptop lid closing, not a keypress, so polling every second would spawn a
82 - /// process per second to learn nothing.
83 - const POLL_TICKS: u64 = 5;
84 -
85 - /// The vendor's admin console.
86 - ///
87 - /// Everything tailnet-wide lives here and nowhere else: deleting a stale node,
88 - /// editing the ACL, minting an auth key. Measured 2026-09-07, the `tailscale`
89 - /// CLI has no verb for any of them, so this key is not a shortcut past a
90 - /// command the log pane could have taught — it is the only door.
91 - ///
92 - /// Not `tailscale web`. That serves localhost:8088, is aimed at NAS
93 - /// appliances, and shows this machine's own state, which is what the peer list
94 - /// behind this key already shows.
95 - const ADMIN_CONSOLE: &str = "https://login.tailscale.com/admin/machines";
96 -
97 - /// Go's zero time, which `tailscale status --json` emits for `LastSeen` on any
98 - /// peer that is currently online. Rendered literally it reads "last seen in
99 - /// year 1".
100 - const GO_ZERO_TIME_PREFIX: &str = "0001-01-01";
101 -
102 - #[derive(Debug, Clone)]
103 - pub(crate) struct Peer {
104 - pub hostname: String,
105 - pub os: String,
106 - /// First tailnet address. Peers can hold both a v4 and a v6; the v4 is
107 - /// what people recognize and type.
108 - pub ip: Option<String>,
109 - pub online: bool,
110 - /// This machine.
111 - pub is_self: bool,
112 - /// Currently carrying this machine's traffic as its exit node.
113 - pub is_exit_node: bool,
114 - /// Advertises itself as available to be an exit node.
115 - pub offers_exit_node: bool,
116 - /// Date last seen, absent while online.
117 - pub last_seen: Option<String>,
118 - }
119 -
120 - impl Peer {
121 - fn severity(&self) -> Severity {
122 - if self.is_exit_node {
123 - Severity::Info
124 - } else if self.online {
125 - Severity::Healthy
126 - } else {
127 - Severity::Warn
128 - }
129 - }
130 -
131 - fn state_label(&self) -> String {
132 - let mut parts = Vec::new();
133 - if self.is_self {
134 - parts.push("this machine".to_string());
135 - }
136 - if self.is_exit_node {
137 - parts.push("exit node".to_string());
138 - } else if self.offers_exit_node {
139 - parts.push("offers exit".to_string());
140 - }
141 - if !self.online
142 - && let Some(seen) = &self.last_seen
143 - {
144 - parts.push(format!("seen {seen}"));
145 - }
146 - parts.join(", ")
147 - }
148 - }
149 -
150 - /// Which control server the mesh is coordinated by.
151 - ///
152 - /// The one place the Tailscale/Headscale distinction is user-visible. A
153 - /// self-hosted tailnet looks identical in every other respect, and "which
154 - /// control plane am I on" is exactly the question someone running Headscale
155 - /// wants answered without dropping to a shell.
156 - #[derive(Debug, Clone, PartialEq, Eq)]
157 - pub(crate) enum ControlPlane {
158 - /// The vendor's own control plane.
159 - Hosted,
160 - /// A self-hosted control server, named by host.
161 - SelfHosted(String),
162 - /// Not determined. The lookup is best-effort (see
163 - /// [`Tailscale::control_plane`]), and an unknown control plane is not
164 - /// worth a warning — the mesh works either way.
165 - Unknown,
166 - }
167 -
168 - impl ControlPlane {
169 - /// Where to send a browser for tailnet-wide administration.
170 - ///
171 - /// `SelfHosted` is a best guess and says so. Headscale serves the control
172 - /// API at that host but no UI at its root, so this may well 404; the
173 - /// alternative considered was refusing to open anything unless the plane
174 - /// is the vendor's, which leaves a self-hosted user with a key that does
175 - /// nothing and no address to try. A guess plus the address it guessed is
176 - /// more use than silence, and one of the several Headscale web UIs may be
177 - /// sitting right there.
178 - fn admin_console(&self) -> Option<(String, Option<String>)> {
179 - match self {
180 - ControlPlane::Hosted => Some((ADMIN_CONSOLE.to_string(), None)),
181 - ControlPlane::SelfHosted(host) => Some((
182 - format!("https://{host}"),
183 - Some(format!(
184 - "{host} is the control server; whether it serves a UI at its root is a guess"
185 - )),
186 - )),
187 - ControlPlane::Unknown => None,
188 - }
189 - }
190 -
191 - /// Suffix for the view title, empty when there is nothing worth saying.
192 - fn label(&self) -> String {
193 - match self {
194 - ControlPlane::SelfHosted(host) => format!(" via {host}"),
195 - ControlPlane::Hosted | ControlPlane::Unknown => String::new(),
196 - }
197 - }
198 - }
199 -
200 - #[derive(Debug, Clone)]
201 - pub(crate) struct MeshStatus {
202 - /// `Running`, `Stopped`, `NeedsLogin`, and friends. Reported verbatim
203 - /// rather than mapped to an enum: it is a Tailscale-owned vocabulary that
204 - /// gains members, and showing an unfamiliar one is better than collapsing
205 - /// it to "unknown".
206 - pub backend_state: String,
207 - pub health: Vec<String>,
208 - pub peers: Vec<Peer>,
209 - }
210 -
211 - impl MeshStatus {
212 - pub(crate) fn is_running(&self) -> bool {
213 - self.backend_state == "Running"
214 - }
215 - }
216 -
217 - pub(crate) trait Backend {
218 - fn name(&self) -> &'static str;
219 - fn status(&self, log: &mut CommandLog) -> Result<MeshStatus>;
220 -
221 - /// Which control server coordinates this mesh.
222 - ///
223 - /// Read once at startup rather than per refresh: changing it requires
224 - /// re-authenticating, so it cannot change under a running view.
225 - fn control_plane(&self) -> ControlPlane {
226 - ControlPlane::Unknown
227 - }
228 -
229 - /// The command that signs this machine into the mesh.
230 - ///
231 - /// Handed back rather than run, because the caller suspends the TUI to run
232 - /// it. See the module docs for why enrollment cannot happen under a live
233 - /// screen: it escalates, and then it blocks on a browser.
234 - ///
235 - /// `login_server` is the control server to join, absent for the vendor's.
236 - fn enroll(&self, login_server: Option<&str>) -> Invocation;
237 -
238 - /// Route traffic through `peer`.
239 - fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()>;
240 -
241 - /// Stop routing through an exit node.
242 - fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()>;
243 - }
244 -
245 - /// Check a login server before spending a suspend on it.
246 - ///
247 - /// Shallow, on the same principle as [`sync`](crate::sync)'s draft validation:
248 - /// whether the host resolves, whether it is really a control server, whether
249 - /// its certificate is good are all questions Tailscale answers, and answers
250 - /// better. What this catches is the bare hostname, because `--login-server`
251 - /// wants a full URL and a user typing the name of their Headscale box is the
252 - /// obvious way to get that wrong. Getting it wrong is more expensive here than
253 - /// in a normal field: the price of a rejected value is the whole console
254 - /// tearing down and rebuilding around the error.
255 - ///
256 - /// Returns the trimmed value, or `None` for the vendor's control plane, which
257 - /// is what an empty field means.
258 - /// Is there a route off this machine, ignoring the mesh's own interface?
259 - ///
260 - /// `tailscale up` reaches a control server before it can print anything. With
261 - /// no route it does not fail, it retries: the console suspends, the user is
262 - /// handed a clean terminal, and nothing appears on it. Measured on fw12,
263 - /// 2026-09-07, after a reinstall took the Wi-Fi credentials with everything
264 - /// else. That is the same shape as the bug `usr/bin/alloy-mesh-up` exists to
265 - /// close and it fails in the same expensive place, *after* the password prompt,
266 - /// one layer further out.
267 - ///
268 - /// A default route rather than a reachability probe. What is being answered is
269 - /// "is it worth handing over the terminal", and a machine with no default route
270 - /// certainly is not; a machine that has one and still cannot reach the control
271 - /// plane has a problem `tailscale up` reports better than a preflight would.
272 - /// The alternative, dialling the control server here, means a network call on a
273 - /// keypress and a second timeout to explain.
274 - ///
275 - /// The mesh's own interfaces are skipped. `tailscale0` carries a default route
276 - /// when an exit node is set, and counting it would let a machine whose only
277 - /// route is the mesh conclude it can go and join the mesh.
278 - fn has_route(table: &str) -> bool {
279 - table
280 - .lines()
281 - .skip(1)
282 - .filter_map(|line| {
283 - let mut cols = line.split_whitespace();
284 - let iface = cols.next()?;
285 - let destination = cols.next()?;
286 - Some((iface, destination))
287 - })
288 - .any(|(iface, destination)| destination == "00000000" && !iface.starts_with("tailscale"))
289 - }
290 -
291 - /// The same question, of this machine.
292 - ///
293 - /// A missing `/proc/net/route` answers yes rather than no. This gate exists to
294 - /// catch a known state, and a console that refuses to enroll because it could
295 - /// not read a procfs file would be inventing a second failure to explain the
296 - /// first.
297 - pub(crate) fn machine_has_route() -> bool {
298 - match std::fs::read_to_string("/proc/net/route") {
299 - Ok(table) => has_route(&table),
300 - Err(_) => true,
301 - }
302 - }
303 -
304 - /// What to say when there is no route, naming the screen that fixes it.
305 - pub(crate) const NO_ROUTE: &str = "no network: signing in needs a route to the control server. Join one in `alloy net`, then enroll.";
306 -
307 - pub(crate) fn validate_login_server(value: &str) -> Result<Option<String>, String> {
308 - let value = value.trim();
309 - if value.is_empty() {
310 - return Ok(None);
311 - }
312 - if !(value.starts_with("https://") || value.starts_with("http://")) {
313 - return Err(format!("a control server is a URL: try https://{value}"));
314 - }
315 - Ok(Some(value.to_string()))
316 - }
317 -
318 - /// Pick a backend: `tailscale` when it answers, the mock otherwise.
319 - ///
320 - /// Tailscale is the only real implementation today, and covers Headscale too
321 - /// since Headscale drives this same client. A different mesh would be another
322 - /// arm here.
323 - pub(crate) fn detect() -> Box<dyn Backend> {
324 - if Invocation::new("tailscale").arg("version").probe() {
325 - Box::new(Tailscale)
326 - } else {
327 - Box::new(Mock)
328 - }
329 - }
330 -
331 - pub(crate) struct Tailscale;
332 -
333 - impl Backend for Tailscale {
334 - fn name(&self) -> &'static str {
335 - "tailscale"
336 - }
337 -
338 - fn status(&self, log: &mut CommandLog) -> Result<MeshStatus> {
339 - let raw = Invocation::new("tailscale")
340 - .args(["status", "--json"])
341 - .run(log)?;
342 - parse_status(&raw)
343 - }
344 -
345 - /// Read the control server from `tailscale debug prefs`.
346 - ///
347 - /// `debug` is explicitly not a stable interface, which is why this is
348 - /// best-effort and every failure path lands on [`ControlPlane::Unknown`]:
349 - /// the command missing, the output not being JSON, the key being renamed.
350 - /// The cost of being wrong is a missing title suffix, so a fragile source
351 - /// is acceptable here in a way it would not be for the peer list. It is
352 - /// also unlogged and runs once, so a `debug` invocation never appears in a
353 - /// pane that teaches commands users should run themselves.
354 - ///
355 - /// There is no stable equivalent. `status --json` carries the tailnet name
356 - /// and MagicDNS suffix but not the control URL, and inferring "self-hosted"
357 - /// from a non-`.ts.net` suffix would be a guess about a configurable value.
358 - fn control_plane(&self) -> ControlPlane {
359 - let Ok(raw) = Invocation::new("tailscale")
360 - .args(["debug", "prefs"])
361 - .capture_quiet()
362 - else {
363 - return ControlPlane::Unknown;
364 - };
365 - let Ok(prefs) = serde_json::from_str::<TsPrefs>(&raw) else {
366 - return ControlPlane::Unknown;
367 - };
368 - classify_control_url(prefs.control_url.as_deref().unwrap_or_default())
369 - }
370 -
371 - /// `run0`, not `sudo` and not bare: `tailscale up` drives a system daemon
372 - /// and is refused without root. run0 is what Alloy names when a user needs
373 - /// root (wiki `alloy-privilege`, "run0-first, not sudo"), and it goes
374 - /// through the same polkit authority every writing view already does, so a
375 - /// fingerprint factor configured once covers this too.
376 - ///
377 - /// No `--pipe`. That flag is for the build scripts, which capture output;
378 - /// here the pty run0 gives its child by default is exactly what is wanted,
379 - /// since the child is about to print a link for a human to read.
380 - ///
381 - /// # Why this drives a script and not `tailscale up`
382 - ///
383 - /// `tailscale up` is a client for a daemon, and
384 - /// `etc/systemd/system-preset/50-alloy.preset` ships `disable
385 - /// tailscaled.service` on purpose. So on a fresh install the daemon is not
386 - /// running, and running the sign-in alone escalates, spends the user's
387 - /// password, and only then fails on a socket nothing is listening on. The
388 - /// order was the bug: the daemon has to come up first, and it needs the same
389 - /// root the sign-in does.
390 - ///
391 - /// `alloy-mesh-up` is that order, in one file so it is one polkit question
392 - /// instead of two. It is a script rather than `run0 sh -c` because
393 - /// [`Invocation`] is argv precisely so a command runs exactly as displayed,
394 - /// and `--login-server` carries a value the user typed; as argv it stays
395 - /// data. The script echoes both commands it runs, so the terminal the user
396 - /// is handed still names `tailscale` and the abstraction conceals nothing.
397 - fn enroll(&self, login_server: Option<&str>) -> Invocation {
398 - let invocation = Invocation::new("run0").arg("alloy-mesh-up");
399 - match login_server {
400 - Some(server) => invocation.arg(format!("--login-server={server}")),
401 - None => invocation,
402 - }
403 - }
404 -
405 - fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()> {
406 - // Addressed by IP rather than hostname: hostnames collide (three
407 - // devices on this tailnet answer to "localhost") and MagicDNS may be
408 - // off, while the tailnet IP is unique and always resolvable.
409 - let ip = peer
410 - .ip
411 - .as_deref()
412 - .context("peer has no mesh address to route through")?;
413 - Invocation::new("tailscale")
414 - .arg("set")
415 - .arg(format!("--exit-node={ip}"))
416 - .run(log)
417 - .map(drop)
418 - }
419 -
420 - fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()> {
421 - Invocation::new("tailscale")
422 - .arg("set")
423 - .arg("--exit-node=")
424 - .run(log)
425 - .map(drop)
426 - }
427 - }
428 -
429 - /// Fixed sample state, for machines without Tailscale.
430 - pub(crate) struct Mock;
431 -
432 - impl Backend for Mock {
433 - fn name(&self) -> &'static str {
434 - "mock"
435 - }
436 -
437 - fn status(&self, log: &mut CommandLog) -> Result<MeshStatus> {
438 - log.record("# no mesh client found; showing mock peers", Severity::Warn);
439 - Ok(MeshStatus {
440 - backend_state: "Running".into(),
441 - health: Vec::new(),
442 - peers: vec![
443 - Peer {
444 - hostname: "fw13".into(),
445 - os: "linux".into(),
446 - ip: Some("100.64.0.1".into()),
447 - online: true,
448 - is_self: true,
449 - is_exit_node: false,
450 - offers_exit_node: false,
451 - last_seen: None,
452 - },
453 - Peer {
454 - hostname: "astra".into(),
455 - os: "linux".into(),
456 - ip: Some("100.64.0.2".into()),
457 - online: true,
458 - is_self: false,
459 - is_exit_node: false,
460 - offers_exit_node: true,
461 - last_seen: None,
462 - },
463 - Peer {
464 - hostname: "phone".into(),
465 - os: "iOS".into(),
466 - ip: Some("100.64.0.3".into()),
467 - online: false,
468 - is_self: false,
469 - is_exit_node: false,
470 - offers_exit_node: false,
471 - last_seen: Some("2026-05-21".into()),
472 - },
473 - ],
474 - })
475 - }
476 -
477 - /// The mock reports `Running`, so the offer screen this belongs to is
478 - /// unreachable under it and nothing calls this in normal use. It still has
479 - /// to return something the shell could hand to a suspend, so it returns the
480 - /// command that does nothing and succeeds.
481 - fn enroll(&self, _login_server: Option<&str>) -> Invocation {
482 - Invocation::new("true")
483 - }
484 -
485 - // The mock is a display fixture, not a simulator.
486 - fn set_exit_node(&self, _peer: &Peer, log: &mut CommandLog) -> Result<()> {
487 - log.record("# mock backend: exit node unchanged", Severity::Warn);
488 - Ok(())
489 - }
490 -
491 - fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()> {
492 - log.record("# mock backend: exit node unchanged", Severity::Warn);
493 - Ok(())
494 - }
495 - }
496 -
497 - // ---- tailscale status --json ----
498 -
499 - #[derive(Deserialize)]
500 - struct TsStatus {
501 - #[serde(default)]
502 - #[serde(rename = "BackendState")]
503 - backend_state: String,
504 - #[serde(default)]
505 - #[serde(rename = "Health")]
506 - health: Option<Vec<String>>,
507 - #[serde(rename = "Self")]
508 - self_node: Option<TsPeer>,
509 - #[serde(default)]
510 - #[serde(rename = "Peer")]
511 - peer: HashMap<String, TsPeer>,
512 - }
513 -
514 - #[derive(Deserialize)]
515 - struct TsPrefs {
516 - #[serde(rename = "ControlURL")]
517 - control_url: Option<String>,
518 - }
519 -
520 - /// Classify a Tailscale `ControlURL` as vendor-hosted or self-hosted.
521 - ///
522 - /// An empty value means the default, which is how a client that has never had
523 - /// one set reports it.
524 - fn classify_control_url(url: &str) -> ControlPlane {
525 - let url = url.trim();
526 - if url.is_empty() {
527 - return ControlPlane::Hosted;
528 - }
529 - // Strip scheme, then any path/port, leaving the host.
530 - let host = url
531 - .split_once("://")
532 - .map_or(url, |(_, rest)| rest)
533 - .split(['/', ':'])
534 - .next()
535 - .unwrap_or_default();
536 -
537 - if host.is_empty() {
538 - return ControlPlane::Unknown;
539 - }
540 - // Matched on a dot-anchored suffix rather than `contains`, so a
541 - // self-hosted `headscale.tailscale.com.example.org` is not mistaken for
542 - // the vendor's.
543 - if host == "tailscale.com" || host.ends_with(".tailscale.com") {
544 - ControlPlane::Hosted
545 - } else {
546 - ControlPlane::SelfHosted(host.to_string())
547 - }
548 - }
549 -
550 - #[derive(Deserialize)]
551 - struct TsPeer {
552 - #[serde(default)]
553 - #[serde(rename = "HostName")]
554 - host_name: String,
555 - #[serde(default)]
556 - #[serde(rename = "OS")]
557 - os: String,
558 - #[serde(default)]
559 - #[serde(rename = "TailscaleIPs")]
560 - tailscale_ips: Option<Vec<String>>,
Lines truncated
@@ -1,0 +1,291 @@
1 + //! The backend seam: a trait, the Tailscale front, and the mock behind it.
2 +
3 + use alloy_tui::Severity;
4 + use anyhow::{Context, Result};
5 +
6 + use super::model::{ControlPlane, MeshStatus, Peer};
7 + use super::parse::{TsPrefs, classify_control_url, parse_status};
8 + use crate::cli::{CommandLog, Invocation};
9 +
10 + pub(crate) trait Backend {
11 + fn name(&self) -> &'static str;
12 + fn status(&self, log: &mut CommandLog) -> Result<MeshStatus>;
13 +
14 + /// Which control server coordinates this mesh.
15 + ///
16 + /// Read once at startup rather than per refresh: changing it requires
17 + /// re-authenticating, so it cannot change under a running view.
18 + fn control_plane(&self) -> ControlPlane {
19 + ControlPlane::Unknown
20 + }
21 +
22 + /// The command that signs this machine into the mesh.
23 + ///
24 + /// Handed back rather than run, because the caller suspends the TUI to run
25 + /// it. See the module docs for why enrollment cannot happen under a live
26 + /// screen: it escalates, and then it blocks on a browser.
27 + ///
28 + /// `login_server` is the control server to join, absent for the vendor's.
29 + fn enroll(&self, login_server: Option<&str>) -> Invocation;
30 +
31 + /// Route traffic through `peer`.
32 + fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()>;
33 +
34 + /// Stop routing through an exit node.
35 + fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()>;
36 + }
37 +
38 + /// Check a login server before spending a suspend on it.
39 + ///
40 + /// Shallow, on the same principle as [`sync`](crate::sync)'s draft validation:
41 + /// whether the host resolves, whether it is really a control server, whether
42 + /// its certificate is good are all questions Tailscale answers, and answers
43 + /// better. What this catches is the bare hostname, because `--login-server`
44 + /// wants a full URL and a user typing the name of their Headscale box is the
45 + /// obvious way to get that wrong. Getting it wrong is more expensive here than
46 + /// in a normal field: the price of a rejected value is the whole console
47 + /// tearing down and rebuilding around the error.
48 + ///
49 + /// Returns the trimmed value, or `None` for the vendor's control plane, which
50 + /// is what an empty field means.
51 + /// Is there a route off this machine, ignoring the mesh's own interface?
52 + ///
53 + /// `tailscale up` reaches a control server before it can print anything. With
54 + /// no route it does not fail, it retries: the console suspends, the user is
55 + /// handed a clean terminal, and nothing appears on it. Measured on fw12,
56 + /// 2026-09-07, after a reinstall took the Wi-Fi credentials with everything
57 + /// else. That is the same shape as the bug `usr/bin/alloy-mesh-up` exists to
58 + /// close and it fails in the same expensive place, *after* the password prompt,
59 + /// one layer further out.
60 + ///
61 + /// A default route rather than a reachability probe. What is being answered is
62 + /// "is it worth handing over the terminal", and a machine with no default route
63 + /// certainly is not; a machine that has one and still cannot reach the control
64 + /// plane has a problem `tailscale up` reports better than a preflight would.
65 + /// The alternative, dialling the control server here, means a network call on a
66 + /// keypress and a second timeout to explain.
67 + ///
68 + /// The mesh's own interfaces are skipped. `tailscale0` carries a default route
69 + /// when an exit node is set, and counting it would let a machine whose only
70 + /// route is the mesh conclude it can go and join the mesh.
71 + fn has_route(table: &str) -> bool {
72 + table
73 + .lines()
74 + .skip(1)
75 + .filter_map(|line| {
76 + let mut cols = line.split_whitespace();
77 + let iface = cols.next()?;
78 + let destination = cols.next()?;
79 + Some((iface, destination))
80 + })
81 + .any(|(iface, destination)| destination == "00000000" && !iface.starts_with("tailscale"))
82 + }
83 +
84 + /// The same question, of this machine.
85 + ///
86 + /// A missing `/proc/net/route` answers yes rather than no. This gate exists to
87 + /// catch a known state, and a console that refuses to enroll because it could
88 + /// not read a procfs file would be inventing a second failure to explain the
89 + /// first.
90 + pub(crate) fn machine_has_route() -> bool {
91 + match std::fs::read_to_string("/proc/net/route") {
92 + Ok(table) => has_route(&table),
93 + Err(_) => true,
94 + }
95 + }
96 +
97 + /// What to say when there is no route, naming the screen that fixes it.
98 + pub(crate) const NO_ROUTE: &str = "no network: signing in needs a route to the control server. Join one in `alloy net`, then enroll.";
99 +
100 + pub(crate) fn validate_login_server(value: &str) -> Result<Option<String>, String> {
101 + let value = value.trim();
102 + if value.is_empty() {
103 + return Ok(None);
104 + }
105 + if !(value.starts_with("https://") || value.starts_with("http://")) {
106 + return Err(format!("a control server is a URL: try https://{value}"));
107 + }
108 + Ok(Some(value.to_string()))
109 + }
110 +
111 + /// Pick a backend: `tailscale` when it answers, the mock otherwise.
112 + ///
113 + /// Tailscale is the only real implementation today, and covers Headscale too
114 + /// since Headscale drives this same client. A different mesh would be another
115 + /// arm here.
116 + pub(crate) fn detect() -> Box<dyn Backend> {
117 + if Invocation::new("tailscale").arg("version").probe() {
118 + Box::new(Tailscale)
119 + } else {
120 + Box::new(Mock)
121 + }
122 + }
123 +
124 + pub(crate) struct Tailscale;
125 +
126 + impl Backend for Tailscale {
127 + fn name(&self) -> &'static str {
128 + "tailscale"
129 + }
130 +
131 + fn status(&self, log: &mut CommandLog) -> Result<MeshStatus> {
132 + let raw = Invocation::new("tailscale")
133 + .args(["status", "--json"])
134 + .run(log)?;
135 + parse_status(&raw)
136 + }
137 +
138 + /// Read the control server from `tailscale debug prefs`.
139 + ///
140 + /// `debug` is explicitly not a stable interface, which is why this is
141 + /// best-effort and every failure path lands on [`ControlPlane::Unknown`]:
142 + /// the command missing, the output not being JSON, the key being renamed.
143 + /// The cost of being wrong is a missing title suffix, so a fragile source
144 + /// is acceptable here in a way it would not be for the peer list. It is
145 + /// also unlogged and runs once, so a `debug` invocation never appears in a
146 + /// pane that teaches commands users should run themselves.
147 + ///
148 + /// There is no stable equivalent. `status --json` carries the tailnet name
149 + /// and MagicDNS suffix but not the control URL, and inferring "self-hosted"
150 + /// from a non-`.ts.net` suffix would be a guess about a configurable value.
151 + fn control_plane(&self) -> ControlPlane {
152 + let Ok(raw) = Invocation::new("tailscale")
153 + .args(["debug", "prefs"])
154 + .capture_quiet()
155 + else {
156 + return ControlPlane::Unknown;
157 + };
158 + let Ok(prefs) = serde_json::from_str::<TsPrefs>(&raw) else {
159 + return ControlPlane::Unknown;
160 + };
161 + classify_control_url(prefs.control_url.as_deref().unwrap_or_default())
162 + }
163 +
164 + /// `run0`, not `sudo` and not bare: `tailscale up` drives a system daemon
165 + /// and is refused without root. run0 is what Alloy names when a user needs
166 + /// root (wiki `alloy-privilege`, "run0-first, not sudo"), and it goes
167 + /// through the same polkit authority every writing view already does, so a
168 + /// fingerprint factor configured once covers this too.
169 + ///
170 + /// No `--pipe`. That flag is for the build scripts, which capture output;
171 + /// here the pty run0 gives its child by default is exactly what is wanted,
172 + /// since the child is about to print a link for a human to read.
173 + ///
174 + /// # Why this drives a script and not `tailscale up`
175 + ///
176 + /// `tailscale up` is a client for a daemon, and
177 + /// `etc/systemd/system-preset/50-alloy.preset` ships `disable
178 + /// tailscaled.service` on purpose. So on a fresh install the daemon is not
179 + /// running, and running the sign-in alone escalates, spends the user's
180 + /// password, and only then fails on a socket nothing is listening on. The
181 + /// order was the bug: the daemon has to come up first, and it needs the same
182 + /// root the sign-in does.
183 + ///
184 + /// `alloy-mesh-up` is that order, in one file so it is one polkit question
185 + /// instead of two. It is a script rather than `run0 sh -c` because
186 + /// [`Invocation`] is argv precisely so a command runs exactly as displayed,
187 + /// and `--login-server` carries a value the user typed; as argv it stays
188 + /// data. The script echoes both commands it runs, so the terminal the user
189 + /// is handed still names `tailscale` and the abstraction conceals nothing.
190 + fn enroll(&self, login_server: Option<&str>) -> Invocation {
191 + let invocation = Invocation::new("run0").arg("alloy-mesh-up");
192 + match login_server {
193 + Some(server) => invocation.arg(format!("--login-server={server}")),
194 + None => invocation,
195 + }
196 + }
197 +
198 + fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()> {
199 + // Addressed by IP rather than hostname: hostnames collide (three
200 + // devices on this tailnet answer to "localhost") and MagicDNS may be
201 + // off, while the tailnet IP is unique and always resolvable.
202 + let ip = peer
203 + .ip
204 + .as_deref()
205 + .context("peer has no mesh address to route through")?;
206 + Invocation::new("tailscale")
207 + .arg("set")
208 + .arg(format!("--exit-node={ip}"))
209 + .run(log)
210 + .map(drop)
211 + }
212 +
213 + fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()> {
214 + Invocation::new("tailscale")
215 + .arg("set")
216 + .arg("--exit-node=")
217 + .run(log)
218 + .map(drop)
219 + }
220 + }
221 +
222 + /// Fixed sample state, for machines without Tailscale.
223 + pub(crate) struct Mock;
224 +
225 + impl Backend for Mock {
226 + fn name(&self) -> &'static str {
227 + "mock"
228 + }
229 +
230 + fn status(&self, log: &mut CommandLog) -> Result<MeshStatus> {
231 + log.record("# no mesh client found; showing mock peers", Severity::Warn);
232 + Ok(MeshStatus {
233 + backend_state: "Running".into(),
234 + health: Vec::new(),
235 + peers: vec![
236 + Peer {
237 + hostname: "fw13".into(),
238 + os: "linux".into(),
239 + ip: Some("100.64.0.1".into()),
240 + online: true,
241 + is_self: true,
242 + is_exit_node: false,
243 + offers_exit_node: false,
244 + last_seen: None,
245 + },
246 + Peer {
247 + hostname: "astra".into(),
248 + os: "linux".into(),
249 + ip: Some("100.64.0.2".into()),
250 + online: true,
251 + is_self: false,
252 + is_exit_node: false,
253 + offers_exit_node: true,
254 + last_seen: None,
255 + },
256 + Peer {
257 + hostname: "phone".into(),
258 + os: "iOS".into(),
259 + ip: Some("100.64.0.3".into()),
260 + online: false,
261 + is_self: false,
262 + is_exit_node: false,
263 + offers_exit_node: false,
264 + last_seen: Some("2026-05-21".into()),
265 + },
266 + ],
267 + })
268 + }
269 +
270 + /// The mock reports `Running`, so the offer screen this belongs to is
271 + /// unreachable under it and nothing calls this in normal use. It still has
272 + /// to return something the shell could hand to a suspend, so it returns the
273 + /// command that does nothing and succeeds.
274 + fn enroll(&self, _login_server: Option<&str>) -> Invocation {
275 + Invocation::new("true")
276 + }
277 +
278 + // The mock is a display fixture, not a simulator.
279 + fn set_exit_node(&self, _peer: &Peer, log: &mut CommandLog) -> Result<()> {
280 + log.record("# mock backend: exit node unchanged", Severity::Warn);
281 + Ok(())
282 + }
283 +
284 + fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()> {
285 + log.record("# mock backend: exit node unchanged", Severity::Warn);
286 + Ok(())
287 + }
288 + }
289 +
290 + #[cfg(test)]
291 + mod tests;
@@ -1,0 +1,149 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + #[test]
6 + fn an_unset_server_means_the_vendor_plane() {
7 + assert_eq!(validate_login_server(""), Ok(None));
8 + assert_eq!(validate_login_server(" "), Ok(None));
9 + }
10 +
11 + #[test]
12 + fn a_server_url_is_trimmed_and_kept() {
13 + assert_eq!(
14 + validate_login_server(" https://hs.example.org "),
15 + Ok(Some("https://hs.example.org".into()))
16 + );
17 + // http is allowed: a Headscale on a tailnet-internal address is a real
18 + // deployment, and refusing it would be a policy this screen has no
19 + // standing to set.
20 + assert_eq!(
21 + validate_login_server("http://10.0.0.5:8080"),
22 + Ok(Some("http://10.0.0.5:8080".into()))
23 + );
24 + }
25 +
26 + // The error a bare hostname earns has to say what to type instead. It is
27 + // the whole reason the check exists.
28 + #[test]
29 + fn a_bare_hostname_is_refused_with_the_fix() {
30 + let error = validate_login_server("hs.example.org").unwrap_err();
31 + assert!(error.contains("https://hs.example.org"), "got: {error}");
32 + }
33 +
34 + // The daemon and the sign-in go under one escalation, which is why this is a
35 + // script and not `tailscale up`. See `Tailscale::enroll`: the preset ships
36 + // tailscaled disabled, so running the sign-in alone spends the user's password
37 + // and then fails on a socket nothing is listening on.
38 + #[test]
39 + fn enrollment_runs_the_mesh_helper_under_run0() {
40 + assert_eq!(Tailscale.enroll(None).display(), "run0 alloy-mesh-up");
41 + assert_eq!(
42 + Tailscale.enroll(Some("https://hs.example.org")).display(),
43 + "run0 alloy-mesh-up --login-server=https://hs.example.org"
44 + );
45 + }
46 +
47 + // The server is carried as one argv element rather than spliced into a command
48 + // string. `validate_login_server` is what stops a hostile value reaching here,
49 + // but the carrier is why it cannot matter: argv stays data all the way to
50 + // `tailscale up "$@"` in the helper. Asserted with a value that would split on
51 + // a shell word boundary, since a quoted display is what a single argument that
52 + // needs quoting looks like.
53 + #[test]
54 + fn the_login_server_stays_one_argument() {
55 + assert_eq!(
56 + Tailscale.enroll(Some("https://hs.example.org x")).display(),
57 + "run0 alloy-mesh-up '--login-server=https://hs.example.org x'"
58 + );
59 + }
60 +
61 + /// Parse this machine's real tailnet.
62 + ///
63 + /// Ignored by default: needs Tailscale installed and logged in, and what
64 + /// it finds depends on the tailnet. Run it when touching the parser.
65 + #[test]
66 + #[ignore = "requires a logged-in Tailscale"]
67 + fn parses_this_machines_real_tailnet() {
68 + let mut log = CommandLog::new();
69 + let status = Tailscale.status(&mut log).expect("tailscale should answer");
70 +
71 + assert!(!status.peers.is_empty(), "a mesh has at least this machine");
72 + assert!(status.peers[0].is_self, "this machine sorts first");
73 +
74 + // The control-plane lookup rides an unstable `debug` interface, so
75 + // what matters is that it produced *something* rather than silently
76 + // degrading to Unknown on a working client.
77 + let control = Tailscale.control_plane();
78 + println!("control plane: {control:?}");
79 + assert_ne!(
80 + control,
81 + ControlPlane::Unknown,
82 + "`tailscale debug prefs` no longer yields a ControlURL; the lookup \
83 + has degraded and the title will silently drop its suffix"
84 + );
85 + println!(
86 + "backend: {} health: {:?}",
87 + status.backend_state, status.health
88 + );
89 + for peer in &status.peers {
90 + assert!(!peer.hostname.is_empty(), "every row is identifiable");
91 + assert!(
92 + peer.last_seen.as_deref() != Some("0001-01-01"),
93 + "Go zero time leaked into a last-seen date"
94 + );
95 + println!(
96 + "{:<18} {:<8} {:<9} {}",
97 + peer.hostname,
98 + peer.os,
99 + if peer.online { "online" } else { "offline" },
100 + peer.state_label()
101 + );
102 + }
103 + }
104 +
105 + /// A machine on Wi-Fi has a route, and the parse finds it.
106 + ///
107 + /// The fixture is `/proc/net/route` as fw12 reported it on 2026-09-07, tabs and
108 + /// all, rather than a tidied version: the columns are whitespace-separated in
109 + /// the file and a test over a prettified copy would not prove the split works.
110 + #[test]
111 + fn a_default_route_is_a_route() {
112 + let table = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
113 + wlp0s20f3\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n\
114 + wlp0s20f3\t0000A8C0\t00000000\t0001\t0\t0\t600\t00FFFFFF\t0\t0\t0\n";
115 + assert!(has_route(table));
116 + }
117 +
118 + /// A machine with interfaces but nothing to the world has none.
119 + #[test]
120 + fn a_subnet_route_alone_is_not_a_route() {
121 + let table = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
122 + wlp0s20f3\t0000A8C0\t00000000\t0001\t0\t0\t600\t00FFFFFF\t0\t0\t0\n";
123 + assert!(!has_route(table));
124 + }
125 +
126 + /// The mesh's own default route does not count as a way to reach the mesh.
127 + ///
128 + /// This is the case the filter exists for: `tailscale0` carries a default route
129 + /// when an exit node is set, and a machine whose only route is the mesh cannot
130 + /// use it to go and join the mesh.
131 + #[test]
132 + fn the_mesh_interface_does_not_count_as_a_route() {
133 + let table = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
134 + tailscale0\t00000000\t00000000\t0001\t0\t0\t0\t00000000\t0\t0\t0\n";
135 + assert!(!has_route(table));
136 + let with_wifi = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
137 + tailscale0\t00000000\t00000000\t0001\t0\t0\t0\t00000000\t0\t0\t0\n\
138 + wlp0s20f3\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
139 + assert!(has_route(with_wifi));
140 + }
141 +
142 + /// A header with no rows, and a file with nothing in it at all.
143 + #[test]
144 + fn an_empty_table_is_not_a_route() {
145 + assert!(!has_route(
146 + "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n"
147 + ));
148 + assert!(!has_route(""));
149 + }
@@ -1,0 +1,148 @@
1 + //! What the screen shows: a peer, the control plane it answers to, and the
2 + //! status the two arrive in.
3 +
4 + use alloy_tui::Severity;
5 +
6 + /// The vendor's admin console.
7 + ///
8 + /// Everything tailnet-wide lives here and nowhere else: deleting a stale node,
9 + /// editing the ACL, minting an auth key. Measured 2026-09-07, the `tailscale`
10 + /// CLI has no verb for any of them, so this key is not a shortcut past a
11 + /// command the log pane could have taught — it is the only door.
12 + ///
13 + /// Not `tailscale web`. That serves localhost:8088, is aimed at NAS
14 + /// appliances, and shows this machine's own state, which is what the peer list
15 + /// behind this key already shows.
16 + const ADMIN_CONSOLE: &str = "https://login.tailscale.com/admin/machines";
17 +
18 + #[derive(Debug, Clone)]
19 + pub(crate) struct Peer {
20 + pub hostname: String,
21 + pub os: String,
22 + /// First tailnet address. Peers can hold both a v4 and a v6; the v4 is
23 + /// what people recognize and type.
24 + pub ip: Option<String>,
25 + pub online: bool,
26 + /// This machine.
27 + pub is_self: bool,
28 + /// Currently carrying this machine's traffic as its exit node.
29 + pub is_exit_node: bool,
30 + /// Advertises itself as available to be an exit node.
31 + pub offers_exit_node: bool,
32 + /// Date last seen, absent while online.
33 + pub last_seen: Option<String>,
34 + }
35 +
36 + impl Peer {
37 + pub(super) fn severity(&self) -> Severity {
38 + if self.is_exit_node {
39 + Severity::Info
40 + } else if self.online {
41 + Severity::Healthy
42 + } else {
43 + Severity::Warn
44 + }
45 + }
46 +
47 + pub(super) fn state_label(&self) -> String {
48 + let mut parts = Vec::new();
49 + if self.is_self {
50 + parts.push("this machine".to_string());
51 + }
52 + if self.is_exit_node {
53 + parts.push("exit node".to_string());
54 + } else if self.offers_exit_node {
55 + parts.push("offers exit".to_string());
56 + }
57 + if !self.online
58 + && let Some(seen) = &self.last_seen
59 + {
60 + parts.push(format!("seen {seen}"));
61 + }
62 + parts.join(", ")
63 + }
64 + }
65 +
66 + /// Which control server the mesh is coordinated by.
67 + ///
68 + /// The one place the Tailscale/Headscale distinction is user-visible. A
69 + /// self-hosted tailnet looks identical in every other respect, and "which
70 + /// control plane am I on" is exactly the question someone running Headscale
71 + /// wants answered without dropping to a shell.
72 + #[derive(Debug, Clone, PartialEq, Eq)]
73 + pub(crate) enum ControlPlane {
74 + /// The vendor's own control plane.
75 + Hosted,
76 + /// A self-hosted control server, named by host.
77 + SelfHosted(String),
78 + /// Not determined. The lookup is best-effort (see
79 + /// [`Tailscale::control_plane`]), and an unknown control plane is not
80 + /// worth a warning — the mesh works either way.
81 + Unknown,
82 + }
83 +
84 + impl ControlPlane {
85 + /// Where to send a browser for tailnet-wide administration.
86 + ///
87 + /// `SelfHosted` is a best guess and says so. Headscale serves the control
88 + /// API at that host but no UI at its root, so this may well 404; the
89 + /// alternative considered was refusing to open anything unless the plane
90 + /// is the vendor's, which leaves a self-hosted user with a key that does
91 + /// nothing and no address to try. A guess plus the address it guessed is
92 + /// more use than silence, and one of the several Headscale web UIs may be
93 + /// sitting right there.
94 + pub(super) fn admin_console(&self) -> Option<(String, Option<String>)> {
95 + match self {
96 + ControlPlane::Hosted => Some((ADMIN_CONSOLE.to_string(), None)),
97 + ControlPlane::SelfHosted(host) => Some((
98 + format!("https://{host}"),
99 + Some(format!(
100 + "{host} is the control server; whether it serves a UI at its root is a guess"
101 + )),
102 + )),
103 + ControlPlane::Unknown => None,
104 + }
105 + }
106 +
107 + /// Suffix for the view title, empty when there is nothing worth saying.
108 + pub(super) fn label(&self) -> String {
109 + match self {
110 + ControlPlane::SelfHosted(host) => format!(" via {host}"),
111 + ControlPlane::Hosted | ControlPlane::Unknown => String::new(),
112 + }
113 + }
114 + }
115 +
116 + #[derive(Debug, Clone)]
117 + pub(crate) struct MeshStatus {
118 + /// `Running`, `Stopped`, `NeedsLogin`, and friends. Reported verbatim
119 + /// rather than mapped to an enum: it is a Tailscale-owned vocabulary that
120 + /// gains members, and showing an unfamiliar one is better than collapsing
121 + /// it to "unknown".
122 + pub backend_state: String,
123 + pub health: Vec<String>,
124 + pub peers: Vec<Peer>,
125 + }
126 +
127 + impl MeshStatus {
128 + pub(crate) fn is_running(&self) -> bool {
129 + self.backend_state == "Running"
130 + }
131 + }
132 +
133 + #[cfg(test)]
134 + mod tests {
135 + use super::*;
136 +
137 + // Only a self-hosted plane is worth title space; the other two say nothing
138 + // rather than "(hosted)" on every screen.
139 + #[test]
140 + fn only_a_self_hosted_plane_earns_a_title_suffix() {
141 + assert_eq!(ControlPlane::Hosted.label(), "");
142 + assert_eq!(ControlPlane::Unknown.label(), "");
143 + assert_eq!(
144 + ControlPlane::SelfHosted("hs.example.org".into()).label(),
145 + " via hs.example.org"
146 + );
147 + }
148 + }
@@ -1,0 +1,175 @@
1 + //! Readers for `tailscale status --json` and `tailscale debug prefs`.
2 + //!
3 + //! Pure: captured stdout in, model types out. The vendor's JSON is a wide
4 + //! surface and only a handful of its fields reach the screen, so the wire
5 + //! types here are deliberately partial.
6 +
7 + use std::collections::HashMap;
8 +
9 + use anyhow::{Context, Result};
10 + use serde::Deserialize;
11 +
12 + use super::model::{ControlPlane, MeshStatus, Peer};
13 +
14 + /// Go's zero time, which `tailscale status --json` emits for `LastSeen` on any
15 + /// peer that is currently online. Rendered literally it reads "last seen in
16 + /// year 1".
17 + const GO_ZERO_TIME_PREFIX: &str = "0001-01-01";
18 +
19 + // ---- tailscale status --json ----
20 +
21 + #[derive(Deserialize)]
22 + struct TsStatus {
23 + #[serde(default)]
24 + #[serde(rename = "BackendState")]
25 + backend_state: String,
26 + #[serde(default)]
27 + #[serde(rename = "Health")]
28 + health: Option<Vec<String>>,
29 + #[serde(rename = "Self")]
30 + self_node: Option<TsPeer>,
31 + #[serde(default)]
32 + #[serde(rename = "Peer")]
33 + peer: HashMap<String, TsPeer>,
34 + }
35 +
36 + #[derive(Deserialize)]
37 + pub(super) struct TsPrefs {
38 + #[serde(rename = "ControlURL")]
39 + pub(super) control_url: Option<String>,
40 + }
41 +
42 + /// Classify a Tailscale `ControlURL` as vendor-hosted or self-hosted.
43 + ///
44 + /// An empty value means the default, which is how a client that has never had
45 + /// one set reports it.
46 + pub(super) fn classify_control_url(url: &str) -> ControlPlane {
47 + let url = url.trim();
48 + if url.is_empty() {
49 + return ControlPlane::Hosted;
50 + }
51 + // Strip scheme, then any path/port, leaving the host.
52 + let host = url
53 + .split_once("://")
54 + .map_or(url, |(_, rest)| rest)
55 + .split(['/', ':'])
56 + .next()
57 + .unwrap_or_default();
58 +
59 + if host.is_empty() {
60 + return ControlPlane::Unknown;
61 + }
62 + // Matched on a dot-anchored suffix rather than `contains`, so a
63 + // self-hosted `headscale.tailscale.com.example.org` is not mistaken for
64 + // the vendor's.
65 + if host == "tailscale.com" || host.ends_with(".tailscale.com") {
66 + ControlPlane::Hosted
67 + } else {
68 + ControlPlane::SelfHosted(host.to_string())
69 + }
70 + }
71 +
72 + #[derive(Deserialize)]
73 + struct TsPeer {
74 + #[serde(default)]
75 + #[serde(rename = "HostName")]
76 + host_name: String,
77 + #[serde(default)]
78 + #[serde(rename = "OS")]
79 + os: String,
80 + #[serde(default)]
81 + #[serde(rename = "TailscaleIPs")]
82 + tailscale_ips: Option<Vec<String>>,
83 + #[serde(default)]
84 + #[serde(rename = "Online")]
85 + online: bool,
86 + #[serde(default)]
87 + #[serde(rename = "ExitNode")]
88 + exit_node: bool,
89 + #[serde(default)]
90 + #[serde(rename = "ExitNodeOption")]
91 + exit_node_option: bool,
92 + #[serde(default)]
93 + #[serde(rename = "LastSeen")]
94 + last_seen: Option<String>,
95 + }
96 +
97 + impl TsPeer {
98 + fn into_peer(self, is_self: bool) -> Peer {
99 + Peer {
100 + // A peer that reports no hostname still needs to occupy an
101 + // identifiable row.
102 + hostname: if self.host_name.is_empty() {
103 + "(unnamed)".to_string()
104 + } else {
105 + self.host_name
106 + },
107 + os: self.os,
108 + ip: preferred_ip(self.tailscale_ips.as_deref().unwrap_or_default()),
109 + // The local node reports `Online: false` in some backend states
110 + // even while it is plainly the machine running the command.
111 + online: self.online || is_self,
112 + is_self,
113 + is_exit_node: self.exit_node,
114 + offers_exit_node: self.exit_node_option,
115 + last_seen: self.last_seen.as_deref().and_then(last_seen_date),
116 + }
117 + }
118 + }
119 +
120 + pub(super) fn parse_status(raw: &str) -> Result<MeshStatus> {
121 + let parsed: TsStatus = serde_json::from_str(raw).context("tailscale emitted invalid JSON")?;
122 +
123 + let mut peers: Vec<Peer> = parsed
124 + .peer
125 + .into_values()
126 + .map(|peer| peer.into_peer(false))
127 + .collect();
128 +
129 + // Sorted for a stable screen: this machine first, then online peers, then
130 + // by name. `Peer` arrives as a map keyed by public key, so iteration order
131 + // is arbitrary and the list would otherwise reshuffle on every refresh —
132 + // with the cursor sitting on whatever landed under it.
133 + peers.sort_by(|a, b| {
134 + b.online
135 + .cmp(&a.online)
136 + .then_with(|| a.hostname.to_lowercase().cmp(&b.hostname.to_lowercase()))
137 + });
138 + if let Some(self_node) = parsed.self_node {
139 + peers.insert(0, self_node.into_peer(true));
140 + }
141 +
142 + Ok(MeshStatus {
143 + backend_state: parsed.backend_state,
144 + health: parsed.health.unwrap_or_default(),
145 + peers,
146 + })
147 + }
148 +
149 + /// Pick the address to show: IPv4 when there is one.
150 + ///
151 + /// Tailscale hands out both, v4 first in practice, but ordering is not
152 + /// promised. The v4 is the one people recognize and type.
153 + fn preferred_ip(ips: &[String]) -> Option<String> {
154 + ips.iter()
155 + .find(|ip| !ip.contains(':'))
156 + .or_else(|| ips.first())
157 + .cloned()
158 + }
159 +
160 + /// Date portion of a `LastSeen` timestamp, or `None` when it is Go's zero
161 + /// time.
162 + ///
163 + /// Online peers carry the zero value rather than omitting the field, so this
164 + /// has to be filtered rather than trusted. Only the date is kept: a relative
165 + /// "3 days ago" would need a date library for something a column this narrow
166 + /// cannot show anyway.
167 + fn last_seen_date(raw: &str) -> Option<String> {
168 + if raw.is_empty() || raw.starts_with(GO_ZERO_TIME_PREFIX) {
169 + return None;
170 + }
171 + raw.split('T').next().map(str::to_string)
172 + }
173 +
174 + #[cfg(test)]
175 + mod tests;
@@ -1,0 +1,187 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + // Shaped from this machine's real `tailscale status --json`, trimmed to
6 + // the fields the parser reads. The awkward parts are real: an online peer
7 + // carrying Go's zero time for LastSeen, a device named "localhost", and
8 + // the Peer map keyed by public key.
9 + const STATUS: &str = r#"{
10 + "Version": "1.90.0",
11 + "BackendState": "Running",
12 + "Health": [],
13 + "MagicDNSSuffix": "example-tailnet.ts.net",
14 + "Self": {
15 + "HostName": "fw13", "OS": "linux",
16 + "TailscaleIPs": ["100.103.89.95", "fd7a:115c:a1e0::af3b:595f"],
17 + "Online": true, "ExitNode": false, "ExitNodeOption": false,
18 + "LastSeen": "0001-01-01T00:00:00Z"
19 + },
20 + "Peer": {
21 + "nodekey:aaa": {
22 + "HostName": "localhost", "OS": "iOS",
23 + "TailscaleIPs": ["100.90.1.2"],
24 + "Online": false, "ExitNode": false, "ExitNodeOption": false,
25 + "LastSeen": "2026-05-21T23:27:30.1Z"
26 + },
27 + "nodekey:bbb": {
28 + "HostName": "astra", "OS": "linux",
29 + "TailscaleIPs": ["100.80.3.4"],
30 + "Online": true, "ExitNode": false, "ExitNodeOption": true,
31 + "LastSeen": "0001-01-01T00:00:00Z"
32 + },
33 + "nodekey:ccc": {
34 + "HostName": "htpy-1", "OS": "linux",
35 + "TailscaleIPs": ["100.70.5.6"],
36 + "Online": true, "ExitNode": false, "ExitNodeOption": false,
37 + "LastSeen": "0001-01-01T00:00:00Z"
38 + }
39 + }
40 + }"#;
41 +
42 + #[test]
43 + fn parses_self_and_peers() {
44 + let status = parse_status(STATUS).unwrap();
45 + assert_eq!(status.backend_state, "Running");
46 + assert!(status.health.is_empty());
47 + assert_eq!(status.peers.len(), 4, "self plus three peers");
48 + }
49 +
50 + // Self first, then online peers by name, then offline. `Peer` is a map, so
51 + // without an explicit sort the list reshuffles on every refresh with the
52 + // cursor sitting on whatever lands under it.
53 + #[test]
54 + fn peers_are_ordered_self_then_online_then_by_name() {
55 + let status = parse_status(STATUS).unwrap();
56 + let names: Vec<&str> = status.peers.iter().map(|p| p.hostname.as_str()).collect();
57 + assert_eq!(names, ["fw13", "astra", "htpy-1", "localhost"]);
58 + assert!(status.peers[0].is_self);
59 + }
60 +
61 + // Go's zero time means "currently online", not "last seen in year 1".
62 + #[test]
63 + fn go_zero_time_is_not_a_last_seen_date() {
64 + assert_eq!(last_seen_date("0001-01-01T00:00:00Z"), None);
65 + assert_eq!(last_seen_date(""), None);
66 + assert_eq!(
67 + last_seen_date("2026-05-21T23:27:30.1Z").as_deref(),
68 + Some("2026-05-21")
69 + );
70 +
71 + let status = parse_status(STATUS).unwrap();
72 + let astra = &status.peers[1];
73 + assert!(astra.online);
74 + assert_eq!(astra.last_seen, None, "an online peer shows no last-seen");
75 + let phone = &status.peers[3];
76 + assert_eq!(phone.last_seen.as_deref(), Some("2026-05-21"));
77 + }
78 +
79 + // The state label is what an offline peer's row says. It must not claim a
80 + // year-1 sighting, and must stay empty for an unremarkable online peer.
81 + #[test]
82 + fn state_labels_read_sensibly() {
83 + let status = parse_status(STATUS).unwrap();
84 + assert_eq!(status.peers[0].state_label(), "this machine");
85 + assert_eq!(status.peers[1].state_label(), "offers exit");
86 + assert_eq!(status.peers[2].state_label(), "", "nothing notable to say");
87 + assert_eq!(status.peers[3].state_label(), "seen 2026-05-21");
88 + }
89 +
90 + // Peers hold a v4 and a v6; the v4 is the recognizable one. Taking the
91 + // first entry blindly works only while Tailscale keeps ordering them.
92 + #[test]
93 + fn prefers_the_ipv4_address() {
94 + let status = parse_status(STATUS).unwrap();
95 + assert_eq!(status.peers[0].ip.as_deref(), Some("100.103.89.95"));
96 +
97 + let v6_first = ["fd7a:115c:a1e0::1".to_string(), "100.1.2.3".to_string()];
98 + assert_eq!(preferred_ip(&v6_first).as_deref(), Some("100.1.2.3"));
99 + assert_eq!(preferred_ip(&[]), None);
100 + // v6-only is better shown than blanked.
101 + let v6_only = ["fd7a:115c:a1e0::1".to_string()];
102 + assert_eq!(preferred_ip(&v6_only).as_deref(), Some("fd7a:115c:a1e0::1"));
103 + }
104 +
105 + #[test]
106 + fn a_stopped_backend_is_surfaced() {
107 + let raw = r#"{"BackendState":"Stopped","Peer":{}}"#;
108 + let status = parse_status(raw).unwrap();
109 + assert!(!status.is_running());
110 + assert!(status.peers.is_empty(), "no Self key means no rows");
111 + }
112 +
113 + // NeedsLogin arrives with no Self and no peers. The screen has to survive
114 + // it rather than unwrapping something absent.
115 + #[test]
116 + fn a_logged_out_tailnet_parses_to_an_empty_list() {
117 + let raw = r#"{"BackendState":"NeedsLogin","Health":["not logged in"],"Peer":{}}"#;
118 + let status = parse_status(raw).unwrap();
119 + assert_eq!(status.peers.len(), 0);
120 + assert_eq!(status.health, ["not logged in"]);
121 + }
122 +
123 + #[test]
124 + fn malformed_json_is_an_error() {
125 + assert!(parse_status("not json").is_err());
126 + }
127 +
128 + #[test]
129 + fn an_unnamed_peer_still_gets_an_identifiable_row() {
130 + let raw = r#"{"BackendState":"Running","Peer":{"k":{"OS":"linux","Online":true}}}"#;
131 + let status = parse_status(raw).unwrap();
132 + assert_eq!(status.peers[0].hostname, "(unnamed)");
133 + assert_eq!(status.peers[0].ip, None);
134 + }
135 +
136 + // ---- control plane ----
137 +
138 + // An empty ControlURL is how a client that never had one set reports the
139 + // default, so it must not read as self-hosted.
140 + #[test]
141 + fn an_unset_control_url_is_the_hosted_plane() {
142 + assert_eq!(classify_control_url(""), ControlPlane::Hosted);
143 + assert_eq!(classify_control_url(" "), ControlPlane::Hosted);
144 + }
145 +
146 + #[test]
147 + fn the_vendor_control_url_is_recognized() {
148 + assert_eq!(
149 + classify_control_url("https://controlplane.tailscale.com"),
150 + ControlPlane::Hosted
151 + );
152 + assert_eq!(
153 + classify_control_url("https://tailscale.com"),
154 + ControlPlane::Hosted
155 + );
156 + }
157 +
158 + #[test]
159 + fn a_headscale_url_is_reported_by_host() {
160 + assert_eq!(
161 + classify_control_url("https://headscale.example.org"),
162 + ControlPlane::SelfHosted("headscale.example.org".into())
163 + );
164 + assert_eq!(
165 + classify_control_url("https://hs.example.org:8080/some/path"),
166 + ControlPlane::SelfHosted("hs.example.org".into()),
167 + "port and path are stripped, leaving the host"
168 + );
169 + assert_eq!(
170 + classify_control_url("http://10.0.0.5:8080"),
171 + ControlPlane::SelfHosted("10.0.0.5".into())
172 + );
173 + }
174 +
175 + // Suffix matching is dot-anchored, so a self-hosted server whose name
176 + // merely contains the vendor's domain is not mistaken for it.
177 + #[test]
178 + fn a_lookalike_host_is_not_mistaken_for_the_vendor() {
179 + assert_eq!(
180 + classify_control_url("https://headscale.tailscale.com.example.org"),
181 + ControlPlane::SelfHosted("headscale.tailscale.com.example.org".into())
182 + );
183 + assert_eq!(
184 + classify_control_url("https://nottailscale.com"),
185 + ControlPlane::SelfHosted("nottailscale.com".into())
186 + );
187 + }
@@ -1,668 +1,0 @@
1 - //! Tests for [`super`].
2 -
3 - use super::*;
4 -
5 - // Shaped from this machine's real `tailscale status --json`, trimmed to
6 - // the fields the parser reads. The awkward parts are real: an online peer
7 - // carrying Go's zero time for LastSeen, a device named "localhost", and
8 - // the Peer map keyed by public key.
9 - const STATUS: &str = r#"{
10 - "Version": "1.90.0",
11 - "BackendState": "Running",
12 - "Health": [],
13 - "MagicDNSSuffix": "example-tailnet.ts.net",
14 - "Self": {
15 - "HostName": "fw13", "OS": "linux",
16 - "TailscaleIPs": ["100.103.89.95", "fd7a:115c:a1e0::af3b:595f"],
17 - "Online": true, "ExitNode": false, "ExitNodeOption": false,
18 - "LastSeen": "0001-01-01T00:00:00Z"
19 - },
20 - "Peer": {
21 - "nodekey:aaa": {
22 - "HostName": "localhost", "OS": "iOS",
23 - "TailscaleIPs": ["100.90.1.2"],
24 - "Online": false, "ExitNode": false, "ExitNodeOption": false,
25 - "LastSeen": "2026-05-21T23:27:30.1Z"
26 - },
27 - "nodekey:bbb": {
28 - "HostName": "astra", "OS": "linux",
29 - "TailscaleIPs": ["100.80.3.4"],
30 - "Online": true, "ExitNode": false, "ExitNodeOption": true,
31 - "LastSeen": "0001-01-01T00:00:00Z"
32 - },
33 - "nodekey:ccc": {
34 - "HostName": "htpy-1", "OS": "linux",
35 - "TailscaleIPs": ["100.70.5.6"],
36 - "Online": true, "ExitNode": false, "ExitNodeOption": false,
37 - "LastSeen": "0001-01-01T00:00:00Z"
38 - }
39 - }
40 - }"#;
41 -
42 - #[test]
43 - fn parses_self_and_peers() {
44 - let status = parse_status(STATUS).unwrap();
45 - assert_eq!(status.backend_state, "Running");
46 - assert!(status.health.is_empty());
47 - assert_eq!(status.peers.len(), 4, "self plus three peers");
48 - }
49 -
50 - // Self first, then online peers by name, then offline. `Peer` is a map, so
51 - // without an explicit sort the list reshuffles on every refresh with the
52 - // cursor sitting on whatever lands under it.
53 - #[test]
54 - fn peers_are_ordered_self_then_online_then_by_name() {
55 - let status = parse_status(STATUS).unwrap();
56 - let names: Vec<&str> = status.peers.iter().map(|p| p.hostname.as_str()).collect();
57 - assert_eq!(names, ["fw13", "astra", "htpy-1", "localhost"]);
58 - assert!(status.peers[0].is_self);
59 - }
60 -
61 - // Go's zero time means "currently online", not "last seen in year 1".
62 - #[test]
63 - fn go_zero_time_is_not_a_last_seen_date() {
64 - assert_eq!(last_seen_date("0001-01-01T00:00:00Z"), None);
65 - assert_eq!(last_seen_date(""), None);
66 - assert_eq!(
67 - last_seen_date("2026-05-21T23:27:30.1Z").as_deref(),
68 - Some("2026-05-21")
69 - );
70 -
71 - let status = parse_status(STATUS).unwrap();
72 - let astra = &status.peers[1];
73 - assert!(astra.online);
74 - assert_eq!(astra.last_seen, None, "an online peer shows no last-seen");
75 - let phone = &status.peers[3];
76 - assert_eq!(phone.last_seen.as_deref(), Some("2026-05-21"));
77 - }
78 -
79 - // The state label is what an offline peer's row says. It must not claim a
80 - // year-1 sighting, and must stay empty for an unremarkable online peer.
81 - #[test]
82 - fn state_labels_read_sensibly() {
83 - let status = parse_status(STATUS).unwrap();
84 - assert_eq!(status.peers[0].state_label(), "this machine");
85 - assert_eq!(status.peers[1].state_label(), "offers exit");
86 - assert_eq!(status.peers[2].state_label(), "", "nothing notable to say");
87 - assert_eq!(status.peers[3].state_label(), "seen 2026-05-21");
88 - }
89 -
90 - // Peers hold a v4 and a v6; the v4 is the recognizable one. Taking the
91 - // first entry blindly works only while Tailscale keeps ordering them.
92 - #[test]
93 - fn prefers_the_ipv4_address() {
94 - let status = parse_status(STATUS).unwrap();
95 - assert_eq!(status.peers[0].ip.as_deref(), Some("100.103.89.95"));
96 -
97 - let v6_first = ["fd7a:115c:a1e0::1".to_string(), "100.1.2.3".to_string()];
98 - assert_eq!(preferred_ip(&v6_first).as_deref(), Some("100.1.2.3"));
99 - assert_eq!(preferred_ip(&[]), None);
100 - // v6-only is better shown than blanked.
101 - let v6_only = ["fd7a:115c:a1e0::1".to_string()];
102 - assert_eq!(preferred_ip(&v6_only).as_deref(), Some("fd7a:115c:a1e0::1"));
103 - }
104 -
105 - #[test]
106 - fn a_stopped_backend_is_surfaced() {
107 - let raw = r#"{"BackendState":"Stopped","Peer":{}}"#;
108 - let status = parse_status(raw).unwrap();
109 - assert!(!status.is_running());
110 - assert!(status.peers.is_empty(), "no Self key means no rows");
111 - }
112 -
113 - // NeedsLogin arrives with no Self and no peers. The screen has to survive
114 - // it rather than unwrapping something absent.
115 - #[test]
116 - fn a_logged_out_tailnet_parses_to_an_empty_list() {
117 - let raw = r#"{"BackendState":"NeedsLogin","Health":["not logged in"],"Peer":{}}"#;
118 - let status = parse_status(raw).unwrap();
119 - assert_eq!(status.peers.len(), 0);
120 - assert_eq!(status.health, ["not logged in"]);
121 - }
122 -
123 - #[test]
124 - fn malformed_json_is_an_error() {
125 - assert!(parse_status("not json").is_err());
126 - }
127 -
128 - #[test]
129 - fn an_unnamed_peer_still_gets_an_identifiable_row() {
130 - let raw = r#"{"BackendState":"Running","Peer":{"k":{"OS":"linux","Online":true}}}"#;
131 - let status = parse_status(raw).unwrap();
132 - assert_eq!(status.peers[0].hostname, "(unnamed)");
133 - assert_eq!(status.peers[0].ip, None);
134 - }
135 -
136 - // ---- control plane ----
137 -
138 - // An empty ControlURL is how a client that never had one set reports the
139 - // default, so it must not read as self-hosted.
140 - #[test]
141 - fn an_unset_control_url_is_the_hosted_plane() {
142 - assert_eq!(classify_control_url(""), ControlPlane::Hosted);
143 - assert_eq!(classify_control_url(" "), ControlPlane::Hosted);
144 - }
145 -
146 - #[test]
147 - fn the_vendor_control_url_is_recognized() {
148 - assert_eq!(
149 - classify_control_url("https://controlplane.tailscale.com"),
150 - ControlPlane::Hosted
151 - );
152 - assert_eq!(
153 - classify_control_url("https://tailscale.com"),
154 - ControlPlane::Hosted
155 - );
156 - }
157 -
158 - #[test]
159 - fn a_headscale_url_is_reported_by_host() {
160 - assert_eq!(
161 - classify_control_url("https://headscale.example.org"),
162 - ControlPlane::SelfHosted("headscale.example.org".into())
163 - );
164 - assert_eq!(
165 - classify_control_url("https://hs.example.org:8080/some/path"),
166 - ControlPlane::SelfHosted("hs.example.org".into()),
167 - "port and path are stripped, leaving the host"
168 - );
169 - assert_eq!(
170 - classify_control_url("http://10.0.0.5:8080"),
171 - ControlPlane::SelfHosted("10.0.0.5".into())
172 - );
173 - }
174 -
175 - // Suffix matching is dot-anchored, so a self-hosted server whose name
176 - // merely contains the vendor's domain is not mistaken for it.
177 - #[test]
178 - fn a_lookalike_host_is_not_mistaken_for_the_vendor() {
179 - assert_eq!(
180 - classify_control_url("https://headscale.tailscale.com.example.org"),
181 - ControlPlane::SelfHosted("headscale.tailscale.com.example.org".into())
182 - );
183 - assert_eq!(
184 - classify_control_url("https://nottailscale.com"),
185 - ControlPlane::SelfHosted("nottailscale.com".into())
186 - );
187 - }
188 -
189 - // Only a self-hosted plane is worth title space; the other two say nothing
190 - // rather than "(hosted)" on every screen.
191 - #[test]
192 - fn only_a_self_hosted_plane_earns_a_title_suffix() {
193 - assert_eq!(ControlPlane::Hosted.label(), "");
194 - assert_eq!(ControlPlane::Unknown.label(), "");
195 - assert_eq!(
196 - ControlPlane::SelfHosted("hs.example.org".into()).label(),
197 - " via hs.example.org"
198 - );
199 - }
200 -
201 - #[test]
202 - fn the_title_names_the_backend_and_a_self_hosted_plane() {
203 - let (mut view, _log) = mock_view();
204 - assert_eq!(view.title(), "mesh (mock)");
205 - view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
206 - assert_eq!(view.title(), "mesh (mock via hs.example.org)");
207 - }
208 -
209 - // ---- view behavior ----
210 -
211 - fn mock_view() -> (MeshView, CommandLog) {
212 - let (mut view, mut log) = bare_view();
213 - view.refresh(&mut log);
214 - (view, log)
215 - }
216 -
217 - /// A view that has not read a status yet.
218 - fn bare_view() -> (MeshView, CommandLog) {
219 - (
220 - MeshView {
221 - backend: Box::new(Mock),
222 - status: None,
223 - control_plane: ControlPlane::Unknown,
224 - cursor: Cursor::new(),
225 - error: None,
226 - notice: None,
227 - ticks: 0,
228 - server: None,
229 - route: || true,
230 - },
231 - CommandLog::new(),
232 - )
233 - }
234 -
235 - #[test]
236 - fn routing_through_this_machine_is_refused() {
237 - let (mut view, mut log) = mock_view();
238 - view.set_exit_node(&mut log);
239 - assert!(
240 - view.error
241 - .as_deref()
242 - .is_some_and(|e| e.contains("this machine")),
243 - "got: {:?}",
244 - view.error
245 - );
246 - }
247 -
248 - // Tailscale would reject this too, but naming the peer up front beats a
249 - // failed command in the log for something knowable in advance.
250 - #[test]
251 - fn routing_through_a_peer_that_does_not_offer_is_refused() {
252 - let (mut view, mut log) = mock_view();
253 - view.cursor.move_by(2); // the phone, which offers nothing
254 - view.set_exit_node(&mut log);
255 - assert!(
256 - view.error
257 - .as_deref()
258 - .is_some_and(|e| e.contains("does not offer")),
259 - "got: {:?}",
260 - view.error
261 - );
262 - }
263 -
264 - #[test]
265 - fn routing_through_an_offering_peer_is_allowed() {
266 - let (mut view, mut log) = mock_view();
267 - view.cursor.move_by(1); // astra, which offers
268 - view.set_exit_node(&mut log);
269 - assert!(view.error.is_none(), "got: {:?}", view.error);
270 - }
271 -
272 - #[test]
273 - fn ticks_are_silent_and_do_not_clear_errors() {
274 - let (mut view, mut log) = mock_view();
275 - view.set_exit_node(&mut log); // refused: self
276 - assert!(view.error.is_some());
277 -
278 - let before = log.entries().len();
279 - for _ in 0..POLL_TICKS * 2 {
280 - view.tick(&mut log);
281 - }
282 - assert_eq!(log.entries().len(), before, "ticks do not log");
283 - assert!(view.error.is_some(), "ticks do not wipe an action error");
284 - }
285 -
286 - #[test]
287 - fn acting_with_no_selection_is_inert() {
288 - let (mut view, mut log) = bare_view();
289 - view.set_exit_node(&mut log);
290 - assert!(view.error.is_none(), "no selection is not an error");
291 - }
292 -
293 - // ---- enrollment ----
294 -
295 - fn press(view: &mut MeshView, c: char, log: &mut CommandLog) -> Flow {
296 - view.handle(KeyEvent::from(KeyCode::Char(c)), log)
297 - }
298 -
299 - fn key(view: &mut MeshView, code: KeyCode, log: &mut CommandLog) -> Flow {
300 - view.handle(KeyEvent::from(code), log)
301 - }
302 -
303 - /// A view sitting on a tailnet it has never signed into.
304 - fn logged_out_view() -> (MeshView, CommandLog) {
305 - let (mut view, log) = bare_view();
306 - view.status = Some(parse_status(r#"{"BackendState":"NeedsLogin","Peer":{}}"#).unwrap());
307 - (view, log)
308 - }
309 -
310 - #[test]
311 - fn an_unset_server_means_the_vendor_plane() {
312 - assert_eq!(validate_login_server(""), Ok(None));
313 - assert_eq!(validate_login_server(" "), Ok(None));
314 - }
315 -
316 - #[test]
317 - fn a_server_url_is_trimmed_and_kept() {
318 - assert_eq!(
319 - validate_login_server(" https://hs.example.org "),
320 - Ok(Some("https://hs.example.org".into()))
321 - );
322 - // http is allowed: a Headscale on a tailnet-internal address is a real
323 - // deployment, and refusing it would be a policy this screen has no
324 - // standing to set.
325 - assert_eq!(
326 - validate_login_server("http://10.0.0.5:8080"),
327 - Ok(Some("http://10.0.0.5:8080".into()))
328 - );
329 - }
330 -
331 - // The error a bare hostname earns has to say what to type instead. It is
332 - // the whole reason the check exists.
333 - #[test]
334 - fn a_bare_hostname_is_refused_with_the_fix() {
335 - let error = validate_login_server("hs.example.org").unwrap_err();
336 - assert!(error.contains("https://hs.example.org"), "got: {error}");
337 - }
338 -
339 - // The daemon and the sign-in go under one escalation, which is why this is a
340 - // script and not `tailscale up`. See `Tailscale::enroll`: the preset ships
341 - // tailscaled disabled, so running the sign-in alone spends the user's password
342 - // and then fails on a socket nothing is listening on.
343 - #[test]
344 - fn enrollment_runs_the_mesh_helper_under_run0() {
345 - assert_eq!(Tailscale.enroll(None).display(), "run0 alloy-mesh-up");
346 - assert_eq!(
347 - Tailscale.enroll(Some("https://hs.example.org")).display(),
348 - "run0 alloy-mesh-up --login-server=https://hs.example.org"
349 - );
350 - }
351 -
352 - // The server is carried as one argv element rather than spliced into a command
353 - // string. `validate_login_server` is what stops a hostile value reaching here,
354 - // but the carrier is why it cannot matter: argv stays data all the way to
355 - // `tailscale up "$@"` in the helper. Asserted with a value that would split on
356 - // a shell word boundary, since a quoted display is what a single argument that
357 - // needs quoting looks like.
358 - #[test]
359 - fn the_login_server_stays_one_argument() {
360 - assert_eq!(
361 - Tailscale.enroll(Some("https://hs.example.org x")).display(),
362 - "run0 alloy-mesh-up '--login-server=https://hs.example.org x'"
363 - );
364 - }
365 -
366 - // A running mesh must not offer to sign in, and a status that failed to
367 - // read must not either — the peer list's error is the thing to show, not an
368 - // invitation to re-join a mesh the user is already on.
369 - #[test]
370 - fn only_a_non_running_backend_gets_the_offer() {
371 - let (view, _log) = mock_view();
372 - assert!(view.is_enrolled(), "the mock reports Running");
373 -
374 - let (view, _log) = logged_out_view();
375 - assert!(!view.is_enrolled());
376 -
377 - let (view, _log) = bare_view();
378 - assert!(view.is_enrolled(), "an unread status is not an offer");
379 - }
380 -
381 - // `e` is the exit-node key on one screen and the sign-in key on the other.
382 - // The two screens are never both on, and this is what says so.
383 - #[test]
384 - fn e_signs_in_on_the_offer_and_picks_an_exit_node_on_the_list() {
385 - let (mut view, mut log) = logged_out_view();
386 - press(&mut view, 'e', &mut log);
387 - assert!(view.server.is_some(), "the offer's e opens enrollment");
388 -
389 - let (mut view, mut log) = mock_view();
390 - press(&mut view, 'e', &mut log);
391 - assert!(
392 - view.server.is_none(),
393 - "the list's e does not open enrollment"
394 - );
395 - assert!(
396 - view.error
397 - .as_deref()
398 - .is_some_and(|e| e.contains("this machine")),
399 - "it tried to route instead: {:?}",
400 - view.error
401 - );
402 - }
403 -
404 - // A control server that survived a down/up cycle is shown rather than
405 - // silently reused, so a self-hosted user sees which mesh they are rejoining.
406 - #[test]
407 - fn the_field_is_prefilled_from_a_self_hosted_plane() {
408 - let (mut view, _log) = logged_out_view();
409 - view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
410 - view.open_enrollment();
411 - assert_eq!(view.server.unwrap().value(), "https://hs.example.org");
412 -
413 - let (mut view, _log) = logged_out_view();
414 - view.open_enrollment();
415 - assert_eq!(
416 - view.server.unwrap().value(),
417 - "",
418 - "the vendor plane is empty"
419 - );
420 - }
421 -
422 - // Typing is not a binding. Without this, a server named `https://r.example`
423 - // would refresh the view and clear the exit node on the way through.
424 - #[test]
425 - fn the_overlay_eats_the_keys_the_list_would_claim() {
426 - let (mut view, mut log) = logged_out_view();
427 - view.open_enrollment();
428 - for c in "https://rex.example".chars() {
429 - press(&mut view, c, &mut log);
430 - }
431 - assert_eq!(view.server.as_ref().unwrap().value(), "https://rex.example");
432 - assert!(
433 - view.text_entry(),
434 - "the shell must release its reserved keys"
435 - );
436 - }
437 -
438 - #[test]
439 - fn a_bad_server_keeps_the_overlay_open_to_be_corrected() {
440 - let (mut view, mut log) = logged_out_view();
441 - view.open_enrollment();
442 - for c in "hs.example.org".chars() {
443 - press(&mut view, c, &mut log);
444 - }
445 - let flow = key(&mut view, KeyCode::Enter, &mut log);
446 - assert!(matches!(flow, Flow::Continue), "no suspend on a bad value");
447 - assert!(view.server.is_some(), "the typed value survives the error");
448 - assert!(view.error.is_some());
449 - }
450 -
451 - #[test]
452 - fn a_good_server_suspends_the_console() {
453 - let (mut view, mut log) = logged_out_view();
454 - view.open_enrollment();
455 - let flow = key(&mut view, KeyCode::Enter, &mut log);
456 - assert!(matches!(flow, Flow::Suspend(_)));
457 - assert!(view.server.is_none(), "the overlay closes on the way out");
458 - // The pane carries the command before the handover, not after: the
459 - // console is about to tear down and there is no after to fill in.
460 - assert!(
461 - log.entries().iter().any(|e| e.command.contains("true")),
462 - "the enrollment command was not logged"
463 - );
464 - }
465 -
466 - #[test]
467 - fn esc_closes_the_overlay_before_it_closes_the_view() {
468 - let (mut view, mut log) = logged_out_view();
469 - view.open_enrollment();
470 - key(&mut view, KeyCode::Esc, &mut log);
471 - assert!(view.server.is_none());
472 - assert!(matches!(view.cancel(), Flow::Exit), "then Esc leaves");
473 - }
474 -
475 - #[test]
476 - fn ticks_do_not_refresh_under_the_overlay() {
477 - let (mut view, mut log) = logged_out_view();
478 - view.open_enrollment();
479 - for _ in 0..POLL_TICKS * 2 {
480 - view.tick(&mut log);
481 - }
482 - assert!(view.server.is_some(), "the overlay survived the poll");
483 - assert!(!view.is_enrolled(), "and the status behind it is untouched");
484 - }
485 -
486 - /// Parse this machine's real tailnet.
487 - ///
488 - /// Ignored by default: needs Tailscale installed and logged in, and what
489 - /// it finds depends on the tailnet. Run it when touching the parser.
490 - #[test]
491 - #[ignore = "requires a logged-in Tailscale"]
492 - fn parses_this_machines_real_tailnet() {
493 - let mut log = CommandLog::new();
494 - let status = Tailscale.status(&mut log).expect("tailscale should answer");
495 -
496 - assert!(!status.peers.is_empty(), "a mesh has at least this machine");
497 - assert!(status.peers[0].is_self, "this machine sorts first");
498 -
499 - // The control-plane lookup rides an unstable `debug` interface, so
500 - // what matters is that it produced *something* rather than silently
Lines truncated
@@ -1,0 +1,480 @@
1 + //! The `alloy mesh` screen: the peer list, the exit-node keys, and enrollment.
2 +
3 + use alloy_tui::{
4 + AlloyBlock, AlloyList, Cursor, Hint, Severity, TextField, Theme, hint, layout, text,
5 + };
6 + use anyhow::Result;
7 + use ratatui::Frame;
8 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
9 + use ratatui::layout::Rect;
10 + use ratatui::style::{Modifier, Style};
11 + use ratatui::text::{Line, Span};
12 +
13 + use super::backend::{Backend, NO_ROUTE, detect, machine_has_route, validate_login_server};
14 + use super::model::{ControlPlane, MeshStatus, Peer};
15 + use crate::cli::{CommandLog, Invocation};
16 + use crate::shell::{Flow, View, block_title, truncate};
17 +
18 + /// Ticks between background refreshes. Peers come and go on the scale of a
19 + /// laptop lid closing, not a keypress, so polling every second would spawn a
20 + /// process per second to learn nothing.
21 + const POLL_TICKS: u64 = 5;
22 +
23 + /// The `alloy tail` screen.
24 + pub(crate) struct MeshView {
25 + backend: Box<dyn Backend>,
26 + status: Option<MeshStatus>,
27 + control_plane: ControlPlane,
28 + cursor: Cursor,
29 + error: Option<String>,
30 + /// A caveat about what the console just did, distinct from a failure.
31 + ///
32 + /// The admin-console key needs one: on a self-hosted control plane the
33 + /// address it opens is a guess, and a guess that works silently teaches
34 + /// the wrong thing about what Alloy knows.
35 + notice: Option<String>,
36 + ticks: u64,
37 + /// The control-server field, while the enrollment overlay is open.
38 + ///
39 + /// One field, so there is no focus ring: the overlay exists to make the
40 + /// Headscale choice visible at the moment it is made, and the vendor's
41 + /// plane is the empty answer.
42 + server: Option<TextField>,
43 + /// Whether this machine can reach anything, as a seam.
44 + ///
45 + /// Injected rather than called directly so the enrollment tests state the
46 + /// network they are testing against. Reading `/proc/net/route` from inside
47 + /// a test makes the result depend on the machine running it: the same test
48 + /// would pass on a laptop and fail in a build container with no default
49 + /// route, and it would fail for a reason that has nothing to do with what
50 + /// it asserts.
51 + route: fn() -> bool,
52 + }
53 +
54 + impl MeshView {
55 + pub(crate) fn new(log: &mut CommandLog) -> Self {
56 + let backend = detect();
57 + // Once, at startup: changing the control server requires
58 + // re-authenticating, so it cannot change under a running view.
59 + let control_plane = backend.control_plane();
60 + let mut view = Self {
61 + backend,
62 + status: None,
63 + control_plane,
64 + cursor: Cursor::new(),
65 + error: None,
66 + notice: None,
67 + ticks: 0,
68 + server: None,
69 + route: machine_has_route,
70 + };
71 + view.refresh(log);
72 + view
73 + }
74 +
75 + // As in `audio`: a successful refresh does not clear `error`, because
76 + // refreshes run on the background tick and would wipe an action's error
77 + // before it could be read. Keypresses clear it instead.
78 + fn refresh(&mut self, log: &mut CommandLog) {
79 + match self.backend.status(log) {
80 + Ok(status) => {
81 + self.cursor.resize(status.peers.len());
82 + self.status = Some(status);
83 + }
84 + Err(err) => self.error = Some(err.to_string()),
85 + }
86 + }
87 +
88 + fn peers(&self) -> &[Peer] {
89 + self.status.as_ref().map_or(&[], |status| &status.peers)
90 + }
91 +
92 + fn selected(&self) -> Option<&Peer> {
93 + self.peers().get(self.cursor.selected()?)
94 + }
95 +
96 + fn set_exit_node(&mut self, log: &mut CommandLog) {
97 + let Some(peer) = self.selected() else {
98 + return;
99 + };
100 + if peer.is_self {
101 + self.error = Some("cannot route through this machine".into());
102 + return;
103 + }
104 + // Tailscale rejects this too, but saying it here names the peer and
105 + // avoids a failed command in the log for something knowable up front.
106 + if !peer.offers_exit_node {
107 + self.error = Some(format!(
108 + "{} does not offer to be an exit node",
109 + peer.hostname
110 + ));
111 + return;
112 + }
113 + let result = self.backend.set_exit_node(peer, log);
114 + self.finish(result, log);
115 + }
116 +
117 + fn clear_exit_node(&mut self, log: &mut CommandLog) {
118 + let result = self.backend.clear_exit_node(log);
119 + self.finish(result, log);
120 + }
121 +
122 + /// Hand the tailnet's admin console to a browser.
123 + ///
124 + /// The peer list can set an exit node and nothing else, because the CLI it
125 + /// drives can do nothing else. Every tailnet-wide act — deleting the two
126 + /// dead `fw12` nodes, editing the ACL, minting an auth key — is an API call
127 + /// this console does not make and a page a browser can already open.
128 + ///
129 + /// Not a suspend, for the same reason `alloy sync`'s `w` is not: a browser
130 + /// owns a window, so the peer list is still here afterwards.
131 + fn open_admin_console(&mut self, log: &mut CommandLog) {
132 + let Some((url, caveat)) = self.control_plane.admin_console() else {
133 + self.error = Some(
134 + "control plane unknown, so there is no console to open; \
135 + `tailscale debug prefs` names it"
136 + .into(),
137 + );
138 + return;
139 + };
140 + self.notice = caveat;
141 + if let Err(err) = Invocation::new("alloy-open").arg(url).launch(log) {
142 + self.error = Some(err.to_string());
143 + self.notice = None;
144 + }
145 + }
146 +
147 + fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
148 + match result {
149 + Ok(()) => log.quiet(|log| self.refresh(log)),
150 + Err(err) => self.error = Some(err.to_string()),
151 + }
152 + }
153 +
154 + // ---- enrollment ----
155 +
156 + /// Whether this machine is in a mesh at all.
157 + ///
158 + /// A status that failed to read is treated as enrolled, so a broken
159 + /// `tailscale status` shows its error rather than inviting a user who is
160 + /// already on the mesh to sign in again.
161 + fn is_enrolled(&self) -> bool {
162 + self.status.as_ref().is_none_or(MeshStatus::is_running)
163 + }
164 +
165 + /// Open the enrollment overlay, prefilled with the control server in use.
166 + ///
167 + /// Prefilling settles the open question in docs/CONTINUITY.md about whether
168 + /// a Headscale login server survives a `down`/`up` cycle. It does: Tailscale
169 + /// keeps `ControlURL` in its prefs, which is where [`ControlPlane`] is read
170 + /// from in the first place, so re-entry is never *required*. It is prefilled
171 + /// anyway, because a self-hosted user reconnecting should be able to see
172 + /// which server they are about to rejoin rather than trust that it was
173 + /// remembered.
174 + fn open_enrollment(&mut self) {
175 + let mut field = TextField::new();
176 + if let ControlPlane::SelfHosted(host) = &self.control_plane {
177 + // Round-tripped back into a URL. `ControlPlane` keeps only the host,
178 + // since that is all a title needs, and `--login-server` needs the
179 + // scheme back. https, because a control server that answered over
180 + // plain http would not have been reachable to be read here.
181 + field.set(format!("https://{host}"));
182 + }
183 + self.server = Some(field);
184 + }
185 +
186 + fn close_enrollment(&mut self) {
187 + self.server = None;
188 + }
189 +
190 + /// Hand the terminal to `run0 tailscale up`.
191 + ///
192 + /// The overlay closes first. There is nothing to come back to: the console
193 + /// is about to tear down, and on the way back the poll will have found
194 + /// either a mesh or the same offer.
195 + fn submit_enrollment(&mut self, log: &mut CommandLog) -> Flow {
196 + let Some(field) = &self.server else {
197 + return Flow::Continue;
198 + };
199 + let server = match validate_login_server(field.value()) {
200 + Ok(server) => server,
201 + Err(message) => {
202 + self.error = Some(message);
203 + return Flow::Continue;
204 + }
205 + };
206 + if !(self.route)() {
207 + self.error = Some(NO_ROUTE.into());
208 + self.close_enrollment();
209 + return Flow::Continue;
210 + }
211 + let invocation = self.backend.enroll(server.as_deref());
212 + self.close_enrollment();
213 + // Recorded before the handover, as in `alloy pkg`: the pane carries what
214 + // the user is about to be dropped into rather than filling in after.
215 + log.record(invocation.display(), Severity::Info);
216 + Flow::Suspend(invocation.command())
217 + }
218 +
219 + /// Keys while the enrollment overlay is open.
220 + ///
221 + /// Returns `Some` when the overlay consumed the key, so an `r` typed into a
222 + /// server name never reaches the refresh binding underneath.
223 + fn handle_enrollment(&mut self, key: KeyEvent, log: &mut CommandLog) -> Option<Flow> {
224 + self.server.as_ref()?;
225 + match key.code {
226 + KeyCode::Esc => self.close_enrollment(),
227 + KeyCode::Enter => return Some(self.submit_enrollment(log)),
228 + _ => {
229 + let field = self.server.as_mut()?;
230 + match key.code {
231 + KeyCode::Char(c) => field.insert(c),
232 + KeyCode::Backspace => field.backspace(),
233 + KeyCode::Delete => field.delete(),
234 + KeyCode::Left => field.left(),
235 + KeyCode::Right => field.right(),
236 + KeyCode::Home => field.home(),
237 + KeyCode::End => field.end(),
238 + _ => {}
239 + }
240 + }
241 + }
242 + Some(Flow::Continue)
243 + }
244 +
245 + fn row<'a>(theme: &Theme, peer: &'a Peer) -> Line<'a> {
246 + Line::from(vec![
247 + text::bold(theme, format!("{:<18}", truncate(&peer.hostname, 17))),
248 + text::muted(theme, format!("{:<8}", truncate(&peer.os, 7))),
249 + text::secondary(theme, format!("{:<17}", peer.ip.as_deref().unwrap_or("-"))),
250 + Span::styled(
251 + format!("{:<9}", if peer.online { "online" } else { "offline" }),
252 + peer.severity().style(theme),
253 + ),
254 + text::muted(theme, peer.state_label()),
255 + ])
256 + }
257 +
258 + /// The offer shown when this machine is not in a mesh.
259 + ///
260 + /// Names the command it is about to run, as `alloy sync`'s offer does. Here
261 + /// that is not only a teaching move: the command escalates, so a user is
262 + /// owed sight of what they are authorizing before the polkit prompt asks.
263 + fn render_offer(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
264 + let state = self
265 + .status
266 + .as_ref()
267 + .map_or("", |status| status.backend_state.as_str());
268 + // "NeedsLogin" is the never-signed-in state and every other non-running
269 + // one means signed in but down. The distinction is worth a sentence:
270 + // the second is a reconnection, and telling someone to sign in when
271 + // they already have reads as the console having lost their account.
272 + let headline = if state == "NeedsLogin" {
273 + "this machine is not signed in to a mesh"
274 + } else {
275 + "the mesh is not connected"
276 + };
277 + let lines = vec![
278 + Line::from(text::muted(theme, headline)),
279 + Line::from(""),
280 + Line::from(text::secondary(
281 + theme,
282 + format!("press e to run: {}", self.backend.enroll(None).display()),
283 + )),
284 + ];
285 + frame.render_widget(ratatui::widgets::Paragraph::new(lines), area);
286 + }
287 +
288 + /// The one-field enrollment overlay.
289 + ///
290 + /// Same shape as `alloy sync`'s add overlay, one row shorter.
291 + fn render_enrollment(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
292 + let Some(field) = &self.server else {
293 + return;
294 + };
295 + // Two rows of border, one of padding either side, one field, and one
296 + // line saying what leaving it empty means.
297 + let overlay = layout::centered(area, 60, 6);
298 + frame.render_widget(ratatui::widgets::Clear, overlay);
299 +
300 + let block = AlloyBlock::new(theme)
301 + .focused(true)
302 + .build()
303 + .title(block_title("sign in"));
304 + let inner = block.inner(overlay);
305 + frame.render_widget(block, overlay);
306 +
307 + let (before, under, after) = field.split();
308 + let mut spans = vec![
309 + text::bold(theme, format!("{:>10} ", "server")),
310 + text::primary(theme, before.to_string()),
311 + ];
312 + // Reversed rather than a block glyph, matching the installer's fields
313 + // and `alloy sync`'s: the caret sits on the character it replaces.
314 + spans.push(Span::styled(
315 + under.unwrap_or(' ').to_string(),
316 + Style::default().add_modifier(Modifier::REVERSED),
317 + ));
318 + spans.push(text::primary(theme, after.to_string()));
319 +
320 + let lines = vec![
321 + Line::from(spans),
322 + Line::from(""),
323 + // The empty answer is the common one, and an empty field with no
324 + // caption reads as a value the user forgot rather than as a choice.
325 + Line::from(text::muted(
326 + theme,
327 + "empty joins tailscale.com; set a URL for a self-hosted server",
328 + )),
329 + ];
330 + frame.render_widget(ratatui::widgets::Paragraph::new(lines), inner);
331 + }
332 + }
333 +
334 + impl View for MeshView {
335 + /// "mesh (tailscale)", or "mesh (tailscale via hs.example.org)" on a
336 + /// self-hosted control plane.
337 + ///
338 + /// The backend name stays visible: abstracting the brand off the verb is
339 + /// so the screen is findable by someone who does not know the product, not
340 + /// so the console conceals what it is driving.
341 + fn title(&self) -> String {
342 + format!(
343 + "mesh ({}{})",
344 + self.backend.name(),
345 + self.control_plane.label()
346 + )
347 + }
348 +
349 + fn hints(&self) -> Vec<Hint> {
350 + if self.server.is_some() {
351 + return vec![hint("enter", "sign in"), hint("esc", "cancel")];
352 + }
353 + // `e` means two things across the two screens, which is safe only
354 + // because they are never both on: an un-enrolled machine has no peer
355 + // list to pick an exit node from, and an enrolled one has no offer.
356 + if !self.is_enrolled() {
357 + return vec![hint("e", "sign in"), hint("r", "refresh")];
358 + }
359 + vec![
360 + hint("j/k", "select"),
361 + hint("e", "exit node"),
362 + hint("x", "clear exit"),
363 + hint("w", "admin console"),
364 + hint("r", "refresh"),
365 + ]
366 + }
367 +
368 + fn status(&self) -> Option<(Severity, String)> {
369 + if let Some(error) = &self.error {
370 + return Some((Severity::Error, error.clone()));
371 + }
372 + // Above the health warnings: the notice is about the key just pressed,
373 + // and a standing warning about the mesh would otherwise bury it.
374 + if let Some(notice) = &self.notice {
375 + return Some((Severity::Warn, notice.clone()));
376 + }
377 + let status = self.status.as_ref()?;
378 + // A health warning is Tailscale telling the user something is wrong
379 + // that the peer list alone will not show.
380 + if let Some(warning) = status.health.first() {
381 + return Some((Severity::Warn, warning.clone()));
382 + }
383 + // Backend state is only worth a line when it is not the normal one.
384 + (!status.is_running()).then(|| (Severity::Warn, status.backend_state.clone()))
385 + }
386 +
387 + fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
388 + let block = AlloyBlock::new(theme)
389 + .focused(true)
390 + .build()
391 + .title(block_title(&self.title()));
392 + let inner = block.inner(area);
393 + frame.render_widget(block, area);
394 +
395 + if self.is_enrolled() {
396 + let peers = self.peers();
397 + if peers.is_empty() {
398 + frame.render_widget(Line::from(text::muted(theme, "no peers")), inner);
399 + } else {
400 + let rows: Vec<Line> = peers.iter().map(|peer| Self::row(theme, peer)).collect();
401 + frame.render_widget(
402 + AlloyList::new(theme, rows).selected(self.cursor.selected()),
403 + inner,
404 + );
405 + }
406 + } else {
407 + self.render_offer(frame, inner, theme);
408 + }
409 +
410 + // Last, and over the whole area rather than the block's inside, so it
411 + // floats above the border as `alloy sync`'s does.
412 + self.render_enrollment(frame, area, theme);
413 + }
414 +
415 + /// True while the server field is open, so the shell stops claiming the
416 + /// reserved keys and a control server can live at `https://q.example.org`.
417 + fn text_entry(&self) -> bool {
418 + self.server.is_some()
419 + }
420 +
421 + /// Esc backs out of the overlay first, and out of the view only when there
422 + /// is no overlay to close.
423 + fn cancel(&mut self) -> Flow {
424 + if self.server.is_some() {
425 + self.close_enrollment();
426 + return Flow::Continue;
427 + }
428 + Flow::Exit
429 + }
430 +
431 + fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
432 + self.error = None;
433 + self.notice = None;
434 +
435 + // The overlay eats every key it is given, so an `x` typed into a server
436 + // name never reaches the clear-exit binding underneath.
437 + if let Some(flow) = self.handle_enrollment(key, log) {
438 + return flow;
439 + }
440 +
441 + // On the offer screen `e` signs in; on the peer list it picks an exit
442 + // node. Split here rather than inside the actions, so the binding a key
443 + // has is decided in one place.
444 + if !self.is_enrolled() {
445 + match key.code {
446 + KeyCode::Char('e') => self.open_enrollment(),
447 + KeyCode::Char('r') => self.refresh(log),
448 + _ => {}
449 + }
450 + return Flow::Continue;
451 + }
452 +
453 + match key.code {
454 + KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
455 + KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
456 + KeyCode::Char('e') => self.set_exit_node(log),
457 + KeyCode::Char('x') => self.clear_exit_node(log),
458 + KeyCode::Char('w') => self.open_admin_console(log),
459 + KeyCode::Char('r') => self.refresh(log),
460 + _ => {}
461 + }
462 + Flow::Continue
463 + }
464 +
465 + fn tick(&mut self, log: &mut CommandLog) {
466 + self.ticks += 1;
467 + // No background refresh under the overlay: it would redraw the offer
468 + // behind a field someone is typing into, to learn nothing that has
469 + // changed, since what changes it is the command they have not run yet.
470 + if self.server.is_some() {
471 + return;
472 + }
473 + if self.ticks.is_multiple_of(POLL_TICKS) {
474 + log.quiet(|log| self.refresh(log));
475 + }
476 + }
477 + }
478 +
479 + #[cfg(test)]
480 + mod tests;
@@ -1,0 +1,328 @@
1 + //! Tests for [`super`].
2 +
3 + use super::super::backend::Mock;
4 + use super::super::parse::parse_status;
5 + use super::*;
6 +
7 + #[test]
8 + fn the_title_names_the_backend_and_a_self_hosted_plane() {
9 + let (mut view, _log) = mock_view();
10 + assert_eq!(view.title(), "mesh (mock)");
11 + view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
12 + assert_eq!(view.title(), "mesh (mock via hs.example.org)");
13 + }
14 +
15 + // ---- view behavior ----
16 +
17 + fn mock_view() -> (MeshView, CommandLog) {
18 + let (mut view, mut log) = bare_view();
19 + view.refresh(&mut log);
20 + (view, log)
21 + }
22 +
23 + /// A view that has not read a status yet.
24 + fn bare_view() -> (MeshView, CommandLog) {
25 + (
26 + MeshView {
27 + backend: Box::new(Mock),
28 + status: None,
29 + control_plane: ControlPlane::Unknown,
30 + cursor: Cursor::new(),
31 + error: None,
32 + notice: None,
33 + ticks: 0,
34 + server: None,
35 + route: || true,
36 + },
37 + CommandLog::new(),
38 + )
39 + }
40 +
41 + #[test]
42 + fn routing_through_this_machine_is_refused() {
43 + let (mut view, mut log) = mock_view();
44 + view.set_exit_node(&mut log);
45 + assert!(
46 + view.error
47 + .as_deref()
48 + .is_some_and(|e| e.contains("this machine")),
49 + "got: {:?}",
50 + view.error
51 + );
52 + }
53 +
54 + // Tailscale would reject this too, but naming the peer up front beats a
55 + // failed command in the log for something knowable in advance.
56 + #[test]
57 + fn routing_through_a_peer_that_does_not_offer_is_refused() {
58 + let (mut view, mut log) = mock_view();
59 + view.cursor.move_by(2); // the phone, which offers nothing
60 + view.set_exit_node(&mut log);
61 + assert!(
62 + view.error
63 + .as_deref()
64 + .is_some_and(|e| e.contains("does not offer")),
65 + "got: {:?}",
66 + view.error
67 + );
68 + }
69 +
70 + #[test]
71 + fn routing_through_an_offering_peer_is_allowed() {
72 + let (mut view, mut log) = mock_view();
73 + view.cursor.move_by(1); // astra, which offers
74 + view.set_exit_node(&mut log);
75 + assert!(view.error.is_none(), "got: {:?}", view.error);
76 + }
77 +
78 + #[test]
79 + fn ticks_are_silent_and_do_not_clear_errors() {
80 + let (mut view, mut log) = mock_view();
81 + view.set_exit_node(&mut log); // refused: self
82 + assert!(view.error.is_some());
83 +
84 + let before = log.entries().len();
85 + for _ in 0..POLL_TICKS * 2 {
86 + view.tick(&mut log);
87 + }
88 + assert_eq!(log.entries().len(), before, "ticks do not log");
89 + assert!(view.error.is_some(), "ticks do not wipe an action error");
90 + }
91 +
92 + #[test]
93 + fn acting_with_no_selection_is_inert() {
94 + let (mut view, mut log) = bare_view();
95 + view.set_exit_node(&mut log);
96 + assert!(view.error.is_none(), "no selection is not an error");
97 + }
98 +
99 + // ---- enrollment ----
100 +
101 + fn press(view: &mut MeshView, c: char, log: &mut CommandLog) -> Flow {
102 + view.handle(KeyEvent::from(KeyCode::Char(c)), log)
103 + }
104 +
105 + fn key(view: &mut MeshView, code: KeyCode, log: &mut CommandLog) -> Flow {
106 + view.handle(KeyEvent::from(code), log)
107 + }
108 +
109 + /// A view sitting on a tailnet it has never signed into.
110 + fn logged_out_view() -> (MeshView, CommandLog) {
111 + let (mut view, log) = bare_view();
112 + view.status = Some(parse_status(r#"{"BackendState":"NeedsLogin","Peer":{}}"#).unwrap());
113 + (view, log)
114 + }
115 +
116 + // A running mesh must not offer to sign in, and a status that failed to
117 + // read must not either — the peer list's error is the thing to show, not an
118 + // invitation to re-join a mesh the user is already on.
119 + #[test]
120 + fn only_a_non_running_backend_gets_the_offer() {
121 + let (view, _log) = mock_view();
122 + assert!(view.is_enrolled(), "the mock reports Running");
123 +
124 + let (view, _log) = logged_out_view();
125 + assert!(!view.is_enrolled());
126 +
127 + let (view, _log) = bare_view();
128 + assert!(view.is_enrolled(), "an unread status is not an offer");
129 + }
130 +
131 + // `e` is the exit-node key on one screen and the sign-in key on the other.
132 + // The two screens are never both on, and this is what says so.
133 + #[test]
134 + fn e_signs_in_on_the_offer_and_picks_an_exit_node_on_the_list() {
135 + let (mut view, mut log) = logged_out_view();
136 + press(&mut view, 'e', &mut log);
137 + assert!(view.server.is_some(), "the offer's e opens enrollment");
138 +
139 + let (mut view, mut log) = mock_view();
140 + press(&mut view, 'e', &mut log);
141 + assert!(
142 + view.server.is_none(),
143 + "the list's e does not open enrollment"
144 + );
145 + assert!(
146 + view.error
147 + .as_deref()
148 + .is_some_and(|e| e.contains("this machine")),
149 + "it tried to route instead: {:?}",
150 + view.error
151 + );
152 + }
153 +
154 + // A control server that survived a down/up cycle is shown rather than
155 + // silently reused, so a self-hosted user sees which mesh they are rejoining.
156 + #[test]
157 + fn the_field_is_prefilled_from_a_self_hosted_plane() {
158 + let (mut view, _log) = logged_out_view();
159 + view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
160 + view.open_enrollment();
161 + assert_eq!(view.server.unwrap().value(), "https://hs.example.org");
162 +
163 + let (mut view, _log) = logged_out_view();
164 + view.open_enrollment();
165 + assert_eq!(
166 + view.server.unwrap().value(),
167 + "",
168 + "the vendor plane is empty"
169 + );
170 + }
171 +
172 + // Typing is not a binding. Without this, a server named `https://r.example`
173 + // would refresh the view and clear the exit node on the way through.
174 + #[test]
175 + fn the_overlay_eats_the_keys_the_list_would_claim() {
176 + let (mut view, mut log) = logged_out_view();
177 + view.open_enrollment();
178 + for c in "https://rex.example".chars() {
179 + press(&mut view, c, &mut log);
180 + }
181 + assert_eq!(view.server.as_ref().unwrap().value(), "https://rex.example");
182 + assert!(
183 + view.text_entry(),
184 + "the shell must release its reserved keys"
185 + );
186 + }
187 +
188 + #[test]
189 + fn a_bad_server_keeps_the_overlay_open_to_be_corrected() {
190 + let (mut view, mut log) = logged_out_view();
191 + view.open_enrollment();
192 + for c in "hs.example.org".chars() {
193 + press(&mut view, c, &mut log);
194 + }
195 + let flow = key(&mut view, KeyCode::Enter, &mut log);
196 + assert!(matches!(flow, Flow::Continue), "no suspend on a bad value");
197 + assert!(view.server.is_some(), "the typed value survives the error");
198 + assert!(view.error.is_some());
199 + }
200 +
201 + #[test]
202 + fn a_good_server_suspends_the_console() {
203 + let (mut view, mut log) = logged_out_view();
204 + view.open_enrollment();
205 + let flow = key(&mut view, KeyCode::Enter, &mut log);
206 + assert!(matches!(flow, Flow::Suspend(_)));
207 + assert!(view.server.is_none(), "the overlay closes on the way out");
208 + // The pane carries the command before the handover, not after: the
209 + // console is about to tear down and there is no after to fill in.
210 + assert!(
211 + log.entries().iter().any(|e| e.command.contains("true")),
212 + "the enrollment command was not logged"
213 + );
214 + }
215 +
216 + #[test]
217 + fn esc_closes_the_overlay_before_it_closes_the_view() {
218 + let (mut view, mut log) = logged_out_view();
219 + view.open_enrollment();
220 + key(&mut view, KeyCode::Esc, &mut log);
221 + assert!(view.server.is_none());
222 + assert!(matches!(view.cancel(), Flow::Exit), "then Esc leaves");
223 + }
224 +
225 + #[test]
226 + fn ticks_do_not_refresh_under_the_overlay() {
227 + let (mut view, mut log) = logged_out_view();
228 + view.open_enrollment();
229 + for _ in 0..POLL_TICKS * 2 {
230 + view.tick(&mut log);
231 + }
232 + assert!(view.server.is_some(), "the overlay survived the poll");
233 + assert!(!view.is_enrolled(), "and the status behind it is untouched");
234 + }
235 +
236 + /// With no route, enrolling says so and keeps the terminal.
237 + ///
238 + /// The seam is the point: this asserts the refusal on a machine that certainly
239 + /// has a route, which is every machine anyone runs the suite on.
240 + #[test]
241 + fn enrolling_without_a_route_refuses_and_names_the_screen() {
242 + let (mut view, mut log) = logged_out_view();
243 + view.route = || false;
244 + view.open_enrollment();
245 +
246 + let flow = view.submit_enrollment(&mut log);
247 + assert!(
248 + matches!(flow, Flow::Continue),
249 + "a sign-in that cannot reach a control server must not take the terminal",
250 + );
251 + let error = view.error.as_deref().expect("the refusal is reported");
252 + assert!(
253 + error.contains("no network"),
254 + "it says what is wrong: {error}"
255 + );
256 + assert!(
257 + error.contains("alloy net"),
258 + "it names the screen that fixes it: {error}",
259 + );
260 + assert!(view.server.is_none(), "the overlay closes either way");
261 + }
262 +
263 + /// With a route, the same submission hands over as before.
264 + #[test]
265 + fn enrolling_with_a_route_still_suspends() {
266 + let (mut view, mut log) = logged_out_view();
267 + view.route = || true;
268 + view.open_enrollment();
269 +
270 + let flow = view.submit_enrollment(&mut log);
271 + assert!(
272 + matches!(flow, Flow::Suspend(_)),
273 + "signing in hands over the terminal",
274 + );
275 + }
276 +
277 + // ---- the admin console ----
278 +
279 + /// The vendor's plane goes straight to the machines page.
280 + #[test]
281 + fn a_hosted_tailnet_opens_the_vendor_console() {
282 + let (url, caveat) = ControlPlane::Hosted
283 + .admin_console()
284 + .expect("the vendor has a console");
285 + assert_eq!(url, "https://login.tailscale.com/admin/machines");
286 + assert!(caveat.is_none(), "nothing is being guessed at");
287 + }
288 +
289 + /// A self-hosted plane gets the control host and is told it is a guess.
290 + #[test]
291 + fn a_self_hosted_tailnet_opens_its_control_host_and_says_it_is_a_guess() {
292 + let (url, caveat) = ControlPlane::SelfHosted("hs.example.org".into())
293 + .admin_console()
294 + .expect("there is an address worth trying");
295 + assert_eq!(url, "https://hs.example.org");
296 + let caveat = caveat.expect("the guess is stated");
297 + assert!(caveat.contains("hs.example.org"), "{caveat}");
298 + assert!(caveat.contains("guess"), "{caveat}");
299 + }
300 +
301 + /// An undetermined plane has no address to invent.
302 + #[test]
303 + fn an_unknown_control_plane_has_no_console() {
304 + assert!(ControlPlane::Unknown.admin_console().is_none());
305 + }
306 +
307 + /// Pressing the key with nothing known says so rather than opening a browser
308 + /// on a URL made up out of nothing.
309 + #[test]
310 + fn the_admin_key_is_refused_when_the_control_plane_is_unknown() {
311 + let (mut view, mut log) = mock_view();
312 + view.control_plane = ControlPlane::Unknown;
313 + view.open_admin_console(&mut log);
314 + let error = view.error.as_deref().expect("it says why");
315 + assert!(error.contains("unknown"), "{error}");
316 + assert!(view.notice.is_none(), "a refusal is not a caveat");
317 + }
318 +
319 + /// The caveat reaches the status line, above a standing health warning.
320 + #[test]
321 + fn the_self_hosted_caveat_outranks_a_health_warning() {
322 + let (mut view, _log) = mock_view();
323 + view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
324 + view.notice = Some("a guess".into());
325 + let (severity, text) = view.status().expect("there is something to say");
326 + assert_eq!(severity, Severity::Warn);
327 + assert_eq!(text, "a guess");
328 + }