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

+84
View File
@@ -0,0 +1,84 @@
package appupdate
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
type AgentClient struct {
socket string
}
func NewAgentClient(socket string) *AgentClient {
return &AgentClient{socket: socket}
}
func (c *AgentClient) Start(ctx context.Context, request updateprotocol.Request) error {
encoded, err := json.Marshal(request)
if err != nil {
return err
}
response, err := c.do(ctx, http.MethodPost, "/v1/update", bytes.NewReader(encoded))
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusAccepted {
return nil
}
if response.StatusCode == http.StatusConflict {
return errors.New("ein Software-Update läuft bereits")
}
return fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
}
func (c *AgentClient) Status(ctx context.Context) (updateprotocol.Status, error) {
response, err := c.do(ctx, http.MethodGet, "/v1/status", nil)
if err != nil {
return updateprotocol.Status{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return updateprotocol.Status{}, fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
}
var status updateprotocol.Status
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&status); err != nil {
return updateprotocol.Status{}, fmt.Errorf("Status des Update-Agents lesen: %w", err)
}
return status, nil
}
func (c *AgentClient) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
if c == nil || c.socket == "" {
return nil, errors.New("Update-Agent ist nicht konfiguriert")
}
transport := &http.Transport{
DisableKeepAlives: true,
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
dialer := net.Dialer{Timeout: time.Second}
return dialer.DialContext(ctx, "unix", c.socket)
},
}
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
request, err := http.NewRequestWithContext(ctx, method, "http://update-agent"+path, body)
if err != nil {
return nil, err
}
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := client.Do(request)
if err != nil {
return nil, errors.New("Update-Agent ist nicht erreichbar")
}
return response, nil
}
+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)
}
+86
View File
@@ -0,0 +1,86 @@
package appupdate
import (
"context"
"encoding/base64"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Parallel()
const token = "test-token-that-must-stay-secret"
var unavailable atomic.Bool
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token "+token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if unavailable.Load() {
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"tag_name":"v1.3.1","html_url":"https://example.invalid/releases/v1.3.1","draft":false,"prerelease":false}`))
}))
defer api.Close()
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
keyFile := filepath.Join(t.TempDir(), "master-key")
key := make([]byte, 32)
for index := range key {
key[index] = byte(index + 1)
}
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
t.Fatal(err)
}
cipher, err := secure.LoadCipher(keyFile)
if err != nil {
t.Fatal(err)
}
manager, err := New(database, cipher, api.URL, filepath.Join(t.TempDir(), "missing-agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
if err != nil {
t.Fatal(err)
}
if err := manager.SaveCredential(ctx, "registry-user", token); err != nil {
t.Fatal(err)
}
if err := manager.CheckNow(ctx); err != nil {
t.Fatal(err)
}
state := manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError != "" {
t.Fatalf("new release was not detected: %#v", state)
}
settings, err := database.GetSoftwareUpdateSettings(ctx)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(settings.TokenCiphertext), token) {
t.Fatal("registry token was stored as plaintext")
}
unavailable.Store(true)
if err := manager.CheckNow(ctx); err == nil {
t.Fatal("unavailable Gitea endpoint was reported as successful")
}
state = manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError == "" {
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
}
}
+66
View File
@@ -0,0 +1,66 @@
package appupdate
import (
"fmt"
"strconv"
"strings"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
type semanticVersion struct {
major int
minor int
patch int
}
func parseVersion(raw string) (semanticVersion, error) {
if !strings.HasPrefix(raw, "v") || strings.ContainsAny(raw, "+-") {
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
}
parts := strings.Split(strings.TrimPrefix(raw, "v"), ".")
if len(parts) != 3 {
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
}
values := make([]int, 3)
for index, part := range parts {
if part == "" || (len(part) > 1 && part[0] == '0') {
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
}
value, err := strconv.Atoi(part)
if err != nil || value < 0 || value > 1_000_000 {
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
}
values[index] = value
}
return semanticVersion{major: values[0], minor: values[1], patch: values[2]}, nil
}
func compareVersions(left, right string) (int, error) {
a, err := parseVersion(left)
if err != nil {
return 0, err
}
b, err := parseVersion(right)
if err != nil {
return 0, err
}
av, bv := []int{a.major, a.minor, a.patch}, []int{b.major, b.minor, b.patch}
for index := range av {
if av[index] < bv[index] {
return -1, nil
}
if av[index] > bv[index] {
return 1, nil
}
}
return 0, nil
}
func validReleaseVersion(version string) bool {
if !updateprotocol.ValidVersion(version) {
return false
}
_, err := parseVersion(version)
return err == nil
}
+27
View File
@@ -0,0 +1,27 @@
package appupdate
import "testing"
func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) {
t.Parallel()
tests := []struct {
left string
right string
want int
}{
{left: "v1.2.2", right: "v1.2.3", want: -1},
{left: "v2.0.0", right: "v1.99.99", want: 1},
{left: "v1.2.2", right: "v1.2.2", want: 0},
}
for _, test := range tests {
got, err := compareVersions(test.left, test.right)
if err != nil || got != test.want {
t.Errorf("compareVersions(%q, %q) = %d, %v; want %d", test.left, test.right, got, err, test.want)
}
}
for _, invalid := range []string{"1.2.3", "v01.2.3", "v1.2", "v1.2.3-rc.1", "v1.2.3+build"} {
if validReleaseVersion(invalid) {
t.Errorf("invalid release version accepted: %q", invalid)
}
}
}