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
+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 {