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:
committed by
Brad Fitzpatrick
parent
64422f274d
commit
d0fcb668d5
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user