Browse Source

cmd/tailscale/cli,clientupdate: extract new clientupdate package (#8827)

Extract the self-update logic from cmd/tailscale/cli into a standalone
package that could be used from tailscaled later.

Updates #6995

Signed-off-by: Andrew Lytvynov <[email protected]>
Andrew Lytvynov 2 years ago
parent
commit
215480a022

+ 892 - 0
clientupdate/clientupdate.go

@@ -0,0 +1,892 @@
+// 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 (
+	"bufio"
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"path"
+	"path/filepath"
+	"regexp"
+	"runtime"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/google/uuid"
+	"tailscale.com/net/tshttpproxy"
+	"tailscale.com/types/logger"
+	"tailscale.com/util/must"
+	"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
+}
+
+type updater struct {
+	UpdateArgs
+	track  string
+	update func() error
+}
+
+// UpdateArgs contains arguments needed to run an update.
+type UpdateArgs 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.
+	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
+}
+
+func (args UpdateArgs) validate() error {
+	if args.Confirm == nil {
+		return errors.New("missing Confirm callback in UpdateArgs")
+	}
+	if args.Logf == nil {
+		return errors.New("missing Logf callback in UpdateArgs")
+	}
+	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 UpdateArgs) error {
+	if err := args.validate(); err != nil {
+		return err
+	}
+	up := &updater{
+		UpdateArgs: args,
+	}
+	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 err
+		}
+	}
+	switch runtime.GOOS {
+	case "windows":
+		up.update = up.updateWindows
+	case "linux":
+		switch distro.Get() {
+		case distro.Synology:
+			up.update = up.updateSynology
+		case distro.Debian: // includes Ubuntu
+			up.update = up.updateDebLike
+		case distro.Arch:
+			up.update = up.updateArchLike
+		case distro.Alpine:
+			up.update = up.updateAlpineLike
+		}
+		switch {
+		case haveExecutable("pacman"):
+			up.update = 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.
+			up.update = up.updateDebLike
+		case haveExecutable("dnf"):
+			up.update = up.updateFedoraLike("dnf")
+		case haveExecutable("yum"):
+			up.update = up.updateFedoraLike("yum")
+		case haveExecutable("apk"):
+			up.update = up.updateAlpineLike
+		}
+	case "darwin":
+		switch {
+		case !args.AppStore && !version.IsSandboxedMacOS():
+			return errors.ErrUnsupported
+		case !args.AppStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
+			up.update = up.updateMacSys
+		default:
+			up.update = up.updateMacAppStore
+		}
+	case "freebsd":
+		up.update = up.updateFreeBSD
+	}
+	if up.update == nil {
+		return errors.ErrUnsupported
+	}
+	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
+}
+
+func (up *updater) updateSynology() error {
+	// TODO(bradfitz): detect, map GOARCH+CPU to the right Synology arch.
+	// TODO(bradfitz): add pkgs.tailscale.com endpoint to get release info
+	// TODO(bradfitz): require root/sudo
+	// TODO(bradfitz): run /usr/syno/bin/synopkg install tailscale.spk
+	return errors.ErrUnsupported
+}
+
+func (up *updater) updateDebLike() error {
+	ver, err := requestedTailscaleVersion(up.Version, up.track)
+	if err != nil {
+		return err
+	}
+	if !up.confirm(ver) {
+		return nil
+	}
+
+	if err := requireRoot(); err != nil {
+		return err
+	}
+
+	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() (err error) {
+	if up.Version != "" {
+		return errors.New("installing a specific version on Arch-based distros is not supported")
+	}
+	if err := requireRoot(); err != nil {
+		return err
+	}
+
+	defer func() {
+		if err != nil {
+			err = fmt.Errorf(`%w; you can try updating using "pacman --sync --refresh tailscale"`, err)
+		}
+	}()
+
+	out, err := exec.Command("pacman", "--sync", "--refresh", "--info", "tailscale").CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("failed checking pacman for latest tailscale version: %w, output: %q", err, out)
+	}
+	ver, err := parsePacmanVersion(out)
+	if err != nil {
+		return err
+	}
+	if !up.confirm(ver) {
+		return nil
+	}
+
+	cmd := exec.Command("pacman", "--sync", "--noconfirm", "tailscale")
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("failed tailscale update using pacman: %w", err)
+	}
+	return nil
+}
+
+func parsePacmanVersion(out []byte) (string, error) {
+	for _, line := range strings.Split(string(out), "\n") {
+		// The line we're looking for looks like this:
+		// Version         : 1.44.2-1
+		if !strings.HasPrefix(line, "Version") {
+			continue
+		}
+		parts := strings.SplitN(line, ":", 2)
+		if len(parts) != 2 {
+			return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
+		}
+		ver := strings.TrimSpace(parts[1])
+		// Trim the Arch patch version.
+		ver = strings.Split(ver, "-")[0]
+		if ver == "" {
+			return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
+		}
+		return ver, nil
+	}
+	return "", fmt.Errorf("could not find latest version of tailscale via pacman")
+}
+
+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
+		}
+		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
+	}
+
+	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 {
+	// use sparkle? do we have permissions from this context? does sudo help?
+	// We can at least fail with a command they can run to update from the shell.
+	// Like "tailscale update --macsys | sudo sh" or something.
+	//
+	// TODO(bradfitz,mihai): implement. But for now:
+	return errors.ErrUnsupported
+}
+
+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
+	}
+	url := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
+	msiTarget := filepath.Join(msiDir, path.Base(url))
+	if err := up.downloadURLToFile(url, 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(urlSrc, fileDst string) (ret error) {
+	tr := http.DefaultTransport.(*http.Transport).Clone()
+	tr.Proxy = tshttpproxy.ProxyFromEnvironment
+	defer tr.CloseIdleConnections()
+	c := &http.Client{Transport: tr}
+
+	quickCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+	headReq := must.Get(http.NewRequestWithContext(quickCtx, "HEAD", urlSrc, nil))
+
+	res, err := c.Do(headReq)
+	if err != nil {
+		return err
+	}
+	if res.StatusCode != http.StatusOK {
+		return fmt.Errorf("HEAD %s: %v", urlSrc, res.Status)
+	}
+	if res.ContentLength <= 0 {
+		return fmt.Errorf("HEAD %s: unexpected Content-Length %v", urlSrc, res.ContentLength)
+	}
+	up.Logf("Download size: %v", res.ContentLength)
+
+	hashReq := must.Get(http.NewRequestWithContext(quickCtx, "GET", urlSrc+".sha256", nil))
+	hashRes, err := c.Do(hashReq)
+	if err != nil {
+		return err
+	}
+	hashHex, err := io.ReadAll(io.LimitReader(hashRes.Body, 100))
+	hashRes.Body.Close()
+	if res.StatusCode != http.StatusOK {
+		return fmt.Errorf("GET %s.sha256: %v", urlSrc, res.Status)
+	}
+	if err != nil {
+		return err
+	}
+	wantHash, err := hex.DecodeString(string(strings.TrimSpace(string(hashHex))))
+	if err != nil {
+		return err
+	}
+	hash := sha256.New()
+
+	dlReq := must.Get(http.NewRequestWithContext(context.Background(), "GET", urlSrc, nil))
+	dlRes, err := c.Do(dlReq)
+	if err != nil {
+		return err
+	}
+	// TODO(bradfitz): resume from existing partial file on disk
+	if dlRes.StatusCode != http.StatusOK {
+		return fmt.Errorf("GET %s: %v", urlSrc, dlRes.Status)
+	}
+
+	of, err := os.Create(fileDst)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if ret != nil {
+			of.Close()
+			// TODO(bradfitz): os.Remove(fileDst) too? or keep it to resume from/debug later.
+		}
+	}()
+	pw := &progressWriter{total: res.ContentLength, logf: up.Logf}
+	n, err := io.Copy(io.MultiWriter(hash, of, pw), io.LimitReader(dlRes.Body, res.ContentLength))
+	if err != nil {
+		return err
+	}
+	if n != res.ContentLength {
+		return fmt.Errorf("downloaded %v; want %v", n, res.ContentLength)
+	}
+	if err := of.Close(); err != nil {
+		return err
+	}
+	pw.print()
+
+	if !bytes.Equal(hash.Sum(nil), wantHash) {
+		return fmt.Errorf("SHA-256 of downloaded MSI didn't match expected value")
+	}
+	up.Logf("hash matched")
+
+	return nil
+}
+
+type progressWriter struct {
+	done      int64
+	total     int64
+	lastPrint time.Time
+	logf      logger.Logf
+}
+
+func (pw *progressWriter) Write(p []byte) (n int, err error) {
+	pw.done += int64(len(p))
+	if time.Since(pw.lastPrint) > 2*time.Second {
+		pw.print()
+	}
+	return len(p), nil
+}
+
+func (pw *progressWriter) print() {
+	pw.lastPrint = time.Now()
+	pw.logf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
+}
+
+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
+	}
+
+	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 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
+		}
+	}
+
+	url := fmt.Sprintf("https://pkgs.tailscale.com/%s/?mode=json&os=%s", track, runtime.GOOS)
+	res, err := http.Get(url)
+	if err != nil {
+		return "", fmt.Errorf("fetching latest tailscale version: %w", err)
+	}
+	var latest struct {
+		Version string
+	}
+	err = json.NewDecoder(res.Body).Decode(&latest)
+	res.Body.Close()
+	if err != nil {
+		return "", fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
+	}
+	if latest.Version == "" {
+		return "", fmt.Errorf("no version found at %q", url)
+	}
+	return latest.Version, 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")
+	}
+}

+ 10 - 10
cmd/tailscale/cli/update_test.go → clientupdate/clientupdate_test.go

@@ -1,7 +1,7 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
-package cli
+package clientupdate
 
 import (
 	"os"
@@ -19,38 +19,38 @@ func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
 	}{
 		{
 			name:    "stable-to-unstable",
-			toTrack: "unstable",
+			toTrack: UnstableTrack,
 			in:      "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
 			want:    "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
 		},
 		{
 			name:    "stable-unchanged",
-			toTrack: "stable",
+			toTrack: StableTrack,
 			in:      "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
 		},
 		{
 			name:    "if-both-stable-and-unstable-dont-change",
-			toTrack: "stable",
+			toTrack: StableTrack,
 			in: "# Tailscale packages for debian buster\n" +
 				"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
 				"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
 		},
 		{
 			name:    "if-both-stable-and-unstable-dont-change-unstable",
-			toTrack: "unstable",
+			toTrack: UnstableTrack,
 			in: "# Tailscale packages for debian buster\n" +
 				"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
 				"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
 		},
 		{
 			name:    "signed-by-form",
-			toTrack: "unstable",
+			toTrack: UnstableTrack,
 			in:      "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu jammy main\n",
 			want:    "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/unstable/ubuntu jammy main\n",
 		},
 		{
 			name:    "unsupported-lines",
-			toTrack: "unstable",
+			toTrack: UnstableTrack,
 			in:      "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/foobar/ubuntu jammy main\n",
 			wantErr: "unexpected/unsupported /etc/apt/sources.list.d/tailscale.list contents",
 		},
@@ -279,7 +279,7 @@ repo_gpgcheck=1
 gpgcheck=0
 gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
 `,
-			track: "stable",
+			track: StableTrack,
 			after: `
 [tailscale-stable]
 name=Tailscale stable
@@ -303,7 +303,7 @@ repo_gpgcheck=1
 gpgcheck=0
 gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
 `,
-			track: "unstable",
+			track: UnstableTrack,
 			after: `
 [tailscale-unstable]
 name=Tailscale unstable
@@ -332,7 +332,7 @@ gpgcheck=1
 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch
 skip_if_unavailable=False
 `,
-			track:   "stable",
+			track:   StableTrack,
 			wantErr: true,
 		},
 	}

+ 10 - 2
cmd/tailscale/cli/update_windows.go → clientupdate/clientupdate_windows.go

@@ -1,20 +1,28 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
-// Windows-specific stuff that can't go in update.go because it needs
+// Windows-specific stuff that can't go in clientupdate.go because it needs
 // x/sys/windows.
 
-package cli
+package clientupdate
 
 import (
 	"golang.org/x/sys/windows"
+	"tailscale.com/util/winutil/authenticode"
 )
 
 func init() {
 	markTempFileFunc = markTempFileWindows
+	verifyAuthenticode = verifyTailscale
 }
 
 func markTempFileWindows(name string) error {
 	name16 := windows.StringToUTF16Ptr(name)
 	return windows.MoveFileEx(name16, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT)
 }
+
+const certSubjectTailscale = "Tailscale Inc."
+
+func verifyTailscale(path string) error {
+	return authenticode.Verify(path, certSubjectTailscale)
+}

+ 0 - 20
cmd/tailscale/cli/authenticode_windows.go

@@ -1,20 +0,0 @@
-/* SPDX-License-Identifier: MIT
- *
- * Copyright (C) 2019-2022 WireGuard LLC. All Rights Reserved.
- */
-
-package cli
-
-import (
-	"tailscale.com/util/winutil/authenticode"
-)
-
-func init() {
-	verifyAuthenticode = verifyTailscale
-}
-
-const certSubjectTailscale = "Tailscale Inc."
-
-func verifyTailscale(path string) error {
-	return authenticode.Verify(path, certSubjectTailscale)
-}

+ 20 - 837
cmd/tailscale/cli/update.go

@@ -4,33 +4,15 @@
 package cli
 
 import (
-	"bufio"
-	"bytes"
 	"context"
-	"crypto/sha256"
-	"encoding/hex"
-	"encoding/json"
 	"errors"
 	"flag"
 	"fmt"
-	"io"
-	"log"
-	"net/http"
-	"os"
-	"os/exec"
-	"path"
-	"path/filepath"
-	"regexp"
 	"runtime"
-	"strconv"
 	"strings"
-	"time"
 
-	"github.com/google/uuid"
 	"github.com/peterbourgon/ff/v3/ffcli"
-	"tailscale.com/net/tshttpproxy"
-	"tailscale.com/util/must"
-	"tailscale.com/util/winutil"
+	"tailscale.com/clientupdate"
 	"tailscale.com/version"
 	"tailscale.com/version/distro"
 )
@@ -67,147 +49,38 @@ var updateArgs struct {
 	version  string // explicit version; empty means auto
 }
 
-// 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"
-
 func runUpdate(ctx context.Context, args []string) error {
-	if msi := os.Getenv(winMSIEnv); msi != "" {
-		log.Printf("installing %v ...", msi)
-		if err := installMSI(msi); err != nil {
-			log.Printf("MSI install failed: %v", err)
-			return err
-		}
-		log.Printf("success.")
-		return nil
-	}
 	if len(args) > 0 {
 		return flag.ErrHelp
 	}
 	if updateArgs.version != "" && updateArgs.track != "" {
 		return errors.New("cannot specify both --version and --track")
 	}
-	up, err := newUpdater()
-	if err != nil {
-		return err
-	}
-	return up.update()
-}
-
-func versionIsStable(v string) (stable, wellFormed bool) {
-	_, rest, ok := strings.Cut(v, ".")
-	if !ok {
-		return false, false
-	}
-	minorStr, _, ok := strings.Cut(rest, ".")
-	if !ok {
-		return false, false
-	}
-	minor, err := strconv.Atoi(minorStr)
-	if err != nil {
-		return false, false
-	}
-	return minor%2 == 0, true
-}
-
-func newUpdater() (*updater, error) {
-	up := &updater{
-		track: updateArgs.track,
-	}
-	switch up.track {
-	case "stable", "unstable":
-	case "":
-		if version.IsUnstableBuild() {
-			up.track = "unstable"
-		} else {
-			up.track = "stable"
-		}
-		if updateArgs.version != "" {
-			stable, ok := versionIsStable(updateArgs.version)
-			if !ok {
-				return nil, fmt.Errorf("malformed version %q", updateArgs.version)
-			}
-			if stable {
-				up.track = "stable"
-			} else {
-				up.track = "unstable"
-			}
-		}
-	default:
-		return nil, fmt.Errorf("unknown track %q; must be 'stable' or 'unstable'", up.track)
-	}
-	switch runtime.GOOS {
-	case "windows":
-		up.update = up.updateWindows
-	case "linux":
-		switch distro.Get() {
-		case distro.Synology:
-			up.update = up.updateSynology
-		case distro.Debian: // includes Ubuntu
-			up.update = up.updateDebLike
-		case distro.Arch:
-			up.update = up.updateArchLike
-		case distro.Alpine:
-			up.update = up.updateAlpineLike
-		}
-		// TODO(awly): add support for Alpine
-		switch {
-		case haveExecutable("pacman"):
-			up.update = 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.
-			up.update = up.updateDebLike
-		case haveExecutable("dnf"):
-			up.update = up.updateFedoraLike("dnf")
-		case haveExecutable("yum"):
-			up.update = up.updateFedoraLike("yum")
-		case haveExecutable("apk"):
-			up.update = up.updateAlpineLike
-		}
-	case "darwin":
-		switch {
-		case !updateArgs.appStore && !version.IsSandboxedMacOS():
-			return nil, errors.New("The 'update' command is not yet supported on this platform; see https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS/ for now")
-		case !updateArgs.appStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
-			up.update = up.updateMacSys
-		default:
-			up.update = up.updateMacAppStore
-		}
-	case "freebsd":
-		up.update = up.updateFreeBSD
+	ver := updateArgs.version
+	if updateArgs.track != "" {
+		ver = updateArgs.track
 	}
-	if up.update == nil {
-		return nil, errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates")
+	err := clientupdate.Update(clientupdate.UpdateArgs{
+		Version:  ver,
+		AppStore: updateArgs.appStore,
+		Logf:     func(format string, args ...any) { fmt.Printf(format+"\n", args...) },
+		Confirm:  confirmUpdate,
+	})
+	if errors.Is(err, errors.ErrUnsupported) {
+		return errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates")
 	}
-	return up, nil
-}
-
-type updater struct {
-	track  string
-	update func() error
+	return err
 }
 
-func (up *updater) currentOrDryRun(ver string) bool {
-	if version.Short() == ver {
-		fmt.Printf("already running %v; no update needed\n", ver)
+func confirmUpdate(ver string) bool {
+	if updateArgs.yes {
+		fmt.Printf("Updating Tailscale from %v to %v; --yes given, continuing without prompts.\n", version.Short(), ver)
 		return true
 	}
+
 	if updateArgs.dryRun {
 		fmt.Printf("Current: %v, Latest: %v\n", version.Short(), ver)
-		return true
-	}
-	return false
-}
-
-var errUserAborted = errors.New("aborting update")
-
-func (up *updater) confirm(ver string) error {
-	if updateArgs.yes {
-		log.Printf("Updating Tailscale from %v to %v; --yes given, continuing without prompts.\n", version.Short(), ver)
-		return nil
+		return false
 	}
 
 	fmt.Printf("This will update Tailscale from %v to %v. Continue? [y/n] ", version.Short(), ver)
@@ -216,697 +89,7 @@ func (up *updater) confirm(ver string) error {
 	resp = strings.ToLower(resp)
 	switch resp {
 	case "y", "yes", "sure":
-		return nil
-	}
-	return errUserAborted
-}
-
-func (up *updater) updateSynology() error {
-	// TODO(bradfitz): detect, map GOARCH+CPU to the right Synology arch.
-	// TODO(bradfitz): add pkgs.tailscale.com endpoint to get release info
-	// TODO(bradfitz): require root/sudo
-	// TODO(bradfitz): run /usr/syno/bin/synopkg install tailscale.spk
-	return errors.New("The 'update' command is not yet implemented on Synology.")
-}
-
-func (up *updater) updateDebLike() error {
-	ver, err := requestedTailscaleVersion(updateArgs.version, up.track)
-	if err != nil {
-		return err
-	}
-	if up.currentOrDryRun(ver) {
-		return nil
-	}
-
-	if err := requireRoot(); err != nil {
-		return err
-	}
-
-	if updated, err := updateDebianAptSourcesList(up.track); err != nil {
-		return err
-	} else if updated {
-		fmt.Printf("Updated %s to use the %s track\n", 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() (err error) {
-	if err := requireRoot(); err != nil {
-		return err
-	}
-
-	defer func() {
-		if err != nil && !errors.Is(err, errUserAborted) {
-			err = fmt.Errorf(`%w; you can try updating using "pacman --sync --refresh tailscale"`, err)
-		}
-	}()
-
-	out, err := exec.Command("pacman", "--sync", "--refresh", "--info", "tailscale").CombinedOutput()
-	if err != nil {
-		return fmt.Errorf("failed checking pacman for latest tailscale version: %w, output: %q", err, out)
-	}
-	ver, err := parsePacmanVersion(out)
-	if err != nil {
-		return err
-	}
-	if up.currentOrDryRun(ver) {
-		return nil
-	}
-	if err := up.confirm(ver); err != nil {
-		return err
-	}
-
-	cmd := exec.Command("pacman", "--sync", "--noconfirm", "tailscale")
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	if err := cmd.Run(); err != nil {
-		return fmt.Errorf("failed tailscale update using pacman: %w", err)
-	}
-	return nil
-}
-
-func parsePacmanVersion(out []byte) (string, error) {
-	for _, line := range strings.Split(string(out), "\n") {
-		// The line we're looking for looks like this:
-		// Version         : 1.44.2-1
-		if !strings.HasPrefix(line, "Version") {
-			continue
-		}
-		parts := strings.SplitN(line, ":", 2)
-		if len(parts) != 2 {
-			return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
-		}
-		ver := strings.TrimSpace(parts[1])
-		// Trim the Arch patch version.
-		ver = strings.Split(ver, "-")[0]
-		if ver == "" {
-			return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
-		}
-		return ver, nil
-	}
-	return "", fmt.Errorf("could not find latest version of tailscale via pacman")
-}
-
-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
-		}
-		defer func() {
-			if err != nil && !errors.Is(err, errUserAborted) {
-				err = fmt.Errorf(`%w; you can try updating using "%s upgrade tailscale"`, err, packageManager)
-			}
-		}()
-
-		ver, err := requestedTailscaleVersion(updateArgs.version, up.track)
-		if err != nil {
-			return err
-		}
-		if up.currentOrDryRun(ver) {
-			return nil
-		}
-		if err := up.confirm(ver); err != nil {
-			return err
-		}
-
-		if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.track); err != nil {
-			return err
-		} else if updated {
-			fmt.Printf("Updated %s to use the %s track\n", 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 err := requireRoot(); err != nil {
-		return err
-	}
-
-	defer func() {
-		if err != nil && !errors.Is(err, errUserAborted) {
-			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.currentOrDryRun(ver) {
-		return nil
-	}
-	if err := up.confirm(ver); err != nil {
-		return err
-	}
-
-	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 {
-	// use sparkle? do we have permissions from this context? does sudo help?
-	// We can at least fail with a command they can run to update from the shell.
-	// Like "tailscale update --macsys | sudo sh" or something.
-	//
-	// TODO(bradfitz,mihai): implement. But for now:
-	return errors.New("The 'update' command is not yet implemented on macOS.")
-}
-
-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 {
-		fmt.Fprintln(os.Stderr, "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 == "" {
-		fmt.Println("no Tailscale update available")
-		return nil
-	}
-
-	newTailscaleVer := strings.TrimPrefix(newTailscale, "Tailscale-")
-	if up.currentOrDryRun(newTailscaleVer) {
-		return nil
-	}
-	if err := up.confirm(newTailscaleVer); err != nil {
-		return err
-	}
-
-	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])
-}
-
-var (
-	verifyAuthenticode func(string) error // or nil on non-Windows
-	markTempFileFunc   func(string) error // or nil on non-Windows
-)
-
-func (up *updater) updateWindows() error {
-	ver, err := requestedTailscaleVersion(updateArgs.version, up.track)
-	if err != nil {
-		return err
-	}
-	arch := runtime.GOARCH
-	if arch == "386" {
-		arch = "x86"
-	}
-	url := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
-
-	if up.currentOrDryRun(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
-	}
-
-	if err := up.confirm(ver); err != nil {
-		return err
-	}
-	msiTarget := filepath.Join(msiDir, path.Base(url))
-	if err := downloadURLToFile(url, msiTarget); err != nil {
-		return err
-	}
-
-	log.Printf("verifying MSI authenticode...")
-	if err := verifyAuthenticode(msiTarget); err != nil {
-		return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
-	}
-	log.Printf("authenticode verification succeeded")
-
-	log.Printf("making tailscale.exe copy to switch to...")
-	selfCopy, err := makeSelfCopy()
-	if err != nil {
-		return err
-	}
-	defer os.Remove(selfCopy)
-	log.Printf("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 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.
-		log.Printf("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()
-		log.Printf("msiexec uninstall: %v", err)
-	}
-	return err
-}
-
-func msiUUIDForVersion(ver string) string {
-	arch := runtime.GOARCH
-	if arch == "386" {
-		arch = "x86"
-	}
-	track := "unstable"
-	if stable, ok := versionIsStable(ver); ok && stable {
-		track = "stable"
-	}
-	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 downloadURLToFile(urlSrc, fileDst string) (ret error) {
-	tr := http.DefaultTransport.(*http.Transport).Clone()
-	tr.Proxy = tshttpproxy.ProxyFromEnvironment
-	defer tr.CloseIdleConnections()
-	c := &http.Client{Transport: tr}
-
-	quickCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-	defer cancel()
-	headReq := must.Get(http.NewRequestWithContext(quickCtx, "HEAD", urlSrc, nil))
-
-	res, err := c.Do(headReq)
-	if err != nil {
-		return err
-	}
-	if res.StatusCode != http.StatusOK {
-		return fmt.Errorf("HEAD %s: %v", urlSrc, res.Status)
-	}
-	if res.ContentLength <= 0 {
-		return fmt.Errorf("HEAD %s: unexpected Content-Length %v", urlSrc, res.ContentLength)
-	}
-	log.Printf("Download size: %v", res.ContentLength)
-
-	hashReq := must.Get(http.NewRequestWithContext(quickCtx, "GET", urlSrc+".sha256", nil))
-	hashRes, err := c.Do(hashReq)
-	if err != nil {
-		return err
-	}
-	hashHex, err := io.ReadAll(io.LimitReader(hashRes.Body, 100))
-	hashRes.Body.Close()
-	if res.StatusCode != http.StatusOK {
-		return fmt.Errorf("GET %s.sha256: %v", urlSrc, res.Status)
-	}
-	if err != nil {
-		return err
-	}
-	wantHash, err := hex.DecodeString(string(strings.TrimSpace(string(hashHex))))
-	if err != nil {
-		return err
-	}
-	hash := sha256.New()
-
-	dlReq := must.Get(http.NewRequestWithContext(context.Background(), "GET", urlSrc, nil))
-	dlRes, err := c.Do(dlReq)
-	if err != nil {
-		return err
-	}
-	// TODO(bradfitz): resume from existing partial file on disk
-	if dlRes.StatusCode != http.StatusOK {
-		return fmt.Errorf("GET %s: %v", urlSrc, dlRes.Status)
-	}
-
-	of, err := os.Create(fileDst)
-	if err != nil {
-		return err
-	}
-	defer func() {
-		if ret != nil {
-			of.Close()
-			// TODO(bradfitz): os.Remove(fileDst) too? or keep it to resume from/debug later.
-		}
-	}()
-	pw := &progressWriter{total: res.ContentLength}
-	n, err := io.Copy(io.MultiWriter(hash, of, pw), io.LimitReader(dlRes.Body, res.ContentLength))
-	if err != nil {
-		return err
-	}
-	if n != res.ContentLength {
-		return fmt.Errorf("downloaded %v; want %v", n, res.ContentLength)
-	}
-	if err := of.Close(); err != nil {
-		return err
-	}
-	pw.print()
-
-	if !bytes.Equal(hash.Sum(nil), wantHash) {
-		return fmt.Errorf("SHA-256 of downloaded MSI didn't match expected value")
-	}
-	log.Printf("hash matched")
-
-	return nil
-}
-
-type progressWriter struct {
-	done      int64
-	total     int64
-	lastPrint time.Time
-}
-
-func (pw *progressWriter) Write(p []byte) (n int, err error) {
-	pw.done += int64(len(p))
-	if time.Since(pw.lastPrint) > 2*time.Second {
-		pw.print()
-	}
-	return len(p), nil
-}
-
-func (pw *progressWriter) print() {
-	pw.lastPrint = time.Now()
-	log.Printf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
-}
-
-func (up *updater) updateFreeBSD() (err error) {
-	if err := requireRoot(); err != nil {
-		return err
-	}
-
-	defer func() {
-		if err != nil && !errors.Is(err, errUserAborted) {
-			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.currentOrDryRun(ver) {
-		return nil
-	}
-	if err := up.confirm(ver); err != nil {
-		return err
-	}
-
-	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 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)
-}
-
-func latestTailscaleVersion(track string) (string, 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 "", fmt.Errorf("fetching latest tailscale version: %w", err)
-	}
-	var latest struct {
-		Version string
-	}
-	err = json.NewDecoder(res.Body).Decode(&latest)
-	res.Body.Close()
-	if err != nil {
-		return "", fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
-	}
-	if latest.Version == "" {
-		return "", fmt.Errorf("no version found at %q", url)
-	}
-	return latest.Version, 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")
+		return true
 	}
+	return false
 }

+ 2 - 5
cmd/tailscale/cli/version.go

@@ -11,6 +11,7 @@ import (
 	"os"
 
 	"github.com/peterbourgon/ff/v3/ffcli"
+	"tailscale.com/clientupdate"
 	"tailscale.com/ipn/ipnstate"
 	"tailscale.com/version"
 )
@@ -51,11 +52,7 @@ func runVersion(ctx context.Context, args []string) error {
 
 	var upstreamVer string
 	if versionArgs.upstream {
-		track := "stable"
-		if version.IsUnstableBuild() {
-			track = "unstable"
-		}
-		upstreamVer, err = latestTailscaleVersion(track)
+		upstreamVer, err = clientupdate.LatestTailscaleVersion(clientupdate.CurrentTrack)
 		if err != nil {
 			return err
 		}

+ 3 - 2
cmd/tailscale/depaware.txt

@@ -69,6 +69,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
         tailscale.com/client/tailscale                               from tailscale.com/cmd/tailscale/cli+
         tailscale.com/client/tailscale/apitype                       from tailscale.com/cmd/tailscale/cli+
         tailscale.com/client/web                                     from tailscale.com/cmd/tailscale/cli
+        tailscale.com/clientupdate                                   from tailscale.com/cmd/tailscale/cli
         tailscale.com/cmd/tailscale/cli                              from tailscale.com/cmd/tailscale
         tailscale.com/control/controlbase                            from tailscale.com/control/controlhttp
         tailscale.com/control/controlhttp                            from tailscale.com/cmd/tailscale/cli
@@ -142,14 +143,14 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
    L    tailscale.com/util/linuxfw                                   from tailscale.com/net/netns
         tailscale.com/util/mak                                       from tailscale.com/net/netcheck+
         tailscale.com/util/multierr                                  from tailscale.com/control/controlhttp+
-        tailscale.com/util/must                                      from tailscale.com/cmd/tailscale/cli
+        tailscale.com/util/must                                      from tailscale.com/cmd/tailscale/cli+
         tailscale.com/util/quarantine                                from tailscale.com/cmd/tailscale/cli
         tailscale.com/util/set                                       from tailscale.com/health+
         tailscale.com/util/singleflight                              from tailscale.com/net/dnscache
         tailscale.com/util/slicesx                                   from tailscale.com/net/dnscache+
         tailscale.com/util/testenv                                   from tailscale.com/cmd/tailscale/cli
      💣 tailscale.com/util/winutil                                   from tailscale.com/hostinfo+
-   W 💣 tailscale.com/util/winutil/authenticode                      from tailscale.com/cmd/tailscale/cli
+   W 💣 tailscale.com/util/winutil/authenticode                      from tailscale.com/clientupdate
         tailscale.com/version                                        from tailscale.com/cmd/tailscale/cli+
         tailscale.com/version/distro                                 from tailscale.com/cmd/tailscale/cli+
         tailscale.com/wgengine/capture                               from tailscale.com/cmd/tailscale/cli