1use 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
15pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
20
21pub const GRACE_PERIOD: Duration = Duration::from_secs(2);
23
24#[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 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#[derive(Debug)]
70pub struct CliBackend {
71 binary: PathBuf,
72 lock: RwLock<()>,
76}
77
78impl CliBackend {
79 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 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 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 .stdin(Stdio::null())
132 .stdout(Stdio::piped())
133 .stderr(Stdio::piped())
134 .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 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
202fn 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 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 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#[allow(dead_code)]
260enum Guard<'a> {
261 Shared(tokio::sync::RwLockReadGuard<'a, ()>),
262 Exclusive(tokio::sync::RwLockWriteGuard<'a, ()>),
263}
264
265fn minimal_env() -> BTreeMap<OsString, OsString> {
273 const KEEP: &[&str] = &[
274 "PATH",
276 "HOME",
278 "USER",
279 "LOGNAME",
280 "TMPDIR",
282 "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 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
324fn 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
339fn 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
370enum Believe {
371 OnSight,
374 OnceItAnswers,
376}
377
378fn 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
391fn 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
411fn 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
442fn bundle_candidates() -> impl Iterator<Item = PathBuf> {
448 let paths: &[&str] = if cfg!(target_os = "macos") {
449 &[
450 "/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 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 #[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 #[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 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 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 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}