diff --git a/cmd/tailscale/cli/update.go b/cmd/tailscale/cli/update.go index e295314d7a016..4d9bd9c72a386 100644 --- a/cmd/tailscale/cli/update.go +++ b/cmd/tailscale/cli/update.go @@ -44,6 +44,7 @@ var updateCmd = &ffcli.Command{ fs := newFlagSet("update") 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`) return fs @@ -51,10 +52,11 @@ var updateCmd = &ffcli.Command{ } var updateArgs struct { - yes bool - dryRun bool - track string // explicit track; empty means same as current - version string // explicit version; empty means auto + yes bool + dryRun bool + appStore bool + track string // explicit track; empty means same as current + version string // explicit version; empty means auto } // winMSIEnv is the environment variable that, if set, is the MSI file for the @@ -140,12 +142,12 @@ func newUpdater() (*updater, error) { } case "darwin": switch { - case !version.IsSandboxedMacOS(): + 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 strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"): + case !updateArgs.appStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"): up.update = up.updateMacSys default: - return nil, errors.New("This is the macOS App Store version of Tailscale; update in the App Store, or see https://tailscale.com/s/unstable-clients to use TestFlight or to install the non-App Store version") + up.update = up.updateMacAppStore } } if up.update == nil { @@ -333,6 +335,59 @@ func (up *updater) updateMacSys() error { 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 diff --git a/cmd/tailscale/cli/update_test.go b/cmd/tailscale/cli/update_test.go index 434188274bc3c..f99935dc04835 100644 --- a/cmd/tailscale/cli/update_test.go +++ b/cmd/tailscale/cli/update_test.go @@ -73,3 +73,81 @@ func TestUpdateDebianAptSourcesListBytes(t *testing.T) { }) } } + +func TestParseSoftwareupdateList(t *testing.T) { + tests := []struct { + name string + input []byte + want string + }{ + { + 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 + Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart, + * Label: ProAppsQTCodecs-1.0 + Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES, + * Label: Tailscale-1.23.4 + Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES, +`), + want: "Tailscale-1.23.4", + }, + { + 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 + Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart, + * Label: Tailscale-1.23.5000 + Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES, + * Label: ProAppsQTCodecs-1.0 + Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES, +`), + want: "Tailscale-1.23.5000", + }, + { + 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 + Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart, + * Label: ProAppsQTCodecs-1.0 + Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES, +`), + want: "", + }, + { + 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 + Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart, + * Label: Malware-1.0 + Title: * Label: Tailscale-0.99.0, Version: 1.0, Size: 968K, Recommended: NOT REALLY TBH, +`), + want: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := parseSoftwareupdateList(test.input) + if test.want != got { + t.Fatalf("got %q, want %q", got, test.want) + } + }) + } +}