mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 19:15:34 +00:00
c40d095c35
Implement naive update for Synology packages, using latest versions from pkgs.tailscale.com. This is naive because we completely trust pkgs.tailscale.com to give us a safe package. We should switch this to some better signing mechanism later. I've only tested this on one DS218 box, so all the CPU architecture munging is purely based on docs. Updates #6995 Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
1004 lines
28 KiB
Go
1004 lines
28 KiB
Go
// 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/hostinfo"
|
||
"tailscale.com/net/tshttpproxy"
|
||
"tailscale.com/tailcfg"
|
||
"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 {
|
||
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.
|
||
osName := fmt.Sprintf("dsm%d", distro.DSMVersion())
|
||
arch, err := synoArch(hostinfo.New())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
latest, err := latestPackages(up.track)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if latest.Version == "" {
|
||
return fmt.Errorf("no latest version found for %q track", up.track)
|
||
}
|
||
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.Version) {
|
||
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
|
||
}
|
||
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/%s", up.track, spkName)
|
||
spkPath := filepath.Join(spkDir, path.Base(url))
|
||
// TODO(awly): we should sign SPKs and validate signatures here too.
|
||
if err := up.downloadURLToFile(url, 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 {
|
||
return fmt.Errorf("synopkg install 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(hinfo *tailcfg.Hostinfo) (string, error) {
|
||
// Most Synology boxes just use a different arch name from GOARCH.
|
||
arch := map[string]string{
|
||
"amd64": "x86_64",
|
||
"386": "i686",
|
||
"arm64": "armv8",
|
||
}[hinfo.GoArch]
|
||
// 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. Here, we override GOARCH for those older boxes that
|
||
// support at least DSM6.
|
||
//
|
||
// This is an artisanal hand-crafted list based on the wiki page. Some
|
||
// values may be wrong, since we don't have all those devices to actually
|
||
// test with.
|
||
switch hinfo.DeviceModel {
|
||
case "DS213air", "DS213", "DS413j",
|
||
"DS112", "DS112+", "DS212", "DS212+", "RS212", "RS812", "DS212j", "DS112j",
|
||
"DS111", "DS211", "DS211+", "DS411slim", "DS411", "RS411", "DS211j", "DS411j":
|
||
arch = "88f6281"
|
||
case "NVR1218", "NVR216", "VS960HD", "VS360HD":
|
||
arch = "hi3535"
|
||
case "DS1517", "DS1817", "DS416", "DS2015xs", "DS715", "DS1515", "DS215+":
|
||
arch = "alpine"
|
||
case "DS216se", "DS115j", "DS114", "DS214se", "DS414slim", "RS214", "DS14", "EDS14", "DS213j":
|
||
arch = "armada370"
|
||
case "DS115", "DS215j":
|
||
arch = "armada375"
|
||
case "DS419slim", "DS218j", "RS217", "DS116", "DS216j", "DS216", "DS416slim", "RS816", "DS416j":
|
||
arch = "armada38x"
|
||
case "RS815", "DS214", "DS214+", "DS414", "RS814":
|
||
arch = "armadaxp"
|
||
case "DS414j":
|
||
arch = "comcerto2k"
|
||
case "DS216play":
|
||
arch = "monaco"
|
||
}
|
||
if arch == "" {
|
||
return "", fmt.Errorf("cannot determine CPU architecture for Synology model %q (Go arch %q), please report a bug at https://github.com/tailscale/tailscale/issues/new/choose", hinfo.DeviceModel, hinfo.GoArch)
|
||
}
|
||
return arch, nil
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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
|
||
Exes []string
|
||
MSIs map[string]string
|
||
MacZips map[string]string
|
||
SPKs map[string]map[string]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")
|
||
}
|
||
}
|