safesocket, version: fix safesocket_darwin behavior for cmd/tailscale (#15275)

fixes tailscale/tailscale#15269

Fixes the various CLIs for all of the various flavors of tailscaled on
darwin.  The logic in version is updated so that we have methods that
return true only for the actual GUI app (which can beCLI) and the
order of the checks in localTCPPortAndTokenDarwin are corrected so
that the logic works with all 5 combinations of CLI and tailscaled.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
This commit is contained in:
Jonathan Nobels 2025-03-11 13:24:11 -04:00 committed by GitHub
parent a6e19f2881
commit 660b0515b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 104 additions and 48 deletions

View File

@ -34,17 +34,17 @@ type safesocketDarwin struct {
mu sync.Mutex
token string // safesocket auth token
port int // safesocket port
sameuserproofFD *os.File // file descriptor for macos app store sameuserproof file
sharedDir string // shared directory for location of sameuserproof file
sameuserproofFD *os.File // File descriptor for macos app store sameuserproof file
sharedDir string // Shared directory for location of sameuserproof file
checkConn bool // Check macsys safesocket port before returning it
isMacSysExt func() bool // For testing only to force macsys
isMacGUIApp func() bool // For testing only to force macOS sandbox
checkConn bool // If true, check macsys safesocket port before returning it
isMacSysExt func() bool // Reports true if this binary is the macOS System Extension
isMacGUIApp func() bool // Reports true if running as a macOS GUI app (Tailscale.app)
}
var ssd = safesocketDarwin{
isMacSysExt: version.IsMacSysExt,
isMacGUIApp: func() bool { return version.IsMacAppStore() || version.IsMacSysApp() || version.IsMacSysExt() },
isMacGUIApp: func() bool { return version.IsMacAppStoreGUI() || version.IsMacSysGUI() },
checkConn: true,
sharedDir: "/Library/Tailscale",
}
@ -63,22 +63,25 @@ var ssd = safesocketDarwin{
// calls InitListenerDarwin.
// localTCPPortAndTokenDarwin returns the localhost TCP port number and auth token
// either generated, or sourced from the NEPacketTunnelProvider managed tailscaled process.
// either from the sameuserproof mechanism, or source and set directly from the
// NEPacketTunnelProvider managed tailscaled process when the CLI is invoked
// from the Tailscale.app GUI.
func localTCPPortAndTokenDarwin() (port int, token string, err error) {
ssd.mu.Lock()
defer ssd.mu.Unlock()
if !ssd.isMacGUIApp() {
return 0, "", ErrNoTokenOnOS
}
if ssd.port != 0 && ssd.token != "" {
switch {
case ssd.port != 0 && ssd.token != "":
// If something has explicitly set our credentials (typically non-standalone macos binary), use them.
return ssd.port, ssd.token, nil
case !ssd.isMacGUIApp():
// We're not a GUI app (probably cmd/tailscale), so try falling back to sameuserproof.
// If portAndTokenFromSameUserProof returns an error here, cmd/tailscale will
// attempt to use the default unix socket mechanism supported by tailscaled.
return portAndTokenFromSameUserProof()
default:
return 0, "", ErrTokenNotFound
}
// Credentials were not explicitly, this is likely a standalone CLI binary.
// Fallback to reading the sameuserproof file.
return portAndTokenFromSameUserProof()
}
// SetCredentials sets an token and port used to authenticate safesocket generated
@ -341,6 +344,11 @@ func readMacosSameUserProof() (port int, token string, err error) {
}
func portAndTokenFromSameUserProof() (port int, token string, err error) {
// When we're cmd/tailscale, we have no idea what tailscaled is, so we'll try
// macos, then macsys and finally, fallback to tailscaled via a unix socket
// if both of those return an error. You can run macos or macsys and
// tailscaled at the same time, but we are forced to choose one and the GUI
// clients are first in line here. You cannot run macos and macsys simultaneously.
if port, token, err := readMacosSameUserProof(); err == nil {
return port, token, nil
}
@ -349,5 +357,5 @@ func portAndTokenFromSameUserProof() (port int, token string, err error) {
return port, token, nil
}
return 0, "", err
return 0, "", ErrTokenNotFound
}

View File

@ -15,9 +15,12 @@ import (
// sets the port and token correctly and that LocalTCPPortAndToken
// returns the given values.
func TestSetCredentials(t *testing.T) {
wantPort := 123
wantToken := "token"
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return true })
const (
wantToken = "token"
wantPort = 123
)
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return false })
SetCredentials(wantToken, wantPort)
gotPort, gotToken, err := LocalTCPPortAndToken()
@ -26,11 +29,47 @@ func TestSetCredentials(t *testing.T) {
}
if gotPort != wantPort {
t.Errorf("got port %d, want %d", gotPort, wantPort)
t.Errorf("port: got %d, want %d", gotPort, wantPort)
}
if gotToken != wantToken {
t.Errorf("got token %s, want %s", gotToken, wantToken)
t.Errorf("token: got %s, want %s", gotToken, wantToken)
}
}
// TestFallbackToSameuserproof verifies that we fallback to the
// sameuserproof file via LocalTCPPortAndToken when we're running
//
// s cmd/tailscale
func TestFallbackToSameuserproof(t *testing.T) {
dir := t.TempDir()
const (
wantToken = "token"
wantPort = 123
)
// Mimics cmd/tailscale falling back to sameuserproof
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return false })
tstest.Replace(t, &ssd.sharedDir, dir)
tstest.Replace(t, &ssd.checkConn, false)
// Behave as macSysExt when initializing sameuserproof
tstest.Replace(t, &ssd.isMacSysExt, func() bool { return true })
if err := initSameUserProofToken(dir, wantPort, wantToken); err != nil {
t.Fatalf("initSameUserProofToken: %v", err)
}
gotPort, gotToken, err := LocalTCPPortAndToken()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPort != wantPort {
t.Errorf("port: got %d, want %d", gotPort, wantPort)
}
if gotToken != wantToken {
t.Errorf("token: got %s, want %s", gotToken, wantToken)
}
}
@ -38,7 +77,7 @@ func TestSetCredentials(t *testing.T) {
// returns a listener and a non-zero port and non-empty token.
func TestInitListenerDarwin(t *testing.T) {
temp := t.TempDir()
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return true })
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return false })
ln, err := InitListenerDarwin(temp)
if err != nil || ln == nil {
@ -52,15 +91,14 @@ func TestInitListenerDarwin(t *testing.T) {
}
if port == 0 {
t.Errorf("expected non-zero port, got %d", port)
t.Errorf("port: got %d, want non-zero", port)
}
if token == "" {
t.Errorf("expected non-empty token, got empty string")
t.Errorf("token: got %s, want non-empty", token)
}
}
// TestTokenGeneration verifies token generation behavior
func TestTokenGeneration(t *testing.T) {
token, err := getToken()
if err != nil {
@ -70,7 +108,7 @@ func TestTokenGeneration(t *testing.T) {
// Verify token length (hex string is 2x byte length)
wantLen := sameUserProofTokenLength * 2
if got := len(token); got != wantLen {
t.Errorf("token length = %d, want %d", got, wantLen)
t.Errorf("token length: got %d, want %d", got, wantLen)
}
// Verify token persistence
@ -79,7 +117,7 @@ func TestTokenGeneration(t *testing.T) {
t.Fatalf("subsequent getToken: %v", err)
}
if subsequentToken != token {
t.Errorf("subsequent token = %q, want %q", subsequentToken, token)
t.Errorf("subsequent token: got %q, want %q", subsequentToken, token)
}
}
@ -107,10 +145,10 @@ func TestMacsysSameuserproof(t *testing.T) {
}
if gotPort != wantPort {
t.Errorf("got port = %d, want %d", gotPort, wantPort)
t.Errorf("port: got %d, want %d", gotPort, wantPort)
}
if wantToken != gotToken {
t.Errorf("got token = %s, want %s", wantToken, gotToken)
t.Errorf("token: got %s, want %s", wantToken, gotToken)
}
assertFileCount(t, dir, 1, "sameuserproof-")
}
@ -138,7 +176,7 @@ func assertFileCount(t *testing.T, dir string, want int, prefix string) {
files, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf("[unexpected] error: %v", err)
}
count := 0
for _, file := range files {
@ -147,6 +185,6 @@ func assertFileCount(t *testing.T, dir string, want int, prefix string) {
}
}
if count != want {
t.Errorf("expected 1 file, got %d", count)
t.Errorf("files: got %d, want 1", count)
}
}

View File

@ -62,26 +62,21 @@ func IsSandboxedMacOS() bool {
// Tailscale for macOS, either the main GUI process (non-sandboxed) or the
// system extension (sandboxed).
func IsMacSys() bool {
return IsMacSysExt() || IsMacSysApp()
return IsMacSysExt() || IsMacSysGUI()
}
var isMacSysApp lazy.SyncValue[bool]
// IsMacSysApp reports whether this process is the main, non-sandboxed GUI process
// IsMacSysGUI reports whether this process is the main, non-sandboxed GUI process
// that ships with the Standalone variant of Tailscale for macOS.
func IsMacSysApp() bool {
func IsMacSysGUI() bool {
if runtime.GOOS != "darwin" {
return false
}
return isMacSysApp.Get(func() bool {
exe, err := os.Executable()
if err != nil {
return false
}
// Check that this is the GUI binary, and it is not sandboxed. The GUI binary
// shipped in the App Store will always have the App Sandbox enabled.
return strings.HasSuffix(exe, "/Contents/MacOS/Tailscale") && !IsMacAppStore()
return strings.Contains(os.Getenv("HOME"), "/Containers/io.tailscale.ipn.macsys/") ||
strings.Contains(os.Getenv("XPC_SERVICE_NAME"), "io.tailscale.ipn.macsys")
})
}
@ -95,10 +90,6 @@ func IsMacSysExt() bool {
return false
}
return isMacSysExt.Get(func() bool {
if strings.Contains(os.Getenv("HOME"), "/Containers/io.tailscale.ipn.macsys/") ||
strings.Contains(os.Getenv("XPC_SERVICE_NAME"), "io.tailscale.ipn.macsys") {
return true
}
exe, err := os.Executable()
if err != nil {
return false
@ -109,8 +100,8 @@ func IsMacSysExt() bool {
var isMacAppStore lazy.SyncValue[bool]
// IsMacAppStore whether this binary is from the App Store version of Tailscale
// for macOS.
// IsMacAppStore returns whether this binary is from the App Store version of Tailscale
// for macOS. Returns true for both the network extension and the GUI app.
func IsMacAppStore() bool {
if runtime.GOOS != "darwin" {
return false
@ -124,6 +115,25 @@ func IsMacAppStore() bool {
})
}
var isMacAppStoreGUI lazy.SyncValue[bool]
// IsMacAppStoreGUI reports whether this binary is the GUI app from the App Store
// version of Tailscale for macOS.
func IsMacAppStoreGUI() bool {
if runtime.GOOS != "darwin" {
return false
}
return isMacAppStoreGUI.Get(func() bool {
exe, err := os.Executable()
if err != nil {
return false
}
// Check that this is the GUI binary, and it is not sandboxed. The GUI binary
// shipped in the App Store will always have the App Sandbox enabled.
return strings.Contains(exe, "/Tailscale") && !IsMacSysGUI()
})
}
var isAppleTV lazy.SyncValue[bool]
// IsAppleTV reports whether this binary is part of the Tailscale network extension for tvOS.