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 a_clean_exit_is_not_an_error() {
350 let ctx = context(StubBackend::ok("1.102.2\n"), None);
351 let text = run_text(&ctx, &meta(None), Invocation::read(["version"]))
352 .await
353 .expect("should succeed");
354 assert_eq!(text.trim(), "1.102.2");
355 }
356
357 #[tokio::test]
358 async fn an_unknown_subcommand_reports_the_minimum_version() {
359 let ctx = context(
360 StubBackend::failure(1, "tailscale service: unknown subcommand \"list\"\n"),
361 Some(Version::new(1, 78, 0)),
362 );
363 let err = run(
364 &ctx,
365 &meta(Some("1.94")),
366 Invocation::read(["service", "list"]),
367 )
368 .await
369 .expect_err("should fail");
370 assert_eq!(err.code, ErrorCode::UnsupportedVersion);
371 assert!(err.message.contains("1.94"), "{}", err.message);
372 assert!(err.message.contains("1.78.0"), "{}", err.message);
373 }
374
375 #[tokio::test]
376 async fn an_unknown_flag_reports_the_minimum_version() {
377 let ctx = context(
378 StubBackend::failure(1, "flag provided but not defined: -report-posture\n"),
379 None,
380 );
381 let err = run(&ctx, &meta(Some("1.58")), Invocation::read(["set"]))
382 .await
383 .expect_err("should fail");
384 assert_eq!(err.code, ErrorCode::UnsupportedVersion);
385 assert!(err.message.contains("1.58"), "{}", err.message);
386 }
387
388 #[tokio::test]
389 async fn a_tool_without_a_minimum_falls_back_to_the_floor() {
390 let ctx = context(StubBackend::failure(1, "unknown subcommand\n"), None);
391 let err = run(&ctx, &meta(None), Invocation::read(["nonsense"]))
392 .await
393 .expect_err("should fail");
394 assert_eq!(err.code, ErrorCode::UnsupportedVersion);
395 assert!(
396 err.message
397 .contains(&crate::version::SUPPORTED_FLOOR.to_string()),
398 "{}",
399 err.message
400 );
401 }
402
403 #[tokio::test]
404 async fn a_known_minimum_is_checked_before_the_command_runs() {
405 let ctx = context(StubBackend::ok(""), Some(Version::new(1, 78, 0)));
406 let err = version_permits(&ctx, &meta(Some("1.94"))).expect_err("should refuse");
407 assert_eq!(err.code, ErrorCode::UnsupportedVersion);
408 version_permits(&ctx, &meta(Some("1.72"))).expect("older requirement is met");
409 version_permits(&ctx, &meta(None)).expect("no requirement is always met");
410 }
411
412 #[tokio::test]
413 async fn a_permission_refusal_is_reported_as_needing_an_operator() {
414 let ctx = context(
415 StubBackend::failure(
416 1,
417 "Access denied: this operation requires the operator to be set\n",
418 ),
419 None,
420 );
421 let err = run(&ctx, &meta(None), Invocation::read(["up"]))
422 .await
423 .expect_err("should fail");
424 assert_eq!(err.code, ErrorCode::NeedsOperator);
425 assert!(
426 err.hint.is_some(),
427 "an operator error should say what to do"
428 );
429 }
430
431 #[tokio::test]
432 async fn a_missing_target_is_reported_as_not_found() {
433 let ctx = context(StubBackend::failure(1, "no such peer: laptop\n"), None);
434 let err = run(&ctx, &meta(None), Invocation::read(["ping", "laptop"]))
435 .await
436 .expect_err("should fail");
437 assert_eq!(err.code, ErrorCode::NotFound);
438 }
439
440 #[tokio::test]
441 async fn anything_else_is_a_plain_command_failure() {
442 let ctx = context(StubBackend::failure(2, "something went wrong\n"), None);
443 let err = run(&ctx, &meta(None), Invocation::read(["status"]))
444 .await
445 .expect_err("should fail");
446 assert_eq!(err.code, ErrorCode::CliFailed);
447 assert_eq!(err.exit_code, Some(2));
448 assert_eq!(err.stderr.as_deref(), Some("something went wrong"));
449 }
450
451 #[tokio::test]
452 async fn a_missing_binary_disables_the_surface_rather_than_failing_the_command() {
453 let ctx = context(StubBackend::missing(), None);
454 let err = run(&ctx, &meta(None), Invocation::read(["status"]))
455 .await
456 .expect_err("should fail");
457 assert_eq!(err.code, ErrorCode::BackendUnavailable);
458 }
459
460 #[tokio::test]
461 async fn a_secret_in_the_error_stream_does_not_reach_the_caller() {
462 let ctx = context(
463 StubBackend::failure(1, "bad key tskey-auth-example1CNTRL-secretpart\n"),
464 None,
465 );
466 let err = run(&ctx, &meta(None), Invocation::read(["up"]))
467 .await
468 .expect_err("should fail");
469 assert!(
470 !err.stderr
471 .as_deref()
472 .unwrap_or_default()
473 .contains("secretpart"),
474 "{err:?}"
475 );
476 }
477
478 #[tokio::test]
479 async fn the_version_probe_reads_the_first_line() {
480 let backend = StubBackend::ok("1.102.2\n go version: go1.24.1\n");
481 assert_eq!(probe_version(&backend).await, Some(Version::new(1, 102, 2)));
482 }
483
484 #[tokio::test]
485 async fn the_version_probe_gives_up_quietly() {
486 assert_eq!(probe_version(&StubBackend::missing()).await, None);
487 assert_eq!(probe_version(&StubBackend::failure(1, "no")).await, None);
488 assert_eq!(probe_version(&StubBackend::ok("not a version")).await, None);
489 }
490
491 #[tokio::test]
493 async fn the_status_probe_names_this_node_and_its_peers() {
494 let backend = StubBackend::missing().on(
495 ["status", "--json"],
496 tailscale_cli::stub::Reply::ok(
497 serde_json::json!({
498 "Self": {
499 "ID": "n1111111CNTRL",
500 "DNSName": "workstation.example-tailnet.ts.net.",
501 "TailscaleIPs": ["100.64.0.1", "fd7a:115c:a1e0::1"],
502 },
503 "Peer": {
504 "nodekey:2222": {
505 "DNSName": "laptop.example-tailnet.ts.net.",
506 "TailscaleIPs": ["100.64.0.2"],
507 },
508 "nodekey:3333": {"DNSName": "ghost.example-tailnet.ts.net."},
510 },
511 })
512 .to_string(),
513 ),
514 );
515
516 let (identity, peers) = probe_node(&backend).await;
517 assert!(identity.matches("n1111111CNTRL"));
518 assert!(identity.matches("workstation"));
519 assert_eq!(
520 peers.get(&"100.64.0.2".parse::<IpAddr>().expect("an address")),
521 Some(&"laptop.example-tailnet.ts.net".to_owned()),
522 "a peer is named by the address a request would arrive from"
523 );
524 assert_eq!(
525 peers.get(&"100.64.0.1".parse::<IpAddr>().expect("an address")),
526 Some(&"workstation.example-tailnet.ts.net".to_owned()),
527 "and so is this node, which can reach its own HTTP transport"
528 );
529 assert_eq!(
530 peers.len(),
531 3,
532 "the trailing dot is dropped, the ghost has no address"
533 );
534
535 assert_eq!(
536 backend.calls().len(),
537 1,
538 "one reading of status, not one per answer wanted"
539 );
540 }
541}