From 772be1b0cc70fe180e4c6b1553320e0a2ed580ac Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Thu, 4 Jun 2026 15:24:17 +0000 Subject: [PATCH] gokrazy, clientupdate: add start of Gokrazy auto-updates, tests This adds support for Gokrazy GAF (Gokrazy Archive Format) zip auto-updates, starting to wire up Tailscale's clientupdate mechanism to Gokrazy's update mechanism. Currently there's just a CLI command to update from a GAF URL, with an --unsigned flag for use in a new natlab vmtest. Next step would be publishing unstable track GAF files on pkgs.tailscale.com, with detached signatures, and then making the clientupdate mechanism also download those and check signatures. Updates #20002 Change-Id: Ib03c56f17a57f8a4638398ef83549dac4813323d Signed-off-by: Brad Fitzpatrick --- clientupdate/clientupdate.go | 19 +++ clientupdate/clientupdate_gokrazy.go | 177 ++++++++++++++++++++ cmd/tailscale/cli/update.go | 48 ++++++ cmd/tailscale/depaware.txt | 1 + cmd/tailscaled/depaware.txt | 1 + cmd/tta/tta.go | 18 ++ flake.nix | 2 +- flakehashes.json | 4 +- go.mod | 4 +- go.sum | 8 +- gokrazy/Makefile | 3 + gokrazy/build.go | 38 +++-- shell.nix | 2 +- tstest/natlab/vmtest/gokrazy_update_test.go | 118 +++++++++++++ tstest/natlab/vmtest/vmtest.go | 53 ++++++ 15 files changed, 474 insertions(+), 22 deletions(-) create mode 100644 clientupdate/clientupdate_gokrazy.go create mode 100644 tstest/natlab/vmtest/gokrazy_update_test.go diff --git a/clientupdate/clientupdate.go b/clientupdate/clientupdate.go index 6d034b342..020aab1e9 100644 --- a/clientupdate/clientupdate.go +++ b/clientupdate/clientupdate.go @@ -11,6 +11,7 @@ import ( "bufio" "bytes" "compress/gzip" + "context" "encoding/json" "errors" "fmt" @@ -37,6 +38,24 @@ import ( "tailscale.com/version/distro" ) +// GokrazyUpdateArgs contains arguments for updating a Gokrazy appliance from a +// GAF fetched from a URL. +type GokrazyUpdateArgs struct { + // URL is the GAF download URL. + URL string + + // AllowUnsigned permits installing a GAF without signature verification. + // This is intended for tests until signed GAF verification is implemented. + AllowUnsigned bool + + // Logf is optional; nil discards log messages. + Logf logger.Logf +} + +// GokrazyUpdateFromURL updates a Gokrazy appliance from a GAF fetched from a +// URL, if Gokrazy update support is linked into the binary. +var GokrazyUpdateFromURL feature.Hook[func(context.Context, GokrazyUpdateArgs) error] + const ( StableTrack = "stable" UnstableTrack = "unstable" diff --git a/clientupdate/clientupdate_gokrazy.go b/clientupdate/clientupdate_gokrazy.go new file mode 100644 index 000000000..d2f2dabd4 --- /dev/null +++ b/clientupdate/clientupdate_gokrazy.go @@ -0,0 +1,177 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux + +package clientupdate + +import ( + "archive/zip" + "context" + "fmt" + "hash/crc32" + "io" + "net" + "net/http" + "os" + "strings" + + "tailscale.com/types/logger" +) + +const ( + gokrazyUpdateSocket = "/run/gokrazy-http.sock" + gokrazyUpdateBaseURL = "http://gokrazy-local-unixsock" +) + +// GokrazyUpdateFromURL downloads a Gokrazy archive format file from args.URL, +// installs its partitions using the local gokrazy init update API, switches to +// the new root partition, and asks gokrazy to reboot. +// +// The local gokrazy API is reached over gokrazyUpdateSocket. The +// gokrazyUpdateBaseURL host is only a net/http URL sentinel; it is not resolved +// with DNS. +func init() { + GokrazyUpdateFromURL.Set(gokrazyUpdateFromURL) +} + +func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error { + logf := args.Logf + 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() + 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 + } + + zr, err := zip.OpenReader(tmpName) + if err != nil { + return err + } + defer zr.Close() + + gokClient := gokrazyHTTPClient() + for _, part := range []struct { + name string + path string + }{ + {"root.img", "/update/root"}, + {"boot.img", "/update/boot"}, + {"mbr.img", "/update/mbr"}, + } { + if err := putGokrazyGAFMember(ctx, gokClient, zr.File, part.name, part.path); err != nil { + return err + } + logf("wrote %s", part.name) + } + if err := postGokrazy(ctx, gokClient, "/update/switch"); err != nil { + return err + } + logf("switched boot target") + if err := postGokrazy(ctx, gokClient, "/reboot?async=true&kexec_merge_cmdline=true"); err != nil { + return err + } + logf("reboot requested") + return nil +} + +func gokrazyHTTPClient() *http.Client { + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", gokrazyUpdateSocket) + } + return &http.Client{ + Transport: tr, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +func putGokrazyGAFMember(ctx context.Context, hc *http.Client, files []*zip.File, name, path string) error { + var zf *zip.File + for _, f := range files { + if f.Name == name { + zf = f + break + } + } + if zf == nil { + return fmt.Errorf("GAF is missing %s", name) + } + rc, err := zf.Open() + if err != nil { + return err + } + defer rc.Close() + + h := crc32.NewIEEE() + body := io.TeeReader(rc, h) + req, err := http.NewRequestWithContext(ctx, "PUT", gokrazyUpdateBaseURL+path, body) + if err != nil { + return err + } + req.ContentLength = int64(zf.UncompressedSize64) + req.Header.Set("X-Gokrazy-Update-Hash", "crc32") + res, err := hc.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + resBody, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if res.StatusCode != http.StatusOK { + return fmt.Errorf("PUT %s: %s: %s", path, res.Status, strings.TrimSpace(string(resBody))) + } + if got, want := strings.TrimSpace(string(resBody)), fmt.Sprintf("%08x", h.Sum32()); got != want { + return fmt.Errorf("PUT %s: gokrazy checksum = %q; want %q", path, got, want) + } + return nil +} + +func postGokrazy(ctx context.Context, hc *http.Client, path string) error { + req, err := http.NewRequestWithContext(ctx, "POST", gokrazyUpdateBaseURL+path, nil) + if err != nil { + return err + } + res, err := hc.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + return fmt.Errorf("POST %s: %s: %s", path, res.Status, strings.TrimSpace(string(body))) + } + return nil +} diff --git a/cmd/tailscale/cli/update.go b/cmd/tailscale/cli/update.go index 47177347d..9f2a60896 100644 --- a/cmd/tailscale/cli/update.go +++ b/cmd/tailscale/cli/update.go @@ -10,6 +10,7 @@ import ( "errors" "flag" "fmt" + "io" "runtime" "github.com/peterbourgon/ff/v3/ffcli" @@ -67,8 +68,19 @@ var updateArgs struct { 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 != "" { @@ -102,3 +114,39 @@ func confirmUpdate(ver string) bool { 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, "allow an unsigned GAF; 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 !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") + } + return &clientupdate.GokrazyUpdateArgs{ + URL: updateURL, + AllowUnsigned: unsigned, + Logf: func(format string, args ...any) { + printf(format+"\n", args...) + }, + }, nil +} diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index a12b12f4b..35320170f 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -372,6 +372,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep vendor/golang.org/x/text/unicode/bidi from vendor/golang.org/x/net/idna+ vendor/golang.org/x/text/unicode/norm from vendor/golang.org/x/net/idna archive/tar from tailscale.com/clientupdate + L archive/zip from tailscale.com/clientupdate bufio from compress/flate+ bytes from archive/tar+ cmp from slices+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index b17ee802b..3499a8736 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -572,6 +572,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de vendor/golang.org/x/text/unicode/bidi from vendor/golang.org/x/net/idna+ vendor/golang.org/x/text/unicode/norm from vendor/golang.org/x/net/idna archive/tar from tailscale.com/clientupdate + L archive/zip from tailscale.com/clientupdate bufio from compress/flate+ bytes from archive/tar+ cmp from slices+ diff --git a/cmd/tta/tta.go b/cmd/tta/tta.go index 5dd1eddb9..bbc6dbcda 100644 --- a/cmd/tta/tta.go +++ b/cmd/tta/tta.go @@ -213,6 +213,24 @@ func main() { } serveCmd(w, "tailscale", args...) }) + ttaMux.HandleFunc("/tailscale", func(w http.ResponseWriter, r *http.Request) { + serveCmd(w, "tailscale", r.URL.Query()["arg"]...) + }) + ttaMux.HandleFunc("/gokrazy-root", func(w http.ResponseWriter, r *http.Request) { + cmdLine, err := os.ReadFile("/proc/cmdline") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + for s := range strings.FieldsSeq(string(cmdLine)) { + if root, ok := strings.CutPrefix(s, "root="); ok { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + io.WriteString(w, root+"\n") + return + } + } + http.Error(w, "no root= in /proc/cmdline", http.StatusInternalServerError) + }) ttaMux.HandleFunc("/ip", func(w http.ResponseWriter, r *http.Request) { conn, ok := r.Context().Value(connContextKey).(net.Conn) if !ok { diff --git a/flake.nix b/flake.nix index 1b4659914..59ef2e061 100644 --- a/flake.nix +++ b/flake.nix @@ -164,4 +164,4 @@ }); }; } -# nix-direnv cache busting line: sha256-4NBYQl+JWkZCQWs2UdtbxdUvq7TMUm5pka1ppUUQafU= +# nix-direnv cache busting line: sha256-lgWFUDa1hHooieOSE/2L74RHtfkzK2q4W2GiDQOrxGw= diff --git a/flakehashes.json b/flakehashes.json index 4de82fbe3..5f7e75b1d 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -4,7 +4,7 @@ "sri": "sha256-cY5yryX+p/xtoTv+WZEKFagiIl0OREHnJY1Bk5VpVVc=" }, "vendor": { - "goModSum": "sha256-QmU8vJ5K/YWKT1BvN5+F8IVMyR2sCRyE5y4IHWiwlB4=", - "sri": "sha256-4NBYQl+JWkZCQWs2UdtbxdUvq7TMUm5pka1ppUUQafU=" + "goModSum": "sha256-ep5BPjx4MPaldI71++7vgJPSyftdBqlKciKyx08MOQw=", + "sri": "sha256-lgWFUDa1hHooieOSE/2L74RHtfkzK2q4W2GiDQOrxGw=" } } diff --git a/go.mod b/go.mod index 8d1144f43..819848d3d 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5 - github.com/bradfitz/monogok v0.0.0-20260429173803-229ef7981a6b + github.com/bradfitz/monogok v0.0.0-20260604043651-77f55eaefb19 github.com/bramvdbogaerde/go-scp v1.4.0 github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc github.com/chromedp/chromedp v0.15.1 @@ -103,7 +103,7 @@ require ( github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc github.com/tailscale/setec v0.0.0-20251203133219-2ab774e4129a - github.com/tailscale/ts-gokrazy v0.0.0-20260429180033-fe741c6deb44 + github.com/tailscale/ts-gokrazy v0.0.0-20260604151927-fc3a567bcf75 github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad diff --git a/go.sum b/go.sum index a41a5b6ab..253c537ea 100644 --- a/go.sum +++ b/go.sum @@ -207,8 +207,8 @@ github.com/bombsimon/wsl/v4 v4.2.1 h1:Cxg6u+XDWff75SIFFmNsqnIOgob+Q9hG6y/ioKbRFi github.com/bombsimon/wsl/v4 v4.2.1/go.mod h1:Xu/kDxGZTofQcDGCtQe9KCzhHphIe0fDuyWTxER9Feo= github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5 h1:0sG3c7afYdBNlc3QyhckvZ4bV9iqlfqCQM1i+mWm0eE= github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5/go.mod h1:78ZLITnBUCDJeU01+wYYJKaPYYgsDzJPRfxeI8qFh5g= -github.com/bradfitz/monogok v0.0.0-20260429173803-229ef7981a6b h1:lhWZfi1U/yi8zuFA6pkJKYv45pVAC3xs6SUE2QsjsEE= -github.com/bradfitz/monogok v0.0.0-20260429173803-229ef7981a6b/go.mod h1:TG1HbU9fRVDnNgXncVkKz9GdvjIvqquXjH6QZSEVmY4= +github.com/bradfitz/monogok v0.0.0-20260604043651-77f55eaefb19 h1:1nIDDePfdaBpj8/Sv0pZVWylK5JqPVkS9CgxbwRRc8Y= +github.com/bradfitz/monogok v0.0.0-20260604043651-77f55eaefb19/go.mod h1:TG1HbU9fRVDnNgXncVkKz9GdvjIvqquXjH6QZSEVmY4= github.com/bramvdbogaerde/go-scp v1.4.0 h1:jKMwpwCbcX1KyvDbm/PDJuXcMuNVlLGi0Q0reuzjyKY= github.com/bramvdbogaerde/go-scp v1.4.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ= github.com/breml/bidichk v0.2.7 h1:dAkKQPLl/Qrk7hnP6P+E0xOodrq8Us7+U0o4UBOAlQY= @@ -1169,8 +1169,8 @@ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+y github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= github.com/tailscale/setec v0.0.0-20251203133219-2ab774e4129a h1:TApskGPim53XY5WRt5hX4DnO8V6CmVoimSklryIoGMM= github.com/tailscale/setec v0.0.0-20251203133219-2ab774e4129a/go.mod h1:+6WyG6kub5/5uPsMdYQuSti8i6F5WuKpFWLQnZt/Mms= -github.com/tailscale/ts-gokrazy v0.0.0-20260429180033-fe741c6deb44 h1:a6GdEBrBcDy/4XQ2CxKQvuCaKN8EFL5JTE7ZFOkXDzQ= -github.com/tailscale/ts-gokrazy v0.0.0-20260429180033-fe741c6deb44/go.mod h1:mu0sethAvP7xItcfBAxMJWiXZ3ZQ5qbKmjPYizOkSHE= +github.com/tailscale/ts-gokrazy v0.0.0-20260604151927-fc3a567bcf75 h1:qdJT1/hv1Iji8j4mFUt1fvVPl97Oszvp0TUZWhMD5oY= +github.com/tailscale/ts-gokrazy v0.0.0-20260604151927-fc3a567bcf75/go.mod h1:mu0sethAvP7xItcfBAxMJWiXZ3ZQ5qbKmjPYizOkSHE= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= diff --git a/gokrazy/Makefile b/gokrazy/Makefile index 014866851..9071e7e9e 100644 --- a/gokrazy/Makefile +++ b/gokrazy/Makefile @@ -4,6 +4,9 @@ help: image: go run build.go --build +gaf: + go run build.go --gaf + qemu: image qemu-system-x86_64 -m 1G -drive file=tsapp.img,format=raw -boot d -netdev user,id=user.0 -device virtio-net-pci,netdev=user.0 -serial mon:stdio -audio none diff --git a/gokrazy/build.go b/gokrazy/build.go index f92edb1a3..e2721e80c 100644 --- a/gokrazy/build.go +++ b/gokrazy/build.go @@ -30,6 +30,7 @@ var ( app = flag.String("app", "tsapp", "appliance name; one of the subdirectories of gokrazy/") bucket = flag.String("bucket", "tskrazy-import", "S3 bucket to upload disk image to while making AMI") build = flag.Bool("build", false, "if true, just build locally and stop, without uploading") + gaf = flag.Bool("gaf", false, "if true, build a gokrazy archive format file instead of a full disk image") ) func findMkfsExt4() (string, error) { @@ -95,7 +96,7 @@ func main() { if err := buildImage(); err != nil { log.Fatalf("build image: %v", err) } - if *build { + if *build || *gaf { log.Printf("built. stopping.") return } @@ -122,11 +123,6 @@ func main() { } func buildImage() error { - mkfs, err := findMkfsExt4() - if err != nil { - return err - } - dir, err := os.Getwd() if err != nil { return err @@ -134,19 +130,37 @@ func buildImage() error { if fi, err := os.Stat(filepath.Join(dir, *app)); err != nil || !fi.IsDir() { return fmt.Errorf("in wrong directory %v; no %q subdirectory found", dir, *app) } - // Build the tsapp.img + + args := []string{"run", "github.com/bradfitz/monogok/cmd/monogok"} + if *gaf { + args = append(args, + "overwrite", + "--gaf", filepath.Join(dir, *app+".gaf"), + ) + } else { + args = append(args, + "overwrite", + "--full", filepath.Join(dir, *app+".img"), + "--target_storage_bytes=1258299392", + ) + } + var buf bytes.Buffer - cmd := exec.Command("go", "run", - "github.com/bradfitz/monogok/cmd/monogok", - "overwrite", - "--full", filepath.Join(dir, *app+".img"), - "--target_storage_bytes=1258299392") + cmd := exec.Command("go", args...) cmd.Dir = filepath.Join(dir, *app) cmd.Stdout = io.MultiWriter(os.Stdout, &buf) cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return err } + if *gaf { + return nil + } + + mkfs, err := findMkfsExt4() + if err != nil { + return err + } // monogok overwrite emits a line of text saying how to run mkfs.ext4 // to create the ext4 /perm filesystem. Parse that and run it. diff --git a/shell.nix b/shell.nix index fa3c0e7db..56b10c5ea 100644 --- a/shell.nix +++ b/shell.nix @@ -16,4 +16,4 @@ ) { src = ./.; }).shellNix -# nix-direnv cache busting line: sha256-4NBYQl+JWkZCQWs2UdtbxdUvq7TMUm5pka1ppUUQafU= +# nix-direnv cache busting line: sha256-lgWFUDa1hHooieOSE/2L74RHtfkzK2q4W2GiDQOrxGw= diff --git a/tstest/natlab/vmtest/gokrazy_update_test.go b/tstest/natlab/vmtest/gokrazy_update_test.go new file mode 100644 index 000000000..9743cbf3f --- /dev/null +++ b/tstest/natlab/vmtest/gokrazy_update_test.go @@ -0,0 +1,118 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package vmtest_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "tailscale.com/tstest" + "tailscale.com/tstest/natlab/vmtest" + "tailscale.com/tstest/natlab/vnet" +) + +// TestGokrazyUpdatesItselfToSameImage exercises the Gokrazy appliance update +// path end-to-end in QEMU. It builds a GAF for the same natlab image, serves it +// from the vnet fileserver, asks the guest to install it to the inactive +// partition, then verifies the guest rebooted successfully from the other root +// partition. +func TestGokrazyUpdatesItselfToSameImage(t *testing.T) { + env := vmtest.New(t) + + wan := env.AddNetwork("1.0.0.1", "192.168.1.1/24", vnet.EasyNAT) + node := env.AddNode("gokrazy", wan, + vmtest.OS(vmtest.Gokrazy), + vmtest.DontJoinTailnet()) + + env.Start() + + gaf := buildNatlabGAF(t) + env.RegisterFile("natlabapp.gaf", gaf) + + rootBefore, err := env.GokrazyRoot(node) + if err != nil { + t.Fatalf("getting initial gokrazy root: %v", err) + } + t.Logf("initial gokrazy root: %s", rootBefore) + + out, err := env.Tailscale(node, + "update", + "--", + "--gokrazy-update-from-url=http://files.tailscale/natlabapp.gaf", + "--unsigned", + ) + if err != nil { + if errors.Is(err, io.EOF) { + t.Logf("update command connection ended during reboot: %v", err) + } else { + t.Fatalf("gokrazy update command failed: %v\n%s", err, out) + } + } else { + t.Logf("update command output:\n%s", out) + } + + if err := tstest.WaitFor(90*time.Second, func() error { + rootAfter, err := env.GokrazyRoot(node) + if err != nil { + return err + } + if rootAfter == rootBefore { + return fmt.Errorf("still booted with root %q", rootAfter) + } + t.Logf("updated gokrazy root: %s", rootAfter) + return nil + }); err != nil { + t.Fatalf("waiting for gokrazy to reboot into inactive partition: %v", err) + } +} + +func buildNatlabGAF(t *testing.T) []byte { + t.Helper() + + modRoot := moduleRoot(t) + gafPath := filepath.Join(modRoot, "gokrazy", "natlabapp.gaf") + t.Cleanup(func() { os.Remove(gafPath) }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + cmd := exec.CommandContext(ctx, "go", "run", "build.go", "--gaf", "--app=natlabapp") + cmd.Dir = filepath.Join(modRoot, "gokrazy") + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + t.Fatalf("building natlabapp.gaf: %v\n%s", err, out.String()) + } + t.Logf("built natlabapp.gaf:\n%s", out.String()) + + gaf, err := os.ReadFile(gafPath) + if err != nil { + t.Fatalf("reading %s: %v", gafPath, err) + } + return gaf +} + +func moduleRoot(t *testing.T) string { + t.Helper() + + out, err := exec.Command("go", "env", "GOMOD").CombinedOutput() + if err != nil { + t.Fatalf("go env GOMOD: %v\n%s", err, out) + } + gomod := strings.TrimSpace(string(out)) + if gomod == "" || gomod == os.DevNull { + t.Fatal("not in a Go module") + } + return filepath.Dir(gomod) +} diff --git a/tstest/natlab/vmtest/vmtest.go b/tstest/natlab/vmtest/vmtest.go index 659e12877..6150bd4d8 100644 --- a/tstest/natlab/vmtest/vmtest.go +++ b/tstest/natlab/vmtest/vmtest.go @@ -400,6 +400,15 @@ func (e *Env) AddNetwork(opts ...any) *vnet.Network { return e.cfg.AddNetwork(opts...) } +// RegisterFile registers a file with the vnet fileserver. +// It is served at http://files.tailscale/. +func (e *Env) RegisterFile(path string, data []byte) { + if e.server == nil { + e.t.Fatalf("RegisterFile called before Start") + } + e.server.RegisterFile(path, data) +} + // Node represents a virtual machine in the test environment. type Node struct { name string @@ -1322,6 +1331,50 @@ func (e *Env) HTTPGet(from *Node, targetURL string) string { return "" } +// Tailscale runs the tailscale CLI on the given node via TTA. +func (e *Env) Tailscale(n *Node, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + q := url.Values{} + for _, arg := range args { + q.Add("arg", arg) + } + req, err := http.NewRequestWithContext(ctx, "GET", "http://unused/tailscale?"+q.Encode(), nil) + if err != nil { + return "", err + } + res, err := n.agent.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return string(body), fmt.Errorf("tailscale %q: %s: %s", args, res.Status, res.Header.Get("Exec-Err")) + } + return string(body), nil +} + +// GokrazyRoot returns the kernel root= argument from a Gokrazy node. +func (e *Env) GokrazyRoot(n *Node) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", "http://unused/gokrazy-root", nil) + if err != nil { + return "", err + } + res, err := n.agent.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("gokrazy-root: %s: %s", res.Status, strings.TrimSpace(string(body))) + } + return strings.TrimSpace(string(body)), nil +} + // setNodeScreenshot stores the latest screenshot data URI for a node. func (e *Env) setNodeScreenshot(name, dataURI string) { e.nodeStatusMu.Lock()