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-06-30 14:11:46 +00:00
committed by Brad Fitzpatrick
parent 64422f274d
commit d0fcb668d5
26 changed files with 2015 additions and 101 deletions

View File

@@ -148,6 +148,14 @@ sshintegrationtest: ## Run the SSH integration tests in various Docker container
generate: ## Generate code
./tool/go generate ./...
.PHONY: tsapp-build-and-flash-pi
tsapp-build-and-flash-pi: ## Build a tsapp-pi.arm64 GAF from HEAD and flash a local SD card (macOS auto-detects the disk; pass DISK=/dev/sdX on Linux)
cd gokrazy && ../tool/go run build.go --gaf --app=tsapp-pi.arm64
./tool/go run --exec=sudo ./cmd/tailscale configure flash-appliance \
--variant=pi-arm64 \
--gaf=gokrazy/tsapp-pi.arm64.gaf \
$(if $(DISK),--disk=$(DISK))
.PHONY: pin-github-actions
pin-github-actions:
./tool/go tool github.com/stacklok/frizbee actions .github/workflows

View File

@@ -361,7 +361,7 @@ func (up *Updater) updateSynology() error {
if err != nil {
return err
}
latest, err := latestPackages(up.Track)
latest, err := LatestPackages(up.Track)
if err != nil {
return err
}
@@ -906,7 +906,7 @@ func (up *Updater) updateGokrazy() error {
if err != nil {
return err
}
latest, err := latestPackages(up.Track)
latest, err := LatestPackages(up.Track)
if err != nil {
return err
}
@@ -1305,7 +1305,7 @@ func LatestTailscaleVersion(track string) (string, error) {
track = CurrentTrack
}
latest, err := latestPackages(track)
latest, err := LatestPackages(track)
if err != nil {
return "", err
}
@@ -1331,7 +1331,8 @@ func LatestTailscaleVersion(track string) (string, error) {
return ver, nil
}
type trackPackages struct {
// TrackPackages is the JSON shape served at <pkgs>/<track>/?mode=json.
type TrackPackages struct {
Version string
Tarballs map[string]string
TarballsVersion string
@@ -1349,14 +1350,16 @@ type trackPackages struct {
var tailscaleHTTPEndpoint = "https://pkgs.tailscale.com"
func latestPackages(track string) (*trackPackages, error) {
// LatestPackages fetches the package manifest served at
// <pkgs>/<track>/?mode=json for the current runtime.GOOS.
func LatestPackages(track string) (*TrackPackages, error) {
url := fmt.Sprintf("%s/%s/?mode=json&os=%s", tailscaleHTTPEndpoint, track, runtime.GOOS)
res, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("fetching latest tailscale version: %w", err)
}
defer res.Body.Close()
var latest trackPackages
var latest TrackPackages
if err := json.NewDecoder(res.Body).Decode(&latest); err != nil {
return nil, fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
}

View File

@@ -13,7 +13,6 @@
"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) {

View File

@@ -373,7 +373,7 @@ func TestCheckOutdatedAlpineRepo(t *testing.T) {
testServ := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
version := trackPackages{
version := TrackPackages{
MSIsVersion: tt.latestHTTPVersion,
MacZipsVersion: tt.latestHTTPVersion,
TarballsVersion: tt.latestHTTPVersion,

View File

@@ -0,0 +1,42 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package distsign
import (
"context"
"fmt"
"net/url"
"strings"
"tailscale.com/types/logger"
)
// DownloadVerified is a convenience wrapper around [Client.Download]
// for callers that have a full URL (e.g.
// https://pkgs.tailscale.com/unstable/foo.gaf) rather than a base URL
// plus path. It splits srcURL into a base ("scheme://host") and a path,
// constructs a [Client] for the base, and downloads with signature
// verification to dstPath.
func DownloadVerified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error {
if logf == nil {
logf = logger.Discard
}
u, err := url.Parse(srcURL)
if err != nil {
return fmt.Errorf("parsing URL %q: %w", srcURL, err)
}
if u.Scheme == "" || u.Host == "" {
return fmt.Errorf("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("URL %q has no path component", srcURL)
}
c, err := NewClient(logf, base.String())
if err != nil {
return err
}
return c.Download(ctx, path, dstPath)
}

View File

@@ -0,0 +1,518 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_flashappliance
package cli
import (
"archive/zip"
"context"
"errors"
"flag"
"fmt"
"io"
"net/url"
"os"
"runtime"
"slices"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/bradfitz/monogok/disklayout"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/clientupdate"
"tailscale.com/clientupdate/distsign"
"tailscale.com/gokrazy/mkfs"
"tailscale.com/util/prompt"
)
var flashApplianceArgs struct {
variant string
disk string
track string
yes bool
gaf string
}
func flashApplianceCmd() *ffcli.Command {
return &ffcli.Command{
Name: "flash-appliance",
ShortUsage: "tailscale configure flash-appliance [flags]",
ShortHelp: "Download a signed Tailscale appliance image and write it to a local disk [experimental]",
LongHelp: hidden + strings.TrimSpace(`
This experimental command downloads a signed Tailscale appliance image (Gokrazy archive
format, "GAF") from pkgs.tailscale.com, verifies its signature, and writes
it to a local block device (SD card, USB drive, virtual disk).
On macOS, the target disk is auto-discovered from 'diskutil list physical',
excluding whichever disks back the running root. On Linux, you must pass
--disk=/dev/sdX explicitly.
This command requires mkfs.ext4 in $PATH to format the writable /perm
partition. On macOS, 'brew install e2fsprogs' provides it.
`),
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("flash-appliance")
fs.StringVar(&flashApplianceArgs.variant, "variant", "", `appliance variant: "pi-arm64", "vm-amd64", or "vm-arm64". Empty prompts interactively.`)
fs.StringVar(&flashApplianceArgs.disk, "disk", "", "target block device (e.g. /dev/sdb or /dev/disk4)")
fs.StringVar(&flashApplianceArgs.track, "track", "", `which track to download from; defaults to "`+clientupdate.CurrentTrack+`"`)
fs.BoolVar(&flashApplianceArgs.yes, "yes", false, "skip the destructive-write confirmation prompt")
fs.StringVar(&flashApplianceArgs.gaf, "gaf", "", "use a local GAF file instead of downloading (skips signature verification)")
return fs
})(),
Exec: runFlashAppliance,
}
}
func runFlashAppliance(ctx context.Context, args []string) error {
if len(args) > 0 {
return errors.New("unknown arguments")
}
if runtime.GOOS == "windows" {
return errors.New("flash-appliance is not supported on Windows yet; consider running under WSL")
}
if os.Geteuid() != 0 {
return errors.New("writing to a raw block device requires root; re-run with sudo")
}
disk, err := resolveTargetDisk(ctx, flashApplianceArgs.disk)
if err != nil {
return err
}
gafPath, gafLabel, variant, cleanup, err := obtainGAF(ctx)
if err != nil {
return err
}
defer cleanup()
zr, err := zip.OpenReader(gafPath)
if err != nil {
return fmt.Errorf("open GAF: %w", err)
}
defer zr.Close()
bootCode, err := readGAFMember(zr.File, "mbr.img", 1<<20)
if err != nil {
return err
}
if !flashApplianceArgs.yes {
msg := fmt.Sprintf("This will ERASE %s. Flash %s?", disk.Path, gafLabel)
if !prompt.YesNo(msg, false) {
return errors.New("aborted")
}
}
printf("Unmounting %s...\n", disk.Path)
if err := unmountDisk(ctx, disk.Path); err != nil {
return fmt.Errorf("unmount %s: %w", disk.Path, err)
}
if err := writeGAFToDisk(zr.File, disk.Path, bootCode, variant); err != nil {
return err
}
if err := formatPermExt4(disk.Path); err != nil {
return fmt.Errorf("formatting perm: %w", err)
}
ejected, err := ejectDisk(ctx, disk.Path)
if err != nil {
// Non-fatal: the user can eject manually.
fmt.Fprintf(Stderr, "ejecting %s: %v\n", disk.Path, err)
}
printf("Done. %s\n", flashSuccessHint(disk.Path, variant, ejected))
return nil
}
// formatPermExt4 creates an ext4 filesystem inside the gokrazy perm
// partition of the disk at diskPath, delegating to gokrazy/mkfs.Perm.
//
// On macOS we open the buffered /dev/diskN path (not /dev/rdiskN)
// because go-diskfs writes ext4 metadata in small unaligned chunks
// that the raw character device rejects.
func formatPermExt4(diskPath string) error {
f, err := os.OpenFile(diskPath, os.O_RDWR, 0)
if err != nil {
return err
}
defer f.Close()
devsize, err := blockDeviceSize(f)
if err != nil {
return fmt.Errorf("sizing %s: %w", diskPath, err)
}
return mkfs.Perm(f, devsize)
}
// flashSuccessHint returns a per-variant next-step hint shown after a
// successful flash. variant is empty when the user passed --gaf
// directly. ejected reports whether we already released the disk (true
// on macOS after diskutil eject); when false, the message tells the
// user to eject it themselves.
func flashSuccessHint(diskPath, variant string, ejected bool) string {
verb := "Eject"
if ejected {
verb = "Pull"
}
switch variant {
case "pi-arm64":
return fmt.Sprintf("%s %s and boot your Raspberry Pi.", verb, diskPath)
case "vm-amd64":
return fmt.Sprintf("%s %s and boot an x86_64 VM from it.", verb, diskPath)
case "vm-arm64":
return fmt.Sprintf("%s %s and boot an arm64 VM from it.", verb, diskPath)
default:
return fmt.Sprintf("%s %s and boot the target device.", verb, diskPath)
}
}
// diskCandidate describes a flashable disk on the host.
type diskCandidate struct {
Path string // e.g. /dev/disk4 or /dev/sdb
SizeBytes int64
Description string // human-readable model + size, e.g. "Generic MassStorage (62.5 GB)"
}
func (d diskCandidate) String() string {
if d.Description != "" {
return fmt.Sprintf("%s: %s", d.Path, d.Description)
}
return d.Path
}
// resolveTargetDisk returns the disk the user wants to flash. On macOS, an
// empty userDisk triggers auto-discovery. On Linux, userDisk is required and
// validated.
func resolveTargetDisk(ctx context.Context, userDisk string) (diskCandidate, error) {
if userDisk != "" {
if err := validateDiskPath(userDisk); err != nil {
return diskCandidate{}, err
}
return diskCandidate{Path: userDisk}, nil
}
disks, err := discoverExternalDisks(ctx)
if err != nil {
return diskCandidate{}, err
}
switch len(disks) {
case 0:
return diskCandidate{}, errors.New("no candidate disks found; insert an SD card or USB drive, or pass --disk")
case 1:
printf("Found 1 candidate disk: %s\n", disks[0])
return disks[0], nil
default:
printf("Multiple candidate disks found:\n")
for i, d := range disks {
printf(" %d) %s\n", i+1, d)
}
return diskCandidate{}, errors.New("pass --disk=/dev/... to pick one")
}
}
// obtainGAF returns a path to a local GAF file the caller can read,
// along with the appliance variant it corresponds to (empty for the
// --gaf path). If the caller passed --gaf, the local file is returned
// directly. Otherwise the latest appliance GAF is fetched from
// pkgs.tailscale.com (with signature verification) into a temp file.
// cleanup removes any temp file it created.
func obtainGAF(ctx context.Context) (path, label, variant string, cleanup func(), err error) {
cleanup = func() {}
if flashApplianceArgs.gaf != "" {
// With --gaf there's no manifest to learn the variant from, so
// we trust whatever --variant the user passed (may be empty).
// rootArchForVariant defaults to arm64 when empty.
return flashApplianceArgs.gaf, flashApplianceArgs.gaf, flashApplianceArgs.variant, cleanup, nil
}
track := flashApplianceArgs.track
if track == "" {
track = clientupdate.CurrentTrack
}
latest, err := clientupdate.LatestPackages(track)
if err != nil {
return "", "", "", cleanup, fmt.Errorf("fetching package manifest: %w", err)
}
if len(latest.GAFs) == 0 {
return "", "", "", cleanup, fmt.Errorf("no appliance GAFs published on %q track", track)
}
variant, err = pickVariant(latest.GAFs)
if err != nil {
return "", "", "", cleanup, err
}
gafName := latest.GAFs[variant]
gafURL, err := url.JoinPath("https://pkgs.tailscale.com", track, gafName)
if err != nil {
return "", "", "", cleanup, err
}
tmp, err := os.CreateTemp("", "tailscale-flash-*.gaf")
if err != nil {
return "", "", "", cleanup, err
}
tmpName := tmp.Name()
tmp.Close()
cleanup = func() { os.Remove(tmpName) }
printf("Downloading %s (version %s)\n", gafURL, latest.GAFsVersion)
logf := func(format string, args ...any) { fmt.Fprintf(Stderr, format+"\n", args...) }
if err := distsign.DownloadVerified(ctx, logf, gafURL, tmpName); err != nil {
cleanup()
return "", "", "", func() {}, fmt.Errorf("download GAF: %w", err)
}
return tmpName, fmt.Sprintf("%s (%s)", gafName, latest.GAFsVersion), variant, cleanup, nil
}
// pickVariant returns the variant key from gafs the user wants to flash. If
// --variant was passed, it's validated against the available keys.
// Otherwise the user is prompted with the variants the server advertises.
func pickVariant(gafs map[string]string) (string, error) {
variants := make([]string, 0, len(gafs))
for k := range gafs {
variants = append(variants, k)
}
sort.Strings(variants)
if v := flashApplianceArgs.variant; v != "" {
if !slices.Contains(variants, v) {
return "", fmt.Errorf("variant %q not published; available: %s", v, strings.Join(variants, ", "))
}
return v, nil
}
printf("Available appliance variants:\n")
for i, v := range variants {
printf(" %d) %s\n", i+1, v)
}
return "", fmt.Errorf("pass --variant=<one of %s>", strings.Join(variants, "|"))
}
// readGAFMember returns the contents of a named member of the GAF zip.
// It returns an error if the member is missing or larger than maxBytes.
func readGAFMember(files []*zip.File, name string, maxBytes int64) ([]byte, error) {
for _, f := range files {
if f.Name != name {
continue
}
if int64(f.UncompressedSize64) > maxBytes {
return nil, fmt.Errorf("%s is %d bytes; refusing to read more than %d", name, f.UncompressedSize64, maxBytes)
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
return nil, fmt.Errorf("GAF is missing %s", name)
}
// writeGAFToDisk writes a fresh gokrazy install to diskPath: the
// protective MBR (with bootCode in the first 446 bytes), the primary
// and secondary GPT, then boot.img at the boot partition's offset and
// root.img at root A's offset. Root B and perm are left untouched — the
// appliance populates root B on first boot, and the caller formats
// perm with mkfs.ext4.
func writeGAFToDisk(files []*zip.File, diskPath string, bootCode []byte, variant string) error {
if len(bootCode) > 446 {
return fmt.Errorf("mbr.img is %d bytes; expected at most 446", len(bootCode))
}
if err := checkPartitionFits(files, "boot.img", int64(disklayout.BootPartitionSizeMB)<<20); err != nil {
return err
}
if err := checkPartitionFits(files, "root.img", int64(disklayout.RootPartitionSizeMB)<<20); err != nil {
return err
}
bootImg, err := readGAFMember(files, "boot.img", int64(disklayout.BootPartitionSizeMB)<<20)
if err != nil {
return err
}
partUUID, err := partUUIDFromBootImg(bootImg)
if err != nil {
return fmt.Errorf("locating gokrazy partuuid in boot.img: %w", err)
}
f, err := openBlockDevice(diskPath)
if err != nil {
return err
}
defer f.Close()
devsize, err := blockDeviceSize(f)
if err != nil {
return fmt.Errorf("sizing %s: %w", diskPath, err)
}
if devsize <= 0 {
return fmt.Errorf("could not determine size of %s", diskPath)
}
printf("Writing protective MBR + GPT (partuuid=%08x, arch=%s)\n", partUUID, rootArchForVariant(variant))
if err := disklayout.WriteGPT(f, uint64(devsize), disklayout.DefaultBootPartitionStartLBA, bootCode, partUUID, rootArchForVariant(variant)); err != nil {
return fmt.Errorf("writing GPT: %w", err)
}
writes := []struct {
member string
offsetLBA uint32
}{
{"boot.img", disklayout.BootStartLBA(disklayout.DefaultBootPartitionStartLBA)},
{"root.img", disklayout.RootAStartLBA(disklayout.DefaultBootPartitionStartLBA)},
}
for _, w := range writes {
zf := findZipMember(files, w.member)
if zf == nil {
return fmt.Errorf("GAF is missing %s", w.member)
}
printf("Writing %s (%d bytes) at sector %d\n", w.member, zf.UncompressedSize64, w.offsetLBA)
if err := writeZipMemberAt(f, zf, int64(w.offsetLBA)*512); err != nil {
return fmt.Errorf("writing %s: %w", w.member, err)
}
}
if err := syncBlockDevice(f); err != nil {
return fmt.Errorf("fsync %s: %w", diskPath, err)
}
if err := rereadPartitionTable(f); err != nil {
return fmt.Errorf("reread partition table: %w", err)
}
return nil
}
// rootArchForVariant picks the GPT root partition type architecture
// based on the GAF variant key (e.g. "pi-arm64" → arm64).
func rootArchForVariant(variant string) disklayout.RootArch {
switch {
case strings.HasSuffix(variant, "-amd64"):
return disklayout.ArchAMD64
default:
// pi-arm64, vm-arm64, or empty (--gaf path): arm64 is the
// default for tailscale appliance images.
return disklayout.ArchARM64
}
}
// partUUIDFromBootImg returns the gokrazy per-disk partuuid embedded in
// boot.img's cmdline.txt. We byte-search the FAT image for the
// "PARTUUID=60c24cc1-..." pattern rather than parsing FAT, which is
// good enough since the only thing on disk with that prefix is
// cmdline.txt.
func partUUIDFromBootImg(boot []byte) (uint32, error) {
return disklayout.ParseCmdlinePartUUID(string(boot))
}
// checkPartitionFits returns an error if the named GAF member is too
// large to fit in a partition of maxBytes.
func checkPartitionFits(files []*zip.File, name string, maxBytes int64) error {
zf := findZipMember(files, name)
if zf == nil {
return fmt.Errorf("GAF is missing %s", name)
}
if got := int64(zf.UncompressedSize64); got > maxBytes {
return fmt.Errorf("%s is %d bytes; gokrazy layout allows up to %d", name, got, maxBytes)
}
return nil
}
func findZipMember(files []*zip.File, name string) *zip.File {
for _, f := range files {
if f.Name == name {
return f
}
}
return nil
}
func writeZipMemberAt(f *os.File, zf *zip.File, offset int64) error {
rc, err := zf.Open()
if err != nil {
return err
}
defer rc.Close()
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return err
}
total := int64(zf.UncompressedSize64)
cw := &countingWriter{w: f}
stop := startProgress(zf.Name, total, &cw.count)
defer stop()
_, err = io.Copy(cw, rc)
return err
}
// countingWriter wraps an io.Writer and tracks total bytes written so
// the progress goroutine can report it.
type countingWriter struct {
w io.Writer
count atomic.Int64
}
func (c *countingWriter) Write(b []byte) (int, error) {
n, err := c.w.Write(b)
if n > 0 {
c.count.Add(int64(n))
}
return n, err
}
// startProgress spawns a 1 Hz goroutine that prints "<name>: <done> / <total>"
// to Stderr until the returned stop function is called. The final tick on
// stop reports the final state, so the caller doesn't need to repeat it.
func startProgress(name string, total int64, done *atomic.Int64) func() {
stop := make(chan struct{})
finished := make(chan struct{})
go func() {
defer close(finished)
t := time.NewTicker(time.Second)
defer t.Stop()
report := func() {
d := done.Load()
pct := 0.0
if total > 0 {
pct = float64(d) * 100 / float64(total)
}
fmt.Fprintf(Stderr, " %s: %s / %s (%.1f%%)\n", name, humanBytes(d), humanBytes(total), pct)
}
for {
select {
case <-stop:
report()
return
case <-t.C:
report()
}
}
}()
return func() {
close(stop)
<-finished
}
}
// humanBytes returns a friendly approximation of n bytes, e.g. "62.5 GB".
func humanBytes(n int64) string {
const (
gb = 1 << 30
mb = 1 << 20
kb = 1 << 10
)
switch {
case n >= gb:
return fmt.Sprintf("%.1f GB", float64(n)/float64(gb))
case n >= mb:
return fmt.Sprintf("%.1f MB", float64(n)/float64(mb))
case n >= kb:
return fmt.Sprintf("%.1f KB", float64(n)/float64(kb))
default:
return fmt.Sprintf("%d B", n)
}
}

View File

@@ -0,0 +1,430 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_flashappliance
package cli
import (
"context"
"encoding/xml"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"golang.org/x/sys/unix"
)
// maxAutoDetectDiskBytes is the upper size limit for a disk that
// flash-appliance auto-discovers. Anything larger is reported to the
// user but skipped from the candidate list, so it's harder to wipe an
// unmounted internal SSD or a backup drive by accident; the user can
// still target it with --disk explicitly.
const maxAutoDetectDiskBytes = 256 << 30
// discoverExternalDisks returns the physical disks suitable for flashing.
// We pass just "physical" (not "external physical") to diskutil because
// macOS reports built-in SD card readers as internal; instead we exclude
// whichever whole disks back the running root.
func discoverExternalDisks(ctx context.Context) ([]diskCandidate, error) {
out, err := exec.CommandContext(ctx, "diskutil", "list", "-plist", "physical").Output()
if err != nil {
return nil, fmt.Errorf("diskutil list: %w", err)
}
ids, err := parseDiskutilListPlist(out)
if err != nil {
return nil, fmt.Errorf("parse diskutil list output: %w", err)
}
boot, err := bootWholeDisks(ctx)
if err != nil {
return nil, fmt.Errorf("locating boot disk: %w", err)
}
disks := make([]diskCandidate, 0, len(ids))
for _, id := range ids {
if boot[id] {
continue
}
d, err := diskutilInfo(ctx, id)
if err != nil {
return nil, err
}
if d.SizeBytes > maxAutoDetectDiskBytes {
printf("Skipping %s (%s) from auto-detection: looks suspiciously large.\n", d.Path, humanBytes(d.SizeBytes))
printf(" To flash it anyway, pass --disk=%s explicitly.\n", d.Path)
continue
}
disks = append(disks, d)
}
return disks, nil
}
var darwinWholeDiskRe = regexp.MustCompile(`^(disk\d+)`)
// bootWholeDisks returns the set of whole-disk identifiers (e.g. "disk0")
// that back the running root filesystem. It seeds the walk from `df -P /`
// (which on Apple Silicon points to the sealed snapshot, e.g.
// disk3s1s1) and follows ParentWholeDisk and APFSPhysicalStores so that
// both the synthesized APFS container (disk3) and the physical disk
// behind it (disk0) get excluded from flash candidates.
func bootWholeDisks(ctx context.Context) (map[string]bool, error) {
rootDev, err := dfRootDevice(ctx)
if err != nil {
return nil, fmt.Errorf("locating root device: %w", err)
}
boot := map[string]bool{}
seen := map[string]bool{}
queue := []string{rootDev}
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
if seen[id] {
continue
}
seen[id] = true
if m := darwinWholeDiskRe.FindString(id); m != "" {
boot[m] = true
}
out, err := exec.CommandContext(ctx, "diskutil", "info", "-plist", id).Output()
if err != nil {
// Skip identifiers diskutil can't resolve (e.g. a physical
// store on a disk that was unplugged); anything already
// collected stays excluded.
continue
}
info, err := parseDiskutilInfoPlist(out)
if err != nil {
continue
}
if d := info.ParentWholeDisk; d != "" {
queue = append(queue, d)
}
queue = append(queue, info.APFSPhysicalStores...)
}
return boot, nil
}
// dfRootDevice returns the device identifier (e.g. "disk3s1s1") that
// backs the root mount, by parsing the second line of `df -P /`.
func dfRootDevice(ctx context.Context) (string, error) {
out, err := exec.CommandContext(ctx, "df", "-P", "/").Output()
if err != nil {
return "", err
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return "", fmt.Errorf("unexpected df output: %q", out)
}
fields := strings.Fields(lines[1])
if len(fields) == 0 {
return "", fmt.Errorf("unexpected df line: %q", lines[1])
}
return strings.TrimPrefix(fields[0], "/dev/"), nil
}
func diskutilInfo(ctx context.Context, id string) (diskCandidate, error) {
out, err := exec.CommandContext(ctx, "diskutil", "info", "-plist", id).Output()
if err != nil {
return diskCandidate{}, fmt.Errorf("diskutil info %s: %w", id, err)
}
info, err := parseDiskutilInfoPlist(out)
if err != nil {
return diskCandidate{}, fmt.Errorf("parse diskutil info %s: %w", id, err)
}
desc := info.Model
if desc == "" {
desc = info.MediaName
}
if info.Size > 0 {
desc = strings.TrimSpace(fmt.Sprintf("%s (%s)", desc, humanBytes(info.Size)))
}
return diskCandidate{
Path: "/dev/" + id,
SizeBytes: info.Size,
Description: desc,
}, nil
}
// validateDiskPath checks that the user-provided disk path looks sane to
// flash on macOS. We trust the user more than on Linux since they had to
// type a /dev/disk path explicitly.
func validateDiskPath(path string) error {
if !strings.HasPrefix(path, "/dev/disk") {
return fmt.Errorf("disk path %q does not look like a macOS whole-disk device (/dev/diskN)", path)
}
if strings.Contains(path, "s") && strings.IndexByte(path, 's') > len("/dev/disk") {
return fmt.Errorf("disk path %q looks like a partition (/dev/diskNsP); pass the whole disk", path)
}
return nil
}
// unmountDisk uses `diskutil unmountDisk` to release all partitions on the
// target disk.
func unmountDisk(ctx context.Context, path string) error {
cmd := exec.CommandContext(ctx, "diskutil", "unmountDisk", path)
cmd.Stdout = Stderr
cmd.Stderr = Stderr
return cmd.Run()
}
// ejectDisk runs `diskutil eject` so the user can pull the SD card or
// USB drive without macOS complaining about an improper eject. Returns
// true if the eject command ran successfully.
func ejectDisk(ctx context.Context, path string) (bool, error) {
cmd := exec.CommandContext(ctx, "diskutil", "eject", path)
cmd.Stdout = Stderr
cmd.Stderr = Stderr
if err := cmd.Run(); err != nil {
return false, err
}
return true, nil
}
// openBlockDevice opens the whole-disk device for writing. On macOS we use
// the raw "rdiskN" alias because the buffered "diskN" path is much slower
// for large writes.
func openBlockDevice(path string) (*os.File, error) {
raw := strings.Replace(path, "/dev/disk", "/dev/rdisk", 1)
return os.OpenFile(raw, os.O_WRONLY, 0)
}
// rereadPartitionTable is a no-op on macOS; diskutil and the kernel pick up
// partition changes when the device is closed and re-opened.
func rereadPartitionTable(_ *os.File) error { return nil }
// macOS ioctls from <sys/disk.h>. lseek(SEEK_END) returns 0 on raw
// (/dev/rdiskN) devices, so we have to compute the size from the block
// size and block count.
const (
dkiocGetBlockSize = 0x40046418 // _IOR('d', 24, uint32_t)
dkiocGetBlockCount = 0x40086419 // _IOR('d', 25, uint64_t)
)
// syncBlockDevice asks the kernel to flush in-flight writes to disk. On
// macOS, /dev/rdiskN is the unbuffered raw device, so its writes are
// already synchronous and fsync returns ENOTTY ("inappropriate ioctl
// for device"). We try F_FULLFSYNC for completeness and tolerate the
// same ENOTTY there.
func syncBlockDevice(f *os.File) error {
_, err := unix.FcntlInt(f.Fd(), unix.F_FULLFSYNC, 0)
if err == nil || err == unix.ENOTTY {
return nil
}
return err
}
// blockDeviceSize returns the size in bytes of the open block device f.
// On little-endian darwin, IoctlGetInt's 8-byte int safely receives
// both a 4-byte uint32 (block size) and an 8-byte uint64 (block count).
func blockDeviceSize(f *os.File) (int64, error) {
blockSize, err := unix.IoctlGetInt(int(f.Fd()), dkiocGetBlockSize)
if err != nil {
return 0, fmt.Errorf("DKIOCGETBLOCKSIZE: %w", err)
}
blockCount, err := unix.IoctlGetInt(int(f.Fd()), dkiocGetBlockCount)
if err != nil {
return 0, fmt.Errorf("DKIOCGETBLOCKCOUNT: %w", err)
}
return int64(blockSize) * int64(blockCount), nil
}
// diskutilInfoFields are the fields we care about from `diskutil info -plist`.
type diskutilInfoFields struct {
Model string
MediaName string
Size int64
ParentWholeDisk string // e.g. "disk3" for "/" on APFS
APFSPhysicalStores []string // e.g. ["disk0s2"] for "/" on APFS
}
// parseDiskutilListPlist returns the WholeDisk device identifiers from the
// output of `diskutil list -plist external physical`.
func parseDiskutilListPlist(data []byte) ([]string, error) {
type listPlist struct {
Dict plistDict `xml:"dict"`
}
var p listPlist
if err := xml.Unmarshal(data, &p); err != nil {
return nil, err
}
arr, ok := p.Dict.Get("WholeDisks").(plistArray)
if !ok {
return nil, nil
}
var out []string
for _, v := range arr {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out, nil
}
// parseDiskutilInfoPlist returns the fields we care about from `diskutil
// info -plist <id>`.
func parseDiskutilInfoPlist(data []byte) (diskutilInfoFields, error) {
type infoPlist struct {
Dict plistDict `xml:"dict"`
}
var p infoPlist
if err := xml.Unmarshal(data, &p); err != nil {
return diskutilInfoFields{}, err
}
var out diskutilInfoFields
if s, ok := p.Dict.Get("MediaName").(string); ok {
out.MediaName = s
}
if s, ok := p.Dict.Get("DeviceModel").(string); ok {
out.Model = s
} else if s, ok := p.Dict.Get("IORegistryEntryName").(string); ok {
out.Model = s
}
if i, ok := p.Dict.Get("Size").(int64); ok {
out.Size = i
} else if i, ok := p.Dict.Get("TotalSize").(int64); ok {
out.Size = i
}
if s, ok := p.Dict.Get("ParentWholeDisk").(string); ok {
out.ParentWholeDisk = s
}
if arr, ok := p.Dict.Get("APFSPhysicalStores").(plistArray); ok {
// The key inside each entry is APFSPhysicalStore (singular) on
// macOS 14+; older releases may use DeviceIdentifier. Accept
// either.
for _, v := range arr {
d, ok := v.(plistDict)
if !ok {
continue
}
if id, ok := d.Get("APFSPhysicalStore").(string); ok {
out.APFSPhysicalStores = append(out.APFSPhysicalStores, id)
} else if id, ok := d.Get("DeviceIdentifier").(string); ok {
out.APFSPhysicalStores = append(out.APFSPhysicalStores, id)
}
}
}
return out, nil
}
// plistDict and plistArray support unmarshaling a small subset of Apple
// XML plists. They preserve key order and decode <string>, <integer>,
// <true>, <false>, <array>, and nested <dict> elements.
type plistDict []plistEntry
type plistEntry struct {
Key string
Value any
}
type plistArray []any
// Get returns the value for a top-level key, or nil if absent.
func (d plistDict) Get(key string) any {
for _, e := range d {
if e.Key == key {
return e.Value
}
}
return nil
}
// UnmarshalXML decodes the children of a <dict> element as alternating
// <key>...</key> and value elements.
func (d *plistDict) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
for {
tok, err := dec.Token()
if err != nil {
return err
}
switch t := tok.(type) {
case xml.EndElement:
if t.Name == start.Name {
return nil
}
case xml.StartElement:
if t.Name.Local != "key" {
return fmt.Errorf("dict child %q is not <key>", t.Name.Local)
}
var key string
if err := dec.DecodeElement(&key, &t); err != nil {
return err
}
vtok, err := nextStart(dec)
if err != nil {
return err
}
v, err := decodePlistValue(dec, vtok)
if err != nil {
return err
}
*d = append(*d, plistEntry{Key: key, Value: v})
}
}
}
func nextStart(dec *xml.Decoder) (xml.StartElement, error) {
for {
tok, err := dec.Token()
if err != nil {
return xml.StartElement{}, err
}
if s, ok := tok.(xml.StartElement); ok {
return s, nil
}
}
}
func decodePlistValue(dec *xml.Decoder, start xml.StartElement) (any, error) {
switch start.Name.Local {
case "string":
var s string
if err := dec.DecodeElement(&s, &start); err != nil {
return nil, err
}
return s, nil
case "integer":
var s string
if err := dec.DecodeElement(&s, &start); err != nil {
return nil, err
}
var i int64
fmt.Sscan(strings.TrimSpace(s), &i)
return i, nil
case "true":
return true, dec.Skip()
case "false":
return false, dec.Skip()
case "array":
var arr plistArray
for {
tok, err := dec.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.EndElement:
if t.Name == start.Name {
return arr, nil
}
case xml.StartElement:
v, err := decodePlistValue(dec, t)
if err != nil {
return nil, err
}
arr = append(arr, v)
}
}
case "dict":
var d plistDict
if err := d.UnmarshalXML(dec, start); err != nil {
return nil, err
}
return d, nil
default:
return nil, dec.Skip()
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_flashappliance
package cli
import "testing"
const diskutilListSample = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AllDisks</key>
<array>
<string>disk4</string>
<string>disk4s1</string>
</array>
<key>WholeDisks</key>
<array>
<string>disk4</string>
</array>
</dict>
</plist>`
const diskutilInfoSample = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DeviceIdentifier</key>
<string>disk4</string>
<key>DeviceModel</key>
<string>Generic STORAGE DEVICE</string>
<key>MediaName</key>
<string>Generic STORAGE DEVICE Media</string>
<key>Size</key>
<integer>62512365568</integer>
<key>Removable</key>
<true/>
</dict>
</plist>`
const diskutilInfoRootSample = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DeviceIdentifier</key>
<string>disk3s1s1</string>
<key>ParentWholeDisk</key>
<string>disk3</string>
<key>APFSPhysicalStores</key>
<array>
<dict>
<key>APFSPhysicalStore</key>
<string>disk0s2</string>
</dict>
</array>
</dict>
</plist>`
func TestParseDiskutilListPlist(t *testing.T) {
ids, err := parseDiskutilListPlist([]byte(diskutilListSample))
if err != nil {
t.Fatalf("parseDiskutilListPlist: %v", err)
}
if len(ids) != 1 || ids[0] != "disk4" {
t.Errorf("ids = %v; want [disk4]", ids)
}
}
func TestParseDiskutilInfoPlist(t *testing.T) {
info, err := parseDiskutilInfoPlist([]byte(diskutilInfoSample))
if err != nil {
t.Fatalf("parseDiskutilInfoPlist: %v", err)
}
if info.Model != "Generic STORAGE DEVICE" {
t.Errorf("Model = %q; want %q", info.Model, "Generic STORAGE DEVICE")
}
if info.MediaName != "Generic STORAGE DEVICE Media" {
t.Errorf("MediaName = %q", info.MediaName)
}
if info.Size != 62512365568 {
t.Errorf("Size = %d; want 62512365568", info.Size)
}
}
func TestParseDiskutilInfoPlistRoot(t *testing.T) {
info, err := parseDiskutilInfoPlist([]byte(diskutilInfoRootSample))
if err != nil {
t.Fatalf("parseDiskutilInfoPlist: %v", err)
}
if info.ParentWholeDisk != "disk3" {
t.Errorf("ParentWholeDisk = %q; want disk3", info.ParentWholeDisk)
}
if len(info.APFSPhysicalStores) != 1 || info.APFSPhysicalStores[0] != "disk0s2" {
t.Errorf("APFSPhysicalStores = %v; want [disk0s2]", info.APFSPhysicalStores)
}
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_flashappliance
package cli
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"golang.org/x/sys/unix"
)
// discoverExternalDisks returns no disks on Linux: the user must pass
// --disk=/dev/sdX. We don't try to enumerate removable disks here because
// the right answer depends heavily on the host (servers don't have
// removable media; Pi-on-Pi flashing has no notion of "external"; LVM
// setups have arbitrary names).
func discoverExternalDisks(_ context.Context) ([]diskCandidate, error) {
return nil, errors.New("on Linux, pass --disk=/dev/sdX (auto-discovery is macOS-only)")
}
// validateDiskPath rejects partition paths, the running root disk, and
// disks with any partition currently mounted.
func validateDiskPath(path string) error {
if !strings.HasPrefix(path, "/dev/") {
return fmt.Errorf("disk path %q must start with /dev/", path)
}
if isPartitionPath(path) {
return fmt.Errorf("disk path %q looks like a partition; pass the whole disk", path)
}
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat %s: %w", path, err)
}
if fi.Mode()&os.ModeDevice == 0 {
return fmt.Errorf("%s is not a device file", path)
}
mounts, err := mountedSources()
if err != nil {
return err
}
for _, m := range mounts {
if m == path || strings.HasPrefix(m, path) {
return fmt.Errorf("%s (or one of its partitions) is currently mounted; unmount it first", path)
}
}
return nil
}
// isPartitionPath reports whether path looks like a partition (e.g.
// /dev/sda1, /dev/nvme0n1p2, /dev/mmcblk0p1) rather than a whole disk.
func isPartitionPath(path string) bool {
base := strings.TrimPrefix(path, "/dev/")
switch {
case strings.HasPrefix(base, "sd"), strings.HasPrefix(base, "hd"), strings.HasPrefix(base, "vd"):
// /dev/sdaN — partition.
if len(base) >= 4 && base[len(base)-1] >= '0' && base[len(base)-1] <= '9' {
return true
}
case strings.HasPrefix(base, "nvme"), strings.HasPrefix(base, "mmcblk"), strings.HasPrefix(base, "loop"):
// /dev/nvme0n1p1 — partition is "<diskname>p<digits>". The 'p'
// must follow a digit (to distinguish loop0 from loop0p1).
i := strings.LastIndexByte(base, 'p')
if i <= 0 || i >= len(base)-1 || base[i-1] < '0' || base[i-1] > '9' {
return false
}
for _, r := range base[i+1:] {
if r < '0' || r > '9' {
return false
}
}
return true
}
return false
}
// mountedSources returns the source device paths from /proc/mounts.
func mountedSources() ([]string, error) {
f, err := os.Open("/proc/mounts")
if err != nil {
return nil, err
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) > 0 {
out = append(out, fields[0])
}
}
return out, sc.Err()
}
// unmountDisk unmounts every entry in /proc/mounts whose source starts with
// path (covers /dev/sdb plus /dev/sdb1, /dev/sdb2, ...).
func unmountDisk(ctx context.Context, path string) error {
mounts, err := mountedSources()
if err != nil {
return err
}
for _, m := range mounts {
if m == path || strings.HasPrefix(m, path) {
cmd := exec.CommandContext(ctx, "umount", m)
cmd.Stdout = Stderr
cmd.Stderr = Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("umount %s: %w", m, err)
}
}
}
return nil
}
func openBlockDevice(path string) (*os.File, error) {
return os.OpenFile(path, os.O_WRONLY|unix.O_SYNC, 0)
}
// rereadPartitionTable asks the kernel to re-scan the partition table on
// the open block device. Required on Linux before we can mkfs the perm
// partition we just wrote.
func rereadPartitionTable(f *os.File) error {
return unix.IoctlSetInt(int(f.Fd()), unix.BLKRRPART, 0)
}
// syncBlockDevice flushes pending writes to disk.
func syncBlockDevice(f *os.File) error { return f.Sync() }
// ejectDisk is a no-op on Linux; the user just pulls the disk after
// the sync at the end of writeGAFToDisk. Returns false so the success
// message instructs the user to eject themselves.
func ejectDisk(_ context.Context, _ string) (bool, error) { return false, nil }
// blockDeviceSize returns the size in bytes of the open block device f.
// BLKGETSIZE64 returns a uint64; on 64-bit linux IoctlGetInt's int is wide
// enough to receive it without needing an unsafe.Pointer.
func blockDeviceSize(f *os.File) (int64, error) {
size, err := unix.IoctlGetInt(int(f.Fd()), unix.BLKGETSIZE64)
if err != nil {
return 0, fmt.Errorf("BLKGETSIZE64: %w", err)
}
return int64(size), nil
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_flashappliance
package cli
import "testing"
func TestIsPartitionPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{"/dev/sda", false},
{"/dev/sda1", true},
{"/dev/sdb", false},
{"/dev/sdb4", true},
{"/dev/sdz9", true},
{"/dev/vdb", false},
{"/dev/vdb1", true},
{"/dev/nvme0n1", false},
{"/dev/nvme0n1p1", true},
{"/dev/nvme0n1p4", true},
{"/dev/mmcblk0", false},
{"/dev/mmcblk0p1", true},
{"/dev/loop0", false},
{"/dev/loop0p1", true},
}
for _, tt := range tests {
if got := isPartitionPath(tt.path); got != tt.want {
t.Errorf("isPartitionPath(%q) = %v; want %v", tt.path, got, tt.want)
}
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_flashappliance
package cli
import "github.com/peterbourgon/ff/v3/ffcli"
func flashApplianceCmd() *ffcli.Command {
// Omitted from the build when the ts_omit_flashappliance build tag is set.
return nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_flashappliance && !linux && !darwin
package cli
import (
"context"
"errors"
"os"
"runtime"
)
var errFlashUnsupported = errors.New("flash-appliance is only supported on linux and darwin (got " + runtime.GOOS + ")")
func discoverExternalDisks(_ context.Context) ([]diskCandidate, error) {
return nil, errFlashUnsupported
}
func validateDiskPath(_ string) error {
return errFlashUnsupported
}
func unmountDisk(_ context.Context, _ string) error {
return errFlashUnsupported
}
func openBlockDevice(_ string) (*os.File, error) {
return nil, errFlashUnsupported
}
func rereadPartitionTable(_ *os.File) error { return nil }
func blockDeviceSize(_ *os.File) (int64, error) { return 0, errFlashUnsupported }
func syncBlockDevice(_ *os.File) error { return errFlashUnsupported }
func ejectDisk(_ context.Context, _ string) (bool, error) { return false, errFlashUnsupported }

View File

@@ -0,0 +1,54 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_flashappliance
package cli
import (
"archive/zip"
"bytes"
"testing"
)
func TestCheckPartitionFits(t *testing.T) {
files := buildZip(t, map[string][]byte{
"boot.img": bytes.Repeat([]byte{0xAB}, 1<<20),
"root.img": bytes.Repeat([]byte{0xCD}, 4<<20),
})
if err := checkPartitionFits(files, "boot.img", 2<<20); err != nil {
t.Errorf("boot.img within limit: %v", err)
}
if err := checkPartitionFits(files, "root.img", 1<<20); err == nil {
t.Errorf("root.img over limit: expected error")
}
if err := checkPartitionFits(files, "missing.img", 1<<20); err == nil {
t.Errorf("missing file: expected error")
}
}
// buildZip returns the *zip.File entries for an in-memory zip containing
// the given members.
func buildZip(t *testing.T, members map[string][]byte) []*zip.File {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, data := range members {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip.Create %s: %v", name, err)
}
if _, err := w.Write(data); err != nil {
t.Fatalf("zip.Write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip.Close: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatalf("zip.NewReader: %v", err)
}
return zr.File
}

View File

@@ -32,6 +32,7 @@ func configureCmd() *ffcli.Command {
Subcommands: nonNilCmds(
configureKubeconfigCmd(),
synologyConfigureCmd(),
flashApplianceCmd(),
ccall(maybeConfigSynologyCertCmd),
ccall(maybeSysExtCmd),
ccall(maybeVPNConfigCmd),

View File

@@ -93,11 +93,19 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm
github.com/bradfitz/monogok/disklayout from tailscale.com/cmd/tailscale/cli+
github.com/coder/websocket from tailscale.com/util/eventbus
github.com/coder/websocket/internal/errd from github.com/coder/websocket
github.com/coder/websocket/internal/util from github.com/coder/websocket
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/pe+
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode
github.com/diskfs/go-diskfs/backend from github.com/diskfs/go-diskfs/filesystem/ext4+
github.com/diskfs/go-diskfs/filesystem from github.com/diskfs/go-diskfs/filesystem/ext4
github.com/diskfs/go-diskfs/filesystem/ext4 from tailscale.com/gokrazy/mkfs
github.com/diskfs/go-diskfs/filesystem/ext4/crc from github.com/diskfs/go-diskfs/filesystem/ext4
github.com/diskfs/go-diskfs/filesystem/ext4/md4 from github.com/diskfs/go-diskfs/filesystem/ext4
github.com/diskfs/go-diskfs/util/bitmap from github.com/diskfs/go-diskfs/filesystem/ext4
github.com/diskfs/go-diskfs/util/slices from github.com/diskfs/go-diskfs/filesystem/ext4
L github.com/fogleman/gg from tailscale.com/client/systray
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/gaissmai/bart from tailscale.com/net/tsdial
@@ -121,7 +129,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
L github.com/golang/freetype/raster from github.com/fogleman/gg+
L github.com/golang/freetype/truetype from github.com/fogleman/gg
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
DW github.com/google/uuid from tailscale.com/clientupdate+
github.com/google/uuid from tailscale.com/clientupdate+
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
@@ -171,7 +179,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+
tailscale.com/client/web from tailscale.com/cmd/tailscale/cli
tailscale.com/clientupdate from tailscale.com/cmd/tailscale/cli
LW tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate+
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/cmd/tailscale/cli/ffcomplete from tailscale.com/cmd/tailscale/cli
tailscale.com/cmd/tailscale/cli/ffcomplete/internal from tailscale.com/cmd/tailscale/cli/ffcomplete
@@ -200,6 +208,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/syspolicy from tailscale.com/cmd/tailscale/cli
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
tailscale.com/gokrazy/mkfs from tailscale.com/cmd/tailscale/cli
tailscale.com/health from tailscale.com/net/tlsdial+
tailscale.com/health/healthmsg from tailscale.com/cmd/tailscale/cli
tailscale.com/hostinfo from tailscale.com/client/web+
@@ -377,7 +386,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
archive/zip from tailscale.com/clientupdate+
bufio from compress/flate+
bytes from archive/tar+
cmp from slices+
@@ -455,7 +464,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
crypto/x509 from crypto/tls+
D crypto/x509/internal/macos from crypto/x509
crypto/x509/pkix from crypto/x509+
DW database/sql/driver from github.com/google/uuid
database/sql/driver from github.com/google/uuid
W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe
embed from github.com/peterbourgon/ff/v3+
@@ -475,7 +484,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
hash from compress/zlib+
hash/adler32 from compress/zlib
hash/crc32 from compress/gzip+
hash/fnv from tailscale.com/net/traffic
hash/fnv from tailscale.com/net/traffic+
hash/maphash from go4.org/mem
html from html/template+
html/template from tailscale.com/util/eventbus

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_flashappliance
package buildfeatures
// HasFlashAppliance is whether the binary was built with support for modular feature "'tailscale configure flash-appliance' CLI command for writing a Tailscale appliance image to a local disk".
// Specifically, it's whether the binary was NOT built with the "ts_omit_flashappliance" build tag.
// It's a const so it can be used for dead code elimination.
const HasFlashAppliance = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_flashappliance
package buildfeatures
// HasFlashAppliance is whether the binary was built with support for modular feature "'tailscale configure flash-appliance' CLI command for writing a Tailscale appliance image to a local disk".
// Specifically, it's whether the binary was NOT built with the "ts_omit_flashappliance" build tag.
// It's a const so it can be used for dead code elimination.
const HasFlashAppliance = true

View File

@@ -160,6 +160,7 @@ type FeatureMeta struct {
"desktop_sessions": {Sym: "DesktopSessions", Desc: "Desktop sessions support"},
"doctor": {Sym: "Doctor", Desc: "Diagnose possible issues with Tailscale and its host environment"},
"drive": {Sym: "Drive", Desc: "Tailscale Drive (file server) support"},
"flashappliance": {Sym: "FlashAppliance", Desc: "'tailscale configure flash-appliance' CLI command for writing a Tailscale appliance image to a local disk"},
"gro": {
Sym: "GRO",
Desc: "Generic Receive Offload support (performance)",

View File

@@ -164,4 +164,4 @@
});
};
}
# nix-direnv cache busting line: sha256-jHFZE8TvqiLd2U4CloE3HzVO9Jq6sDNNTsqDNx7bhHM=
# nix-direnv cache busting line: sha256-OcWKaba80LdWwpL4j2bmQC2w1MchYQtjOE2G9AgOf+0=

View File

@@ -4,7 +4,7 @@
"sri": "sha256-cY5yryX+p/xtoTv+WZEKFagiIl0OREHnJY1Bk5VpVVc="
},
"vendor": {
"goModSum": "sha256-iyJ4/iFU8LZH3ecGrIieGjTRpzMgD2qMw23PCjJTi+8=",
"sri": "sha256-jHFZE8TvqiLd2U4CloE3HzVO9Jq6sDNNTsqDNx7bhHM="
"goModSum": "sha256-WTfA+y87fLUAqXlV3/AMoNtZS8pK6NeuakgBCuwHIMk=",
"sri": "sha256-OcWKaba80LdWwpL4j2bmQC2w1MchYQtjOE2G9AgOf+0="
}
}

3
go.mod
View File

@@ -17,7 +17,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0
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-20260604043651-77f55eaefb19
github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f
github.com/bramvdbogaerde/go-scp v1.4.0
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1
@@ -31,6 +31,7 @@ require (
github.com/creack/pty v1.1.24
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e
github.com/diskfs/go-diskfs v1.9.3
github.com/distribution/reference v0.6.0
github.com/djherbis/times v1.6.0
github.com/dsnet/try v0.0.3

14
go.sum
View File

@@ -125,6 +125,8 @@ github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pO
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@@ -209,8 +211,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-20260604043651-77f55eaefb19 h1:1nIDDePfdaBpj8/Sv0pZVWylK5JqPVkS9CgxbwRRc8Y=
github.com/bradfitz/monogok v0.0.0-20260604043651-77f55eaefb19/go.mod h1:TG1HbU9fRVDnNgXncVkKz9GdvjIvqquXjH6QZSEVmY4=
github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f h1:voy0korWbg2e1gsJpBZ8/OBhVL8evXeUwbCZcA1PWv8=
github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f/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=
@@ -322,6 +324,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/diskfs/go-diskfs v1.9.3 h1:cLciNCeZ4QAXVxyPJDr1ZJ9N9CCG3rQlQ/z/Cs/cNDM=
github.com/diskfs/go-diskfs v1.9.3/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM=
github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
@@ -348,6 +352,8 @@ github.com/elastic/crd-ref-docs v0.0.12 h1:F3seyncbzUz3rT3d+caeYWhumb5ojYQ6Bl0Z+
github.com/elastic/crd-ref-docs v0.0.12/go.mod h1:X83mMBdJt05heJUYiS3T0yJ/JkCuliuhSUNav5Gjo/U=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg=
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -451,6 +457,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8=
github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU=
github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s=
@@ -992,6 +1000,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

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 @@
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
}

342
gokrazy/mkfs/mkfs.go Normal file
View File

@@ -0,0 +1,342 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package mkfs creates the writable ext4 /perm filesystem inside a
// gokrazy disk image or block device, at the offset and length
// determined by the gokrazy partition layout.
//
// Used by gokrazy/build.go when producing a "--full" disk image and by
// "tailscale configure flash-appliance" when flashing an image to an
// SD card, so the appliance has a working /perm on first boot without
// requiring users to install mkfs.ext4 (e.g. e2fsprogs on macOS).
package mkfs
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"slices"
"sync/atomic"
"time"
"github.com/bradfitz/monogok/disklayout"
"github.com/diskfs/go-diskfs/backend"
"github.com/diskfs/go-diskfs/filesystem/ext4"
)
// gptSecondaryReservedSectors is the number of 512-byte sectors that
// monogok's GPT writer reserves at the end of the disk for the
// secondary GPT (1 header sector + 32 partition-entry sectors). The
// perm partition entry written by disklayout.WriteGPT is this many
// sectors shorter than [disklayout.PermSize], so the ext4 filesystem
// we create must shrink by the same amount to fit within the partition
// the kernel sees.
const gptSecondaryReservedSectors = 34
const sectorSize = 512
// Perm creates an ext4 filesystem with volume label "PERM" inside the
// gokrazy /perm partition of f. devsizeBytes is the total disk size
// that the gokrazy GPT in f was written for; the partition layout is
// derived from it via [disklayout].
//
// To avoid issuing ext4.Create's hundreds of small scattered writes
// against slow storage one syscall at a time, the filesystem is first
// built in an in-memory sparse buffer and then only the genuinely
// non-zero metadata pages are flushed to f, coalesced into the
// fewest possible contiguous writes. ext4's initial superblock,
// group descriptors, bitmaps, root inode, etc. land at the same
// per-group byte offsets whether the destination had old ext4
// metadata or zeros there, so a fresh ext4 always overwrites stale
// metadata in place; data-area bytes that were never written are
// not read by the kernel until they're allocated.
//
// f must be open read/write, and on macOS should be the buffered
// /dev/diskN device rather than the raw /dev/rdiskN alias.
func Perm(f *os.File, devsizeBytes int64) error {
permStart := int64(disklayout.PermStartLBA(disklayout.DefaultBootPartitionStartLBA)) * sectorSize
permSize := int64(disklayout.PermSize(disklayout.DefaultBootPartitionStartLBA, uint64(devsizeBytes))-gptSecondaryReservedSectors) * sectorSize
fmt.Fprintf(os.Stderr, "Formatting /perm as ext4 (PERM): %s filesystem\n", humanBytes(permSize))
mem := newMemBackend(permSize)
if _, err := ext4.Create(mem, permSize, 0, sectorSize, &ext4.Params{
VolumeName: "PERM",
// Force 4 KiB blocks. go-diskfs v1.9.3 otherwise defaults to 1
// KiB blocks regardless of filesystem size, which makes a 128
// MiB journal need ~131k blocks — past the 65535-blocks-per-
// extent limit. 4 KiB blocks keep a typical journal in a
// single extent. (Fixed upstream after v1.9.3.)
SectorsPerBlock: 8,
// Disable resize_inode. go-diskfs v1.9.3 only implements it
// for 1 KiB block filesystems; for our 4 KiB blocks +
// ~96 MiB perm, initResizeInode fails with "no backup groups
// available". Matches go-diskfs's own tests for non-1 KiB
// block sizes.
Features: []ext4.FeatureOpt{
ext4.WithFeatureReservedGDTBlocksForExpansion(false),
},
}); err != nil {
return fmt.Errorf("ext4.Create: %w", err)
}
return mem.flushTo(f, permStart)
}
// memPageSize is the granularity of memBackend's sparse allocation.
// 4 KiB matches the ext4 block size we use, so most of ext4.Create's
// writes touch exactly one page.
const memPageSize = 4096
// memBackend is a sparse in-memory implementation of go-diskfs's
// [backend.Storage]. It only allocates a [memPageSize]-byte chunk for
// each page that ext4.Create actually touches; unwritten regions cost
// only a map entry's worth of overhead and read back as zeros. The
// caller flushes the allocated pages to the destination in contiguous
// runs via [memBackend.flushTo].
type memBackend struct {
size int64 // logical size of the virtual device
pages map[int64][]byte // page index → memPageSize bytes
off int64 // current offset for io.Reader / io.Seeker compatibility
}
func newMemBackend(size int64) *memBackend {
return &memBackend{
size: size,
pages: make(map[int64][]byte),
}
}
// ReadAt implements [io.ReaderAt]. Bytes within pages that were never
// written read as zero.
func (m *memBackend) ReadAt(p []byte, off int64) (int, error) {
if off < 0 || off >= m.size {
return 0, io.EOF
}
if max := m.size - off; int64(len(p)) > max {
p = p[:max]
}
// Default everything to zero; allocated pages overwrite below.
clear(p)
total := 0
for total < len(p) {
absOff := off + int64(total)
page := absOff / memPageSize
within := int(absOff % memPageSize)
room := memPageSize - within
if room > len(p)-total {
room = len(p) - total
}
if chunk, ok := m.pages[page]; ok {
copy(p[total:total+room], chunk[within:within+room])
}
total += room
}
if int64(total) < int64(len(p)) {
return total, io.EOF
}
return total, nil
}
// WriteAt implements [io.WriterAt]. Pages are allocated on first
// touch, except that writes whose data is entirely zero do NOT
// allocate (or modify) any page: the caller's destination is assumed
// to already have zeros where we never write. ext4.Create writes
// tens-to-hundreds of MiB of zeros to initialize the inode table and
// journal; suppressing those allocations is what keeps memory and SD
// card writes proportional to the *real* metadata rather than the
// filesystem size.
//
// CAVEAT: if the destination has stale non-zero data in those regions
// (e.g. an SD card previously formatted with a different filesystem),
// that data is left in place. For a fresh card this is fine; for
// re-flashed cards the perm region's old data could confuse ext4's
// recovery on first mount. Callers that re-flash should discard the
// perm region first; we don't do that here.
func (m *memBackend) WriteAt(p []byte, off int64) (int, error) {
if off < 0 || off+int64(len(p)) > m.size {
return 0, fmt.Errorf("write past buffer end: off=%d len=%d size=%d", off, len(p), m.size)
}
total := 0
for total < len(p) {
absOff := off + int64(total)
page := absOff / memPageSize
within := int(absOff % memPageSize)
room := memPageSize - within
if room > len(p)-total {
room = len(p) - total
}
chunk, ok := m.pages[page]
if !ok && isAllZero(p[total:total+room]) {
// Don't allocate a fresh zero page.
total += room
continue
}
if !ok {
chunk = make([]byte, memPageSize)
m.pages[page] = chunk
}
copy(chunk[within:within+room], p[total:total+room])
total += room
}
return total, nil
}
// isAllZero reports whether p is entirely 0x00.
func isAllZero(p []byte) bool {
for _, b := range p {
if b != 0 {
return false
}
}
return true
}
// Read implements [io.Reader].
func (m *memBackend) Read(p []byte) (int, error) {
n, err := m.ReadAt(p, m.off)
m.off += int64(n)
return n, err
}
// Seek implements [io.Seeker].
func (m *memBackend) Seek(off int64, whence int) (int64, error) {
switch whence {
case io.SeekStart:
m.off = off
case io.SeekCurrent:
m.off += off
case io.SeekEnd:
m.off = m.size + off
default:
return 0, fmt.Errorf("invalid whence %d", whence)
}
return m.off, nil
}
// Close implements [io.Closer].
func (m *memBackend) Close() error { return nil }
// Stat implements [fs.File].
func (m *memBackend) Stat() (fs.FileInfo, error) {
return memFileInfo{size: m.size}, nil
}
// Sys implements [backend.Storage]; it returns ErrNotSuitable so
// ext4.Create's optional fsync (ext4.go:730) is gracefully skipped.
func (m *memBackend) Sys() (*os.File, error) { return nil, backend.ErrNotSuitable }
// Writable implements [backend.Storage].
func (m *memBackend) Writable() (backend.WritableFile, error) { return m, nil }
// Path implements [backend.Storage].
func (m *memBackend) Path() string { return "" }
type memFileInfo struct{ size int64 }
func (fi memFileInfo) Name() string { return "mkfs-buffer" }
func (fi memFileInfo) Size() int64 { return fi.size }
func (fi memFileInfo) Mode() fs.FileMode { return 0o600 }
func (fi memFileInfo) ModTime() time.Time { return time.Time{} }
func (fi memFileInfo) IsDir() bool { return false }
func (fi memFileInfo) Sys() any { return nil }
// flushTo writes the allocated (non-zero) pages of m to f at
// baseOffset+pageIndex*memPageSize, coalescing consecutive page
// indices into a single WriteAt so the destination sees the fewest
// possible writes. Pages that ext4.Create only ever wrote zeros into
// were never allocated by WriteAt and are not written here either; the
// destination is assumed to have zeros (or a previous ext4 install's
// metadata in the same locations, which is functionally equivalent
// since fresh ext4 metadata overwrites it in place).
//
// Progress is printed to os.Stderr roughly once per second.
func (m *memBackend) flushTo(f io.WriterAt, baseOffset int64) error {
if len(m.pages) == 0 {
return errors.New("BUG: ext4.Create allocated no pages")
}
keys := make([]int64, 0, len(m.pages))
for k := range m.pages {
keys = append(keys, k)
}
slices.Sort(keys)
totalBytes := int64(len(m.pages)) * memPageSize
var written atomic.Int64
stop := startExt4FlushProgress(&written, totalBytes)
defer stop()
for i := 0; i < len(keys); {
runStart := keys[i]
j := i
for j < len(keys) && keys[j] == runStart+int64(j-i) {
j++
}
runPages := keys[i:j]
buf := make([]byte, len(runPages)*memPageSize)
for k, page := range runPages {
copy(buf[k*memPageSize:], m.pages[page])
}
if _, err := f.WriteAt(buf, baseOffset+runStart*memPageSize); err != nil {
return fmt.Errorf("flushing perm metadata: %w", err)
}
written.Add(int64(len(buf)))
i = j
}
return nil
}
// startExt4FlushProgress prints "ext4 perm: N MB / M MB (X%)" to
// os.Stderr roughly once a second, plus a final tick on stop. Returns
// a function the caller must invoke when the flush is done.
func startExt4FlushProgress(done *atomic.Int64, total int64) func() {
stopCh := make(chan struct{})
finished := make(chan struct{})
go func() {
defer close(finished)
t := time.NewTicker(time.Second)
defer t.Stop()
report := func() {
d := done.Load()
pct := 0.0
if total > 0 {
pct = float64(d) * 100 / float64(total)
}
fmt.Fprintf(os.Stderr, " ext4 perm: %s / %s (%.1f%%)\n",
humanBytes(d), humanBytes(total), pct)
}
for {
select {
case <-stopCh:
report()
return
case <-t.C:
report()
}
}
}()
return func() {
close(stopCh)
<-finished
}
}
func humanBytes(n int64) string {
const (
gb = 1 << 30
mb = 1 << 20
kb = 1 << 10
)
switch {
case n >= gb:
return fmt.Sprintf("%.1f GB", float64(n)/float64(gb))
case n >= mb:
return fmt.Sprintf("%.1f MB", float64(n)/float64(mb))
case n >= kb:
return fmt.Sprintf("%.1f KB", float64(n)/float64(kb))
default:
return fmt.Sprintf("%d B", n)
}
}

183
gokrazy/mkfs/mkfs_test.go Normal file
View File

@@ -0,0 +1,183 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package mkfs
import (
"bytes"
"testing"
"github.com/diskfs/go-diskfs/filesystem/ext4"
)
// fakeWriterAt records every WriteAt to a single contiguous backing
// buffer (so tests can inspect what flushTo produced) and counts the
// calls so we can assert the chunked flush issues a predictable
// handful of big sequential writes.
type fakeWriterAt struct {
buf []byte
calls int
sizes []int
}
func (w *fakeWriterAt) WriteAt(p []byte, off int64) (int, error) {
w.calls++
w.sizes = append(w.sizes, len(p))
if int(off)+len(p) > len(w.buf) {
w.buf = append(w.buf, make([]byte, int(off)+len(p)-len(w.buf))...)
}
return copy(w.buf[off:], p), nil
}
// TestMemBackendSparseAlloc exercises ext4.Create against an in-memory
// memBackend sized like a typical /perm partition and confirms that
// the page allocator stays small. ext4.Create issues writes for tens
// to hundreds of MiB of zero-initialized inode table and journal; we
// rely on memBackend.WriteAt suppressing those zero writes so that
// the eventual flush to the (slow) SD card stays under a few MiB.
//
// The assertion is intentionally loose — we only catch regressions
// that bloat by an order of magnitude, not bookkeeping changes.
func TestMemBackendSparseAlloc(t *testing.T) {
for _, tc := range []struct {
name string
sizeBytes int64
maxPagesKiB int64
}{
// ~96 MiB matches our tsapp pi/vm builds with
// target_storage_bytes=1258299392.
{"96MiB", 96 * 1024 * 1024, 256},
// 2 GiB is the size the user complained about in the
// flash-appliance progress meter: ext4.Create wrote ~131
// MiB before suppression.
{"2GiB", 2 * 1024 * 1024 * 1024, 1024},
// 32 GiB simulates a full-size SD card. ext4.Create would
// write a few hundred MiB of zeros for the inode table; we
// must still stay tiny.
{"32GiB", 32 * 1024 * 1024 * 1024, 2048},
} {
t.Run(tc.name, func(t *testing.T) {
mem := newMemBackend(tc.sizeBytes)
_, err := ext4.Create(mem, tc.sizeBytes, 0, sectorSize, &ext4.Params{
VolumeName: "PERM",
SectorsPerBlock: 8,
Features: []ext4.FeatureOpt{
ext4.WithFeatureReservedGDTBlocksForExpansion(false),
},
})
if err != nil {
t.Fatalf("ext4.Create: %v", err)
}
pageBytes := int64(len(mem.pages)) * memPageSize
t.Logf("%s filesystem: %d allocated pages (%d KiB)",
tc.name, len(mem.pages), pageBytes/1024)
if pageBytes/1024 > tc.maxPagesKiB {
t.Errorf("allocated %d KiB; want < %d KiB", pageBytes/1024, tc.maxPagesKiB)
}
})
}
}
// TestFlushToDirtyOnly exercises memBackend.flushTo against a fake
// io.WriterAt: it must issue only one WriteAt per maximal run of
// allocated (non-zero) pages — never anything for the gaps in between
// — and the bytes at each destination offset must match what was
// originally written.
func TestFlushToDirtyOnly(t *testing.T) {
const size = 40 * 1024 * 1024
m := newMemBackend(size)
// Two contiguous runs separated by a large all-zero gap. The
// flush should issue exactly two WriteAt calls (one per run),
// and never touch the gap between them.
page := func(b byte) []byte {
p := make([]byte, memPageSize)
p[0] = b
return p
}
// Run 1: 2 consecutive pages at offset 0.
if _, err := m.WriteAt(page(0x11), 0); err != nil {
t.Fatalf("WriteAt: %v", err)
}
if _, err := m.WriteAt(page(0x22), memPageSize); err != nil {
t.Fatalf("WriteAt: %v", err)
}
// Run 2: 1 page at the end of the region.
if _, err := m.WriteAt(page(0x33), size-memPageSize); err != nil {
t.Fatalf("WriteAt: %v", err)
}
const baseOffset int64 = 1 << 20
fw := &fakeWriterAt{}
if err := m.flushTo(fw, baseOffset); err != nil {
t.Fatalf("flushTo: %v", err)
}
if fw.calls != 2 {
t.Errorf("WriteAt calls=%d; want 2 (one per dirty run), sizes=%v", fw.calls, fw.sizes)
}
if got, want := fw.sizes[0], 2*memPageSize; got != want {
t.Errorf("first run size=%d; want %d (2 contiguous pages)", got, want)
}
if got, want := fw.sizes[1], memPageSize; got != want {
t.Errorf("second run size=%d; want %d (1 page)", got, want)
}
// Page contents at the right absolute offsets.
if fw.buf[baseOffset+0] != 0x11 {
t.Errorf("page 0 marker = %#x; want 0x11", fw.buf[baseOffset+0])
}
if fw.buf[baseOffset+memPageSize] != 0x22 {
t.Errorf("page 1 marker = %#x; want 0x22", fw.buf[baseOffset+memPageSize])
}
if fw.buf[baseOffset+size-memPageSize] != 0x33 {
t.Errorf("last page marker = %#x; want 0x33", fw.buf[baseOffset+size-memPageSize])
}
// The gap pages between run 1 and run 2 must not have been touched
// at all in the fake's backing buffer (it lazily grows on WriteAt;
// untouched bytes stay zero).
for _, off := range []int64{2 * memPageSize, 8 * 1024 * 1024, 20 * 1024 * 1024} {
if !bytes.Equal(fw.buf[baseOffset+off:baseOffset+off+memPageSize], make([]byte, memPageSize)) {
t.Errorf("flushTo touched an unallocated gap at offset %d", off)
}
}
}
// TestMemBackendZeroSuppressed asserts that a write whose data is all
// zero does not allocate a page when the destination page is absent —
// the core invariant that makes TestMemBackendSparseAlloc pass — and
// that writes touching multiple pages allocate per-page based on
// whether each page's slice has any non-zero byte.
func TestMemBackendZeroSuppressed(t *testing.T) {
m := newMemBackend(1 << 20)
// All-zero write spanning 2 pages: nothing allocated.
zero := make([]byte, 8192)
if _, err := m.WriteAt(zero, 4096); err != nil {
t.Fatalf("WriteAt zero: %v", err)
}
if got := len(m.pages); got != 0 {
t.Errorf("after %d-byte zero write: %d pages, want 0", len(zero), got)
}
// Non-zero byte in page 0 only: page 0 allocated; page 1 stays
// zero-suppressed.
mixed := make([]byte, 8192)
mixed[100] = 1
if _, err := m.WriteAt(mixed, 0); err != nil {
t.Fatalf("WriteAt mixed: %v", err)
}
if got := len(m.pages); got != 1 {
t.Errorf("after write with non-zero only in page 0: %d pages, want 1", got)
}
// Non-zero bytes in both pages: both allocated.
m = newMemBackend(1 << 20)
mixed[5000] = 1 // also non-zero in page 1
if _, err := m.WriteAt(mixed, 0); err != nil {
t.Fatalf("WriteAt mixed-both: %v", err)
}
if got := len(m.pages); got != 2 {
t.Errorf("after write with non-zero in pages 0 and 1: %d pages, want 2", got)
}
}

View File

@@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-jHFZE8TvqiLd2U4CloE3HzVO9Jq6sDNNTsqDNx7bhHM=
# nix-direnv cache busting line: sha256-OcWKaba80LdWwpL4j2bmQC2w1MchYQtjOE2G9AgOf+0=