// Copyright (c) Tailscale Inc & contributors
// 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        // 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.IsMacAppStoreGUI() || version.IsMacSysGUI() },
	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 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()

	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
	}
}

// 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 multiple times
// has no effect.  The listener and its 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) {
	// 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
	}

	if port, token, err := readMacsysSameUserProof(); err == nil {
		return port, token, nil
	}

	return 0, "", ErrTokenNotFound
}
