397 lines
13 KiB
Go
397 lines
13 KiB
Go
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
|
|
CredentialUsername string
|
|
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, credential string) error {
|
|
username = strings.TrimSpace(username)
|
|
if username == "" || len(username) > 128 {
|
|
return errors.New("Gitea-Benutzername fehlt oder ist zu lang")
|
|
}
|
|
if len(credential) < 8 || len(credential) > 1024 {
|
|
return errors.New("Gitea-Passwort oder -Token fehlt oder hat eine ungültige Länge")
|
|
}
|
|
ciphertext, nonce, err := m.cipher.Encrypt(credential)
|
|
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
|
|
}
|
|
credential, 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, settings.RegistryUsername, credential)
|
|
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
|
|
}
|
|
credential, 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: credential}
|
|
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, username, credential string) (releaseResponse, error) {
|
|
response, err := m.fetchLatestWithAuth(ctx, username, credential, true)
|
|
if err != nil {
|
|
return releaseResponse{}, err
|
|
}
|
|
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
|
response.Body.Close()
|
|
response, err = m.fetchLatestWithAuth(ctx, username, credential, false)
|
|
if err != nil {
|
|
return releaseResponse{}, err
|
|
}
|
|
}
|
|
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) fetchLatestWithAuth(ctx context.Context, username, credential string, basic bool) (*http.Response, error) {
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
|
|
if err != nil {
|
|
return nil, errors.New("Update-Endpunkt ist ungültig")
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
if basic {
|
|
request.SetBasicAuth(username, credential)
|
|
} else {
|
|
request.Header.Set("Authorization", "token "+credential)
|
|
}
|
|
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
|
|
response, err := m.httpClient.Do(request)
|
|
if err != nil {
|
|
return nil, errors.New("Git-Server ist momentan nicht erreichbar")
|
|
}
|
|
return response, 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,
|
|
CredentialUsername: settings.RegistryUsername,
|
|
}
|
|
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)
|
|
}
|