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 macOS
85    /// application bundle. The override is first because an operator who names
86    /// a path means it — if it is wrong, that is an error rather than a quiet
87    /// fallback to some other Tailscale on the machine.
88    pub fn discover() -> Result<Self, ExecError> {
89        Self::discover_with(std::env::var_os(BINARY_ENV).as_deref())
90    }
91
92    /// [`Self::discover`], with the override supplied rather than read from the
93    /// environment, so a test can drive every branch.
94    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    /// A backend over a known binary. The stub-binary tests use this.
116    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        // Held for the whole call. Dropped on every exit path, including the
129        // timeout, because it lives in this scope.
130        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                // Closed, so a command that would prompt fails instead of
144                // hanging on a terminal that is not there.
145                Stdio::null()
146            })
147            .stdout(Stdio::piped())
148            .stderr(Stdio::piped())
149            // If this future is dropped — a cancelled request, a shutdown —
150            // the child does not outlive it.
151            .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        // Owned out here rather than inside the reading futures, so that a
164        // child killed for taking too long still leaves behind whatever it had
165        // said. `read_to_end` fills the buffer as it goes, and cancelling it
166        // keeps what it filled.
167        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                // Closing the pipe is what tells the child there is no more.
177                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
229/// Ask the child to stop, then insist.
230///
231/// The polite request matters: `tailscale` cleans up state on the way out, and
232/// a killed process can leave a half-applied preference behind.
233/// What a killed child had said, both streams together and in the order a
234/// person reading a terminal would have seen them: standard output first,
235/// since that is where the client puts the thing it wants acted on.
236///
237/// Kept short, because it goes into an error message rather than into a result.
238fn 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        // On a character boundary, so the result is still a string.
254        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        // A failure here means the child is already gone, which is the outcome
268        // we wanted anyway.
269        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/// Held for the duration of a call and never inspected: the lock is released
285/// by dropping it, which is the whole point.
286#[allow(dead_code)]
287enum Guard<'a> {
288    Shared(tokio::sync::RwLockReadGuard<'a, ()>),
289    Exclusive(tokio::sync::RwLockWriteGuard<'a, ()>),
290}
291
292/// The environment the child gets: an allow-list, not the parent's.
293///
294/// Two reasons. Our own credentials — API access tokens, OAuth secrets — are in
295/// this process's environment and have no business in a child that does not need
296/// them. And `TS_DEBUG_*` and friends change the CLI's behaviour, so inheriting
297/// whatever the launching shell happened to have makes the server's behaviour
298/// depend on how it was started.
299fn minimal_env() -> BTreeMap<OsString, OsString> {
300    const KEEP: &[&str] = &[
301        // Needed to find helpers and, on macOS, the app bundle.
302        "PATH",
303        // The CLI reads and writes per-user state.
304        "HOME",
305        "USER",
306        "LOGNAME",
307        // Where a secret file may live.
308        "TMPDIR",
309        // Windows cannot start a process without these.
310        "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    // Stable, parseable output regardless of the operator's locale.
329    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
351/// `tailscale` as found on `PATH`, resolved by hand so that the error message
352/// can say where we looked.
353fn 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
366/// Where the macOS applications put their CLI. Neither is on `PATH` unless the
367/// app has been asked to install its symlink.
368fn bundle_candidates() -> impl Iterator<Item = PathBuf> {
369    let paths: &[&str] = if cfg!(target_os = "macos") {
370        &[
371            // The standalone build, and the App Store build's own copy.
372            "/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        // Falls through to discovery rather than failing on the empty path.
418        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}