feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-08-28 14:02:07 +02:00
1 parent a000152423
commit f02ec9977c
41 files changed
+3277 -151

No files matched your search

+302
View File
@@ -0,0 +1,302 @@
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
AgentStatus updateprotocol.Status
}
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
},
},
}
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")
}
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, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
if err := m.agent.Start(ctx, request); err != nil {
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()
m.state.AgentAvailable = err == nil
if err == nil {
m.state.AgentStatus = status
}
m.stateMu.Unlock()
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.AgentStatus = m.state.AgentStatus
m.state = state
m.stateMu.Unlock()
}
func (m *Manager) refreshAgentStatus(ctx context.Context) {
statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, _ = m.UpdateStatus(statusCtx)
}