feat: establish secure update foundation
Container-Image veröffentlichen / container (push) v1.4.0 wurde manuell veröffentlicht und verifiziert

This commit is contained in:
admin committed 2026-08-29 11:30:43 +02:00
1 parent f02ec9977c
commit bb60c06c0e
28 files changed
+1403 -280

No files matched your search

+74 -1
View File
@@ -32,7 +32,11 @@ type State struct {
CredentialConfigured bool
UpdateAvailable bool
AgentAvailable bool
AgentCompatible bool
AgentStatus updateprotocol.Status
AgentVersion string
AgentProtocol int
ComposeSyncRequired bool
}
type Manager struct {
@@ -76,6 +80,7 @@ func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket strin
},
},
}
settings = manager.reconcileInstalledVersion(context.Background(), settings)
manager.setStoredState(settings)
return manager, nil
}
@@ -192,6 +197,12 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
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
@@ -203,8 +214,11 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
return err
}
request := updateprotocol.Request{Version: version, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
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)
@@ -217,11 +231,26 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
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
}
@@ -290,11 +319,55 @@ func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
}
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()
+69 -3
View File
@@ -13,8 +13,10 @@ import (
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"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"
)
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
@@ -31,7 +33,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
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}`))
_, _ = w.Write([]byte(`{"tag_name":"v1.4.1","html_url":"https://example.invalid/releases/v1.4.1","draft":false,"prerelease":false}`))
}))
defer api.Close()
@@ -64,7 +66,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Fatal(err)
}
state := manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError != "" {
if !state.UpdateAvailable || state.LatestVersion != "v1.4.1" || state.LastError != "" {
t.Fatalf("new release was not detected: %#v", state)
}
settings, err := database.GetSoftwareUpdateSettings(ctx)
@@ -80,7 +82,71 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Fatal("unavailable Gitea endpoint was reported as successful")
}
state = manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError == "" {
if !state.UpdateAvailable || state.LatestVersion != "v1.4.1" || state.LastError == "" {
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
}
}
func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
t.Parallel()
tests := []struct {
name string
status updateprotocol.Status
want bool
}{
{name: "v1.4 baseline", status: updateprotocol.Status{AgentVersion: "v1.4.0", Protocol: 2}, want: true},
{name: "newer compatible agent version", status: updateprotocol.Status{AgentVersion: "v2.0.0", Protocol: buildinfo.UpdateProtocolVersion}, want: true},
{name: "legacy protocol", status: updateprotocol.Status{Protocol: 0}},
{name: "old agent version", status: updateprotocol.Status{AgentVersion: "v1.3.0", Protocol: 2}},
{name: "future unknown protocol", status: updateprotocol.Status{AgentVersion: "v2.0.0", Protocol: buildinfo.UpdateProtocolVersion + 1}},
{name: "missing version", status: updateprotocol.Status{Protocol: 2}},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
if got := agentCompatible(test.status); got != test.want {
t.Fatalf("agentCompatible(%#v) = %v; want %v", test.status, got, test.want)
}
})
}
}
func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
t.Parallel()
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() })
if err := database.SetDesiredSoftwareVersion(ctx, buildinfo.Version); err != nil {
t.Fatal(err)
}
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, "https://git.example/api/releases/latest", filepath.Join(t.TempDir(), "agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
if err != nil {
t.Fatal(err)
}
if manager.State().DesiredVersion != "" {
t.Fatalf("installed version remained pending: %#v", manager.State())
}
settings, err := database.GetSoftwareUpdateSettings(ctx)
if err != nil {
t.Fatal(err)
}
if settings.DesiredVersion != "" {
t.Fatalf("completed desired version remained persisted: %#v", settings)
}
}
+3 -54
View File
@@ -1,66 +1,15 @@
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
}
import "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
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
return updateprotocol.CompareVersions(left, right)
}
func validReleaseVersion(version string) bool {
if !updateprotocol.ValidVersion(version) {
return false
}
_, err := parseVersion(version)
_, err := updateprotocol.CompareVersions(version, version)
return err == nil
}
+1
View File
@@ -10,6 +10,7 @@ func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) {
want int
}{
{left: "v1.2.2", right: "v1.2.3", want: -1},
{left: "v1.3.0", right: "v2.0.0", want: -1},
{left: "v2.0.0", right: "v1.99.99", want: 1},
{left: "v1.2.2", right: "v1.2.2", want: 0},
}