From 3f6b0d8c84e4bde79400d2c26c0d96d25b823ac6 Mon Sep 17 00:00:00 2001 From: Chris Palmer Date: Wed, 19 Jul 2023 17:06:16 -0700 Subject: [PATCH] cmd/tailscale/cli: make `tailscale update` query `softwareupdate` (#8641) * cmd/tailscale/cli: make `tailscale update` query `softwareupdate` Even on macOS when Tailscale was installed via the App Store, we can check for and even install new versions if people ask explicitly. Also, warn if App Store AutoUpdate is not turned on. Updates #6995 --- cmd/tailscale/cli/update.go | 69 +++++++++++++++++++++++++--- cmd/tailscale/cli/update_test.go | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 7 deletions(-) diff --git a/cmd/tailscale/cli/update.go b/cmd/tailscale/cli/update.go index e295314d7..4d9bd9c72 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 434188274..f99935dc0 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) + } + }) + } +}