package appupdate import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "sync" "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" ) var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet") type State struct { CurrentVersion string LatestVersion string ReleaseURL string DesiredVersion string LastCheckedAt time.Time LastError string CredentialConfigured bool UpdateAvailable bool AgentAvailable bool AgentCompatible bool AgentStatus updateprotocol.Status AgentVersion string AgentProtocol int ComposeSyncRequired bool } type Manager struct { store *store.Store cipher *secure.Cipher logger *slog.Logger apiURL string interval time.Duration agent *AgentClient httpClient *http.Client checkMu sync.Mutex stateMu sync.RWMutex state State checkSignal chan struct{} } type releaseResponse struct { TagName string `json:"tag_name"` HTMLURL string `json:"html_url"` Draft bool `json:"draft"` Prerelease bool `json:"prerelease"` } func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket string, interval time.Duration, logger *slog.Logger) (*Manager, error) { settings, err := database.GetSoftwareUpdateSettings(context.Background()) if err != nil { return nil, err } manager := &Manager{ store: database, cipher: cipher, logger: logger, apiURL: apiURL, interval: interval, agent: NewAgentClient(agentSocket), checkSignal: make(chan struct{}, 1), httpClient: &http.Client{ Timeout: 12 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, }, } settings = manager.reconcileInstalledVersion(context.Background(), settings) manager.setStoredState(settings) return manager, nil } func (m *Manager) Start(ctx context.Context) { go func() { m.refreshAgentStatus(ctx) initial := time.NewTimer(15 * time.Second) defer initial.Stop() var ticker *time.Ticker var tick <-chan time.Time for { select { case <-ctx.Done(): if ticker != nil { ticker.Stop() } return case <-initial.C: if ticker == nil { ticker = time.NewTicker(m.interval) tick = ticker.C } m.backgroundCheck(ctx) case <-tick: m.backgroundCheck(ctx) case <-m.checkSignal: m.backgroundCheck(ctx) } } }() } func (m *Manager) backgroundCheck(ctx context.Context) { checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() if err := m.CheckNow(checkCtx); err != nil && !errors.Is(err, ErrCredentialMissing) && !errors.Is(err, context.Canceled) { m.logger.Warn("software update check failed", "error", err) } m.refreshAgentStatus(ctx) } func (m *Manager) TriggerCheck() { select { case m.checkSignal <- struct{}{}: default: } } func (m *Manager) State() State { m.stateMu.RLock() defer m.stateMu.RUnlock() return m.state } func (m *Manager) SaveCredential(ctx context.Context, username, token string) error { username, token = strings.TrimSpace(username), strings.TrimSpace(token) if username == "" || len(username) > 128 { return errors.New("Gitea-Benutzername fehlt oder ist zu lang") } if len(token) < 8 || len(token) > 1024 { return errors.New("Gitea-Token fehlt oder hat eine ungültige Länge") } ciphertext, nonce, err := m.cipher.Encrypt(token) if err != nil { return fmt.Errorf("Update-Zugang verschlüsseln: %w", err) } if err := m.store.SaveSoftwareUpdateCredential(ctx, username, ciphertext, nonce); err != nil { return err } settings, err := m.store.GetSoftwareUpdateSettings(ctx) if err == nil { m.setStoredState(settings) } return err } func (m *Manager) CheckNow(ctx context.Context) error { m.checkMu.Lock() defer m.checkMu.Unlock() settings, err := m.store.GetSoftwareUpdateSettings(ctx) if err != nil { return err } if settings.RegistryUsername == "" || len(settings.TokenCiphertext) == 0 || len(settings.TokenNonce) == 0 { m.setStoredState(settings) return ErrCredentialMissing } token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) if err != nil { return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden")) } release, err := m.fetchLatest(ctx, token) if err != nil { return m.recordFailure(ctx, err) } checkedAt := time.Now().UTC() if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil { return err } settings, err = m.store.GetSoftwareUpdateSettings(ctx) if err == nil { m.setStoredState(settings) } return err } func (m *Manager) RequestUpdate(ctx context.Context, version string) error { version = strings.TrimSpace(version) state := m.State() if !validReleaseVersion(version) || version != state.LatestVersion { return errors.New("die ausgewählte Version ist nicht das zuletzt geprüfte Release") } if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 { return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version") } if !state.AgentAvailable { return errors.New("Update-Agent ist nicht erreichbar") } if !state.AgentCompatible { return errors.New("Web-App und Update-Agent müssen zuerst gemeinsam mit Docker Compose aktualisiert werden") } settings, err := m.store.GetSoftwareUpdateSettings(ctx) if err != nil { return err } token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) if err != nil || settings.RegistryUsername == "" { return ErrCredentialMissing } if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil { return err } request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: token} if err := m.agent.Start(ctx, request); err != nil { cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) m.clearDesiredVersion(cleanupCtx, version) cancel() return err } settings, err = m.store.GetSoftwareUpdateSettings(ctx) if err == nil { m.setStoredState(settings) } return err } func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) { status, err := m.agent.Status(ctx) m.stateMu.Lock() desiredVersion := m.state.DesiredVersion m.state.AgentAvailable = err == nil if err == nil { m.state.AgentStatus = status m.state.AgentVersion = status.AgentVersion m.state.AgentProtocol = status.Protocol m.state.AgentCompatible = agentCompatible(status) m.state.ComposeSyncRequired = !m.state.AgentCompatible } else { m.state.AgentCompatible = false m.state.ComposeSyncRequired = false } m.stateMu.Unlock() if err == nil && status.Phase == updateprotocol.PhaseFailed && status.TargetVersion != "" { m.clearDesiredVersion(ctx, status.TargetVersion) } else if err == nil && status.Phase == updateprotocol.PhaseIdle && desiredVersion != "" { // An agent restart intentionally resets its in-memory queue. Do not leave // an update marked as pending when no update can still be running. m.clearDesiredVersion(ctx, desiredVersion) } return status, err } func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseResponse, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil) if err != nil { return releaseResponse{}, errors.New("Update-Endpunkt ist ungültig") } request.Header.Set("Accept", "application/json") request.Header.Set("Authorization", "token "+token) request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version) response, err := m.httpClient.Do(request) if err != nil { return releaseResponse{}, errors.New("Git-Server ist momentan nicht erreichbar") } defer response.Body.Close() if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { return releaseResponse{}, errors.New("Gitea-Zugang wurde abgelehnt") } if response.StatusCode != http.StatusOK { return releaseResponse{}, fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode) } var release releaseResponse if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&release); err != nil { return releaseResponse{}, errors.New("Release-Antwort des Git-Servers ist ungültig") } if release.Draft || release.Prerelease || !validReleaseVersion(release.TagName) { return releaseResponse{}, errors.New("Git-Server lieferte kein gültiges stabiles Release") } release.HTMLURL = m.safeReleaseURL(release.HTMLURL) return release, nil } func (m *Manager) safeReleaseURL(raw string) string { apiURL, apiErr := url.Parse(m.apiURL) releaseURL, releaseErr := url.Parse(raw) if apiErr != nil || releaseErr != nil || releaseURL.Scheme != "https" || !strings.EqualFold(apiURL.Hostname(), releaseURL.Hostname()) { return "" } return releaseURL.String() } func (m *Manager) recordFailure(ctx context.Context, checkErr error) error { checkedAt := time.Now().UTC() if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil { return err } if settings, err := m.store.GetSoftwareUpdateSettings(ctx); err == nil { m.setStoredState(settings) } return checkErr } func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) { state := State{ CurrentVersion: buildinfo.Version, LatestVersion: settings.LatestVersion, ReleaseURL: settings.ReleaseURL, DesiredVersion: settings.DesiredVersion, LastCheckedAt: settings.LastCheckedAt, LastError: settings.LastError, CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0, } if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil { state.UpdateAvailable = comparison < 0 } m.stateMu.Lock() state.AgentAvailable = m.state.AgentAvailable state.AgentCompatible = m.state.AgentCompatible state.AgentStatus = m.state.AgentStatus state.AgentVersion = m.state.AgentVersion state.AgentProtocol = m.state.AgentProtocol state.ComposeSyncRequired = state.AgentAvailable && !state.AgentCompatible m.state = state m.stateMu.Unlock() } func (m *Manager) reconcileInstalledVersion(ctx context.Context, settings model.SoftwareUpdateSettings) model.SoftwareUpdateSettings { if settings.DesiredVersion == "" { return settings } comparison, err := compareVersions(buildinfo.Version, settings.DesiredVersion) if err != nil || comparison < 0 { return settings } if err := m.store.ClearDesiredSoftwareVersion(ctx, settings.DesiredVersion); err != nil { m.logger.Warn("clear completed software update state", "error", err) return settings } settings.DesiredVersion = "" return settings } func (m *Manager) clearDesiredVersion(ctx context.Context, expected string) { if expected == "" { return } if err := m.store.ClearDesiredSoftwareVersion(ctx, expected); err != nil { m.logger.Warn("clear stale software update state", "error", err) return } settings, err := m.store.GetSoftwareUpdateSettings(ctx) if err != nil { m.logger.Warn("refresh software update state", "error", err) return } m.setStoredState(settings) } func agentCompatible(status updateprotocol.Status) bool { if status.Protocol < buildinfo.MinimumAgentProtocol || status.Protocol > buildinfo.UpdateProtocolVersion { return false } comparison, err := compareVersions(status.AgentVersion, buildinfo.MinimumAgentVersion) return err == nil && comparison >= 0 } func (m *Manager) refreshAgentStatus(ctx context.Context) { statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() _, _ = m.UpdateStatus(statusCtx) }