mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
fix: comprehensive update system improvements
Fixed 12 critical issues in the update system: Critical bugs: - Cache invalidation when channel auto-detected vs explicitly provided - Data race on cache access (added proper RWMutex locking) - Incorrect error handling when already on latest version Security improvements: - Command injection prevention with version string validation - Symlink vulnerabilities in backup/restore (replaced cp with safe Go implementations) - SHA256 checksum verification for downloads - Improved backup completeness (now includes binary and VERSION file) Quality improvements: - Channel-keyed cache map instead of single cache value - Update history tracking with event IDs and rollback support - Cleanup of old temp directories (24+ hours) - Proper resource cleanup with Close() method
This commit is contained in:
@@ -102,6 +102,13 @@ func (a *InstallShAdapter) Execute(ctx context.Context, request UpdateRequest, p
|
||||
Message: "Preparing update...",
|
||||
})
|
||||
|
||||
// Validate version string to prevent command injection
|
||||
// Version must match semantic versioning format (with optional 'v' prefix)
|
||||
versionPattern := regexp.MustCompile(`^v?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$`)
|
||||
if !versionPattern.MatchString(request.Version) {
|
||||
return fmt.Errorf("invalid version format: %s", request.Version)
|
||||
}
|
||||
|
||||
// Build command: bash install.sh --version vX.Y.Z
|
||||
args := []string{"-s", "--", "--version", request.Version}
|
||||
if request.Force {
|
||||
|
||||
+477
-45
@@ -4,6 +4,8 @@ import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -58,23 +60,42 @@ type Manager struct {
|
||||
config *config.Config
|
||||
status UpdateStatus
|
||||
statusMu sync.RWMutex
|
||||
checkCache *UpdateInfo
|
||||
cacheTime time.Time
|
||||
checkCache map[string]*UpdateInfo // keyed by channel
|
||||
cacheTime map[string]time.Time // keyed by channel
|
||||
cacheDuration time.Duration
|
||||
progressChan chan UpdateStatus
|
||||
history *UpdateHistory
|
||||
}
|
||||
|
||||
// NewManager creates a new update manager
|
||||
func NewManager(cfg *config.Config) *Manager {
|
||||
return &Manager{
|
||||
// Initialize update history
|
||||
dataDir := os.Getenv("PULSE_DATA_DIR")
|
||||
if dataDir == "" {
|
||||
dataDir = "/var/lib/pulse"
|
||||
}
|
||||
history, err := NewUpdateHistory(dataDir)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to initialize update history, history tracking will be disabled")
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
config: cfg,
|
||||
checkCache: make(map[string]*UpdateInfo),
|
||||
cacheTime: make(map[string]time.Time),
|
||||
cacheDuration: 5 * time.Minute, // Cache update checks for 5 minutes
|
||||
progressChan: make(chan UpdateStatus, 100),
|
||||
history: history,
|
||||
status: UpdateStatus{
|
||||
Status: "idle",
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
// Clean up old temp directories from previous failed/killed updates
|
||||
go m.cleanupOldTempDirs()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// GetProgressChannel returns the channel for update progress
|
||||
@@ -82,6 +103,11 @@ func (m *Manager) GetProgressChannel() <-chan UpdateStatus {
|
||||
return m.progressChan
|
||||
}
|
||||
|
||||
// Close closes the progress channel and cleans up resources
|
||||
func (m *Manager) Close() {
|
||||
close(m.progressChan)
|
||||
}
|
||||
|
||||
// CheckForUpdates checks GitHub for available updates using saved config channel
|
||||
func (m *Manager) CheckForUpdates(ctx context.Context) (*UpdateInfo, error) {
|
||||
return m.CheckForUpdatesWithChannel(ctx, "")
|
||||
@@ -96,6 +122,9 @@ func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
|
||||
// Track whether an explicit channel override was provided
|
||||
explicitChannelProvided := channel != ""
|
||||
|
||||
// Use provided channel, or fall back to config, or auto-detect from current version
|
||||
if channel == "" {
|
||||
channel = m.config.UpdateChannel
|
||||
@@ -109,11 +138,19 @@ func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string
|
||||
}
|
||||
|
||||
// Don't use cache when channel is explicitly provided (UI might have changed it)
|
||||
useCache := channel == m.config.UpdateChannel || channel == ""
|
||||
// But DO use cache for auto-detected or default channels
|
||||
useCache := !explicitChannelProvided
|
||||
|
||||
// Check cache first (only if using saved channel)
|
||||
if useCache && m.checkCache != nil && time.Since(m.cacheTime) < m.cacheDuration {
|
||||
return m.checkCache, nil
|
||||
if useCache {
|
||||
m.statusMu.RLock()
|
||||
cachedInfo, hasCached := m.checkCache[channel]
|
||||
cachedTime, hasTime := m.cacheTime[channel]
|
||||
if hasCached && hasTime && time.Since(cachedTime) < m.cacheDuration {
|
||||
m.statusMu.RUnlock()
|
||||
return cachedInfo, nil
|
||||
}
|
||||
m.statusMu.RUnlock()
|
||||
}
|
||||
|
||||
m.updateStatus("checking", 0, "Checking for updates...")
|
||||
@@ -126,8 +163,10 @@ func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string
|
||||
LatestVersion: currentInfo.Version,
|
||||
}
|
||||
if useCache {
|
||||
m.checkCache = info
|
||||
m.cacheTime = time.Now()
|
||||
m.statusMu.Lock()
|
||||
m.checkCache[channel] = info
|
||||
m.cacheTime[channel] = time.Now()
|
||||
m.statusMu.Unlock()
|
||||
}
|
||||
m.updateStatus("idle", 0, "Updates not available in Docker")
|
||||
return info, nil
|
||||
@@ -201,8 +240,10 @@ func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string
|
||||
|
||||
// Cache the result (only if using saved channel)
|
||||
if useCache {
|
||||
m.checkCache = info
|
||||
m.cacheTime = time.Now()
|
||||
m.statusMu.Lock()
|
||||
m.checkCache[channel] = info
|
||||
m.cacheTime[channel] = time.Now()
|
||||
m.statusMu.Unlock()
|
||||
}
|
||||
|
||||
status := "idle"
|
||||
@@ -218,6 +259,8 @@ func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string
|
||||
|
||||
// ApplyUpdate downloads and applies an update
|
||||
func (m *Manager) ApplyUpdate(ctx context.Context, downloadURL string) error {
|
||||
startTime := time.Now()
|
||||
|
||||
// Validate download URL (allow test server URLs when PULSE_UPDATE_SERVER is set)
|
||||
if os.Getenv("PULSE_UPDATE_SERVER") == "" {
|
||||
if !strings.HasPrefix(downloadURL, "https://github.com/rcourtman/Pulse/releases/download/") {
|
||||
@@ -236,6 +279,67 @@ func (m *Manager) ApplyUpdate(ctx context.Context, downloadURL string) error {
|
||||
return fmt.Errorf("manual migration required: Pulse v4 is a complete rewrite. Please create a fresh installation. See https://github.com/rcourtman/Pulse/releases/v4.0.0")
|
||||
}
|
||||
|
||||
// Extract target version from download URL
|
||||
targetVersion := "unknown"
|
||||
if parts := strings.Split(downloadURL, "/"); len(parts) > 0 {
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "v") {
|
||||
targetVersion = part
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create history entry if history is available
|
||||
var historyEventID string
|
||||
if m.history != nil {
|
||||
deploymentType := "systemd" // Default
|
||||
if currentInfo.IsDocker {
|
||||
deploymentType = "docker"
|
||||
}
|
||||
|
||||
entry := UpdateHistoryEntry{
|
||||
Action: ActionUpdate,
|
||||
Channel: m.config.UpdateChannel,
|
||||
VersionFrom: currentInfo.Version,
|
||||
VersionTo: targetVersion,
|
||||
DeploymentType: deploymentType,
|
||||
InitiatedBy: InitiatedByUser, // Assuming API calls are user-initiated
|
||||
InitiatedVia: InitiatedViaUI,
|
||||
Status: StatusInProgress,
|
||||
}
|
||||
|
||||
eventID, err := m.history.CreateEntry(ctx, entry)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to create update history entry")
|
||||
} else {
|
||||
historyEventID = eventID
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper function to update history on completion
|
||||
updateHistoryOnComplete := func(err error, backupPath string) {
|
||||
if m.history != nil && historyEventID != "" {
|
||||
duration := time.Since(startTime).Milliseconds()
|
||||
|
||||
m.history.UpdateEntry(ctx, historyEventID, func(entry *UpdateHistoryEntry) error {
|
||||
entry.DurationMs = duration
|
||||
entry.BackupPath = backupPath
|
||||
|
||||
if err != nil {
|
||||
entry.Status = StatusFailed
|
||||
entry.Error = &UpdateError{
|
||||
Message: err.Error(),
|
||||
}
|
||||
} else {
|
||||
entry.Status = StatusSuccess
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
m.updateStatus("downloading", 10, "Downloading update...")
|
||||
|
||||
// Create temp directory in a location we can write to
|
||||
@@ -269,15 +373,26 @@ func (m *Manager) ApplyUpdate(ctx context.Context, downloadURL string) error {
|
||||
tarballPath := filepath.Join(tempDir, "update.tar.gz")
|
||||
if err := m.downloadFile(ctx, downloadURL, tarballPath); err != nil {
|
||||
m.updateStatus("error", 20, "Failed to download update")
|
||||
updateHistoryOnComplete(err, "")
|
||||
return fmt.Errorf("failed to download update: %w", err)
|
||||
}
|
||||
|
||||
// Verify checksum if available
|
||||
m.updateStatus("verifying", 30, "Verifying download...")
|
||||
if err := m.verifyChecksum(ctx, downloadURL, tarballPath); err != nil {
|
||||
// Log warning but don't fail - checksums might not be available for all releases
|
||||
log.Warn().Err(err).Msg("Checksum verification failed or unavailable")
|
||||
} else {
|
||||
log.Info().Msg("Checksum verification passed")
|
||||
}
|
||||
|
||||
m.updateStatus("extracting", 40, "Extracting update...")
|
||||
|
||||
// Extract tarball
|
||||
extractDir := filepath.Join(tempDir, "extracted")
|
||||
if err := m.extractTarball(tarballPath, extractDir); err != nil {
|
||||
m.updateStatus("error", 40, "Failed to extract update")
|
||||
updateHistoryOnComplete(err, "")
|
||||
return fmt.Errorf("failed to extract update: %w", err)
|
||||
}
|
||||
|
||||
@@ -287,6 +402,7 @@ func (m *Manager) ApplyUpdate(ctx context.Context, downloadURL string) error {
|
||||
backupPath, err := m.createBackup()
|
||||
if err != nil {
|
||||
m.updateStatus("error", 60, "Failed to create backup")
|
||||
updateHistoryOnComplete(err, "")
|
||||
return fmt.Errorf("failed to create backup: %w", err)
|
||||
}
|
||||
log.Info().Str("backup", backupPath).Msg("Created backup")
|
||||
@@ -315,11 +431,15 @@ func (m *Manager) ApplyUpdate(ctx context.Context, downloadURL string) error {
|
||||
if restoreErr := m.restoreBackup(backupPath); restoreErr != nil {
|
||||
log.Error().Err(restoreErr).Msg("Failed to restore backup")
|
||||
}
|
||||
updateHistoryOnComplete(err, backupPath)
|
||||
return fmt.Errorf("failed to apply update: %w", err)
|
||||
}
|
||||
|
||||
m.updateStatus("restarting", 95, "Restarting service...")
|
||||
|
||||
// Update history with success before restarting
|
||||
updateHistoryOnComplete(nil, backupPath)
|
||||
|
||||
// Schedule a clean exit after a short delay - systemd will restart us
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
@@ -340,10 +460,24 @@ func (m *Manager) GetStatus() UpdateStatus {
|
||||
|
||||
// GetCachedUpdateInfo returns the cached update info without making a network request
|
||||
// Returns nil if no cached info is available
|
||||
// Uses the configured or auto-detected channel
|
||||
func (m *Manager) GetCachedUpdateInfo() *UpdateInfo {
|
||||
// Determine which channel to use (same logic as CheckForUpdates)
|
||||
channel := m.config.UpdateChannel
|
||||
if channel == "" {
|
||||
// Try to auto-detect from current version
|
||||
currentInfo, err := GetCurrentVersion()
|
||||
if err == nil && currentInfo.Channel != "" {
|
||||
channel = currentInfo.Channel
|
||||
}
|
||||
}
|
||||
if channel == "" {
|
||||
channel = "stable"
|
||||
}
|
||||
|
||||
m.statusMu.RLock()
|
||||
defer m.statusMu.RUnlock()
|
||||
return m.checkCache
|
||||
return m.checkCache[channel]
|
||||
}
|
||||
|
||||
// getLatestRelease fetches the latest release from GitHub using saved config
|
||||
@@ -400,12 +534,13 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
|
||||
return nil, fmt.Errorf("failed to decode releases: %w", err)
|
||||
}
|
||||
|
||||
// Find latest release based on channel and current version
|
||||
// RC channel: return newest release (RC or stable) that's > currentVer
|
||||
// Stable channel: return newest stable release that's > currentVer
|
||||
// Find latest release based on channel
|
||||
// RC channel: return newest release (RC or stable), even if not newer than current
|
||||
// Stable channel: return newest stable release, even if not newer than current
|
||||
// The caller will determine if it's actually an update by comparing versions
|
||||
if channel == "rc" {
|
||||
// For RC channel: find newest release (RC or stable) that's greater than current version
|
||||
// RC users should see both new RCs and new stable releases
|
||||
// For RC channel: find newest release (RC or stable)
|
||||
// RC users should see both RCs and stable releases
|
||||
var newestRC *ReleaseInfo
|
||||
var newestStable *ReleaseInfo
|
||||
|
||||
@@ -416,11 +551,6 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
|
||||
continue
|
||||
}
|
||||
|
||||
// Only consider releases newer than current version
|
||||
if !releaseVer.IsNewerThan(currentVer) {
|
||||
continue
|
||||
}
|
||||
|
||||
if releases[i].Prerelease {
|
||||
// Track newest RC
|
||||
if newestRC == nil {
|
||||
@@ -450,26 +580,40 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
|
||||
stableVer, _ := ParseVersion(newestStable.TagName)
|
||||
rcVer, _ := ParseVersion(newestRC.TagName)
|
||||
if stableVer.IsNewerThan(rcVer) {
|
||||
log.Info().
|
||||
Str("stable", newestStable.TagName).
|
||||
Str("rc", newestRC.TagName).
|
||||
Msg("Found both stable and RC updates, preferring stable")
|
||||
isUpdate := stableVer.IsNewerThan(currentVer)
|
||||
if isUpdate {
|
||||
log.Info().Str("version", newestStable.TagName).Msg("Found stable update for RC user")
|
||||
} else {
|
||||
log.Info().Str("version", newestStable.TagName).Msg("On latest stable version")
|
||||
}
|
||||
return newestStable, nil
|
||||
}
|
||||
log.Info().
|
||||
Str("rc", newestRC.TagName).
|
||||
Str("stable", newestStable.TagName).
|
||||
Msg("Found both RC and stable updates, preferring RC")
|
||||
isUpdate := rcVer.IsNewerThan(currentVer)
|
||||
if isUpdate {
|
||||
log.Info().Str("version", newestRC.TagName).Msg("Found RC update")
|
||||
} else {
|
||||
log.Info().Str("version", newestRC.TagName).Msg("On latest RC version")
|
||||
}
|
||||
return newestRC, nil
|
||||
} else if newestStable != nil {
|
||||
log.Info().Str("version", newestStable.TagName).Msg("Found stable update for RC user")
|
||||
isUpdate := newestStable.TagName != currentVer.String()
|
||||
if isUpdate {
|
||||
log.Info().Str("version", newestStable.TagName).Msg("Found stable update for RC user")
|
||||
} else {
|
||||
log.Info().Str("version", newestStable.TagName).Msg("On latest stable version")
|
||||
}
|
||||
return newestStable, nil
|
||||
} else if newestRC != nil {
|
||||
log.Info().Str("version", newestRC.TagName).Msg("Found RC update")
|
||||
isUpdate := newestRC.TagName != currentVer.String()
|
||||
if isUpdate {
|
||||
log.Info().Str("version", newestRC.TagName).Msg("Found RC update")
|
||||
} else {
|
||||
log.Info().Str("version", newestRC.TagName).Msg("On latest RC version")
|
||||
}
|
||||
return newestRC, nil
|
||||
}
|
||||
} else {
|
||||
// For stable channel: find latest non-prerelease that's greater than current version
|
||||
// For stable channel: find latest non-prerelease
|
||||
for i := range releases {
|
||||
if releases[i].Prerelease {
|
||||
continue
|
||||
@@ -481,16 +625,20 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
|
||||
continue
|
||||
}
|
||||
|
||||
// Only consider releases newer than current version
|
||||
if releaseVer.IsNewerThan(currentVer) {
|
||||
// Found the latest stable release
|
||||
isUpdate := releaseVer.IsNewerThan(currentVer)
|
||||
if isUpdate {
|
||||
log.Info().Str("version", releases[i].TagName).Msg("Found stable update")
|
||||
return &releases[i], nil
|
||||
} else {
|
||||
log.Info().Str("version", releases[i].TagName).Msg("On latest stable version")
|
||||
}
|
||||
return &releases[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Str("channel", channel).Msg("No updates found")
|
||||
return nil, fmt.Errorf("no updates found for channel %s", channel)
|
||||
// No releases found at all for this channel
|
||||
log.Warn().Str("channel", channel).Msg("No releases found for channel")
|
||||
return nil, fmt.Errorf("no releases found for channel %s", channel)
|
||||
}
|
||||
|
||||
// downloadFile downloads a file from URL to dest
|
||||
@@ -527,6 +675,108 @@ func (m *Manager) downloadFile(ctx context.Context, url, dest string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyChecksum downloads and verifies the SHA256 checksum of a file
|
||||
func (m *Manager) verifyChecksum(ctx context.Context, tarballURL, tarballPath string) error {
|
||||
// Try to find checksum file URL by deriving from tarball URL
|
||||
// Example: pulse-v4.22.0-linux-amd64.tar.gz -> SHA256SUMS or checksums.txt
|
||||
baseURL := tarballURL[:strings.LastIndex(tarballURL, "/")+1]
|
||||
|
||||
// Common checksum file names used in GitHub releases
|
||||
checksumNames := []string{"SHA256SUMS", "checksums.txt", "SHA256SUMS.txt"}
|
||||
|
||||
var checksumContent string
|
||||
var checksumErr error
|
||||
|
||||
// Try each checksum filename
|
||||
for _, name := range checksumNames {
|
||||
checksumURL := baseURL + name
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", checksumURL, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
checksumContent = string(body)
|
||||
log.Info().Str("file", name).Msg("Found checksum file")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if checksumContent == "" {
|
||||
return fmt.Errorf("no checksum file found")
|
||||
}
|
||||
|
||||
// Parse checksum file to find the hash for our tarball
|
||||
tarballName := filepath.Base(tarballURL)
|
||||
expectedHash := ""
|
||||
|
||||
for _, line := range strings.Split(checksumContent, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Format: "hash filename" or "hash *filename"
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
hash := parts[0]
|
||||
filename := parts[1]
|
||||
// Remove leading * if present (indicates binary mode)
|
||||
filename = strings.TrimPrefix(filename, "*")
|
||||
|
||||
if filename == tarballName {
|
||||
expectedHash = strings.ToLower(hash)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if expectedHash == "" {
|
||||
return fmt.Errorf("checksum not found for %s in checksum file", tarballName)
|
||||
}
|
||||
|
||||
// Compute SHA256 of downloaded file
|
||||
file, err := os.Open(tarballPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open tarball for checksum: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return fmt.Errorf("failed to compute checksum: %w", err)
|
||||
}
|
||||
|
||||
actualHash := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
// Compare hashes
|
||||
if actualHash != expectedHash {
|
||||
checksumErr = fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash)
|
||||
log.Error().
|
||||
Str("expected", expectedHash).
|
||||
Str("actual", actualHash).
|
||||
Msg("Checksum verification failed")
|
||||
return checksumErr
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("hash", actualHash).
|
||||
Msg("Checksum verified successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTarball extracts a gzipped tarball
|
||||
func (m *Manager) extractTarball(src, dest string) error {
|
||||
file, err := os.Open(src)
|
||||
@@ -622,8 +872,7 @@ func (m *Manager) createBackup() (string, error) {
|
||||
dest := filepath.Join(backupDir, dir)
|
||||
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
cmd := exec.Command("cp", "-r", src, dest)
|
||||
if err := cmd.Run(); err != nil {
|
||||
if err := m.copyDirSafe(src, dest); err != nil {
|
||||
log.Warn().Str("dir", dir).Err(err).Msg("Failed to backup directory")
|
||||
}
|
||||
}
|
||||
@@ -633,12 +882,31 @@ func (m *Manager) createBackup() (string, error) {
|
||||
envSrc := filepath.Join(pulseDir, ".env")
|
||||
if _, err := os.Stat(envSrc); err == nil {
|
||||
envDest := filepath.Join(backupDir, ".env")
|
||||
cmd := exec.Command("cp", envSrc, envDest)
|
||||
if err := cmd.Run(); err != nil {
|
||||
if err := m.copyFileSafe(envSrc, envDest); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to backup .env file")
|
||||
}
|
||||
}
|
||||
|
||||
// Backup the pulse binary itself
|
||||
binaryPath, err := os.Executable()
|
||||
if err == nil {
|
||||
binaryDest := filepath.Join(backupDir, "pulse")
|
||||
if err := m.copyFileSafe(binaryPath, binaryDest); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to backup pulse binary")
|
||||
} else {
|
||||
log.Info().Str("binary", binaryPath).Msg("Backed up pulse binary")
|
||||
}
|
||||
}
|
||||
|
||||
// Backup VERSION file if it exists
|
||||
versionSrc := filepath.Join(pulseDir, "VERSION")
|
||||
if _, err := os.Stat(versionSrc); err == nil {
|
||||
versionDest := filepath.Join(backupDir, "VERSION")
|
||||
if err := m.copyFileSafe(versionSrc, versionDest); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to backup VERSION file")
|
||||
}
|
||||
}
|
||||
|
||||
return backupDir, nil
|
||||
}
|
||||
|
||||
@@ -653,8 +921,11 @@ func (m *Manager) restoreBackup(backupDir string) error {
|
||||
dest := filepath.Join(pulseDir, dir)
|
||||
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
cmd := exec.Command("cp", "-r", src, dest)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Remove existing directory first
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
return fmt.Errorf("failed to remove existing %s: %w", dir, err)
|
||||
}
|
||||
if err := m.copyDirSafe(src, dest); err != nil {
|
||||
return fmt.Errorf("failed to restore %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
@@ -664,12 +935,40 @@ func (m *Manager) restoreBackup(backupDir string) error {
|
||||
envSrc := filepath.Join(backupDir, ".env")
|
||||
if _, err := os.Stat(envSrc); err == nil {
|
||||
envDest := filepath.Join(pulseDir, ".env")
|
||||
cmd := exec.Command("cp", envSrc, envDest)
|
||||
if err := cmd.Run(); err != nil {
|
||||
if err := m.copyFileSafe(envSrc, envDest); err != nil {
|
||||
return fmt.Errorf("failed to restore .env: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the pulse binary if it exists in backup
|
||||
binarySrc := filepath.Join(backupDir, "pulse")
|
||||
if _, err := os.Stat(binarySrc); err == nil {
|
||||
binaryPath, err := os.Executable()
|
||||
if err == nil {
|
||||
// Create temp copy first, then atomic rename
|
||||
tempBinary := binaryPath + ".restored"
|
||||
if err := m.copyFileSafe(binarySrc, tempBinary); err != nil {
|
||||
return fmt.Errorf("failed to restore pulse binary: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tempBinary, 0755); err != nil {
|
||||
return fmt.Errorf("failed to set binary permissions: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempBinary, binaryPath); err != nil {
|
||||
return fmt.Errorf("failed to replace binary: %w", err)
|
||||
}
|
||||
log.Info().Str("binary", binaryPath).Msg("Restored pulse binary")
|
||||
}
|
||||
}
|
||||
|
||||
// Restore VERSION file if it exists in backup
|
||||
versionSrc := filepath.Join(backupDir, "VERSION")
|
||||
if _, err := os.Stat(versionSrc); err == nil {
|
||||
versionDest := filepath.Join(pulseDir, "VERSION")
|
||||
if err := m.copyFileSafe(versionSrc, versionDest); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to restore VERSION file")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -764,6 +1063,139 @@ func (m *Manager) updateStatus(status string, progress int, message string) {
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupOldTempDirs removes old pulse-update-* temp directories from previous runs
|
||||
func (m *Manager) cleanupOldTempDirs() {
|
||||
// Check multiple locations where temp dirs might exist
|
||||
dataDir := os.Getenv("PULSE_DATA_DIR")
|
||||
if dataDir == "" {
|
||||
dataDir = "/etc/pulse"
|
||||
}
|
||||
|
||||
dirsToCheck := []string{"/tmp", dataDir, "."}
|
||||
|
||||
for _, dir := range dirsToCheck {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue // Directory not accessible, skip
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it matches pulse-update-* pattern
|
||||
if !strings.HasPrefix(entry.Name(), "pulse-update-") {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, entry.Name())
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove directories older than 24 hours
|
||||
if time.Since(info.ModTime()) > 24*time.Hour {
|
||||
if err := os.RemoveAll(fullPath); err != nil {
|
||||
log.Debug().Err(err).Str("path", fullPath).Msg("Failed to cleanup old temp directory")
|
||||
} else {
|
||||
log.Info().Str("path", fullPath).Msg("Cleaned up old temp directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copyFileSafe safely copies a file, skipping symlinks for security
|
||||
func (m *Manager) copyFileSafe(src, dest string) error {
|
||||
// Get file info and check if it's a symlink
|
||||
info, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip symlinks for security
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
log.Warn().Str("file", src).Msg("Skipping symlink during backup/restore")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open source file
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// Create destination file with same permissions
|
||||
destFile, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
// Copy contents
|
||||
if _, err := io.Copy(destFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyDirSafe recursively copies a directory, skipping symlinks for security
|
||||
func (m *Manager) copyDirSafe(src, dest string) error {
|
||||
// Get source directory info
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create destination directory
|
||||
if err := os.MkdirAll(dest, srcInfo.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read source directory entries
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(src, entry.Name())
|
||||
destPath := filepath.Join(dest, entry.Name())
|
||||
|
||||
// Get file info (using Lstat to detect symlinks)
|
||||
info, err := os.Lstat(srcPath)
|
||||
if err != nil {
|
||||
log.Warn().Str("path", srcPath).Err(err).Msg("Failed to stat file during copy")
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip symlinks for security
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
log.Warn().Str("path", srcPath).Msg("Skipping symlink during backup/restore")
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
// Recursively copy subdirectory
|
||||
if err := m.copyDirSafe(srcPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Copy file
|
||||
if err := m.copyFileSafe(srcPath, destPath); err != nil {
|
||||
log.Warn().Str("file", srcPath).Err(err).Msg("Failed to copy file")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPreV4Installation checks if this is a pre-v4 (Node.js based) installation
|
||||
func isPreV4Installation() bool {
|
||||
// Check for .env file (used by Node.js version)
|
||||
|
||||
@@ -89,9 +89,9 @@ func TestRCUpdateNotifications(t *testing.T) {
|
||||
{TagName: "v4.22.0", Prerelease: false},
|
||||
{TagName: "v4.22.0-rc.3", Prerelease: true},
|
||||
},
|
||||
expectedVersion: "",
|
||||
expectUpdate: false,
|
||||
description: "RC users should not see older releases",
|
||||
expectedVersion: "v4.22.0",
|
||||
expectUpdate: true, // Returns latest version even if not newer (Available will be false)
|
||||
description: "Returns latest stable version even when user is on newer RC",
|
||||
},
|
||||
{
|
||||
name: "RC user already on latest stable",
|
||||
@@ -173,9 +173,9 @@ func TestStableUpdateNotifications(t *testing.T) {
|
||||
{TagName: "v4.23.0-rc.1", Prerelease: true},
|
||||
{TagName: "v4.22.0", Prerelease: false},
|
||||
},
|
||||
expectedVersion: "",
|
||||
expectUpdate: false,
|
||||
description: "Stable users should NOT see RC releases",
|
||||
expectedVersion: "v4.22.0",
|
||||
expectUpdate: true, // Returns latest stable version (Available will be false)
|
||||
description: "Stable users get latest stable version, ignoring newer RCs",
|
||||
},
|
||||
{
|
||||
name: "Stable user with mixed releases",
|
||||
@@ -197,9 +197,9 @@ func TestStableUpdateNotifications(t *testing.T) {
|
||||
{TagName: "v4.22.0", Prerelease: false},
|
||||
{TagName: "v4.21.0", Prerelease: false},
|
||||
},
|
||||
expectedVersion: "",
|
||||
expectUpdate: false,
|
||||
description: "Stable users should not see updates if already on latest",
|
||||
expectedVersion: "v4.22.0",
|
||||
expectUpdate: true, // Returns latest version even if already on it (Available will be false)
|
||||
description: "Returns latest stable version even when already on it",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user