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:
Brad Fitzpatrick 2026-07-06 11:24:30 -07:00 committed by Brad Fitzpatrick
parent c1ae2bb1f8
commit 887005d255
19 changed files with 710 additions and 152 deletions

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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
}

View 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
}

View 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
}

View File

@ -33,6 +33,7 @@ func configureCmd() *ffcli.Command {
configureKubeconfigCmd(),
synologyConfigureCmd(),
flashApplianceCmd(),
pveApplianceCmd(),
ccall(maybeConfigSynologyCertCmd),
ccall(maybeSysExtCmd),
ccall(maybeVPNConfigCmd),

View File

@ -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

View File

@ -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

View File

@ -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)",

View File

@ -169,4 +169,4 @@
});
};
}
# nix-direnv cache busting line: sha256-UrvJ5fM+Oqgu2pZwhg5AnUcgi8wPwZ8qDwWpXNmKaPk=
# nix-direnv cache busting line: sha256-sWU4abv9Oz7P21ivL5zgdYNGiJSXamnQR0VmRGKoIrI=

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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
}

View File

@ -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"
],

View File

@ -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"

View File

@ -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"
],

View File

@ -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"

View File

@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-UrvJ5fM+Oqgu2pZwhg5AnUcgi8wPwZ8qDwWpXNmKaPk=
# nix-direnv cache busting line: sha256-sWU4abv9Oz7P21ivL5zgdYNGiJSXamnQR0VmRGKoIrI=