Files
deploy-felhom-compose/controller/internal/selfupdate/updater.go
T
admin 45f75a916c fix: P2+P3 bug fixes, hardening, and cleanup (18 files)
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>
2026-02-25 13:47:52 +01:00

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
}