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 <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-06-04 11:20:14 -07:00
committed by Brad Fitzpatrick
parent 6ff761c5f8
commit 772be1b0cc
15 changed files with 474 additions and 22 deletions
+19
View File
@@ -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"
+177
View File
@@ -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
}
+48
View File
@@ -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
}
+1
View File
@@ -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+
+1
View File
@@ -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+
+18
View File
@@ -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 {
+1 -1
View File
@@ -164,4 +164,4 @@
});
};
}
# nix-direnv cache busting line: sha256-4NBYQl+JWkZCQWs2UdtbxdUvq7TMUm5pka1ppUUQafU=
# nix-direnv cache busting line: sha256-lgWFUDa1hHooieOSE/2L74RHtfkzK2q4W2GiDQOrxGw=
+2 -2
View File
@@ -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="
}
}
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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=
+3
View File
@@ -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
+26 -12
View File
@@ -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.
+1 -1
View File
@@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-4NBYQl+JWkZCQWs2UdtbxdUvq7TMUm5pka1ppUUQafU=
# nix-direnv cache busting line: sha256-lgWFUDa1hHooieOSE/2L74RHtfkzK2q4W2GiDQOrxGw=
+118
View File
@@ -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)
}
+53
View File
@@ -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/<path>.
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()