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 _;
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/// Something went wrong before, or instead of, the command producing a result.
25///
26/// A command that ran and exited non-zero is not an error here: that is an
27/// [`Output`] with a non-zero code, and the layer above turns it into the
28/// `cli_failed` result.
29#[derive(Debug, Error)]
30pub enum ExecError {
31    #[error("no `tailscale` binary found; looked at {}", .searched.join(", "))]
32    BinaryNotFound { searched: Vec<String> },
33
34    #[error("`{path}` was set as the CLI path but is not an executable file")]
35    BinaryNotExecutable { path: String },
36
37    #[error("could not start `{binary}`: {source}")]
38    Spawn {
39        binary: String,
40        #[source]
41        source: std::io::Error,
42    },
43
44    #[error("`{command}` did not finish within {}s", .timeout.as_secs())]
45    Timeout {
46        command: String,
47        timeout: Duration,
48        /// Whatever the child had printed by the time it was killed.
49        ///
50        /// A command that hangs usually says why first — `tailscale funnel`
51        /// prints the URL that enables Funnel and then waits for someone to
52        /// visit it — so the words are kept and handed to the caller. Empty
53        /// when the child said nothing.
54        printed: String,
55    },
56
57    #[error("failed talking to `{command}`: {source}")]
58    Io {
59        command: String,
60        #[source]
61        source: std::io::Error,
62    },
63
64    #[error("could not create a private file for a secret: {0}")]
65    SecretFile(#[source] std::io::Error),
66}
67
68/// The real backend: finds the binary once, then spawns it per call.
69#[derive(Debug)]
70pub struct CliBackend {
71    binary: PathBuf,
72    /// Read-locked by shared calls, write-locked by exclusive ones. An
73    /// `RwLock` says precisely what the design wants: reads overlap each other,
74    /// a mutation overlaps nothing.
75    lock: RwLock<()>,
76}
77
78impl CliBackend {
79    /// Find the binary and build a backend around it.
80    ///
81    /// Order: the explicit override, then the search path, then the shim the
82    /// macOS applications install, and last the executable inside the
83    /// application bundle. The override is first because an operator who names
84    /// a path means it — if it is wrong, that is an error rather than a quiet
85    /// fallback to some other Tailscale on the machine. The bundle is last
86    /// because it is the only candidate that can be present and not be a
87    /// command-line interface; `bundle_candidates` says why.
88    pub fn discover_with(override_path: Option<&std::ffi::OsStr>) -> Result<Self, ExecError> {
89        if let Some(path) = override_path.filter(|p| !p.is_empty()) {
90            let path = PathBuf::from(path);
91            if !is_executable_file(&path) {
92                return Err(ExecError::BinaryNotExecutable {
93                    path: path.display().to_string(),
94                });
95            }
96            return Ok(Self::at(path));
97        }
98
99        first_usable(candidates())
100            .map(Self::at)
101            .map_err(|searched| ExecError::BinaryNotFound { searched })
102    }
103
104    /// A backend over a known binary. The stub-binary tests use this.
105    pub fn at(binary: impl Into<PathBuf>) -> Self {
106        Self {
107            binary: binary.into(),
108            lock: RwLock::new(()),
109        }
110    }
111
112    pub fn binary(&self) -> &Path {
113        &self.binary
114    }
115
116    async fn spawn(&self, invocation: Invocation) -> Result<Output, ExecError> {
117        // Held for the whole call. Dropped on every exit path, including the
118        // timeout, because it lives in this scope.
119        let _guard = match invocation.concurrency {
120            Concurrency::Shared => Guard::Shared(self.lock.read().await),
121            Concurrency::Exclusive => Guard::Exclusive(self.lock.write().await),
122        };
123
124        let command = invocation.display();
125        let mut cmd = tokio::process::Command::new(&self.binary);
126        cmd.args(&invocation.args)
127            .env_clear()
128            .envs(minimal_env())
129            // Closed, so a command that would prompt fails instead of hanging
130            // on a terminal that is not there.
131            .stdin(Stdio::null())
132            .stdout(Stdio::piped())
133            .stderr(Stdio::piped())
134            // If this future is dropped — a cancelled request, a shutdown —
135            // the child does not outlive it.
136            .kill_on_drop(true);
137
138        let mut child = cmd.spawn().map_err(|source| ExecError::Spawn {
139            binary: self.binary.display().to_string(),
140            source,
141        })?;
142
143        let mut stdout_pipe = child.stdout.take();
144        let mut stderr_pipe = child.stderr.take();
145
146        // Owned out here rather than inside the reading futures, so that a
147        // child killed for taking too long still leaves behind whatever it had
148        // said. `read_to_end` fills the buffer as it goes, and cancelling it
149        // keeps what it filled.
150        let mut stdout_buf = Vec::new();
151        let mut stderr_buf = Vec::new();
152
153        let collected = {
154            let read_out = async {
155                if let Some(pipe) = stdout_pipe.as_mut() {
156                    pipe.read_to_end(&mut stdout_buf).await?;
157                }
158                Ok::<(), std::io::Error>(())
159            };
160            let read_err = async {
161                if let Some(pipe) = stderr_pipe.as_mut() {
162                    pipe.read_to_end(&mut stderr_buf).await?;
163                }
164                Ok::<(), std::io::Error>(())
165            };
166
167            let work = async {
168                let (out, err, status) = tokio::join!(read_out, read_err, child.wait());
169                out?;
170                err?;
171                status
172            };
173
174            tokio::time::timeout(invocation.timeout, work).await.ok()
175        };
176
177        match collected {
178            Some(Ok(status)) => Ok(Output {
179                exit_code: status.code(),
180                stdout: stdout_buf,
181                stderr: String::from_utf8_lossy(&stderr_buf).into_owned(),
182            }),
183            Some(Err(source)) => Err(ExecError::Io { command, source }),
184            None => {
185                terminate(&mut child).await;
186                Err(ExecError::Timeout {
187                    command,
188                    timeout: invocation.timeout,
189                    printed: printed(&stdout_buf, &stderr_buf),
190                })
191            }
192        }
193    }
194}
195
196impl LocalBackend for CliBackend {
197    fn run<'a>(&'a self, invocation: Invocation) -> BoxFuture<'a, Result<Output, ExecError>> {
198        Box::pin(self.spawn(invocation))
199    }
200}
201
202/// Ask the child to stop, then insist.
203///
204/// The polite request matters: `tailscale` cleans up state on the way out, and
205/// a killed process can leave a half-applied preference behind.
206/// What a killed child had said, both streams together and in the order a
207/// person reading a terminal would have seen them: standard output first,
208/// since that is where the client puts the thing it wants acted on.
209///
210/// Kept short, because it goes into an error message rather than into a result.
211fn printed(stdout: &[u8], stderr: &[u8]) -> String {
212    const LIMIT: usize = 2_000;
213    let mut out = String::new();
214    for stream in [stdout, stderr] {
215        let text = String::from_utf8_lossy(stream);
216        let text = text.trim();
217        if text.is_empty() {
218            continue;
219        }
220        if !out.is_empty() {
221            out.push('\n');
222        }
223        out.push_str(text);
224    }
225    if out.len() > LIMIT {
226        // On a character boundary, so the result is still a string.
227        let end = (0..=LIMIT)
228            .rev()
229            .find(|i| out.is_char_boundary(*i))
230            .unwrap_or(0);
231        out.truncate(end);
232        out.push('\u{2026}');
233    }
234    out
235}
236
237async fn terminate(child: &mut tokio::process::Child) {
238    #[cfg(unix)]
239    if let Some(pid) = child.id() {
240        // A failure here means the child is already gone, which is the outcome
241        // we wanted anyway.
242        let _ = nix::sys::signal::kill(
243            nix::unistd::Pid::from_raw(pid as i32),
244            nix::sys::signal::Signal::SIGTERM,
245        );
246        if tokio::time::timeout(GRACE_PERIOD, child.wait())
247            .await
248            .is_ok()
249        {
250            return;
251        }
252    }
253    let _ = child.start_kill();
254    let _ = child.wait().await;
255}
256
257/// Held for the duration of a call and never inspected: the lock is released
258/// by dropping it, which is the whole point.
259#[allow(dead_code)]
260enum Guard<'a> {
261    Shared(tokio::sync::RwLockReadGuard<'a, ()>),
262    Exclusive(tokio::sync::RwLockWriteGuard<'a, ()>),
263}
264
265/// The environment the child gets: an allow-list, not the parent's.
266///
267/// Two reasons. Our own credentials — API access tokens, OAuth secrets — are in
268/// this process's environment and have no business in a child that does not need
269/// them. And `TS_DEBUG_*` and friends change the CLI's behaviour, so inheriting
270/// whatever the launching shell happened to have makes the server's behaviour
271/// depend on how it was started.
272fn minimal_env() -> BTreeMap<OsString, OsString> {
273    const KEEP: &[&str] = &[
274        // Needed to find helpers and, on macOS, the app bundle.
275        "PATH",
276        // The CLI reads and writes per-user state.
277        "HOME",
278        "USER",
279        "LOGNAME",
280        // Where a secret file may live.
281        "TMPDIR",
282        // Windows cannot start a process without these.
283        "SystemRoot",
284        "SystemDrive",
285        "COMSPEC",
286        "PATHEXT",
287        "USERPROFILE",
288        "APPDATA",
289        "LOCALAPPDATA",
290        "ProgramData",
291        "ProgramFiles",
292        "TEMP",
293        "TMP",
294        "windir",
295    ];
296
297    let mut env: BTreeMap<OsString, OsString> = KEEP
298        .iter()
299        .filter_map(|key| std::env::var_os(key).map(|value| (OsString::from(key), value)))
300        .collect();
301    // Stable, parseable output regardless of the operator's locale.
302    env.insert(OsString::from("LC_ALL"), OsString::from("C"));
303    env
304}
305
306fn is_executable_file(path: &Path) -> bool {
307    let Ok(meta) = std::fs::metadata(path) else {
308        return false;
309    };
310    if !meta.is_file() {
311        return false;
312    }
313    #[cfg(unix)]
314    {
315        use std::os::unix::fs::PermissionsExt as _;
316        meta.permissions().mode() & 0o111 != 0
317    }
318    #[cfg(not(unix))]
319    {
320        true
321    }
322}
323
324/// `tailscale` as found on `PATH`, resolved by hand so that the error message
325/// can say where we looked.
326fn search_path_candidates() -> impl Iterator<Item = PathBuf> {
327    let names: &[&str] = if cfg!(windows) {
328        &["tailscale.exe"]
329    } else {
330        &["tailscale"]
331    };
332    let dirs: Vec<PathBuf> = std::env::var_os("PATH")
333        .map(|path| std::env::split_paths(&path).collect())
334        .unwrap_or_default();
335    dirs.into_iter()
336        .flat_map(|dir| names.iter().map(move |name| dir.join(name)))
337}
338
339/// The first candidate that can be used, or every place that was looked.
340///
341/// Takes the list rather than calling [`candidates`] so that a test can drive
342/// both kinds of belief over stubs it controls; the real list is absolute paths
343/// on this machine, which no test can arrange.
344fn first_usable(
345    candidates: impl IntoIterator<Item = (PathBuf, Believe)>,
346) -> Result<PathBuf, Vec<String>> {
347    let mut searched = Vec::new();
348    for (candidate, believe) in candidates {
349        if !is_executable_file(&candidate) {
350            searched.push(candidate.display().to_string());
351            continue;
352        }
353        // Reached only when nothing earlier was there, so the cost of asking is
354        // paid on the machines that would otherwise be handed a surface on
355        // which every call fails.
356        if believe == Believe::OnceItAnswers && !answers_as_cli(&candidate) {
357            searched.push(format!(
358                "{} (present, but does not answer `version` as the CLI)",
359                candidate.display()
360            ));
361            continue;
362        }
363        return Ok(candidate);
364    }
365    Err(searched)
366}
367
368/// Whether a candidate is believed on sight, or only once it has answered.
369#[derive(Clone, Copy, Debug, PartialEq, Eq)]
370enum Believe {
371    /// A `tailscale` on `PATH`, or the shim beside it. Nothing else on a
372    /// machine is called that, and both are the command-line interface.
373    OnSight,
374    /// The executable inside the application bundle: see [`answers_as_cli`].
375    OnceItAnswers,
376}
377
378/// Everywhere a `tailscale` might be, in the order they are considered.
379///
380/// Gathered into one ordered list rather than chained at the point of use so
381/// that a test can hold the order, which is half of what this fix is: the shim
382/// has to be reached before the executable inside the bundle (Q157).
383fn candidates() -> Vec<(PathBuf, Believe)> {
384    search_path_candidates()
385        .chain(shim_candidates())
386        .map(|path| (path, Believe::OnSight))
387        .chain(bundle_candidates().map(|path| (path, Believe::OnceItAnswers)))
388        .collect()
389}
390
391/// The shim the macOS applications install for command-line use.
392///
393/// Both builds offer to put this there, and it is what a person means by "the
394/// `tailscale` command" on a Mac. It is a two-line `/bin/sh` script in front of
395/// the executable inside the bundle and, unlike that executable, it acts as the
396/// command-line interface whatever environment it is handed — which is the
397/// property that matters here (Q157).
398///
399/// Looked for by absolute path rather than left to `PATH`, because
400/// `/usr/local/bin` is not in the environment a launcher hands a server started
401/// outside a login shell, and that is how an MCP client starts one.
402fn shim_candidates() -> impl Iterator<Item = PathBuf> {
403    let paths: &[&str] = if cfg!(target_os = "macos") {
404        &["/usr/local/bin/tailscale"]
405    } else {
406        &[]
407    };
408    paths.iter().map(PathBuf::from)
409}
410
411/// Whether a candidate answers as the command-line interface.
412///
413/// The executable inside the standalone application's bundle is the
414/// application's own. Handed the environment [`minimal_env`] builds, it starts
415/// the GUI rather than running the command, prints `The Tailscale GUI failed to
416/// start` **on standard output**, and **exits 0** — so neither the exit status
417/// nor the stream it chose tells it apart from a version. Only the shape of
418/// what it printed does: `version` opens with the number and nothing before it,
419/// so a first line not starting with a digit is not one (Q157).
420///
421/// `version` is the right question to ask: it reads the build stamped into the
422/// binary, so it is local, prompt, and changes nothing.
423fn answers_as_cli(path: &Path) -> bool {
424    let Ok(output) = std::process::Command::new(path)
425        .arg("version")
426        .env_clear()
427        .envs(minimal_env())
428        .stdin(Stdio::null())
429        .stdout(Stdio::piped())
430        .stderr(Stdio::null())
431        .output()
432    else {
433        return false;
434    };
435    output.status.success()
436        && String::from_utf8_lossy(&output.stdout)
437            .lines()
438            .next()
439            .is_some_and(|line| line.starts_with(|c: char| c.is_ascii_digit()))
440}
441
442/// Where the macOS applications keep the executable itself.
443///
444/// Tried last and only when it answers, because this is the one candidate that
445/// can be present, executable, and still not be a command-line interface:
446/// see [`answers_as_cli`]. Neither path is on `PATH` at all.
447fn bundle_candidates() -> impl Iterator<Item = PathBuf> {
448    let paths: &[&str] = if cfg!(target_os = "macos") {
449        &[
450            // The standalone build, and the App Store build's own copy.
451            "/Applications/Tailscale.app/Contents/MacOS/tailscale",
452            "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
453        ]
454    } else {
455        &[]
456    };
457    let mut candidates: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
458    if cfg!(target_os = "macos")
459        && let Some(home) = std::env::var_os("HOME")
460    {
461        candidates
462            .push(PathBuf::from(home).join("Applications/Tailscale.app/Contents/MacOS/tailscale"));
463    }
464    candidates.into_iter()
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn a_named_binary_that_is_not_there_is_an_error_not_a_fallback() {
473        let err =
474            CliBackend::discover_with(Some(std::ffi::OsStr::new("/definitely/not/here/tailscale")))
475                .expect_err("a missing override must fail");
476        assert!(
477            matches!(err, ExecError::BinaryNotExecutable { .. }),
478            "{err:?}"
479        );
480    }
481
482    #[test]
483    fn an_empty_override_is_treated_as_unset() {
484        // Falls through to discovery rather than failing on the empty path.
485        let result = CliBackend::discover_with(Some(std::ffi::OsStr::new("")));
486        match result {
487            Ok(_) | Err(ExecError::BinaryNotFound { .. }) => {}
488            Err(other) => panic!("unexpected error: {other:?}"),
489        }
490    }
491
492    /// What the standalone application's own executable prints when it is run
493    /// outside a login environment: no version, and a zero exit.
494    #[cfg(unix)]
495    const STARTS_THE_GUI: &str = "echo 'The Tailscale GUI failed to start: The operation couldn\u{2019}t be completed. \
496             (Tailscale.CLIError error 3.)'";
497
498    /// A stub `tailscale` that runs `script` and exits however it exits.
499    #[cfg(unix)]
500    fn stub_named(dir: &tempfile::TempDir, name: &str, script: &str) -> PathBuf {
501        use std::os::unix::fs::PermissionsExt as _;
502        let path = dir.path().join(name);
503        std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).expect("write the stub");
504        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
505            .expect("make the stub executable");
506        path
507    }
508
509    #[cfg(unix)]
510    #[test]
511    fn a_bundled_executable_that_starts_the_gui_is_passed_over_for_one_that_answers() {
512        // The affected machine in one list: the application is there, and so
513        // is the shim, and only the shim is the command-line interface.
514        // Two directories, because the names differ only in case and a Mac's
515        // filesystem does not: in one directory the second would be the first.
516        let bundle = tempfile::tempdir().expect("a temp dir");
517        let usr_local = tempfile::tempdir().expect("a temp dir");
518        let bundled = stub_named(&bundle, "Tailscale", STARTS_THE_GUI);
519        let shim = stub_named(&usr_local, "tailscale", "echo '1.102.2'");
520        let found = first_usable(vec![
521            (bundled, Believe::OnceItAnswers),
522            (shim.clone(), Believe::OnSight),
523        ])
524        .expect("the shim is usable");
525        assert_eq!(found, shim);
526    }
527
528    #[cfg(unix)]
529    #[test]
530    fn a_bundled_executable_that_starts_the_gui_is_not_a_binary_we_found() {
531        // With nothing else on the machine the honest answer is that there is
532        // no command-line interface here, so the surface is not offered at all
533        // rather than offered and failing on every call (Q157).
534        let bundle = tempfile::tempdir().expect("a temp dir");
535        let bundled = stub_named(&bundle, "Tailscale", STARTS_THE_GUI);
536        let searched =
537            first_usable(vec![(bundled, Believe::OnceItAnswers)]).expect_err("nothing is usable");
538        assert!(
539            searched.iter().any(|line| line.contains("does not answer")),
540            "the reason has to name itself: {searched:?}"
541        );
542    }
543
544    #[cfg(unix)]
545    #[test]
546    fn a_candidate_that_reports_a_version_answers_as_the_cli() {
547        let dir = tempfile::tempdir().expect("a temp dir");
548        let path = stub_named(
549            &dir,
550            "tailscale",
551            "echo '1.102.2'\necho '  tailscale commit: 6cac9181'",
552        );
553        assert!(answers_as_cli(&path));
554    }
555
556    #[cfg(unix)]
557    #[test]
558    fn a_candidate_that_says_nothing_at_all_does_not_answer_as_the_cli() {
559        let dir = tempfile::tempdir().expect("a temp dir");
560        let path = stub_named(&dir, "tailscale", "exit 0");
561        assert!(!answers_as_cli(&path));
562    }
563
564    #[test]
565    fn the_shim_is_reached_before_the_executable_inside_the_bundle() {
566        // The whole of the macOS fix is this order: on an affected machine both
567        // are present, and only the one reached first acts as the CLI.
568        let all = candidates();
569        let at = |wanted: Vec<PathBuf>| {
570            all.iter()
571                .position(|(path, _)| wanted.iter().any(|w| w == path))
572        };
573        match (
574            at(shim_candidates().collect()),
575            at(bundle_candidates().collect()),
576        ) {
577            (Some(shim), Some(bundle)) => assert!(shim < bundle, "{all:?}"),
578            (None, None) if cfg!(target_os = "macos") => panic!("macOS offers both"),
579            (None, None) => {}
580            other => panic!("one list reached without the other: {other:?}"),
581        }
582    }
583
584    #[test]
585    fn only_the_executable_inside_the_bundle_has_to_answer_before_it_is_believed() {
586        let bundled: Vec<PathBuf> = bundle_candidates().collect();
587        for (path, believe) in candidates() {
588            assert_eq!(
589                believe == Believe::OnceItAnswers,
590                bundled.contains(&path),
591                "{} is believed the wrong way round",
592                path.display()
593            );
594        }
595    }
596}