diff --git a/Makefile b/Makefile index 0efd57fb4..28bf09f11 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/clientupdate/clientupdate.go b/clientupdate/clientupdate.go index cb9ddb3aa..20d2e7b0a 100644 --- a/clientupdate/clientupdate.go +++ b/clientupdate/clientupdate.go @@ -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 //?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 +// //?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) } diff --git a/clientupdate/clientupdate_gokrazy.go b/clientupdate/clientupdate_gokrazy.go index fb497d701..bad473fac 100644 --- a/clientupdate/clientupdate_gokrazy.go +++ b/clientupdate/clientupdate_gokrazy.go @@ -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 ".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) { diff --git a/clientupdate/clientupdate_test.go b/clientupdate/clientupdate_test.go index 8095151c8..d2e7601be 100644 --- a/clientupdate/clientupdate_test.go +++ b/clientupdate/clientupdate_test.go @@ -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, diff --git a/clientupdate/distsign/url.go b/clientupdate/distsign/url.go new file mode 100644 index 000000000..1571cc73a --- /dev/null +++ b/clientupdate/distsign/url.go @@ -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) +} diff --git a/cmd/tailscale/cli/configure-flash-appliance.go b/cmd/tailscale/cli/configure-flash-appliance.go new file mode 100644 index 000000000..113014242 --- /dev/null +++ b/cmd/tailscale/cli/configure-flash-appliance.go @@ -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=", 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 ": / " +// 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) + } +} diff --git a/cmd/tailscale/cli/configure-flash-appliance_darwin.go b/cmd/tailscale/cli/configure-flash-appliance_darwin.go new file mode 100644 index 000000000..254fd6de6 --- /dev/null +++ b/cmd/tailscale/cli/configure-flash-appliance_darwin.go @@ -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 . 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 `. +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 , , +// , , , and nested 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 element as alternating +// ... 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 ", 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() + } +} diff --git a/cmd/tailscale/cli/configure-flash-appliance_darwin_test.go b/cmd/tailscale/cli/configure-flash-appliance_darwin_test.go new file mode 100644 index 000000000..c82754dff --- /dev/null +++ b/cmd/tailscale/cli/configure-flash-appliance_darwin_test.go @@ -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 = ` + + + + AllDisks + + disk4 + disk4s1 + + WholeDisks + + disk4 + + +` + +const diskutilInfoSample = ` + + + + DeviceIdentifier + disk4 + DeviceModel + Generic STORAGE DEVICE + MediaName + Generic STORAGE DEVICE Media + Size + 62512365568 + Removable + + +` + +const diskutilInfoRootSample = ` + + + + DeviceIdentifier + disk3s1s1 + ParentWholeDisk + disk3 + APFSPhysicalStores + + + APFSPhysicalStore + disk0s2 + + + +` + +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) + } +} diff --git a/cmd/tailscale/cli/configure-flash-appliance_linux.go b/cmd/tailscale/cli/configure-flash-appliance_linux.go new file mode 100644 index 000000000..987447ea6 --- /dev/null +++ b/cmd/tailscale/cli/configure-flash-appliance_linux.go @@ -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 "p". 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 +} diff --git a/cmd/tailscale/cli/configure-flash-appliance_linux_test.go b/cmd/tailscale/cli/configure-flash-appliance_linux_test.go new file mode 100644 index 000000000..7da5c064b --- /dev/null +++ b/cmd/tailscale/cli/configure-flash-appliance_linux_test.go @@ -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) + } + } +} diff --git a/cmd/tailscale/cli/configure-flash-appliance_omit.go b/cmd/tailscale/cli/configure-flash-appliance_omit.go new file mode 100644 index 000000000..903fe2ac4 --- /dev/null +++ b/cmd/tailscale/cli/configure-flash-appliance_omit.go @@ -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 +} diff --git a/cmd/tailscale/cli/configure-flash-appliance_other.go b/cmd/tailscale/cli/configure-flash-appliance_other.go new file mode 100644 index 000000000..2253f69e7 --- /dev/null +++ b/cmd/tailscale/cli/configure-flash-appliance_other.go @@ -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 } diff --git a/cmd/tailscale/cli/configure-flash-appliance_test.go b/cmd/tailscale/cli/configure-flash-appliance_test.go new file mode 100644 index 000000000..9a1fd9a7b --- /dev/null +++ b/cmd/tailscale/cli/configure-flash-appliance_test.go @@ -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 +} diff --git a/cmd/tailscale/cli/configure.go b/cmd/tailscale/cli/configure.go index e7a6448e7..0fda1dab1 100644 --- a/cmd/tailscale/cli/configure.go +++ b/cmd/tailscale/cli/configure.go @@ -32,6 +32,7 @@ func configureCmd() *ffcli.Command { Subcommands: nonNilCmds( configureKubeconfigCmd(), synologyConfigureCmd(), + flashApplianceCmd(), ccall(maybeConfigSynologyCertCmd), ccall(maybeSysExtCmd), ccall(maybeVPNConfigCmd), diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 896225a8d..c7a063962 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -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 diff --git a/feature/buildfeatures/feature_flashappliance_disabled.go b/feature/buildfeatures/feature_flashappliance_disabled.go new file mode 100644 index 000000000..9f8978cbd --- /dev/null +++ b/feature/buildfeatures/feature_flashappliance_disabled.go @@ -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 diff --git a/feature/buildfeatures/feature_flashappliance_enabled.go b/feature/buildfeatures/feature_flashappliance_enabled.go new file mode 100644 index 000000000..c02f6bc2c --- /dev/null +++ b/feature/buildfeatures/feature_flashappliance_enabled.go @@ -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 diff --git a/feature/featuretags/featuretags.go b/feature/featuretags/featuretags.go index 0c2099d52..8ca4c2831 100644 --- a/feature/featuretags/featuretags.go +++ b/feature/featuretags/featuretags.go @@ -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)", diff --git a/flake.nix b/flake.nix index ad7501b50..0b6d79475 100644 --- a/flake.nix +++ b/flake.nix @@ -164,4 +164,4 @@ }); }; } -# nix-direnv cache busting line: sha256-jHFZE8TvqiLd2U4CloE3HzVO9Jq6sDNNTsqDNx7bhHM= +# nix-direnv cache busting line: sha256-OcWKaba80LdWwpL4j2bmQC2w1MchYQtjOE2G9AgOf+0= diff --git a/flakehashes.json b/flakehashes.json index 04faa5053..b38b682bc 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -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=" } } diff --git a/go.mod b/go.mod index cbd8ed0a2..22fdc0299 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a646a4256..09766d5f8 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/gokrazy/build.go b/gokrazy/build.go index e2721e80c..0e32cebab 100644 --- a/gokrazy/build.go +++ b/gokrazy/build.go @@ -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 } diff --git a/gokrazy/mkfs/mkfs.go b/gokrazy/mkfs/mkfs.go new file mode 100644 index 000000000..0e9dedaed --- /dev/null +++ b/gokrazy/mkfs/mkfs.go @@ -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) + } +} diff --git a/gokrazy/mkfs/mkfs_test.go b/gokrazy/mkfs/mkfs_test.go new file mode 100644 index 000000000..4cb73078d --- /dev/null +++ b/gokrazy/mkfs/mkfs_test.go @@ -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) + } +} diff --git a/shell.nix b/shell.nix index 1e00b54fb..3ef4b7d5a 100644 --- a/shell.nix +++ b/shell.nix @@ -16,4 +16,4 @@ ) { src = ./.; }).shellNix -# nix-direnv cache busting line: sha256-jHFZE8TvqiLd2U4CloE3HzVO9Jq6sDNNTsqDNx7bhHM= +# nix-direnv cache busting line: sha256-OcWKaba80LdWwpL4j2bmQC2w1MchYQtjOE2G9AgOf+0=