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        // Set only under a coverage run. Without it an instrumented child
283        // writes its profile into the working directory, out of the merge.
284        "LLVM_PROFILE_FILE",
285        // Windows cannot start a process without these.
286        "SystemRoot",
287        "SystemDrive",
288        "COMSPEC",
289        "PATHEXT",
290        "USERPROFILE",
291        "APPDATA",
292        "LOCALAPPDATA",
293        "ProgramData",
294        "ProgramFiles",
295        "TEMP",
296        "TMP",
297        "windir",
298    ];
299
300    let mut env: BTreeMap<OsString, OsString> = KEEP
301        .iter()
302        .filter_map(|key| std::env::var_os(key).map(|value| (OsString::from(key), value)))
303        .collect();
304    // Stable, parseable output regardless of the operator's locale.
305    env.insert(OsString::from("LC_ALL"), OsString::from("C"));
306    env
307}
308
309fn is_executable_file(path: &Path) -> bool {
310    let Ok(meta) = std::fs::metadata(path) else {
311        return false;
312    };
313    if !meta.is_file() {
314        return false;
315    }
316    #[cfg(unix)]
317    {
318        use std::os::unix::fs::PermissionsExt as _;
319        meta.permissions().mode() & 0o111 != 0
320    }
321    #[cfg(not(unix))]
322    {
323        true
324    }
325}
326
327/// `tailscale` as found on `PATH`, resolved by hand so that the error message
328/// can say where we looked.
329fn search_path_candidates() -> impl Iterator<Item = PathBuf> {
330    let names: &[&str] = if cfg!(windows) {
331        &["tailscale.exe"]
332    } else {
333        &["tailscale"]
334    };
335    let dirs: Vec<PathBuf> = std::env::var_os("PATH")
336        .map(|path| std::env::split_paths(&path).collect())
337        .unwrap_or_default();
338    dirs.into_iter()
339        .flat_map(|dir| names.iter().map(move |name| dir.join(name)))
340}
341
342/// The first candidate that can be used, or every place that was looked.
343///
344/// Takes the list rather than calling [`candidates`] so that a test can drive
345/// both kinds of belief over stubs it controls; the real list is absolute paths
346/// on this machine, which no test can arrange.
347fn first_usable(
348    candidates: impl IntoIterator<Item = (PathBuf, Believe)>,
349) -> Result<PathBuf, Vec<String>> {
350    let mut searched = Vec::new();
351    for (candidate, believe) in candidates {
352        if !is_executable_file(&candidate) {
353            searched.push(candidate.display().to_string());
354            continue;
355        }
356        // Reached only when nothing earlier was there, so the cost of asking is
357        // paid on the machines that would otherwise be handed a surface on
358        // which every call fails.
359        if believe == Believe::OnceItAnswers && !answers_as_cli(&candidate) {
360            searched.push(format!(
361                "{} (present, but does not answer `version` as the CLI)",
362                candidate.display()
363            ));
364            continue;
365        }
366        return Ok(candidate);
367    }
368    Err(searched)
369}
370
371/// Whether a candidate is believed on sight, or only once it has answered.
372#[derive(Clone, Copy, Debug, PartialEq, Eq)]
373enum Believe {
374    /// A `tailscale` outside any application bundle. Nothing else on a
375    /// machine is called that, and it is the command-line interface.
376    OnSight,
377    /// The executable inside an application bundle: see [`answers_as_cli`].
378    OnceItAnswers,
379}
380
381/// Everywhere a `tailscale` might be, in the order they are considered.
382///
383/// Gathered into one ordered list rather than chained at the point of use so
384/// that a test can hold the order, which is half of what this fix is: the shim
385/// has to be reached before the executable inside the bundle (Q157).
386fn candidates() -> Vec<(PathBuf, Believe)> {
387    search_path_candidates()
388        .chain(shim_candidates())
389        .chain(bundle_candidates())
390        .map(|path| {
391            let believe = belief(&path);
392            (path, believe)
393        })
394        .collect()
395}
396
397/// Decided by where the file really lives rather than by which list found it,
398/// because a `PATH` entry or a link reaches the bundle's executable as surely
399/// as the bundle's own path does.
400fn belief(path: &Path) -> Believe {
401    let real = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned());
402    let in_a_bundle = real.components().any(|part| {
403        Path::new(part.as_os_str())
404            .extension()
405            .is_some_and(|ext| ext.eq_ignore_ascii_case("app"))
406    });
407    if in_a_bundle {
408        Believe::OnceItAnswers
409    } else {
410        Believe::OnSight
411    }
412}
413
414/// The shim the macOS applications install for command-line use.
415///
416/// Both builds offer to put this there, and it is what a person means by "the
417/// `tailscale` command" on a Mac. It is a two-line `/bin/sh` script in front of
418/// the executable inside the bundle and, unlike that executable, it acts as the
419/// command-line interface whatever environment it is handed — which is the
420/// property that matters here (Q157).
421///
422/// Looked for by absolute path rather than left to `PATH`, because
423/// `/usr/local/bin` is not in the environment a launcher hands a server started
424/// outside a login shell, and that is how an MCP client starts one.
425fn shim_candidates() -> impl Iterator<Item = PathBuf> {
426    let paths: &[&str] = if cfg!(target_os = "macos") {
427        &["/usr/local/bin/tailscale"]
428    } else {
429        &[]
430    };
431    paths.iter().map(PathBuf::from)
432}
433
434/// Whether a candidate answers as the command-line interface.
435///
436/// The executable inside the standalone application's bundle is the
437/// application's own. Handed the environment [`minimal_env`] builds, it starts
438/// the GUI rather than running the command, prints `The Tailscale GUI failed to
439/// start` **on standard output**, and **exits 0** — so neither the exit status
440/// nor the stream it chose tells it apart from a version. Only the shape of
441/// what it printed does: `version` opens with the number and nothing before it,
442/// so a first line not starting with a digit is not one (Q157).
443///
444/// `version` is the right question to ask: it reads the build stamped into the
445/// binary, so it is local, prompt, and changes nothing.
446fn answers_as_cli(path: &Path) -> bool {
447    let Ok(output) = std::process::Command::new(path)
448        .arg("version")
449        .env_clear()
450        .envs(minimal_env())
451        .stdin(Stdio::null())
452        .stdout(Stdio::piped())
453        .stderr(Stdio::null())
454        .output()
455    else {
456        return false;
457    };
458    output.status.success()
459        && String::from_utf8_lossy(&output.stdout)
460            .lines()
461            .next()
462            .is_some_and(|line| line.starts_with(|c: char| c.is_ascii_digit()))
463}
464
465/// Where the macOS applications keep the executable itself.
466///
467/// Tried last and only when it answers, because this is the one candidate that
468/// can be present, executable, and still not be a command-line interface:
469/// see [`answers_as_cli`]. Neither path is on `PATH` unless a person puts it
470/// there, and then [`belief`] asks it all the same.
471fn bundle_candidates() -> impl Iterator<Item = PathBuf> {
472    let paths: &[&str] = if cfg!(target_os = "macos") {
473        &[
474            // The standalone build, and the App Store build's own copy.
475            "/Applications/Tailscale.app/Contents/MacOS/tailscale",
476            "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
477        ]
478    } else {
479        &[]
480    };
481    let mut candidates: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
482    if cfg!(target_os = "macos")
483        && let Some(home) = std::env::var_os("HOME")
484    {
485        candidates
486            .push(PathBuf::from(home).join("Applications/Tailscale.app/Contents/MacOS/tailscale"));
487    }
488    candidates.into_iter()
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn a_named_binary_that_is_not_there_is_an_error_not_a_fallback() {
497        let err =
498            CliBackend::discover_with(Some(std::ffi::OsStr::new("/definitely/not/here/tailscale")))
499                .expect_err("a missing override must fail");
500        assert!(
501            matches!(err, ExecError::BinaryNotExecutable { .. }),
502            "{err:?}"
503        );
504    }
505
506    #[test]
507    fn an_empty_override_is_treated_as_unset() {
508        // Falls through to discovery rather than failing on the empty path.
509        let result = CliBackend::discover_with(Some(std::ffi::OsStr::new("")));
510        match result {
511            Ok(_) | Err(ExecError::BinaryNotFound { .. }) => {}
512            Err(other) => panic!("unexpected error: {other:?}"),
513        }
514    }
515
516    /// What the standalone application's own executable prints when it is run
517    /// outside a login environment: no version, and a zero exit.
518    #[cfg(unix)]
519    const STARTS_THE_GUI: &str = "echo 'The Tailscale GUI failed to start: The operation couldn\u{2019}t be completed. \
520             (Tailscale.CLIError error 3.)'";
521
522    /// A stub `tailscale` that runs `script` and exits however it exits.
523    #[cfg(unix)]
524    fn stub_named(dir: &tempfile::TempDir, name: &str, script: &str) -> PathBuf {
525        use std::os::unix::fs::PermissionsExt as _;
526        let path = dir.path().join(name);
527        std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).expect("write the stub");
528        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
529            .expect("make the stub executable");
530        path
531    }
532
533    #[cfg(unix)]
534    #[test]
535    fn a_bundled_executable_that_starts_the_gui_is_passed_over_for_one_that_answers() {
536        // The affected machine in one list: the application is there, and so
537        // is the shim, and only the shim is the command-line interface.
538        // Two directories, because the names differ only in case and a Mac's
539        // filesystem does not: in one directory the second would be the first.
540        let bundle = tempfile::tempdir().expect("a temp dir");
541        let usr_local = tempfile::tempdir().expect("a temp dir");
542        let bundled = stub_named(&bundle, "Tailscale", STARTS_THE_GUI);
543        let shim = stub_named(&usr_local, "tailscale", "echo '1.102.2'");
544        let found = first_usable(vec![
545            (bundled, Believe::OnceItAnswers),
546            (shim.clone(), Believe::OnSight),
547        ])
548        .expect("the shim is usable");
549        assert_eq!(found, shim);
550    }
551
552    #[cfg(unix)]
553    #[test]
554    fn a_bundled_executable_that_starts_the_gui_is_not_a_binary_we_found() {
555        // With nothing else on the machine the honest answer is that there is
556        // no command-line interface here, so the surface is not offered at all
557        // rather than offered and failing on every call (Q157).
558        let bundle = tempfile::tempdir().expect("a temp dir");
559        let bundled = stub_named(&bundle, "Tailscale", STARTS_THE_GUI);
560        let searched =
561            first_usable(vec![(bundled, Believe::OnceItAnswers)]).expect_err("nothing is usable");
562        assert!(
563            searched.iter().any(|line| line.contains("does not answer")),
564            "the reason has to name itself: {searched:?}"
565        );
566    }
567
568    #[cfg(unix)]
569    #[test]
570    fn a_candidate_that_reports_a_version_answers_as_the_cli() {
571        let dir = tempfile::tempdir().expect("a temp dir");
572        let path = stub_named(
573            &dir,
574            "tailscale",
575            "echo '1.102.2'\necho '  tailscale commit: 6cac9181'",
576        );
577        assert!(answers_as_cli(&path));
578    }
579
580    #[cfg(unix)]
581    #[test]
582    fn a_candidate_that_says_nothing_at_all_does_not_answer_as_the_cli() {
583        let dir = tempfile::tempdir().expect("a temp dir");
584        let path = stub_named(&dir, "tailscale", "exit 0");
585        assert!(!answers_as_cli(&path));
586    }
587
588    #[test]
589    fn the_shim_is_reached_before_the_executable_inside_the_bundle() {
590        // The whole of the macOS fix is this order: on an affected machine both
591        // are present, and only the one reached first acts as the CLI.
592        let all = candidates();
593        let at = |wanted: Vec<PathBuf>| {
594            all.iter()
595                .position(|(path, _)| wanted.iter().any(|w| w == path))
596        };
597        match (
598            at(shim_candidates().collect()),
599            at(bundle_candidates().collect()),
600        ) {
601            (Some(shim), Some(bundle)) => assert!(shim < bundle, "{all:?}"),
602            (None, None) if cfg!(target_os = "macos") => panic!("macOS offers both"),
603            (None, None) => {}
604            other => panic!("one list reached without the other: {other:?}"),
605        }
606    }
607
608    #[cfg(unix)]
609    #[test]
610    fn only_what_lives_inside_a_bundle_has_to_answer_before_it_is_believed() {
611        let bundle = tempfile::Builder::new()
612            .suffix(".app")
613            .tempdir()
614            .expect("a temp dir");
615        let elsewhere = tempfile::tempdir().expect("a temp dir");
616        let bundled = stub_named(&bundle, "tailscale", STARTS_THE_GUI);
617        let plain = stub_named(&elsewhere, "tailscale", "echo '1.102.2'");
618        let linked = elsewhere.path().join("linked");
619        std::os::unix::fs::symlink(&bundled, &linked).expect("the link is made");
620
621        for path in bundle_candidates().chain([bundled, linked]) {
622            assert_eq!(belief(&path), Believe::OnceItAnswers, "{}", path.display());
623        }
624        assert_eq!(belief(&plain), Believe::OnSight);
625    }
626}