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
+27 -49
View File
@@ -10,20 +10,17 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"tailscale.com/gokrazy/mkfs"
)
var (
@@ -33,25 +30,22 @@ var (
gaf = flag.Bool("gaf", false, "if true, build a gokrazy archive format file instead of a full disk image")
)
func findMkfsExt4() (string, error) {
tries := []string{
"/opt/homebrew/opt/e2fsprogs/sbin/mkfs.ext4",
"/sbin/mkfs.ext4",
}
for _, p := range tries {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
p, err := exec.LookPath("mkfs.ext4")
if err == nil {
return p, nil
}
if runtime.GOOS == "darwin" {
return "", errors.New("no mkfs.ext4 found; run `brew install e2fsprogs`")
}
return "", errors.New("No mkfs.ext4 found on system")
}
// imageSizeBytes is the size of the disk image we ask monogok to
// produce (and that the AWS AMI import expects). It has to be large
// enough to fit gokrazy's standard partition layout (see
// github.com/bradfitz/monogok/disklayout):
//
// 4 MiB gap before the first partition
// 100 MiB boot (FAT)
// 500 MiB root A (squashfs; the partition OTA updates write into)
// 500 MiB root B (squashfs)
// ~96 MiB /perm (ext4; rest of the disk minus the secondary GPT)
//
// Bump this to give /perm more room (and to make the produced .img
// file larger). The same value is passed to monogok via
// --target_storage_bytes and to mkfs.Perm so the GPT and the ext4
// inside it agree on the disk's size.
const imageSizeBytes = 1258299392
var conf gokrazyConfig
@@ -141,14 +135,13 @@ func buildImage() error {
args = append(args,
"overwrite",
"--full", filepath.Join(dir, *app+".img"),
"--target_storage_bytes=1258299392",
fmt.Sprintf("--target_storage_bytes=%d", imageSizeBytes),
)
}
var buf bytes.Buffer
cmd := exec.Command("go", args...)
cmd.Dir = filepath.Join(dir, *app)
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
@@ -157,31 +150,16 @@ func buildImage() error {
return nil
}
mkfs, err := findMkfsExt4()
imgPath := filepath.Join(dir, *app+".img")
f, err := os.OpenFile(imgPath, os.O_RDWR, 0)
if err != nil {
return err
return fmt.Errorf("open %s: %w", imgPath, 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.
// The regexp is tight to avoid matching if the command changes,
// to force us to check it's still correct/safe. But it shouldn't
// change on its own because we pin the monogok version in our go.mod.
//
// TODO(bradfitz): emit this in a machine-readable way from monogok.
rx := regexp.MustCompile(`(?m)/mkfs.ext4 (-F) (-E) (offset=\d+) (\S+) (\d+)\s*?$`)
m := rx.FindStringSubmatch(buf.String())
if m == nil {
return fmt.Errorf("found no ext4 instructions in output")
defer f.Close()
if err := mkfs.Perm(f, imageSizeBytes); err != nil {
return fmt.Errorf("formatting /perm in %s: %v", imgPath, err)
}
log.Printf("Running %s %q ...", mkfs, m[1:])
out, err := exec.Command(mkfs, m[1:]...).CombinedOutput()
if err != nil {
return fmt.Errorf("error running %v: %v, %s", mkfs, err, out)
}
log.Printf("Success.")
log.Printf("Wrote ext4 /perm filesystem to %s.", imgPath)
return nil
}