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> {
89 Self::discover_with(std::env::var_os(BINARY_ENV).as_deref())
90 }
91
92 pub fn discover_with(override_path: Option<&std::ffi::OsStr>) -> Result<Self, ExecError> {
95 if let Some(path) = override_path.filter(|p| !p.is_empty()) {
96 let path = PathBuf::from(path);
97 if !is_executable_file(&path) {
98 return Err(ExecError::BinaryNotExecutable {
99 path: path.display().to_string(),
100 });
101 }
102 return Ok(Self::at(path));
103 }
104
105 let mut searched = Vec::new();
106 for candidate in search_path_candidates().chain(bundle_candidates()) {
107 if is_executable_file(&candidate) {
108 return Ok(Self::at(candidate));
109 }
110 searched.push(candidate.display().to_string());
111 }
112 Err(ExecError::BinaryNotFound { searched })
113 }
114
115 pub fn at(binary: impl Into<PathBuf>) -> Self {
117 Self {
118 binary: binary.into(),
119 lock: RwLock::new(()),
120 }
121 }
122
123 pub fn binary(&self) -> &Path {
124 &self.binary
125 }
126
127 async fn spawn(&self, invocation: Invocation) -> Result<Output, ExecError> {
128 let _guard = match invocation.concurrency {
131 Concurrency::Shared => Guard::Shared(self.lock.read().await),
132 Concurrency::Exclusive => Guard::Exclusive(self.lock.write().await),
133 };
134
135 let command = invocation.display();
136 let mut cmd = tokio::process::Command::new(&self.binary);
137 cmd.args(&invocation.args)
138 .env_clear()
139 .envs(minimal_env())
140 .stdin(if invocation.stdin.is_some() {
141 Stdio::piped()
142 } else {
143 Stdio::null()
146 })
147 .stdout(Stdio::piped())
148 .stderr(Stdio::piped())
149 .kill_on_drop(true);
152
153 let mut child = cmd.spawn().map_err(|source| ExecError::Spawn {
154 binary: self.binary.display().to_string(),
155 source,
156 })?;
157
158 let mut stdin_pipe = child.stdin.take();
159 let mut stdout_pipe = child.stdout.take();
160 let mut stderr_pipe = child.stderr.take();
161 let stdin_bytes = invocation.stdin;
162
163 let mut stdout_buf = Vec::new();
168 let mut stderr_buf = Vec::new();
169
170 let collected = {
171 let feed = async {
172 if let (Some(pipe), Some(bytes)) = (stdin_pipe.as_mut(), stdin_bytes.as_ref()) {
173 pipe.write_all(bytes).await?;
174 pipe.shutdown().await?;
175 }
176 drop(stdin_pipe.take());
178 Ok::<(), std::io::Error>(())
179 };
180 let read_out = async {
181 if let Some(pipe) = stdout_pipe.as_mut() {
182 pipe.read_to_end(&mut stdout_buf).await?;
183 }
184 Ok::<(), std::io::Error>(())
185 };
186 let read_err = async {
187 if let Some(pipe) = stderr_pipe.as_mut() {
188 pipe.read_to_end(&mut stderr_buf).await?;
189 }
190 Ok::<(), std::io::Error>(())
191 };
192
193 let work = async {
194 let (fed, out, err, status) = tokio::join!(feed, read_out, read_err, child.wait());
195 fed?;
196 out?;
197 err?;
198 status
199 };
200
201 tokio::time::timeout(invocation.timeout, work).await.ok()
202 };
203
204 match collected {
205 Some(Ok(status)) => Ok(Output {
206 exit_code: status.code(),
207 stdout: stdout_buf,
208 stderr: String::from_utf8_lossy(&stderr_buf).into_owned(),
209 }),
210 Some(Err(source)) => Err(ExecError::Io { command, source }),
211 None => {
212 terminate(&mut child).await;
213 Err(ExecError::Timeout {
214 command,
215 timeout: invocation.timeout,
216 printed: printed(&stdout_buf, &stderr_buf),
217 })
218 }
219 }
220 }
221}
222
223impl LocalBackend for CliBackend {
224 fn run<'a>(&'a self, invocation: Invocation) -> BoxFuture<'a, Result<Output, ExecError>> {
225 Box::pin(self.spawn(invocation))
226 }
227}
228
229fn printed(stdout: &[u8], stderr: &[u8]) -> String {
239 const LIMIT: usize = 2_000;
240 let mut out = String::new();
241 for stream in [stdout, stderr] {
242 let text = String::from_utf8_lossy(stream);
243 let text = text.trim();
244 if text.is_empty() {
245 continue;
246 }
247 if !out.is_empty() {
248 out.push('\n');
249 }
250 out.push_str(text);
251 }
252 if out.len() > LIMIT {
253 let end = (0..=LIMIT)
255 .rev()
256 .find(|i| out.is_char_boundary(*i))
257 .unwrap_or(0);
258 out.truncate(end);
259 out.push('\u{2026}');
260 }
261 out
262}
263
264async fn terminate(child: &mut tokio::process::Child) {
265 #[cfg(unix)]
266 if let Some(pid) = child.id() {
267 let _ = nix::sys::signal::kill(
270 nix::unistd::Pid::from_raw(pid as i32),
271 nix::sys::signal::Signal::SIGTERM,
272 );
273 if tokio::time::timeout(GRACE_PERIOD, child.wait())
274 .await
275 .is_ok()
276 {
277 return;
278 }
279 }
280 let _ = child.start_kill();
281 let _ = child.wait().await;
282}
283
284#[allow(dead_code)]
287enum Guard<'a> {
288 Shared(tokio::sync::RwLockReadGuard<'a, ()>),
289 Exclusive(tokio::sync::RwLockWriteGuard<'a, ()>),
290}
291
292fn minimal_env() -> BTreeMap<OsString, OsString> {
300 const KEEP: &[&str] = &[
301 "PATH",
303 "HOME",
305 "USER",
306 "LOGNAME",
307 "TMPDIR",
309 "SystemRoot",
311 "SystemDrive",
312 "COMSPEC",
313 "PATHEXT",
314 "USERPROFILE",
315 "APPDATA",
316 "LOCALAPPDATA",
317 "ProgramData",
318 "ProgramFiles",
319 "TEMP",
320 "TMP",
321 "windir",
322 ];
323
324 let mut env: BTreeMap<OsString, OsString> = KEEP
325 .iter()
326 .filter_map(|key| std::env::var_os(key).map(|value| (OsString::from(key), value)))
327 .collect();
328 env.insert(OsString::from("LC_ALL"), OsString::from("C"));
330 env
331}
332
333fn is_executable_file(path: &Path) -> bool {
334 let Ok(meta) = std::fs::metadata(path) else {
335 return false;
336 };
337 if !meta.is_file() {
338 return false;
339 }
340 #[cfg(unix)]
341 {
342 use std::os::unix::fs::PermissionsExt as _;
343 meta.permissions().mode() & 0o111 != 0
344 }
345 #[cfg(not(unix))]
346 {
347 true
348 }
349}
350
351fn search_path_candidates() -> impl Iterator<Item = PathBuf> {
354 let names: &[&str] = if cfg!(windows) {
355 &["tailscale.exe"]
356 } else {
357 &["tailscale"]
358 };
359 let dirs: Vec<PathBuf> = std::env::var_os("PATH")
360 .map(|path| std::env::split_paths(&path).collect())
361 .unwrap_or_default();
362 dirs.into_iter()
363 .flat_map(|dir| names.iter().map(move |name| dir.join(name)))
364}
365
366fn bundle_candidates() -> impl Iterator<Item = PathBuf> {
369 let paths: &[&str] = if cfg!(target_os = "macos") {
370 &[
371 "/Applications/Tailscale.app/Contents/MacOS/tailscale",
373 "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
374 ]
375 } else {
376 &[]
377 };
378 let mut candidates: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
379 if cfg!(target_os = "macos")
380 && let Some(home) = std::env::var_os("HOME")
381 {
382 candidates
383 .push(PathBuf::from(home).join("Applications/Tailscale.app/Contents/MacOS/tailscale"));
384 }
385 candidates.into_iter()
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn the_child_environment_is_an_allow_list() {
394 let env = minimal_env();
395 assert!(!env.contains_key(std::ffi::OsStr::new("TAILSCALE_API_KEY")));
396 assert!(!env.contains_key(std::ffi::OsStr::new("TS_DEBUG_MUCK")));
397 assert_eq!(
398 env.get(std::ffi::OsStr::new("LC_ALL"))
399 .map(|v| v.as_os_str()),
400 Some(std::ffi::OsStr::new("C"))
401 );
402 }
403
404 #[test]
405 fn a_named_binary_that_is_not_there_is_an_error_not_a_fallback() {
406 let err =
407 CliBackend::discover_with(Some(std::ffi::OsStr::new("/definitely/not/here/tailscale")))
408 .expect_err("a missing override must fail");
409 assert!(
410 matches!(err, ExecError::BinaryNotExecutable { .. }),
411 "{err:?}"
412 );
413 }
414
415 #[test]
416 fn an_empty_override_is_treated_as_unset() {
417 let result = CliBackend::discover_with(Some(std::ffi::OsStr::new("")));
419 match result {
420 Ok(_) | Err(ExecError::BinaryNotFound { .. }) => {}
421 Err(other) => panic!("unexpected error: {other:?}"),
422 }
423 }
424
425 #[test]
426 fn an_invocation_renders_without_a_shell_anywhere_near_it() {
427 let inv = Invocation::read(["ping", "--c=1", "host with spaces"]);
428 assert_eq!(inv.display(), "tailscale ping --c=1 host with spaces");
429 assert_eq!(inv.args.len(), 3, "the arguments stay separate");
430 }
431}