mirror of
https://github.com/trailofbits/dropkit
synced 2026-06-21 14:11:54 +00:00
Fix macOS Tailscale CLI discovery for App Store installs (#35)
* Fix macOS Tailscale CLI discovery for App Store installs On macOS, Tailscale installed via the App Store places its CLI at /Applications/Tailscale.app/Contents/MacOS/Tailscale, which is not in PATH. Add find_tailscale_cli() helper that checks PATH first, then falls back to the macOS App Store location on darwin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR feedback: better error message, remove excessive test Distinguish "Tailscale not found" from "not running" when the local check fails, so users know whether to install or start Tailscale. Remove the overly comprehensive binary_vanishes race condition test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+32
-5
@@ -2,7 +2,9 @@
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -735,6 +737,26 @@ def wait_for_cloud_init(ssh_hostname: str, verbose: bool = False) -> tuple[bool,
|
||||
|
||||
# Tailscale helper functions
|
||||
|
||||
_MACOS_TAILSCALE_PATH = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
|
||||
|
||||
|
||||
def find_tailscale_cli() -> str | None:
|
||||
"""
|
||||
Find the Tailscale CLI binary.
|
||||
|
||||
Checks PATH first, then falls back to the macOS App Store location
|
||||
where Tailscale is installed as a GUI app without a symlink in PATH.
|
||||
|
||||
Returns:
|
||||
Path to the Tailscale CLI binary, or None if not found
|
||||
"""
|
||||
path = shutil.which("tailscale")
|
||||
if path is not None:
|
||||
return path
|
||||
if sys.platform == "darwin" and Path(_MACOS_TAILSCALE_PATH).exists():
|
||||
return _MACOS_TAILSCALE_PATH
|
||||
return None
|
||||
|
||||
|
||||
def check_local_tailscale() -> bool:
|
||||
"""
|
||||
@@ -743,18 +765,20 @@ def check_local_tailscale() -> bool:
|
||||
Returns:
|
||||
True if Tailscale is running and the user is connected to a tailnet
|
||||
"""
|
||||
tailscale_bin = find_tailscale_cli()
|
||||
if tailscale_bin is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tailscale", "status", "--json"],
|
||||
[tailscale_bin, "status", "--json"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
# Parse JSON to check if we're connected
|
||||
status = json.loads(result.stdout.decode("utf-8", errors="ignore"))
|
||||
# BackendState should be "Running" if connected
|
||||
return status.get("BackendState") == "Running"
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
|
||||
return False
|
||||
@@ -1475,9 +1499,12 @@ def setup_tailscale(
|
||||
"[yellow]⚠[/yellow] SSH verification failed - you may need to wait a moment"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
"[yellow]⚠[/yellow] Local Tailscale not running - skipping firewall lockdown"
|
||||
reason = (
|
||||
"not running — skipping firewall lockdown"
|
||||
if find_tailscale_cli()
|
||||
else "not found — install it or add it to PATH"
|
||||
)
|
||||
console.print(f"[yellow]⚠[/yellow] Tailscale {reason}")
|
||||
console.print(
|
||||
"[dim]Public SSH access remains available. Start Tailscale locally and run:[/dim]"
|
||||
)
|
||||
|
||||
+50
-9
@@ -9,6 +9,7 @@ from dropkit.config import TailscaleConfig
|
||||
from dropkit.main import (
|
||||
check_local_tailscale,
|
||||
check_tailscale_installed,
|
||||
find_tailscale_cli,
|
||||
install_tailscale_on_droplet,
|
||||
is_tailscale_ip,
|
||||
lock_down_to_tailscale,
|
||||
@@ -82,11 +83,48 @@ class TestIsTailscaleIP:
|
||||
assert is_tailscale_ip(None) is False # type: ignore
|
||||
|
||||
|
||||
class TestFindTailscaleCli:
|
||||
"""Tests for find_tailscale_cli function."""
|
||||
|
||||
@patch("dropkit.main.shutil.which", return_value="/usr/bin/tailscale")
|
||||
def test_found_in_path(self, mock_which):
|
||||
"""Test when tailscale is found in PATH."""
|
||||
assert find_tailscale_cli() == "/usr/bin/tailscale"
|
||||
|
||||
@patch("dropkit.main.Path.exists", return_value=True)
|
||||
@patch("dropkit.main.sys.platform", "darwin")
|
||||
@patch("dropkit.main.shutil.which", return_value=None)
|
||||
def test_macos_app_store_fallback(self, mock_which, mock_exists):
|
||||
"""Test fallback to macOS App Store location."""
|
||||
assert find_tailscale_cli() == "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
|
||||
|
||||
@patch("dropkit.main.Path.exists", return_value=False)
|
||||
@patch("dropkit.main.sys.platform", "darwin")
|
||||
@patch("dropkit.main.shutil.which", return_value=None)
|
||||
def test_macos_app_not_installed(self, mock_which, mock_exists):
|
||||
"""Test when Tailscale is not installed on macOS."""
|
||||
assert find_tailscale_cli() is None
|
||||
|
||||
@patch("dropkit.main.sys.platform", "linux")
|
||||
@patch("dropkit.main.shutil.which", return_value=None)
|
||||
def test_linux_not_in_path(self, mock_which):
|
||||
"""Test when tailscale is not in PATH on Linux (no macOS fallback)."""
|
||||
assert find_tailscale_cli() is None
|
||||
|
||||
@patch("dropkit.main.Path.exists")
|
||||
@patch("dropkit.main.shutil.which", return_value="/opt/bin/tailscale")
|
||||
def test_which_returns_path_skips_fallback(self, mock_which, mock_exists):
|
||||
"""Test that PATH hit skips macOS App Store check."""
|
||||
assert find_tailscale_cli() == "/opt/bin/tailscale"
|
||||
mock_exists.assert_not_called()
|
||||
|
||||
|
||||
class TestCheckLocalTailscale:
|
||||
"""Tests for check_local_tailscale function."""
|
||||
|
||||
@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
|
||||
@patch("dropkit.main.subprocess.run")
|
||||
def test_tailscale_running(self, mock_run):
|
||||
def test_tailscale_running(self, mock_run, mock_find):
|
||||
"""Test when Tailscale is running locally."""
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
@@ -94,8 +132,9 @@ class TestCheckLocalTailscale:
|
||||
)
|
||||
assert check_local_tailscale() is True
|
||||
|
||||
@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
|
||||
@patch("dropkit.main.subprocess.run")
|
||||
def test_tailscale_not_running(self, mock_run):
|
||||
def test_tailscale_not_running(self, mock_run, mock_find):
|
||||
"""Test when Tailscale is installed but not running."""
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
@@ -103,28 +142,30 @@ class TestCheckLocalTailscale:
|
||||
)
|
||||
assert check_local_tailscale() is False
|
||||
|
||||
@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
|
||||
@patch("dropkit.main.subprocess.run")
|
||||
def test_tailscale_command_fails(self, mock_run):
|
||||
def test_tailscale_command_fails(self, mock_run, mock_find):
|
||||
"""Test when tailscale command returns non-zero."""
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
assert check_local_tailscale() is False
|
||||
|
||||
@patch("dropkit.main.subprocess.run")
|
||||
def test_tailscale_not_installed(self, mock_run):
|
||||
"""Test when tailscale is not installed."""
|
||||
mock_run.side_effect = FileNotFoundError()
|
||||
@patch("dropkit.main.find_tailscale_cli", return_value=None)
|
||||
def test_tailscale_not_installed(self, mock_find):
|
||||
"""Test when tailscale binary is not found."""
|
||||
assert check_local_tailscale() is False
|
||||
|
||||
@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
|
||||
@patch("dropkit.main.subprocess.run")
|
||||
def test_tailscale_timeout(self, mock_run):
|
||||
def test_tailscale_timeout(self, mock_run, mock_find):
|
||||
"""Test when tailscale command times out."""
|
||||
import subprocess
|
||||
|
||||
mock_run.side_effect = subprocess.TimeoutExpired("tailscale", 5)
|
||||
assert check_local_tailscale() is False
|
||||
|
||||
@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
|
||||
@patch("dropkit.main.subprocess.run")
|
||||
def test_invalid_json_response(self, mock_run):
|
||||
def test_invalid_json_response(self, mock_run, mock_find):
|
||||
"""Test when tailscale returns invalid JSON."""
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
|
||||
Reference in New Issue
Block a user