mirror of
https://github.com/fosrl/newt.git
synced 2026-07-15 13:02:48 -05:00
301 lines
10 KiB
Go
301 lines
10 KiB
Go
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)
|
||
}
|