Files
newt/updates/selfupdate.go
2026-06-12 15:44:43 -07:00

301 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package updates
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/fosrl/newt/logger"
)
// 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://app.pangolin.net")
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
// Platform is the OS+arch string embedded at build time via ldflags
// (e.g. "linux_amd64", "darwin_arm64"). When non-empty it is used
// directly; when empty the value is derived from runtime.GOOS/GOARCH.
Platform 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"`
Sha256 string `json:"sha256"`
} `json:"data"`
Success bool `json:"success"`
Message string `json:"message"`
}
// ErrAutoUpdateUnsupportedInOfficialContainer indicates auto-update is not
// available when running inside official Fossorial container images.
var ErrAutoUpdateUnsupportedInOfficialContainer = errors.New("auto-update unsupported in official Fossorial container images")
// isOfficialContainer returns true when the process is running inside an
// official Fossorial-built container image. The image sets
// NEWT_SYSTEM_SUBSTRATE="CONTAINER" 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_SYSTEM_SUBSTRATE") == "CONTAINER"
}
// platform returns the OS+arch string used in the newt release binary names,
// e.g. "linux_amd64", "darwin_arm64", "windows_amd64".
// It is used as a fallback when no platform was embedded at build time.
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)
}
// verifySHA256 checks that the file at path has the expected SHA-256 hex digest.
func verifySHA256(path, expected string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open file for hashing: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return fmt.Errorf("failed to hash file: %w", err)
}
got := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(got, expected) {
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expected, got)
}
return nil
}
// 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 {
logger.Debug("checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion)
if isOfficialContainer() {
logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update")
return fmt.Errorf("%w; pull a new image tag instead", ErrAutoUpdateUnsupportedInOfficialContainer)
}
if cfg.CurrentVersion == "version_replaceme" {
logger.Debug("checkAndSelfUpdate: development build detected, skipping auto-update")
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)
}
logger.Debug("checkAndSelfUpdate: current executable path: %s", exePath)
// --- Step 1: Ask the server for the latest version ---
plat := cfg.Platform
if plat == "" {
plat = platform()
}
logger.Debug("checkAndSelfUpdate: querying server for latest version (platform=%s, endpoint=%s)", plat, baseEndpoint)
reqBody, err := json.Marshal(map[string]string{
"newtId": cfg.NewtID,
"secret": cfg.Secret,
"platform": plat,
})
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.StatusNoContent { // updates are disabled
logger.Debug("checkAndSelfUpdate: server indicated updates are disabled (204 No Content)")
return nil
}
if resp.StatusCode == http.StatusNotFound { // older server without version endpoint
logger.Debug("checkAndSelfUpdate: server does not support version endpoint (404 Not Found), skipping")
return nil
}
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 {
logger.Debug("checkAndSelfUpdate: already up to date (%s)", cfg.CurrentVersion)
return nil
}
logger.Debug("checkAndSelfUpdate: update available %s → %s", cfg.CurrentVersion, verResp.Data.LatestVersion)
// --- Pre-download: verify we can write to the binary's directory ---
// Do this before downloading so a permission failure doesn't waste bandwidth.
exeDir := filepath.Dir(exePath)
logger.Debug("checkAndSelfUpdate: verifying write access to %s", exeDir)
writeTestFile, err := os.CreateTemp(exeDir, ".newt-write-test-*")
if err != nil {
return fmt.Errorf("cannot write to %s (you may need to run as root or with elevated permissions): %w", exeDir, err)
}
writeTestFile.Close()
_ = os.Remove(writeTestFile.Name())
// --- Step 2: Download the new binary ---
logger.Debug("checkAndSelfUpdate: beginning download of 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.
tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*")
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)
}
// --- Verify SHA256 checksum if the server provided one ---
if verResp.Data.Sha256 != "" {
logger.Debug("checkAndSelfUpdate: verifying SHA256 checksum")
if err := verifySHA256(tmpPath, verResp.Data.Sha256); err != nil {
return fmt.Errorf("binary integrity check failed: %w", err)
}
logger.Debug("checkAndSelfUpdate: SHA256 checksum verified")
} else {
logger.Debug("checkAndSelfUpdate: no SHA256 checksum provided by server, skipping verification")
}
// 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.
logger.Debug("checkAndSelfUpdate: replacing binary at %s", exePath)
if err := os.Rename(tmpPath, exePath); err != nil {
return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err)
}
systemSubstrate := os.Getenv("NEWT_SYSTEM_SUBSTRATE")
logger.Debug("checkAndSelfUpdate: system substrate: %s", systemSubstrate)
if systemSubstrate == "ADVANTECH_ROUTER_APP" {
pidFile := os.Getenv("NEWT_PID_FILE")
if err := postUpdateAdvantech(verResp.Data.LatestVersion, pidFile); err != nil {
logger.Debug("checkAndSelfUpdate: advantech post-update steps failed: %v", err)
}
}
// --- Step 4: Re-exec ---
logger.Debug("checkAndSelfUpdate: re-executing new binary")
return reexec(exePath)
}