Skip to main content

max / alloy

Open the tailnet admin console from alloy mesh The peer list can pick an exit node and nothing else, because the `tailscale` client can do nothing else: measured 2026-09-07, its CLI has no verb for deleting a node, editing an ACL or minting an auth key. Every one of those is an api.tailscale.com call, and the admin console is the only door a machine already has. `w` opens it, matching `alloy sync`'s key for the same act. Not `tailscale web`. That serves localhost:8088, is aimed at NAS appliances, and reports this machine's own state, which is what the list behind the key already shows. On a self-hosted control plane the address is the control host's root, and the status line says it is a guess. Headscale serves the control API there and no UI, so this may 404; the alternative was a key that does nothing for a self-hosted user and names no address to try, which is less use than a guess that admits to being one. `notice` carries that caveat, distinct from `error` because a working key with a caveat is not a failure. The manual also drops the share-list editor from "not built yet", where 54d3461 left it, and records the two `w` keys.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-07 23:08 UTC
Signed with PGP, not checked
Commit: 7ec682147cf2beb314f76d148fb61c3b6d508ed4
Parent: c37786b
4 files changed, +141 insertions, -4 deletions
@@ -17,7 +17,7 @@
17 17 | `alloy audio` | Streams, outputs and inputs, and which is routed where |
18 18 | `alloy bluetooth` | Devices, and what state each one is actually in |
19 19 | `alloy display` | Outputs sway is driving: scale, rotation, on or off |
20 - | `alloy mesh` | Mesh VPN peers and exit node (alias: `alloy tail`) |
20 + | `alloy mesh` | Mesh VPN peers, exit node, admin console (alias: `alloy tail`) |
21 21 | `alloy sync` | Synchronized folders, devices, and pending invitations |
22 22 | `alloy pkg` | What is installed and where it lives |
23 23 | `alloy disk` | Attached storage: mount, unmount, eject |
@@ -90,6 +90,5 @@
90 90 - `alloy theme <name>` for swapping the runtime theme without logging out.
91 91 Today the theme is chosen in `alloy settings` and applied at the next login.
92 92 - MagicDNS lookup and share/unshare in `alloy mesh`.
93 - - Editing an existing folder's share list in `alloy sync`.
94 93 - Multi-output mode picking in `alloy display`. Scale and enable work; the mode
95 94 picker waits on a machine with two displays to test against.
@@ -80,6 +80,13 @@
80 80 last seen, and lets you pick or clear an exit node. This machine is listed
81 81 first. If you are on a self-hosted control plane, the title says so.
82 82
83 + Anything that acts on the whole tailnet rather than on this machine — deleting
84 + a node, editing the ACL, minting an auth key — lives in the admin console and
85 + nowhere else. The `tailscale` client has no verb for any of it. `w` opens that
86 + console in a browser. On a self-hosted control plane the address is a guess at
87 + the control host's root, and the status line says so, because Headscale serves
88 + the control API there and no UI.
89 +
83 90 The plain command is `run0 tailscale up`, and `alloy tail` is kept as an alias
84 91 for the verb.
85 92
@@ -95,7 +102,9 @@
95 102 - Folders shows each synchronized folder's path, share mode, and whether it is
96 103 paused. `a` adds one, `d` removes one, and both confirms tell you what is
97 104 *not* deleted, because "remove folder" must never read as "delete my
98 - documents".
105 + documents". `s` opens the folder's share list, where each device toggles on
106 + or off as you press it; this machine is not offered there, because taking it
107 + off the list is what `d` does.
99 108 - Devices shows connection state, this machine first.
100 109 - Pending lists devices that have added you and are knocking. Accepting one
101 110 finishes the pairing.
@@ -119,7 +128,8 @@
119 128 `d` there.
120 129
121 130 Syncthing's own web UI is still at `127.0.0.1:8384` for anything the console
122 - does not cover.
131 + does not cover — ignore patterns, versioning, per-folder advanced settings.
132 + `w` opens it rather than making you remember the address.
123 133
124 134 ## Backup: restic
125 135
@@ -82,6 +82,18 @@
82 82 /// process per second to learn nothing.
83 83 const POLL_TICKS: u64 = 5;
84 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 +
85 97 /// Go's zero time, which `tailscale status --json` emits for `LastSeen` on any
86 98 /// peer that is currently online. Rendered literally it reads "last seen in
87 99 /// year 1".
@@ -154,6 +166,28 @@
154 166 }
155 167
156 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 +
157 191 /// Suffix for the view title, empty when there is nothing worth saying.
158 192 fn label(&self) -> String {
159 193 match self {
@@ -622,6 +656,12 @@
622 656 control_plane: ControlPlane,
623 657 cursor: Cursor,
624 658 error: Option<String>,
659 + /// A caveat about what the console just did, distinct from a failure.
660 + ///
661 + /// The admin-console key needs one: on a self-hosted control plane the
662 + /// address it opens is a guess, and a guess that works silently teaches
663 + /// the wrong thing about what Alloy knows.
664 + notice: Option<String>,
625 665 ticks: u64,
626 666 /// The control-server field, while the enrollment overlay is open.
627 667 ///
@@ -652,6 +692,7 @@
652 692 control_plane,
653 693 cursor: Cursor::new(),
654 694 error: None,
695 + notice: None,
655 696 ticks: 0,
656 697 server: None,
657 698 route: machine_has_route,
@@ -707,6 +748,31 @@
707 748 self.finish(result, log);
708 749 }
709 750
751 + /// Hand the tailnet's admin console to a browser.
752 + ///
753 + /// The peer list can set an exit node and nothing else, because the CLI it
754 + /// drives can do nothing else. Every tailnet-wide act — deleting the two
755 + /// dead `fw12` nodes, editing the ACL, minting an auth key — is an API call
756 + /// this console does not make and a page a browser can already open.
757 + ///
758 + /// Not a suspend, for the same reason `alloy sync`'s `w` is not: a browser
759 + /// owns a window, so the peer list is still here afterwards.
760 + fn open_admin_console(&mut self, log: &mut CommandLog) {
761 + let Some((url, caveat)) = self.control_plane.admin_console() else {
762 + self.error = Some(
763 + "control plane unknown, so there is no console to open; \
764 + `tailscale debug prefs` names it"
765 + .into(),
766 + );
767 + return;
768 + };
769 + self.notice = caveat;
770 + if let Err(err) = Invocation::new("alloy-open").arg(url).launch(log) {
771 + self.error = Some(err.to_string());
772 + self.notice = None;
773 + }
774 + }
775 +
710 776 fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
711 777 match result {
712 778 Ok(()) => log.quiet(|log| self.refresh(log)),
@@ -923,6 +989,7 @@
923 989 hint("j/k", "select"),
924 990 hint("e", "exit node"),
925 991 hint("x", "clear exit"),
992 + hint("w", "admin console"),
926 993 hint("r", "refresh"),
927 994 ]
928 995 }
@@ -931,6 +998,11 @@
931 998 if let Some(error) = &self.error {
932 999 return Some((Severity::Error, error.clone()));
933 1000 }
1001 + // Above the health warnings: the notice is about the key just pressed,
1002 + // and a standing warning about the mesh would otherwise bury it.
1003 + if let Some(notice) = &self.notice {
1004 + return Some((Severity::Warn, notice.clone()));
1005 + }
934 1006 let status = self.status.as_ref()?;
935 1007 // A health warning is Tailscale telling the user something is wrong
936 1008 // that the peer list alone will not show.
@@ -987,6 +1059,7 @@
987 1059
988 1060 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
989 1061 self.error = None;
1062 + self.notice = None;
990 1063
991 1064 // The overlay eats every key it is given, so an `x` typed into a server
992 1065 // name never reaches the clear-exit binding underneath.
@@ -1011,6 +1084,7 @@
1011 1084 KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
1012 1085 KeyCode::Char('e') => self.set_exit_node(log),
1013 1086 KeyCode::Char('x') => self.clear_exit_node(log),
1087 + KeyCode::Char('w') => self.open_admin_console(log),
1014 1088 KeyCode::Char('r') => self.refresh(log),
1015 1089 _ => {}
1016 1090 }
@@ -223,6 +223,7 @@
223 223 control_plane: ControlPlane::Unknown,
224 224 cursor: Cursor::new(),
225 225 error: None,
226 + notice: None,
226 227 ticks: 0,
227 228 server: None,
228 229 route: || true,
@@ -612,3 +613,56 @@
612 613 "signing in hands over the terminal",
613 614 );
614 615 }
616 +
617 + // ---- the admin console ----
618 +
619 + /// The vendor's plane goes straight to the machines page.
620 + #[test]
621 + fn a_hosted_tailnet_opens_the_vendor_console() {
622 + let (url, caveat) = ControlPlane::Hosted
623 + .admin_console()
624 + .expect("the vendor has a console");
625 + assert_eq!(url, "https://login.tailscale.com/admin/machines");
626 + assert!(caveat.is_none(), "nothing is being guessed at");
627 + }
628 +
629 + /// A self-hosted plane gets the control host and is told it is a guess.
630 + #[test]
631 + fn a_self_hosted_tailnet_opens_its_control_host_and_says_it_is_a_guess() {
632 + let (url, caveat) = ControlPlane::SelfHosted("hs.example.org".into())
633 + .admin_console()
634 + .expect("there is an address worth trying");
635 + assert_eq!(url, "https://hs.example.org");
636 + let caveat = caveat.expect("the guess is stated");
637 + assert!(caveat.contains("hs.example.org"), "{caveat}");
638 + assert!(caveat.contains("guess"), "{caveat}");
639 + }
640 +
641 + /// An undetermined plane has no address to invent.
642 + #[test]
643 + fn an_unknown_control_plane_has_no_console() {
644 + assert!(ControlPlane::Unknown.admin_console().is_none());
645 + }
646 +
647 + /// Pressing the key with nothing known says so rather than opening a browser
648 + /// on a URL made up out of nothing.
649 + #[test]
650 + fn the_admin_key_is_refused_when_the_control_plane_is_unknown() {
651 + let (mut view, mut log) = mock_view();
652 + view.control_plane = ControlPlane::Unknown;
653 + view.open_admin_console(&mut log);
654 + let error = view.error.as_deref().expect("it says why");
655 + assert!(error.contains("unknown"), "{error}");
656 + assert!(view.notice.is_none(), "a refusal is not a caveat");
657 + }
658 +
659 + /// The caveat reaches the status line, above a standing health warning.
660 + #[test]
661 + fn the_self_hosted_caveat_outranks_a_health_warning() {
662 + let (mut view, _log) = mock_view();
663 + view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
664 + view.notice = Some("a guess".into());
665 + let (severity, text) = view.status().expect("there is something to say");
666 + assert_eq!(severity, Severity::Warn);
667 + assert_eq!(text, "a guess");
668 + }