1use std::collections::HashMap;
10use std::net::IpAddr;
11
12use tailscale_cli::{ExecError, Invocation, Output};
13
14use crate::context::{SelfIdentity, ToolContext};
15use crate::error::{ToolError, ToolResult};
16use crate::meta::ToolMeta;
17use crate::version::{Version, satisfies};
18
19pub async fn run(ctx: &ToolContext, meta: &ToolMeta, invocation: Invocation) -> ToolResult<Output> {
21 let display = displayed(ctx, &invocation);
22 let output = ctx
23 .local
24 .run(invocation)
25 .await
26 .map_err(|e| exec_error(ctx, &display, e))?;
27 if output.success() {
28 return Ok(output);
29 }
30 Err(command_failure(ctx, meta, &display, &output))
31}
32
33pub async fn run_tolerant(
46 ctx: &ToolContext,
47 meta: &ToolMeta,
48 invocation: Invocation,
49) -> ToolResult<Output> {
50 let display = displayed(ctx, &invocation);
51 let output = ctx
52 .local
53 .run(invocation)
54 .await
55 .map_err(|e| exec_error(ctx, &display, e))?;
56 if output.success() {
57 return Ok(output);
58 }
59 let stderr = ctx.redactor.apply(&output.stderr);
60 if is_unrecognised(&stderr) {
61 return Err(version_error(ctx, meta));
62 }
63 if needs_operator(&stderr) {
64 return Err(ToolError::needs_operator(&stderr));
65 }
66 Ok(output)
69}
70
71pub async fn run_text(
73 ctx: &ToolContext,
74 meta: &ToolMeta,
75 invocation: Invocation,
76) -> ToolResult<String> {
77 let output = run(ctx, meta, invocation).await?;
78 Ok(output.stdout_str().into_owned())
79}
80
81pub(crate) fn displayed(ctx: &ToolContext, invocation: &Invocation) -> String {
92 ctx.redactor.apply(&invocation.display()).into_owned()
93}
94
95fn exec_error(ctx: &ToolContext, display: &str, error: ExecError) -> ToolError {
103 let told = |error: &ExecError| ctx.redactor.apply(&error.to_string()).into_owned();
104 match error {
105 ExecError::BinaryNotFound { .. }
106 | ExecError::BinaryNotExecutable { .. }
107 | ExecError::Spawn { .. } => {
108 ToolError::backend_unavailable("the local surface", &told(&error))
109 }
110 ExecError::Timeout {
111 timeout, printed, ..
112 } => ToolError::timeout(display, timeout.as_secs(), &ctx.redactor.apply(&printed)),
113 ExecError::Io { .. } | ExecError::SecretFile(_) => {
114 ToolError::new(crate::error::ErrorCode::CliFailed, told(&error))
115 }
116 }
117}
118
119pub fn command_failure(
121 ctx: &ToolContext,
122 meta: &ToolMeta,
123 display: &str,
124 output: &Output,
125) -> ToolError {
126 let stderr = ctx.redactor.apply(&output.stderr);
127 if is_unrecognised(&stderr) {
128 return version_error(ctx, meta);
129 }
130 if needs_operator(&stderr) {
131 return ToolError::needs_operator(&stderr);
132 }
133 if is_not_found(&stderr) {
134 return ToolError::not_found(stderr.trim());
135 }
136 ToolError::cli_failed(display, output.exit_code, &stderr)
137}
138
139fn is_unrecognised(stderr: &str) -> bool {
144 const MARKERS: &[&str] = &[
145 "flag provided but not defined",
146 "unknown flag",
147 "unknown subcommand",
148 "unknown command",
149 "is not a tailscale command",
150 "unrecognized command",
151 ];
152 let lowered = stderr.to_ascii_lowercase();
153 MARKERS.iter().any(|m| lowered.contains(m))
154}
155
156fn needs_operator(stderr: &str) -> bool {
158 const MARKERS: &[&str] = &[
159 "access denied",
160 "--operator=",
161 "must be run as root",
162 "permission denied",
163 "you must be root",
164 ];
165 let lowered = stderr.to_ascii_lowercase();
166 MARKERS.iter().any(|m| lowered.contains(m))
167}
168
169fn is_not_found(stderr: &str) -> bool {
171 const MARKERS: &[&str] = &["no such", "not found", "does not exist", "unknown peer"];
172 let lowered = stderr.to_ascii_lowercase();
173 MARKERS.iter().any(|m| lowered.contains(m))
174}
175
176fn version_error(ctx: &ToolContext, meta: &ToolMeta) -> ToolError {
182 let needs = meta
183 .min_version
184 .map(str::to_owned)
185 .unwrap_or_else(|| crate::version::SUPPORTED_FLOOR.to_string());
186 let found = ctx
187 .cli_version
188 .map_or_else(|| "unknown".to_owned(), |v| v.to_string());
189 ToolError::unsupported_version(meta.name, &needs, &found)
190}
191
192pub fn version_permits(ctx: &ToolContext, meta: &ToolMeta) -> ToolResult<()> {
197 if satisfies(ctx.cli_version, meta.min_version) {
198 Ok(())
199 } else {
200 Err(version_error(ctx, meta))
201 }
202}
203
204pub async fn probe_version(backend: &dyn tailscale_cli::LocalBackend) -> Option<Version> {
210 let output = backend
211 .run(Invocation::read(["version"]))
212 .await
213 .ok()
214 .filter(Output::success)?;
215 Version::parse_cli_output(&output.stdout_str())
216}
217
218pub async fn probe_identity(backend: &dyn tailscale_cli::LocalBackend) -> SelfIdentity {
229 status_document(backend)
230 .await
231 .as_ref()
232 .map(identity_in)
233 .unwrap_or_default()
234}
235
236pub async fn probe_node(
242 backend: &dyn tailscale_cli::LocalBackend,
243) -> (SelfIdentity, HashMap<IpAddr, String>) {
244 let Some(document) = status_document(backend).await else {
245 return (SelfIdentity::default(), HashMap::new());
246 };
247 (identity_in(&document), peer_names_in(&document))
248}
249
250pub(crate) async fn status_document(
252 backend: &dyn tailscale_cli::LocalBackend,
253) -> Option<serde_json::Value> {
254 let output = backend
255 .run(Invocation::read(["status", "--json"]))
256 .await
257 .ok()
258 .filter(Output::success)?;
259 serde_json::from_str(&output.stdout_str()).ok()
260}
261
262fn identity_in(document: &serde_json::Value) -> SelfIdentity {
263 let node = &document["Self"];
264 SelfIdentity {
265 node_id: node["ID"].as_str().map(str::to_owned),
266 numeric_id: None,
270 addresses: node["TailscaleIPs"]
271 .as_array()
272 .map(|ips| {
273 ips.iter()
274 .filter_map(|ip| ip.as_str().map(str::to_owned))
275 .collect()
276 })
277 .unwrap_or_default(),
278 dns_name: node["DNSName"].as_str().map(str::to_owned),
279 }
280}
281
282fn peer_names_in(document: &serde_json::Value) -> HashMap<IpAddr, String> {
289 let mut named = HashMap::new();
290 let peers = document["Peer"]
291 .as_object()
292 .into_iter()
293 .flat_map(|by_key| by_key.values());
294 for node in std::iter::once(&document["Self"]).chain(peers) {
295 let Some(name) = node["DNSName"].as_str() else {
296 continue;
297 };
298 let name = name.trim_end_matches('.');
299 for address in node["TailscaleIPs"].as_array().into_iter().flatten() {
300 if let Some(address) = address.as_str().and_then(|a| a.parse().ok()) {
301 named.insert(address, name.to_owned());
302 }
303 }
304 }
305 named
306}
307
308#[cfg(test)]
309mod tests {
310 use std::sync::Arc;
311
312 use super::*;
313 use crate::context::{Identity, PathPolicy};
314 use crate::error::{ErrorCode, Redactor};
315 use crate::meta::{Tier, ToolMeta, Toolset};
316 use crate::testing::StubBackend;
317
318 fn meta(min_version: Option<&'static str>) -> ToolMeta {
319 ToolMeta {
320 name: "tailscale_service_list",
321 toolset: Toolset::LocalStatus,
322 tier: Tier::Read,
323 summary: "",
324 self_severing: false,
325 severs_local_node: false,
326 requires_confirmation: false,
327 idempotent: true,
328 varying_tier: false,
329 min_version,
330 platforms: None,
331 }
332 }
333
334 fn context(backend: StubBackend, cli_version: Option<Version>) -> ToolContext {
335 ToolContext {
336 local: Arc::new(backend),
337 tailnet: None,
338 redactor: Redactor::default(),
339 max_result_bytes: 1 << 20,
340 identity: Identity::default(),
341 cli_version,
342 paths: PathPolicy::default(),
343 devices: Default::default(),
344 max_tier: crate::meta::Tier::Destructive,
345 }
346 }
347
348 #[tokio::test]
349 async fn an_unknown_subcommand_reports_the_minimum_version() {
350 let ctx = context(
351 StubBackend::failure(1, "tailscale service: unknown subcommand \"list\"\n"),
352 Some(Version::new(1, 78, 0)),
353 );
354 let err = run(
355 &ctx,
356 &meta(Some("1.94")),
357 Invocation::read(["service", "list"]),
358 )
359 .await
360 .expect_err("should fail");
361 assert_eq!(err.code, ErrorCode::UnsupportedVersion);
362 assert!(err.message.contains("1.94"), "{}", err.message);
363 assert!(err.message.contains("1.78.0"), "{}", err.message);
364 }
365
366 #[tokio::test]
367 async fn an_unknown_flag_reports_the_minimum_version() {
368 let ctx = context(
369 StubBackend::failure(1, "flag provided but not defined: -report-posture\n"),
370 None,
371 );
372 let err = run(&ctx, &meta(Some("1.58")), Invocation::read(["set"]))
373 .await
374 .expect_err("should fail");
375 assert_eq!(err.code, ErrorCode::UnsupportedVersion);
376 assert!(err.message.contains("1.58"), "{}", err.message);
377 }
378
379 #[tokio::test]
380 async fn a_tool_without_a_minimum_falls_back_to_the_floor() {
381 let ctx = context(StubBackend::failure(1, "unknown subcommand\n"), None);
382 let err = run(&ctx, &meta(None), Invocation::read(["nonsense"]))
383 .await
384 .expect_err("should fail");
385 assert_eq!(err.code, ErrorCode::UnsupportedVersion);
386 assert!(
387 err.message
388 .contains(&crate::version::SUPPORTED_FLOOR.to_string()),
389 "{}",
390 err.message
391 );
392 }
393
394 #[tokio::test]
395 async fn a_peer_named_for_an_operator_is_still_not_found() {
396 let ctx = context(
397 StubBackend::failure(1, "no such peer: operator-laptop\n"),
398 None,
399 );
400 let err = run(&ctx, &meta(None), Invocation::read(["ping"]))
401 .await
402 .expect_err("should fail");
403 assert_eq!(err.code, ErrorCode::NotFound, "{err:?}");
404 }
405
406 #[tokio::test]
407 async fn a_secret_in_the_error_stream_does_not_reach_the_caller() {
408 let ctx = context(
409 StubBackend::failure(1, "bad key tskey-auth-example1CNTRL-secretpart\n"),
410 None,
411 );
412 let err = run(&ctx, &meta(None), Invocation::read(["up"]))
413 .await
414 .expect_err("should fail");
415 assert!(
416 !err.stderr
417 .as_deref()
418 .unwrap_or_default()
419 .contains("secretpart"),
420 "{err:?}"
421 );
422 }
423
424 #[test]
425 fn a_broken_pipe_or_an_unwritable_secret_file_is_a_cli_failure() {
426 let ctx = context(StubBackend::ok(""), None);
427 for error in [
428 ExecError::Io {
429 command: "tailscale up".to_owned(),
430 source: std::io::Error::other("broken pipe"),
431 },
432 ExecError::SecretFile(std::io::Error::other("no space left on device")),
433 ] {
434 let told = error.to_string();
435 assert_eq!(
436 exec_error(&ctx, "tailscale up", error).code,
437 ErrorCode::CliFailed,
438 "{told}"
439 );
440 }
441 }
442
443 #[tokio::test]
445 async fn the_status_probe_names_this_node_and_its_peers() {
446 let backend = StubBackend::missing().on(
447 ["status", "--json"],
448 tailscale_cli::stub::Reply::ok(
449 serde_json::json!({
450 "Self": {
451 "ID": "n1111111CNTRL",
452 "DNSName": "workstation.example-tailnet.ts.net.",
453 "TailscaleIPs": ["100.64.0.1", "fd7a:115c:a1e0::1"],
454 },
455 "Peer": {
456 "nodekey:2222": {
457 "DNSName": "laptop.example-tailnet.ts.net.",
458 "TailscaleIPs": ["100.64.0.2"],
459 },
460 "nodekey:3333": {"DNSName": "ghost.example-tailnet.ts.net."},
462 },
463 })
464 .to_string(),
465 ),
466 );
467
468 let (identity, peers) = probe_node(&backend).await;
469 assert!(identity.matches("n1111111CNTRL"));
470 assert!(identity.matches("workstation"));
471 assert_eq!(
472 peers.get(&"100.64.0.2".parse::<IpAddr>().expect("an address")),
473 Some(&"laptop.example-tailnet.ts.net".to_owned()),
474 "a peer is named by the address a request would arrive from"
475 );
476 assert_eq!(
477 peers.get(&"100.64.0.1".parse::<IpAddr>().expect("an address")),
478 Some(&"workstation.example-tailnet.ts.net".to_owned()),
479 "and so is this node, which can reach its own HTTP transport"
480 );
481 assert_eq!(
482 peers.len(),
483 3,
484 "the trailing dot is dropped, the ghost has no address"
485 );
486
487 assert_eq!(
488 backend.calls().len(),
489 1,
490 "one reading of status, not one per answer wanted"
491 );
492 }
493}