From 887005d25566ae97aaec23137d7e84e8f2e01862 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Mon, 6 Jul 2026 11:24:30 -0700 Subject: [PATCH] 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 --- clientupdate/clientupdate_gokrazy.go | 39 +- cmd/fbstatus/fbstatus.go | 130 ++++-- .../cli/configure-flash-appliance.go | 105 +++-- cmd/tailscale/cli/configure-pve-appliance.go | 413 ++++++++++++++++++ .../cli/configure-pve-appliance_omit.go | 13 + cmd/tailscale/cli/configure.go | 1 + .../feature_flashappliance_disabled.go | 2 +- .../feature_flashappliance_enabled.go | 2 +- feature/featuretags/featuretags.go | 2 +- flake.nix | 2 +- flakehashes.json | 4 +- go.mod | 1 + go.sum | 2 + gokrazy/gafpush/gafpush.go | 138 +++--- gokrazy/tsapp-vm.arm64/config.json | 2 + gokrazy/tsapp-vm.arm64/gokrazydeps.go | 1 + gokrazy/tsapp/config.json | 2 + gokrazy/tsapp/gokrazydeps.go | 1 + shell.nix | 2 +- 19 files changed, 710 insertions(+), 152 deletions(-) create mode 100644 cmd/tailscale/cli/configure-pve-appliance.go create mode 100644 cmd/tailscale/cli/configure-pve-appliance_omit.go diff --git a/clientupdate/clientupdate_gokrazy.go b/clientupdate/clientupdate_gokrazy.go index a78bdf89b..1ffd5477f 100644 --- a/clientupdate/clientupdate_gokrazy.go +++ b/clientupdate/clientupdate_gokrazy.go @@ -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) { diff --git a/cmd/fbstatus/fbstatus.go b/cmd/fbstatus/fbstatus.go index 140e9a7c2..af17e7944 100644 --- a/cmd/fbstatus/fbstatus.go +++ b/cmd/fbstatus/fbstatus.go @@ -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 } } diff --git a/cmd/tailscale/cli/configure-flash-appliance.go b/cmd/tailscale/cli/configure-flash-appliance.go index 5dcb32cf1..c9f9af724 100644 --- a/cmd/tailscale/cli/configure-flash-appliance.go +++ b/cmd/tailscale/cli/configure-flash-appliance.go @@ -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 } diff --git a/cmd/tailscale/cli/configure-pve-appliance.go b/cmd/tailscale/cli/configure-pve-appliance.go new file mode 100644 index 000000000..17656ebb1 --- /dev/null +++ b/cmd/tailscale/cli/configure-pve-appliance.go @@ -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= [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 ' 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-"`) + 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--disk-" and "importing disk … as ", and the +// allocated volume name isn't always vm--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 +} diff --git a/cmd/tailscale/cli/configure-pve-appliance_omit.go b/cmd/tailscale/cli/configure-pve-appliance_omit.go new file mode 100644 index 000000000..81af1f31d --- /dev/null +++ b/cmd/tailscale/cli/configure-pve-appliance_omit.go @@ -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 +} diff --git a/cmd/tailscale/cli/configure.go b/cmd/tailscale/cli/configure.go index 0fda1dab1..863f0fb6b 100644 --- a/cmd/tailscale/cli/configure.go +++ b/cmd/tailscale/cli/configure.go @@ -33,6 +33,7 @@ func configureCmd() *ffcli.Command { configureKubeconfigCmd(), synologyConfigureCmd(), flashApplianceCmd(), + pveApplianceCmd(), ccall(maybeConfigSynologyCertCmd), ccall(maybeSysExtCmd), ccall(maybeVPNConfigCmd), diff --git a/feature/buildfeatures/feature_flashappliance_disabled.go b/feature/buildfeatures/feature_flashappliance_disabled.go index 9f8978cbd..30d44696d 100644 --- a/feature/buildfeatures/feature_flashappliance_disabled.go +++ b/feature/buildfeatures/feature_flashappliance_disabled.go @@ -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 diff --git a/feature/buildfeatures/feature_flashappliance_enabled.go b/feature/buildfeatures/feature_flashappliance_enabled.go index c02f6bc2c..b8852009f 100644 --- a/feature/buildfeatures/feature_flashappliance_enabled.go +++ b/feature/buildfeatures/feature_flashappliance_enabled.go @@ -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 diff --git a/feature/featuretags/featuretags.go b/feature/featuretags/featuretags.go index fe8ed0fb9..428a5f2b8 100644 --- a/feature/featuretags/featuretags.go +++ b/feature/featuretags/featuretags.go @@ -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)", diff --git a/flake.nix b/flake.nix index d7750970f..4f0dc908e 100644 --- a/flake.nix +++ b/flake.nix @@ -169,4 +169,4 @@ }); }; } -# nix-direnv cache busting line: sha256-UrvJ5fM+Oqgu2pZwhg5AnUcgi8wPwZ8qDwWpXNmKaPk= +# nix-direnv cache busting line: sha256-sWU4abv9Oz7P21ivL5zgdYNGiJSXamnQR0VmRGKoIrI= diff --git a/flakehashes.json b/flakehashes.json index cb7f6130f..f4095d4d6 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -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=" } } diff --git a/go.mod b/go.mod index 9b949348a..16f0673d8 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 37519b0a3..c1dd32133 100644 --- a/go.sum +++ b/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= diff --git a/gokrazy/gafpush/gafpush.go b/gokrazy/gafpush/gafpush.go index 2edaf5c59..f51424e91 100644 --- a/gokrazy/gafpush/gafpush.go +++ b/gokrazy/gafpush/gafpush.go @@ -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://:/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= +// gafpush --gaf=path/to/file.gaf --host= // // 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 } diff --git a/gokrazy/tsapp-vm.arm64/config.json b/gokrazy/tsapp-vm.arm64/config.json index 16f2005de..42c38840c 100644 --- a/gokrazy/tsapp-vm.arm64/config.json +++ b/gokrazy/tsapp-vm.arm64/config.json @@ -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" ], diff --git a/gokrazy/tsapp-vm.arm64/gokrazydeps.go b/gokrazy/tsapp-vm.arm64/gokrazydeps.go index 5911053f0..ed3fc3df2 100644 --- a/gokrazy/tsapp-vm.arm64/gokrazydeps.go +++ b/gokrazy/tsapp-vm.arm64/gokrazydeps.go @@ -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" diff --git a/gokrazy/tsapp/config.json b/gokrazy/tsapp/config.json index 54776ab9b..c46dc6f1f 100644 --- a/gokrazy/tsapp/config.json +++ b/gokrazy/tsapp/config.json @@ -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" ], diff --git a/gokrazy/tsapp/gokrazydeps.go b/gokrazy/tsapp/gokrazydeps.go index 22bdc3a49..bee05729d 100644 --- a/gokrazy/tsapp/gokrazydeps.go +++ b/gokrazy/tsapp/gokrazydeps.go @@ -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" diff --git a/shell.nix b/shell.nix index 9082caaf0..65bcd1105 100644 --- a/shell.nix +++ b/shell.nix @@ -16,4 +16,4 @@ ) { src = ./.; }).shellNix -# nix-direnv cache busting line: sha256-UrvJ5fM+Oqgu2pZwhg5AnUcgi8wPwZ8qDwWpXNmKaPk= +# nix-direnv cache busting line: sha256-sWU4abv9Oz7P21ivL5zgdYNGiJSXamnQR0VmRGKoIrI=