mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-20 21:23:07 +08:00
cmd/tailscale: add 'configure pve-appliance' to make Proxmox VM of appliance
This is a variant of "tailscale configure flash-appliance" but for running on Proxmox PVE hosts to make a Proxmox VM running the experimental Tailscale Appliance. This also makes the "Esc" key make the fbstatus GUI open up a terminal, instead of Control-Alt-F2 which is hard to type over NoVNC. And make gafpush unidirectional, to not require a local port be opened locally, which I hit while working on this. And make fbstatus included in all appliance variants, but bail out early and stop respawing if the machine has no framebuffer (e.g. AWS VMs). Updates #1866 Change-Id: I18ec2a16e4d5ff5574e16fe55c0e8d06cf4fab7f Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
parent
c1ae2bb1f8
commit
887005d255
@ -99,8 +99,13 @@ func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) 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.
|
||||
// that serve the GAF from a fileserver that does not publish distsign.pub
|
||||
// and for the gafpush "sftp the GAF onto the appliance and update from a
|
||||
// local path" flow, which uses a "file://" URL.
|
||||
func downloadUnverified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error {
|
||||
if after, ok := strings.CutPrefix(srcURL, "file://"); ok {
|
||||
return copyLocalFile(after, dstPath, logf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", srcURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -130,6 +135,38 @@ func downloadUnverified(ctx context.Context, logf logger.Logf, srcURL, dstPath s
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// copyLocalFile copies the GAF at src to dst. Used by the "file://" branch
|
||||
// of downloadUnverified. The source file is left in place; callers that
|
||||
// staged it (e.g. gafpush) clean up after the update completes.
|
||||
func copyLocalFile(src, dst string, logf logger.Logf) error {
|
||||
sf, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sf.Close()
|
||||
df, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := sf.Stat()
|
||||
if err != nil {
|
||||
df.Close()
|
||||
return err
|
||||
}
|
||||
total := fi.Size()
|
||||
logf("copying local GAF %s (%d MB)", src, total>>20)
|
||||
pw := progresstracking.NewWriter(io.Discard, total, time.Second, func(done int64) {
|
||||
if total > 0 {
|
||||
logf("copying: %d / %d MB (%.0f%%)", done>>20, total>>20, float64(done)/float64(total)*100)
|
||||
}
|
||||
})
|
||||
if _, err := io.Copy(df, io.TeeReader(sf, pw)); err != nil {
|
||||
df.Close()
|
||||
return err
|
||||
}
|
||||
return df.Close()
|
||||
}
|
||||
|
||||
func gokrazyHTTPClient() *http.Client {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
@ -50,6 +51,7 @@
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/util/cloudenv"
|
||||
)
|
||||
|
||||
//go:embed tailscale.png
|
||||
@ -93,6 +95,20 @@
|
||||
|
||||
var flagFB = flag.String("fb", "/dev/fb0", "framebuffer device to draw to")
|
||||
|
||||
// noFramebufferReason reports whether this host lacks a usable Linux
|
||||
// framebuffer, along with a short human-readable explanation. If the
|
||||
// framebuffer device is missing, or we're running on a cloud (currently
|
||||
// only AWS) whose instances don't expose one, we return true.
|
||||
func noFramebufferReason(fbPath string) (string, bool) {
|
||||
if cloudenv.Get() == cloudenv.AWS {
|
||||
return "running on AWS (no framebuffer)", true
|
||||
}
|
||||
if _, err := os.Stat(fbPath); err != nil {
|
||||
return fmt.Sprintf("no framebuffer at %s: %v", fbPath, err), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||
@ -102,6 +118,19 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Bail out early on cloud VMs that don't ship a framebuffer. We
|
||||
// still kick off breakglass once DHCP succeeds (otherwise the
|
||||
// appliance is unreachable — breakglass declares DontStartOnBoot
|
||||
// and only runs when fbstatus pokes the supervisor), then exit
|
||||
// 125 so the gokrazy supervisor stops respawning us. See
|
||||
// https://gokrazy.org/development/process-interface/.
|
||||
if reason, ok := noFramebufferReason(*flagFB); ok {
|
||||
log.Printf("%s; starting breakglass after DHCP then exiting 125", reason)
|
||||
startBreakglassAfterDHCP(&uiState{})
|
||||
log.Printf("breakglass started; exiting 125 so gokrazy won't respawn fbstatus")
|
||||
os.Exit(125)
|
||||
}
|
||||
|
||||
if restore, err := claimVTGraphics(); err != nil {
|
||||
log.Printf("could not put VT into graphics mode (fbcon may overdraw): %v", err)
|
||||
} else {
|
||||
@ -288,10 +317,16 @@ func startBreakglass() {
|
||||
}
|
||||
}
|
||||
|
||||
// watchKeyboardForConsole monitors keyboard input devices for Ctrl-Alt-F1/F2.
|
||||
// Ctrl-Alt-F2 switches to VT2 (text mode with a busybox shell).
|
||||
// Ctrl-Alt-F1 switches back to VT1 (fbstatus graphics mode).
|
||||
// This mirrors standard Linux VT switching conventions.
|
||||
// watchKeyboardForConsole monitors keyboard input devices for VT-switching
|
||||
// accelerators.
|
||||
// - Ctrl-Alt-F2 (or plain Esc — easier to type in NoVNC where the
|
||||
// Ctrl-Alt-Fn sequence doesn't always propagate) switches to VT2, a
|
||||
// text-mode busybox shell. When that shell exits, we switch back
|
||||
// automatically.
|
||||
// - Ctrl-Alt-F1 switches back to VT1 (fbstatus graphics mode).
|
||||
//
|
||||
// This mirrors standard Linux VT switching conventions plus a NoVNC-
|
||||
// friendly shortcut.
|
||||
func watchKeyboardForConsole(ctx context.Context, st *uiState) {
|
||||
kbdPath := findKeyboard()
|
||||
if kbdPath == "" {
|
||||
@ -313,13 +348,14 @@ func watchKeyboardForConsole(ctx context.Context, st *uiState) {
|
||||
defer ttyFile.Close()
|
||||
ttyFd := int(ttyFile.Fd())
|
||||
|
||||
log.Printf("watching %s for Ctrl-Alt-F1/F2 (VT switching)", kbdPath)
|
||||
log.Printf("watching %s for Ctrl-Alt-F1/F2 and Esc (VT switching)", kbdPath)
|
||||
|
||||
// Linux input_event on arm64: {uint64 sec, uint64 usec, uint16 type, uint16 code, int32 value}
|
||||
// TODO: verify this layout also works on amd64 (e.g. the Proxmox
|
||||
// framebuffer), where it should be the same size and shape.
|
||||
// Linux input_event has the same layout on both arm64 and amd64
|
||||
// (24 bytes: two uint64 timestamps + uint16 type + uint16 code +
|
||||
// int32 value), so this parser handles both the Pi and Proxmox VM.
|
||||
const evSize = 24
|
||||
const evKey = 1 // EV_KEY
|
||||
const keyEsc = 1 // KEY_ESC
|
||||
const keyF1 = 59 // KEY_F1
|
||||
const keyF2 = 60 // KEY_F2
|
||||
const keyLeftCtrl = 29
|
||||
@ -331,6 +367,22 @@ func watchKeyboardForConsole(ctx context.Context, st *uiState) {
|
||||
buf := make([]byte, evSize)
|
||||
var ctrlHeld, altHeld bool
|
||||
|
||||
switchToFbstatus := func() {
|
||||
st.paused.Store(false)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 1)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 1)
|
||||
ioctlSetInt(ttyFile, kdSetMode, kdGraphics)
|
||||
st.render()
|
||||
}
|
||||
switchToShell := func(reason string) {
|
||||
st.paused.Store(true)
|
||||
ioctlSetInt(ttyFile, kdSetMode, kdText)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 2)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 2)
|
||||
go ensureShellOnVT2(switchToFbstatus)
|
||||
log.Printf("%s: switched to text console", reason)
|
||||
}
|
||||
|
||||
for ctx.Err() == nil {
|
||||
n, err := kbd.Read(buf)
|
||||
if err != nil || n < evSize {
|
||||
@ -360,25 +412,19 @@ func watchKeyboardForConsole(ctx context.Context, st *uiState) {
|
||||
} else if released {
|
||||
altHeld = false
|
||||
}
|
||||
case keyEsc:
|
||||
if pressed && !ctrlHeld && !altHeld {
|
||||
// Bare Esc: NoVNC-friendly shortcut to the shell.
|
||||
switchToShell("Esc")
|
||||
}
|
||||
case keyF1:
|
||||
if pressed && ctrlHeld && altHeld {
|
||||
// Switch to VT1 (fbstatus graphics).
|
||||
st.paused.Store(false)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 1)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 1)
|
||||
ioctlSetInt(ttyFile, kdSetMode, kdGraphics)
|
||||
st.render()
|
||||
switchToFbstatus()
|
||||
log.Printf("Ctrl-Alt-F1: switched to fbstatus")
|
||||
}
|
||||
case keyF2:
|
||||
if pressed && ctrlHeld && altHeld {
|
||||
// Switch to VT2 (text console with shell).
|
||||
st.paused.Store(true)
|
||||
ioctlSetInt(ttyFile, kdSetMode, kdText)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 2)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 2)
|
||||
go ensureShellOnVT2()
|
||||
log.Printf("Ctrl-Alt-F2: switched to text console")
|
||||
switchToShell("Ctrl-Alt-F2")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -386,15 +432,21 @@ func watchKeyboardForConsole(ctx context.Context, st *uiState) {
|
||||
|
||||
// ensureShellOnVT2 spawns a busybox ash shell on /dev/tty2 if one isn't
|
||||
// already running. The shell gets the VT2 tty as its controlling terminal
|
||||
// so keyboard input on VT2 goes to it.
|
||||
// so keyboard input on VT2 goes to it. When the shell exits, onExit is
|
||||
// called (typically to switch back to VT1 / fbstatus graphics mode).
|
||||
var shellOnVT2Running atomic.Bool
|
||||
|
||||
func ensureShellOnVT2() {
|
||||
func ensureShellOnVT2(onExit func()) {
|
||||
if !shellOnVT2Running.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer shellOnVT2Running.Store(false)
|
||||
defer func() {
|
||||
if onExit != nil {
|
||||
onExit()
|
||||
}
|
||||
}()
|
||||
shell := "/tmp/serial-busybox/ash"
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
log.Printf("no shell at %s for VT2", shell)
|
||||
@ -424,26 +476,32 @@ func ensureShellOnVT2() {
|
||||
}
|
||||
|
||||
// findKeyboard looks for a keyboard among /dev/input/event* devices by
|
||||
// checking for EV_KEY capability with KEY_ESC support.
|
||||
// checking that the device's key capability bitmap has KEY_ESC set. The
|
||||
// alternative "any non-zero key bitmap" check picks up the ACPI power
|
||||
// button (which advertises KEY_POWER but no Esc) and misses the real
|
||||
// keyboard on amd64 Proxmox VMs, where the AT keyboard is event1 but
|
||||
// event0 is Power Button.
|
||||
func findKeyboard() string {
|
||||
matches, _ := filepath.Glob("/dev/input/event*")
|
||||
for _, path := range matches {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// EVIOCGBIT(EV_KEY) = ioctl to get key capability bitmap
|
||||
// We just try reading an EV_KEY event; if the device has keys it'll work.
|
||||
// Simpler: check /sys/class/input/eventN/device/capabilities/key
|
||||
name := filepath.Base(path)
|
||||
capPath := "/sys/class/input/" + name + "/device/capabilities/key"
|
||||
cap, err := os.ReadFile(capPath)
|
||||
f.Close()
|
||||
capData, err := os.ReadFile("/sys/class/input/" + name + "/device/capabilities/key")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// A keyboard has a non-zero key capability bitmap.
|
||||
if strings.TrimSpace(string(cap)) != "0" {
|
||||
fields := strings.Fields(strings.TrimSpace(string(capData)))
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
// The kernel prints capability bitmaps as space-separated 64-bit
|
||||
// hex chunks, most-significant chunk first. The last chunk holds
|
||||
// bits 0..63. KEY_ESC = 1, so its bit-mask is 1<<1 == 0x2.
|
||||
low, err := strconv.ParseUint(fields[len(fields)-1], 16, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
const keyEscBit = 1 << 1
|
||||
if low&keyEscBit != 0 {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,7 +84,11 @@ func runFlashAppliance(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
gafPath, gafLabel, variant, cleanup, err := obtainGAF(ctx)
|
||||
gafPath, gafLabel, variant, cleanup, err := obtainGAF(ctx, gafDownloadArgs{
|
||||
localGAF: flashApplianceArgs.gaf,
|
||||
track: flashApplianceArgs.track,
|
||||
variant: flashApplianceArgs.variant,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -229,22 +233,30 @@ func resolveTargetDisk(ctx context.Context, userDisk string) (diskCandidate, err
|
||||
}
|
||||
}
|
||||
|
||||
// gafDownloadArgs are the inputs to [obtainGAF]. All fields are optional;
|
||||
// zero values mean "download the latest and prompt/pick sensible defaults".
|
||||
type gafDownloadArgs struct {
|
||||
localGAF string // if set, skip the network and use this local GAF path
|
||||
track string // release track (empty → clientupdate.CurrentTrack)
|
||||
variant string // GAF variant key (e.g. "vm-amd64"); empty prompts interactively
|
||||
}
|
||||
|
||||
// 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
|
||||
// --gaf path). If args.localGAF is set, 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) {
|
||||
func obtainGAF(ctx context.Context, args gafDownloadArgs) (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
|
||||
if args.localGAF != "" {
|
||||
// With a local GAF there's no manifest to learn the variant
|
||||
// from, so we trust whatever variant the caller passed (may be
|
||||
// empty). rootArchForVariant defaults to arm64 when empty.
|
||||
return args.localGAF, args.localGAF, args.variant, cleanup, nil
|
||||
}
|
||||
|
||||
track := flashApplianceArgs.track
|
||||
track := args.track
|
||||
if track == "" {
|
||||
track = clientupdate.CurrentTrack
|
||||
}
|
||||
@ -256,7 +268,7 @@ func obtainGAF(ctx context.Context) (path, label, variant string, cleanup func()
|
||||
return "", "", "", cleanup, fmt.Errorf("no appliance GAFs published on %q track", track)
|
||||
}
|
||||
|
||||
variant, err = pickVariant(latest.GAFs)
|
||||
variant, err = pickVariant(latest.GAFs, args.variant)
|
||||
if err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
@ -284,21 +296,21 @@ func obtainGAF(ctx context.Context) (path, label, variant string, cleanup func()
|
||||
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) {
|
||||
// pickVariant returns the variant key from gafs the user wants. If
|
||||
// chosen is non-empty, it's validated against the available keys.
|
||||
// Otherwise the caller is prompted to choose one on the next invocation.
|
||||
func pickVariant(gafs map[string]string, chosen 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, ", "))
|
||||
if chosen != "" {
|
||||
if !slices.Contains(variants, chosen) {
|
||||
return "", fmt.Errorf("variant %q not published; available: %s", chosen, strings.Join(variants, ", "))
|
||||
}
|
||||
return v, nil
|
||||
return chosen, nil
|
||||
}
|
||||
|
||||
printf("Available appliance variants:\n")
|
||||
@ -335,6 +347,42 @@ func readGAFMember(files []*zip.File, name string, maxBytes int64) ([]byte, erro
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
if err := writeApplianceImage(f, devsize, files, bootCode, variant); err != nil {
|
||||
return 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
|
||||
}
|
||||
|
||||
// writeApplianceImage writes the gokrazy install (protective MBR + GPT,
|
||||
// boot.img, root.img) to f, whose usable size is devsize bytes. It does
|
||||
// not fsync or reread the partition table; those are the caller's
|
||||
// responsibility if targeting a block device.
|
||||
//
|
||||
// f is used for random-access seeks and writes and must already be sized
|
||||
// to devsize (a fresh block device, or a regular file that the caller
|
||||
// has truncated to devsize).
|
||||
func writeApplianceImage(f *os.File, devsize int64, files []*zip.File, bootCode []byte, variant string) error {
|
||||
if len(bootCode) > 446 {
|
||||
return fmt.Errorf("mbr.img is %d bytes; expected at most 446", len(bootCode))
|
||||
}
|
||||
@ -355,20 +403,6 @@ func writeGAFToDisk(files []*zip.File, diskPath string, bootCode []byte, variant
|
||||
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)
|
||||
@ -391,13 +425,6 @@ func writeGAFToDisk(files []*zip.File, diskPath string, bootCode []byte, variant
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
413
cmd/tailscale/cli/configure-pve-appliance.go
Normal file
413
cmd/tailscale/cli/configure-pve-appliance.go
Normal file
@ -0,0 +1,413 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_flashappliance
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/gokrazy/mkfs"
|
||||
"tailscale.com/util/prompt"
|
||||
)
|
||||
|
||||
var pveApplianceArgs struct {
|
||||
vmid int
|
||||
name string
|
||||
storage string
|
||||
diskSize string
|
||||
cores int
|
||||
memory int
|
||||
bridge string
|
||||
variant string
|
||||
track string
|
||||
gaf string
|
||||
addSSHAuthorizedKeys string
|
||||
start bool
|
||||
yes bool
|
||||
}
|
||||
|
||||
func pveApplianceCmd() *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "pve-appliance",
|
||||
ShortUsage: "tailscale configure pve-appliance --storage=<name> [flags]",
|
||||
ShortHelp: "Create a Proxmox VE VM running the Tailscale appliance image [experimental]",
|
||||
LongHelp: hidden + strings.TrimSpace(`
|
||||
This experimental command downloads a signed Tailscale appliance GAF from
|
||||
pkgs.tailscale.com, builds a raw disk image in /var/tmp, then invokes
|
||||
'qm create' / 'qm disk import' / 'qm set' on the local Proxmox VE host to
|
||||
create a new VM backed by that image. It must be run on the PVE host
|
||||
itself (where the 'qm' CLI is available).
|
||||
|
||||
The imported disk is attached as scsi0 on a virtio-scsi-single controller
|
||||
with iothread, the network attaches to the given bridge (default vmbr0),
|
||||
and the guest agent is enabled — pair with the appliance's built-in
|
||||
qemu-guest-kragent so PVE can see the guest's IPs.
|
||||
|
||||
The VM is created with a virtio-serial console (--serial0 socket). Once
|
||||
the VM is running, 'qm terminal <vmid>' on the PVE host — then press
|
||||
Enter — drops you into a busybox shell inside the appliance without
|
||||
needing an SSH key.
|
||||
|
||||
Defaults are chosen so a bare invocation like:
|
||||
|
||||
tailscale configure pve-appliance --storage=local-lvm
|
||||
|
||||
is enough to produce a bootable Tailscale appliance VM.
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("pve-appliance")
|
||||
fs.IntVar(&pveApplianceArgs.vmid, "vmid", 0, "target VM ID; 0 asks Proxmox for the next available ID")
|
||||
fs.StringVar(&pveApplianceArgs.name, "name", "", `VM name; defaults to "tsapp-<vmid>"`)
|
||||
fs.StringVar(&pveApplianceArgs.storage, "storage", "", "PVE storage to import the disk into (e.g. local-lvm, ssd2); required")
|
||||
fs.StringVar(&pveApplianceArgs.diskSize, "disk-size", "4G", "raw image size (accepts K/M/G suffixes, e.g. 4G, 8192M)")
|
||||
fs.IntVar(&pveApplianceArgs.cores, "cores", 2, "vCPU cores")
|
||||
fs.IntVar(&pveApplianceArgs.memory, "memory", 1024, "memory in MiB")
|
||||
fs.StringVar(&pveApplianceArgs.bridge, "bridge", "vmbr0", "network bridge to attach virtio net0 to")
|
||||
fs.StringVar(&pveApplianceArgs.variant, "variant", "vm-amd64", `appliance variant: "vm-amd64" or "vm-arm64"`)
|
||||
fs.StringVar(&pveApplianceArgs.track, "track", "", `which track to download from; defaults to "`+clientupdate.CurrentTrack+`"`)
|
||||
fs.StringVar(&pveApplianceArgs.gaf, "gaf", "", "use a local GAF file instead of downloading (skips signature verification)")
|
||||
fs.StringVar(&pveApplianceArgs.addSSHAuthorizedKeys, "add-ssh-authorized-keys", "", "path to an authorized_keys file to include on the appliance for breakglass SSH access")
|
||||
fs.BoolVar(&pveApplianceArgs.start, "start", true, "start the VM after import")
|
||||
fs.BoolVar(&pveApplianceArgs.yes, "yes", false, "skip the confirmation prompt")
|
||||
return fs
|
||||
})(),
|
||||
Exec: runPVEAppliance,
|
||||
}
|
||||
}
|
||||
|
||||
func runPVEAppliance(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return errors.New("unknown arguments")
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
return errors.New("the pve-appliance subcommand is only available on Linux; for use on Proxmox PVE hosts")
|
||||
}
|
||||
if fi, err := os.Stat("/etc/pve"); err != nil || !fi.IsDir() {
|
||||
return errors.New("/etc/pve is not a directory: run this on a Proxmox VE host")
|
||||
}
|
||||
if _, err := exec.LookPath("qm"); err != nil {
|
||||
return errors.New("`qm` not found in $PATH: run this on the Proxmox VE host")
|
||||
}
|
||||
if pveApplianceArgs.storage == "" {
|
||||
return errors.New("--storage is required (e.g. --storage=local-lvm)")
|
||||
}
|
||||
|
||||
diskBytes, err := parseSizeBytes(pveApplianceArgs.diskSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing --disk-size: %w", err)
|
||||
}
|
||||
|
||||
vmid := pveApplianceArgs.vmid
|
||||
if vmid == 0 {
|
||||
vmid, err = pveNextID(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching next VMID: %w", err)
|
||||
}
|
||||
}
|
||||
name := pveApplianceArgs.name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("tsapp-%d", vmid)
|
||||
}
|
||||
|
||||
gafPath, gafLabel, variant, cleanup, err := obtainGAF(ctx, gafDownloadArgs{
|
||||
localGAF: pveApplianceArgs.gaf,
|
||||
track: pveApplianceArgs.track,
|
||||
variant: pveApplianceArgs.variant,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if !pveApplianceArgs.yes {
|
||||
printf("About to create Proxmox VM %d (%q) on storage %q from %s\n",
|
||||
vmid, name, pveApplianceArgs.storage, gafLabel)
|
||||
printf(" cores=%d memory=%dMiB bridge=%s disk=%s start=%v\n",
|
||||
pveApplianceArgs.cores, pveApplianceArgs.memory,
|
||||
pveApplianceArgs.bridge, pveApplianceArgs.diskSize, pveApplianceArgs.start)
|
||||
if !prompt.YesNo("Proceed?", false) {
|
||||
return errors.New("aborted")
|
||||
}
|
||||
}
|
||||
|
||||
imgPath, err := buildPVERawImage(gafPath, diskBytes, variant)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(imgPath)
|
||||
|
||||
if err := createPVEVM(ctx, vmid, name); err != nil {
|
||||
return fmt.Errorf("qm create: %w", err)
|
||||
}
|
||||
diskRef, err := importPVEDisk(ctx, vmid, pveApplianceArgs.storage, imgPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("qm disk import: %w", err)
|
||||
}
|
||||
if err := attachPVEDisk(ctx, vmid, diskRef); err != nil {
|
||||
return fmt.Errorf("qm set: %w", err)
|
||||
}
|
||||
|
||||
if pveApplianceArgs.start {
|
||||
if err := runQM(ctx, "start", strconv.Itoa(vmid)); err != nil {
|
||||
return fmt.Errorf("qm start: %w", err)
|
||||
}
|
||||
printf("VM %d started.\n", vmid)
|
||||
} else {
|
||||
printf("VM %d created; not started (pass --start to auto-start).\n", vmid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPVERawImage creates a sparse raw disk image in /var/tmp and
|
||||
// writes the GAF's boot + root images to it plus a fresh /perm ext4
|
||||
// filesystem. The returned path is the caller's to remove.
|
||||
func buildPVERawImage(gafPath string, devsize int64, variant string) (string, error) {
|
||||
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
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("/var/tmp", "tsapp-pve-*.raw")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
imgPath := tmp.Name()
|
||||
if err := tmp.Truncate(devsize); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(imgPath)
|
||||
return "", fmt.Errorf("truncate: %w", err)
|
||||
}
|
||||
|
||||
if err := writeApplianceImage(tmp, devsize, zr.File, bootCode, variant); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(imgPath)
|
||||
return "", err
|
||||
}
|
||||
|
||||
var permFiles []mkfs.PermFile
|
||||
if k := pveApplianceArgs.addSSHAuthorizedKeys; k != "" {
|
||||
keys, err := os.ReadFile(k)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(imgPath)
|
||||
return "", fmt.Errorf("reading --add-ssh-authorized-keys: %w", err)
|
||||
}
|
||||
permFiles = append(permFiles, mkfs.PermFile{
|
||||
Path: "breakglass.authorized_keys",
|
||||
Content: keys,
|
||||
})
|
||||
printf("Including SSH authorized_keys for breakglass access.\n")
|
||||
}
|
||||
if err := mkfs.Perm(tmp, devsize, permFiles...); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(imgPath)
|
||||
return "", fmt.Errorf("formatting perm: %w", err)
|
||||
}
|
||||
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(imgPath)
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(imgPath)
|
||||
return "", err
|
||||
}
|
||||
return imgPath, nil
|
||||
}
|
||||
|
||||
// pveNextID asks Proxmox for the next unused VMID via pvesh.
|
||||
func pveNextID(ctx context.Context) (int, error) {
|
||||
out, err := exec.CommandContext(ctx, "pvesh", "get", "/cluster/nextid").Output()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s := strings.TrimSpace(string(out))
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parsing pvesh output %q: %w", s, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func createPVEVM(ctx context.Context, vmid int, name string) error {
|
||||
return runQM(ctx, "create", strconv.Itoa(vmid),
|
||||
"--name", name,
|
||||
"--memory", strconv.Itoa(pveApplianceArgs.memory),
|
||||
"--cores", strconv.Itoa(pveApplianceArgs.cores),
|
||||
"--net0", "virtio,bridge="+pveApplianceArgs.bridge,
|
||||
"--scsihw", "virtio-scsi-single",
|
||||
"--serial0", "socket",
|
||||
"--agent", "1",
|
||||
"--ostype", "l26",
|
||||
"--tablet", "0",
|
||||
"--vga", "virtio",
|
||||
"--description", vmNotes(vmid),
|
||||
)
|
||||
}
|
||||
|
||||
// vmNotes is the description shown in the PVE web UI's "Notes" panel
|
||||
// for VMs we create. It tells admins the two ways to get into the
|
||||
// appliance without needing an SSH key: the framebuffer console (press
|
||||
// Esc for a shell) and the serial console (qm terminal).
|
||||
func vmNotes(vmid int) string {
|
||||
return fmt.Sprintf(`# Tailscale appliance [experimental]
|
||||
|
||||
Admin access to this VM (no SSH key required):
|
||||
|
||||
- **Framebuffer / NoVNC console**: press **Esc** on the enrollment screen
|
||||
to drop into a busybox shell. Type `+"`exit`"+` to return to the
|
||||
Tailscale status display.
|
||||
|
||||
- **Serial console**: on this Proxmox host, run
|
||||
|
||||
qm terminal %d
|
||||
|
||||
then press **Enter** to get a busybox shell. Press **Ctrl+O** to
|
||||
detach from `+"`qm terminal`"+` (leaves the guest shell alive).
|
||||
|
||||
Inside either shell, run `+"`tailscale`"+` commands as usual
|
||||
(`+"`tailscale up`"+`, `+"`tailscale status`"+`, etc.).
|
||||
`, vmid)
|
||||
}
|
||||
|
||||
// importPVEDisk imports src into storage on vmid and returns the
|
||||
// Proxmox volume reference of the newly-imported disk (e.g.
|
||||
// "local-lvm:vm-102-disk-0").
|
||||
//
|
||||
// The stdout of "qm disk import" isn't a stable API across Proxmox
|
||||
// versions — historically it has printed variants like
|
||||
// "vm-<vmid>-disk-<N>" and "importing disk … as <volid>", and the
|
||||
// allocated volume name isn't always vm-<vmid>-disk-0 (e.g. if a
|
||||
// stale volume with that name exists on the storage from a prior
|
||||
// VM). We instead diff the VM config before and after the import
|
||||
// and pick out the newly-added unusedN entry, which holds the real
|
||||
// volume ref.
|
||||
func importPVEDisk(ctx context.Context, vmid int, storage, src string) (volume string, err error) {
|
||||
before, err := pveVMConfig(ctx, vmid)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading VM %d config: %w", vmid, err)
|
||||
}
|
||||
if err := runQM(ctx, "disk", "import", strconv.Itoa(vmid), src, storage, "--format", "raw"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
after, err := pveVMConfig(ctx, vmid)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading VM %d config after import: %w", vmid, err)
|
||||
}
|
||||
for k, v := range after {
|
||||
if !strings.HasPrefix(k, "unused") {
|
||||
continue
|
||||
}
|
||||
if before[k] == v {
|
||||
continue
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
return "", fmt.Errorf("qm disk import didn't add an unusedN entry to VM %d config", vmid)
|
||||
}
|
||||
|
||||
// pveVMConfig returns the current runtime config for vmid on the local
|
||||
// PVE node, as a flat key → string map. We ask pvesh for JSON since
|
||||
// "qm config" text output has evolved over releases.
|
||||
func pveVMConfig(ctx context.Context, vmid int) (map[string]string, error) {
|
||||
node, err := os.Hostname()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "pvesh", "get",
|
||||
fmt.Sprintf("/nodes/%s/qemu/%d/config", node, vmid),
|
||||
"--output-format", "json").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(out, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parsing pvesh JSON: %w", err)
|
||||
}
|
||||
m := make(map[string]string, len(raw))
|
||||
for k, v := range raw {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
m[k] = v
|
||||
case float64:
|
||||
m[k] = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
m[k] = strconv.FormatBool(v)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func attachPVEDisk(ctx context.Context, vmid int, diskRef string) error {
|
||||
return runQM(ctx, "set", strconv.Itoa(vmid),
|
||||
"--scsi0", diskRef+",iothread=1",
|
||||
"--boot", "order=scsi0",
|
||||
)
|
||||
}
|
||||
|
||||
// runQM invokes `qm` with args, streaming its output to Stderr so the
|
||||
// caller can see disk-import progress and any error messages.
|
||||
func runQM(ctx context.Context, args ...string) error {
|
||||
printf("$ qm %s\n", strings.Join(args, " "))
|
||||
cmd := exec.CommandContext(ctx, "qm", args...)
|
||||
cmd.Stdout = Stderr
|
||||
cmd.Stderr = Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// parseSizeBytes parses a size string like "4G", "8192M", "1024K", or
|
||||
// "12345" (bytes) into a byte count. Empty string returns an error.
|
||||
func parseSizeBytes(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, errors.New("empty size")
|
||||
}
|
||||
mult := int64(1)
|
||||
switch last := s[len(s)-1]; {
|
||||
case last >= '0' && last <= '9':
|
||||
// no suffix
|
||||
default:
|
||||
switch last {
|
||||
case 'K', 'k':
|
||||
mult = 1 << 10
|
||||
case 'M', 'm':
|
||||
mult = 1 << 20
|
||||
case 'G', 'g':
|
||||
mult = 1 << 30
|
||||
case 'T', 't':
|
||||
mult = 1 << 40
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown size suffix %q", string(last))
|
||||
}
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0, fmt.Errorf("size %d must be positive", n)
|
||||
}
|
||||
return n * mult, nil
|
||||
}
|
||||
13
cmd/tailscale/cli/configure-pve-appliance_omit.go
Normal file
13
cmd/tailscale/cli/configure-pve-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 pveApplianceCmd() *ffcli.Command {
|
||||
// Omitted from the build when the ts_omit_flashappliance build tag is set.
|
||||
return nil
|
||||
}
|
||||
@ -33,6 +33,7 @@ func configureCmd() *ffcli.Command {
|
||||
configureKubeconfigCmd(),
|
||||
synologyConfigureCmd(),
|
||||
flashApplianceCmd(),
|
||||
pveApplianceCmd(),
|
||||
ccall(maybeConfigSynologyCertCmd),
|
||||
ccall(maybeSysExtCmd),
|
||||
ccall(maybeVPNConfigCmd),
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
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".
|
||||
// HasFlashAppliance is whether the binary was built with support for modular feature "'tailscale configure flash-appliance' and 'pve-appliance' CLI commands for deploying Tailscale appliance images".
|
||||
// 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
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
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".
|
||||
// HasFlashAppliance is whether the binary was built with support for modular feature "'tailscale configure flash-appliance' and 'pve-appliance' CLI commands for deploying Tailscale appliance images".
|
||||
// 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,7 +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"},
|
||||
"flashappliance": {Sym: "FlashAppliance", Desc: "'tailscale configure flash-appliance' and 'pve-appliance' CLI commands for deploying Tailscale appliance images"},
|
||||
"gro": {
|
||||
Sym: "GRO",
|
||||
Desc: "Generic Receive Offload support (performance)",
|
||||
|
||||
@ -169,4 +169,4 @@
|
||||
});
|
||||
};
|
||||
}
|
||||
# nix-direnv cache busting line: sha256-UrvJ5fM+Oqgu2pZwhg5AnUcgi8wPwZ8qDwWpXNmKaPk=
|
||||
# nix-direnv cache busting line: sha256-sWU4abv9Oz7P21ivL5zgdYNGiJSXamnQR0VmRGKoIrI=
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"sri": "sha256-cY5yryX+p/xtoTv+WZEKFagiIl0OREHnJY1Bk5VpVVc="
|
||||
},
|
||||
"vendor": {
|
||||
"goModSum": "sha256-evStNa6VrP7dYOuUIIj8nFPCFVUJ/GzcAefl2C4h8QA=",
|
||||
"sri": "sha256-UrvJ5fM+Oqgu2pZwhg5AnUcgi8wPwZ8qDwWpXNmKaPk="
|
||||
"goModSum": "sha256-gFAlZFTtqRlHzg0hHneMjNpkJ/CLuHEmeEeKT6fHRm0=",
|
||||
"sri": "sha256-sWU4abv9Oz7P21ivL5zgdYNGiJSXamnQR0VmRGKoIrI="
|
||||
}
|
||||
}
|
||||
|
||||
1
go.mod
1
go.mod
@ -18,6 +18,7 @@ require (
|
||||
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-20260630033929-b1eef977b41f
|
||||
github.com/bradfitz/qemu-guest-kragent v0.0.0-20240513123539-55a43ea02a03
|
||||
github.com/bramvdbogaerde/go-scp v1.4.0
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
|
||||
2
go.sum
2
go.sum
@ -213,6 +213,8 @@ github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5 h1:0sG3c7af
|
||||
github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5/go.mod h1:78ZLITnBUCDJeU01+wYYJKaPYYgsDzJPRfxeI8qFh5g=
|
||||
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/bradfitz/qemu-guest-kragent v0.0.0-20240513123539-55a43ea02a03 h1:V1gD/xbQZtimHXZ0y19oMgTVBIXQyazG/mG9JIflrIY=
|
||||
github.com/bradfitz/qemu-guest-kragent v0.0.0-20240513123539-55a43ea02a03/go.mod h1:DFLM0H5mh/gbb8viqnMwZzZ8TU4o3bbXOa5tyknrOQY=
|
||||
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=
|
||||
|
||||
@ -5,17 +5,22 @@
|
||||
// Tailscale appliance over the network without moving the SD card.
|
||||
//
|
||||
// The flow:
|
||||
// 1. Start a local HTTP server on an ephemeral port, auto-detecting
|
||||
// the local IP on the same subnet as the target.
|
||||
// 2. SSH into the appliance and run: tailscale update --
|
||||
// --gokrazy-update-from-url=http://<local>:<port>/file.gaf --unsigned
|
||||
// 3. The appliance downloads the GAF, writes partitions, switches root,
|
||||
// and reboots.
|
||||
// 4. Wait for the appliance to come back on SSH.
|
||||
// 1. scp the GAF to /perm/gafpush.tmp on the appliance (via
|
||||
// breakglass's SFTP subsystem).
|
||||
// 2. SSH into the appliance and run
|
||||
// tailscale update -- --gokrazy-update-from-url=file:///perm/gafpush.tmp --unsigned
|
||||
// which copies the file into a tmp GAF, writes partitions, switches
|
||||
// root, and reboots.
|
||||
// 3. Wait for the appliance to come back on SSH; then remove
|
||||
// /perm/gafpush.tmp.
|
||||
//
|
||||
// This flow is purely one-directional (developer → appliance), so
|
||||
// gafpush works from behind NAT, laptop firewalls (e.g. NixOS's default
|
||||
// deny-inbound), or when the appliance is on a different subnet.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// gafpush --gaf=path/to/file.gaf --pi=<ip>
|
||||
// gafpush --gaf=path/to/file.gaf --host=<ip-or-name>
|
||||
//
|
||||
// Or via the Makefile:
|
||||
//
|
||||
@ -23,12 +28,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@ -36,13 +38,19 @@
|
||||
)
|
||||
|
||||
var (
|
||||
gafPath = flag.String("gaf", "", "path to the GAF file to push")
|
||||
piAddr = flag.String("pi", "", "IP address of the target Pi")
|
||||
gafPath = flag.String("gaf", "", "path to the GAF file to push")
|
||||
host = flag.String("host", "", "target hostname or IP (SSH as root)")
|
||||
piAddr = flag.String("pi", "", "alias for --host, kept for backwards compatibility with the Makefile")
|
||||
remotePath = flag.String("remote-path", "/perm/gafpush.tmp", "where on the appliance to stage the GAF before the update")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *gafPath == "" || *piAddr == "" {
|
||||
target := *host
|
||||
if target == "" {
|
||||
target = *piAddr
|
||||
}
|
||||
if *gafPath == "" || target == "" {
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
@ -54,82 +62,74 @@ func main() {
|
||||
absGAF, _ := filepath.Abs(*gafPath)
|
||||
log.Printf("GAF: %s (%.1f MB)", absGAF, float64(fi.Size())/(1<<20))
|
||||
|
||||
localIP, err := findLocalIPFor(*piAddr)
|
||||
if err != nil {
|
||||
log.Fatalf("finding local IP on same subnet as %s: %v", *piAddr, err)
|
||||
}
|
||||
|
||||
// Start HTTP server on an ephemeral port.
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort(localIP, "0"))
|
||||
if err != nil {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
_, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||
gafURL := fmt.Sprintf("http://%s:%s/%s", localIP, port, filepath.Base(absGAF))
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("serving %s to %s", absGAF, r.RemoteAddr)
|
||||
http.ServeFile(w, r, absGAF)
|
||||
}),
|
||||
}
|
||||
go srv.Serve(ln)
|
||||
defer srv.Shutdown(context.Background())
|
||||
|
||||
log.Printf("serving GAF at %s", gafURL)
|
||||
log.Printf("SSHing into %s to trigger update...", *piAddr)
|
||||
|
||||
// SSH into the Pi and run tailscale update with the GAF URL.
|
||||
cmd := exec.Command("ssh",
|
||||
log.Printf("scp'ing GAF to %s:%s ...", target, *remotePath)
|
||||
scp := exec.Command("scp",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"root@"+*piAddr,
|
||||
absGAF, "root@"+target+":"+*remotePath,
|
||||
)
|
||||
scp.Stdout = os.Stdout
|
||||
scp.Stderr = os.Stderr
|
||||
if err := scp.Run(); err != nil {
|
||||
log.Fatalf("scp: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("SSHing into %s to trigger update...", target)
|
||||
fileURL := "file://" + *remotePath
|
||||
sshUpdate := exec.Command("ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"root@"+target,
|
||||
"tailscale", "update", "--",
|
||||
"--gokrazy-update-from-url="+gafURL,
|
||||
"--gokrazy-update-from-url="+fileURL,
|
||||
"--unsigned",
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// The SSH connection may drop when the Pi reboots, which is expected.
|
||||
sshUpdate.Stdout = os.Stdout
|
||||
sshUpdate.Stderr = os.Stderr
|
||||
if err := sshUpdate.Run(); err != nil {
|
||||
// SSH will drop when the appliance reboots, which is expected.
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 255 {
|
||||
log.Printf("SSH connection closed (Pi is rebooting)")
|
||||
log.Printf("SSH connection closed (appliance is rebooting)")
|
||||
} else {
|
||||
log.Fatalf("ssh: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("update pushed; Pi should reboot into the new image shortly")
|
||||
log.Printf("waiting for Pi to come back...")
|
||||
waitForPi(*piAddr)
|
||||
}
|
||||
|
||||
// findLocalIPFor returns our local IP address that's on the same subnet
|
||||
// as the given remote IP. It does this by dialing a UDP connection (which
|
||||
// doesn't actually send anything) and checking the local address chosen.
|
||||
func findLocalIPFor(remoteIP string) (string, error) {
|
||||
conn, err := net.DialTimeout("udp4", net.JoinHostPort(remoteIP, "9"), time.Second)
|
||||
if err != nil {
|
||||
return "", err
|
||||
log.Printf("update pushed; waiting for %s to come back...", target)
|
||||
if !waitForHost(target) {
|
||||
log.Printf("timed out waiting for appliance to come back (may have gotten a new IP); leaving %s in place", *remotePath)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("cleaning up staged GAF on %s ...", target)
|
||||
sshRm := exec.Command("ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"root@"+target,
|
||||
"rm", "-f", *remotePath,
|
||||
)
|
||||
sshRm.Stdout = os.Stdout
|
||||
sshRm.Stderr = os.Stderr
|
||||
if err := sshRm.Run(); err != nil {
|
||||
log.Printf("cleanup: %v (staged GAF still at %s)", err, *remotePath)
|
||||
}
|
||||
defer conn.Close()
|
||||
host, _, _ := net.SplitHostPort(conn.LocalAddr().String())
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// waitForPi polls the Pi's SSH port until it comes back up.
|
||||
func waitForPi(addr string) {
|
||||
// waitForHost polls the appliance's SSH port until it comes back up.
|
||||
// Returns true if it comes back within the deadline.
|
||||
func waitForHost(addr string) bool {
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(addr, "22"), 2*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
log.Printf("Pi is back at %s:22", addr)
|
||||
return
|
||||
log.Printf("appliance is back at %s:22", addr)
|
||||
return true
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
log.Printf("timed out waiting for Pi to come back (may have gotten a new IP)")
|
||||
return false
|
||||
}
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
"Packages": [
|
||||
"github.com/gokrazy/serial-busybox",
|
||||
"github.com/gokrazy/breakglass",
|
||||
"github.com/bradfitz/qemu-guest-kragent",
|
||||
"tailscale.com/cmd/fbstatus",
|
||||
"tailscale.com/cmd/tailscale",
|
||||
"tailscale.com/cmd/tailscaled"
|
||||
],
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
package gokrazydeps
|
||||
|
||||
import (
|
||||
_ "github.com/bradfitz/qemu-guest-kragent"
|
||||
_ "github.com/gokrazy/breakglass"
|
||||
_ "github.com/gokrazy/gokrazy/cmd/dhcp"
|
||||
_ "github.com/gokrazy/gokrazy/cmd/ntp"
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
"Packages": [
|
||||
"github.com/gokrazy/serial-busybox",
|
||||
"github.com/gokrazy/breakglass",
|
||||
"github.com/bradfitz/qemu-guest-kragent",
|
||||
"tailscale.com/cmd/fbstatus",
|
||||
"tailscale.com/cmd/tailscale",
|
||||
"tailscale.com/cmd/tailscaled"
|
||||
],
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
package gokrazydeps
|
||||
|
||||
import (
|
||||
_ "github.com/bradfitz/qemu-guest-kragent"
|
||||
_ "github.com/gokrazy/breakglass"
|
||||
_ "github.com/gokrazy/gokrazy/cmd/dhcp"
|
||||
_ "github.com/gokrazy/gokrazy/cmd/ntp"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user