| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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");
|