Skip to main content

tailscale_cli/
exec.rs

1//! Spawning the `tailscale` binary.
2
3use 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
15/// How long a call may take before it is cut off.
16///
17/// Generous, because a few commands legitimately wait on the network, and the
18/// tools that need longer say so.
19pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
20
21/// How long a timed-out child is given to exit on its own before it is killed.
22pub const GRACE_PERIOD: Duration = Duration::from_secs(2);
23
24/// The environment variable that overrides binary discovery.
25pub const BINARY_ENV: &str = "TAILSCALE_MCP_CLI_PATH";
26
27/// Something went wrong before, or instead of, the command producing a result.
28///
29/// A command that ran and exited non-zero is not an error here: that is an
30/// [`Output`] with a non-zero code, and the layer above turns it into the
31/// `cli_failed` result.
32#[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        /// Whatever the child had printed by the time it was killed.
52        ///
53        /// A command that hangs usually says why first — `tailscale funnel`
54        /// prints the URL that enables Funnel and then waits for someone to
55        /// visit it — so the words are kept and handed to the caller. Empty
56        /// when the child said nothing.
57        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/// The real backend: finds the binary once, then spawns it per call.
72#[derive(Debug)]
73pub struct CliBackend {
74    binary: PathBuf,
75    /// Read-locked by shared calls, write-locked by exclusive ones. An
76    /// `RwLock` says precisely what the design wants: reads overlap each other,
77    /// a mutation overlaps nothing.
78    lock: RwLock<()>,
79}
80
81impl CliBackend {
82    /// Find the binary and build a backend around it.
83    ///
84    /// Order: the explicit override, then the search path, then the shim the
85    /// macOS applications install, and last the executable inside the
86    /// application bundle. The override is first because an operator who names
87    /// a path means it — if it is wrong, that is an error rather than a quiet
88    /// fallback to some other Tailscale on the machine. The bundle is last
89    /// because it is the only candidate that can be present and not be a
90    /// command-line interface; `bundle_candidates` says why.
91    pub fn discover() -> Result<Self, ExecError> {
92        Self::discover_with(std::env::var_os(BINARY_ENV).as_deref())
93    }
94
95    /// [`Self::discover`], with the override supplied rather than read from the
96    /// environment, so a test can drive every branch.
97    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    /// A backend over a known binary. The stub-binary tests use this.
114    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        // Held for the whole call. Dropped on every exit path, including the
127        // timeout, because it lives in this scope.
128        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                // Closed, so a command that would prompt fails instead of
142                // hanging on a terminal that is not there.
143                Stdio::null()
144            })
145            .stdout(Stdio::piped())
146            .stderr(Stdio::piped())
147            // If this future is dropped — a cancelled request, a shutdown —
148            // the child does not outlive it.
149            .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        // Owned out here rather than inside the reading futures, so that a
162        // child killed for taking too long still leaves behind whatever it had
163        // said. `read_to_end` fills the buffer as it goes, and cancelling it
164        // keeps what it filled.
165        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                // Closing the pipe is what tells the child there is no more.
175                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
227/// Ask the child to stop, then insist.
228///
229/// The polite request matters: `tailscale` cleans up state on the way out, and
230/// a killed process can leave a half-applied preference behind.
231/// What a killed child had said, both streams together and in the order a
232/// person reading a terminal would have seen them: standard output first,
233/// since that is where the client puts the thing it wants acted on.
234///
235/// Kept short, because it goes into an error message rather than into a result.
236fn 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        // On a character boundary, so the result is still a string.
252        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        // A failure here means the child is already gone, which is the outcome
266        // we wanted anyway.
267        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/// Held for the duration of a call and never inspected: the lock is released
283/// by dropping it, which is the whole point.
284#[allow(dead_code)]
285enum Guard<'a> {
286    Shared(tokio::sync::RwLockReadGuard<'a, ()>),
287    Exclusive(tokio::sync::RwLockWriteGuard<'a, ()>),
288}
289
290/// The environment the child gets: an allow-list, not the parent's.
291///
292/// Two reasons. Our own credentials — API access tokens, OAuth secrets — are in
293/// this process's environment and have no business in a child that does not need
294/// them. And `TS_DEBUG_*` and friends change the CLI's behaviour, so inheriting
295/// whatever the launching shell happened to have makes the server's behaviour
296/// depend on how it was started.
297fn minimal_env() -> BTreeMap<OsString, OsString> {
298    const KEEP: &[&str] = &[
299        // Needed to find helpers and, on macOS, the app bundle.
300        "PATH",
301        // The CLI reads and writes per-user state.
302        "HOME",
303        "USER",
304        "LOGNAME",
305        // Where a secret file may live.
306        "TMPDIR",
307        // Windows cannot start a process without these.
308        "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    // Stable, parseable output regardless of the operator's locale.
327    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
349/// `tailscale` as found on `PATH`, resolved by hand so that the error message
350/// can say where we looked.
351fn 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
364/// The first candidate that can be used, or every place that was looked.
365///
366/// Takes the list rather than calling [`candidates`] so that a test can drive
367/// both kinds of belief over stubs it controls; the real list is absolute paths
368/// on this machine, which no test can arrange.
369fn 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        // Reached only when nothing earlier was there, so the cost of asking is
379        // paid on the machines that would otherwise be handed a surface on
380        // which every call fails.
381        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/// Whether a candidate is believed on sight, or only once it has answered.
394#[derive(Clone, Copy, Debug, PartialEq, Eq)]
395enum Believe {
396    /// A `tailscale` on `PATH`, or the shim beside it. Nothing else on a
397    /// machine is called that, and both are the command-line interface.
398    OnSight,
399    /// The executable inside the application bundle: see [`answers_as_cli`].
400    OnceItAnswers,
401}
402
403/// Everywhere a `tailscale` might be, in the order they are considered.
404///
405/// Gathered into one ordered list rather than chained at the point of use so
406/// that a test can hold the order, which is half of what this fix is: the shim
407/// has to be reached before the executable inside the bundle (Q157).
408fn 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
416/// The shim the macOS applications install for command-line use.
417///
418/// Both builds offer to put this there, and it is what a person means by "the
419/// `tailscale` command" on a Mac. It is a two-line `/bin/sh` script in front of
420/// the executable inside the bundle and, unlike that executable, it acts as the
421/// command-line interface whatever environment it is handed — which is the
422/// property that matters here (Q157).
423///
424/// Looked for by absolute path rather than left to `PATH`, because
425/// `/usr/local/bin` is not in the environment a launcher hands a server started
426/// outside a login shell, and that is how an MCP client starts one.
427fn 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
436/// Whether a candidate answers as the command-line interface.
437///
438/// The executable inside the standalone application's bundle is the
439/// application's own. Handed the environment [`minimal_env`] builds, it starts
440/// the GUI rather than running the command, prints `The Tailscale GUI failed to
441/// start` **on standard output**, and **exits 0** — so neither the exit status
442/// nor the stream it chose tells it apart from a version. Only the shape of
443/// what it printed does: `version` opens with the number and nothing before it,
444/// so a first line not starting with a digit is not one (Q157).
445///
446/// `version` is the right question to ask: it reads the build stamped into the
447/// binary, so it is local, prompt, and changes nothing.
448fn 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
467/// Where the macOS applications keep the executable itself.
468///
469/// Tried last and only when it answers, because this is the one candidate that
470/// can be present, executable, and still not be a command-line interface:
471/// see [`answers_as_cli`]. Neither path is on `PATH` at all.
472fn bundle_candidates() -> impl Iterator<Item = PathBuf> {
473    let paths: &[&str] = if cfg!(target_os = "macos") {
474        &[
475            // The standalone build, and the App Store build's own copy.
476            "/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        // Falls through to discovery rather than failing on the empty path.
522        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    /// What the standalone application's own executable prints when it is run
530    /// outside a login environment: no version, and a zero exit.
531    #[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    /// A stub `tailscale` that runs `script` and exits however it exits.
536    #[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        // Verbatim what the standalone application's own executable does when
550        // it is handed the environment `minimal_env` builds: that message, on
551        // standard output, and a zero exit. Neither the status nor the stream
552        // it chose tells it apart from a version, so only the shape can.
553        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        // The affected machine in one list: the application is there, and so
562        // is the shim, and only the shim is the command-line interface.
563        // Two directories, because the names differ only in case and a Mac's
564        // filesystem does not: in one directory the second would be the first.
565        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        // With nothing else on the machine the honest answer is that there is
581        // no command-line interface here, so the surface is not offered at all
582        // rather than offered and failing on every call (Q157).
583        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        // The whole of the macOS fix is this order: on an affected machine both
616        // are present, and only the one reached first acts as the CLI.
617        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}