45f75a916c
Bug fixes: - Add applyEnvOverrides to LoadFromBytes (M05) - Set state=failed on compose-up failure in selfupdate (M16) - Clamp usableMB to min 0 in memory check (M22) - Remove "manual" schedule from triggerAllCrossBackups (M23) - Add mmcblk device handling for partition paths (M21) - Fix stripPartition for mmcblk devices (L25) - Fix TruncateStr for UTF-8 and negative maxLen (L05/L06) - Fix AllDone to return false for empty restore plans (L14) - Fix PushOnce to return actual errors (L39) - Restore pending events on save failure in DrainPendingEvents (M03) - Add duplicate check in AddStoragePath (M04) - Call CleanupTempMounts after drive scan (H13) - Log SetStep save errors (M25) Hardening: - Guard scheduler Start() against double-start (M14) - Acquire mutex in scheduler Stop() before reading cancel (L24) - Cap log lines parameter to 10000 (L31) - Require POST for logout (L32) - Use sync.Once for Server.Close() (L49) - Panic on crypto/rand.Read failure in setup CSRF (L40) - Validate Bearer token against Hub API key in CSRF (H16 fix) - Replace custom hasPrefix with strings.HasPrefix (L13) - Replace simpleHash with crc32.ChecksumIEEE (L48) Cleanup: - Remove dead imageName function (L02) - Remove dead detectHostIPViaRoute function (L03) - Rename shadowed copy variable to cp (L07) - Copy DefaultEnabledEvents in GetNotificationPrefs early return (L09) - Update BUGHUNT.md with comprehensive audit results Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
501 lines
14 KiB
Go
501 lines
14 KiB
Go
package selfupdate
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
)
|
|
|
|
// CheckResult holds the result of a version check.
|
|
type CheckResult struct {
|
|
CurrentVersion string `json:"current_version"`
|
|
LatestVersion string `json:"latest_version"`
|
|
UpdateAvailable bool `json:"update_available"`
|
|
Error string `json:"error,omitempty"`
|
|
CheckedAt string `json:"checked_at"`
|
|
}
|
|
|
|
// UpdateStatus is the complete status returned by the API.
|
|
type UpdateStatus struct {
|
|
Running bool `json:"running"`
|
|
LastCheck *CheckResult `json:"last_check,omitempty"`
|
|
LastState *UpdateState `json:"last_state,omitempty"`
|
|
}
|
|
|
|
// Updater manages controller self-updates.
|
|
type Updater struct {
|
|
cfg *config.SelfUpdateConfig
|
|
gitCfg *config.GitConfig
|
|
currentVer string
|
|
dataDir string
|
|
composePath string // e.g., "/opt/docker/felhom-controller/docker-compose.yml"
|
|
logger *log.Logger
|
|
debug bool
|
|
|
|
mu sync.Mutex
|
|
latestVersion string
|
|
lastCheck *CheckResult
|
|
updateRunning bool
|
|
backupRunning func() bool
|
|
}
|
|
|
|
// NewUpdater creates a new Updater instance.
|
|
func NewUpdater(cfg *config.SelfUpdateConfig, gitCfg *config.GitConfig, currentVersion, dataDir, composePath string, logger *log.Logger, debug bool) *Updater {
|
|
return &Updater{
|
|
cfg: cfg,
|
|
gitCfg: gitCfg,
|
|
currentVer: currentVersion,
|
|
dataDir: dataDir,
|
|
composePath: composePath,
|
|
logger: logger,
|
|
debug: debug,
|
|
}
|
|
}
|
|
|
|
// SetBackupRunningCheck sets the callback to check if a backup is in progress.
|
|
func (u *Updater) SetBackupRunningCheck(fn func() bool) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
u.backupRunning = fn
|
|
}
|
|
|
|
// IsUpdateRunning returns true if an update is currently in progress.
|
|
func (u *Updater) IsUpdateRunning() bool {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
return u.updateRunning
|
|
}
|
|
|
|
// GetStatus returns the current update status for API/UI.
|
|
func (u *Updater) GetStatus() UpdateStatus {
|
|
u.mu.Lock()
|
|
lastCheck := u.lastCheck
|
|
running := u.updateRunning
|
|
u.mu.Unlock()
|
|
|
|
state, err := LoadState(u.dataDir)
|
|
if err != nil {
|
|
u.logger.Printf("[WARN] Failed to load update state: %v", err)
|
|
}
|
|
|
|
return UpdateStatus{
|
|
Running: running,
|
|
LastCheck: lastCheck,
|
|
LastState: state,
|
|
}
|
|
}
|
|
|
|
// CheckForUpdate queries the Gitea registry for the latest version tag.
|
|
// Caches the result. Thread-safe.
|
|
func (u *Updater) CheckForUpdate() CheckResult {
|
|
result := CheckResult{
|
|
CurrentVersion: u.currentVer,
|
|
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
// Dev version can't check for updates
|
|
currentVer, err := ParseVersion(u.currentVer)
|
|
if err != nil {
|
|
result.Error = "Dev verzió nem ellenőrizhető"
|
|
u.mu.Lock()
|
|
u.lastCheck = &result
|
|
u.mu.Unlock()
|
|
return result
|
|
}
|
|
|
|
// Query registry
|
|
latestStr, err := u.queryRegistry()
|
|
if err != nil {
|
|
result.Error = fmt.Sprintf("Registry lekérdezés sikertelen: %v", err)
|
|
u.logger.Printf("[WARN] Registry check failed: %v", err)
|
|
u.mu.Lock()
|
|
u.lastCheck = &result
|
|
u.mu.Unlock()
|
|
return result
|
|
}
|
|
|
|
result.LatestVersion = latestStr
|
|
|
|
latestVer, err := ParseVersion(latestStr)
|
|
if err != nil {
|
|
result.Error = fmt.Sprintf("Érvénytelen verzió a registry-ben: %s", latestStr)
|
|
u.mu.Lock()
|
|
u.lastCheck = &result
|
|
u.mu.Unlock()
|
|
return result
|
|
}
|
|
|
|
cmp := latestVer.Compare(currentVer)
|
|
if cmp > 0 {
|
|
result.UpdateAvailable = true
|
|
}
|
|
|
|
if u.debug {
|
|
u.logger.Printf("[DEBUG] [SELFUPDATE] Version comparison: current=%s, latest=%s, cmp=%d, updateAvailable=%v",
|
|
u.currentVer, latestStr, cmp, result.UpdateAvailable)
|
|
}
|
|
|
|
u.mu.Lock()
|
|
u.latestVersion = latestStr
|
|
u.lastCheck = &result
|
|
u.mu.Unlock()
|
|
|
|
return result
|
|
}
|
|
|
|
// queryRegistry queries the Gitea Docker Registry V2 API for available tags.
|
|
// Returns the highest valid semver tag found.
|
|
func (u *Updater) queryRegistry() (string, error) {
|
|
if u.gitCfg.Username == "" || u.gitCfg.Token == "" {
|
|
return "", fmt.Errorf("registry hitelesítő adatok hiányoznak")
|
|
}
|
|
|
|
// Gitea registry V2: GET /v2/<owner>/<repo>/tags/list
|
|
url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image))
|
|
|
|
if u.debug {
|
|
u.logger.Printf("[DEBUG] [SELFUPDATE] Registry API URL: %s (user: %s)", url, u.gitCfg.Username)
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.SetBasicAuth(u.gitCfg.Username, u.gitCfg.Token)
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("HTTP request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 401 {
|
|
return "", fmt.Errorf("authentication failed (401)")
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
|
}
|
|
|
|
var tagsResp struct {
|
|
Name string `json:"name"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil {
|
|
return "", fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
if u.debug {
|
|
u.logger.Printf("[DEBUG] [SELFUPDATE] Registry returned %d tags: %v", len(tagsResp.Tags), tagsResp.Tags)
|
|
}
|
|
|
|
// Find highest semver tag
|
|
var highest *Version
|
|
for _, tag := range tagsResp.Tags {
|
|
v, err := ParseVersion(tag)
|
|
if err != nil {
|
|
continue // skip non-semver tags ("latest", "dev", etc.)
|
|
}
|
|
if highest == nil || v.Compare(*highest) > 0 {
|
|
highest = &v
|
|
}
|
|
}
|
|
|
|
if highest == nil {
|
|
return "", fmt.Errorf("no valid semver tags found")
|
|
}
|
|
|
|
return highest.String(), nil
|
|
}
|
|
|
|
// registryImagePath extracts the "owner/repo" from a full image reference.
|
|
// e.g., "gitea.dooplex.hu/admin/felhom-controller" → "admin/felhom-controller"
|
|
func registryImagePath(image string) string {
|
|
// Remove registry host
|
|
parts := strings.SplitN(image, "/", 2)
|
|
if len(parts) == 2 {
|
|
return parts[1]
|
|
}
|
|
return image
|
|
}
|
|
|
|
// DryRunResult holds the result of a self-update dry run.
|
|
type DryRunResult struct {
|
|
CurrentVersion string `json:"current_version"`
|
|
LatestVersion string `json:"latest_version"`
|
|
UpdateAvailable bool `json:"update_available"`
|
|
ComposeWritable bool `json:"compose_writable"`
|
|
CurrentImageLine string `json:"current_image_line"`
|
|
NewImageLine string `json:"new_image_line"`
|
|
BackupRunning bool `json:"backup_running"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// DryRun checks for updates and reports what would happen without performing any changes.
|
|
func (u *Updater) DryRun() *DryRunResult {
|
|
result := &DryRunResult{
|
|
CurrentVersion: u.currentVer,
|
|
}
|
|
|
|
// Check for update
|
|
check := u.CheckForUpdate()
|
|
result.LatestVersion = check.LatestVersion
|
|
result.UpdateAvailable = check.UpdateAvailable
|
|
if check.Error != "" {
|
|
result.Error = check.Error
|
|
return result
|
|
}
|
|
|
|
// Check compose file
|
|
data, err := os.ReadFile(u.composePath)
|
|
if err != nil {
|
|
result.Error = fmt.Sprintf("Compose fájl nem olvasható: %v", err)
|
|
return result
|
|
}
|
|
|
|
// Find current image line
|
|
re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`)
|
|
match := re.Find(data)
|
|
if match != nil {
|
|
result.CurrentImageLine = string(match)
|
|
}
|
|
|
|
// Build new image line
|
|
if check.UpdateAvailable {
|
|
result.NewImageLine = fmt.Sprintf("image: %s:%s", u.cfg.Image, check.LatestVersion)
|
|
}
|
|
|
|
// Check writability
|
|
f, err := os.OpenFile(u.composePath, os.O_WRONLY, 0)
|
|
if err == nil {
|
|
f.Close()
|
|
result.ComposeWritable = true
|
|
}
|
|
|
|
// Check backup running
|
|
if u.backupRunning != nil {
|
|
result.BackupRunning = u.backupRunning()
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// TriggerUpdate starts the self-update process. Returns error immediately if
|
|
// preconditions fail. The actual update runs in a goroutine.
|
|
func (u *Updater) TriggerUpdate(initiatedBy string) error {
|
|
u.mu.Lock()
|
|
if u.updateRunning {
|
|
u.mu.Unlock()
|
|
return fmt.Errorf("Frissítés már folyamatban")
|
|
}
|
|
|
|
// Dev version check
|
|
if _, err := ParseVersion(u.currentVer); err != nil {
|
|
u.mu.Unlock()
|
|
return fmt.Errorf("Dev verzió nem frissíthető")
|
|
}
|
|
|
|
// Backup running check
|
|
if u.backupRunning != nil && u.backupRunning() {
|
|
u.mu.Unlock()
|
|
return fmt.Errorf("Mentés fut, próbálja később")
|
|
}
|
|
|
|
// Compose file accessible check
|
|
if _, err := os.Stat(u.composePath); err != nil {
|
|
u.mu.Unlock()
|
|
return fmt.Errorf("docker-compose.yml nem elérhető: %w", err)
|
|
}
|
|
|
|
u.updateRunning = true
|
|
u.mu.Unlock()
|
|
|
|
// Check for update (or use cached)
|
|
result := u.CheckForUpdate()
|
|
if !result.UpdateAvailable {
|
|
u.mu.Lock()
|
|
u.updateRunning = false
|
|
u.mu.Unlock()
|
|
return fmt.Errorf("Nincs elérhető frissítés")
|
|
}
|
|
|
|
targetVersion := result.LatestVersion
|
|
targetImage := fmt.Sprintf("%s:%s", u.cfg.Image, targetVersion)
|
|
previousImage := fmt.Sprintf("%s:%s", u.cfg.Image, u.currentVer)
|
|
|
|
u.logger.Printf("[INFO] Starting self-update: %s → %s (initiated by: %s)", u.currentVer, targetVersion, initiatedBy)
|
|
|
|
go u.performUpdate(targetVersion, targetImage, previousImage, initiatedBy)
|
|
|
|
return nil
|
|
}
|
|
|
|
// performUpdate runs the actual update steps in a goroutine.
|
|
func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initiatedBy string) {
|
|
defer func() {
|
|
u.mu.Lock()
|
|
u.updateRunning = false
|
|
u.mu.Unlock()
|
|
}()
|
|
|
|
// 1. Write pending state
|
|
state := &UpdateState{
|
|
Status: "pending",
|
|
PreviousVersion: u.currentVer,
|
|
PreviousImage: previousImage,
|
|
TargetVersion: targetVersion,
|
|
TargetImage: targetImage,
|
|
InitiatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
InitiatedBy: initiatedBy,
|
|
}
|
|
if err := SaveState(u.dataDir, state); err != nil {
|
|
u.logger.Printf("[ERROR] Failed to save update state: %v", err)
|
|
return
|
|
}
|
|
|
|
// 2. Docker pull
|
|
u.logger.Printf("[INFO] Pulling image: %s", targetImage)
|
|
pullOut, pullErr := runCommand("docker", "pull", targetImage)
|
|
if pullErr != nil {
|
|
state.Status = "failed"
|
|
state.Error = fmt.Sprintf("docker pull failed: %v — %s", pullErr, pullOut)
|
|
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
|
SaveState(u.dataDir, state)
|
|
u.logger.Printf("[ERROR] Docker pull failed: %v — %s", pullErr, pullOut)
|
|
return
|
|
}
|
|
u.logger.Printf("[INFO] Image pulled successfully: %s", targetImage)
|
|
|
|
// 3. Update compose file (replace image tag)
|
|
if err := u.updateComposeFile(targetImage); err != nil {
|
|
state.Status = "failed"
|
|
state.Error = fmt.Sprintf("compose update failed: %v", err)
|
|
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
|
SaveState(u.dataDir, state)
|
|
u.logger.Printf("[ERROR] Compose file update failed: %v", err)
|
|
return
|
|
}
|
|
u.logger.Printf("[INFO] Compose file updated with new image: %s", targetImage)
|
|
|
|
// 4. Docker compose up -d (this kills the current container)
|
|
u.logger.Printf("[INFO] Running docker compose up -d — container will restart")
|
|
composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml")
|
|
upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d")
|
|
if upErr != nil {
|
|
state.Status = "failed"
|
|
state.Error = fmt.Sprintf("docker compose up -d failed: %v — %s", upErr, upOut)
|
|
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
|
SaveState(u.dataDir, state)
|
|
u.logger.Printf("[ERROR] docker compose up -d failed: %v — %s (dir: %s)", upErr, upOut, composeDir)
|
|
return
|
|
}
|
|
|
|
// If we're still alive after compose up -d, log it.
|
|
// Normally this process should be killed when Docker replaces the container.
|
|
u.logger.Printf("[WARN] Still running after docker compose up -d — expected to be replaced")
|
|
time.Sleep(30 * time.Second)
|
|
u.logger.Printf("[WARN] Still alive 30s after docker compose up -d")
|
|
}
|
|
|
|
// updateComposeFile reads the compose file, replaces the image tag, and writes it back atomically.
|
|
func (u *Updater) updateComposeFile(newImage string) error {
|
|
data, err := os.ReadFile(u.composePath)
|
|
if err != nil {
|
|
return fmt.Errorf("reading compose file: %w", err)
|
|
}
|
|
|
|
// Replace image line: "image: gitea.dooplex.hu/admin/felhom-controller:..." → new image
|
|
re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`)
|
|
|
|
if u.debug {
|
|
// Log old image line for debugging
|
|
oldMatch := re.Find(data)
|
|
if oldMatch != nil {
|
|
u.logger.Printf("[DEBUG] [SELFUPDATE] Compose file edit: %q → %q", string(oldMatch), "image: "+newImage)
|
|
}
|
|
}
|
|
|
|
newData := re.ReplaceAll(data, []byte("${1}"+newImage))
|
|
|
|
if bytes.Equal(data, newData) {
|
|
return fmt.Errorf("no image line found to replace in compose file")
|
|
}
|
|
|
|
// Atomic write: write to .tmp, then rename
|
|
tmpPath := u.composePath + ".tmp"
|
|
if err := os.WriteFile(tmpPath, newData, 0644); err != nil {
|
|
return fmt.Errorf("writing temp compose file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, u.composePath); err != nil {
|
|
return fmt.Errorf("renaming compose file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifyStartup checks the update state file on startup.
|
|
// Called once from main.go before the scheduler starts.
|
|
// Returns the state if a pending update was detected, nil otherwise.
|
|
func (u *Updater) VerifyStartup() *UpdateState {
|
|
state, err := LoadState(u.dataDir)
|
|
if err != nil {
|
|
u.logger.Printf("[WARN] Failed to load update state on startup: %v — clearing", err)
|
|
ClearState(u.dataDir, u.logger)
|
|
return nil
|
|
}
|
|
if state == nil || state.Status != "pending" {
|
|
return nil
|
|
}
|
|
|
|
// Compare current version with target
|
|
currentVer, curErr := ParseVersion(u.currentVer)
|
|
targetVer, tgtErr := ParseVersion(state.TargetVersion)
|
|
|
|
if curErr != nil || tgtErr != nil {
|
|
state.Status = "failed"
|
|
state.Error = "Version parse error on startup verification"
|
|
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
|
SaveState(u.dataDir, state)
|
|
u.logger.Printf("[WARN] Post-update startup: version parse error (current=%s, target=%s)", u.currentVer, state.TargetVersion)
|
|
return state
|
|
}
|
|
|
|
if currentVer.Compare(targetVer) == 0 {
|
|
// Success — we're running the target version
|
|
state.Status = "success"
|
|
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
|
SaveState(u.dataDir, state)
|
|
u.logger.Printf("[INFO] Post-update startup: update successful (%s → %s)", state.PreviousVersion, state.TargetVersion)
|
|
} else {
|
|
// Version mismatch — update may have failed
|
|
state.Status = "failed"
|
|
state.Error = fmt.Sprintf("Version mismatch: expected %s, running %s", state.TargetVersion, u.currentVer)
|
|
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
|
SaveState(u.dataDir, state)
|
|
u.logger.Printf("[WARN] Post-update startup: version mismatch (expected %s, running %s)", state.TargetVersion, u.currentVer)
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
// runCommand executes a command and returns combined stdout+stderr and error.
|
|
func runCommand(name string, args ...string) (string, error) {
|
|
cmd := exec.Command(name, args...)
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &out
|
|
err := cmd.Run()
|
|
return out.String(), err
|
|
}
|
|
|