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>
This commit is contained in:
Brad Fitzpatrick
2026-06-30 07:09:25 -07:00
committed by Brad Fitzpatrick
parent 66af25733c
commit fad8b9b8a9
7 changed files with 171 additions and 33 deletions
+69 -2
View File
@@ -45,7 +45,8 @@ type GokrazyUpdateArgs struct {
URL string URL string
// AllowUnsigned permits installing a GAF without signature verification. // 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 AllowUnsigned bool
// Logf is optional; nil discards log messages. // 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 // release cadence with Synology Package Center and use their
// auto-update mechanism. // auto-update mechanism.
return up.updateSynology, false 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 case distro.Debian: // includes Ubuntu
return up.updateDebLike, true return up.updateDebLike, true
case distro.Arch: case distro.Arch:
@@ -883,6 +895,56 @@ func (up *Updater) updateFreeBSD() (err error) {
return nil 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 { func (up *Updater) updateLinuxBinary() error {
// Root is needed to overwrite binaries and restart systemd unit. // Root is needed to overwrite binaries and restart systemd unit.
if err := requireRoot(); err != nil { if err := requireRoot(); err != nil {
@@ -1255,8 +1317,11 @@ func LatestTailscaleVersion(track string) (string, error) {
ver = latest.MacZipsVersion ver = latest.MacZipsVersion
case "linux": case "linux":
ver = latest.TarballsVersion ver = latest.TarballsVersion
if distro.Get() == distro.Synology { switch distro.Get() {
case distro.Synology:
ver = latest.SPKsVersion ver = latest.SPKsVersion
case distro.Gokrazy:
ver = latest.GAFsVersion
} }
} }
@@ -1274,6 +1339,8 @@ type trackPackages struct {
ExesVersion string ExesVersion string
MSIs map[string]string MSIs map[string]string
MSIsVersion string MSIsVersion string
GAFs map[string]string
GAFsVersion string
MacZips map[string]string MacZips map[string]string
MacZipsVersion string MacZipsVersion string
SPKs map[string]map[string]string SPKs map[string]map[string]string
+64 -24
View File
@@ -13,9 +13,11 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
"tailscale.com/clientupdate/distsign"
"tailscale.com/types/logger" "tailscale.com/types/logger"
) )
@@ -40,38 +42,23 @@ func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error {
if logf == nil { if logf == nil {
logf = logger.Discard 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") tmp, err := os.CreateTemp("", "tailscale-gokrazy-*.gaf")
if err != nil { if err != nil {
return err return err
} }
tmpName := tmp.Name() tmpName := tmp.Name()
tmp.Close()
defer os.Remove(tmpName) defer os.Remove(tmpName)
req, err := http.NewRequestWithContext(ctx, "GET", args.URL, nil) if args.AllowUnsigned {
if err != nil { if err := downloadGAFUnverified(ctx, args.URL, tmpName); err != nil {
tmp.Close() return err
return err }
} } else {
res, err := http.DefaultClient.Do(req) if err := downloadGAFVerified(ctx, logf, args.URL, tmpName); err != nil {
if err != nil { return err
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
} }
zr, err := zip.OpenReader(tmpName) zr, err := zip.OpenReader(tmpName)
@@ -105,6 +92,59 @@ func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error {
return nil 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 "<srcURL>.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 { func gokrazyHTTPClient() *http.Client {
tr := http.DefaultTransport.(*http.Transport).Clone() tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
+1 -4
View File
@@ -126,7 +126,7 @@ func gokrazyUpdateArgsFromMagicArg(args []string) (*clientupdate.GokrazyUpdateAr
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
// This flag path is exercised end-to-end by TestGokrazyUpdatesItselfToSameImage. // 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.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 { if err := fs.Parse(args); err != nil {
return nil, err return nil, err
} }
@@ -136,9 +136,6 @@ func gokrazyUpdateArgsFromMagicArg(args []string) (*clientupdate.GokrazyUpdateAr
if updateURL == "" { if updateURL == "" {
return nil, nil 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() { if !clientupdate.GokrazyUpdateFromURL.IsSet() {
return nil, errors.New("gokrazy update support is not linked into this binary") return nil, errors.New("gokrazy update support is not linked into this binary")
} }
+9 -1
View File
@@ -24,7 +24,15 @@
"tailscale.com/cmd/tailscale": { "tailscale.com/cmd/tailscale": {
"ExtraFilePaths": { "ExtraFilePaths": {
"/usr": "usr-dir" "/usr": "usr-dir"
} },
"GoBuildTags": [
"ts_appliance"
]
},
"tailscale.com/cmd/tailscaled": {
"GoBuildTags": [
"ts_appliance"
]
} }
}, },
"Environment": [ "Environment": [
+9 -1
View File
@@ -24,7 +24,15 @@
"tailscale.com/cmd/tailscale": { "tailscale.com/cmd/tailscale": {
"ExtraFilePaths": { "ExtraFilePaths": {
"/usr": "usr-dir" "/usr": "usr-dir"
} },
"GoBuildTags": [
"ts_appliance"
]
},
"tailscale.com/cmd/tailscaled": {
"GoBuildTags": [
"ts_appliance"
]
} }
}, },
"Environment": [ "Environment": [
+9 -1
View File
@@ -24,7 +24,15 @@
"tailscale.com/cmd/tailscale": { "tailscale.com/cmd/tailscale": {
"ExtraFilePaths": { "ExtraFilePaths": {
"/usr": "usr-dir" "/usr": "usr-dir"
} },
"GoBuildTags": [
"ts_appliance"
]
},
"tailscale.com/cmd/tailscaled": {
"GoBuildTags": [
"ts_appliance"
]
} }
}, },
"Environment": [ "Environment": [
+10
View File
@@ -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"
}