From c57b917ac1b332abb749af5de31cc6796582eb6a Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 1 Jul 2026 17:15:28 +0000 Subject: [PATCH] gokrazy/gafpush, Makefile: add OTA push tool for appliance development Adds gokrazy/gafpush and a 'make tsapp-push-pi PI=' Makefile target for pushing a freshly-built GAF to a running appliance over the network. See the gokrazy/gafpush package doc for details. Updates #1866 Change-Id: Id7a0bad712fcf2eddae593f71d5feacee05c5234 Signed-off-by: Brad Fitzpatrick --- Makefile | 6 ++ gokrazy/gafpush/gafpush.go | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 gokrazy/gafpush/gafpush.go diff --git a/Makefile b/Makefile index 28bf09f11..998d709e9 100644 --- a/Makefile +++ b/Makefile @@ -156,6 +156,12 @@ tsapp-build-and-flash-pi: ## Build a tsapp-pi.arm64 GAF from HEAD and flash a lo --gaf=gokrazy/tsapp-pi.arm64.gaf \ $(if $(DISK),--disk=$(DISK)) +.PHONY: tsapp-push-pi +tsapp-push-pi: ## Build a tsapp-pi.arm64 GAF from HEAD and push it to a running Pi over the network (pass PI=) + @[ -n "$(PI)" ] || { echo "usage: make tsapp-push-pi PI="; exit 1; } + cd gokrazy && ../tool/go run build.go --gaf --app=tsapp-pi.arm64 + ./tool/go run ./gokrazy/gafpush --gaf=gokrazy/tsapp-pi.arm64.gaf --pi=$(PI) + .PHONY: pin-github-actions pin-github-actions: ./tool/go tool github.com/stacklok/frizbee actions .github/workflows diff --git a/gokrazy/gafpush/gafpush.go b/gokrazy/gafpush/gafpush.go new file mode 100644 index 000000000..2edaf5c59 --- /dev/null +++ b/gokrazy/gafpush/gafpush.go @@ -0,0 +1,135 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// gafpush pushes a Gokrazy Archive Format (GAF) file to a running +// 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. +// +// Usage: +// +// gafpush --gaf=path/to/file.gaf --pi= +// +// Or via the Makefile: +// +// make tsapp-push-pi PI= +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "time" +) + +var ( + gafPath = flag.String("gaf", "", "path to the GAF file to push") + piAddr = flag.String("pi", "", "IP address of the target Pi") +) + +func main() { + flag.Parse() + if *gafPath == "" || *piAddr == "" { + flag.Usage() + os.Exit(1) + } + + fi, err := os.Stat(*gafPath) + if err != nil { + log.Fatalf("GAF file: %v", err) + } + 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", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=10", + "root@"+*piAddr, + "tailscale", "update", "--", + "--gokrazy-update-from-url="+gafURL, + "--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. + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 255 { + log.Printf("SSH connection closed (Pi 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 + } + 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) { + 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 + } + time.Sleep(2 * time.Second) + } + log.Printf("timed out waiting for Pi to come back (may have gotten a new IP)") +}