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>
pull/14931/head
Jonathan Nobels 10 months ago committed by GitHub
parent 05ac21ebe4
commit 1bf4c6481a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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 // Unix-like platforms and specifies the ID of a local user
// (in the os/user.User.Uid string form) who is allowed // (in the os/user.User.Uid string form) who is allowed
// to operate tailscaled without being root or using sudo. // 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) { func (a *actor) Permissions(operatorUID string) (read, write bool) {
switch envknob.GOOS() { switch envknob.GOOS() {
case "windows": case "windows":

@ -6,8 +6,11 @@ package safesocket
import ( import (
"bufio" "bufio"
"bytes" "bytes"
crand "crypto/rand"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log"
"net" "net"
"os" "os"
"os/exec" "os/exec"
@ -17,6 +20,7 @@ import (
"sync" "sync"
"time" "time"
"golang.org/x/sys/unix"
"tailscale.com/version" "tailscale.com/version"
) )
@ -24,96 +28,278 @@ func init() {
localTCPPortAndToken = localTCPPortAndTokenDarwin localTCPPortAndToken = localTCPPortAndTokenDarwin
} }
// localTCPPortAndTokenMacsys returns the localhost TCP port number and auth token const sameUserProofTokenLength = 10
// from /Library/Tailscale.
// type safesocketDarwin struct {
// In that case the files are: 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.
// //
// /Library/Tailscale/ipnport => $port (symlink with localhost port number target) // The macOS and macsys binaries can communicate directly via XPC with
// /Library/Tailscale/sameuserproof-$port is a file with auth // the NEPacketTunnelProvider managed tailscaled process and are responsible for
func localTCPPortAndTokenMacsys() (port int, token string, err error) { // 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
}
const dir = "/Library/Tailscale" // Credentials were not explicitly, this is likely a standalone CLI binary.
portStr, err := os.Readlink(filepath.Join(dir, "ipnport")) // 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 { if err != nil {
return 0, "", err log.Printf("InitListenerDarwin: listener initialization failed")
return nil, err
} }
port, err = strconv.Atoi(portStr)
port, err := localhostTCPPort()
if err != nil { if err != nil {
return 0, "", err log.Printf("localhostTCPPort: listener initialization failed")
return nil, err
} }
authb, err := os.ReadFile(filepath.Join(dir, "sameuserproof-"+portStr))
token, err := getToken()
if err != nil { if err != nil {
return 0, "", err log.Printf("localhostTCPPort: getToken failed")
return nil, err
} }
auth := strings.TrimSpace(string(authb))
if auth == "" { if port == 0 || token == "" {
return 0, "", errors.New("empty auth token in sameuserproof file") log.Printf("localhostTCPPort: Invalid token or port")
return nil, fmt.Errorf("invalid localhostTCPPort: returned 0")
} }
// The above files exist forever after the first run of ssd.sharedDir = sharedDir
// /Applications/Tailscale.app, so check we can connect to avoid returning a ssd.token = token
// port nothing is listening on. Connect to "127.0.0.1" rather than ssd.port = port
// "localhost" due to #7851.
conn, err := net.DialTimeout("tcp", "127.0.0.1:"+portStr, time.Second) // Write the port and token to a sameuserproof file
err = initSameUserProofToken(sharedDir, port, token)
if err != nil { if err != nil {
return 0, "", err // Not fatal
log.Printf("initSameUserProofToken: failed: %v", err)
} }
conn.Close()
return port, auth, nil return ln, nil
} }
var warnAboutRootOnce sync.Once var onceListener struct {
once sync.Once
ln *net.Listener
}
func localTCPPortAndTokenDarwin() (port int, token string, err error) { func localhostTCPPort() (int, error) {
// There are two ways this binary can be run: as the Mac App Store sandboxed binary, if onceListener.ln == nil {
// or a normal binary that somebody built or download and are being run from outside return 0, fmt.Errorf("listener not initialized")
// the sandbox. Detect which way we're running and then figure out how to connect }
// to the local daemon.
ln, err := localhostListener()
if dir := os.Getenv("TS_MACOS_CLI_SHARED_DIR"); dir != "" { if err != nil {
// First see if we're running as the non-AppStore "macsys" variant. return 0, err
if version.IsMacSys() { }
if port, token, err := localTCPPortAndTokenMacsys(); err == nil {
return port, token, nil 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)
} }
} }
}
// The current binary (this process) is sandboxed. The user is var baseFile string
// running the CLI via /Applications/Tailscale.app/Contents/MacOS/Tailscale var perm fs.FileMode
// which sets the TS_MACOS_CLI_SHARED_DIR environment variable. if ssd.isMacSysExt() {
fis, err := os.ReadDir(dir) perm = 0640 // allow wheel to read
baseFile = fmt.Sprintf("sameuserproof-%d", port)
portFile := filepath.Join(sharedDir, "ipnport")
err := os.Remove(portFile)
if err != nil { if err != nil {
return 0, "", err log.Printf("failed to remove portfile %s: %v", portFile, err)
} }
for _, fi := range fis { symlinkErr := os.Symlink(fmt.Sprint(port), portFile)
name := filepath.Base(fi.Name()) if symlinkErr != nil {
// Look for name like "sameuserproof-61577-2ae2ec9e0aa2005784f1" log.Printf("failed to symlink portfile: %v", symlinkErr)
// 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 { } else {
// Log a warning as the clue to the user, in case the error perm = 0666
// message is swallowed. Only do this once since we may retry baseFile = fmt.Sprintf("sameuserproof-%d-%s", port, token)
// 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") 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 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
}
port, err = strconv.Atoi(portStr)
if err != nil {
return 0, "", err
}
authb, err := os.ReadFile(filepath.Join(ssd.sharedDir, "sameuserproof-"+portStr))
if err != nil {
return 0, "", err
}
auth := strings.TrimSpace(string(authb))
if auth == "" {
return 0, "", errors.New("empty auth token in sameuserproof file")
}
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
} }
return 0, "", fmt.Errorf("failed to find sandboxed sameuserproof-* file in TS_MACOS_CLI_SHARED_DIR %q", dir) conn.Close()
} }
// The current process is running outside the sandbox, so use return port, auth, nil
// 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", cmd := exec.Command("lsof",
"-n", // numeric sockets; don't do DNS lookups, etc "-n", // numeric sockets; don't do DNS lookups, etc
"-a", // logical AND remaining options "-a", // logical AND remaining options
@ -122,39 +308,40 @@ func localTCPPortAndTokenDarwin() (port int, token string, err error) {
"-F", // machine-readable output "-F", // machine-readable output
) )
out, err := cmd.Output() out, err := cmd.Output()
if err != nil {
// Before returning an error, see if we're running the if err == nil {
// macsys variant at the normal location. bs := bufio.NewScanner(bytes.NewReader(out))
if port, token, err := localTCPPortAndTokenMacsys(); err == nil { subStr := []byte(".tailscale.ipn.macos/sameuserproof-")
for bs.Scan() {
line := bs.Bytes()
i := bytes.Index(line, subStr)
if i == -1 {
continue
}
f := strings.SplitN(string(line[i+len(subStr):]), "-", 2)
if len(f) != 2 {
continue
}
portStr, token := f[0], f[1]
port, err := strconv.Atoi(portStr)
if err != nil {
return 0, "", fmt.Errorf("invalid port %q found in lsof", portStr)
}
return port, token, nil return port, token, nil
} }
return 0, "", fmt.Errorf("failed to run '%s' looking for IPNExtension: %w", cmd, err)
} }
bs := bufio.NewScanner(bytes.NewReader(out)) return 0, "", ErrTokenNotFound
subStr := []byte(".tailscale.ipn.macos/sameuserproof-") }
for bs.Scan() {
line := bs.Bytes() func portAndTokenFromSameUserProof() (port int, token string, err error) {
i := bytes.Index(line, subStr) if port, token, err := readMacosSameUserProof(); err == nil {
if i == -1 {
continue
}
f := strings.SplitN(string(line[i+len(subStr):]), "-", 2)
if len(f) != 2 {
continue
}
portStr, token := f[0], f[1]
port, err := strconv.Atoi(portStr)
if err != nil {
return 0, "", fmt.Errorf("invalid port %q found in lsof", portStr)
}
return port, token, nil return port, token, nil
} }
// Before returning an error, see if we're running the if port, token, err := readMacsysSameUserProof(); err == nil {
// macsys variant at the normal location.
if port, token, err := localTCPPortAndTokenMacsys(); err == nil {
return port, token, nil return port, token, nil
} }
return 0, "", ErrTokenNotFound
return 0, "", err
} }

@ -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)
}
}
Loading…
Cancel
Save