max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
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 | + | } |