mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-14 17:43:03 -04:00
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
8
Makefile
8
Makefile
@@ -148,6 +148,14 @@ sshintegrationtest: ## Run the SSH integration tests in various Docker container
|
||||
generate: ## Generate code
|
||||
./tool/go generate ./...
|
||||
|
||||
.PHONY: tsapp-build-and-flash-pi
|
||||
tsapp-build-and-flash-pi: ## Build a tsapp-pi.arm64 GAF from HEAD and flash a local SD card (macOS auto-detects the disk; pass DISK=/dev/sdX on Linux)
|
||||
cd gokrazy && ../tool/go run build.go --gaf --app=tsapp-pi.arm64
|
||||
./tool/go run --exec=sudo ./cmd/tailscale configure flash-appliance \
|
||||
--variant=pi-arm64 \
|
||||
--gaf=gokrazy/tsapp-pi.arm64.gaf \
|
||||
$(if $(DISK),--disk=$(DISK))
|
||||
|
||||
.PHONY: pin-github-actions
|
||||
pin-github-actions:
|
||||
./tool/go tool github.com/stacklok/frizbee actions .github/workflows
|
||||
|
||||
@@ -361,7 +361,7 @@ func (up *Updater) updateSynology() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest, err := latestPackages(up.Track)
|
||||
latest, err := LatestPackages(up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -906,7 +906,7 @@ func (up *Updater) updateGokrazy() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest, err := latestPackages(up.Track)
|
||||
latest, err := LatestPackages(up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1305,7 +1305,7 @@ func LatestTailscaleVersion(track string) (string, error) {
|
||||
track = CurrentTrack
|
||||
}
|
||||
|
||||
latest, err := latestPackages(track)
|
||||
latest, err := LatestPackages(track)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1331,7 +1331,8 @@ func LatestTailscaleVersion(track string) (string, error) {
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
type trackPackages struct {
|
||||
// TrackPackages is the JSON shape served at <pkgs>/<track>/?mode=json.
|
||||
type TrackPackages struct {
|
||||
Version string
|
||||
Tarballs map[string]string
|
||||
TarballsVersion string
|
||||
@@ -1349,14 +1350,16 @@ type trackPackages struct {
|
||||
|
||||
var tailscaleHTTPEndpoint = "https://pkgs.tailscale.com"
|
||||
|
||||
func latestPackages(track string) (*trackPackages, error) {
|
||||
// LatestPackages fetches the package manifest served at
|
||||
// <pkgs>/<track>/?mode=json for the current runtime.GOOS.
|
||||
func LatestPackages(track string) (*TrackPackages, error) {
|
||||
url := fmt.Sprintf("%s/%s/?mode=json&os=%s", tailscaleHTTPEndpoint, track, runtime.GOOS)
|
||||
res, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching latest tailscale version: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var latest trackPackages
|
||||
var latest TrackPackages
|
||||
if err := json.NewDecoder(res.Body).Decode(&latest); err != nil {
|
||||
return nil, fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -52,11 +51,11 @@ func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error {
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if args.AllowUnsigned {
|
||||
if err := downloadGAFUnverified(ctx, args.URL, tmpName); err != nil {
|
||||
if err := downloadUnverified(ctx, args.URL, tmpName); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := downloadGAFVerified(ctx, logf, args.URL, tmpName); err != nil {
|
||||
if err := distsign.DownloadVerified(ctx, logf, args.URL, tmpName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -92,10 +91,10 @@ func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadGAFUnverified saves the GAF at srcURL to dstPath without verifying a
|
||||
// signature. It is used only when args.AllowUnsigned is set, for tests that
|
||||
// serve the GAF from a fileserver that does not publish distsign.pub.
|
||||
func downloadGAFUnverified(ctx context.Context, srcURL, dstPath string) error {
|
||||
// downloadUnverified saves the GAF at srcURL to dstPath without verifying
|
||||
// a signature. It is used only when args.AllowUnsigned is set, for tests
|
||||
// that serve the GAF from a fileserver that does not publish distsign.pub.
|
||||
func downloadUnverified(ctx context.Context, srcURL, dstPath string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", srcURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -119,32 +118,6 @@ func downloadGAFUnverified(ctx context.Context, srcURL, dstPath string) error {
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// downloadGAFVerified saves the GAF at srcURL to dstPath, verifying the
|
||||
// detached ed25519 signature at "<srcURL>.sig" against the root signing keys
|
||||
// embedded in this binary via the distsign package.
|
||||
//
|
||||
// The signing-key bundle distsign.pub and its signature distsign.pub.sig are
|
||||
// fetched from the root of the server hosting srcURL.
|
||||
func downloadGAFVerified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error {
|
||||
u, err := url.Parse(srcURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing GAF URL %q: %w", srcURL, err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("GAF URL %q is missing scheme or host", srcURL)
|
||||
}
|
||||
base := &url.URL{Scheme: u.Scheme, User: u.User, Host: u.Host}
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
if path == "" {
|
||||
return fmt.Errorf("GAF URL %q has no path component", srcURL)
|
||||
}
|
||||
c, err := distsign.NewClient(logf, base.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Download(ctx, path, dstPath)
|
||||
}
|
||||
|
||||
func gokrazyHTTPClient() *http.Client {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
|
||||
@@ -373,7 +373,7 @@ func TestCheckOutdatedAlpineRepo(t *testing.T) {
|
||||
|
||||
testServ := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
version := trackPackages{
|
||||
version := TrackPackages{
|
||||
MSIsVersion: tt.latestHTTPVersion,
|
||||
MacZipsVersion: tt.latestHTTPVersion,
|
||||
TarballsVersion: tt.latestHTTPVersion,
|
||||
|
||||
42
clientupdate/distsign/url.go
Normal file
42
clientupdate/distsign/url.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package distsign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// DownloadVerified is a convenience wrapper around [Client.Download]
|
||||
// for callers that have a full URL (e.g.
|
||||
// https://pkgs.tailscale.com/unstable/foo.gaf) rather than a base URL
|
||||
// plus path. It splits srcURL into a base ("scheme://host") and a path,
|
||||
// constructs a [Client] for the base, and downloads with signature
|
||||
// verification to dstPath.
|
||||
func DownloadVerified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error {
|
||||
if logf == nil {
|
||||
logf = logger.Discard
|
||||
}
|
||||
u, err := url.Parse(srcURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing URL %q: %w", srcURL, err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("URL %q is missing scheme or host", srcURL)
|
||||
}
|
||||
base := &url.URL{Scheme: u.Scheme, User: u.User, Host: u.Host}
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
if path == "" {
|
||||
return fmt.Errorf("URL %q has no path component", srcURL)
|
||||
}
|
||||
c, err := NewClient(logf, base.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Download(ctx, path, dstPath)
|
||||
}
|
||||
518
cmd/tailscale/cli/configure-flash-appliance.go
Normal file
518
cmd/tailscale/cli/configure-flash-appliance.go
Normal file
@@ -0,0 +1,518 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_flashappliance
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/bradfitz/monogok/disklayout"
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/clientupdate/distsign"
|
||||
"tailscale.com/gokrazy/mkfs"
|
||||
"tailscale.com/util/prompt"
|
||||
)
|
||||
|
||||
var flashApplianceArgs struct {
|
||||
variant string
|
||||
disk string
|
||||
track string
|
||||
yes bool
|
||||
gaf string
|
||||
}
|
||||
|
||||
func flashApplianceCmd() *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "flash-appliance",
|
||||
ShortUsage: "tailscale configure flash-appliance [flags]",
|
||||
ShortHelp: "Download a signed Tailscale appliance image and write it to a local disk [experimental]",
|
||||
LongHelp: hidden + strings.TrimSpace(`
|
||||
This experimental command downloads a signed Tailscale appliance image (Gokrazy archive
|
||||
format, "GAF") from pkgs.tailscale.com, verifies its signature, and writes
|
||||
it to a local block device (SD card, USB drive, virtual disk).
|
||||
|
||||
On macOS, the target disk is auto-discovered from 'diskutil list physical',
|
||||
excluding whichever disks back the running root. On Linux, you must pass
|
||||
--disk=/dev/sdX explicitly.
|
||||
|
||||
This command requires mkfs.ext4 in $PATH to format the writable /perm
|
||||
partition. On macOS, 'brew install e2fsprogs' provides it.
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("flash-appliance")
|
||||
fs.StringVar(&flashApplianceArgs.variant, "variant", "", `appliance variant: "pi-arm64", "vm-amd64", or "vm-arm64". Empty prompts interactively.`)
|
||||
fs.StringVar(&flashApplianceArgs.disk, "disk", "", "target block device (e.g. /dev/sdb or /dev/disk4)")
|
||||
fs.StringVar(&flashApplianceArgs.track, "track", "", `which track to download from; defaults to "`+clientupdate.CurrentTrack+`"`)
|
||||
fs.BoolVar(&flashApplianceArgs.yes, "yes", false, "skip the destructive-write confirmation prompt")
|
||||
fs.StringVar(&flashApplianceArgs.gaf, "gaf", "", "use a local GAF file instead of downloading (skips signature verification)")
|
||||
return fs
|
||||
})(),
|
||||
Exec: runFlashAppliance,
|
||||
}
|
||||
}
|
||||
|
||||
func runFlashAppliance(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return errors.New("unknown arguments")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return errors.New("flash-appliance is not supported on Windows yet; consider running under WSL")
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return errors.New("writing to a raw block device requires root; re-run with sudo")
|
||||
}
|
||||
|
||||
disk, err := resolveTargetDisk(ctx, flashApplianceArgs.disk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gafPath, gafLabel, variant, cleanup, err := obtainGAF(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
zr, err := zip.OpenReader(gafPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open GAF: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
bootCode, err := readGAFMember(zr.File, "mbr.img", 1<<20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !flashApplianceArgs.yes {
|
||||
msg := fmt.Sprintf("This will ERASE %s. Flash %s?", disk.Path, gafLabel)
|
||||
if !prompt.YesNo(msg, false) {
|
||||
return errors.New("aborted")
|
||||
}
|
||||
}
|
||||
|
||||
printf("Unmounting %s...\n", disk.Path)
|
||||
if err := unmountDisk(ctx, disk.Path); err != nil {
|
||||
return fmt.Errorf("unmount %s: %w", disk.Path, err)
|
||||
}
|
||||
|
||||
if err := writeGAFToDisk(zr.File, disk.Path, bootCode, variant); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := formatPermExt4(disk.Path); err != nil {
|
||||
return fmt.Errorf("formatting perm: %w", err)
|
||||
}
|
||||
|
||||
ejected, err := ejectDisk(ctx, disk.Path)
|
||||
if err != nil {
|
||||
// Non-fatal: the user can eject manually.
|
||||
fmt.Fprintf(Stderr, "ejecting %s: %v\n", disk.Path, err)
|
||||
}
|
||||
|
||||
printf("Done. %s\n", flashSuccessHint(disk.Path, variant, ejected))
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatPermExt4 creates an ext4 filesystem inside the gokrazy perm
|
||||
// partition of the disk at diskPath, delegating to gokrazy/mkfs.Perm.
|
||||
//
|
||||
// On macOS we open the buffered /dev/diskN path (not /dev/rdiskN)
|
||||
// because go-diskfs writes ext4 metadata in small unaligned chunks
|
||||
// that the raw character device rejects.
|
||||
func formatPermExt4(diskPath string) error {
|
||||
f, err := os.OpenFile(diskPath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
devsize, err := blockDeviceSize(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sizing %s: %w", diskPath, err)
|
||||
}
|
||||
return mkfs.Perm(f, devsize)
|
||||
}
|
||||
|
||||
// flashSuccessHint returns a per-variant next-step hint shown after a
|
||||
// successful flash. variant is empty when the user passed --gaf
|
||||
// directly. ejected reports whether we already released the disk (true
|
||||
// on macOS after diskutil eject); when false, the message tells the
|
||||
// user to eject it themselves.
|
||||
func flashSuccessHint(diskPath, variant string, ejected bool) string {
|
||||
verb := "Eject"
|
||||
if ejected {
|
||||
verb = "Pull"
|
||||
}
|
||||
switch variant {
|
||||
case "pi-arm64":
|
||||
return fmt.Sprintf("%s %s and boot your Raspberry Pi.", verb, diskPath)
|
||||
case "vm-amd64":
|
||||
return fmt.Sprintf("%s %s and boot an x86_64 VM from it.", verb, diskPath)
|
||||
case "vm-arm64":
|
||||
return fmt.Sprintf("%s %s and boot an arm64 VM from it.", verb, diskPath)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s and boot the target device.", verb, diskPath)
|
||||
}
|
||||
}
|
||||
|
||||
// diskCandidate describes a flashable disk on the host.
|
||||
type diskCandidate struct {
|
||||
Path string // e.g. /dev/disk4 or /dev/sdb
|
||||
SizeBytes int64
|
||||
Description string // human-readable model + size, e.g. "Generic MassStorage (62.5 GB)"
|
||||
}
|
||||
|
||||
func (d diskCandidate) String() string {
|
||||
if d.Description != "" {
|
||||
return fmt.Sprintf("%s: %s", d.Path, d.Description)
|
||||
}
|
||||
return d.Path
|
||||
}
|
||||
|
||||
// resolveTargetDisk returns the disk the user wants to flash. On macOS, an
|
||||
// empty userDisk triggers auto-discovery. On Linux, userDisk is required and
|
||||
// validated.
|
||||
func resolveTargetDisk(ctx context.Context, userDisk string) (diskCandidate, error) {
|
||||
if userDisk != "" {
|
||||
if err := validateDiskPath(userDisk); err != nil {
|
||||
return diskCandidate{}, err
|
||||
}
|
||||
return diskCandidate{Path: userDisk}, nil
|
||||
}
|
||||
|
||||
disks, err := discoverExternalDisks(ctx)
|
||||
if err != nil {
|
||||
return diskCandidate{}, err
|
||||
}
|
||||
switch len(disks) {
|
||||
case 0:
|
||||
return diskCandidate{}, errors.New("no candidate disks found; insert an SD card or USB drive, or pass --disk")
|
||||
case 1:
|
||||
printf("Found 1 candidate disk: %s\n", disks[0])
|
||||
return disks[0], nil
|
||||
default:
|
||||
printf("Multiple candidate disks found:\n")
|
||||
for i, d := range disks {
|
||||
printf(" %d) %s\n", i+1, d)
|
||||
}
|
||||
return diskCandidate{}, errors.New("pass --disk=/dev/... to pick one")
|
||||
}
|
||||
}
|
||||
|
||||
// obtainGAF returns a path to a local GAF file the caller can read,
|
||||
// along with the appliance variant it corresponds to (empty for the
|
||||
// --gaf path). If the caller passed --gaf, the local file is returned
|
||||
// directly. Otherwise the latest appliance GAF is fetched from
|
||||
// pkgs.tailscale.com (with signature verification) into a temp file.
|
||||
// cleanup removes any temp file it created.
|
||||
func obtainGAF(ctx context.Context) (path, label, variant string, cleanup func(), err error) {
|
||||
cleanup = func() {}
|
||||
if flashApplianceArgs.gaf != "" {
|
||||
// With --gaf there's no manifest to learn the variant from, so
|
||||
// we trust whatever --variant the user passed (may be empty).
|
||||
// rootArchForVariant defaults to arm64 when empty.
|
||||
return flashApplianceArgs.gaf, flashApplianceArgs.gaf, flashApplianceArgs.variant, cleanup, nil
|
||||
}
|
||||
|
||||
track := flashApplianceArgs.track
|
||||
if track == "" {
|
||||
track = clientupdate.CurrentTrack
|
||||
}
|
||||
latest, err := clientupdate.LatestPackages(track)
|
||||
if err != nil {
|
||||
return "", "", "", cleanup, fmt.Errorf("fetching package manifest: %w", err)
|
||||
}
|
||||
if len(latest.GAFs) == 0 {
|
||||
return "", "", "", cleanup, fmt.Errorf("no appliance GAFs published on %q track", track)
|
||||
}
|
||||
|
||||
variant, err = pickVariant(latest.GAFs)
|
||||
if err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
gafName := latest.GAFs[variant]
|
||||
|
||||
gafURL, err := url.JoinPath("https://pkgs.tailscale.com", track, gafName)
|
||||
if err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "tailscale-flash-*.gaf")
|
||||
if err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
tmp.Close()
|
||||
cleanup = func() { os.Remove(tmpName) }
|
||||
|
||||
printf("Downloading %s (version %s)\n", gafURL, latest.GAFsVersion)
|
||||
logf := func(format string, args ...any) { fmt.Fprintf(Stderr, format+"\n", args...) }
|
||||
if err := distsign.DownloadVerified(ctx, logf, gafURL, tmpName); err != nil {
|
||||
cleanup()
|
||||
return "", "", "", func() {}, fmt.Errorf("download GAF: %w", err)
|
||||
}
|
||||
return tmpName, fmt.Sprintf("%s (%s)", gafName, latest.GAFsVersion), variant, cleanup, nil
|
||||
}
|
||||
|
||||
// pickVariant returns the variant key from gafs the user wants to flash. If
|
||||
// --variant was passed, it's validated against the available keys.
|
||||
// Otherwise the user is prompted with the variants the server advertises.
|
||||
func pickVariant(gafs map[string]string) (string, error) {
|
||||
variants := make([]string, 0, len(gafs))
|
||||
for k := range gafs {
|
||||
variants = append(variants, k)
|
||||
}
|
||||
sort.Strings(variants)
|
||||
|
||||
if v := flashApplianceArgs.variant; v != "" {
|
||||
if !slices.Contains(variants, v) {
|
||||
return "", fmt.Errorf("variant %q not published; available: %s", v, strings.Join(variants, ", "))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
printf("Available appliance variants:\n")
|
||||
for i, v := range variants {
|
||||
printf(" %d) %s\n", i+1, v)
|
||||
}
|
||||
return "", fmt.Errorf("pass --variant=<one of %s>", strings.Join(variants, "|"))
|
||||
}
|
||||
|
||||
// readGAFMember returns the contents of a named member of the GAF zip.
|
||||
// It returns an error if the member is missing or larger than maxBytes.
|
||||
func readGAFMember(files []*zip.File, name string, maxBytes int64) ([]byte, error) {
|
||||
for _, f := range files {
|
||||
if f.Name != name {
|
||||
continue
|
||||
}
|
||||
if int64(f.UncompressedSize64) > maxBytes {
|
||||
return nil, fmt.Errorf("%s is %d bytes; refusing to read more than %d", name, f.UncompressedSize64, maxBytes)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
return nil, fmt.Errorf("GAF is missing %s", name)
|
||||
}
|
||||
|
||||
// writeGAFToDisk writes a fresh gokrazy install to diskPath: the
|
||||
// protective MBR (with bootCode in the first 446 bytes), the primary
|
||||
// and secondary GPT, then boot.img at the boot partition's offset and
|
||||
// root.img at root A's offset. Root B and perm are left untouched — the
|
||||
// appliance populates root B on first boot, and the caller formats
|
||||
// perm with mkfs.ext4.
|
||||
func writeGAFToDisk(files []*zip.File, diskPath string, bootCode []byte, variant string) error {
|
||||
if len(bootCode) > 446 {
|
||||
return fmt.Errorf("mbr.img is %d bytes; expected at most 446", len(bootCode))
|
||||
}
|
||||
|
||||
if err := checkPartitionFits(files, "boot.img", int64(disklayout.BootPartitionSizeMB)<<20); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkPartitionFits(files, "root.img", int64(disklayout.RootPartitionSizeMB)<<20); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bootImg, err := readGAFMember(files, "boot.img", int64(disklayout.BootPartitionSizeMB)<<20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
partUUID, err := partUUIDFromBootImg(bootImg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("locating gokrazy partuuid in boot.img: %w", err)
|
||||
}
|
||||
|
||||
f, err := openBlockDevice(diskPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
devsize, err := blockDeviceSize(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sizing %s: %w", diskPath, err)
|
||||
}
|
||||
if devsize <= 0 {
|
||||
return fmt.Errorf("could not determine size of %s", diskPath)
|
||||
}
|
||||
|
||||
printf("Writing protective MBR + GPT (partuuid=%08x, arch=%s)\n", partUUID, rootArchForVariant(variant))
|
||||
if err := disklayout.WriteGPT(f, uint64(devsize), disklayout.DefaultBootPartitionStartLBA, bootCode, partUUID, rootArchForVariant(variant)); err != nil {
|
||||
return fmt.Errorf("writing GPT: %w", err)
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
member string
|
||||
offsetLBA uint32
|
||||
}{
|
||||
{"boot.img", disklayout.BootStartLBA(disklayout.DefaultBootPartitionStartLBA)},
|
||||
{"root.img", disklayout.RootAStartLBA(disklayout.DefaultBootPartitionStartLBA)},
|
||||
}
|
||||
for _, w := range writes {
|
||||
zf := findZipMember(files, w.member)
|
||||
if zf == nil {
|
||||
return fmt.Errorf("GAF is missing %s", w.member)
|
||||
}
|
||||
printf("Writing %s (%d bytes) at sector %d\n", w.member, zf.UncompressedSize64, w.offsetLBA)
|
||||
if err := writeZipMemberAt(f, zf, int64(w.offsetLBA)*512); err != nil {
|
||||
return fmt.Errorf("writing %s: %w", w.member, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := syncBlockDevice(f); err != nil {
|
||||
return fmt.Errorf("fsync %s: %w", diskPath, err)
|
||||
}
|
||||
if err := rereadPartitionTable(f); err != nil {
|
||||
return fmt.Errorf("reread partition table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rootArchForVariant picks the GPT root partition type architecture
|
||||
// based on the GAF variant key (e.g. "pi-arm64" → arm64).
|
||||
func rootArchForVariant(variant string) disklayout.RootArch {
|
||||
switch {
|
||||
case strings.HasSuffix(variant, "-amd64"):
|
||||
return disklayout.ArchAMD64
|
||||
default:
|
||||
// pi-arm64, vm-arm64, or empty (--gaf path): arm64 is the
|
||||
// default for tailscale appliance images.
|
||||
return disklayout.ArchARM64
|
||||
}
|
||||
}
|
||||
|
||||
// partUUIDFromBootImg returns the gokrazy per-disk partuuid embedded in
|
||||
// boot.img's cmdline.txt. We byte-search the FAT image for the
|
||||
// "PARTUUID=60c24cc1-..." pattern rather than parsing FAT, which is
|
||||
// good enough since the only thing on disk with that prefix is
|
||||
// cmdline.txt.
|
||||
func partUUIDFromBootImg(boot []byte) (uint32, error) {
|
||||
return disklayout.ParseCmdlinePartUUID(string(boot))
|
||||
}
|
||||
|
||||
// checkPartitionFits returns an error if the named GAF member is too
|
||||
// large to fit in a partition of maxBytes.
|
||||
func checkPartitionFits(files []*zip.File, name string, maxBytes int64) error {
|
||||
zf := findZipMember(files, name)
|
||||
if zf == nil {
|
||||
return fmt.Errorf("GAF is missing %s", name)
|
||||
}
|
||||
if got := int64(zf.UncompressedSize64); got > maxBytes {
|
||||
return fmt.Errorf("%s is %d bytes; gokrazy layout allows up to %d", name, got, maxBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findZipMember(files []*zip.File, name string) *zip.File {
|
||||
for _, f := range files {
|
||||
if f.Name == name {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeZipMemberAt(f *os.File, zf *zip.File, offset int64) error {
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
total := int64(zf.UncompressedSize64)
|
||||
cw := &countingWriter{w: f}
|
||||
stop := startProgress(zf.Name, total, &cw.count)
|
||||
defer stop()
|
||||
_, err = io.Copy(cw, rc)
|
||||
return err
|
||||
}
|
||||
|
||||
// countingWriter wraps an io.Writer and tracks total bytes written so
|
||||
// the progress goroutine can report it.
|
||||
type countingWriter struct {
|
||||
w io.Writer
|
||||
count atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countingWriter) Write(b []byte) (int, error) {
|
||||
n, err := c.w.Write(b)
|
||||
if n > 0 {
|
||||
c.count.Add(int64(n))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// startProgress spawns a 1 Hz goroutine that prints "<name>: <done> / <total>"
|
||||
// to Stderr until the returned stop function is called. The final tick on
|
||||
// stop reports the final state, so the caller doesn't need to repeat it.
|
||||
func startProgress(name string, total int64, done *atomic.Int64) func() {
|
||||
stop := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
report := func() {
|
||||
d := done.Load()
|
||||
pct := 0.0
|
||||
if total > 0 {
|
||||
pct = float64(d) * 100 / float64(total)
|
||||
}
|
||||
fmt.Fprintf(Stderr, " %s: %s / %s (%.1f%%)\n", name, humanBytes(d), humanBytes(total), pct)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
report()
|
||||
return
|
||||
case <-t.C:
|
||||
report()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(stop)
|
||||
<-finished
|
||||
}
|
||||
}
|
||||
|
||||
// humanBytes returns a friendly approximation of n bytes, e.g. "62.5 GB".
|
||||
func humanBytes(n int64) string {
|
||||
const (
|
||||
gb = 1 << 30
|
||||
mb = 1 << 20
|
||||
kb = 1 << 10
|
||||
)
|
||||
switch {
|
||||
case n >= gb:
|
||||
return fmt.Sprintf("%.1f GB", float64(n)/float64(gb))
|
||||
case n >= mb:
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/float64(mb))
|
||||
case n >= kb:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/float64(kb))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
}
|
||||
430
cmd/tailscale/cli/configure-flash-appliance_darwin.go
Normal file
430
cmd/tailscale/cli/configure-flash-appliance_darwin.go
Normal file
@@ -0,0 +1,430 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_flashappliance
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// maxAutoDetectDiskBytes is the upper size limit for a disk that
|
||||
// flash-appliance auto-discovers. Anything larger is reported to the
|
||||
// user but skipped from the candidate list, so it's harder to wipe an
|
||||
// unmounted internal SSD or a backup drive by accident; the user can
|
||||
// still target it with --disk explicitly.
|
||||
const maxAutoDetectDiskBytes = 256 << 30
|
||||
|
||||
// discoverExternalDisks returns the physical disks suitable for flashing.
|
||||
// We pass just "physical" (not "external physical") to diskutil because
|
||||
// macOS reports built-in SD card readers as internal; instead we exclude
|
||||
// whichever whole disks back the running root.
|
||||
func discoverExternalDisks(ctx context.Context) ([]diskCandidate, error) {
|
||||
out, err := exec.CommandContext(ctx, "diskutil", "list", "-plist", "physical").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("diskutil list: %w", err)
|
||||
}
|
||||
ids, err := parseDiskutilListPlist(out)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse diskutil list output: %w", err)
|
||||
}
|
||||
boot, err := bootWholeDisks(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("locating boot disk: %w", err)
|
||||
}
|
||||
disks := make([]diskCandidate, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if boot[id] {
|
||||
continue
|
||||
}
|
||||
d, err := diskutilInfo(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.SizeBytes > maxAutoDetectDiskBytes {
|
||||
printf("Skipping %s (%s) from auto-detection: looks suspiciously large.\n", d.Path, humanBytes(d.SizeBytes))
|
||||
printf(" To flash it anyway, pass --disk=%s explicitly.\n", d.Path)
|
||||
continue
|
||||
}
|
||||
disks = append(disks, d)
|
||||
}
|
||||
return disks, nil
|
||||
}
|
||||
|
||||
var darwinWholeDiskRe = regexp.MustCompile(`^(disk\d+)`)
|
||||
|
||||
// bootWholeDisks returns the set of whole-disk identifiers (e.g. "disk0")
|
||||
// that back the running root filesystem. It seeds the walk from `df -P /`
|
||||
// (which on Apple Silicon points to the sealed snapshot, e.g.
|
||||
// disk3s1s1) and follows ParentWholeDisk and APFSPhysicalStores so that
|
||||
// both the synthesized APFS container (disk3) and the physical disk
|
||||
// behind it (disk0) get excluded from flash candidates.
|
||||
func bootWholeDisks(ctx context.Context) (map[string]bool, error) {
|
||||
rootDev, err := dfRootDevice(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("locating root device: %w", err)
|
||||
}
|
||||
|
||||
boot := map[string]bool{}
|
||||
seen := map[string]bool{}
|
||||
queue := []string{rootDev}
|
||||
for len(queue) > 0 {
|
||||
id := queue[0]
|
||||
queue = queue[1:]
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
|
||||
if m := darwinWholeDiskRe.FindString(id); m != "" {
|
||||
boot[m] = true
|
||||
}
|
||||
|
||||
out, err := exec.CommandContext(ctx, "diskutil", "info", "-plist", id).Output()
|
||||
if err != nil {
|
||||
// Skip identifiers diskutil can't resolve (e.g. a physical
|
||||
// store on a disk that was unplugged); anything already
|
||||
// collected stays excluded.
|
||||
continue
|
||||
}
|
||||
info, err := parseDiskutilInfoPlist(out)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if d := info.ParentWholeDisk; d != "" {
|
||||
queue = append(queue, d)
|
||||
}
|
||||
queue = append(queue, info.APFSPhysicalStores...)
|
||||
}
|
||||
return boot, nil
|
||||
}
|
||||
|
||||
// dfRootDevice returns the device identifier (e.g. "disk3s1s1") that
|
||||
// backs the root mount, by parsing the second line of `df -P /`.
|
||||
func dfRootDevice(ctx context.Context) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, "df", "-P", "/").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) < 2 {
|
||||
return "", fmt.Errorf("unexpected df output: %q", out)
|
||||
}
|
||||
fields := strings.Fields(lines[1])
|
||||
if len(fields) == 0 {
|
||||
return "", fmt.Errorf("unexpected df line: %q", lines[1])
|
||||
}
|
||||
return strings.TrimPrefix(fields[0], "/dev/"), nil
|
||||
}
|
||||
|
||||
func diskutilInfo(ctx context.Context, id string) (diskCandidate, error) {
|
||||
out, err := exec.CommandContext(ctx, "diskutil", "info", "-plist", id).Output()
|
||||
if err != nil {
|
||||
return diskCandidate{}, fmt.Errorf("diskutil info %s: %w", id, err)
|
||||
}
|
||||
info, err := parseDiskutilInfoPlist(out)
|
||||
if err != nil {
|
||||
return diskCandidate{}, fmt.Errorf("parse diskutil info %s: %w", id, err)
|
||||
}
|
||||
desc := info.Model
|
||||
if desc == "" {
|
||||
desc = info.MediaName
|
||||
}
|
||||
if info.Size > 0 {
|
||||
desc = strings.TrimSpace(fmt.Sprintf("%s (%s)", desc, humanBytes(info.Size)))
|
||||
}
|
||||
return diskCandidate{
|
||||
Path: "/dev/" + id,
|
||||
SizeBytes: info.Size,
|
||||
Description: desc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateDiskPath checks that the user-provided disk path looks sane to
|
||||
// flash on macOS. We trust the user more than on Linux since they had to
|
||||
// type a /dev/disk path explicitly.
|
||||
func validateDiskPath(path string) error {
|
||||
if !strings.HasPrefix(path, "/dev/disk") {
|
||||
return fmt.Errorf("disk path %q does not look like a macOS whole-disk device (/dev/diskN)", path)
|
||||
}
|
||||
if strings.Contains(path, "s") && strings.IndexByte(path, 's') > len("/dev/disk") {
|
||||
return fmt.Errorf("disk path %q looks like a partition (/dev/diskNsP); pass the whole disk", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unmountDisk uses `diskutil unmountDisk` to release all partitions on the
|
||||
// target disk.
|
||||
func unmountDisk(ctx context.Context, path string) error {
|
||||
cmd := exec.CommandContext(ctx, "diskutil", "unmountDisk", path)
|
||||
cmd.Stdout = Stderr
|
||||
cmd.Stderr = Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// ejectDisk runs `diskutil eject` so the user can pull the SD card or
|
||||
// USB drive without macOS complaining about an improper eject. Returns
|
||||
// true if the eject command ran successfully.
|
||||
func ejectDisk(ctx context.Context, path string) (bool, error) {
|
||||
cmd := exec.CommandContext(ctx, "diskutil", "eject", path)
|
||||
cmd.Stdout = Stderr
|
||||
cmd.Stderr = Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// openBlockDevice opens the whole-disk device for writing. On macOS we use
|
||||
// the raw "rdiskN" alias because the buffered "diskN" path is much slower
|
||||
// for large writes.
|
||||
func openBlockDevice(path string) (*os.File, error) {
|
||||
raw := strings.Replace(path, "/dev/disk", "/dev/rdisk", 1)
|
||||
return os.OpenFile(raw, os.O_WRONLY, 0)
|
||||
}
|
||||
|
||||
// rereadPartitionTable is a no-op on macOS; diskutil and the kernel pick up
|
||||
// partition changes when the device is closed and re-opened.
|
||||
func rereadPartitionTable(_ *os.File) error { return nil }
|
||||
|
||||
// macOS ioctls from <sys/disk.h>. lseek(SEEK_END) returns 0 on raw
|
||||
// (/dev/rdiskN) devices, so we have to compute the size from the block
|
||||
// size and block count.
|
||||
const (
|
||||
dkiocGetBlockSize = 0x40046418 // _IOR('d', 24, uint32_t)
|
||||
dkiocGetBlockCount = 0x40086419 // _IOR('d', 25, uint64_t)
|
||||
)
|
||||
|
||||
// syncBlockDevice asks the kernel to flush in-flight writes to disk. On
|
||||
// macOS, /dev/rdiskN is the unbuffered raw device, so its writes are
|
||||
// already synchronous and fsync returns ENOTTY ("inappropriate ioctl
|
||||
// for device"). We try F_FULLFSYNC for completeness and tolerate the
|
||||
// same ENOTTY there.
|
||||
func syncBlockDevice(f *os.File) error {
|
||||
_, err := unix.FcntlInt(f.Fd(), unix.F_FULLFSYNC, 0)
|
||||
if err == nil || err == unix.ENOTTY {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// blockDeviceSize returns the size in bytes of the open block device f.
|
||||
// On little-endian darwin, IoctlGetInt's 8-byte int safely receives
|
||||
// both a 4-byte uint32 (block size) and an 8-byte uint64 (block count).
|
||||
func blockDeviceSize(f *os.File) (int64, error) {
|
||||
blockSize, err := unix.IoctlGetInt(int(f.Fd()), dkiocGetBlockSize)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("DKIOCGETBLOCKSIZE: %w", err)
|
||||
}
|
||||
blockCount, err := unix.IoctlGetInt(int(f.Fd()), dkiocGetBlockCount)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("DKIOCGETBLOCKCOUNT: %w", err)
|
||||
}
|
||||
return int64(blockSize) * int64(blockCount), nil
|
||||
}
|
||||
|
||||
// diskutilInfoFields are the fields we care about from `diskutil info -plist`.
|
||||
type diskutilInfoFields struct {
|
||||
Model string
|
||||
MediaName string
|
||||
Size int64
|
||||
ParentWholeDisk string // e.g. "disk3" for "/" on APFS
|
||||
APFSPhysicalStores []string // e.g. ["disk0s2"] for "/" on APFS
|
||||
}
|
||||
|
||||
// parseDiskutilListPlist returns the WholeDisk device identifiers from the
|
||||
// output of `diskutil list -plist external physical`.
|
||||
func parseDiskutilListPlist(data []byte) ([]string, error) {
|
||||
type listPlist struct {
|
||||
Dict plistDict `xml:"dict"`
|
||||
}
|
||||
var p listPlist
|
||||
if err := xml.Unmarshal(data, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr, ok := p.Dict.Get("WholeDisks").(plistArray)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
var out []string
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseDiskutilInfoPlist returns the fields we care about from `diskutil
|
||||
// info -plist <id>`.
|
||||
func parseDiskutilInfoPlist(data []byte) (diskutilInfoFields, error) {
|
||||
type infoPlist struct {
|
||||
Dict plistDict `xml:"dict"`
|
||||
}
|
||||
var p infoPlist
|
||||
if err := xml.Unmarshal(data, &p); err != nil {
|
||||
return diskutilInfoFields{}, err
|
||||
}
|
||||
var out diskutilInfoFields
|
||||
if s, ok := p.Dict.Get("MediaName").(string); ok {
|
||||
out.MediaName = s
|
||||
}
|
||||
if s, ok := p.Dict.Get("DeviceModel").(string); ok {
|
||||
out.Model = s
|
||||
} else if s, ok := p.Dict.Get("IORegistryEntryName").(string); ok {
|
||||
out.Model = s
|
||||
}
|
||||
if i, ok := p.Dict.Get("Size").(int64); ok {
|
||||
out.Size = i
|
||||
} else if i, ok := p.Dict.Get("TotalSize").(int64); ok {
|
||||
out.Size = i
|
||||
}
|
||||
if s, ok := p.Dict.Get("ParentWholeDisk").(string); ok {
|
||||
out.ParentWholeDisk = s
|
||||
}
|
||||
if arr, ok := p.Dict.Get("APFSPhysicalStores").(plistArray); ok {
|
||||
// The key inside each entry is APFSPhysicalStore (singular) on
|
||||
// macOS 14+; older releases may use DeviceIdentifier. Accept
|
||||
// either.
|
||||
for _, v := range arr {
|
||||
d, ok := v.(plistDict)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if id, ok := d.Get("APFSPhysicalStore").(string); ok {
|
||||
out.APFSPhysicalStores = append(out.APFSPhysicalStores, id)
|
||||
} else if id, ok := d.Get("DeviceIdentifier").(string); ok {
|
||||
out.APFSPhysicalStores = append(out.APFSPhysicalStores, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// plistDict and plistArray support unmarshaling a small subset of Apple
|
||||
// XML plists. They preserve key order and decode <string>, <integer>,
|
||||
// <true>, <false>, <array>, and nested <dict> elements.
|
||||
type plistDict []plistEntry
|
||||
|
||||
type plistEntry struct {
|
||||
Key string
|
||||
Value any
|
||||
}
|
||||
|
||||
type plistArray []any
|
||||
|
||||
// Get returns the value for a top-level key, or nil if absent.
|
||||
func (d plistDict) Get(key string) any {
|
||||
for _, e := range d {
|
||||
if e.Key == key {
|
||||
return e.Value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalXML decodes the children of a <dict> element as alternating
|
||||
// <key>...</key> and value elements.
|
||||
func (d *plistDict) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.EndElement:
|
||||
if t.Name == start.Name {
|
||||
return nil
|
||||
}
|
||||
case xml.StartElement:
|
||||
if t.Name.Local != "key" {
|
||||
return fmt.Errorf("dict child %q is not <key>", t.Name.Local)
|
||||
}
|
||||
var key string
|
||||
if err := dec.DecodeElement(&key, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
vtok, err := nextStart(dec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := decodePlistValue(dec, vtok)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = append(*d, plistEntry{Key: key, Value: v})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nextStart(dec *xml.Decoder) (xml.StartElement, error) {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return xml.StartElement{}, err
|
||||
}
|
||||
if s, ok := tok.(xml.StartElement); ok {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodePlistValue(dec *xml.Decoder, start xml.StartElement) (any, error) {
|
||||
switch start.Name.Local {
|
||||
case "string":
|
||||
var s string
|
||||
if err := dec.DecodeElement(&s, &start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
case "integer":
|
||||
var s string
|
||||
if err := dec.DecodeElement(&s, &start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var i int64
|
||||
fmt.Sscan(strings.TrimSpace(s), &i)
|
||||
return i, nil
|
||||
case "true":
|
||||
return true, dec.Skip()
|
||||
case "false":
|
||||
return false, dec.Skip()
|
||||
case "array":
|
||||
var arr plistArray
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.EndElement:
|
||||
if t.Name == start.Name {
|
||||
return arr, nil
|
||||
}
|
||||
case xml.StartElement:
|
||||
v, err := decodePlistValue(dec, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr = append(arr, v)
|
||||
}
|
||||
}
|
||||
case "dict":
|
||||
var d plistDict
|
||||
if err := d.UnmarshalXML(dec, start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
default:
|
||||
return nil, dec.Skip()
|
||||
}
|
||||
}
|
||||
98
cmd/tailscale/cli/configure-flash-appliance_darwin_test.go
Normal file
98
cmd/tailscale/cli/configure-flash-appliance_darwin_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_flashappliance
|
||||
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
const diskutilListSample = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AllDisks</key>
|
||||
<array>
|
||||
<string>disk4</string>
|
||||
<string>disk4s1</string>
|
||||
</array>
|
||||
<key>WholeDisks</key>
|
||||
<array>
|
||||
<string>disk4</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>`
|
||||
|
||||
const diskutilInfoSample = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>DeviceIdentifier</key>
|
||||
<string>disk4</string>
|
||||
<key>DeviceModel</key>
|
||||
<string>Generic STORAGE DEVICE</string>
|
||||
<key>MediaName</key>
|
||||
<string>Generic STORAGE DEVICE Media</string>
|
||||
<key>Size</key>
|
||||
<integer>62512365568</integer>
|
||||
<key>Removable</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>`
|
||||
|
||||
const diskutilInfoRootSample = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>DeviceIdentifier</key>
|
||||
<string>disk3s1s1</string>
|
||||
<key>ParentWholeDisk</key>
|
||||
<string>disk3</string>
|
||||
<key>APFSPhysicalStores</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>APFSPhysicalStore</key>
|
||||
<string>disk0s2</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>`
|
||||
|
||||
func TestParseDiskutilListPlist(t *testing.T) {
|
||||
ids, err := parseDiskutilListPlist([]byte(diskutilListSample))
|
||||
if err != nil {
|
||||
t.Fatalf("parseDiskutilListPlist: %v", err)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != "disk4" {
|
||||
t.Errorf("ids = %v; want [disk4]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiskutilInfoPlist(t *testing.T) {
|
||||
info, err := parseDiskutilInfoPlist([]byte(diskutilInfoSample))
|
||||
if err != nil {
|
||||
t.Fatalf("parseDiskutilInfoPlist: %v", err)
|
||||
}
|
||||
if info.Model != "Generic STORAGE DEVICE" {
|
||||
t.Errorf("Model = %q; want %q", info.Model, "Generic STORAGE DEVICE")
|
||||
}
|
||||
if info.MediaName != "Generic STORAGE DEVICE Media" {
|
||||
t.Errorf("MediaName = %q", info.MediaName)
|
||||
}
|
||||
if info.Size != 62512365568 {
|
||||
t.Errorf("Size = %d; want 62512365568", info.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiskutilInfoPlistRoot(t *testing.T) {
|
||||
info, err := parseDiskutilInfoPlist([]byte(diskutilInfoRootSample))
|
||||
if err != nil {
|
||||
t.Fatalf("parseDiskutilInfoPlist: %v", err)
|
||||
}
|
||||
if info.ParentWholeDisk != "disk3" {
|
||||
t.Errorf("ParentWholeDisk = %q; want disk3", info.ParentWholeDisk)
|
||||
}
|
||||
if len(info.APFSPhysicalStores) != 1 || info.APFSPhysicalStores[0] != "disk0s2" {
|
||||
t.Errorf("APFSPhysicalStores = %v; want [disk0s2]", info.APFSPhysicalStores)
|
||||
}
|
||||
}
|
||||
150
cmd/tailscale/cli/configure-flash-appliance_linux.go
Normal file
150
cmd/tailscale/cli/configure-flash-appliance_linux.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_flashappliance
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// discoverExternalDisks returns no disks on Linux: the user must pass
|
||||
// --disk=/dev/sdX. We don't try to enumerate removable disks here because
|
||||
// the right answer depends heavily on the host (servers don't have
|
||||
// removable media; Pi-on-Pi flashing has no notion of "external"; LVM
|
||||
// setups have arbitrary names).
|
||||
func discoverExternalDisks(_ context.Context) ([]diskCandidate, error) {
|
||||
return nil, errors.New("on Linux, pass --disk=/dev/sdX (auto-discovery is macOS-only)")
|
||||
}
|
||||
|
||||
// validateDiskPath rejects partition paths, the running root disk, and
|
||||
// disks with any partition currently mounted.
|
||||
func validateDiskPath(path string) error {
|
||||
if !strings.HasPrefix(path, "/dev/") {
|
||||
return fmt.Errorf("disk path %q must start with /dev/", path)
|
||||
}
|
||||
if isPartitionPath(path) {
|
||||
return fmt.Errorf("disk path %q looks like a partition; pass the whole disk", path)
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
if fi.Mode()&os.ModeDevice == 0 {
|
||||
return fmt.Errorf("%s is not a device file", path)
|
||||
}
|
||||
mounts, err := mountedSources()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range mounts {
|
||||
if m == path || strings.HasPrefix(m, path) {
|
||||
return fmt.Errorf("%s (or one of its partitions) is currently mounted; unmount it first", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPartitionPath reports whether path looks like a partition (e.g.
|
||||
// /dev/sda1, /dev/nvme0n1p2, /dev/mmcblk0p1) rather than a whole disk.
|
||||
func isPartitionPath(path string) bool {
|
||||
base := strings.TrimPrefix(path, "/dev/")
|
||||
switch {
|
||||
case strings.HasPrefix(base, "sd"), strings.HasPrefix(base, "hd"), strings.HasPrefix(base, "vd"):
|
||||
// /dev/sdaN — partition.
|
||||
if len(base) >= 4 && base[len(base)-1] >= '0' && base[len(base)-1] <= '9' {
|
||||
return true
|
||||
}
|
||||
case strings.HasPrefix(base, "nvme"), strings.HasPrefix(base, "mmcblk"), strings.HasPrefix(base, "loop"):
|
||||
// /dev/nvme0n1p1 — partition is "<diskname>p<digits>". The 'p'
|
||||
// must follow a digit (to distinguish loop0 from loop0p1).
|
||||
i := strings.LastIndexByte(base, 'p')
|
||||
if i <= 0 || i >= len(base)-1 || base[i-1] < '0' || base[i-1] > '9' {
|
||||
return false
|
||||
}
|
||||
for _, r := range base[i+1:] {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mountedSources returns the source device paths from /proc/mounts.
|
||||
func mountedSources() ([]string, error) {
|
||||
f, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var out []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) > 0 {
|
||||
out = append(out, fields[0])
|
||||
}
|
||||
}
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// unmountDisk unmounts every entry in /proc/mounts whose source starts with
|
||||
// path (covers /dev/sdb plus /dev/sdb1, /dev/sdb2, ...).
|
||||
func unmountDisk(ctx context.Context, path string) error {
|
||||
mounts, err := mountedSources()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range mounts {
|
||||
if m == path || strings.HasPrefix(m, path) {
|
||||
cmd := exec.CommandContext(ctx, "umount", m)
|
||||
cmd.Stdout = Stderr
|
||||
cmd.Stderr = Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("umount %s: %w", m, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openBlockDevice(path string) (*os.File, error) {
|
||||
return os.OpenFile(path, os.O_WRONLY|unix.O_SYNC, 0)
|
||||
}
|
||||
|
||||
// rereadPartitionTable asks the kernel to re-scan the partition table on
|
||||
// the open block device. Required on Linux before we can mkfs the perm
|
||||
// partition we just wrote.
|
||||
func rereadPartitionTable(f *os.File) error {
|
||||
return unix.IoctlSetInt(int(f.Fd()), unix.BLKRRPART, 0)
|
||||
}
|
||||
|
||||
// syncBlockDevice flushes pending writes to disk.
|
||||
func syncBlockDevice(f *os.File) error { return f.Sync() }
|
||||
|
||||
// ejectDisk is a no-op on Linux; the user just pulls the disk after
|
||||
// the sync at the end of writeGAFToDisk. Returns false so the success
|
||||
// message instructs the user to eject themselves.
|
||||
func ejectDisk(_ context.Context, _ string) (bool, error) { return false, nil }
|
||||
|
||||
// blockDeviceSize returns the size in bytes of the open block device f.
|
||||
// BLKGETSIZE64 returns a uint64; on 64-bit linux IoctlGetInt's int is wide
|
||||
// enough to receive it without needing an unsafe.Pointer.
|
||||
func blockDeviceSize(f *os.File) (int64, error) {
|
||||
size, err := unix.IoctlGetInt(int(f.Fd()), unix.BLKGETSIZE64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("BLKGETSIZE64: %w", err)
|
||||
}
|
||||
return int64(size), nil
|
||||
}
|
||||
35
cmd/tailscale/cli/configure-flash-appliance_linux_test.go
Normal file
35
cmd/tailscale/cli/configure-flash-appliance_linux_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_flashappliance
|
||||
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsPartitionPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/dev/sda", false},
|
||||
{"/dev/sda1", true},
|
||||
{"/dev/sdb", false},
|
||||
{"/dev/sdb4", true},
|
||||
{"/dev/sdz9", true},
|
||||
{"/dev/vdb", false},
|
||||
{"/dev/vdb1", true},
|
||||
{"/dev/nvme0n1", false},
|
||||
{"/dev/nvme0n1p1", true},
|
||||
{"/dev/nvme0n1p4", true},
|
||||
{"/dev/mmcblk0", false},
|
||||
{"/dev/mmcblk0p1", true},
|
||||
{"/dev/loop0", false},
|
||||
{"/dev/loop0p1", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isPartitionPath(tt.path); got != tt.want {
|
||||
t.Errorf("isPartitionPath(%q) = %v; want %v", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
cmd/tailscale/cli/configure-flash-appliance_omit.go
Normal file
13
cmd/tailscale/cli/configure-flash-appliance_omit.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build ts_omit_flashappliance
|
||||
|
||||
package cli
|
||||
|
||||
import "github.com/peterbourgon/ff/v3/ffcli"
|
||||
|
||||
func flashApplianceCmd() *ffcli.Command {
|
||||
// Omitted from the build when the ts_omit_flashappliance build tag is set.
|
||||
return nil
|
||||
}
|
||||
39
cmd/tailscale/cli/configure-flash-appliance_other.go
Normal file
39
cmd/tailscale/cli/configure-flash-appliance_other.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_flashappliance && !linux && !darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
var errFlashUnsupported = errors.New("flash-appliance is only supported on linux and darwin (got " + runtime.GOOS + ")")
|
||||
|
||||
func discoverExternalDisks(_ context.Context) ([]diskCandidate, error) {
|
||||
return nil, errFlashUnsupported
|
||||
}
|
||||
|
||||
func validateDiskPath(_ string) error {
|
||||
return errFlashUnsupported
|
||||
}
|
||||
|
||||
func unmountDisk(_ context.Context, _ string) error {
|
||||
return errFlashUnsupported
|
||||
}
|
||||
|
||||
func openBlockDevice(_ string) (*os.File, error) {
|
||||
return nil, errFlashUnsupported
|
||||
}
|
||||
|
||||
func rereadPartitionTable(_ *os.File) error { return nil }
|
||||
|
||||
func blockDeviceSize(_ *os.File) (int64, error) { return 0, errFlashUnsupported }
|
||||
|
||||
func syncBlockDevice(_ *os.File) error { return errFlashUnsupported }
|
||||
|
||||
func ejectDisk(_ context.Context, _ string) (bool, error) { return false, errFlashUnsupported }
|
||||
54
cmd/tailscale/cli/configure-flash-appliance_test.go
Normal file
54
cmd/tailscale/cli/configure-flash-appliance_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_flashappliance
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckPartitionFits(t *testing.T) {
|
||||
files := buildZip(t, map[string][]byte{
|
||||
"boot.img": bytes.Repeat([]byte{0xAB}, 1<<20),
|
||||
"root.img": bytes.Repeat([]byte{0xCD}, 4<<20),
|
||||
})
|
||||
|
||||
if err := checkPartitionFits(files, "boot.img", 2<<20); err != nil {
|
||||
t.Errorf("boot.img within limit: %v", err)
|
||||
}
|
||||
if err := checkPartitionFits(files, "root.img", 1<<20); err == nil {
|
||||
t.Errorf("root.img over limit: expected error")
|
||||
}
|
||||
if err := checkPartitionFits(files, "missing.img", 1<<20); err == nil {
|
||||
t.Errorf("missing file: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// buildZip returns the *zip.File entries for an in-memory zip containing
|
||||
// the given members.
|
||||
func buildZip(t *testing.T, members map[string][]byte) []*zip.File {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, data := range members {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip.Create %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
t.Fatalf("zip.Write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip.Close: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("zip.NewReader: %v", err)
|
||||
}
|
||||
return zr.File
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func configureCmd() *ffcli.Command {
|
||||
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
|
||||
|
||||
13
feature/buildfeatures/feature_flashappliance_disabled.go
Normal file
13
feature/buildfeatures/feature_flashappliance_disabled.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code generated by gen.go; DO NOT EDIT.
|
||||
|
||||
//go:build ts_omit_flashappliance
|
||||
|
||||
package buildfeatures
|
||||
|
||||
// HasFlashAppliance is whether the binary was built with support for modular feature "'tailscale configure flash-appliance' CLI command for writing a Tailscale appliance image to a local disk".
|
||||
// Specifically, it's whether the binary was NOT built with the "ts_omit_flashappliance" build tag.
|
||||
// It's a const so it can be used for dead code elimination.
|
||||
const HasFlashAppliance = false
|
||||
13
feature/buildfeatures/feature_flashappliance_enabled.go
Normal file
13
feature/buildfeatures/feature_flashappliance_enabled.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code generated by gen.go; DO NOT EDIT.
|
||||
|
||||
//go:build !ts_omit_flashappliance
|
||||
|
||||
package buildfeatures
|
||||
|
||||
// HasFlashAppliance is whether the binary was built with support for modular feature "'tailscale configure flash-appliance' CLI command for writing a Tailscale appliance image to a local disk".
|
||||
// Specifically, it's whether the binary was NOT built with the "ts_omit_flashappliance" build tag.
|
||||
// It's a const so it can be used for dead code elimination.
|
||||
const HasFlashAppliance = true
|
||||
@@ -160,6 +160,7 @@ type FeatureMeta struct {
|
||||
"desktop_sessions": {Sym: "DesktopSessions", Desc: "Desktop sessions support"},
|
||||
"doctor": {Sym: "Doctor", Desc: "Diagnose possible issues with Tailscale and its host environment"},
|
||||
"drive": {Sym: "Drive", Desc: "Tailscale Drive (file server) support"},
|
||||
"flashappliance": {Sym: "FlashAppliance", Desc: "'tailscale configure flash-appliance' CLI command for writing a Tailscale appliance image to a local disk"},
|
||||
"gro": {
|
||||
Sym: "GRO",
|
||||
Desc: "Generic Receive Offload support (performance)",
|
||||
|
||||
@@ -164,4 +164,4 @@
|
||||
});
|
||||
};
|
||||
}
|
||||
# nix-direnv cache busting line: sha256-jHFZE8TvqiLd2U4CloE3HzVO9Jq6sDNNTsqDNx7bhHM=
|
||||
# nix-direnv cache busting line: sha256-OcWKaba80LdWwpL4j2bmQC2w1MchYQtjOE2G9AgOf+0=
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"sri": "sha256-cY5yryX+p/xtoTv+WZEKFagiIl0OREHnJY1Bk5VpVVc="
|
||||
},
|
||||
"vendor": {
|
||||
"goModSum": "sha256-iyJ4/iFU8LZH3ecGrIieGjTRpzMgD2qMw23PCjJTi+8=",
|
||||
"sri": "sha256-jHFZE8TvqiLd2U4CloE3HzVO9Jq6sDNNTsqDNx7bhHM="
|
||||
"goModSum": "sha256-WTfA+y87fLUAqXlV3/AMoNtZS8pK6NeuakgBCuwHIMk=",
|
||||
"sri": "sha256-OcWKaba80LdWwpL4j2bmQC2w1MchYQtjOE2G9AgOf+0="
|
||||
}
|
||||
}
|
||||
|
||||
3
go.mod
3
go.mod
@@ -17,7 +17,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02
|
||||
github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5
|
||||
github.com/bradfitz/monogok v0.0.0-20260604043651-77f55eaefb19
|
||||
github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f
|
||||
github.com/bramvdbogaerde/go-scp v1.4.0
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
@@ -31,6 +31,7 @@ require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e
|
||||
github.com/diskfs/go-diskfs v1.9.3
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/djherbis/times v1.6.0
|
||||
github.com/dsnet/try v0.0.3
|
||||
|
||||
14
go.sum
14
go.sum
@@ -125,6 +125,8 @@ github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pO
|
||||
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
|
||||
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
|
||||
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
|
||||
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
@@ -209,8 +211,8 @@ github.com/bombsimon/wsl/v4 v4.2.1 h1:Cxg6u+XDWff75SIFFmNsqnIOgob+Q9hG6y/ioKbRFi
|
||||
github.com/bombsimon/wsl/v4 v4.2.1/go.mod h1:Xu/kDxGZTofQcDGCtQe9KCzhHphIe0fDuyWTxER9Feo=
|
||||
github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5 h1:0sG3c7afYdBNlc3QyhckvZ4bV9iqlfqCQM1i+mWm0eE=
|
||||
github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5/go.mod h1:78ZLITnBUCDJeU01+wYYJKaPYYgsDzJPRfxeI8qFh5g=
|
||||
github.com/bradfitz/monogok v0.0.0-20260604043651-77f55eaefb19 h1:1nIDDePfdaBpj8/Sv0pZVWylK5JqPVkS9CgxbwRRc8Y=
|
||||
github.com/bradfitz/monogok v0.0.0-20260604043651-77f55eaefb19/go.mod h1:TG1HbU9fRVDnNgXncVkKz9GdvjIvqquXjH6QZSEVmY4=
|
||||
github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f h1:voy0korWbg2e1gsJpBZ8/OBhVL8evXeUwbCZcA1PWv8=
|
||||
github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f/go.mod h1:TG1HbU9fRVDnNgXncVkKz9GdvjIvqquXjH6QZSEVmY4=
|
||||
github.com/bramvdbogaerde/go-scp v1.4.0 h1:jKMwpwCbcX1KyvDbm/PDJuXcMuNVlLGi0Q0reuzjyKY=
|
||||
github.com/bramvdbogaerde/go-scp v1.4.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ=
|
||||
github.com/breml/bidichk v0.2.7 h1:dAkKQPLl/Qrk7hnP6P+E0xOodrq8Us7+U0o4UBOAlQY=
|
||||
@@ -322,6 +324,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
|
||||
github.com/diskfs/go-diskfs v1.9.3 h1:cLciNCeZ4QAXVxyPJDr1ZJ9N9CCG3rQlQ/z/Cs/cNDM=
|
||||
github.com/diskfs/go-diskfs v1.9.3/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
|
||||
github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM=
|
||||
github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
@@ -348,6 +352,8 @@ github.com/elastic/crd-ref-docs v0.0.12 h1:F3seyncbzUz3rT3d+caeYWhumb5ojYQ6Bl0Z+
|
||||
github.com/elastic/crd-ref-docs v0.0.12/go.mod h1:X83mMBdJt05heJUYiS3T0yJ/JkCuliuhSUNav5Gjo/U=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg=
|
||||
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
@@ -451,6 +457,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8=
|
||||
github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU=
|
||||
github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s=
|
||||
@@ -992,6 +1000,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
|
||||
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
|
||||
github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
|
||||
github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
|
||||
@@ -10,20 +10,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/gokrazy/mkfs"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -33,25 +30,22 @@
|
||||
gaf = flag.Bool("gaf", false, "if true, build a gokrazy archive format file instead of a full disk image")
|
||||
)
|
||||
|
||||
func findMkfsExt4() (string, error) {
|
||||
tries := []string{
|
||||
"/opt/homebrew/opt/e2fsprogs/sbin/mkfs.ext4",
|
||||
"/sbin/mkfs.ext4",
|
||||
}
|
||||
for _, p := range tries {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
p, err := exec.LookPath("mkfs.ext4")
|
||||
if err == nil {
|
||||
return p, nil
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
return "", errors.New("no mkfs.ext4 found; run `brew install e2fsprogs`")
|
||||
}
|
||||
return "", errors.New("No mkfs.ext4 found on system")
|
||||
}
|
||||
// imageSizeBytes is the size of the disk image we ask monogok to
|
||||
// produce (and that the AWS AMI import expects). It has to be large
|
||||
// enough to fit gokrazy's standard partition layout (see
|
||||
// github.com/bradfitz/monogok/disklayout):
|
||||
//
|
||||
// 4 MiB gap before the first partition
|
||||
// 100 MiB boot (FAT)
|
||||
// 500 MiB root A (squashfs; the partition OTA updates write into)
|
||||
// 500 MiB root B (squashfs)
|
||||
// ~96 MiB /perm (ext4; rest of the disk minus the secondary GPT)
|
||||
//
|
||||
// Bump this to give /perm more room (and to make the produced .img
|
||||
// file larger). The same value is passed to monogok via
|
||||
// --target_storage_bytes and to mkfs.Perm so the GPT and the ext4
|
||||
// inside it agree on the disk's size.
|
||||
const imageSizeBytes = 1258299392
|
||||
|
||||
var conf gokrazyConfig
|
||||
|
||||
@@ -141,14 +135,13 @@ func buildImage() error {
|
||||
args = append(args,
|
||||
"overwrite",
|
||||
"--full", filepath.Join(dir, *app+".img"),
|
||||
"--target_storage_bytes=1258299392",
|
||||
fmt.Sprintf("--target_storage_bytes=%d", imageSizeBytes),
|
||||
)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir = filepath.Join(dir, *app)
|
||||
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
@@ -157,31 +150,16 @@ func buildImage() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mkfs, err := findMkfsExt4()
|
||||
imgPath := filepath.Join(dir, *app+".img")
|
||||
f, err := os.OpenFile(imgPath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("open %s: %w", imgPath, err)
|
||||
}
|
||||
|
||||
// monogok overwrite emits a line of text saying how to run mkfs.ext4
|
||||
// to create the ext4 /perm filesystem. Parse that and run it.
|
||||
// The regexp is tight to avoid matching if the command changes,
|
||||
// to force us to check it's still correct/safe. But it shouldn't
|
||||
// change on its own because we pin the monogok version in our go.mod.
|
||||
//
|
||||
// TODO(bradfitz): emit this in a machine-readable way from monogok.
|
||||
rx := regexp.MustCompile(`(?m)/mkfs.ext4 (-F) (-E) (offset=\d+) (\S+) (\d+)\s*?$`)
|
||||
m := rx.FindStringSubmatch(buf.String())
|
||||
if m == nil {
|
||||
return fmt.Errorf("found no ext4 instructions in output")
|
||||
defer f.Close()
|
||||
if err := mkfs.Perm(f, imageSizeBytes); err != nil {
|
||||
return fmt.Errorf("formatting /perm in %s: %v", imgPath, err)
|
||||
}
|
||||
|
||||
log.Printf("Running %s %q ...", mkfs, m[1:])
|
||||
out, err := exec.Command(mkfs, m[1:]...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error running %v: %v, %s", mkfs, err, out)
|
||||
}
|
||||
log.Printf("Success.")
|
||||
|
||||
log.Printf("Wrote ext4 /perm filesystem to %s.", imgPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
342
gokrazy/mkfs/mkfs.go
Normal file
342
gokrazy/mkfs/mkfs.go
Normal file
@@ -0,0 +1,342 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package mkfs creates the writable ext4 /perm filesystem inside a
|
||||
// gokrazy disk image or block device, at the offset and length
|
||||
// determined by the gokrazy partition layout.
|
||||
//
|
||||
// Used by gokrazy/build.go when producing a "--full" disk image and by
|
||||
// "tailscale configure flash-appliance" when flashing an image to an
|
||||
// SD card, so the appliance has a working /perm on first boot without
|
||||
// requiring users to install mkfs.ext4 (e.g. e2fsprogs on macOS).
|
||||
package mkfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/bradfitz/monogok/disklayout"
|
||||
"github.com/diskfs/go-diskfs/backend"
|
||||
"github.com/diskfs/go-diskfs/filesystem/ext4"
|
||||
)
|
||||
|
||||
// gptSecondaryReservedSectors is the number of 512-byte sectors that
|
||||
// monogok's GPT writer reserves at the end of the disk for the
|
||||
// secondary GPT (1 header sector + 32 partition-entry sectors). The
|
||||
// perm partition entry written by disklayout.WriteGPT is this many
|
||||
// sectors shorter than [disklayout.PermSize], so the ext4 filesystem
|
||||
// we create must shrink by the same amount to fit within the partition
|
||||
// the kernel sees.
|
||||
const gptSecondaryReservedSectors = 34
|
||||
|
||||
const sectorSize = 512
|
||||
|
||||
// Perm creates an ext4 filesystem with volume label "PERM" inside the
|
||||
// gokrazy /perm partition of f. devsizeBytes is the total disk size
|
||||
// that the gokrazy GPT in f was written for; the partition layout is
|
||||
// derived from it via [disklayout].
|
||||
//
|
||||
// To avoid issuing ext4.Create's hundreds of small scattered writes
|
||||
// against slow storage one syscall at a time, the filesystem is first
|
||||
// built in an in-memory sparse buffer and then only the genuinely
|
||||
// non-zero metadata pages are flushed to f, coalesced into the
|
||||
// fewest possible contiguous writes. ext4's initial superblock,
|
||||
// group descriptors, bitmaps, root inode, etc. land at the same
|
||||
// per-group byte offsets whether the destination had old ext4
|
||||
// metadata or zeros there, so a fresh ext4 always overwrites stale
|
||||
// metadata in place; data-area bytes that were never written are
|
||||
// not read by the kernel until they're allocated.
|
||||
//
|
||||
// f must be open read/write, and on macOS should be the buffered
|
||||
// /dev/diskN device rather than the raw /dev/rdiskN alias.
|
||||
func Perm(f *os.File, devsizeBytes int64) error {
|
||||
permStart := int64(disklayout.PermStartLBA(disklayout.DefaultBootPartitionStartLBA)) * sectorSize
|
||||
permSize := int64(disklayout.PermSize(disklayout.DefaultBootPartitionStartLBA, uint64(devsizeBytes))-gptSecondaryReservedSectors) * sectorSize
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Formatting /perm as ext4 (PERM): %s filesystem\n", humanBytes(permSize))
|
||||
|
||||
mem := newMemBackend(permSize)
|
||||
if _, err := ext4.Create(mem, permSize, 0, sectorSize, &ext4.Params{
|
||||
VolumeName: "PERM",
|
||||
// Force 4 KiB blocks. go-diskfs v1.9.3 otherwise defaults to 1
|
||||
// KiB blocks regardless of filesystem size, which makes a 128
|
||||
// MiB journal need ~131k blocks — past the 65535-blocks-per-
|
||||
// extent limit. 4 KiB blocks keep a typical journal in a
|
||||
// single extent. (Fixed upstream after v1.9.3.)
|
||||
SectorsPerBlock: 8,
|
||||
// Disable resize_inode. go-diskfs v1.9.3 only implements it
|
||||
// for 1 KiB block filesystems; for our 4 KiB blocks +
|
||||
// ~96 MiB perm, initResizeInode fails with "no backup groups
|
||||
// available". Matches go-diskfs's own tests for non-1 KiB
|
||||
// block sizes.
|
||||
Features: []ext4.FeatureOpt{
|
||||
ext4.WithFeatureReservedGDTBlocksForExpansion(false),
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("ext4.Create: %w", err)
|
||||
}
|
||||
return mem.flushTo(f, permStart)
|
||||
}
|
||||
|
||||
// memPageSize is the granularity of memBackend's sparse allocation.
|
||||
// 4 KiB matches the ext4 block size we use, so most of ext4.Create's
|
||||
// writes touch exactly one page.
|
||||
const memPageSize = 4096
|
||||
|
||||
// memBackend is a sparse in-memory implementation of go-diskfs's
|
||||
// [backend.Storage]. It only allocates a [memPageSize]-byte chunk for
|
||||
// each page that ext4.Create actually touches; unwritten regions cost
|
||||
// only a map entry's worth of overhead and read back as zeros. The
|
||||
// caller flushes the allocated pages to the destination in contiguous
|
||||
// runs via [memBackend.flushTo].
|
||||
type memBackend struct {
|
||||
size int64 // logical size of the virtual device
|
||||
pages map[int64][]byte // page index → memPageSize bytes
|
||||
off int64 // current offset for io.Reader / io.Seeker compatibility
|
||||
}
|
||||
|
||||
func newMemBackend(size int64) *memBackend {
|
||||
return &memBackend{
|
||||
size: size,
|
||||
pages: make(map[int64][]byte),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadAt implements [io.ReaderAt]. Bytes within pages that were never
|
||||
// written read as zero.
|
||||
func (m *memBackend) ReadAt(p []byte, off int64) (int, error) {
|
||||
if off < 0 || off >= m.size {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if max := m.size - off; int64(len(p)) > max {
|
||||
p = p[:max]
|
||||
}
|
||||
// Default everything to zero; allocated pages overwrite below.
|
||||
clear(p)
|
||||
total := 0
|
||||
for total < len(p) {
|
||||
absOff := off + int64(total)
|
||||
page := absOff / memPageSize
|
||||
within := int(absOff % memPageSize)
|
||||
room := memPageSize - within
|
||||
if room > len(p)-total {
|
||||
room = len(p) - total
|
||||
}
|
||||
if chunk, ok := m.pages[page]; ok {
|
||||
copy(p[total:total+room], chunk[within:within+room])
|
||||
}
|
||||
total += room
|
||||
}
|
||||
if int64(total) < int64(len(p)) {
|
||||
return total, io.EOF
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// WriteAt implements [io.WriterAt]. Pages are allocated on first
|
||||
// touch, except that writes whose data is entirely zero do NOT
|
||||
// allocate (or modify) any page: the caller's destination is assumed
|
||||
// to already have zeros where we never write. ext4.Create writes
|
||||
// tens-to-hundreds of MiB of zeros to initialize the inode table and
|
||||
// journal; suppressing those allocations is what keeps memory and SD
|
||||
// card writes proportional to the *real* metadata rather than the
|
||||
// filesystem size.
|
||||
//
|
||||
// CAVEAT: if the destination has stale non-zero data in those regions
|
||||
// (e.g. an SD card previously formatted with a different filesystem),
|
||||
// that data is left in place. For a fresh card this is fine; for
|
||||
// re-flashed cards the perm region's old data could confuse ext4's
|
||||
// recovery on first mount. Callers that re-flash should discard the
|
||||
// perm region first; we don't do that here.
|
||||
func (m *memBackend) WriteAt(p []byte, off int64) (int, error) {
|
||||
if off < 0 || off+int64(len(p)) > m.size {
|
||||
return 0, fmt.Errorf("write past buffer end: off=%d len=%d size=%d", off, len(p), m.size)
|
||||
}
|
||||
total := 0
|
||||
for total < len(p) {
|
||||
absOff := off + int64(total)
|
||||
page := absOff / memPageSize
|
||||
within := int(absOff % memPageSize)
|
||||
room := memPageSize - within
|
||||
if room > len(p)-total {
|
||||
room = len(p) - total
|
||||
}
|
||||
chunk, ok := m.pages[page]
|
||||
if !ok && isAllZero(p[total:total+room]) {
|
||||
// Don't allocate a fresh zero page.
|
||||
total += room
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
chunk = make([]byte, memPageSize)
|
||||
m.pages[page] = chunk
|
||||
}
|
||||
copy(chunk[within:within+room], p[total:total+room])
|
||||
total += room
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// isAllZero reports whether p is entirely 0x00.
|
||||
func isAllZero(p []byte) bool {
|
||||
for _, b := range p {
|
||||
if b != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Read implements [io.Reader].
|
||||
func (m *memBackend) Read(p []byte) (int, error) {
|
||||
n, err := m.ReadAt(p, m.off)
|
||||
m.off += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Seek implements [io.Seeker].
|
||||
func (m *memBackend) Seek(off int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
m.off = off
|
||||
case io.SeekCurrent:
|
||||
m.off += off
|
||||
case io.SeekEnd:
|
||||
m.off = m.size + off
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid whence %d", whence)
|
||||
}
|
||||
return m.off, nil
|
||||
}
|
||||
|
||||
// Close implements [io.Closer].
|
||||
func (m *memBackend) Close() error { return nil }
|
||||
|
||||
// Stat implements [fs.File].
|
||||
func (m *memBackend) Stat() (fs.FileInfo, error) {
|
||||
return memFileInfo{size: m.size}, nil
|
||||
}
|
||||
|
||||
// Sys implements [backend.Storage]; it returns ErrNotSuitable so
|
||||
// ext4.Create's optional fsync (ext4.go:730) is gracefully skipped.
|
||||
func (m *memBackend) Sys() (*os.File, error) { return nil, backend.ErrNotSuitable }
|
||||
|
||||
// Writable implements [backend.Storage].
|
||||
func (m *memBackend) Writable() (backend.WritableFile, error) { return m, nil }
|
||||
|
||||
// Path implements [backend.Storage].
|
||||
func (m *memBackend) Path() string { return "" }
|
||||
|
||||
type memFileInfo struct{ size int64 }
|
||||
|
||||
func (fi memFileInfo) Name() string { return "mkfs-buffer" }
|
||||
func (fi memFileInfo) Size() int64 { return fi.size }
|
||||
func (fi memFileInfo) Mode() fs.FileMode { return 0o600 }
|
||||
func (fi memFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (fi memFileInfo) IsDir() bool { return false }
|
||||
func (fi memFileInfo) Sys() any { return nil }
|
||||
|
||||
// flushTo writes the allocated (non-zero) pages of m to f at
|
||||
// baseOffset+pageIndex*memPageSize, coalescing consecutive page
|
||||
// indices into a single WriteAt so the destination sees the fewest
|
||||
// possible writes. Pages that ext4.Create only ever wrote zeros into
|
||||
// were never allocated by WriteAt and are not written here either; the
|
||||
// destination is assumed to have zeros (or a previous ext4 install's
|
||||
// metadata in the same locations, which is functionally equivalent
|
||||
// since fresh ext4 metadata overwrites it in place).
|
||||
//
|
||||
// Progress is printed to os.Stderr roughly once per second.
|
||||
func (m *memBackend) flushTo(f io.WriterAt, baseOffset int64) error {
|
||||
if len(m.pages) == 0 {
|
||||
return errors.New("BUG: ext4.Create allocated no pages")
|
||||
}
|
||||
|
||||
keys := make([]int64, 0, len(m.pages))
|
||||
for k := range m.pages {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
|
||||
totalBytes := int64(len(m.pages)) * memPageSize
|
||||
var written atomic.Int64
|
||||
stop := startExt4FlushProgress(&written, totalBytes)
|
||||
defer stop()
|
||||
|
||||
for i := 0; i < len(keys); {
|
||||
runStart := keys[i]
|
||||
j := i
|
||||
for j < len(keys) && keys[j] == runStart+int64(j-i) {
|
||||
j++
|
||||
}
|
||||
runPages := keys[i:j]
|
||||
buf := make([]byte, len(runPages)*memPageSize)
|
||||
for k, page := range runPages {
|
||||
copy(buf[k*memPageSize:], m.pages[page])
|
||||
}
|
||||
if _, err := f.WriteAt(buf, baseOffset+runStart*memPageSize); err != nil {
|
||||
return fmt.Errorf("flushing perm metadata: %w", err)
|
||||
}
|
||||
written.Add(int64(len(buf)))
|
||||
i = j
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startExt4FlushProgress prints "ext4 perm: N MB / M MB (X%)" to
|
||||
// os.Stderr roughly once a second, plus a final tick on stop. Returns
|
||||
// a function the caller must invoke when the flush is done.
|
||||
func startExt4FlushProgress(done *atomic.Int64, total int64) func() {
|
||||
stopCh := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
report := func() {
|
||||
d := done.Load()
|
||||
pct := 0.0
|
||||
if total > 0 {
|
||||
pct = float64(d) * 100 / float64(total)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ext4 perm: %s / %s (%.1f%%)\n",
|
||||
humanBytes(d), humanBytes(total), pct)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
report()
|
||||
return
|
||||
case <-t.C:
|
||||
report()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(stopCh)
|
||||
<-finished
|
||||
}
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const (
|
||||
gb = 1 << 30
|
||||
mb = 1 << 20
|
||||
kb = 1 << 10
|
||||
)
|
||||
switch {
|
||||
case n >= gb:
|
||||
return fmt.Sprintf("%.1f GB", float64(n)/float64(gb))
|
||||
case n >= mb:
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/float64(mb))
|
||||
case n >= kb:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/float64(kb))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
}
|
||||
183
gokrazy/mkfs/mkfs_test.go
Normal file
183
gokrazy/mkfs/mkfs_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package mkfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/diskfs/go-diskfs/filesystem/ext4"
|
||||
)
|
||||
|
||||
// fakeWriterAt records every WriteAt to a single contiguous backing
|
||||
// buffer (so tests can inspect what flushTo produced) and counts the
|
||||
// calls so we can assert the chunked flush issues a predictable
|
||||
// handful of big sequential writes.
|
||||
type fakeWriterAt struct {
|
||||
buf []byte
|
||||
calls int
|
||||
sizes []int
|
||||
}
|
||||
|
||||
func (w *fakeWriterAt) WriteAt(p []byte, off int64) (int, error) {
|
||||
w.calls++
|
||||
w.sizes = append(w.sizes, len(p))
|
||||
if int(off)+len(p) > len(w.buf) {
|
||||
w.buf = append(w.buf, make([]byte, int(off)+len(p)-len(w.buf))...)
|
||||
}
|
||||
return copy(w.buf[off:], p), nil
|
||||
}
|
||||
|
||||
// TestMemBackendSparseAlloc exercises ext4.Create against an in-memory
|
||||
// memBackend sized like a typical /perm partition and confirms that
|
||||
// the page allocator stays small. ext4.Create issues writes for tens
|
||||
// to hundreds of MiB of zero-initialized inode table and journal; we
|
||||
// rely on memBackend.WriteAt suppressing those zero writes so that
|
||||
// the eventual flush to the (slow) SD card stays under a few MiB.
|
||||
//
|
||||
// The assertion is intentionally loose — we only catch regressions
|
||||
// that bloat by an order of magnitude, not bookkeeping changes.
|
||||
func TestMemBackendSparseAlloc(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sizeBytes int64
|
||||
maxPagesKiB int64
|
||||
}{
|
||||
// ~96 MiB matches our tsapp pi/vm builds with
|
||||
// target_storage_bytes=1258299392.
|
||||
{"96MiB", 96 * 1024 * 1024, 256},
|
||||
// 2 GiB is the size the user complained about in the
|
||||
// flash-appliance progress meter: ext4.Create wrote ~131
|
||||
// MiB before suppression.
|
||||
{"2GiB", 2 * 1024 * 1024 * 1024, 1024},
|
||||
// 32 GiB simulates a full-size SD card. ext4.Create would
|
||||
// write a few hundred MiB of zeros for the inode table; we
|
||||
// must still stay tiny.
|
||||
{"32GiB", 32 * 1024 * 1024 * 1024, 2048},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mem := newMemBackend(tc.sizeBytes)
|
||||
_, err := ext4.Create(mem, tc.sizeBytes, 0, sectorSize, &ext4.Params{
|
||||
VolumeName: "PERM",
|
||||
SectorsPerBlock: 8,
|
||||
Features: []ext4.FeatureOpt{
|
||||
ext4.WithFeatureReservedGDTBlocksForExpansion(false),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ext4.Create: %v", err)
|
||||
}
|
||||
pageBytes := int64(len(mem.pages)) * memPageSize
|
||||
t.Logf("%s filesystem: %d allocated pages (%d KiB)",
|
||||
tc.name, len(mem.pages), pageBytes/1024)
|
||||
if pageBytes/1024 > tc.maxPagesKiB {
|
||||
t.Errorf("allocated %d KiB; want < %d KiB", pageBytes/1024, tc.maxPagesKiB)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlushToDirtyOnly exercises memBackend.flushTo against a fake
|
||||
// io.WriterAt: it must issue only one WriteAt per maximal run of
|
||||
// allocated (non-zero) pages — never anything for the gaps in between
|
||||
// — and the bytes at each destination offset must match what was
|
||||
// originally written.
|
||||
func TestFlushToDirtyOnly(t *testing.T) {
|
||||
const size = 40 * 1024 * 1024
|
||||
m := newMemBackend(size)
|
||||
|
||||
// Two contiguous runs separated by a large all-zero gap. The
|
||||
// flush should issue exactly two WriteAt calls (one per run),
|
||||
// and never touch the gap between them.
|
||||
page := func(b byte) []byte {
|
||||
p := make([]byte, memPageSize)
|
||||
p[0] = b
|
||||
return p
|
||||
}
|
||||
// Run 1: 2 consecutive pages at offset 0.
|
||||
if _, err := m.WriteAt(page(0x11), 0); err != nil {
|
||||
t.Fatalf("WriteAt: %v", err)
|
||||
}
|
||||
if _, err := m.WriteAt(page(0x22), memPageSize); err != nil {
|
||||
t.Fatalf("WriteAt: %v", err)
|
||||
}
|
||||
// Run 2: 1 page at the end of the region.
|
||||
if _, err := m.WriteAt(page(0x33), size-memPageSize); err != nil {
|
||||
t.Fatalf("WriteAt: %v", err)
|
||||
}
|
||||
|
||||
const baseOffset int64 = 1 << 20
|
||||
fw := &fakeWriterAt{}
|
||||
if err := m.flushTo(fw, baseOffset); err != nil {
|
||||
t.Fatalf("flushTo: %v", err)
|
||||
}
|
||||
|
||||
if fw.calls != 2 {
|
||||
t.Errorf("WriteAt calls=%d; want 2 (one per dirty run), sizes=%v", fw.calls, fw.sizes)
|
||||
}
|
||||
if got, want := fw.sizes[0], 2*memPageSize; got != want {
|
||||
t.Errorf("first run size=%d; want %d (2 contiguous pages)", got, want)
|
||||
}
|
||||
if got, want := fw.sizes[1], memPageSize; got != want {
|
||||
t.Errorf("second run size=%d; want %d (1 page)", got, want)
|
||||
}
|
||||
|
||||
// Page contents at the right absolute offsets.
|
||||
if fw.buf[baseOffset+0] != 0x11 {
|
||||
t.Errorf("page 0 marker = %#x; want 0x11", fw.buf[baseOffset+0])
|
||||
}
|
||||
if fw.buf[baseOffset+memPageSize] != 0x22 {
|
||||
t.Errorf("page 1 marker = %#x; want 0x22", fw.buf[baseOffset+memPageSize])
|
||||
}
|
||||
if fw.buf[baseOffset+size-memPageSize] != 0x33 {
|
||||
t.Errorf("last page marker = %#x; want 0x33", fw.buf[baseOffset+size-memPageSize])
|
||||
}
|
||||
// The gap pages between run 1 and run 2 must not have been touched
|
||||
// at all in the fake's backing buffer (it lazily grows on WriteAt;
|
||||
// untouched bytes stay zero).
|
||||
for _, off := range []int64{2 * memPageSize, 8 * 1024 * 1024, 20 * 1024 * 1024} {
|
||||
if !bytes.Equal(fw.buf[baseOffset+off:baseOffset+off+memPageSize], make([]byte, memPageSize)) {
|
||||
t.Errorf("flushTo touched an unallocated gap at offset %d", off)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemBackendZeroSuppressed asserts that a write whose data is all
|
||||
// zero does not allocate a page when the destination page is absent —
|
||||
// the core invariant that makes TestMemBackendSparseAlloc pass — and
|
||||
// that writes touching multiple pages allocate per-page based on
|
||||
// whether each page's slice has any non-zero byte.
|
||||
func TestMemBackendZeroSuppressed(t *testing.T) {
|
||||
m := newMemBackend(1 << 20)
|
||||
|
||||
// All-zero write spanning 2 pages: nothing allocated.
|
||||
zero := make([]byte, 8192)
|
||||
if _, err := m.WriteAt(zero, 4096); err != nil {
|
||||
t.Fatalf("WriteAt zero: %v", err)
|
||||
}
|
||||
if got := len(m.pages); got != 0 {
|
||||
t.Errorf("after %d-byte zero write: %d pages, want 0", len(zero), got)
|
||||
}
|
||||
|
||||
// Non-zero byte in page 0 only: page 0 allocated; page 1 stays
|
||||
// zero-suppressed.
|
||||
mixed := make([]byte, 8192)
|
||||
mixed[100] = 1
|
||||
if _, err := m.WriteAt(mixed, 0); err != nil {
|
||||
t.Fatalf("WriteAt mixed: %v", err)
|
||||
}
|
||||
if got := len(m.pages); got != 1 {
|
||||
t.Errorf("after write with non-zero only in page 0: %d pages, want 1", got)
|
||||
}
|
||||
|
||||
// Non-zero bytes in both pages: both allocated.
|
||||
m = newMemBackend(1 << 20)
|
||||
mixed[5000] = 1 // also non-zero in page 1
|
||||
if _, err := m.WriteAt(mixed, 0); err != nil {
|
||||
t.Fatalf("WriteAt mixed-both: %v", err)
|
||||
}
|
||||
if got := len(m.pages); got != 2 {
|
||||
t.Errorf("after write with non-zero in pages 0 and 1: %d pages, want 2", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user