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