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

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

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

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

Behind a ts_omit_flashappliance build tag (on by default).

Updates #1866

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ic1a8cd185e7039edccb7702ab4104544fcb58d29
This commit is contained in:
Brad Fitzpatrick
2026-07-01 08:09:50 -07:00
committed by Brad Fitzpatrick
parent 64422f274d
commit d0fcb668d5
26 changed files with 2015 additions and 101 deletions
@@ -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)
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
}
@@ -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
}
@@ -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 }
@@ -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
}
+1
View File
@@ -32,6 +32,7 @@ services on the host to use Tailscale in more ways.
Subcommands: nonNilCmds(
configureKubeconfigCmd(),
synologyConfigureCmd(),
flashApplianceCmd(),
ccall(maybeConfigSynologyCertCmd),
ccall(maybeSysExtCmd),
ccall(maybeVPNConfigCmd),
+14 -5
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