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
@@ -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 @@ import (
|
||||
"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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user