From fad8b9b8a957c3d4d81d1d9f61c09090dd8d0ba3 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Mon, 29 Jun 2026 20:34:10 +0000 Subject: [PATCH] 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 //?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 --- clientupdate/clientupdate.go | 71 +++++++++++++++++++++- clientupdate/clientupdate_gokrazy.go | 88 ++++++++++++++++++++-------- cmd/tailscale/cli/update.go | 5 +- gokrazy/tsapp-pi.arm64/config.json | 10 +++- gokrazy/tsapp-vm.arm64/config.json | 10 +++- gokrazy/tsapp/config.json | 10 +++- hostinfo/packagetype_appliance.go | 10 ++++ 7 files changed, 171 insertions(+), 33 deletions(-) create mode 100644 hostinfo/packagetype_appliance.go diff --git a/clientupdate/clientupdate.go b/clientupdate/clientupdate.go index 020aab1e9..cb9ddb3aa 100644 --- a/clientupdate/clientupdate.go +++ b/clientupdate/clientupdate.go @@ -45,7 +45,8 @@ type GokrazyUpdateArgs struct { URL string // AllowUnsigned permits installing a GAF without signature verification. - // This is intended for tests until signed GAF verification is implemented. + // It is intended for tests that serve a GAF from a fileserver that does + // not publish distsign.pub. AllowUnsigned bool // Logf is optional; nil discards log messages. @@ -216,6 +217,17 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) { // release cadence with Synology Package Center and use their // auto-update mechanism. return up.updateSynology, false + case distro.Gokrazy: + // Only the official Tailscale appliance image (built with the + // ts_appliance build tag, which causes hostinfo to report + // Package="tsapp") is auto-updatable. A user running a custom + // Gokrazy build that happens to include tailscaled must not be + // updated with our stock GAFs. TS_FORCE_ALLOW_TSAPP_UPDATE is an + // escape hatch for callers who know what they're doing. + if hi.Package != "tsapp" && !envknob.Bool("TS_FORCE_ALLOW_TSAPP_UPDATE") { + return nil, false + } + return up.updateGokrazy, true case distro.Debian: // includes Ubuntu return up.updateDebLike, true case distro.Arch: @@ -883,6 +895,56 @@ func (up *Updater) updateFreeBSD() (err error) { return nil } +// updateGokrazy fetches the latest signed GAF for this gokrazy device variant +// (vm-amd64, vm-arm64, or pi-arm64) from up.PkgsAddr and applies it via the +// local gokrazy init update API. +func (up *Updater) updateGokrazy() error { + if !GokrazyUpdateFromURL.IsSet() { + return errors.New("gokrazy update support is not linked into this binary") + } + variant, err := gokrazyDeviceVariant() + if err != nil { + return err + } + latest, err := latestPackages(up.Track) + if err != nil { + return err + } + gafName, ok := latest.GAFs[variant] + if !ok { + return fmt.Errorf("no GAF for device %q on %q track", variant, up.Track) + } + if latest.GAFsVersion == "" { + return fmt.Errorf("no GAF version on %q track", up.Track) + } + if !up.confirm(latest.GAFsVersion) { + return nil + } + gafURL := fmt.Sprintf("%s/%s/%s", strings.TrimRight(up.PkgsAddr, "/"), up.Track, gafName) + up.Logf("Updating to %s (%s)", latest.GAFsVersion, gafURL) + return GokrazyUpdateFromURL.Get()(context.Background(), GokrazyUpdateArgs{ + URL: gafURL, + Logf: up.Logf, + }) +} + +// gokrazyDeviceVariant returns the GAFs JSON key for the current gokrazy +// device, e.g. "vm-amd64", "vm-arm64", or "pi-arm64". On arm64, it reads the +// device-tree model to tell a Raspberry Pi apart from a VM. +func gokrazyDeviceVariant() (string, error) { + switch runtime.GOARCH { + case "amd64": + return "vm-amd64", nil + case "arm64": + b, _ := os.ReadFile("/sys/firmware/devicetree/base/model") + if strings.HasPrefix(strings.Trim(string(b), "\x00\r\n\t "), "Raspberry Pi") { + return "pi-arm64", nil + } + return "vm-arm64", nil + } + return "", fmt.Errorf("unsupported gokrazy GOARCH %q", runtime.GOARCH) +} + func (up *Updater) updateLinuxBinary() error { // Root is needed to overwrite binaries and restart systemd unit. if err := requireRoot(); err != nil { @@ -1255,8 +1317,11 @@ func LatestTailscaleVersion(track string) (string, error) { ver = latest.MacZipsVersion case "linux": ver = latest.TarballsVersion - if distro.Get() == distro.Synology { + switch distro.Get() { + case distro.Synology: ver = latest.SPKsVersion + case distro.Gokrazy: + ver = latest.GAFsVersion } } @@ -1274,6 +1339,8 @@ type trackPackages struct { ExesVersion string MSIs map[string]string MSIsVersion string + GAFs map[string]string + GAFsVersion string MacZips map[string]string MacZipsVersion string SPKs map[string]map[string]string diff --git a/clientupdate/clientupdate_gokrazy.go b/clientupdate/clientupdate_gokrazy.go index d2f2dabd4..fb497d701 100644 --- a/clientupdate/clientupdate_gokrazy.go +++ b/clientupdate/clientupdate_gokrazy.go @@ -13,9 +13,11 @@ import ( "io" "net" "net/http" + "net/url" "os" "strings" + "tailscale.com/clientupdate/distsign" "tailscale.com/types/logger" ) @@ -40,38 +42,23 @@ func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error { if logf == nil { logf = logger.Discard } - if !args.AllowUnsigned { - return fmt.Errorf("signed GAF verification is not implemented yet; see https://github.com/tailscale/tailscale/issues/20002") - } tmp, err := os.CreateTemp("", "tailscale-gokrazy-*.gaf") if err != nil { return err } tmpName := tmp.Name() + tmp.Close() defer os.Remove(tmpName) - req, err := http.NewRequestWithContext(ctx, "GET", args.URL, nil) - if err != nil { - tmp.Close() - return err - } - res, err := http.DefaultClient.Do(req) - if err != nil { - tmp.Close() - return err - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - tmp.Close() - return fmt.Errorf("download GAF: %s", res.Status) - } - if _, err := io.Copy(tmp, res.Body); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err + if args.AllowUnsigned { + if err := downloadGAFUnverified(ctx, args.URL, tmpName); err != nil { + return err + } + } else { + if err := downloadGAFVerified(ctx, logf, args.URL, tmpName); err != nil { + return err + } } zr, err := zip.OpenReader(tmpName) @@ -105,6 +92,59 @@ func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error { return nil } +// downloadGAFUnverified saves the GAF at srcURL to dstPath without verifying a +// signature. It is used only when args.AllowUnsigned is set, for tests that +// serve the GAF from a fileserver that does not publish distsign.pub. +func downloadGAFUnverified(ctx context.Context, srcURL, dstPath string) error { + req, err := http.NewRequestWithContext(ctx, "GET", srcURL, nil) + if err != nil { + return err + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("download GAF: %s", res.Status) + } + f, err := os.Create(dstPath) + if err != nil { + return err + } + if _, err := io.Copy(f, res.Body); err != nil { + f.Close() + return err + } + return f.Close() +} + +// downloadGAFVerified saves the GAF at srcURL to dstPath, verifying the +// detached ed25519 signature at ".sig" against the root signing keys +// embedded in this binary via the distsign package. +// +// The signing-key bundle distsign.pub and its signature distsign.pub.sig are +// fetched from the root of the server hosting srcURL. +func downloadGAFVerified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error { + u, err := url.Parse(srcURL) + if err != nil { + return fmt.Errorf("parsing GAF URL %q: %w", srcURL, err) + } + if u.Scheme == "" || u.Host == "" { + return fmt.Errorf("GAF URL %q is missing scheme or host", srcURL) + } + base := &url.URL{Scheme: u.Scheme, User: u.User, Host: u.Host} + path := strings.TrimPrefix(u.Path, "/") + if path == "" { + return fmt.Errorf("GAF URL %q has no path component", srcURL) + } + c, err := distsign.NewClient(logf, base.String()) + if err != nil { + return err + } + return c.Download(ctx, path, dstPath) +} + func gokrazyHTTPClient() *http.Client { tr := http.DefaultTransport.(*http.Transport).Clone() tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { diff --git a/cmd/tailscale/cli/update.go b/cmd/tailscale/cli/update.go index 9f2a60896..39cdfe6c3 100644 --- a/cmd/tailscale/cli/update.go +++ b/cmd/tailscale/cli/update.go @@ -126,7 +126,7 @@ func gokrazyUpdateArgsFromMagicArg(args []string) (*clientupdate.GokrazyUpdateAr 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, "allow an unsigned GAF; for tests only") + fs.BoolVar(&unsigned, "unsigned", false, "skip GAF signature verification; for tests only") if err := fs.Parse(args); err != nil { return nil, err } @@ -136,9 +136,6 @@ func gokrazyUpdateArgsFromMagicArg(args []string) (*clientupdate.GokrazyUpdateAr if updateURL == "" { return nil, nil } - if !unsigned { - return nil, errors.New("signed GAF verification is not implemented yet; see https://github.com/tailscale/tailscale/issues/20002; pass --unsigned for test updates") - } if !clientupdate.GokrazyUpdateFromURL.IsSet() { return nil, errors.New("gokrazy update support is not linked into this binary") } diff --git a/gokrazy/tsapp-pi.arm64/config.json b/gokrazy/tsapp-pi.arm64/config.json index 206bb2f34..6a987c9d2 100644 --- a/gokrazy/tsapp-pi.arm64/config.json +++ b/gokrazy/tsapp-pi.arm64/config.json @@ -24,7 +24,15 @@ "tailscale.com/cmd/tailscale": { "ExtraFilePaths": { "/usr": "usr-dir" - } + }, + "GoBuildTags": [ + "ts_appliance" + ] + }, + "tailscale.com/cmd/tailscaled": { + "GoBuildTags": [ + "ts_appliance" + ] } }, "Environment": [ diff --git a/gokrazy/tsapp-vm.arm64/config.json b/gokrazy/tsapp-vm.arm64/config.json index 4cb3df2d8..16f2005de 100644 --- a/gokrazy/tsapp-vm.arm64/config.json +++ b/gokrazy/tsapp-vm.arm64/config.json @@ -24,7 +24,15 @@ "tailscale.com/cmd/tailscale": { "ExtraFilePaths": { "/usr": "usr-dir" - } + }, + "GoBuildTags": [ + "ts_appliance" + ] + }, + "tailscale.com/cmd/tailscaled": { + "GoBuildTags": [ + "ts_appliance" + ] } }, "Environment": [ diff --git a/gokrazy/tsapp/config.json b/gokrazy/tsapp/config.json index 15533afd1..21e610336 100644 --- a/gokrazy/tsapp/config.json +++ b/gokrazy/tsapp/config.json @@ -24,7 +24,15 @@ "tailscale.com/cmd/tailscale": { "ExtraFilePaths": { "/usr": "usr-dir" - } + }, + "GoBuildTags": [ + "ts_appliance" + ] + }, + "tailscale.com/cmd/tailscaled": { + "GoBuildTags": [ + "ts_appliance" + ] } }, "Environment": [ diff --git a/hostinfo/packagetype_appliance.go b/hostinfo/packagetype_appliance.go new file mode 100644 index 000000000..997a064ec --- /dev/null +++ b/hostinfo/packagetype_appliance.go @@ -0,0 +1,10 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && ts_appliance + +package hostinfo + +func init() { + linuxBuildTagPackageType = "tsapp" +}