mirror of
https://github.com/fosrl/newt.git
synced 2026-07-22 07:31:28 +02:00
Auto update newt
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
26
main.go
26
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)
|
||||
|
||||
14
updates/reexec_unix.go
Normal file
14
updates/reexec_unix.go
Normal file
@@ -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())
|
||||
}
|
||||
28
updates/reexec_windows.go
Normal file
28
updates/reexec_windows.go
Normal file
@@ -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
|
||||
}
|
||||
218
updates/selfupdate.go
Normal file
218
updates/selfupdate.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user