From 42cb8e790851ea6fa2df73a846523751e0d96276 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 11:29:57 -0700 Subject: [PATCH] Auto update newt --- Dockerfile | 4 + main.go | 26 +++++ updates/reexec_unix.go | 14 +++ updates/reexec_windows.go | 28 +++++ updates/selfupdate.go | 218 ++++++++++++++++++++++++++++++++++++++ websocket/client.go | 16 +++ 6 files changed, 306 insertions(+) create mode 100644 updates/reexec_unix.go create mode 100644 updates/reexec_windows.go create mode 100644 updates/selfupdate.go diff --git a/Dockerfile b/Dockerfile index ea870c2..6887923 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,10 @@ RUN apk --no-cache add ca-certificates tzdata iputils COPY --from=builder /newt /usr/local/bin/ COPY entrypoint.sh / +# Marks this as an official Fossorial container image. +# Auto-update is disabled in official images — update by pulling a new image tag. +ENV NEWT_OFFICIAL_CONTAINER=true + # Admin/metrics endpoint (Prometheus scrape) EXPOSE 2112 diff --git a/main.go b/main.go index 448f71d..034ab82 100644 --- a/main.go +++ b/main.go @@ -176,6 +176,9 @@ var ( // Path to config file (overrides CONFIG_FILE env var and default location) configFile string + + // Auto-update flag + autoUpdate bool ) // generateChainId generates a random chain ID for deduplicating round-trip messages. @@ -489,6 +492,7 @@ func runNewtMain(ctx context.Context) { // do a --version check version := flag.Bool("version", false, "Print the version") + flag.BoolVar(&autoUpdate, "auto-update", false, "Check for a newer version on the server and self-update if one is available") flag.Parse() @@ -656,9 +660,31 @@ func runNewtMain(ctx context.Context) { endpoint = client.GetConfig().Endpoint // Update endpoint from config id = client.GetConfig().ID // Update ID from config + secret = client.GetConfig().Secret // Update secret from config // Update site labels for metrics with the resolved ID telemetry.UpdateSiteInfo(id, region) + // Auto-update: check for a new version, download and replace if available. + if autoUpdate { + var tlsCfg *tls.Config + if opt != nil { + // Reuse the TLS configuration already set up for the websocket client. + tlsCfg, _ = websocket.BuildTLSConfig(tlsClientCert, tlsClientKey, tlsClientCAs, tlsPrivateKey) + } + if err := updates.CheckAndSelfUpdate(updates.SelfUpdateConfig{ + Endpoint: endpoint, + NewtID: id, + Secret: secret, + CurrentVersion: newtVersion, + TLSConfig: tlsCfg, + }); err != nil { + logger.Fatal("Auto-update failed: %v", err) + } + // CheckAndSelfUpdate re-execs on success, so we only reach here if + // newt is already up to date. + return + } + // output env var values if set logger.Debug("Endpoint: %v", endpoint) logger.Debug("Log Level: %v", logLevel) diff --git a/updates/reexec_unix.go b/updates/reexec_unix.go new file mode 100644 index 0000000..b439d10 --- /dev/null +++ b/updates/reexec_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package updates + +import ( + "os" + "syscall" +) + +// reexec replaces the current process image with the binary at exePath, +// forwarding all original arguments and environment variables. +func reexec(exePath string) error { + return syscall.Exec(exePath, os.Args, os.Environ()) +} diff --git a/updates/reexec_windows.go b/updates/reexec_windows.go new file mode 100644 index 0000000..6174037 --- /dev/null +++ b/updates/reexec_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package updates + +import ( + "fmt" + "os" + "os/exec" +) + +// reexec on Windows cannot use syscall.Exec (there is no exec syscall that +// replaces the process image). Instead we start a new process and exit the +// current one. +func reexec(exePath string) error { + cmd := exec.Command(exePath, os.Args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start updated binary: %w", err) + } + + // Exit the current process so the new binary takes over. + os.Exit(0) + return nil // unreachable +} diff --git a/updates/selfupdate.go b/updates/selfupdate.go new file mode 100644 index 0000000..751f358 --- /dev/null +++ b/updates/selfupdate.go @@ -0,0 +1,218 @@ +package updates + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// SelfUpdateConfig holds the configuration required to perform a self-update. +type SelfUpdateConfig struct { + // Endpoint is the base URL of the pangolin server (e.g. "https://pangolin.example.com") + Endpoint string + // NewtID is the newt client identifier used for authentication. + NewtID string + // Secret is the newt client secret used for authentication. + Secret string + // CurrentVersion is the version of the currently running binary. + CurrentVersion string + // TLSConfig is an optional TLS configuration for the HTTP client (may be nil). + TLSConfig *tls.Config +} + +// versionResponse mirrors the JSON returned by POST /api/v1/auth/newt/version +type versionResponse struct { + Data struct { + LatestVersion string `json:"latestVersion"` + CurrentIsLatest bool `json:"currentIsLatest"` + DownloadUrl string `json:"downloadUrl"` + } `json:"data"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// isOfficialContainer returns true when the process is running inside an +// official Fossorial-built container image. The image sets +// NEWT_OFFICIAL_CONTAINER=true at build time; users running newt in their own +// containers (or bare-metal) will not have this variable set. +func isOfficialContainer() bool { + return os.Getenv("NEWT_OFFICIAL_CONTAINER") == "true" +} + +// platform returns the OS+arch string used in the newt release binary names, +// e.g. "linux_amd64", "darwin_arm64", "windows_amd64". +func platform() string { + goarch := runtime.GOARCH + goos := runtime.GOOS + + // Map Go arch names to the names used by newt releases + archMap := map[string]string{ + "amd64": "amd64", + "arm64": "arm64", + "arm": "arm32", + "riscv64": "riscv64", + } + + arch, ok := archMap[goarch] + if !ok { + arch = goarch + } + + return fmt.Sprintf("%s_%s", goos, arch) +} + +// CheckAndSelfUpdate contacts the pangolin server, checks whether a newer +// version of newt is available, downloads it if so, replaces the running +// binary on disk, and re-executes from the new binary. +// +// It returns an error when the check or update fails. On a successful update +// the function does not return – the process is replaced by the new binary via +// syscall.Exec. +func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { + if isOfficialContainer() { + return fmt.Errorf("auto-update is not supported in official Fossorial container images; pull a new image tag instead") + } + + if cfg.CurrentVersion == "version_replaceme" { + return fmt.Errorf("cannot auto-update a development build (version_replaceme)") + } + + baseEndpoint := strings.TrimRight(cfg.Endpoint, "/") + + // Build the HTTP client. + httpClient := &http.Client{Timeout: 30 * time.Second} + if cfg.TLSConfig != nil { + httpClient.Transport = &http.Transport{TLSClientConfig: cfg.TLSConfig} + } + + // Check the current binary path before we do anything else so we can fail + // fast if the executable path cannot be determined. + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine current executable path: %w", err) + } + exePath, err = filepath.EvalSymlinks(exePath) + if err != nil { + return fmt.Errorf("failed to resolve symlinks for executable path: %w", err) + } + + // --- Step 1: Ask the server for the latest version --- + reqBody, err := json.Marshal(map[string]string{ + "newtId": cfg.NewtID, + "secret": cfg.Secret, + "platform": platform(), + }) + if err != nil { + return fmt.Errorf("failed to marshal version request: %w", err) + } + + versionURL, err := url.JoinPath(baseEndpoint, "/api/v1/auth/newt/version") + if err != nil { + return fmt.Errorf("failed to build version URL: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(reqBody)) + if err != nil { + return fmt.Errorf("failed to create version request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", "x-csrf-protection") + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to request version info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) + } + + var verResp versionResponse + if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil { + return fmt.Errorf("failed to parse version response: %w", err) + } + + if !verResp.Success { + return fmt.Errorf("server error: %s", verResp.Message) + } + + if verResp.Data.CurrentIsLatest { + fmt.Printf("newt is already up to date (%s)\n", cfg.CurrentVersion) + return nil + } + + fmt.Printf("Update available: %s → %s\n", cfg.CurrentVersion, verResp.Data.LatestVersion) + fmt.Printf("Downloading from: %s\n", verResp.Data.DownloadUrl) + + // --- Step 2: Download the new binary --- + dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer dlCancel() + + dlReq, err := http.NewRequestWithContext(dlCtx, "GET", verResp.Data.DownloadUrl, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + + dlResp, err := httpClient.Do(dlReq) + if err != nil { + return fmt.Errorf("failed to download new binary: %w", err) + } + defer dlResp.Body.Close() + + if dlResp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status %d", dlResp.StatusCode) + } + + // Write to a temp file in the same directory as the current binary so that + // an atomic rename works even across filesystem boundaries. + exeDir := filepath.Dir(exePath) + tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*") + if err != nil { + return fmt.Errorf("failed to create temp file for download: %w", err) + } + tmpPath := tmpFile.Name() + + // Ensure the temp file is cleaned up on any error path. + defer func() { + _ = os.Remove(tmpPath) + }() + + if _, err := io.Copy(tmpFile, dlResp.Body); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write downloaded binary: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // Make the new binary executable. + if err := os.Chmod(tmpPath, 0755); err != nil { + return fmt.Errorf("failed to set executable permission: %w", err) + } + + // --- Step 3: Replace the running binary --- + // On Unix an atomic rename works even while the file is running. + if err := os.Rename(tmpPath, exePath); err != nil { + return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err) + } + + fmt.Printf("Binary updated to %s at %s\n", verResp.Data.LatestVersion, exePath) + + // --- Step 4: Re-exec --- + return reexec(exePath) +} diff --git a/websocket/client.go b/websocket/client.go index 5068471..22187ac 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -931,3 +931,19 @@ func loadClientCertificate(p12Path string) (*tls.Config, error) { RootCAs: rootCAs, }, nil } + +// BuildTLSConfig creates a *tls.Config from the provided certificate files. +// Pass empty strings / nil slice for fields that are not used. +// Returns nil, nil when no TLS credentials are provided. +func BuildTLSConfig(certFile, keyFile string, caFiles []string, pkcs12File string) (*tls.Config, error) { + c := &Client{ + tlsConfig: TLSConfig{ + ClientCertFile: certFile, + ClientKeyFile: keyFile, + CAFiles: caFiles, + PKCS12File: pkcs12File, + }, + config: &Config{}, + } + return c.setupTLS() +}