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:
committed by
Brad Fitzpatrick
parent
66af25733c
commit
fad8b9b8a9
@@ -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
|
||||
|
||||
@@ -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 "<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 {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -24,7 +24,15 @@
|
||||
"tailscale.com/cmd/tailscale": {
|
||||
"ExtraFilePaths": {
|
||||
"/usr": "usr-dir"
|
||||
}
|
||||
},
|
||||
"GoBuildTags": [
|
||||
"ts_appliance"
|
||||
]
|
||||
},
|
||||
"tailscale.com/cmd/tailscaled": {
|
||||
"GoBuildTags": [
|
||||
"ts_appliance"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Environment": [
|
||||
|
||||
@@ -24,7 +24,15 @@
|
||||
"tailscale.com/cmd/tailscale": {
|
||||
"ExtraFilePaths": {
|
||||
"/usr": "usr-dir"
|
||||
}
|
||||
},
|
||||
"GoBuildTags": [
|
||||
"ts_appliance"
|
||||
]
|
||||
},
|
||||
"tailscale.com/cmd/tailscaled": {
|
||||
"GoBuildTags": [
|
||||
"ts_appliance"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Environment": [
|
||||
|
||||
@@ -24,7 +24,15 @@
|
||||
"tailscale.com/cmd/tailscale": {
|
||||
"ExtraFilePaths": {
|
||||
"/usr": "usr-dir"
|
||||
}
|
||||
},
|
||||
"GoBuildTags": [
|
||||
"ts_appliance"
|
||||
]
|
||||
},
|
||||
"tailscale.com/cmd/tailscaled": {
|
||||
"GoBuildTags": [
|
||||
"ts_appliance"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Environment": [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user