feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user