diff --git a/cmd/tailscale/cli/update.go b/cmd/tailscale/cli/update.go index dcf72dd71b5bd..b54c3668dfe03 100644 --- a/cmd/tailscale/cli/update.go +++ b/cmd/tailscale/cli/update.go @@ -144,7 +144,20 @@ func newUpdater() (*updater, error) { case distro.Debian: // includes Ubuntu up.update = up.updateDebLike case distro.Arch: - up.update = up.updateArch + up.update = up.updateArchLike + } + // 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 "darwin": switch { @@ -207,48 +220,22 @@ func (up *updater) updateSynology() error { } func (up *updater) updateDebLike() error { - ver := updateArgs.version - if ver == "" { - res, err := http.Get("https://pkgs.tailscale.com/" + up.track + "/?mode=json") - if err != nil { - return err - } - var latest struct { - Tarballs map[string]string // ~goarch (ignoring "geode") => "tailscale_1.34.2_mips.tgz" - } - err = json.NewDecoder(res.Body).Decode(&latest) - res.Body.Close() - if err != nil { - return fmt.Errorf("decoding JSON: %v: %w", res.Status, err) - } - f, ok := latest.Tarballs[runtime.GOARCH] - if !ok { - return fmt.Errorf("can't update architecture %q", runtime.GOARCH) - } - ver, _, ok = strings.Cut(strings.TrimPrefix(f, "tailscale_"), "_") - if !ok { - return fmt.Errorf("can't parse version from %q", f) - } + ver, err := requestedTailscaleVersion(updateArgs.version, up.track) + if err != nil { + return err } if up.currentOrDryRun(ver) { return nil } - track := "unstable" - if stable, ok := versionIsStable(ver); !ok { - return fmt.Errorf("malformed version %q", ver) - } else if stable { - track = "stable" - } - - if os.Geteuid() != 0 { - return errors.New("must be root; use sudo") + if err := requireRoot(); err != nil { + return err } - if updated, err := updateDebianAptSourcesList(track); err != nil { + if updated, err := updateDebianAptSourcesList(up.track); err != nil { return err } else if updated { - fmt.Printf("Updated %s to use the %s track\n", aptSourcesFile, track) + fmt.Printf("Updated %s to use the %s track\n", aptSourcesFile, up.track) } cmd := exec.Command("apt-get", "update", @@ -334,9 +321,9 @@ func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent [] return buf.Bytes(), nil } -func (up *updater) updateArch() (err error) { - if os.Geteuid() != 0 { - return errors.New("must be root; use sudo") +func (up *updater) updateArchLike() (err error) { + if err := requireRoot(); err != nil { + return err } defer func() { @@ -391,6 +378,90 @@ func parsePacmanVersion(out []byte) (string, error) { 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) 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. @@ -459,24 +530,9 @@ var ( ) func (up *updater) updateWindows() error { - ver := updateArgs.version - if ver == "" { - res, err := http.Get("https://pkgs.tailscale.com/" + up.track + "/?mode=json&os=windows") - if err != nil { - return 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) - } - ver = latest.Version - if ver == "" { - return errors.New("no version found") - } + ver, err := requestedTailscaleVersion(updateArgs.version, up.track) + if err != nil { + return err } arch := runtime.GOARCH if arch == "386" { @@ -705,3 +761,45 @@ 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 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 + } + 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") + } +} diff --git a/cmd/tailscale/cli/update_test.go b/cmd/tailscale/cli/update_test.go index 5455bdd14dd66..99152cba94151 100644 --- a/cmd/tailscale/cli/update_test.go +++ b/cmd/tailscale/cli/update_test.go @@ -3,7 +3,11 @@ package cli -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestUpdateDebianAptSourcesListBytes(t *testing.T) { tests := []struct { @@ -253,3 +257,114 @@ Version : 1.44.2 }) } } + +func TestUpdateYUMRepoTrack(t *testing.T) { + tests := []struct { + desc string + before string + track string + after string + rewrote bool + wantErr bool + }{ + { + desc: "same track", + before: ` +[tailscale-stable] +name=Tailscale stable +baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch +enabled=1 +type=rpm +repo_gpgcheck=1 +gpgcheck=0 +gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg +`, + track: "stable", + after: ` +[tailscale-stable] +name=Tailscale stable +baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch +enabled=1 +type=rpm +repo_gpgcheck=1 +gpgcheck=0 +gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg +`, + }, + { + desc: "change track", + before: ` +[tailscale-stable] +name=Tailscale stable +baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch +enabled=1 +type=rpm +repo_gpgcheck=1 +gpgcheck=0 +gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg +`, + track: "unstable", + after: ` +[tailscale-unstable] +name=Tailscale unstable +baseurl=https://pkgs.tailscale.com/unstable/fedora/$basearch +enabled=1 +type=rpm +repo_gpgcheck=1 +gpgcheck=0 +gpgkey=https://pkgs.tailscale.com/unstable/fedora/repo.gpg +`, + rewrote: true, + }, + { + desc: "non-tailscale repo file", + before: ` +[fedora] +name=Fedora $releasever - $basearch +#baseurl=http://download.example/pub/fedora/linux/releases/$releasever/Everything/$basearch/os/ +metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever&arch=$basearch +enabled=1 +countme=1 +metadata_expire=7d +repo_gpgcheck=0 +type=rpm +gpgcheck=1 +gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch +skip_if_unavailable=False +`, + track: "stable", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "tailscale.repo") + if err := os.WriteFile(path, []byte(tt.before), 0644); err != nil { + t.Fatal(err) + } + + rewrote, err := updateYUMRepoTrack(path, tt.track) + if err == nil && tt.wantErr { + t.Fatal("got nil error, want non-nil") + } + if err != nil && !tt.wantErr { + t.Fatalf("got error %q, want nil", err) + } + if err != nil { + return + } + if rewrote != tt.rewrote { + t.Errorf("got rewrote flag %v, want %v", rewrote, tt.rewrote) + } + + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != tt.after { + t.Errorf("got repo file after update:\n%swant:\n%s", after, tt.after) + } + }) + } +}