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 "LLVM_PROFILE_FILE",
285 "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 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
327fn 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
342fn 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
373enum Believe {
374 OnSight,
377 OnceItAnswers,
379}
380
381fn 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
397fn 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
414fn 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
434fn 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
465fn bundle_candidates() -> impl Iterator<Item = PathBuf> {
472 let paths: &[&str] = if cfg!(target_os = "macos") {
473 &[
474 "/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 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 #[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 #[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 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 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 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}