safesocket: add ability for Darwin clients to set explicit credentials (#14702)

updates tailscale/corp#25687

The darwin appstore and standalone clients now support XPC and the keychain for passing user credentials securely between the gui process and an NEVPNExtension hosted tailscaled. Clients that can communicate directly with the network extension, via XPC or the keychain, are now expected to call SetCredentials and supply credentials explicitly, fixing issues with the cli breaking if the current user cannot read the contents of /Library/Tailscale due to group membership restrictions. This matches how those clients source and supply credentials to the localAPI http client.

Non-platform-specific code that has traditionally been in the client is moved to safesocket.

/Libraray/Tailscaled/sameuserproof has its permissions changed to that it's readably only by users in the admin group. This restricts standalone CLI access for and direct use of localAPI to admins.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
This commit is contained in:
Jonathan Nobels 2025-02-06 09:51:00 -05:00 committed by GitHub
parent 05ac21ebe4
commit 1bf4c6481a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 440 additions and 97 deletions

View File

@ -318,6 +318,13 @@ func (s *Server) blockWhileIdentityInUse(ctx context.Context, actor ipnauth.Acto
// Unix-like platforms and specifies the ID of a local user
// (in the os/user.User.Uid string form) who is allowed
// to operate tailscaled without being root or using sudo.
//
// Sandboxed macos clients must directly supply, or be able to read,
// an explicit token. Permission is inferred by validating that
// token. Sandboxed macos clients also don't use ipnserver.actor at all
// (and prior to that, they didn't use ipnauth.ConnIdentity)
//
// See safesocket and safesocket_darwin.
func (a *actor) Permissions(operatorUID string) (read, write bool) {
switch envknob.GOOS() {
case "windows":

View File

@ -6,8 +6,11 @@ package safesocket
import (
"bufio"
"bytes"
crand "crypto/rand"
"errors"
"fmt"
"io/fs"
"log"
"net"
"os"
"os/exec"
@ -17,6 +20,7 @@ import (
"sync"
"time"
"golang.org/x/sys/unix"
"tailscale.com/version"
)
@ -24,17 +28,245 @@ func init() {
localTCPPortAndToken = localTCPPortAndTokenDarwin
}
// localTCPPortAndTokenMacsys returns the localhost TCP port number and auth token
// from /Library/Tailscale.
const sameUserProofTokenLength = 10
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
checkConn bool // Check macsys safesocket port before returning it
isMacSysExt func() bool // For testing only to force macsys
}
var ssd = safesocketDarwin{
isMacSysExt: version.IsMacSysExt,
checkConn: true,
sharedDir: "/Library/Tailscale",
}
// There are three ways a Darwin binary can be run: as the Mac App Store (macOS)
// standalone notarized (macsys), or a separate CLI (tailscale) that was
// built or downloaded.
//
// The macOS and macsys binaries can communicate directly via XPC with
// the NEPacketTunnelProvider managed tailscaled process and are responsible for
// calling SetCredentials when they need to operate as a CLI.
// A built/downloaded CLI binary will not be managing the NEPacketTunnelProvider
// hosting tailscaled directly and must source the credentials from a 'sameuserproof' file.
// This file is written to sharedDir when tailscaled/NEPacketTunnelProvider
// calls InitListenerDarwin.
// localTCPPortAndTokenDarwin returns the localhost TCP port number and auth token
// either generated, or sourced from the NEPacketTunnelProvider managed tailscaled process.
func localTCPPortAndTokenDarwin() (port int, token string, err error) {
ssd.mu.Lock()
defer ssd.mu.Unlock()
if ssd.port != 0 && ssd.token != "" {
return ssd.port, ssd.token, nil
}
// 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
// by the NEPacketTunnelProvider tailscaled process. This is only used when running
// the CLI via Tailscale.app.
func SetCredentials(token string, port int) {
ssd.mu.Lock()
defer ssd.mu.Unlock()
if ssd.token != "" || ssd.port != 0 {
// Not fatal, but likely programmer error. Credentials do not change.
log.Printf("warning: SetCredentials credentials already set")
}
ssd.token = token
ssd.port = port
}
// InitListenerDarwin initializes the listener for the CLI commands
// and localapi HTTP server and sets the port/token. This will override
// any credentials set explicitly via SetCredentials(). Calling this mulitple times
// has no effect. The listener and it's corresponding token/port is initialized only once.
func InitListenerDarwin(sharedDir string) (*net.Listener, error) {
ssd.mu.Lock()
defer ssd.mu.Unlock()
ln := onceListener.ln
if ln != nil {
return ln, nil
}
var err error
ln, err = localhostListener()
if err != nil {
log.Printf("InitListenerDarwin: listener initialization failed")
return nil, err
}
port, err := localhostTCPPort()
if err != nil {
log.Printf("localhostTCPPort: listener initialization failed")
return nil, err
}
token, err := getToken()
if err != nil {
log.Printf("localhostTCPPort: getToken failed")
return nil, err
}
if port == 0 || token == "" {
log.Printf("localhostTCPPort: Invalid token or port")
return nil, fmt.Errorf("invalid localhostTCPPort: returned 0")
}
ssd.sharedDir = sharedDir
ssd.token = token
ssd.port = port
// Write the port and token to a sameuserproof file
err = initSameUserProofToken(sharedDir, port, token)
if err != nil {
// Not fatal
log.Printf("initSameUserProofToken: failed: %v", err)
}
return ln, nil
}
var onceListener struct {
once sync.Once
ln *net.Listener
}
func localhostTCPPort() (int, error) {
if onceListener.ln == nil {
return 0, fmt.Errorf("listener not initialized")
}
ln, err := localhostListener()
if err != nil {
return 0, err
}
return (*ln).Addr().(*net.TCPAddr).Port, nil
}
func localhostListener() (*net.Listener, error) {
onceListener.once.Do(func() {
ln, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return
}
onceListener.ln = &ln
})
if onceListener.ln == nil {
return nil, fmt.Errorf("failed to get TCP listener")
}
return onceListener.ln, nil
}
var onceToken struct {
once sync.Once
token string
}
func getToken() (string, error) {
onceToken.once.Do(func() {
buf := make([]byte, sameUserProofTokenLength)
if _, err := crand.Read(buf); err != nil {
return
}
t := fmt.Sprintf("%x", buf)
onceToken.token = t
})
if onceToken.token == "" {
return "", fmt.Errorf("failed to generate token")
}
return onceToken.token, nil
}
// initSameUserProofToken writes the port and token to a sameuserproof
// file owned by the current user. We leave the file open to allow us
// to discover it via lsof.
//
// "sameuserproof" is intended to convey that the user attempting to read
// the credentials from the file is the same user that wrote them. For
// standalone macsys where tailscaled is running as root, we set group
// permissions to allow users in the admin group to read the file.
func initSameUserProofToken(sharedDir string, port int, token string) error {
var err error
// Guard against bad sharedDir
old, err := os.ReadDir(sharedDir)
if err == os.ErrNotExist {
log.Printf("failed to read shared dir %s: %v", sharedDir, err)
return err
}
// Remove all old sameuserproof files
for _, fi := range old {
if name := fi.Name(); strings.HasPrefix(name, "sameuserproof-") {
err := os.Remove(filepath.Join(sharedDir, name))
if err != nil {
log.Printf("failed to remove %s: %v", name, err)
}
}
}
var baseFile string
var perm fs.FileMode
if ssd.isMacSysExt() {
perm = 0640 // allow wheel to read
baseFile = fmt.Sprintf("sameuserproof-%d", port)
portFile := filepath.Join(sharedDir, "ipnport")
err := os.Remove(portFile)
if err != nil {
log.Printf("failed to remove portfile %s: %v", portFile, err)
}
symlinkErr := os.Symlink(fmt.Sprint(port), portFile)
if symlinkErr != nil {
log.Printf("failed to symlink portfile: %v", symlinkErr)
}
} else {
perm = 0666
baseFile = fmt.Sprintf("sameuserproof-%d-%s", port, token)
}
path := filepath.Join(sharedDir, baseFile)
ssd.sameuserproofFD, err = os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm)
log.Printf("initSameUserProofToken : done=%v", err == nil)
if ssd.isMacSysExt() && err == nil {
fmt.Fprintf(ssd.sameuserproofFD, "%s\n", token)
// Macsys runs as root so ownership of this file will be
// root/wheel. Change ownership to root/admin which will let all members
// of the admin group to read it.
unix.Fchown(int(ssd.sameuserproofFD.Fd()), 0, 80 /* admin */)
}
return err
}
// readMacsysSameuserproof returns the localhost TCP port number and auth token
// from a sameuserproof file written to /Library/Tailscale.
//
// In that case the files are:
//
// /Library/Tailscale/ipnport => $port (symlink with localhost port number target)
// /Library/Tailscale/sameuserproof-$port is a file with auth
func localTCPPortAndTokenMacsys() (port int, token string, err error) {
const dir = "/Library/Tailscale"
portStr, err := os.Readlink(filepath.Join(dir, "ipnport"))
// /Library/Tailscale/sameuserproof-$port is a file containing only the auth token as a hex string.
func readMacsysSameUserProof() (port int, token string, err error) {
portStr, err := os.Readlink(filepath.Join(ssd.sharedDir, "ipnport"))
if err != nil {
return 0, "", err
}
@ -42,7 +274,7 @@ func localTCPPortAndTokenMacsys() (port int, token string, err error) {
if err != nil {
return 0, "", err
}
authb, err := os.ReadFile(filepath.Join(dir, "sameuserproof-"+portStr))
authb, err := os.ReadFile(filepath.Join(ssd.sharedDir, "sameuserproof-"+portStr))
if err != nil {
return 0, "", err
}
@ -51,69 +283,23 @@ func localTCPPortAndTokenMacsys() (port int, token string, err error) {
return 0, "", errors.New("empty auth token in sameuserproof file")
}
// The above files exist forever after the first run of
// /Applications/Tailscale.app, so check we can connect to avoid returning a
// port nothing is listening on. Connect to "127.0.0.1" rather than
// "localhost" due to #7851.
if ssd.checkConn {
// Files may be stale and there is no guarantee that the sameuserproof
// derived port is open and valid. Check it before returning it.
conn, err := net.DialTimeout("tcp", "127.0.0.1:"+portStr, time.Second)
if err != nil {
return 0, "", err
}
conn.Close()
}
return port, auth, nil
}
var warnAboutRootOnce sync.Once
func localTCPPortAndTokenDarwin() (port int, token string, err error) {
// There are two ways this binary can be run: as the Mac App Store sandboxed binary,
// or a normal binary that somebody built or download and are being run from outside
// the sandbox. Detect which way we're running and then figure out how to connect
// to the local daemon.
if dir := os.Getenv("TS_MACOS_CLI_SHARED_DIR"); dir != "" {
// First see if we're running as the non-AppStore "macsys" variant.
if version.IsMacSys() {
if port, token, err := localTCPPortAndTokenMacsys(); err == nil {
return port, token, nil
}
}
// The current binary (this process) is sandboxed. The user is
// running the CLI via /Applications/Tailscale.app/Contents/MacOS/Tailscale
// which sets the TS_MACOS_CLI_SHARED_DIR environment variable.
fis, err := os.ReadDir(dir)
if err != nil {
return 0, "", err
}
for _, fi := range fis {
name := filepath.Base(fi.Name())
// Look for name like "sameuserproof-61577-2ae2ec9e0aa2005784f1"
// to extract out the port number and token.
if strings.HasPrefix(name, "sameuserproof-") {
f := strings.SplitN(name, "-", 3)
if len(f) == 3 {
if port, err := strconv.Atoi(f[1]); err == nil {
return port, f[2], nil
}
}
}
}
if os.Geteuid() == 0 {
// Log a warning as the clue to the user, in case the error
// message is swallowed. Only do this once since we may retry
// multiple times to connect, and don't want to spam.
warnAboutRootOnce.Do(func() {
fmt.Fprintf(os.Stderr, "Warning: The CLI is running as root from within a sandboxed binary. It cannot reach the local tailscaled, please try again as a regular user.\n")
})
}
return 0, "", fmt.Errorf("failed to find sandboxed sameuserproof-* file in TS_MACOS_CLI_SHARED_DIR %q", dir)
}
// The current process is running outside the sandbox, so use
// lsof to find the IPNExtension (the Mac App Store variant).
// readMacosSameUserProof searches for open sameuserproof files belonging
// to the current user and the IPNExtension (macOS App Store) process and returns a
// port and token.
func readMacosSameUserProof() (port int, token string, err error) {
cmd := exec.Command("lsof",
"-n", // numeric sockets; don't do DNS lookups, etc
"-a", // logical AND remaining options
@ -122,15 +308,8 @@ func localTCPPortAndTokenDarwin() (port int, token string, err error) {
"-F", // machine-readable output
)
out, err := cmd.Output()
if err != nil {
// Before returning an error, see if we're running the
// macsys variant at the normal location.
if port, token, err := localTCPPortAndTokenMacsys(); err == nil {
return port, token, nil
}
return 0, "", fmt.Errorf("failed to run '%s' looking for IPNExtension: %w", cmd, err)
}
if err == nil {
bs := bufio.NewScanner(bytes.NewReader(out))
subStr := []byte(".tailscale.ipn.macos/sameuserproof-")
for bs.Scan() {
@ -148,13 +327,21 @@ func localTCPPortAndTokenDarwin() (port int, token string, err error) {
if err != nil {
return 0, "", fmt.Errorf("invalid port %q found in lsof", portStr)
}
return port, token, nil
}
// Before returning an error, see if we're running the
// macsys variant at the normal location.
if port, token, err := localTCPPortAndTokenMacsys(); err == nil {
return port, token, nil
}
return 0, "", ErrTokenNotFound
}
func portAndTokenFromSameUserProof() (port int, token string, err error) {
if port, token, err := readMacosSameUserProof(); err == nil {
return port, token, nil
}
if port, token, err := readMacsysSameUserProof(); err == nil {
return port, token, nil
}
return 0, "", err
}

View File

@ -0,0 +1,149 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package safesocket
import (
"os"
"strings"
"testing"
"tailscale.com/tstest"
)
// TestSetCredentials verifies that calling SetCredentials
// sets the port and token correctly and that LocalTCPPortAndToken
// returns the given values.
func TestSetCredentials(t *testing.T) {
wantPort := 123
wantToken := "token"
SetCredentials(wantToken, wantPort)
gotPort, gotToken, err := LocalTCPPortAndToken()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPort != wantPort {
t.Errorf("got port %d, want %d", gotPort, wantPort)
}
if gotToken != wantToken {
t.Errorf("got token %s, want %s", gotToken, wantToken)
}
}
// TestInitListenerDarwin verifies that InitListenerDarwin
// returns a listener and a non-zero port and non-empty token.
func TestInitListenerDarwin(t *testing.T) {
temp := t.TempDir()
ln, err := InitListenerDarwin(temp)
if err != nil || ln == nil {
t.Fatalf("InitListenerDarwin failed: %v", err)
}
defer (*ln).Close()
port, token, err := LocalTCPPortAndToken()
if err != nil {
t.Fatalf("LocalTCPPortAndToken failed: %v", err)
}
if port == 0 {
t.Errorf("expected non-zero port, got %d", port)
}
if token == "" {
t.Errorf("expected non-empty token, got empty string")
}
}
// TestTokenGeneration verifies token generation behavior
func TestTokenGeneration(t *testing.T) {
token, err := getToken()
if err != nil {
t.Fatalf("getToken: %v", err)
}
// 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)
}
// Verify token persistence
subsequentToken, err := getToken()
if err != nil {
t.Fatalf("subsequent getToken: %v", err)
}
if subsequentToken != token {
t.Errorf("subsequent token = %q, want %q", subsequentToken, token)
}
}
// TestSameUserProofToken verifies that the sameuserproof file
// is created and read correctly for the macsys variant
func TestMacsysSameuserproof(t *testing.T) {
dir := t.TempDir()
tstest.Replace(t, &ssd.isMacSysExt, func() bool { return true })
tstest.Replace(t, &ssd.checkConn, false)
tstest.Replace(t, &ssd.sharedDir, dir)
const (
wantToken = "token"
wantPort = 123
)
if err := initSameUserProofToken(dir, wantPort, wantToken); err != nil {
t.Fatalf("initSameUserProofToken: %v", err)
}
gotPort, gotToken, err := readMacsysSameUserProof()
if err != nil {
t.Fatalf("readMacOSSameUserProof: %v", err)
}
if gotPort != wantPort {
t.Errorf("got port = %d, want %d", gotPort, wantPort)
}
if wantToken != gotToken {
t.Errorf("got token = %s, want %s", wantToken, gotToken)
}
assertFileCount(t, dir, 1, "sameuserproof-")
}
// TestMacosSameuserproof verifies that the sameuserproof file
// is created correctly for the macos variant
func TestMacosSameuserproof(t *testing.T) {
dir := t.TempDir()
wantToken := "token"
wantPort := 123
initSameUserProofToken(dir, wantPort, wantToken)
// initSameUserProofToken should never leave duplicates
initSameUserProofToken(dir, wantPort, wantToken)
// we can't just call readMacosSameUserProof because it relies on lsof
// and makes some assumptions about the user. But we can make sure
// the file exists
assertFileCount(t, dir, 1, "sameuserproof-")
}
func assertFileCount(t *testing.T, dir string, want int, prefix string) {
t.Helper()
files, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
count := 0
for _, file := range files {
if strings.HasPrefix(file.Name(), prefix) {
count += 1
}
}
if count != want {
t.Errorf("expected 1 file, got %d", count)
}
}