mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 23:41:48 +01:00
385 lines
12 KiB
Go
385 lines
12 KiB
Go
package dockeragent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
|
)
|
|
|
|
// checkForUpdates checks if a newer version is available and performs self-update if needed
|
|
func (a *Agent) checkForUpdates(ctx context.Context) {
|
|
// Skip updates if disabled via config
|
|
if a.cfg.DisableAutoUpdate {
|
|
a.logger.Info().Msg("Skipping update check - auto-update disabled")
|
|
return
|
|
}
|
|
|
|
// Skip updates in development mode to prevent update loops
|
|
if Version == "dev" {
|
|
a.logger.Debug().Msg("Skipping update check - running in development mode")
|
|
return
|
|
}
|
|
|
|
// Skip updates if running as part of the unified agent
|
|
if a.cfg.AgentType == "unified" {
|
|
a.logger.Debug().Msg("Skipping update check - running in unified agent mode")
|
|
return
|
|
}
|
|
|
|
a.logger.Debug().Msg("Checking for agent updates")
|
|
|
|
target := a.primaryTarget()
|
|
if target.URL == "" {
|
|
a.logger.Debug().Msg("Skipping update check - no Pulse target configured")
|
|
return
|
|
}
|
|
|
|
// Get current version from server
|
|
url := fmt.Sprintf("%s/api/agent/version", target.URL)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
a.logger.Warn().Err(err).Msg("Failed to create version check request")
|
|
return
|
|
}
|
|
|
|
if target.Token != "" {
|
|
req.Header.Set("X-API-Token", target.Token)
|
|
req.Header.Set("Authorization", "Bearer "+target.Token)
|
|
}
|
|
|
|
client := a.httpClientFor(target)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
a.logger.Warn().Err(err).Msg("Failed to check for updates")
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
a.logger.Warn().Int("status", resp.StatusCode).Msg("Version endpoint returned non-200 status")
|
|
return
|
|
}
|
|
|
|
var versionResp struct {
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&versionResp); err != nil {
|
|
a.logger.Warn().Err(err).Msg("Failed to decode version response")
|
|
return
|
|
}
|
|
|
|
// Skip updates if server is also in development mode
|
|
if versionResp.Version == "dev" {
|
|
a.logger.Debug().Msg("Skipping update - server is in development mode")
|
|
return
|
|
}
|
|
|
|
// Compare versions - normalize by stripping "v" prefix for comparison.
|
|
// Server returns version without prefix (e.g., "4.33.1"), but agent's
|
|
// Version may include it (e.g., "v4.33.1") depending on build.
|
|
if utils.NormalizeVersion(versionResp.Version) == utils.NormalizeVersion(Version) {
|
|
a.logger.Debug().Str("version", Version).Msg("Agent is up to date")
|
|
return
|
|
}
|
|
|
|
a.logger.Info().
|
|
Str("currentVersion", Version).
|
|
Str("availableVersion", versionResp.Version).
|
|
Msg("New agent version available, performing self-update")
|
|
|
|
// Perform self-update
|
|
if err := selfUpdateFunc(a, ctx); err != nil {
|
|
a.logger.Error().Err(err).Msg("Failed to self-update agent")
|
|
return
|
|
}
|
|
|
|
a.logger.Info().Msg("Agent updated successfully, restarting...")
|
|
}
|
|
|
|
// isUnraid checks if we're running on Unraid by looking for /etc/unraid-version
|
|
func isUnraid() bool {
|
|
_, err := osStatFn(unraidVersionPath)
|
|
return err == nil
|
|
}
|
|
|
|
// resolveSymlink resolves symlinks to get the real path of a file.
|
|
// This is needed for self-update because os.Rename() fails across filesystems.
|
|
func resolveSymlink(path string) (string, error) {
|
|
return filepath.EvalSymlinks(path)
|
|
}
|
|
|
|
// verifyELFMagic checks that the file is a valid ELF binary
|
|
func verifyELFMagic(path string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
magic := make([]byte, 4)
|
|
if _, err := io.ReadFull(f, magic); err != nil {
|
|
return fmt.Errorf("failed to read magic bytes: %w", err)
|
|
}
|
|
|
|
// ELF magic: 0x7f 'E' 'L' 'F'
|
|
if magic[0] == 0x7f && magic[1] == 'E' && magic[2] == 'L' && magic[3] == 'F' {
|
|
return nil
|
|
}
|
|
return errors.New("not a valid ELF binary")
|
|
}
|
|
|
|
func determineSelfUpdateArch() string {
|
|
switch goArch {
|
|
case "amd64":
|
|
return "linux-amd64"
|
|
case "arm64":
|
|
return "linux-arm64"
|
|
case "arm":
|
|
return "linux-armv7"
|
|
}
|
|
|
|
out, err := unameMachine()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
normalized := strings.ToLower(strings.TrimSpace(out))
|
|
switch normalized {
|
|
case "x86_64", "amd64":
|
|
return "linux-amd64"
|
|
case "aarch64", "arm64":
|
|
return "linux-arm64"
|
|
case "armv7l", "armhf", "armv7":
|
|
return "linux-armv7"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// selfUpdate downloads the new agent binary and replaces the current one
|
|
func (a *Agent) selfUpdate(ctx context.Context) error {
|
|
target := a.primaryTarget()
|
|
if target.URL == "" {
|
|
return errors.New("no Pulse target configured for self-update")
|
|
}
|
|
|
|
// Get path to current executable
|
|
execPath, err := osExecutableFn()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get executable path: %w", err)
|
|
}
|
|
|
|
// Resolve symlinks to get the real path for atomic rename
|
|
// os.Rename() fails across filesystems, so we need the actual target path
|
|
realExecPath, err := resolveSymlink(execPath)
|
|
if err != nil {
|
|
a.logger.Debug().Err(err).Str("path", execPath).Msg("Failed to resolve symlinks, using original path")
|
|
realExecPath = execPath
|
|
} else if realExecPath != execPath {
|
|
a.logger.Debug().
|
|
Str("symlink", execPath).
|
|
Str("target", realExecPath).
|
|
Msg("Resolved symlink for self-update")
|
|
}
|
|
|
|
downloadBase := strings.TrimRight(target.URL, "/") + "/download/pulse-docker-agent"
|
|
archParam := determineSelfUpdateArch()
|
|
|
|
type downloadCandidate struct {
|
|
url string
|
|
arch string
|
|
}
|
|
|
|
candidates := make([]downloadCandidate, 0, 2)
|
|
if archParam != "" {
|
|
candidates = append(candidates, downloadCandidate{
|
|
url: fmt.Sprintf("%s?arch=%s", downloadBase, archParam),
|
|
arch: archParam,
|
|
})
|
|
}
|
|
candidates = append(candidates, downloadCandidate{url: downloadBase})
|
|
|
|
client := a.httpClientFor(target)
|
|
var resp *http.Response
|
|
lastErr := errors.New("failed to download new binary")
|
|
|
|
for _, candidate := range candidates {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, candidate.url, nil)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("failed to create download request: %w", err)
|
|
continue
|
|
}
|
|
|
|
if target.Token != "" {
|
|
req.Header.Set("X-API-Token", target.Token)
|
|
req.Header.Set("Authorization", "Bearer "+target.Token)
|
|
}
|
|
|
|
response, err := client.Do(req)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("failed to download new binary: %w", err)
|
|
continue
|
|
}
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
lastErr = fmt.Errorf("download failed with status: %s", response.Status)
|
|
response.Body.Close()
|
|
continue
|
|
}
|
|
|
|
resp = response
|
|
if candidate.arch != "" {
|
|
a.logger.Debug().
|
|
Str("arch", candidate.arch).
|
|
Msg("Self-update: downloaded architecture-specific agent binary")
|
|
} else if archParam != "" {
|
|
a.logger.Debug().Msg("Self-update: falling back to server default agent binary")
|
|
}
|
|
break
|
|
}
|
|
|
|
if resp == nil {
|
|
return lastErr
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
checksumHeader := strings.TrimSpace(resp.Header.Get("X-Checksum-Sha256"))
|
|
|
|
// Create temporary file in the same directory as the target binary
|
|
// to ensure atomic rename works (os.Rename fails across filesystems)
|
|
targetDir := filepath.Dir(realExecPath)
|
|
tmpFile, err := osCreateTempFn(targetDir, "pulse-docker-agent-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file in %s: %w", targetDir, err)
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
defer osRemoveFn(tmpPath) // Clean up if something goes wrong
|
|
|
|
// Write downloaded binary to temp file with size limit (100 MB max)
|
|
const maxBinarySize = 100 * 1024 * 1024
|
|
hasher := sha256.New()
|
|
limitedReader := io.LimitReader(resp.Body, maxBinarySize+1)
|
|
written, err := io.Copy(tmpFile, io.TeeReader(limitedReader, hasher))
|
|
if err != nil {
|
|
tmpFile.Close()
|
|
return fmt.Errorf("failed to write downloaded binary: %w", err)
|
|
}
|
|
if written > maxBinarySize {
|
|
tmpFile.Close()
|
|
return fmt.Errorf("downloaded binary exceeds maximum size (%d bytes)", maxBinarySize)
|
|
}
|
|
if err := closeFileFn(tmpFile); err != nil {
|
|
return fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
// Verify it's a valid ELF binary (basic sanity check for Linux)
|
|
if err := verifyELFMagic(tmpPath); err != nil {
|
|
return fmt.Errorf("downloaded file is not a valid executable: %w", err)
|
|
}
|
|
|
|
// Verify checksum (mandatory for security)
|
|
downloadChecksum := hex.EncodeToString(hasher.Sum(nil))
|
|
if checksumHeader == "" {
|
|
return fmt.Errorf("server did not provide checksum header (X-Checksum-Sha256); refusing update for security")
|
|
}
|
|
|
|
expected := strings.ToLower(strings.TrimSpace(checksumHeader))
|
|
actual := strings.ToLower(downloadChecksum)
|
|
if expected != actual {
|
|
return fmt.Errorf("checksum verification failed: expected %s, got %s", expected, actual)
|
|
}
|
|
a.logger.Debug().Str("checksum", downloadChecksum).Msg("Self-update: checksum verified")
|
|
|
|
// Verify cryptographic signature (X-Signature-Ed25519 header)
|
|
signatureHeader := strings.TrimSpace(resp.Header.Get("X-Signature-Ed25519"))
|
|
if signatureHeader != "" {
|
|
if err := verifyFileSignature(tmpPath, signatureHeader); err != nil {
|
|
return fmt.Errorf("signature verification failed: %w", err)
|
|
}
|
|
a.logger.Info().Msg("Self-update: cryptographic signature verified")
|
|
} else {
|
|
// For now, only warn if missing. In strict mode, we would error here.
|
|
a.logger.Warn().Msg("Self-update: server did not provide cryptographic signature (X-Signature-Ed25519)")
|
|
}
|
|
|
|
// Make temp file executable
|
|
if err := osChmodFn(tmpPath, 0755); err != nil {
|
|
return fmt.Errorf("failed to make temp file executable: %w", err)
|
|
}
|
|
|
|
// Pre-flight check: Run the new binary with --self-test
|
|
// We use the same environment as the current process
|
|
// We also need to supply minimal required flags if they are mandatory (like token)
|
|
// However, we just grab current args and replace the executable path,
|
|
// and force --self-test
|
|
a.logger.Debug().Msg("Self-update: running pre-flight check on new binary...")
|
|
|
|
// Construct args for self-test. We need a valid token source for config load to pass.
|
|
// Since we are running on the same host, we can pass the token file or env.
|
|
// Simplest approach: pass --self-test and --token=dummy (if validation requires it)
|
|
// But our config loader checks token presence.
|
|
// Let's use the actual token configured to be safe.
|
|
checkCmd := execCommandContextFn(ctx, tmpPath, "--self-test", "--token", a.cfg.APIToken)
|
|
if output, err := checkCmd.CombinedOutput(); err != nil {
|
|
a.logger.Error().Str("output", string(output)).Err(err).Msg("Self-update: pre-flight check failed")
|
|
return fmt.Errorf("new binary failed self-test: %w", err)
|
|
}
|
|
a.logger.Debug().Msg("Self-update: pre-flight check passed")
|
|
|
|
// Create backup of current binary (use realExecPath for atomic operations)
|
|
backupPath := realExecPath + ".backup"
|
|
if err := osRenameFn(realExecPath, backupPath); err != nil {
|
|
return fmt.Errorf("failed to backup current binary: %w", err)
|
|
}
|
|
|
|
// Move new binary to current location
|
|
if err := osRenameFn(tmpPath, realExecPath); err != nil {
|
|
// Restore backup on failure
|
|
_ = osRenameFn(backupPath, realExecPath)
|
|
return fmt.Errorf("failed to replace binary: %w", err)
|
|
}
|
|
|
|
// Remove backup on success
|
|
_ = osRemoveFn(backupPath)
|
|
|
|
// On Unraid, also update the persistent copy on the flash drive
|
|
if isUnraid() {
|
|
persistPath := unraidPersistPath
|
|
if _, err := osStatFn(persistPath); err == nil {
|
|
a.logger.Debug().Str("path", persistPath).Msg("Updating Unraid persistent binary")
|
|
if newBinary, err := osReadFileFn(execPath); err == nil {
|
|
tmpPersist := persistPath + ".tmp"
|
|
if err := osWriteFileFn(tmpPersist, newBinary, 0644); err != nil {
|
|
a.logger.Warn().Err(err).Msg("Failed to write Unraid persistent binary")
|
|
} else if err := osRenameFn(tmpPersist, persistPath); err != nil {
|
|
a.logger.Warn().Err(err).Msg("Failed to rename Unraid persistent binary")
|
|
_ = osRemoveFn(tmpPersist)
|
|
} else {
|
|
a.logger.Info().Str("path", persistPath).Msg("Updated Unraid persistent binary")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restart agent with same arguments
|
|
args := os.Args
|
|
env := os.Environ()
|
|
|
|
if err := syscallExecFn(execPath, args, env); err != nil {
|
|
return fmt.Errorf("failed to restart agent: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|