1use std::collections::BTreeMap;
4use std::ffi::OsString;
5use std::path::{Path, PathBuf};
6use std::process::Stdio;
7use std::time::Duration;
8
9use thiserror::Error;
10use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
11use tokio::sync::RwLock;
12
13use crate::backend::{BoxFuture, Concurrency, Invocation, LocalBackend, Output};
14
15pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
20
21pub const GRACE_PERIOD: Duration = Duration::from_secs(2);
23
24pub const BINARY_ENV: &str = "TAILSCALE_MCP_CLI_PATH";
26
27#[derive(Debug, Error)]
33pub enum ExecError {
34 #[error("no `tailscale` binary found; looked at {}", .searched.join(", "))]
35 BinaryNotFound { searched: Vec<String> },
36
37 #[error("`{path}` was set as the CLI path but is not an executable file")]
38 BinaryNotExecutable { path: String },
39
40 #[error("could not start `{binary}`: {source}")]
41 Spawn {
42 binary: String,
43 #[source]
44 source: std::io::Error,
45 },
46
47 #[error("`{command}` did not finish within {}s", .timeout.as_secs())]
48 Timeout {
49 command: String,
50 timeout: Duration,
51 printed: String,
58 },
59
60 #[error("failed talking to `{command}`: {source}")]
61 Io {
62 command: String,
63 #[source]
64 source: std::io::Error,
65 },
66
67 #[error("could not create a private file for a secret: {0}")]
68 SecretFile(#[source] std::io::Error),
69}
70
71#[derive(Debug)]
73pub struct CliBackend {
74 binary: PathBuf,
75 lock: RwLock<()>,
79}
80
81impl CliBackend {
82 pub fn discover() -> Result<Self, ExecError> {
92 Self::discover_with(std::env::var_os(BINARY_ENV).as_deref())
93 }
94
95 pub fn discover_with(override_path: Option<&std::ffi::OsStr>) -> Result<Self, ExecError> {
98 if let Some(path) = override_path.filter(|p| !p.is_empty()) {
99 let path = PathBuf::from(path);
100 if !is_executable_file(&path) {
101 return Err(ExecError::BinaryNotExecutable {
102 path: path.display().to_string(),
103 });
104 }
105 return Ok(Self::at(path));
106 }
107
108 first_usable(candidates())
109 .map(Self::at)
110 .map_err(|searched| ExecError::BinaryNotFound { searched })
111 }
112
113 pub fn at(binary: impl Into<PathBuf>) -> Self {
115 Self {
116 binary: binary.into(),
117 lock: RwLock::new(()),
118 }
119 }
120
121 pub fn binary(&self) -> &Path {
122 &self.binary
123 }
124
125 async fn spawn(&self, invocation: Invocation) -> Result<Output, ExecError> {
126 let _guard = match invocation.concurrency {
129 Concurrency::Shared => Guard::Shared(self.lock.read().await),
130 Concurrency::Exclusive => Guard::Exclusive(self.lock.write().await),
131 };
132
133 let command = invocation.display();
134 let mut cmd = tokio::process::Command::new(&self.binary);
135 cmd.args(&invocation.args)
136 .env_clear()
137 .envs(minimal_env())
138 .stdin(if invocation.stdin.is_some() {
139 Stdio::piped()
140 } else {
141 Stdio::null()
144 })
145 .stdout(Stdio::piped())
146 .stderr(Stdio::piped())
147 .kill_on_drop(true);
150
151 let mut child = cmd.spawn().map_err(|source| ExecError::Spawn {
152 binary: self.binary.display().to_string(),
153 source,
154 })?;
155
156 let mut stdin_pipe = child.stdin.take();
157 let mut stdout_pipe = child.stdout.take();
158 let mut stderr_pipe = child.stderr.take();
159 let stdin_bytes = invocation.stdin;
160
161 let mut stdout_buf = Vec::new();
166 let mut stderr_buf = Vec::new();
167
168 let collected = {
169 let feed = async {
170 if let (Some(pipe), Some(bytes)) = (stdin_pipe.as_mut(), stdin_bytes.as_ref()) {
171 pipe.write_all(bytes).await?;
172 pipe.shutdown().await?;
173 }
174 drop(stdin_pipe.take());
176 Ok::<(), std::io::Error>(())
177 };
178 let read_out = async {
179 if let Some(pipe) = stdout_pipe.as_mut() {
180 pipe.read_to_end(&mut stdout_buf).await?;
181 }
182 Ok::<(), std::io::Error>(())
183 };
184 let read_err = async {
185 if let Some(pipe) = stderr_pipe.as_mut() {
186 pipe.read_to_end(&mut stderr_buf).await?;
187 }
188 Ok::<(), std::io::Error>(())
189 };
190
191 let work = async {
192 let (fed, out, err, status) = tokio::join!(feed, read_out, read_err, child.wait());
193 fed?;
194 out?;
195 err?;
196 status
197 };
198
199 tokio::time::timeout(invocation.timeout, work).await.ok()
200 };
201
202 match collected {
203 Some(Ok(status)) => Ok(Output {
204 exit_code: status.code(),
205 stdout: stdout_buf,
206 stderr: String::from_utf8_lossy(&stderr_buf).into_owned(),
207 }),
208 Some(Err(source)) => Err(ExecError::Io { command, source }),
209 None => {
210 terminate(&mut child).await;
211 Err(ExecError::Timeout {
212 command,
213 timeout: invocation.timeout,
214 printed: printed(&stdout_buf, &stderr_buf),
215 })
216 }
217 }
218 }
219}
220
221impl LocalBackend for CliBackend {
222 fn run<'a>(&'a self, invocation: Invocation) -> BoxFuture<'a, Result<Output, ExecError>> {
223 Box::pin(self.spawn(invocation))
224 }
225}
226
227fn printed(stdout: &[u8], stderr: &[u8]) -> String {
237 const LIMIT: usize = 2_000;
238 let mut out = String::new();
239 for stream in [stdout, stderr] {
240 let text = String::from_utf8_lossy(stream);
241 let text = text.trim();
242 if text.is_empty() {
243 continue;
244 }
245 if !out.is_empty() {
246 out.push('\n');
247 }
248 out.push_str(text);
249 }
250 if out.len() > LIMIT {
251 let end = (0..=LIMIT)
253 .rev()
254 .find(|i| out.is_char_boundary(*i))
255 .unwrap_or(0);
256 out.truncate(end);
257 out.push('\u{2026}');
258 }
259 out
260}
261
262async fn terminate(child: &mut tokio::process::Child) {
263 #[cfg(unix)]
264 if let Some(pid) = child.id() {
265 let _ = nix::sys::signal::kill(
268 nix::unistd::Pid::from_raw(pid as i32),
269 nix::sys::signal::Signal::SIGTERM,
270 );
271 if tokio::time::timeout(GRACE_PERIOD, child.wait())
272 .await
273 .is_ok()
274 {
275 return;
276 }
277 }
278 let _ = child.start_kill();
279 let _ = child.wait().await;
280}
281
282#[allow(dead_code)]
285enum Guard<'a> {
286 Shared(tokio::sync::RwLockReadGuard<'a, ()>),
287 Exclusive(tokio::sync::RwLockWriteGuard<'a, ()>),
288}
289
290fn minimal_env() -> BTreeMap<OsString, OsString> {
298 const KEEP: &[&str] = &[
299 "PATH",
301 "HOME",
303 "USER",
304 "LOGNAME",
305 "TMPDIR",
307 "SystemRoot",
309 "SystemDrive",
310 "COMSPEC",
311 "PATHEXT",
312 "USERPROFILE",
313 "APPDATA",
314 "LOCALAPPDATA",
315 "ProgramData",
316 "ProgramFiles",
317 "TEMP",
318 "TMP",
319 "windir",
320 ];
321
322 let mut env: BTreeMap<OsString, OsString> = KEEP
323 .iter()
324 .filter_map(|key| std::env::var_os(key).map(|value| (OsString::from(key), value)))
325 .collect();
326 env.insert(OsString::from("LC_ALL"), OsString::from("C"));
328 env
329}
330
331fn is_executable_file(path: &Path) -> bool {
332 let Ok(meta) = std::fs::metadata(path) else {
333 return false;
334 };
335 if !meta.is_file() {
336 return false;
337 }
338 #[cfg(unix)]
339 {
340 use std::os::unix::fs::PermissionsExt as _;
341 meta.permissions().mode() & 0o111 != 0
342 }
343 #[cfg(not(unix))]
344 {
345 true
346 }
347}
348
349fn search_path_candidates() -> impl Iterator<Item = PathBuf> {
352 let names: &[&str] = if cfg!(windows) {
353 &["tailscale.exe"]
354 } else {
355 &["tailscale"]
356 };
357 let dirs: Vec<PathBuf> = std::env::var_os("PATH")
358 .map(|path| std::env::split_paths(&path).collect())
359 .unwrap_or_default();
360 dirs.into_iter()
361 .flat_map(|dir| names.iter().map(move |name| dir.join(name)))
362}
363
364fn first_usable(
370 candidates: impl IntoIterator<Item = (PathBuf, Believe)>,
371) -> Result<PathBuf, Vec<String>> {
372 let mut searched = Vec::new();
373 for (candidate, believe) in candidates {
374 if !is_executable_file(&candidate) {
375 searched.push(candidate.display().to_string());
376 continue;
377 }
378 if believe == Believe::OnceItAnswers && !answers_as_cli(&candidate) {
382 searched.push(format!(
383 "{} (present, but does not answer `version` as the CLI)",
384 candidate.display()
385 ));
386 continue;
387 }
388 return Ok(candidate);
389 }
390 Err(searched)
391}
392
393#[derive(Clone, Copy, Debug, PartialEq, Eq)]
395enum Believe {
396 OnSight,
399 OnceItAnswers,
401}
402
403fn candidates() -> Vec<(PathBuf, Believe)> {
409 search_path_candidates()
410 .chain(shim_candidates())
411 .map(|path| (path, Believe::OnSight))
412 .chain(bundle_candidates().map(|path| (path, Believe::OnceItAnswers)))
413 .collect()
414}
415
416fn shim_candidates() -> impl Iterator<Item = PathBuf> {
428 let paths: &[&str] = if cfg!(target_os = "macos") {
429 &["/usr/local/bin/tailscale"]
430 } else {
431 &[]
432 };
433 paths.iter().map(PathBuf::from)
434}
435
436fn answers_as_cli(path: &Path) -> bool {
449 let Ok(output) = std::process::Command::new(path)
450 .arg("version")
451 .env_clear()
452 .envs(minimal_env())
453 .stdin(Stdio::null())
454 .stdout(Stdio::piped())
455 .stderr(Stdio::null())
456 .output()
457 else {
458 return false;
459 };
460 output.status.success()
461 && String::from_utf8_lossy(&output.stdout)
462 .lines()
463 .next()
464 .is_some_and(|line| line.starts_with(|c: char| c.is_ascii_digit()))
465}
466
467fn bundle_candidates() -> impl Iterator<Item = PathBuf> {
473 let paths: &[&str] = if cfg!(target_os = "macos") {
474 &[
475 "/Applications/Tailscale.app/Contents/MacOS/tailscale",
477 "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
478 ]
479 } else {
480 &[]
481 };
482 let mut candidates: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
483 if cfg!(target_os = "macos")
484 && let Some(home) = std::env::var_os("HOME")
485 {
486 candidates
487 .push(PathBuf::from(home).join("Applications/Tailscale.app/Contents/MacOS/tailscale"));
488 }
489 candidates.into_iter()
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn the_child_environment_is_an_allow_list() {
498 let env = minimal_env();
499 assert!(!env.contains_key(std::ffi::OsStr::new("TAILSCALE_API_KEY")));
500 assert!(!env.contains_key(std::ffi::OsStr::new("TS_DEBUG_MUCK")));
501 assert_eq!(
502 env.get(std::ffi::OsStr::new("LC_ALL"))
503 .map(|v| v.as_os_str()),
504 Some(std::ffi::OsStr::new("C"))
505 );
506 }
507
508 #[test]
509 fn a_named_binary_that_is_not_there_is_an_error_not_a_fallback() {
510 let err =
511 CliBackend::discover_with(Some(std::ffi::OsStr::new("/definitely/not/here/tailscale")))
512 .expect_err("a missing override must fail");
513 assert!(
514 matches!(err, ExecError::BinaryNotExecutable { .. }),
515 "{err:?}"
516 );
517 }
518
519 #[test]
520 fn an_empty_override_is_treated_as_unset() {
521 let result = CliBackend::discover_with(Some(std::ffi::OsStr::new("")));
523 match result {
524 Ok(_) | Err(ExecError::BinaryNotFound { .. }) => {}
525 Err(other) => panic!("unexpected error: {other:?}"),
526 }
527 }
528
529 #[cfg(unix)]
532 const STARTS_THE_GUI: &str = "echo 'The Tailscale GUI failed to start: The operation couldn\u{2019}t be completed. \
533 (Tailscale.CLIError error 3.)'";
534
535 #[cfg(unix)]
537 fn stub_named(dir: &tempfile::TempDir, name: &str, script: &str) -> PathBuf {
538 use std::os::unix::fs::PermissionsExt as _;
539 let path = dir.path().join(name);
540 std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).expect("write the stub");
541 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
542 .expect("make the stub executable");
543 path
544 }
545
546 #[cfg(unix)]
547 #[test]
548 fn a_candidate_that_starts_the_gui_does_not_answer_as_the_cli() {
549 let dir = tempfile::tempdir().expect("a temp dir");
554 let path = stub_named(&dir, "tailscale", STARTS_THE_GUI);
555 assert!(!answers_as_cli(&path));
556 }
557
558 #[cfg(unix)]
559 #[test]
560 fn a_bundled_executable_that_starts_the_gui_is_passed_over_for_one_that_answers() {
561 let bundle = tempfile::tempdir().expect("a temp dir");
566 let usr_local = tempfile::tempdir().expect("a temp dir");
567 let bundled = stub_named(&bundle, "Tailscale", STARTS_THE_GUI);
568 let shim = stub_named(&usr_local, "tailscale", "echo '1.102.2'");
569 let found = first_usable(vec![
570 (bundled, Believe::OnceItAnswers),
571 (shim.clone(), Believe::OnSight),
572 ])
573 .expect("the shim is usable");
574 assert_eq!(found, shim);
575 }
576
577 #[cfg(unix)]
578 #[test]
579 fn a_bundled_executable_that_starts_the_gui_is_not_a_binary_we_found() {
580 let bundle = tempfile::tempdir().expect("a temp dir");
584 let bundled = stub_named(&bundle, "Tailscale", STARTS_THE_GUI);
585 let searched =
586 first_usable(vec![(bundled, Believe::OnceItAnswers)]).expect_err("nothing is usable");
587 assert!(
588 searched.iter().any(|line| line.contains("does not answer")),
589 "the reason has to name itself: {searched:?}"
590 );
591 }
592
593 #[cfg(unix)]
594 #[test]
595 fn a_candidate_that_reports_a_version_answers_as_the_cli() {
596 let dir = tempfile::tempdir().expect("a temp dir");
597 let path = stub_named(
598 &dir,
599 "tailscale",
600 "echo '1.102.2'\necho ' tailscale commit: 6cac9181'",
601 );
602 assert!(answers_as_cli(&path));
603 }
604
605 #[cfg(unix)]
606 #[test]
607 fn a_candidate_that_says_nothing_at_all_does_not_answer_as_the_cli() {
608 let dir = tempfile::tempdir().expect("a temp dir");
609 let path = stub_named(&dir, "tailscale", "exit 0");
610 assert!(!answers_as_cli(&path));
611 }
612
613 #[test]
614 fn the_shim_is_reached_before_the_executable_inside_the_bundle() {
615 let all = candidates();
618 let at = |wanted: Vec<PathBuf>| {
619 all.iter()
620 .position(|(path, _)| wanted.iter().any(|w| w == path))
621 };
622 match (
623 at(shim_candidates().collect()),
624 at(bundle_candidates().collect()),
625 ) {
626 (Some(shim), Some(bundle)) => assert!(shim < bundle, "{all:?}"),
627 (None, None) if cfg!(target_os = "macos") => panic!("macOS offers both"),
628 (None, None) => {}
629 other => panic!("one list reached without the other: {other:?}"),
630 }
631 }
632
633 #[test]
634 fn only_the_executable_inside_the_bundle_has_to_answer_before_it_is_believed() {
635 let bundled: Vec<PathBuf> = bundle_candidates().collect();
636 for (path, believe) in candidates() {
637 assert_eq!(
638 believe == Believe::OnceItAnswers,
639 bundled.contains(&path),
640 "{} is believed the wrong way round",
641 path.display()
642 );
643 }
644 }
645
646 #[test]
647 fn an_invocation_renders_without_a_shell_anywhere_near_it() {
648 let inv = Invocation::read(["ping", "--c=1", "host with spaces"]);
649 assert_eq!(inv.display(), "tailscale ping --c=1 host with spaces");
650 assert_eq!(inv.args.len(), 3, "the arguments stay separate");
651 }
652}