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" +}