diff --git a/safesocket/safesocket_darwin.go b/safesocket/safesocket_darwin.go index fb35ad9df..e2b3ea458 100644 --- a/safesocket/safesocket_darwin.go +++ b/safesocket/safesocket_darwin.go @@ -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 } diff --git a/safesocket/safesocket_darwin_test.go b/safesocket/safesocket_darwin_test.go index 2793d6aa3..e52959ad5 100644 --- a/safesocket/safesocket_darwin_test.go +++ b/safesocket/safesocket_darwin_test.go @@ -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) } } diff --git a/version/prop.go b/version/prop.go index 6026d1179..9327e6fe6 100644 --- a/version/prop.go +++ b/version/prop.go @@ -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.