cmd/tailscale/cli: add 'tailscale configure flash-appliance'

Adds a CLI subcommand that downloads a signed Tailscale appliance
image (Gokrazy archive format, GAF) from pkgs.tailscale.com,
constructs a fresh GPT-partitioned disk from it (mbr.img + a
synthesized partition table + boot.img + root.img), formats /perm
as ext4 in pure Go via go-diskfs, and ejects the disk so a user
running on a regular workstation can flash an SD card or homelab
VM disk in one command without installing e2fsprogs.

On macOS the target disk is auto-discovered via diskutil, skipping
the boot disk and anything bigger than 256 GB out of paranoia. On
Linux the user passes --disk=/dev/sdX explicitly. Windows is not
supported yet and the command returns an error.

The GPT layout matches monogok's full-disk layout via the new
public github.com/bradfitz/monogok/disklayout package; a drift-
guard test inside monogok asserts the two implementations stay
byte-identical so OTA updates against monogok-built images keep
working.

Behind a ts_omit_flashappliance build tag (on by default).

Updates #1866

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ic1a8cd185e7039edccb7702ab4104544fcb58d29
This commit is contained in:
Brad Fitzpatrick
2026-07-01 08:09:50 -07:00
committed by Brad Fitzpatrick
parent 64422f274d
commit d0fcb668d5
26 changed files with 2015 additions and 101 deletions
+6 -33
View File
@@ -13,7 +13,6 @@ import (
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
@@ -52,11 +51,11 @@ func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error {
defer os.Remove(tmpName)
if args.AllowUnsigned {
if err := downloadGAFUnverified(ctx, args.URL, tmpName); err != nil {
if err := downloadUnverified(ctx, args.URL, tmpName); err != nil {
return err
}
} else {
if err := downloadGAFVerified(ctx, logf, args.URL, tmpName); err != nil {
if err := distsign.DownloadVerified(ctx, logf, args.URL, tmpName); err != nil {
return err
}
}
@@ -92,10 +91,10 @@ 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 {
// downloadUnverified 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 downloadUnverified(ctx context.Context, srcURL, dstPath string) error {
req, err := http.NewRequestWithContext(ctx, "GET", srcURL, nil)
if err != nil {
return err
@@ -119,32 +118,6 @@ func downloadGAFUnverified(ctx context.Context, srcURL, dstPath string) error {
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) {