// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

// Package clientupdate implements tailscale client update for all supported
// platforms. This package can be used from both tailscaled and tailscale
// binaries.
package clientupdate

import (
	"archive/tar"
	"bufio"
	"bytes"
	"compress/gzip"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"maps"
	"net/http"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"regexp"
	"runtime"
	"strconv"
	"strings"

	"github.com/google/uuid"
	"tailscale.com/clientupdate/distsign"
	"tailscale.com/types/logger"
	"tailscale.com/util/winutil"
	"tailscale.com/version"
	"tailscale.com/version/distro"
)

const (
	CurrentTrack  = ""
	StableTrack   = "stable"
	UnstableTrack = "unstable"
)

func versionToTrack(v string) (string, error) {
	_, rest, ok := strings.Cut(v, ".")
	if !ok {
		return "", fmt.Errorf("malformed version %q", v)
	}
	minorStr, _, ok := strings.Cut(rest, ".")
	if !ok {
		return "", fmt.Errorf("malformed version %q", v)
	}
	minor, err := strconv.Atoi(minorStr)
	if err != nil {
		return "", fmt.Errorf("malformed version %q", v)
	}
	if minor%2 == 0 {
		return "stable", nil
	}
	return "unstable", nil
}

// Arguments contains arguments needed to run an update.
type Arguments struct {
	// Version can be a specific version number or one of the predefined track
	// constants:
	//
	//   - CurrentTrack will use the latest version from the same track as the
	//     running binary
	//   - StableTrack and UnstableTrack will use the latest versions of the
	//     corresponding tracks
	//
	// Leaving this empty is the same as using CurrentTrack.
	Version string
	// AppStore forces a local app store check, even if the current binary was
	// not installed via an app store. TODO(cpalmer): Remove this.
	AppStore bool
	// Logf is a logger for update progress messages.
	Logf logger.Logf
	// Confirm is called when a new version is available and should return true
	// if this new version should be installed. When Confirm returns false, the
	// update is aborted.
	Confirm func(newVer string) bool
	// PkgsAddr is the address of the pkgs server to fetch updates from.
	// Defaults to "https://pkgs.tailscale.com".
	PkgsAddr string
}

func (args Arguments) validate() error {
	if args.Confirm == nil {
		return errors.New("missing Confirm callback in Arguments")
	}
	if args.Logf == nil {
		return errors.New("missing Logf callback in Arguments")
	}
	return nil
}

type Updater struct {
	Arguments
	track string
	// Update is a platform-specific method that updates the installation. May be
	// nil (not all platforms support updates from within Tailscale).
	Update func() error
}

func NewUpdater(args Arguments) (*Updater, error) {
	up := Updater{
		Arguments: args,
	}
	up.Update = up.getUpdateFunction()
	if up.Update == nil {
		return nil, errors.ErrUnsupported
	}
	switch up.Version {
	case StableTrack, UnstableTrack:
		up.track = up.Version
	case CurrentTrack:
		if version.IsUnstableBuild() {
			up.track = UnstableTrack
		} else {
			up.track = StableTrack
		}
	default:
		var err error
		up.track, err = versionToTrack(args.Version)
		if err != nil {
			return nil, err
		}
	}
	if up.Arguments.PkgsAddr == "" {
		up.Arguments.PkgsAddr = "https://pkgs.tailscale.com"
	}
	return &up, nil
}

type updateFunction func() error

func (up *Updater) getUpdateFunction() updateFunction {
	switch runtime.GOOS {
	case "windows":
		return up.updateWindows
	case "linux":
		switch distro.Get() {
		case distro.Synology:
			return up.updateSynology
		case distro.Debian: // includes Ubuntu
			return up.updateDebLike
		case distro.Arch:
			return up.updateArchLike
		case distro.Alpine:
			return up.updateAlpineLike
		}
		switch {
		case haveExecutable("pacman"):
			return up.updateArchLike
		case haveExecutable("apt-get"): // TODO(awly): add support for "apt"
			// The distro.Debian switch case above should catch most apt-based
			// systems, but add this fallback just in case.
			return up.updateDebLike
		case haveExecutable("dnf"):
			return up.updateFedoraLike("dnf")
		case haveExecutable("yum"):
			return up.updateFedoraLike("yum")
		case haveExecutable("apk"):
			return up.updateAlpineLike
		}
		// If nothing matched, fall back to tarball updates.
		if up.Update == nil {
			return up.updateLinuxBinary
		}
	case "darwin":
		switch {
		case !up.Arguments.AppStore && !version.IsSandboxedMacOS():
			return nil
		case !up.Arguments.AppStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
			return up.updateMacSys
		default:
			return up.updateMacAppStore
		}
	case "freebsd":
		return up.updateFreeBSD
	}
	return nil
}

// Update runs a single update attempt using the platform-specific mechanism.
//
// On Windows, this copies the calling binary and re-executes it to apply the
// update. The calling binary should handle an "update" subcommand and call
// this function again for the re-executed binary to proceed.
func Update(args Arguments) error {
	if err := args.validate(); err != nil {
		return err
	}
	up, err := NewUpdater(args)
	if err != nil {
		return err
	}
	return up.Update()
}

func (up *Updater) confirm(ver string) bool {
	if version.Short() == ver {
		up.Logf("already running %v; no update needed", ver)
		return false
	}
	if up.Confirm != nil {
		return up.Confirm(ver)
	}
	return true
}

const synoinfoConfPath = "/etc/synoinfo.conf"

func (up *Updater) updateSynology() error {
	if up.Version != "" {
		return errors.New("installing a specific version on Synology is not supported")
	}

	// Get the latest version and list of SPKs from pkgs.tailscale.com.
	dsmVersion := distro.DSMVersion()
	osName := fmt.Sprintf("dsm%d", dsmVersion)
	arch, err := synoArch(runtime.GOARCH, synoinfoConfPath)
	if err != nil {
		return err
	}
	latest, err := latestPackages(up.track)
	if err != nil {
		return err
	}
	spkName := latest.SPKs[osName][arch]
	if spkName == "" {
		return fmt.Errorf("cannot find Synology package for os=%s arch=%s, please report a bug with your device model", osName, arch)
	}

	if !up.confirm(latest.SPKsVersion) {
		return nil
	}
	if err := requireRoot(); err != nil {
		return err
	}

	// Download the SPK into a temporary directory.
	spkDir, err := os.MkdirTemp("", "tailscale-update")
	if err != nil {
		return err
	}
	pkgsPath := fmt.Sprintf("%s/%s", up.track, spkName)
	spkPath := filepath.Join(spkDir, path.Base(pkgsPath))
	if err := up.downloadURLToFile(pkgsPath, spkPath); err != nil {
		return err
	}

	// Install the SPK. Run via nohup to allow install to succeed when we're
	// connected over tailscale ssh and this parent process dies. Otherwise, if
	// you abort synopkg install mid-way, tailscaled is not restarted.
	cmd := exec.Command("nohup", "synopkg", "install", spkPath)
	// Don't attach cmd.Stdout to os.Stdout because nohup will redirect that
	// into nohup.out file. synopkg doesn't have any progress output anyway, it
	// just spits out a JSON result when done.
	out, err := cmd.CombinedOutput()
	if err != nil {
		if dsmVersion == 6 && bytes.Contains(out, []byte("error = [290]")) {
			return fmt.Errorf("synopkg install failed: %w\noutput:\n%s\nplease make sure that packages from 'Any publisher' are allowed in the Package Center (Package Center -> Settings -> Trust Level -> Any publisher)", err, out)
		}
		return fmt.Errorf("synopkg install failed: %w\noutput:\n%s", err, out)
	}
	if dsmVersion == 6 {
		// DSM6 does not automatically restart the package on install. Do it
		// manually.
		cmd := exec.Command("nohup", "synopkg", "start", "Tailscale")
		out, err := cmd.CombinedOutput()
		if err != nil {
			return fmt.Errorf("synopkg start failed: %w\noutput:\n%s", err, out)
		}
	}
	return nil
}

// synoArch returns the Synology CPU architecture matching one of the SPK
// architectures served from pkgs.tailscale.com.
func synoArch(goArch, synoinfoPath string) (string, error) {
	// Most Synology boxes just use a different arch name from GOARCH.
	arch := map[string]string{
		"amd64": "x86_64",
		"386":   "i686",
		"arm64": "armv8",
	}[goArch]

	if arch == "" {
		// Here's the fun part, some older ARM boxes require you to use SPKs
		// specifically for their CPU. See
		// https://github.com/SynoCommunity/spksrc/wiki/Synology-and-SynoCommunity-Package-Architectures
		// for a complete list.
		//
		// Some CPUs will map to neither this list nor the goArch map above, and we
		// don't have SPKs for them.
		cpu, err := parseSynoinfo(synoinfoPath)
		if err != nil {
			return "", fmt.Errorf("failed to get CPU architecture: %w", err)
		}
		switch cpu {
		case "88f6281", "88f6282", "hi3535", "alpine", "armada370",
			"armada375", "armada38x", "armadaxp", "comcerto2k", "monaco":
			arch = cpu
		default:
			return "", fmt.Errorf("unsupported Synology CPU architecture %q (Go arch %q), please report a bug at https://github.com/tailscale/tailscale/issues/new/choose", cpu, goArch)
		}
	}
	return arch, nil
}

func parseSynoinfo(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()

	// Look for a line like:
	// unique="synology_88f6282_413j"
	// Extract the CPU in the middle (88f6282 in the above example).
	s := bufio.NewScanner(f)
	for s.Scan() {
		l := s.Text()
		if !strings.HasPrefix(l, "unique=") {
			continue
		}
		parts := strings.SplitN(l, "_", 3)
		if len(parts) != 3 {
			return "", fmt.Errorf(`malformed %q: found %q, expected format like 'unique="synology_$cpu_$model'`, path, l)
		}
		return parts[1], nil
	}
	return "", fmt.Errorf(`missing "unique=" field in %q`, path)
}

func (up *Updater) updateDebLike() error {
	if err := requireRoot(); err != nil {
		return err
	}
	if err := exec.Command("dpkg", "--status", "tailscale").Run(); err != nil && isExitError(err) {
		// Tailscale was not installed via apt, update via tarball download
		// instead.
		return up.updateLinuxBinary()
	}
	ver, err := requestedTailscaleVersion(up.Version, up.track)
	if err != nil {
		return err
	}
	if !up.confirm(ver) {
		return nil
	}

	if updated, err := updateDebianAptSourcesList(up.track); err != nil {
		return err
	} else if updated {
		up.Logf("Updated %s to use the %s track", aptSourcesFile, up.track)
	}

	cmd := exec.Command("apt-get", "update",
		// Only update the tailscale repo, not the other ones, treating
		// the tailscale.list file as the main "sources.list" file.
		"-o", "Dir::Etc::SourceList=sources.list.d/tailscale.list",
		// Disable the "sources.list.d" directory:
		"-o", "Dir::Etc::SourceParts=-",
		// Don't forget about packages in the other repos just because
		// we're not updating them:
		"-o", "APT::Get::List-Cleanup=0",
	)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return err
	}

	cmd = exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return err
	}

	return nil
}

const aptSourcesFile = "/etc/apt/sources.list.d/tailscale.list"

// updateDebianAptSourcesList updates the /etc/apt/sources.list.d/tailscale.list
// file to make sure it has the provided track (stable or unstable) in it.
//
// If it already has the right track (including containing both stable and
// unstable), it does nothing.
func updateDebianAptSourcesList(dstTrack string) (rewrote bool, err error) {
	was, err := os.ReadFile(aptSourcesFile)
	if err != nil {
		return false, err
	}
	newContent, err := updateDebianAptSourcesListBytes(was, dstTrack)
	if err != nil {
		return false, err
	}
	if bytes.Equal(was, newContent) {
		return false, nil
	}
	return true, os.WriteFile(aptSourcesFile, newContent, 0644)
}

func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent []byte, err error) {
	trackURLPrefix := []byte("https://pkgs.tailscale.com/" + dstTrack + "/")
	var buf bytes.Buffer
	var changes int
	bs := bufio.NewScanner(bytes.NewReader(was))
	hadCorrect := false
	commentLine := regexp.MustCompile(`^\s*\#`)
	pkgsURL := regexp.MustCompile(`\bhttps://pkgs\.tailscale\.com/((un)?stable)/`)
	for bs.Scan() {
		line := bs.Bytes()
		if !commentLine.Match(line) {
			line = pkgsURL.ReplaceAllFunc(line, func(m []byte) []byte {
				if bytes.Equal(m, trackURLPrefix) {
					hadCorrect = true
				} else {
					changes++
				}
				return trackURLPrefix
			})
		}
		buf.Write(line)
		buf.WriteByte('\n')
	}
	if hadCorrect || (changes == 1 && bytes.Equal(bytes.TrimSpace(was), bytes.TrimSpace(buf.Bytes()))) {
		// Unchanged or close enough.
		return was, nil
	}
	if changes != 1 {
		// No changes, or an unexpected number of changes (what?). Bail.
		// They probably editted it by hand and we don't know what to do.
		return nil, fmt.Errorf("unexpected/unsupported %s contents", aptSourcesFile)
	}
	return buf.Bytes(), nil
}

func (up *Updater) updateArchLike() error {
	if err := exec.Command("pacman", "--query", "tailscale").Run(); err != nil && isExitError(err) {
		// Tailscale was not installed via pacman, update via tarball download
		// instead.
		return up.updateLinuxBinary()
	}
	// Arch maintainer asked us not to implement "tailscale update" or
	// auto-updates on Arch-based distros:
	// https://github.com/tailscale/tailscale/issues/6995#issuecomment-1687080106
	return errors.New(`individual package updates are not supported on Arch-based distros, only full-system updates are: https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported.
you can use "pacman --sync --refresh --sysupgrade" or "pacman -Syu" to upgrade the system, including Tailscale.`)
}

const yumRepoConfigFile = "/etc/yum.repos.d/tailscale.repo"

// updateFedoraLike updates tailscale on any distros in the Fedora family,
// specifically anything that uses "dnf" or "yum" package managers. The actual
// package manager is passed via packageManager.
func (up *Updater) updateFedoraLike(packageManager string) func() error {
	return func() (err error) {
		if err := requireRoot(); err != nil {
			return err
		}
		if err := exec.Command(packageManager, "info", "--installed", "tailscale").Run(); err != nil && isExitError(err) {
			// Tailscale was not installed via yum/dnf, update via tarball
			// download instead.
			return up.updateLinuxBinary()
		}
		defer func() {
			if err != nil {
				err = fmt.Errorf(`%w; you can try updating using "%s upgrade tailscale"`, err, packageManager)
			}
		}()

		ver, err := requestedTailscaleVersion(up.Version, up.track)
		if err != nil {
			return err
		}
		if !up.confirm(ver) {
			return nil
		}

		if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.track); err != nil {
			return err
		} else if updated {
			up.Logf("Updated %s to use the %s track", yumRepoConfigFile, up.track)
		}

		cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver))
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		if err := cmd.Run(); err != nil {
			return err
		}
		return nil
	}
}

// updateYUMRepoTrack updates the repoFile file to make sure it has the
// provided track (stable or unstable) in it.
func updateYUMRepoTrack(repoFile, dstTrack string) (rewrote bool, err error) {
	was, err := os.ReadFile(repoFile)
	if err != nil {
		return false, err
	}

	urlRe := regexp.MustCompile(`^(baseurl|gpgkey)=https://pkgs\.tailscale\.com/(un)?stable/`)
	urlReplacement := fmt.Sprintf("$1=https://pkgs.tailscale.com/%s/", dstTrack)

	s := bufio.NewScanner(bytes.NewReader(was))
	newContent := bytes.NewBuffer(make([]byte, 0, len(was)))
	for s.Scan() {
		line := s.Text()
		// Handle repo section name, like "[tailscale-stable]".
		if len(line) > 0 && line[0] == '[' {
			if !strings.HasPrefix(line, "[tailscale-") {
				return false, fmt.Errorf("%q does not look like a tailscale repo file, it contains an unexpected %q section", repoFile, line)
			}
			fmt.Fprintf(newContent, "[tailscale-%s]\n", dstTrack)
			continue
		}
		// Update the track mentioned in repo name.
		if strings.HasPrefix(line, "name=") {
			fmt.Fprintf(newContent, "name=Tailscale %s\n", dstTrack)
			continue
		}
		// Update the actual repo URLs.
		if strings.HasPrefix(line, "baseurl=") || strings.HasPrefix(line, "gpgkey=") {
			fmt.Fprintln(newContent, urlRe.ReplaceAllString(line, urlReplacement))
			continue
		}
		fmt.Fprintln(newContent, line)
	}
	if bytes.Equal(was, newContent.Bytes()) {
		return false, nil
	}
	return true, os.WriteFile(repoFile, newContent.Bytes(), 0644)
}

func (up *Updater) updateAlpineLike() (err error) {
	if up.Version != "" {
		return errors.New("installing a specific version on Alpine-based distros is not supported")
	}
	if err := requireRoot(); err != nil {
		return err
	}
	if err := exec.Command("apk", "info", "--installed", "tailscale").Run(); err != nil && isExitError(err) {
		// Tailscale was not installed via apk, update via tarball download
		// instead.
		return up.updateLinuxBinary()
	}

	defer func() {
		if err != nil {
			err = fmt.Errorf(`%w; you can try updating using "apk upgrade tailscale"`, err)
		}
	}()

	out, err := exec.Command("apk", "update").CombinedOutput()
	if err != nil {
		return fmt.Errorf("failed refresh apk repository indexes: %w, output: %q", err, out)
	}
	out, err = exec.Command("apk", "info", "tailscale").CombinedOutput()
	if err != nil {
		return fmt.Errorf("failed checking apk for latest tailscale version: %w, output: %q", err, out)
	}
	ver, err := parseAlpinePackageVersion(out)
	if err != nil {
		return fmt.Errorf(`failed to parse latest version from "apk info tailscale": %w`, err)
	}
	if !up.confirm(ver) {
		return nil
	}

	cmd := exec.Command("apk", "upgrade", "tailscale")
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("failed tailscale update using apk: %w", err)
	}
	return nil
}

func parseAlpinePackageVersion(out []byte) (string, error) {
	s := bufio.NewScanner(bytes.NewReader(out))
	for s.Scan() {
		// The line should look like this:
		// tailscale-1.44.2-r0 description:
		line := strings.TrimSpace(s.Text())
		if !strings.HasPrefix(line, "tailscale-") {
			continue
		}
		parts := strings.SplitN(line, "-", 3)
		if len(parts) < 3 {
			return "", fmt.Errorf("malformed info line: %q", line)
		}
		return parts[1], nil
	}
	return "", errors.New("tailscale version not found in output")
}

func (up *Updater) updateMacSys() error {
	return errors.New("NOTREACHED: On MacSys builds, `tailscale update` is handled in Swift to launch the GUI updater")
}

func (up *Updater) updateMacAppStore() error {
	out, err := exec.Command("defaults", "read", "/Library/Preferences/com.apple.commerce.plist", "AutoUpdate").CombinedOutput()
	if err != nil {
		return fmt.Errorf("can't check App Store auto-update setting: %w, output: %q", err, string(out))
	}
	const on = "1\n"
	if string(out) != on {
		up.Logf("NOTE: Automatic updating for App Store apps is turned off. You can change this setting in System Settings (search for ‘update’).")
	}

	out, err = exec.Command("softwareupdate", "--list").CombinedOutput()
	if err != nil {
		return fmt.Errorf("can't check App Store for available updates: %w, output: %q", err, string(out))
	}

	newTailscale := parseSoftwareupdateList(out)
	if newTailscale == "" {
		up.Logf("no Tailscale update available")
		return nil
	}

	newTailscaleVer := strings.TrimPrefix(newTailscale, "Tailscale-")
	if !up.confirm(newTailscaleVer) {
		return nil
	}

	cmd := exec.Command("sudo", "softwareupdate", "--install", newTailscale)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("can't install App Store update for Tailscale: %w", err)
	}
	return nil
}

var macOSAppStoreListPattern = regexp.MustCompile(`(?m)^\s+\*\s+Label:\s*(Tailscale-\d[\d\.]+)`)

// parseSoftwareupdateList searches the output of `softwareupdate --list` on
// Darwin and returns the matching Tailscale package label. If there is none,
// returns the empty string.
//
// See TestParseSoftwareupdateList for example inputs.
func parseSoftwareupdateList(stdout []byte) string {
	matches := macOSAppStoreListPattern.FindSubmatch(stdout)
	if len(matches) < 2 {
		return ""
	}
	return string(matches[1])
}

// winMSIEnv is the environment variable that, if set, is the MSI file for the
// update command to install. It's passed like this so we can stop the
// tailscale.exe process from running before the msiexec process runs and tries
// to overwrite ourselves.
const winMSIEnv = "TS_UPDATE_WIN_MSI"

var (
	verifyAuthenticode func(string) error // or nil on non-Windows
	markTempFileFunc   func(string) error // or nil on non-Windows
)

func (up *Updater) updateWindows() error {
	if msi := os.Getenv(winMSIEnv); msi != "" {
		up.Logf("installing %v ...", msi)
		if err := up.installMSI(msi); err != nil {
			up.Logf("MSI install failed: %v", err)
			return err
		}
		up.Logf("success.")
		return nil
	}
	ver, err := requestedTailscaleVersion(up.Version, up.track)
	if err != nil {
		return err
	}
	arch := runtime.GOARCH
	if arch == "386" {
		arch = "x86"
	}

	if !up.confirm(ver) {
		return nil
	}
	if !winutil.IsCurrentProcessElevated() {
		return errors.New("must be run as Administrator")
	}

	tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
	msiDir := filepath.Join(tsDir, "MSICache")
	if fi, err := os.Stat(tsDir); err != nil {
		return fmt.Errorf("expected %s to exist, got stat error: %w", tsDir, err)
	} else if !fi.IsDir() {
		return fmt.Errorf("expected %s to be a directory; got %v", tsDir, fi.Mode())
	}
	if err := os.MkdirAll(msiDir, 0700); err != nil {
		return err
	}
	pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
	msiTarget := filepath.Join(msiDir, path.Base(pkgsPath))
	if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil {
		return err
	}

	up.Logf("verifying MSI authenticode...")
	if err := verifyAuthenticode(msiTarget); err != nil {
		return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
	}
	up.Logf("authenticode verification succeeded")

	up.Logf("making tailscale.exe copy to switch to...")
	selfCopy, err := makeSelfCopy()
	if err != nil {
		return err
	}
	defer os.Remove(selfCopy)
	up.Logf("running tailscale.exe copy for final install...")

	cmd := exec.Command(selfCopy, "update")
	cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget)
	cmd.Stdout = os.Stderr
	cmd.Stderr = os.Stderr
	cmd.Stdin = os.Stdin
	if err := cmd.Start(); err != nil {
		return err
	}
	// Once it's started, exit ourselves, so the binary is free
	// to be replaced.
	os.Exit(0)
	panic("unreachable")
}

func (up *Updater) installMSI(msi string) error {
	var err error
	for tries := 0; tries < 2; tries++ {
		cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
		cmd.Dir = filepath.Dir(msi)
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		cmd.Stdin = os.Stdin
		err = cmd.Run()
		if err == nil {
			break
		}
		uninstallVersion := version.Short()
		if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
			uninstallVersion = v
		}
		// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
		up.Logf("Uninstalling current version %q for downgrade...", uninstallVersion)
		cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		cmd.Stdin = os.Stdin
		err = cmd.Run()
		up.Logf("msiexec uninstall: %v", err)
	}
	return err
}

func msiUUIDForVersion(ver string) string {
	arch := runtime.GOARCH
	if arch == "386" {
		arch = "x86"
	}
	track, err := versionToTrack(ver)
	if err != nil {
		track = UnstableTrack
	}
	msiURL := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", track, ver, arch)
	return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
}

func makeSelfCopy() (tmpPathExe string, err error) {
	selfExe, err := os.Executable()
	if err != nil {
		return "", err
	}
	f, err := os.Open(selfExe)
	if err != nil {
		return "", err
	}
	defer f.Close()
	f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
	if err != nil {
		return "", err
	}
	if f := markTempFileFunc; f != nil {
		if err := f(f2.Name()); err != nil {
			return "", err
		}
	}
	if _, err := io.Copy(f2, f); err != nil {
		f2.Close()
		return "", err
	}
	return f2.Name(), f2.Close()
}

func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
	c, err := distsign.NewClient(up.Logf, up.PkgsAddr)
	if err != nil {
		return err
	}
	return c.Download(context.Background(), pathSrc, fileDst)
}

func (up *Updater) updateFreeBSD() (err error) {
	if up.Version != "" {
		return errors.New("installing a specific version on FreeBSD is not supported")
	}
	if err := requireRoot(); err != nil {
		return err
	}
	if err := exec.Command("pkg", "query", "%n", "tailscale").Run(); err != nil && isExitError(err) {
		// Tailscale was not installed via pkg and we don't pre-compile
		// binaries for it.
		return errors.New("Tailscale was not installed via pkg, binary updates on FreeBSD are not supported; please reinstall Tailscale using pkg or update manually")
	}

	defer func() {
		if err != nil {
			err = fmt.Errorf(`%w; you can try updating using "pkg upgrade tailscale"`, err)
		}
	}()

	out, err := exec.Command("pkg", "update").CombinedOutput()
	if err != nil {
		return fmt.Errorf("failed refresh pkg repository indexes: %w, output: %q", err, out)
	}
	out, err = exec.Command("pkg", "rquery", "%v", "tailscale").CombinedOutput()
	if err != nil {
		return fmt.Errorf("failed checking pkg for latest tailscale version: %w, output: %q", err, out)
	}
	ver := string(bytes.TrimSpace(out))
	if !up.confirm(ver) {
		return nil
	}

	cmd := exec.Command("pkg", "upgrade", "tailscale")
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("failed tailscale update using pkg: %w", err)
	}
	return nil
}

func (up *Updater) updateLinuxBinary() error {
	ver, err := requestedTailscaleVersion(up.Version, up.track)
	if err != nil {
		return err
	}
	if !up.confirm(ver) {
		return nil
	}
	// Root is needed to overwrite binaries and restart systemd unit.
	if err := requireRoot(); err != nil {
		return err
	}

	dlPath, err := up.downloadLinuxTarball(ver)
	if err != nil {
		return err
	}
	up.Logf("Extracting %q", dlPath)
	if err := up.unpackLinuxTarball(dlPath); err != nil {
		return err
	}
	if err := os.Remove(dlPath); err != nil {
		up.Logf("failed to clean up %q: %v", dlPath, err)
	}
	if err := restartSystemdUnit(context.Background()); err != nil {
		if errors.Is(err, errors.ErrUnsupported) {
			up.Logf("Tailscale binaries updated successfully.\nPlease restart tailscaled to finish the update.")
		} else {
			up.Logf("Tailscale binaries updated successfully, but failed to restart tailscaled: %s.\nPlease restart tailscaled to finish the update.", err)
		}
	} else {
		up.Logf("Success")
	}

	return nil
}

func (up *Updater) downloadLinuxTarball(ver string) (string, error) {
	dlDir, err := os.UserCacheDir()
	if err != nil {
		return "", err
	}
	dlDir = filepath.Join(dlDir, "tailscale-update")
	if err := os.MkdirAll(dlDir, 0700); err != nil {
		return "", err
	}
	pkgsPath := fmt.Sprintf("%s/tailscale_%s_%s.tgz", up.track, ver, runtime.GOARCH)
	dlPath := filepath.Join(dlDir, path.Base(pkgsPath))
	if err := up.downloadURLToFile(pkgsPath, dlPath); err != nil {
		return "", err
	}
	return dlPath, nil
}

func (up *Updater) unpackLinuxTarball(path string) error {
	tailscale, tailscaled, err := binaryPaths()
	if err != nil {
		return err
	}
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()
	gr, err := gzip.NewReader(f)
	if err != nil {
		return err
	}
	defer gr.Close()
	tr := tar.NewReader(gr)
	files := make(map[string]int)
	wantFiles := map[string]int{
		"tailscale":  1,
		"tailscaled": 1,
	}
	for {
		th, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return fmt.Errorf("failed extracting %q: %w", path, err)
		}
		// TODO(awly): try to also extract tailscaled.service. The tricky part
		// is fixing up binary paths in that file if they differ from where
		// local tailscale/tailscaled are installed. Also, this may not be a
		// systemd distro.
		switch filepath.Base(th.Name) {
		case "tailscale":
			files["tailscale"]++
			if err := writeFile(tr, tailscale+".new", 0755); err != nil {
				return fmt.Errorf("failed extracting the new tailscale binary from %q: %w", path, err)
			}
		case "tailscaled":
			files["tailscaled"]++
			if err := writeFile(tr, tailscaled+".new", 0755); err != nil {
				return fmt.Errorf("failed extracting the new tailscaled binary from %q: %w", path, err)
			}
		}
	}
	if !maps.Equal(files, wantFiles) {
		return fmt.Errorf("%q has missing or duplicate files: got %v, want %v", path, files, wantFiles)
	}

	// Only place the files in final locations after everything extracted correctly.
	if err := os.Rename(tailscale+".new", tailscale); err != nil {
		return err
	}
	up.Logf("Updated %s", tailscale)
	if err := os.Rename(tailscaled+".new", tailscaled); err != nil {
		return err
	}
	up.Logf("Updated %s", tailscaled)
	return nil
}

func writeFile(r io.Reader, path string, perm os.FileMode) error {
	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("failed to remove existing file at %q: %w", path, err)
	}
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
	if err != nil {
		return err
	}
	defer f.Close()
	if _, err := io.Copy(f, r); err != nil {
		return err
	}
	return f.Close()
}

// Var allows overriding this in tests.
var binaryPaths = func() (tailscale, tailscaled string, err error) {
	// This can be either tailscale or tailscaled.
	this, err := os.Executable()
	if err != nil {
		return "", "", err
	}
	otherName := "tailscaled"
	if filepath.Base(this) == "tailscaled" {
		otherName = "tailscale"
	}
	// Try to find the other binary in the same directory.
	other := filepath.Join(filepath.Dir(this), otherName)
	_, err = os.Stat(other)
	if os.IsNotExist(err) {
		// If it's not in the same directory, try to find it in $PATH.
		other, err = exec.LookPath(otherName)
	}
	if err != nil {
		return "", "", fmt.Errorf("cannot find %q in neither %q nor $PATH: %w", otherName, filepath.Dir(this), err)
	}
	if otherName == "tailscaled" {
		return this, other, nil
	} else {
		return other, this, nil
	}
}

func haveExecutable(name string) bool {
	path, err := exec.LookPath(name)
	return err == nil && path != ""
}

func requestedTailscaleVersion(ver, track string) (string, error) {
	if ver != "" {
		return ver, nil
	}
	return LatestTailscaleVersion(track)
}

// LatestTailscaleVersion returns the latest released version for the given
// track from pkgs.tailscale.com.
func LatestTailscaleVersion(track string) (string, error) {
	if track == CurrentTrack {
		if version.IsUnstableBuild() {
			track = UnstableTrack
		} else {
			track = StableTrack
		}
	}

	latest, err := latestPackages(track)
	if err != nil {
		return "", err
	}
	if latest.Version == "" {
		return "", fmt.Errorf("no latest version found for %q track", track)
	}
	return latest.Version, nil
}

type trackPackages struct {
	Version         string
	Tarballs        map[string]string
	TarballsVersion string
	Exes            []string
	ExesVersion     string
	MSIs            map[string]string
	MSIsVersion     string
	MacZips         map[string]string
	MacZipsVersion  string
	SPKs            map[string]map[string]string
	SPKsVersion     string
}

func latestPackages(track string) (*trackPackages, error) {
	url := fmt.Sprintf("https://pkgs.tailscale.com/%s/?mode=json&os=%s", track, runtime.GOOS)
	res, err := http.Get(url)
	if err != nil {
		return nil, fmt.Errorf("fetching latest tailscale version: %w", err)
	}
	defer res.Body.Close()
	var latest trackPackages
	if err := json.NewDecoder(res.Body).Decode(&latest); err != nil {
		return nil, fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
	}
	return &latest, nil
}

func requireRoot() error {
	if os.Geteuid() == 0 {
		return nil
	}
	switch runtime.GOOS {
	case "linux":
		return errors.New("must be root; use sudo")
	case "freebsd", "openbsd":
		return errors.New("must be root; use doas")
	default:
		return errors.New("must be root")
	}
}

func isExitError(err error) bool {
	var exitErr *exec.ExitError
	return errors.As(err, &exitErr)
}
