tailscale/safesocket/safesocket_darwin.go
Jonathan Nobels 90273a7f70
safesocket: return an error for LocalTCPPortAndToken for tailscaled (#15144)
fixes tailscale/corp#26806

Fixes a regression where LocalTCPPortAndToken needs to error out early
if we're not running as sandboxed macos so that we attempt to connect
using the normal unix machinery.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2025-02-27 18:55:46 -05:00

354 lines
9.8 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package safesocket
import (
"bufio"
"bytes"
crand "crypto/rand"
"errors"
"fmt"
"io/fs"
"log"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sys/unix"
"tailscale.com/version"
)
func init() {
localTCPPortAndToken = localTCPPortAndTokenDarwin
}
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
isSandboxedMacos func() bool // For testing only to force macOS sandbox
}
var ssd = safesocketDarwin{
isMacSysExt: version.IsMacSysExt,
isSandboxedMacos: version.IsSandboxedMacOS,
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.isSandboxedMacos() {
return 0, "", ErrNoTokenOnOS
}
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 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
}
conn.Close()
}
return port, auth, nil
}
// 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
fmt.Sprintf("-u%d", os.Getuid()), // process of same user only
"-c", "IPNExtension", // starting with IPNExtension
"-F", // machine-readable output
)
out, err := cmd.Output()
if err == nil {
bs := bufio.NewScanner(bytes.NewReader(out))
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 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
}