Skip to main content

max / alloy_tui

mesh: make the mesh view vendor-neutral, surface the control plane alloy tail becomes alloy mesh, with tail kept as an alias so the verb docs/CONSOLE.md named still works. tail.rs becomes mesh.rs and the types follow; only the Tailscale backend names a product now. Two things motivate it. Someone who has never heard of Tailscale should still find the screen listing machines they can reach: mesh says what the thing is, tail says who makes it. And Headscale is a self-hosted control server for the same client, driven by pointing this same tailscale binary at it with --login-server, so supporting it needs no second backend at all. What it needs is a surface that does not read as a product page for one vendor, and a way to see which control plane you are on. That is the ControlPlane enum, read from tailscale debug prefs. debug is explicitly not a stable interface, so every failure path lands on Unknown and the only consequence is a missing title suffix. It is worth the fragility because there is no stable equivalent: status --json carries the tailnet name and MagicDNS suffix but not the control URL, and inferring self-hosted from a non-.ts.net suffix would be a guess about a configurable value. Read once at startup, since changing it requires re-authenticating, and never logged, so a debug subcommand never appears in a pane whose entries are meant to be commands users can run. URL matching is dot-anchored rather than a contains check, so a self-hosted headscale.tailscale.com.example.org is not mistaken for the vendor's own. An empty ControlURL is the hosted plane, which is how a client that never had one set reports it. The title still names the backend: mesh (tailscale), or mesh (tailscale via hs.example.org) when self-hosted. Abstracting the brand off the verb is so the screen is findable, not so the console hides what it drives. The log pane still teaches real tailscale commands. Exit node is deliberately not abstracted along with the brand. It is the standard term across Tailscale and Headscale, it is what someone would search for, and it is the word the logged command uses. A friendlier synonym would have the console teach a term nothing else uses. capture_quiet is new on Invocation: an unlogged call that keeps its output, which probe could not do. It marks calls that should never be logged at all, as against run inside CommandLog::quiet, which suppresses logging for calls that are merely bookkeeping. Caught while checking the help output: the alias rationale was written as a doc comment, and clap renders those as --help text, so an implementation note had become user-facing copy. It is a plain comment now. Verified live: control plane resolves to Hosted on this machine, and the ignored test now fails loudly rather than silently degrading if debug prefs stops yielding a ControlURL. The SelfHosted path is covered by URL fixtures only. No headscale server was available to point a client at, so nothing has confirmed the suffix against a real self-hosted tailnet. docs/CONSOLE.md and CONTINUITY.md still say tail and describe the view as a tailscale front.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-19 18:04 UTC
Signed with PGP, not checked
Commit: dbbbe59feebb41b83485d019a86aa876b7012827
Parent: 1345e61
3 files changed, +271 insertions, -28 deletions
@@ -144,6 +144,18 @@
144 144 self.capture().is_ok()
145 145 }
146 146
147 + /// Run without logging, keeping the output.
148 + ///
149 + /// For console bookkeeping that needs a result rather than a yes/no:
150 + /// one-time capability lookups at startup. Distinct from
151 + /// [`run`](Self::run) with [`CommandLog::quiet`] in intent rather than
152 + /// effect — this is for calls that should never be logged at all, such as
153 + /// `debug` subcommands the console reads but no user should be told to
154 + /// run.
155 + pub fn capture_quiet(&self) -> Result<String> {
156 + self.capture()
157 + }
158 +
147 159 fn capture(&self) -> Result<String> {
148 160 let output = Command::new(&self.program)
149 161 .args(&self.args)
@@ -8,9 +8,9 @@
8 8
9 9 mod audio;
10 10 mod cli;
11 + mod mesh;
11 12 mod net;
12 13 mod shell;
13 - mod tail;
14 14 mod theme;
15 15
16 16 use anyhow::Result;
@@ -35,8 +35,13 @@
35 35 Net,
36 36 /// Audio outputs and inputs
37 37 Audio,
38 - /// Tailnet peers and exit node
39 - Tail,
38 + // `tail` stays as an alias: docs/CONSOLE.md named the verb that way, and
39 + // the muscle memory is worth more than the tidiness of a single name.
40 + // Deliberately a plain comment, not a doc comment — clap turns those into
41 + // `--help` text, and this is a note to maintainers, not to users.
42 + /// Mesh network peers and exit node
43 + #[command(alias = "tail")]
44 + Mesh,
40 45 }
41 46
42 47 fn main() -> Result<()> {
@@ -53,8 +58,8 @@
53 58 let mut view = audio::AudioView::new(&mut log);
54 59 shell::run(&theme, &mut view, &mut log)
55 60 }
56 - Command::Tail => {
57 - let mut view = tail::TailView::new(&mut log);
61 + Command::Mesh => {
62 + let mut view = mesh::MeshView::new(&mut log);
58 63 shell::run(&theme, &mut view, &mut log)
59 64 }
60 65 }
@@ -1,13 +1,37 @@
1 - //! `alloy tail` — a Tailscale front.
1 + //! `alloy mesh` — the mesh network view.
2 2 //!
3 - //! See docs/CONTINUITY.md: Tailscale and Syncthing are what make an Alloy
3 + //! See docs/CONTINUITY.md: a mesh VPN and a file sync are what make an Alloy
4 4 //! machine feel like the same machine as the last one, so the console fronts
5 - //! both. This is the tailnet half.
5 + //! both. This is the mesh half.
6 + //!
7 + //! # Why the surface is not called Tailscale
8 + //!
9 + //! The verb, the types, and every string on screen are generic; only the
10 + //! [`Tailscale`] backend names a product. Two reasons.
11 + //!
12 + //! A user who has never heard of Tailscale should still find the screen that
13 + //! lists the machines they can reach. "mesh" describes what the thing is;
14 + //! "tail" describes who makes it. The backend name stays visible in the title
15 + //! so the abstraction never hides which tool is actually running, and the log
16 + //! pane still teaches the real `tailscale` commands.
17 + //!
18 + //! And Headscale is a self-hosted control server for the *same client*: you
19 + //! point this same `tailscale` binary at it with `--login-server`. So
20 + //! supporting it needs no second backend, only a surface that does not claim
21 + //! to be a product page for one vendor, plus [`ControlPlane`] so a self-hosted
22 + //! tailnet says so. A genuinely different mesh (Netbird, Nebula, ZeroTier)
23 + //! would slot in as another [`Backend`] against this same vocabulary.
6 24 //!
7 25 //! One invocation covers the whole screen. `tailscale status --json` is a
8 26 //! documented contract carrying the local node, every peer, the backend state,
9 27 //! and any health warnings, so unlike `alloy audio` this view costs a single
10 28 //! logged line per refresh.
29 + //!
30 + //! "Exit node" stays as-is throughout, and is not abstracted along with the
31 + //! brand. It is the standard term across Tailscale and Headscale alike, it is
32 + //! what a user would search for, and it is the word the logged command uses.
33 + //! Inventing a friendlier synonym would have the console teach a term nothing
34 + //! else in the ecosystem uses.
11 35
12 36 use std::collections::HashMap;
13 37
@@ -78,8 +102,36 @@
78 102 }
79 103 }
80 104
105 + /// Which control server the mesh is coordinated by.
106 + ///
107 + /// The one place the Tailscale/Headscale distinction is user-visible. A
108 + /// self-hosted tailnet looks identical in every other respect, and "which
109 + /// control plane am I on" is exactly the question someone running Headscale
110 + /// wants answered without dropping to a shell.
111 + #[derive(Debug, Clone, PartialEq, Eq)]
112 + pub enum ControlPlane {
113 + /// The vendor's own control plane.
114 + Hosted,
115 + /// A self-hosted control server, named by host.
116 + SelfHosted(String),
117 + /// Not determined. The lookup is best-effort (see
118 + /// [`Tailscale::control_plane`]), and an unknown control plane is not
119 + /// worth a warning — the mesh works either way.
120 + Unknown,
121 + }
122 +
123 + impl ControlPlane {
124 + /// Suffix for the view title, empty when there is nothing worth saying.
125 + fn label(&self) -> String {
126 + match self {
127 + ControlPlane::SelfHosted(host) => format!(" via {host}"),
128 + ControlPlane::Hosted | ControlPlane::Unknown => String::new(),
129 + }
130 + }
131 + }
132 +
81 133 #[derive(Debug, Clone)]
82 - pub struct TailStatus {
134 + pub struct MeshStatus {
83 135 /// `Running`, `Stopped`, `NeedsLogin`, and friends. Reported verbatim
84 136 /// rather than mapped to an enum: it is a Tailscale-owned vocabulary that
85 137 /// gains members, and showing an unfamiliar one is better than collapsing
@@ -89,7 +141,7 @@
89 141 pub peers: Vec<Peer>,
90 142 }
91 143
92 - impl TailStatus {
144 + impl MeshStatus {
93 145 fn is_running(&self) -> bool {
94 146 self.backend_state == "Running"
95 147 }
@@ -97,7 +149,15 @@
97 149
98 150 pub trait Backend {
99 151 fn name(&self) -> &'static str;
100 - fn status(&self, log: &mut CommandLog) -> Result<TailStatus>;
152 + fn status(&self, log: &mut CommandLog) -> Result<MeshStatus>;
153 +
154 + /// Which control server coordinates this mesh.
155 + ///
156 + /// Read once at startup rather than per refresh: changing it requires
157 + /// re-authenticating, so it cannot change under a running view.
158 + fn control_plane(&self) -> ControlPlane {
159 + ControlPlane::Unknown
160 + }
101 161
102 162 /// Route traffic through `peer`.
103 163 fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()>;
@@ -107,6 +167,10 @@
107 167 }
108 168
109 169 /// Pick a backend: `tailscale` when it answers, the mock otherwise.
170 + ///
171 + /// Tailscale is the only real implementation today, and covers Headscale too
172 + /// since Headscale drives this same client. A different mesh would be another
173 + /// arm here.
110 174 pub fn detect() -> Box<dyn Backend> {
111 175 if Invocation::new("tailscale").arg("version").probe() {
112 176 Box::new(Tailscale)
@@ -122,13 +186,39 @@
122 186 "tailscale"
123 187 }
124 188
125 - fn status(&self, log: &mut CommandLog) -> Result<TailStatus> {
189 + fn status(&self, log: &mut CommandLog) -> Result<MeshStatus> {
126 190 let raw = Invocation::new("tailscale")
127 191 .args(["status", "--json"])
128 192 .run(log)?;
129 193 parse_status(&raw)
130 194 }
131 195
196 + /// Read the control server from `tailscale debug prefs`.
197 + ///
198 + /// `debug` is explicitly not a stable interface, which is why this is
199 + /// best-effort and every failure path lands on [`ControlPlane::Unknown`]:
200 + /// the command missing, the output not being JSON, the key being renamed.
201 + /// The cost of being wrong is a missing title suffix, so a fragile source
202 + /// is acceptable here in a way it would not be for the peer list. It is
203 + /// also unlogged and runs once, so a `debug` invocation never appears in a
204 + /// pane that teaches commands users should run themselves.
205 + ///
206 + /// There is no stable equivalent. `status --json` carries the tailnet name
207 + /// and MagicDNS suffix but not the control URL, and inferring "self-hosted"
208 + /// from a non-`.ts.net` suffix would be a guess about a configurable value.
209 + fn control_plane(&self) -> ControlPlane {
210 + let Ok(raw) = Invocation::new("tailscale")
211 + .args(["debug", "prefs"])
212 + .capture_quiet()
213 + else {
214 + return ControlPlane::Unknown;
215 + };
216 + let Ok(prefs) = serde_json::from_str::<TsPrefs>(&raw) else {
217 + return ControlPlane::Unknown;
218 + };
219 + classify_control_url(prefs.control_url.as_deref().unwrap_or_default())
220 + }
221 +
132 222 fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()> {
133 223 // Addressed by IP rather than hostname: hostnames collide (three
134 224 // devices on this tailnet answer to "localhost") and MagicDNS may be
@@ -136,7 +226,7 @@
136 226 let ip = peer
137 227 .ip
138 228 .as_deref()
139 - .context("peer has no tailnet address to route through")?;
229 + .context("peer has no mesh address to route through")?;
140 230 Invocation::new("tailscale")
141 231 .arg("set")
142 232 .arg(format!("--exit-node={ip}"))
@@ -161,9 +251,9 @@
161 251 "mock"
162 252 }
163 253
164 - fn status(&self, log: &mut CommandLog) -> Result<TailStatus> {
165 - log.record("# no tailscale; showing mock tailnet", Severity::Warn);
166 - Ok(TailStatus {
254 + fn status(&self, log: &mut CommandLog) -> Result<MeshStatus> {
255 + log.record("# no mesh client found; showing mock peers", Severity::Warn);
256 + Ok(MeshStatus {
167 257 backend_state: "Running".into(),
168 258 health: Vec::new(),
169 259 peers: vec![
@@ -230,6 +320,42 @@
230 320 peer: HashMap<String, TsPeer>,
231 321 }
232 322
323 + #[derive(Deserialize)]
324 + struct TsPrefs {
325 + #[serde(rename = "ControlURL")]
326 + control_url: Option<String>,
327 + }
328 +
329 + /// Classify a Tailscale `ControlURL` as vendor-hosted or self-hosted.
330 + ///
331 + /// An empty value means the default, which is how a client that has never had
332 + /// one set reports it.
333 + fn classify_control_url(url: &str) -> ControlPlane {
334 + let url = url.trim();
335 + if url.is_empty() {
336 + return ControlPlane::Hosted;
337 + }
338 + // Strip scheme, then any path/port, leaving the host.
339 + let host = url
340 + .split_once("://")
341 + .map_or(url, |(_, rest)| rest)
342 + .split(['/', ':'])
343 + .next()
344 + .unwrap_or_default();
345 +
346 + if host.is_empty() {
347 + return ControlPlane::Unknown;
348 + }
349 + // Matched on a dot-anchored suffix rather than `contains`, so a
350 + // self-hosted `headscale.tailscale.com.example.org` is not mistaken for
351 + // the vendor's.
352 + if host == "tailscale.com" || host.ends_with(".tailscale.com") {
353 + ControlPlane::Hosted
354 + } else {
355 + ControlPlane::SelfHosted(host.to_string())
356 + }
357 + }
358 +
233 359 #[derive(Deserialize)]
234 360 struct TsPeer {
235 361 #[serde(default)]
@@ -278,7 +404,7 @@
278 404 }
279 405 }
280 406
281 - fn parse_status(raw: &str) -> Result<TailStatus> {
407 + fn parse_status(raw: &str) -> Result<MeshStatus> {
282 408 let parsed: TsStatus =
283 409 serde_json::from_str(raw).context("tailscale emitted invalid JSON")?;
284 410
@@ -301,7 +427,7 @@
301 427 peers.insert(0, self_node.into_peer(true));
302 428 }
303 429
304 - Ok(TailStatus {
430 + Ok(MeshStatus {
305 431 backend_state: parsed.backend_state,
306 432 health: parsed.health.unwrap_or_default(),
307 433 peers,
@@ -334,19 +460,25 @@
334 460 }
335 461
336 462 /// The `alloy tail` screen.
337 - pub struct TailView {
463 + pub struct MeshView {
338 464 backend: Box<dyn Backend>,
339 - status: Option<TailStatus>,
465 + status: Option<MeshStatus>,
466 + control_plane: ControlPlane,
340 467 cursor: Cursor,
341 468 error: Option<String>,
342 469 ticks: u64,
343 470 }
344 471
345 - impl TailView {
472 + impl MeshView {
346 473 pub fn new(log: &mut CommandLog) -> Self {
474 + let backend = detect();
475 + // Once, at startup: changing the control server requires
476 + // re-authenticating, so it cannot change under a running view.
477 + let control_plane = backend.control_plane();
347 478 let mut view = Self {
348 - backend: detect(),
479 + backend,
349 480 status: None,
481 + control_plane,
350 482 cursor: Cursor::new(),
351 483 error: None,
352 484 ticks: 0,
@@ -430,9 +562,19 @@
430 562 format!("{kept}…")
431 563 }
432 564
433 - impl View for TailView {
565 + impl View for MeshView {
566 + /// "mesh (tailscale)", or "mesh (tailscale via hs.example.org)" on a
567 + /// self-hosted control plane.
568 + ///
569 + /// The backend name stays visible: abstracting the brand off the verb is
570 + /// so the screen is findable by someone who does not know the product, not
571 + /// so the console conceals what it is driving.
434 572 fn title(&self) -> String {
435 - format!("tailnet ({})", self.backend.name())
573 + format!(
574 + "mesh ({}{})",
575 + self.backend.name(),
576 + self.control_plane.label()
577 + )
436 578 }
437 579
438 580 fn hints(&self) -> Vec<Hint> {
@@ -636,13 +778,84 @@
636 778 assert_eq!(status.peers[0].ip, None);
637 779 }
638 780
781 + // ---- control plane ----
782 +
783 + // An empty ControlURL is how a client that never had one set reports the
784 + // default, so it must not read as self-hosted.
785 + #[test]
786 + fn an_unset_control_url_is_the_hosted_plane() {
787 + assert_eq!(classify_control_url(""), ControlPlane::Hosted);
788 + assert_eq!(classify_control_url(" "), ControlPlane::Hosted);
789 + }
790 +
791 + #[test]
792 + fn the_vendor_control_url_is_recognized() {
793 + assert_eq!(
794 + classify_control_url("https://controlplane.tailscale.com"),
795 + ControlPlane::Hosted
796 + );
797 + assert_eq!(classify_control_url("https://tailscale.com"), ControlPlane::Hosted);
798 + }
799 +
800 + #[test]
801 + fn a_headscale_url_is_reported_by_host() {
802 + assert_eq!(
803 + classify_control_url("https://headscale.example.org"),
804 + ControlPlane::SelfHosted("headscale.example.org".into())
805 + );
806 + assert_eq!(
807 + classify_control_url("https://hs.example.org:8080/some/path"),
808 + ControlPlane::SelfHosted("hs.example.org".into()),
809 + "port and path are stripped, leaving the host"
810 + );
811 + assert_eq!(
812 + classify_control_url("http://10.0.0.5:8080"),
813 + ControlPlane::SelfHosted("10.0.0.5".into())
814 + );
815 + }
816 +
817 + // Suffix matching is dot-anchored, so a self-hosted server whose name
818 + // merely contains the vendor's domain is not mistaken for it.
819 + #[test]
820 + fn a_lookalike_host_is_not_mistaken_for_the_vendor() {
821 + assert_eq!(
822 + classify_control_url("https://headscale.tailscale.com.example.org"),
823 + ControlPlane::SelfHosted("headscale.tailscale.com.example.org".into())
824 + );
825 + assert_eq!(
826 + classify_control_url("https://nottailscale.com"),
827 + ControlPlane::SelfHosted("nottailscale.com".into())
828 + );
829 + }
830 +
831 + // Only a self-hosted plane is worth title space; the other two say nothing
832 + // rather than "(hosted)" on every screen.
833 + #[test]
834 + fn only_a_self_hosted_plane_earns_a_title_suffix() {
835 + assert_eq!(ControlPlane::Hosted.label(), "");
836 + assert_eq!(ControlPlane::Unknown.label(), "");
837 + assert_eq!(
838 + ControlPlane::SelfHosted("hs.example.org".into()).label(),
839 + " via hs.example.org"
840 + );
841 + }
842 +
843 + #[test]
844 + fn the_title_names_the_backend_and_a_self_hosted_plane() {
845 + let (mut view, _log) = mock_view();
846 + assert_eq!(view.title(), "mesh (mock)");
847 + view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
848 + assert_eq!(view.title(), "mesh (mock via hs.example.org)");
849 + }
850 +
639 851 // ---- view behavior ----
640 852
641 - fn mock_view() -> (TailView, CommandLog) {
853 + fn mock_view() -> (MeshView, CommandLog) {
642 854 let mut log = CommandLog::new();
643 - let mut view = TailView {
855 + let mut view = MeshView {
644 856 backend: Box::new(Mock),
645 857 status: None,
858 + control_plane: ControlPlane::Unknown,
646 859 cursor: Cursor::new(),
647 860 error: None,
648 861 ticks: 0,
@@ -701,9 +914,10 @@
701 914 #[test]
702 915 fn acting_with_no_selection_is_inert() {
703 916 let mut log = CommandLog::new();
704 - let mut view = TailView {
917 + let mut view = MeshView {
705 918 backend: Box::new(Mock),
706 919 status: None,
920 + control_plane: ControlPlane::Unknown,
707 921 cursor: Cursor::new(),
708 922 error: None,
709 923 ticks: 0,
@@ -722,8 +936,20 @@
722 936 let mut log = CommandLog::new();
723 937 let status = Tailscale.status(&mut log).expect("tailscale should answer");
724 938
725 - assert!(!status.peers.is_empty(), "a tailnet has at least this machine");
939 + assert!(!status.peers.is_empty(), "a mesh has at least this machine");
726 940 assert!(status.peers[0].is_self, "this machine sorts first");
941 +
942 + // The control-plane lookup rides an unstable `debug` interface, so
943 + // what matters is that it produced *something* rather than silently
944 + // degrading to Unknown on a working client.
945 + let control = Tailscale.control_plane();
946 + println!("control plane: {control:?}");
947 + assert_ne!(
948 + control,
949 + ControlPlane::Unknown,
950 + "`tailscale debug prefs` no longer yields a ControlURL; the lookup \
951 + has degraded and the title will silently drop its suffix"
952 + );
727 953 println!("backend: {} health: {:?}", status.backend_state, status.health);
728 954 for peer in &status.peers {
729 955 assert!(!peer.hostname.is_empty(), "every row is identifiable");