Files
tailscale/cmd/tailscale/cli/update.go
T
Brad Fitzpatrick fad8b9b8a9 clientupdate, cmd/tailscale: verify signed GAFs, wire up tailscale update for Gokrazy
Builds on top of the unsigned URL-based GAF update flow added previously
(see referenced issue for context). The pkgs.tailscale.com server now
publishes signed GAFs for the unstable track, with detached ed25519
signatures produced by pkgsign's signdist path (the same distsign scheme
used for every other release artifact). This change consumes them.

The URL-based path (tailscale update --gokrazy-update-from-url=URL) now
verifies the signature by default using clientupdate/distsign.Client,
which fetches distsign.pub from the root of the host serving the GAF and
checks the .sig against the root keys embedded in this binary. The
--unsigned flag stays for TestGokrazyUpdatesItselfToSameImage, whose
in-test fileserver does not publish distsign.pub.

The bare tailscale update path is now wired up for the Tailscale
appliance image. It fetches <pkgs>/<track>/?mode=json, picks the GAF
whose key matches the local device (vm-amd64, vm-arm64, or pi-arm64,
where arm64 is split via /sys/firmware/devicetree/base/model), confirms
the version with the user, and reuses the verified download path above.

To avoid wiping a user's custom Gokrazy build that happens to include
tailscaled, the bare update path is gated on hostinfo.Package == "tsapp",
which is only set when the new ts_appliance build tag is present
(mirroring the existing ts_package_container tag). The
gokrazy/tsapp*/config.json files now pass GoBuildTags ["ts_appliance"]
for the tailscale and tailscaled packages so monogok bakes the tag into
the official appliance builds. The TS_FORCE_ALLOW_TSAPP_UPDATE env var
is an escape hatch for callers who want to force the appliance update
path on a non-appliance build. The URL-based path stays ungated since it
requires explicit user intent (and is exercised by the natlab vmtest).

Updates #20002

Change-Id: I7c7856a88bf3dffb9eb8d3e9111fad0b3906743c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-30 07:09:25 -07:00

150 lines
4.6 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_clientupdate
package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"runtime"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/clientupdate"
"tailscale.com/util/prompt"
"tailscale.com/version"
"tailscale.com/version/distro"
)
func init() {
maybeUpdateCmd = func() *ffcli.Command { return updateCmd }
clientupdateLatestTailscaleVersion.Set(func(track string) (string, error) {
if track == "" {
return clientupdate.LatestTailscaleVersion(clientupdate.CurrentTrack)
}
return clientupdate.LatestTailscaleVersion(track)
})
}
var updateCmd = &ffcli.Command{
Name: "update",
ShortUsage: "tailscale update",
ShortHelp: "Update Tailscale to the latest/different version",
Exec: runUpdate,
FlagSet: (func() *flag.FlagSet {
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")
// These flags are not supported on several systems that only provide
// the latest version of Tailscale:
//
// - Arch (and other pacman-based distros)
// - Alpine (and other apk-based distros)
// - FreeBSD (and other pkg-based distros)
// - Unraid/QNAP/Synology
// - macOS
if distro.Get() != distro.Arch &&
distro.Get() != distro.Alpine &&
distro.Get() != distro.QNAP &&
distro.Get() != distro.Synology &&
runtime.GOOS != "freebsd" &&
runtime.GOOS != "darwin" {
fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable", "release-candidate", or "unstable" (dev); empty means same as current`)
fs.StringVar(&updateArgs.version, "version", "", `explicit version to update/downgrade to`)
}
return fs
})(),
}
var updateArgs struct {
yes bool
dryRun bool
track string // explicit track; empty means same as current
version string // explicit version; empty means auto
}
const gokrazyUpdateFromURLMagicArg = "--gokrazy-update-from-url"
func runUpdate(ctx context.Context, args []string) error {
if len(args) > 0 {
if runtime.GOOS == "linux" && distro.Get() == distro.Gokrazy {
gokArgs, err := gokrazyUpdateArgsFromMagicArg(args)
if err != nil {
return err
}
if gokArgs != nil {
return clientupdate.GokrazyUpdateFromURL.Get()(ctx, *gokArgs)
}
}
return flag.ErrHelp
}
if updateArgs.version != "" && updateArgs.track != "" {
return errors.New("cannot specify both --version and --track")
}
err := clientupdate.Update(clientupdate.Arguments{
Version: updateArgs.version,
Track: updateArgs.track,
Logf: func(f string, a ...any) { printf(f+"\n", a...) },
Stdout: Stdout,
Stderr: Stderr,
Confirm: confirmUpdate,
})
if errors.Is(err, errors.ErrUnsupported) {
return errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates")
}
return err
}
func confirmUpdate(ver string) bool {
if updateArgs.yes {
fmt.Printf("Updating Tailscale from %v to %v; --yes given, continuing without prompts.\n", version.Short(), ver)
return true
}
if updateArgs.dryRun {
fmt.Printf("Current: %v, Latest: %v\n", version.Short(), ver)
return false
}
msg := fmt.Sprintf("This will update Tailscale from %v to %v. Continue?", version.Short(), ver)
return prompt.YesNo(msg, true)
}
// gokrazyUpdateArgsFromMagicArg parses the Gokrazy update-from-URL command-line
// flow. It returns nil if args do not select that flow. A non-nil result means
// the caller may safely invoke clientupdate.GokrazyUpdateFromURL.
func gokrazyUpdateArgsFromMagicArg(args []string) (*clientupdate.GokrazyUpdateArgs, error) {
var updateURL string
var unsigned bool
fs := flag.NewFlagSet("gokrazy-update", flag.ContinueOnError)
fs.SetOutput(io.Discard)
// This flag path is exercised end-to-end by TestGokrazyUpdatesItselfToSameImage.
fs.StringVar(&updateURL, gokrazyUpdateFromURLMagicArg[2:], "", "URL of the Gokrazy archive format file to install")
fs.BoolVar(&unsigned, "unsigned", false, "skip GAF signature verification; for tests only")
if err := fs.Parse(args); err != nil {
return nil, err
}
if fs.NArg() != 0 {
return nil, nil
}
if updateURL == "" {
return nil, nil
}
if !clientupdate.GokrazyUpdateFromURL.IsSet() {
return nil, errors.New("gokrazy update support is not linked into this binary")
}
return &clientupdate.GokrazyUpdateArgs{
URL: updateURL,
AllowUnsigned: unsigned,
Logf: func(format string, args ...any) {
printf(format+"\n", args...)
},
}, nil
}