use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use thiserror::Error;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::sync::RwLock;
use crate::backend::{BoxFuture, Concurrency, Invocation, LocalBackend, Output};
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
pub const GRACE_PERIOD: Duration = Duration::from_secs(2);
pub const BINARY_ENV: &str = "TAILSCALE_MCP_CLI_PATH";
#[derive(Debug, Error)]
pub enum ExecError {
#[error("no `tailscale` binary found; looked at {}", .searched.join(", "))]
BinaryNotFound { searched: Vec<String> },
#[error("`{path}` was set as the CLI path but is not an executable file")]
BinaryNotExecutable { path: String },
#[error("could not start `{binary}`: {source}")]
Spawn {
binary: String,
#[source]
source: std::io::Error,
},
#[error("`{command}` did not finish within {}s", .timeout.as_secs())]
Timeout {
command: String,
timeout: Duration,
printed: String,
},
#[error("failed talking to `{command}`: {source}")]
Io {
command: String,
#[source]
source: std::io::Error,
},
#[error("could not create a private file for a secret: {0}")]
SecretFile(#[source] std::io::Error),
}
#[derive(Debug)]
pub struct CliBackend {
binary: PathBuf,
lock: RwLock<()>,
}
impl CliBackend {
pub fn discover() -> Result<Self, ExecError> {
Self::discover_with(std::env::var_os(BINARY_ENV).as_deref())
}
pub fn discover_with(override_path: Option<&std::ffi::OsStr>) -> Result<Self, ExecError> {
if let Some(path) = override_path.filter(|p| !p.is_empty()) {
let path = PathBuf::from(path);
if !is_executable_file(&path) {
return Err(ExecError::BinaryNotExecutable {
path: path.display().to_string(),
});
}
return Ok(Self::at(path));
}
first_usable(candidates())
.map(Self::at)
.map_err(|searched| ExecError::BinaryNotFound { searched })
}
pub fn at(binary: impl Into<PathBuf>) -> Self {
Self {
binary: binary.into(),
lock: RwLock::new(()),
}
}
pub fn binary(&self) -> &Path {
&self.binary
}
async fn spawn(&self, invocation: Invocation) -> Result<Output, ExecError> {
let _guard = match invocation.concurrency {
Concurrency::Shared => Guard::Shared(self.lock.read().await),
Concurrency::Exclusive => Guard::Exclusive(self.lock.write().await),
};
let command = invocation.display();
let mut cmd = tokio::process::Command::new(&self.binary);
cmd.args(&invocation.args)
.env_clear()
.envs(minimal_env())
.stdin(if invocation.stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().map_err(|source| ExecError::Spawn {
binary: self.binary.display().to_string(),
source,
})?;
let mut stdin_pipe = child.stdin.take();
let mut stdout_pipe = child.stdout.take();
let mut stderr_pipe = child.stderr.take();
let stdin_bytes = invocation.stdin;
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let collected = {
let feed = async {
if let (Some(pipe), Some(bytes)) = (stdin_pipe.as_mut(), stdin_bytes.as_ref()) {
pipe.write_all(bytes).await?;
pipe.shutdown().await?;
}
drop(stdin_pipe.take());
Ok::<(), std::io::Error>(())
};
let read_out = async {
if let Some(pipe) = stdout_pipe.as_mut() {
pipe.read_to_end(&mut stdout_buf).await?;
}
Ok::<(), std::io::Error>(())
};
let read_err = async {
if let Some(pipe) = stderr_pipe.as_mut() {
pipe.read_to_end(&mut stderr_buf).await?;
}
Ok::<(), std::io::Error>(())
};
let work = async {
let (fed, out, err, status) = tokio::join!(feed, read_out, read_err, child.wait());
fed?;
out?;
err?;
status
};
tokio::time::timeout(invocation.timeout, work).await.ok()
};
match collected {
Some(Ok(status)) => Ok(Output {
exit_code: status.code(),
stdout: stdout_buf,
stderr: String::from_utf8_lossy(&stderr_buf).into_owned(),
}),
Some(Err(source)) => Err(ExecError::Io { command, source }),
None => {
terminate(&mut child).await;
Err(ExecError::Timeout {
command,
timeout: invocation.timeout,
printed: printed(&stdout_buf, &stderr_buf),
})
}
}
}
}
impl LocalBackend for CliBackend {
fn run<'a>(&'a self, invocation: Invocation) -> BoxFuture<'a, Result<Output, ExecError>> {
Box::pin(self.spawn(invocation))
}
}
fn printed(stdout: &[u8], stderr: &[u8]) -> String {
const LIMIT: usize = 2_000;
let mut out = String::new();
for stream in [stdout, stderr] {
let text = String::from_utf8_lossy(stream);
let text = text.trim();
if text.is_empty() {
continue;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(text);
}
if out.len() > LIMIT {
let end = (0..=LIMIT)
.rev()
.find(|i| out.is_char_boundary(*i))
.unwrap_or(0);
out.truncate(end);
out.push('\u{2026}');
}
out
}
async fn terminate(child: &mut tokio::process::Child) {
#[cfg(unix)]
if let Some(pid) = child.id() {
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(pid as i32),
nix::sys::signal::Signal::SIGTERM,
);
if tokio::time::timeout(GRACE_PERIOD, child.wait())
.await
.is_ok()
{
return;
}
}
let _ = child.start_kill();
let _ = child.wait().await;
}
#[allow(dead_code)]
enum Guard<'a> {
Shared(tokio::sync::RwLockReadGuard<'a, ()>),
Exclusive(tokio::sync::RwLockWriteGuard<'a, ()>),
}
fn minimal_env() -> BTreeMap<OsString, OsString> {
const KEEP: &[&str] = &[
"PATH",
"HOME",
"USER",
"LOGNAME",
"TMPDIR",
"SystemRoot",
"SystemDrive",
"COMSPEC",
"PATHEXT",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"ProgramData",
"ProgramFiles",
"TEMP",
"TMP",
"windir",
];
let mut env: BTreeMap<OsString, OsString> = KEEP
.iter()
.filter_map(|key| std::env::var_os(key).map(|value| (OsString::from(key), value)))
.collect();
env.insert(OsString::from("LC_ALL"), OsString::from("C"));
env
}
fn is_executable_file(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
fn search_path_candidates() -> impl Iterator<Item = PathBuf> {
let names: &[&str] = if cfg!(windows) {
&["tailscale.exe"]
} else {
&["tailscale"]
};
let dirs: Vec<PathBuf> = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
dirs.into_iter()
.flat_map(|dir| names.iter().map(move |name| dir.join(name)))
}
fn first_usable(
candidates: impl IntoIterator<Item = (PathBuf, Believe)>,
) -> Result<PathBuf, Vec<String>> {
let mut searched = Vec::new();
for (candidate, believe) in candidates {
if !is_executable_file(&candidate) {
searched.push(candidate.display().to_string());
continue;
}
if believe == Believe::OnceItAnswers && !answers_as_cli(&candidate) {
searched.push(format!(
"{} (present, but does not answer `version` as the CLI)",
candidate.display()
));
continue;
}
return Ok(candidate);
}
Err(searched)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Believe {
OnSight,
OnceItAnswers,
}
fn candidates() -> Vec<(PathBuf, Believe)> {
search_path_candidates()
.chain(shim_candidates())
.map(|path| (path, Believe::OnSight))
.chain(bundle_candidates().map(|path| (path, Believe::OnceItAnswers)))
.collect()
}
fn shim_candidates() -> impl Iterator<Item = PathBuf> {
let paths: &[&str] = if cfg!(target_os = "macos") {
&["/usr/local/bin/tailscale"]
} else {
&[]
};
paths.iter().map(PathBuf::from)
}
fn answers_as_cli(path: &Path) -> bool {
let Ok(output) = std::process::Command::new(path)
.arg("version")
.env_clear()
.envs(minimal_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
else {
return false;
};
output.status.success()
&& String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.is_some_and(|line| line.starts_with(|c: char| c.is_ascii_digit()))
}
fn bundle_candidates() -> impl Iterator<Item = PathBuf> {
let paths: &[&str] = if cfg!(target_os = "macos") {
&[
"/Applications/Tailscale.app/Contents/MacOS/tailscale",
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
]
} else {
&[]
};
let mut candidates: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
if cfg!(target_os = "macos")
&& let Some(home) = std::env::var_os("HOME")
{
candidates
.push(PathBuf::from(home).join("Applications/Tailscale.app/Contents/MacOS/tailscale"));
}
candidates.into_iter()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_child_environment_is_an_allow_list() {
let env = minimal_env();
assert!(!env.contains_key(std::ffi::OsStr::new("TAILSCALE_API_KEY")));
assert!(!env.contains_key(std::ffi::OsStr::new("TS_DEBUG_MUCK")));
assert_eq!(
env.get(std::ffi::OsStr::new("LC_ALL"))
.map(|v| v.as_os_str()),
Some(std::ffi::OsStr::new("C"))
);
}
#[test]
fn a_named_binary_that_is_not_there_is_an_error_not_a_fallback() {
let err =
CliBackend::discover_with(Some(std::ffi::OsStr::new("/definitely/not/here/tailscale")))
.expect_err("a missing override must fail");
assert!(
matches!(err, ExecError::BinaryNotExecutable { .. }),
"{err:?}"
);
}
#[test]
fn an_empty_override_is_treated_as_unset() {
let result = CliBackend::discover_with(Some(std::ffi::OsStr::new("")));
match result {
Ok(_) | Err(ExecError::BinaryNotFound { .. }) => {}
Err(other) => panic!("unexpected error: {other:?}"),
}
}
#[cfg(unix)]
const STARTS_THE_GUI: &str = "echo 'The Tailscale GUI failed to start: The operation couldn\u{2019}t be completed. \
(Tailscale.CLIError error 3.)'";
#[cfg(unix)]
fn stub_named(dir: &tempfile::TempDir, name: &str, script: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt as _;
let path = dir.path().join(name);
std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).expect("write the stub");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("make the stub executable");
path
}
#[cfg(unix)]
#[test]
fn a_candidate_that_starts_the_gui_does_not_answer_as_the_cli() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = stub_named(&dir, "tailscale", STARTS_THE_GUI);
assert!(!answers_as_cli(&path));
}
#[cfg(unix)]
#[test]
fn a_bundled_executable_that_starts_the_gui_is_passed_over_for_one_that_answers() {
let bundle = tempfile::tempdir().expect("a temp dir");
let usr_local = tempfile::tempdir().expect("a temp dir");
let bundled = stub_named(&bundle, "Tailscale", STARTS_THE_GUI);
let shim = stub_named(&usr_local, "tailscale", "echo '1.102.2'");
let found = first_usable(vec![
(bundled, Believe::OnceItAnswers),
(shim.clone(), Believe::OnSight),
])
.expect("the shim is usable");
assert_eq!(found, shim);
}
#[cfg(unix)]
#[test]
fn a_bundled_executable_that_starts_the_gui_is_not_a_binary_we_found() {
let bundle = tempfile::tempdir().expect("a temp dir");
let bundled = stub_named(&bundle, "Tailscale", STARTS_THE_GUI);
let searched =
first_usable(vec![(bundled, Believe::OnceItAnswers)]).expect_err("nothing is usable");
assert!(
searched.iter().any(|line| line.contains("does not answer")),
"the reason has to name itself: {searched:?}"
);
}
#[cfg(unix)]
#[test]
fn a_candidate_that_reports_a_version_answers_as_the_cli() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = stub_named(
&dir,
"tailscale",
"echo '1.102.2'\necho ' tailscale commit: 6cac9181'",
);
assert!(answers_as_cli(&path));
}
#[cfg(unix)]
#[test]
fn a_candidate_that_says_nothing_at_all_does_not_answer_as_the_cli() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = stub_named(&dir, "tailscale", "exit 0");
assert!(!answers_as_cli(&path));
}
#[test]
fn the_shim_is_reached_before_the_executable_inside_the_bundle() {
let all = candidates();
let at = |wanted: Vec<PathBuf>| {
all.iter()
.position(|(path, _)| wanted.iter().any(|w| w == path))
};
match (
at(shim_candidates().collect()),
at(bundle_candidates().collect()),
) {
(Some(shim), Some(bundle)) => assert!(shim < bundle, "{all:?}"),
(None, None) if cfg!(target_os = "macos") => panic!("macOS offers both"),
(None, None) => {}
other => panic!("one list reached without the other: {other:?}"),
}
}
#[test]
fn only_the_executable_inside_the_bundle_has_to_answer_before_it_is_believed() {
let bundled: Vec<PathBuf> = bundle_candidates().collect();
for (path, believe) in candidates() {
assert_eq!(
believe == Believe::OnceItAnswers,
bundled.contains(&path),
"{} is believed the wrong way round",
path.display()
);
}
}
#[test]
fn an_invocation_renders_without_a_shell_anywhere_near_it() {
let inv = Invocation::read(["ping", "--c=1", "host with spaces"]);
assert_eq!(inv.display(), "tailscale ping --c=1 host with spaces");
assert_eq!(inv.args.len(), 3, "the arguments stay separate");
}
}