diff --git a/cmd/tailscale/cli/update.go b/cmd/tailscale/cli/update.go index 4d9bd9c72..adf07f712 100644 --- a/cmd/tailscale/cli/update.go +++ b/cmd/tailscale/cli/update.go @@ -45,8 +45,12 @@ var updateCmd = &ffcli.Command{ fs.BoolVar(&updateArgs.yes, "yes", false, "update without interactive prompts") fs.BoolVar(&updateArgs.dryRun, "dry-run", false, "print what update would do without doing it, or prompts") fs.BoolVar(&updateArgs.appStore, "app-store", false, "check the App Store for updates, even if this is not an App Store install (for testing only!)") - fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable" or "unstable" (dev); empty means same as current`) - fs.StringVar(&updateArgs.version, "version", "", `explicit version to update/downgrade to`) + // These flags are not supported on Arch-based installs. Arch only + // offers one variant of tailscale and it's always the latest version. + if distro.Get() != distro.Arch { + fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable" or "unstable" (dev); empty means same as current`) + fs.StringVar(&updateArgs.version, "version", "", `explicit version to update/downgrade to`) + } return fs })(), } @@ -139,6 +143,8 @@ func newUpdater() (*updater, error) { up.update = up.updateSynology case distro.Debian: // includes Ubuntu up.update = up.updateDebLike + case distro.Arch: + up.update = up.updateArch } case "darwin": switch { @@ -173,6 +179,8 @@ func (up *updater) currentOrDryRun(ver string) bool { 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) @@ -187,7 +195,7 @@ func (up *updater) confirm(ver string) error { case "y", "yes", "sure": return nil } - return errors.New("aborting update") + return errUserAborted } func (up *updater) updateSynology() error { @@ -326,6 +334,63 @@ 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") + } + + 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") +} + 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. diff --git a/cmd/tailscale/cli/update_test.go b/cmd/tailscale/cli/update_test.go index f99935dc0..5455bdd14 100644 --- a/cmd/tailscale/cli/update_test.go +++ b/cmd/tailscale/cli/update_test.go @@ -84,7 +84,7 @@ func TestParseSoftwareupdateList(t *testing.T) { name: "update-at-end-of-list", input: []byte(` Software Update Tool - + Finding available software Software Update found the following new or updated software: * Label: MacBookAirEFIUpdate2.4-2.4 @@ -100,7 +100,7 @@ func TestParseSoftwareupdateList(t *testing.T) { name: "update-in-middle-of-list", input: []byte(` Software Update Tool - + Finding available software Software Update found the following new or updated software: * Label: MacBookAirEFIUpdate2.4-2.4 @@ -116,7 +116,7 @@ func TestParseSoftwareupdateList(t *testing.T) { name: "update-not-in-list", input: []byte(` Software Update Tool - + Finding available software Software Update found the following new or updated software: * Label: MacBookAirEFIUpdate2.4-2.4 @@ -130,7 +130,7 @@ func TestParseSoftwareupdateList(t *testing.T) { name: "decoy-in-list", input: []byte(` Software Update Tool - + Finding available software Software Update found the following new or updated software: * Label: MacBookAirEFIUpdate2.4-2.4 @@ -151,3 +151,105 @@ func TestParseSoftwareupdateList(t *testing.T) { }) } } + +func TestParsePacmanVersion(t *testing.T) { + tests := []struct { + desc string + out string + want string + wantErr bool + }{ + { + desc: "valid version", + out: ` +:: Synchronizing package databases... + endeavouros is up to date + core is up to date + extra is up to date + multilib is up to date +Repository : extra +Name : tailscale +Version : 1.44.2-1 +Description : A mesh VPN that makes it easy to connect your devices, wherever they are. +Architecture : x86_64 +URL : https://tailscale.com +Licenses : MIT +Groups : None +Provides : None +Depends On : glibc +Optional Deps : None +Conflicts With : None +Replaces : None +Download Size : 7.98 MiB +Installed Size : 32.47 MiB +Packager : Christian Heusel +Build Date : Tue 18 Jul 2023 12:28:37 PM PDT +Validated By : MD5 Sum SHA-256 Sum Signature +`, + want: "1.44.2", + }, + { + desc: "version without Arch patch number", + out: ` +... snip ... +Name : tailscale +Version : 1.44.2 +Description : A mesh VPN that makes it easy to connect your devices, wherever they are. +... snip ... +`, + want: "1.44.2", + }, + { + desc: "missing version", + out: ` +... snip ... +Name : tailscale +Description : A mesh VPN that makes it easy to connect your devices, wherever they are. +... snip ... +`, + wantErr: true, + }, + { + desc: "empty version", + out: ` +... snip ... +Name : tailscale +Version : +Description : A mesh VPN that makes it easy to connect your devices, wherever they are. +... snip ... +`, + wantErr: true, + }, + { + desc: "empty input", + out: "", + wantErr: true, + }, + { + desc: "sneaky version in description", + out: ` +... snip ... +Name : tailscale +Description : A mesh VPN that makes it easy to connect your devices, wherever they are. Version : 1.2.3 +Version : 1.44.2 +... snip ... +`, + want: "1.44.2", + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got, err := parsePacmanVersion([]byte(tt.out)) + if err == nil && tt.wantErr { + t.Fatalf("got nil error and version %q, want non-nil error", got) + } + if err != nil && !tt.wantErr { + t.Fatalf("got error: %q, want nil", err) + } + if got != tt.want { + t.Fatalf("got version: %q, want %q", got, tt.want) + } + }) + } +}