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},
}
+17 -1
View File
@@ -1,5 +1,21 @@
package buildinfo
const (
// UpdateProtocolVersion is returned by the privileged update agent. Version
// 2 requires the running version so the agent can verify it independently.
UpdateProtocolVersion = 2
// The contracts below are deliberately stable across normal releases. A
// breaking container or database change must use a new contract and cannot
// be installed through the unattended update path.
RuntimeContractVersion = 2
DatabaseContractVersion = 1
CurrentSchemaVersion = 11
MinimumUpdateVersion = "v1.4.0"
MinimumAgentVersion = "v1.4.0"
MinimumAgentProtocol = 2
)
// Version is replaced through -ldflags for release images. The source fallback
// keeps local builds and tests honest about the currently checked-out release.
var Version = "v1.3.0"
var Version = "v1.4.0"
+50
View File
@@ -0,0 +1,50 @@
package buildinfo
import (
"os"
"strconv"
"strings"
"testing"
)
func TestDockerfileMetadataMatchesRuntimeContracts(t *testing.T) {
t.Parallel()
content, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatal(err)
}
dockerfile := string(content)
wanted := []string{
"ARG VERSION=" + Version,
"ARG MINIMUM_UPDATE_VERSION=" + MinimumUpdateVersion,
"ARG MINIMUM_AGENT_PROTOCOL=" + strconv.Itoa(MinimumAgentProtocol),
"ARG RUNTIME_CONTRACT_VERSION=" + strconv.Itoa(RuntimeContractVersion),
"ARG DATABASE_CONTRACT_VERSION=" + strconv.Itoa(DatabaseContractVersion),
"ARG SCHEMA_VERSION=" + strconv.Itoa(CurrentSchemaVersion),
}
for _, line := range wanted {
if !strings.Contains(dockerfile, line+"\n") {
t.Errorf("Dockerfile is missing %q", line)
}
}
versionLinkerFlag := "-X github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo.Version=${VERSION}"
if count := strings.Count(dockerfile, versionLinkerFlag); count != 1 {
t.Fatalf("release version must be linked into the control binary, found %d flags", count)
}
if !strings.Contains(dockerfile, "ln -s domain-control /usr/local/bin/update-agent") {
t.Fatal("Dockerfile is missing the v1.3 Compose compatibility symlink")
}
for path, marker := range map[string]string{
"../../compose.yml": "domain-control-webinterface-prod:" + Version,
"../../README.md": "domain-control-webinterface-prod:" + Version,
"../../UPDATE.md": "Compose-Version ist `" + Version + "`",
} {
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
if !strings.Contains(string(data), marker) {
t.Errorf("%s is not synchronized with %s", path, Version)
}
}
}
+21 -3
View File
@@ -37,6 +37,16 @@ type Cipher struct {
}
func LoadCipher(path string) (*Cipher, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, fmt.Errorf("inspect master key: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("master key must be a regular file")
}
if err := os.Chmod(path, 0o600); err != nil {
return nil, fmt.Errorf("secure master key: %w", err)
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read master key: %w", err)
@@ -47,6 +57,17 @@ func LoadCipher(path string) (*Cipher, error) {
// LoadOrCreateCipher loads an existing master key or creates one on first start.
// The key must live on a persistent volume separate from the application data.
func LoadOrCreateCipher(path string) (*Cipher, error) {
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, fmt.Errorf("create master key directory: %w", err)
}
info, err := os.Lstat(directory)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("master key directory must be a regular directory")
}
if err := os.Chmod(directory, 0o700); err != nil {
return nil, fmt.Errorf("secure master key directory: %w", err)
}
cipher, err := LoadCipher(path)
if err == nil {
return cipher, nil
@@ -55,9 +76,6 @@ func LoadOrCreateCipher(path string) (*Cipher, error) {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create master key directory: %w", err)
}
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("create master key: %w", err)
+24
View File
@@ -56,6 +56,13 @@ func TestLoadOrCreateCipherPersistsSecureKey(t *testing.T) {
if permissions := info.Mode().Perm(); permissions != 0o600 {
t.Fatalf("master key permissions = %o, want 600", permissions)
}
directoryInfo, err := os.Stat(filepath.Dir(path))
if err != nil {
t.Fatal(err)
}
if permissions := directoryInfo.Mode().Perm(); permissions != 0o700 {
t.Fatalf("master key directory permissions = %o, want 700", permissions)
}
ciphertext, nonce, err := first.Encrypt("persistent token")
if err != nil {
t.Fatal(err)
@@ -70,6 +77,23 @@ func TestLoadOrCreateCipherPersistsSecureKey(t *testing.T) {
}
}
func TestLoadCipherRejectsSymlinkedMasterKey(t *testing.T) {
t.Parallel()
directory := t.TempDir()
target := filepath.Join(directory, "target")
key := make([]byte, 32)
if err := os.WriteFile(target, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(directory, "master_key")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err := LoadCipher(link); err == nil {
t.Fatal("symlinked master key was accepted")
}
}
func TestPasswordHash(t *testing.T) {
t.Parallel()
hash, err := HashPassword("a secure password")
+13
View File
@@ -13,6 +13,7 @@ import (
_ "github.com/mattn/go-sqlite3"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
)
@@ -128,6 +129,13 @@ func (s *Store) migrate(ctx context.Context) error {
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
return err
}
var appliedMigrations int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version BETWEEN 1 AND ?`, buildinfo.CurrentSchemaVersion).Scan(&appliedMigrations); err != nil {
return err
}
if appliedMigrations != buildinfo.CurrentSchemaVersion {
return fmt.Errorf("database migration chain is incomplete: have %d of %d", appliedMigrations, buildinfo.CurrentSchemaVersion)
}
return tx.Commit()
}
@@ -861,6 +869,11 @@ func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) e
return err
}
func (s *Store) ClearDesiredSoftwareVersion(ctx context.Context, expected string) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = '', updated_at = ? WHERE id = 1 AND desired_version = ?`, now(), expected)
return err
}
func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error {
_, err := s.db.ExecContext(ctx, `UPDATE settings SET
last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4),
+58
View File
@@ -2,16 +2,60 @@ package store
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"testing"
"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"
)
func TestMigrateAppliesEveryIntermediateSchemaFromV1(t *testing.T) {
t.Parallel()
ctx := context.Background()
databasePath := filepath.Join(t.TempDir(), "legacy.db")
legacy, err := sql.Open("sqlite3", databasePath)
if err != nil {
t.Fatal(err)
}
if _, err := legacy.ExecContext(ctx, `CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil {
t.Fatal(err)
}
if _, err := legacy.ExecContext(ctx, schemaV1); err != nil {
t.Fatal(err)
}
if _, err := legacy.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(1, 1)`); err != nil {
t.Fatal(err)
}
if _, err := legacy.ExecContext(ctx, `INSERT INTO admins(id, password_hash, created_at, updated_at) VALUES(1, 'legacy-password-hash', 1, 1)`); err != nil {
t.Fatal(err)
}
if err := legacy.Close(); err != nil {
t.Fatal(err)
}
database, err := Open(ctx, databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
var applied int
if err := database.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version BETWEEN 1 AND ?`, buildinfo.CurrentSchemaVersion).Scan(&applied); err != nil {
t.Fatal(err)
}
if applied != buildinfo.CurrentSchemaVersion {
t.Fatalf("direct migration applied %d of %d schemas", applied, buildinfo.CurrentSchemaVersion)
}
admin, _, err := database.GetUserByUsername(ctx, "admin")
if err != nil || admin.Role != "admin" || !admin.Active {
t.Fatalf("legacy administrator was not retained: %#v, %v", admin, err)
}
}
func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -280,6 +324,20 @@ func TestSoftwareUpdateStateRetainsKnownReleaseOnCheckFailure(t *testing.T) {
if settings.RegistryUsername != "registry-user" || string(settings.TokenCiphertext) != "encrypted-token" || settings.LatestVersion != "v1.2.3" || settings.DesiredVersion != "v1.2.3" || settings.LastError != "temporarily unavailable" {
t.Fatalf("software update state was not persisted safely: %#v", settings)
}
if err := database.ClearDesiredSoftwareVersion(ctx, "v9.9.9"); err != nil {
t.Fatal(err)
}
settings, err = database.GetSoftwareUpdateSettings(ctx)
if err != nil || settings.DesiredVersion != "v1.2.3" {
t.Fatalf("unrelated update cleared the pending target: %#v, %v", settings, err)
}
if err := database.ClearDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil {
t.Fatal(err)
}
settings, err = database.GetSoftwareUpdateSettings(ctx)
if err != nil || settings.DesiredVersion != "" {
t.Fatalf("matching completed target remained pending: %#v, %v", settings, err)
}
}
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
+14 -3
View File
@@ -11,6 +11,7 @@ import (
"sync"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
@@ -26,7 +27,16 @@ type Controller struct {
}
func NewController(runner Runner, logger *slog.Logger) *Controller {
return &Controller{runner: runner, logger: logger, status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, UpdatedAt: time.Now().UTC()}}
return &Controller{runner: runner, logger: logger, status: agentStatus(updateprotocol.PhaseIdle)}
}
func agentStatus(phase string) updateprotocol.Status {
return updateprotocol.Status{
Phase: phase,
UpdatedAt: time.Now().UTC(),
AgentVersion: buildinfo.Version,
Protocol: buildinfo.UpdateProtocolVersion,
}
}
func (c *Controller) Handler() http.Handler {
@@ -57,9 +67,10 @@ func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
return
}
request.Version = strings.TrimSpace(request.Version)
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
request.RegistryToken = strings.TrimSpace(request.RegistryToken)
if !updateprotocol.ValidVersion(request.Version) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
}
@@ -80,7 +91,7 @@ func (c *Controller) begin(version string) bool {
return false
}
now := time.Now().UTC()
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now}
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now, AgentVersion: buildinfo.Version, Protocol: buildinfo.UpdateProtocolVersion}
return true
}
+13 -3
View File
@@ -56,9 +56,19 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
if trailing.Code != http.StatusBadRequest {
t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code)
}
invalidCurrent := httptest.NewRecorder()
handler.ServeHTTP(invalidCurrent, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","current_version":"1.3","registry_username":"user","registry_token":"secret-token"}`)))
if invalidCurrent.Code != http.StatusBadRequest {
t.Fatalf("invalid current version accepted: status=%d", invalidCurrent.Code)
}
missingCurrent := httptest.NewRecorder()
handler.ServeHTTP(missingCurrent, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","registry_username":"user","registry_token":"secret-token"}`)))
if missingCurrent.Code != http.StatusBadRequest {
t.Fatalf("request without current version accepted: status=%d", missingCurrent.Code)
}
const secret = "registry-token-must-not-leak"
body := []byte(`{"version":"v1.2.3","registry_username":"user","registry_token":"` + secret + `"}`)
body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`)
accepted := httptest.NewRecorder()
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
if accepted.Code != http.StatusAccepted {
@@ -66,7 +76,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
}
select {
case request := <-runner.started:
if request.Version != "v1.2.3" || request.RegistryToken != secret {
if request.Version != "v2.0.0" || request.CurrentVersion != "v1.4.0" || request.RegistryToken != secret {
t.Fatalf("runner received an altered request: %#v", request)
}
case <-time.After(time.Second):
@@ -80,7 +90,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
}
status := httptest.NewRecorder()
handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) {
if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) || !strings.Contains(status.Body.String(), `"protocol_version":2`) {
t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String())
}
+442 -17
View File
@@ -13,14 +13,30 @@ import (
"net"
"net/http"
"net/url"
"reflect"
"strconv"
"strings"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
const defaultImageRepository = "git.j-mahler.de/admin/domain-control-webinterface-prod"
const (
labelImageVersion = "org.opencontainers.image.version"
labelMinimumVersion = "de.j-mahler.domain-control.minimum-update-version"
labelMinimumAgent = "de.j-mahler.domain-control.minimum-agent-protocol"
labelRuntimeContract = "de.j-mahler.domain-control.runtime-contract"
labelDatabaseContract = "de.j-mahler.domain-control.database-contract"
labelSchemaVersion = "de.j-mahler.domain-control.schema-version"
runtimeEntrypoint = "/usr/local/bin/domain-control"
runtimeCommand = "serve"
runtimeUser = "dyndns:dyndns"
runtimeWorkingDirectory = "/data"
)
type DockerRunner struct {
docker *dockerClient
imageRepository string
@@ -51,10 +67,6 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
if err := r.docker.negotiate(ctx); err != nil {
return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
}
progress(updateprotocol.PhasePulling, "Container-Image wird geladen")
if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil {
return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err}
}
target, err := r.docker.findTarget(ctx, r.targetLabel)
if err != nil {
return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err}
@@ -63,11 +75,42 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
if err != nil {
return err
}
if inspected.State.Health == nil || inspected.State.Health.Status != "healthy" {
return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")}
}
currentVersion, err := resolveCurrentVersion(request.CurrentVersion, inspected.Config, r.imageRepository)
if err != nil {
return &updateError{message: "Die laufende Version konnte nicht sicher bestimmt werden; bitte einmal mit Docker Compose aktualisieren", err: err}
}
if comparison, compareErr := updateprotocol.CompareVersions(currentVersion, request.Version); compareErr != nil || comparison >= 0 {
return &updateError{message: "Die Zielversion ist nicht neuer als die laufende Version", err: fmt.Errorf("invalid version transition %s to %s", currentVersion, request.Version)}
}
currentImage, err := r.docker.inspectImage(ctx, inspected.Image)
if err != nil {
return &updateError{message: "Die laufende Container-Basis konnte nicht geprüft werden", err: err}
}
targetReference := r.imageRepository + ":" + request.Version
progress(updateprotocol.PhasePulling, "Container-Image wird geladen und geprüft")
if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil {
return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err}
}
targetImage, err := r.docker.inspectImage(ctx, targetReference)
if err != nil {
return &updateError{message: "Das geladene Image konnte nicht geprüft werden; der Dienst läuft unverändert weiter", err: err}
}
if err := validateUpdateCompatibility(currentVersion, request.Version, currentImage, targetImage); err != nil {
return &updateError{message: "Dieses Release unterstützt kein sicheres Direktupdate von der laufenden Version; bitte Docker Compose verwenden", err: err}
}
replacementConfig, err := buildReplacementConfig(inspected, currentImage.Config, targetImage.Config, targetReference)
if err != nil {
return &updateError{message: "Die neue Container-Konfiguration konnte nicht sicher vorbereitet werden", err: err}
}
originalName := strings.TrimPrefix(inspected.Name, "/")
if originalName == "" {
return errors.New("target container has no name")
}
backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().Unix())
backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().UnixNano())
var oldStopped, oldRenamed bool
var newID string
defer func() {
@@ -76,7 +119,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, oldStopped, oldRenamed, newID); rollbackErr != nil {
if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, inspected.Image, oldStopped, oldRenamed, newID); rollbackErr != nil {
r.logger.Error("software update rollback failed", "error", rollbackErr)
resultErr = &updateError{message: "Update und automatische Wiederherstellung sind fehlgeschlagen; Containerstatus am Host prüfen", err: fmt.Errorf("%w; rollback: %v", resultErr, rollbackErr)}
}
@@ -91,7 +134,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
return err
}
oldRenamed = true
newID, err = r.docker.createReplacement(ctx, inspected, originalName, r.imageRepository+":"+request.Version)
newID, err = r.docker.createReplacement(ctx, inspected, originalName, replacementConfig)
if err != nil {
return err
}
@@ -99,7 +142,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
return err
}
progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft")
if err := r.docker.waitHealthy(ctx, newID, 100*time.Second); err != nil {
if err := r.docker.waitHealthy(ctx, newID, targetImage.ID, request.Version, 100*time.Second); err != nil {
return err
}
if err := r.docker.removeContainer(ctx, target.ID, false); err != nil {
@@ -109,7 +152,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
return nil
}
func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string, oldStopped, oldRenamed bool, newID string) error {
func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName, oldImageID string, oldStopped, oldRenamed bool, newID string) error {
var failures []string
if newID != "" {
if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) {
@@ -127,6 +170,8 @@ func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string,
if oldStopped {
if err := r.docker.startContainer(ctx, oldID); err != nil && !isDockerNotModified(err) {
failures = append(failures, "restart previous container: "+err.Error())
} else if err := r.docker.waitHealthy(ctx, oldID, oldImageID, "", 60*time.Second); err != nil {
failures = append(failures, "verify previous container: "+err.Error())
}
}
if len(failures) > 0 {
@@ -149,6 +194,7 @@ type containerSummary struct {
type containerInspect struct {
ID string `json:"Id"`
Image string `json:"Image"`
Name string `json:"Name"`
Config json.RawMessage `json:"Config"`
HostConfig json.RawMessage `json:"HostConfig"`
@@ -158,6 +204,23 @@ type containerInspect struct {
State containerState `json:"State"`
}
type imageInspect struct {
ID string `json:"Id"`
Config json.RawMessage `json:"Config"`
}
type imageRuntimeConfig struct {
User string `json:"User"`
Entrypoint []string `json:"Entrypoint"`
Cmd []string `json:"Cmd"`
WorkingDir string `json:"WorkingDir"`
Labels map[string]string `json:"Labels"`
ExposedPorts map[string]any `json:"ExposedPorts"`
Healthcheck *struct {
Test []string `json:"Test"`
} `json:"Healthcheck"`
}
type endpointSettings struct {
IPAMConfig json.RawMessage `json:"IPAMConfig,omitempty"`
Links []string `json:"Links,omitempty"`
@@ -282,6 +345,28 @@ func (c *dockerClient) inspectContainer(ctx context.Context, id string) (contain
return inspected, nil
}
func (c *dockerClient) inspectImage(ctx context.Context, reference string) (imageInspect, error) {
if strings.TrimSpace(reference) == "" {
return imageInspect{}, errors.New("image reference is empty")
}
response, err := c.request(ctx, http.MethodGet, "/images/"+url.PathEscape(reference)+"/json", nil, nil, true)
if err != nil {
return imageInspect{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return imageInspect{}, dockerStatusError(response)
}
var inspected imageInspect
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&inspected); err != nil {
return imageInspect{}, err
}
if inspected.ID == "" || len(inspected.Config) == 0 {
return imageInspect{}, errors.New("Docker returned incomplete image metadata")
}
return inspected, nil
}
func (c *dockerClient) stopContainer(ctx context.Context, id string, seconds int) error {
query := url.Values{"t": {fmt.Sprint(seconds)}}
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?"+query.Encode(), nil, nil, true)
@@ -308,13 +393,338 @@ func (c *dockerClient) renameContainer(ctx context.Context, id, name string) err
return dockerStatusError(response)
}
func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name, image string) (string, error) {
var body map[string]json.RawMessage
if err := json.Unmarshal(inspected.Config, &body); err != nil {
return "", fmt.Errorf("decode container config: %w", err)
func resolveCurrentVersion(reported string, rawConfig json.RawMessage, repository string) (string, error) {
var config struct {
Image string `json:"Image"`
Labels map[string]string `json:"Labels"`
}
imageJSON, _ := json.Marshal(image)
body["Image"] = imageJSON
if err := json.Unmarshal(rawConfig, &config); err != nil {
return "", fmt.Errorf("decode running container version: %w", err)
}
prefix := strings.TrimSuffix(repository, ":") + ":"
var imageVersion string
if strings.HasPrefix(config.Image, prefix) {
if version := strings.TrimPrefix(config.Image, prefix); updateprotocol.ValidVersion(version) {
imageVersion = version
}
}
labelVersion := strings.TrimSpace(config.Labels[labelImageVersion])
if labelVersion != "" && !updateprotocol.ValidVersion(labelVersion) {
return "", errors.New("running container has an invalid version label")
}
if imageVersion == "" || labelVersion == "" {
return "", errors.New("running container has no trustworthy version metadata")
}
if imageVersion != labelVersion {
return "", fmt.Errorf("running image reference %s does not match version label %s", imageVersion, labelVersion)
}
actualVersion := imageVersion
if reported != "" {
if !updateprotocol.ValidVersion(reported) || reported != actualVersion {
return "", fmt.Errorf("reported version %q does not match running version %q", reported, actualVersion)
}
}
return actualVersion, nil
}
func validateUpdateCompatibility(currentVersion, targetVersion string, currentImage, targetImage imageInspect) error {
if comparison, err := updateprotocol.CompareVersions(currentVersion, targetVersion); err != nil || comparison >= 0 {
return fmt.Errorf("target %s is not newer than %s", targetVersion, currentVersion)
}
var current, target imageRuntimeConfig
if err := json.Unmarshal(currentImage.Config, &current); err != nil {
return fmt.Errorf("decode current image config: %w", err)
}
if err := json.Unmarshal(targetImage.Config, &target); err != nil {
return fmt.Errorf("decode target image config: %w", err)
}
if current.Labels[labelImageVersion] != currentVersion {
return fmt.Errorf("current image version label is %q", current.Labels[labelImageVersion])
}
if target.Labels[labelImageVersion] != targetVersion {
return fmt.Errorf("target image version label is %q", target.Labels[labelImageVersion])
}
minimumVersion := target.Labels[labelMinimumVersion]
if !updateprotocol.ValidVersion(minimumVersion) {
return errors.New("target image has no valid minimum update version")
}
if comparison, err := updateprotocol.CompareVersions(currentVersion, minimumVersion); err != nil || comparison < 0 {
return fmt.Errorf("target requires at least %s", minimumVersion)
}
minimumAgent, err := strconv.Atoi(target.Labels[labelMinimumAgent])
if err != nil || minimumAgent < buildinfo.MinimumAgentProtocol || minimumAgent > buildinfo.UpdateProtocolVersion {
return fmt.Errorf("target requires unsupported update protocol %q", target.Labels[labelMinimumAgent])
}
runtimeContract := strconv.Itoa(buildinfo.RuntimeContractVersion)
if current.Labels[labelRuntimeContract] != runtimeContract || target.Labels[labelRuntimeContract] != runtimeContract {
return fmt.Errorf("unsupported runtime contract transition %q to %q", current.Labels[labelRuntimeContract], target.Labels[labelRuntimeContract])
}
databaseContract := strconv.Itoa(buildinfo.DatabaseContractVersion)
if current.Labels[labelDatabaseContract] != databaseContract || target.Labels[labelDatabaseContract] != databaseContract {
return fmt.Errorf("unsupported database contract transition %q to %q", current.Labels[labelDatabaseContract], target.Labels[labelDatabaseContract])
}
currentSchema, currentSchemaErr := strconv.Atoi(current.Labels[labelSchemaVersion])
targetSchema, targetSchemaErr := strconv.Atoi(target.Labels[labelSchemaVersion])
if currentSchemaErr != nil || targetSchemaErr != nil || currentSchema < buildinfo.CurrentSchemaVersion || targetSchema < currentSchema {
return fmt.Errorf("unsupported schema transition %q to %q", current.Labels[labelSchemaVersion], target.Labels[labelSchemaVersion])
}
if !compatibleRuntimeConfig(current) {
return errors.New("current image violates the stable runtime contract")
}
if !compatibleRuntimeConfig(target) {
return errors.New("target image violates the stable runtime contract")
}
return nil
}
func compatibleRuntimeConfig(config imageRuntimeConfig) bool {
if config.User != runtimeUser || config.WorkingDir != runtimeWorkingDirectory || len(config.Entrypoint) != 1 || config.Entrypoint[0] != runtimeEntrypoint || len(config.Cmd) != 1 || config.Cmd[0] != runtimeCommand {
return false
}
if _, ok := config.ExposedPorts["8080/tcp"]; !ok {
return false
}
return config.Healthcheck != nil && strings.Join(config.Healthcheck.Test, "\x00") == strings.Join([]string{"CMD", runtimeEntrypoint, "healthcheck"}, "\x00")
}
func buildReplacementConfig(inspected containerInspect, oldImageConfig, targetImageConfig json.RawMessage, targetReference string) (map[string]json.RawMessage, error) {
oldContainer, err := decodeConfigMap(inspected.Config)
if err != nil {
return nil, fmt.Errorf("decode running container config: %w", err)
}
oldImage, err := decodeConfigMap(oldImageConfig)
if err != nil {
return nil, fmt.Errorf("decode running image config: %w", err)
}
target, err := decodeConfigMap(targetImageConfig)
if err != nil {
return nil, fmt.Errorf("decode target image config: %w", err)
}
for _, field := range []string{
"Domainname", "AttachStdin", "AttachStdout", "AttachStderr", "Tty", "OpenStdin", "StdinOnce",
"User", "Cmd", "Entrypoint", "WorkingDir", "Healthcheck", "StopSignal", "StopTimeout", "Shell",
"NetworkDisabled", "MacAddress",
} {
preserveConfigOverride(target, oldContainer, oldImage, field)
}
preserveCustomHostname(target, oldContainer, inspected.ID)
if err := mergeEnvironment(target, oldContainer, oldImage); err != nil {
return nil, err
}
if err := mergeLabels(target, oldContainer, oldImage); err != nil {
return nil, err
}
for _, field := range []string{"ExposedPorts", "Volumes"} {
if err := mergeAddedMapKeys(target, oldContainer, oldImage, field); err != nil {
return nil, err
}
}
encodedReference, _ := json.Marshal(targetReference)
target["Image"] = encodedReference
return target, nil
}
func decodeConfigMap(raw json.RawMessage) (map[string]json.RawMessage, error) {
var config map[string]json.RawMessage
if len(raw) == 0 || string(raw) == "null" {
return nil, errors.New("empty config")
}
if err := json.Unmarshal(raw, &config); err != nil {
return nil, err
}
return config, nil
}
func preserveConfigOverride(target, container, image map[string]json.RawMessage, field string) {
containerValue, exists := container[field]
if !exists || rawJSONEqual(containerValue, image[field]) {
return
}
target[field] = append(json.RawMessage(nil), containerValue...)
}
func preserveCustomHostname(target, container map[string]json.RawMessage, containerID string) {
var hostname string
if err := json.Unmarshal(container["Hostname"], &hostname); err != nil || hostname == "" {
return
}
shortID := containerID
if len(shortID) > 12 {
shortID = shortID[:12]
}
if hostname == containerID || hostname == shortID {
delete(target, "Hostname")
return
}
target["Hostname"] = append(json.RawMessage(nil), container["Hostname"]...)
}
func mergeEnvironment(target, container, image map[string]json.RawMessage) error {
targetValues, err := decodeStringSlice(target["Env"])
if err != nil {
return fmt.Errorf("decode target environment: %w", err)
}
containerValues, err := decodeStringSlice(container["Env"])
if err != nil {
return fmt.Errorf("decode container environment: %w", err)
}
imageValues, err := decodeStringSlice(image["Env"])
if err != nil {
return fmt.Errorf("decode image environment: %w", err)
}
result, positions := indexedEnvironment(targetValues)
imageEnvironment := make(map[string]string, len(imageValues))
for _, value := range imageValues {
imageEnvironment[environmentKey(value)] = value
}
for _, value := range containerValues {
key := environmentKey(value)
if previous, exists := imageEnvironment[key]; exists && previous == value {
continue
}
if position, exists := positions[key]; exists {
result[position] = value
} else {
positions[key] = len(result)
result = append(result, value)
}
}
encoded, _ := json.Marshal(result)
target["Env"] = encoded
return nil
}
func decodeStringSlice(raw json.RawMessage) ([]string, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var values []string
if err := json.Unmarshal(raw, &values); err != nil {
return nil, err
}
return values, nil
}
func indexedEnvironment(values []string) ([]string, map[string]int) {
result := append([]string(nil), values...)
positions := make(map[string]int, len(values))
for index, value := range values {
positions[environmentKey(value)] = index
}
return result, positions
}
func environmentKey(value string) string {
if index := strings.IndexByte(value, '='); index >= 0 {
return value[:index]
}
return value
}
func mergeLabels(target, container, image map[string]json.RawMessage) error {
targetLabels, err := decodeStringMap(target["Labels"])
if err != nil {
return fmt.Errorf("decode target labels: %w", err)
}
containerLabels, err := decodeStringMap(container["Labels"])
if err != nil {
return fmt.Errorf("decode container labels: %w", err)
}
imageLabels, err := decodeStringMap(image["Labels"])
if err != nil {
return fmt.Errorf("decode image labels: %w", err)
}
if targetLabels == nil {
targetLabels = make(map[string]string)
}
for key, value := range containerLabels {
if protectedImageLabel(key) {
continue
}
if imageValue, exists := imageLabels[key]; exists && imageValue == value {
continue
}
targetLabels[key] = value
}
encoded, _ := json.Marshal(targetLabels)
target["Labels"] = encoded
return nil
}
func protectedImageLabel(key string) bool {
switch key {
case labelImageVersion, labelMinimumVersion, labelMinimumAgent, labelRuntimeContract, labelDatabaseContract, labelSchemaVersion:
return true
default:
return false
}
}
func decodeStringMap(raw json.RawMessage) (map[string]string, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var values map[string]string
if err := json.Unmarshal(raw, &values); err != nil {
return nil, err
}
return values, nil
}
func mergeAddedMapKeys(target, container, image map[string]json.RawMessage, field string) error {
targetValues, err := decodeRawMap(target[field])
if err != nil {
return fmt.Errorf("decode target %s: %w", field, err)
}
containerValues, err := decodeRawMap(container[field])
if err != nil {
return fmt.Errorf("decode container %s: %w", field, err)
}
imageValues, err := decodeRawMap(image[field])
if err != nil {
return fmt.Errorf("decode image %s: %w", field, err)
}
if targetValues == nil {
targetValues = make(map[string]json.RawMessage)
}
for key, value := range containerValues {
if imageValue, exists := imageValues[key]; exists && rawJSONEqual(value, imageValue) {
continue
}
targetValues[key] = value
}
encoded, _ := json.Marshal(targetValues)
target[field] = encoded
return nil
}
func decodeRawMap(raw json.RawMessage) (map[string]json.RawMessage, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var values map[string]json.RawMessage
if err := json.Unmarshal(raw, &values); err != nil {
return nil, err
}
return values, nil
}
func rawJSONEqual(left, right json.RawMessage) bool {
if len(left) == 0 && len(right) == 0 {
return true
}
var a, b any
if err := json.Unmarshal(left, &a); err != nil {
return false
}
if err := json.Unmarshal(right, &b); err != nil {
return false
}
return reflect.DeepEqual(a, b)
}
func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name string, body map[string]json.RawMessage) (string, error) {
body["HostConfig"] = inspected.HostConfig
endpoints := make(map[string]endpointSettings, len(inspected.NetworkSettings.Networks))
oldIDPrefix := inspected.ID
@@ -383,7 +793,7 @@ func (c *dockerClient) removeContainer(ctx context.Context, id string, force boo
return dockerStatusError(response)
}
func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.Duration) error {
func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, expectedVersion string, timeout time.Duration) error {
deadline := time.NewTimer(timeout)
ticker := time.NewTicker(time.Second)
defer deadline.Stop()
@@ -404,9 +814,24 @@ func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.
if !inspected.State.Running {
return fmt.Errorf("replacement container stopped with exit code %d", inspected.State.ExitCode)
}
if expectedImageID != "" && inspected.Image != expectedImageID {
return fmt.Errorf("container uses image %s instead of %s", inspected.Image, expectedImageID)
}
if expectedVersion != "" {
var config struct {
Labels map[string]string `json:"Labels"`
}
if err := json.Unmarshal(inspected.Config, &config); err != nil {
return fmt.Errorf("decode replacement version: %w", err)
}
version := strings.TrimSpace(config.Labels[labelImageVersion])
if version != expectedVersion {
return fmt.Errorf("container version is %q instead of %q", version, expectedVersion)
}
}
if inspected.State.Health == nil {
if time.Since(startedAt) >= 8*time.Second {
return nil
return errors.New("replacement container has no healthcheck")
}
continue
}
+205
View File
@@ -0,0 +1,205 @@
package updateagent
import (
"encoding/json"
"strconv"
"testing"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
)
func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
t.Parallel()
current := compatibleImage("v1.4.0", "v1.4.0")
target := compatibleImage("v2.0.0", "v1.4.0")
if err := validateUpdateCompatibility("v1.4.0", "v2.0.0", current, target); err != nil {
t.Fatalf("compatible direct update was rejected: %v", err)
}
}
func TestResolveCurrentVersionRequiresRuntimeAndImageMetadataToAgree(t *testing.T) {
t.Parallel()
config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`)
version, err := resolveCurrentVersion("", config, "registry.example/app")
if err != nil || version != "v1.4.0" {
t.Fatalf("exact image version was not resolved: version=%q err=%v", version, err)
}
if _, err := resolveCurrentVersion("v1.5.0", config, "registry.example/app"); err == nil {
t.Fatal("untrusted reported version overrode the running image metadata")
}
mismatch := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.3.0"}}`)
if _, err := resolveCurrentVersion("", mismatch, "registry.example/app"); err == nil {
t.Fatal("mismatching image reference and label were accepted")
}
}
func TestValidateUpdateCompatibilityRejectsUnsupportedJump(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(map[string]any)
}{
{name: "minimum version", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelMinimumVersion] = "v1.5.0" }},
{name: "runtime contract", mutate: func(config map[string]any) {
config["Labels"].(map[string]string)[labelRuntimeContract] = strconv.Itoa(buildinfo.RuntimeContractVersion + 1)
}},
{name: "database contract", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelDatabaseContract] = "2" }},
{name: "agent protocol", mutate: func(config map[string]any) {
config["Labels"].(map[string]string)[labelMinimumAgent] = strconv.Itoa(buildinfo.UpdateProtocolVersion + 1)
}},
{name: "wrong image version", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelImageVersion] = "v1.9.9" }},
{name: "missing healthcheck", mutate: func(config map[string]any) { delete(config, "Healthcheck") }},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
image := compatibleImage("v2.0.0", "v1.4.0")
var config map[string]any
if err := json.Unmarshal(image.Config, &config); err != nil {
t.Fatal(err)
}
labels := make(map[string]string)
for key, value := range config["Labels"].(map[string]any) {
labels[key] = value.(string)
}
config["Labels"] = labels
test.mutate(config)
image.Config, _ = json.Marshal(config)
if err := validateUpdateCompatibility("v1.4.0", "v2.0.0", compatibleImage("v1.4.0", "v1.4.0"), image); err == nil {
t.Fatal("incompatible image was accepted")
}
})
}
}
func TestValidateUpdateCompatibilityRejectsSchemaRegressionAfterEarlierSkip(t *testing.T) {
t.Parallel()
current := compatibleImage("v2.0.0", "v1.4.0")
target := compatibleImage("v2.1.0", "v1.4.0")
current.Config = withImageLabel(t, current.Config, labelSchemaVersion, "20")
target.Config = withImageLabel(t, target.Config, labelSchemaVersion, "19")
if err := validateUpdateCompatibility("v2.0.0", "v2.1.0", current, target); err == nil {
t.Fatal("target with an older schema contract was accepted")
}
}
func TestBuildReplacementConfigUsesNewImageDefaultsAndKeepsDeploymentOverrides(t *testing.T) {
t.Parallel()
oldImage := json.RawMessage(`{
"User":"dyndns:dyndns",
"Entrypoint":["/old-entrypoint"],
"WorkingDir":"/data",
"Env":["PATH=/old","OLD_DEFAULT=1"],
"Labels":{"org.opencontainers.image.version":"v1.3.0","image-owned":"old"},
"ExposedPorts":{"8080/tcp":{}},
"Volumes":{"/data":{}}
}`)
targetImage := json.RawMessage(`{
"User":"dyndns:dyndns",
"Entrypoint":["/new-entrypoint"],
"WorkingDir":"/data",
"Env":["PATH=/new","NEW_DEFAULT=1"],
"Labels":{"org.opencontainers.image.version":"v2.0.0","image-owned":"new"},
"ExposedPorts":{"8080/tcp":{}},
"Volumes":{"/data":{}}
}`)
container := containerInspect{
ID: "0123456789abcdef",
Config: json.RawMessage(`{
"Hostname":"0123456789ab",
"User":"dyndns:dyndns",
"Entrypoint":["/old-entrypoint"],
"WorkingDir":"/data",
"Env":["PATH=/old","OLD_DEFAULT=1","CUSTOM=yes"],
"Labels":{"org.opencontainers.image.version":"v0.0.0","image-owned":"old","com.docker.compose.project":"domain-control","de.j-mahler.domain-control.role":"app"},
"ExposedPorts":{"8080/tcp":{},"9091/tcp":{}},
"Volumes":{"/data":{},"/custom":{}}
}`),
}
config, err := buildReplacementConfig(container, oldImage, targetImage, "registry.example/app:v2.0.0")
if err != nil {
t.Fatal(err)
}
if _, exists := config["Hostname"]; exists {
t.Fatal("generated container hostname was copied")
}
var image, user string
var entrypoint, environment []string
var labels map[string]string
var ports, volumes map[string]any
_ = json.Unmarshal(config["Image"], &image)
_ = json.Unmarshal(config["User"], &user)
_ = json.Unmarshal(config["Entrypoint"], &entrypoint)
_ = json.Unmarshal(config["Env"], &environment)
_ = json.Unmarshal(config["Labels"], &labels)
_ = json.Unmarshal(config["ExposedPorts"], &ports)
_ = json.Unmarshal(config["Volumes"], &volumes)
if image != "registry.example/app:v2.0.0" || user != runtimeUser || len(entrypoint) != 1 || entrypoint[0] != "/new-entrypoint" {
t.Fatalf("new image defaults were not retained: image=%q user=%q entrypoint=%v", image, user, entrypoint)
}
if !contains(environment, "PATH=/new") || !contains(environment, "NEW_DEFAULT=1") || !contains(environment, "CUSTOM=yes") || contains(environment, "OLD_DEFAULT=1") {
t.Fatalf("environment was merged incorrectly: %v", environment)
}
if labels[labelImageVersion] != "v2.0.0" || labels["image-owned"] != "new" || labels["com.docker.compose.project"] != "domain-control" || labels["de.j-mahler.domain-control.role"] != "app" {
t.Fatalf("labels were merged incorrectly: %#v", labels)
}
if _, exists := ports["9091/tcp"]; !exists {
t.Fatalf("custom exposed port was lost: %#v", ports)
}
if _, exists := volumes["/custom"]; !exists {
t.Fatalf("custom volume was lost: %#v", volumes)
}
}
func compatibleImage(version, minimum string) imageInspect {
config := map[string]any{
"User": runtimeUser,
"Entrypoint": []string{runtimeEntrypoint},
"Cmd": []string{runtimeCommand},
"WorkingDir": runtimeWorkingDirectory,
"ExposedPorts": map[string]any{
"8080/tcp": map[string]any{},
},
"Healthcheck": map[string]any{
"Test": []string{"CMD", runtimeEntrypoint, "healthcheck"},
},
"Labels": map[string]string{
labelImageVersion: version,
labelMinimumVersion: minimum,
labelMinimumAgent: strconv.Itoa(buildinfo.MinimumAgentProtocol),
labelRuntimeContract: strconv.Itoa(buildinfo.RuntimeContractVersion),
labelDatabaseContract: strconv.Itoa(buildinfo.DatabaseContractVersion),
labelSchemaVersion: strconv.Itoa(buildinfo.CurrentSchemaVersion),
},
}
encoded, _ := json.Marshal(config)
return imageInspect{ID: "sha256:target", Config: encoded}
}
func withImageLabel(t *testing.T, raw json.RawMessage, key, value string) json.RawMessage {
t.Helper()
var config map[string]any
if err := json.Unmarshal(raw, &config); err != nil {
t.Fatal(err)
}
labels, ok := config["Labels"].(map[string]any)
if !ok {
t.Fatal("image labels are missing")
}
labels[key] = value
encoded, err := json.Marshal(config)
if err != nil {
t.Fatal(err)
}
return encoded
}
func contains(values []string, expected string) bool {
for _, value := range values {
if value == expected {
return true
}
}
return false
}
+119
View File
@@ -0,0 +1,119 @@
package updateagent
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type ServerConfig struct {
Socket string
DockerSocket string
ImageRepository string
TargetLabel string
}
func ServerConfigFromEnvironment() ServerConfig {
return ServerConfig{
Socket: environmentValue("UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"),
DockerSocket: environmentValue("UPDATE_DOCKER_SOCKET", "/var/run/docker.sock"),
ImageRepository: environmentValue("UPDATE_IMAGE_REPOSITORY", defaultImageRepository),
TargetLabel: environmentValue("UPDATE_TARGET_LABEL", "de.j-mahler.domain-control.role=app"),
}
}
func Serve(ctx context.Context, config ServerConfig, logger *slog.Logger) error {
if err := validateServerConfig(config); err != nil {
return err
}
directory := filepath.Dir(config.Socket)
if err := os.MkdirAll(directory, 0o711); err != nil {
return err
}
info, err := os.Lstat(directory)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("update agent socket directory is not a regular directory")
}
if err := os.Chmod(directory, 0o711); err != nil {
return err
}
if info, err := os.Lstat(config.Socket); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return errors.New("update agent socket path is occupied by a non-socket file")
}
if err := os.Remove(config.Socket); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
listener, err := net.Listen("unix", config.Socket)
if err != nil {
return err
}
defer listener.Close()
defer os.Remove(config.Socket)
// The agent container runs as uid 0 with gid 10001. Group-only access lets
// the unprivileged web process connect without exposing the control socket.
if err := os.Chmod(config.Socket, 0o660); err != nil {
return err
}
runner := NewDockerRunner(config.DockerSocket, config.ImageRepository, config.TargetLabel, logger)
controller := NewController(runner, logger)
server := &http.Server{
Handler: controller.Handler(),
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 15 * time.Second,
MaxHeaderBytes: 8 << 10,
}
serverErrors := make(chan error, 1)
go func() {
logger.Info("update agent ready", "socket", config.Socket)
serverErrors <- server.Serve(listener)
}()
select {
case err := <-serverErrors:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return server.Shutdown(shutdownCtx)
}
}
func validateServerConfig(config ServerConfig) error {
socket := filepath.Clean(config.Socket)
dockerSocket := filepath.Clean(config.DockerSocket)
if !filepath.IsAbs(socket) || filepath.Dir(socket) == "/" {
return errors.New("update agent socket must use a dedicated absolute directory")
}
if !filepath.IsAbs(dockerSocket) || socket == dockerSocket {
return errors.New("Docker socket path is invalid")
}
if config.ImageRepository == "" || strings.ContainsAny(config.ImageRepository, " \t\r\n") {
return errors.New("update image repository is invalid")
}
if len(config.TargetLabel) == 0 || len(config.TargetLabel) > 256 || !strings.Contains(config.TargetLabel, "=") {
return errors.New("update target label is invalid")
}
return nil
}
func environmentValue(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
+53
View File
@@ -0,0 +1,53 @@
package updateagent
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
)
func TestValidateServerConfigRejectsUnsafeSocketPaths(t *testing.T) {
t.Parallel()
valid := ServerConfig{
Socket: "/run/domain-control-update/agent.sock",
DockerSocket: "/var/run/docker.sock",
ImageRepository: defaultImageRepository,
TargetLabel: "de.j-mahler.domain-control.role=app",
}
if err := validateServerConfig(valid); err != nil {
t.Fatalf("valid config rejected: %v", err)
}
for _, socket := range []string{"agent.sock", "/agent.sock", "/var/run/docker.sock"} {
candidate := valid
candidate.Socket = socket
if err := validateServerConfig(candidate); err == nil {
t.Errorf("unsafe socket path %q was accepted", socket)
}
}
}
func TestServeDoesNotDeleteRegularFileAtSocketPath(t *testing.T) {
t.Parallel()
directory := t.TempDir()
socket := filepath.Join(directory, "agent.sock")
if err := os.WriteFile(socket, []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
config := ServerConfig{
Socket: socket,
DockerSocket: "/var/run/docker.sock",
ImageRepository: defaultImageRepository,
TargetLabel: "de.j-mahler.domain-control.role=app",
}
err := Serve(context.Background(), config, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil {
t.Fatal("regular file at the socket path was silently replaced")
}
content, readErr := os.ReadFile(socket)
if readErr != nil || string(content) != "keep" {
t.Fatalf("regular file was changed: content=%q err=%v", content, readErr)
}
}
+44
View File
@@ -1,7 +1,10 @@
package updateprotocol
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
@@ -19,6 +22,7 @@ const (
type Request struct {
Version string `json:"version"`
CurrentVersion string `json:"current_version"`
RegistryUsername string `json:"registry_username"`
RegistryToken string `json:"registry_token"`
}
@@ -29,6 +33,8 @@ type Status struct {
Message string `json:"message,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
AgentVersion string `json:"agent_version,omitempty"`
Protocol int `json:"protocol_version,omitempty"`
}
func (s Status) Busy() bool {
@@ -38,3 +44,41 @@ func (s Status) Busy() bool {
func ValidVersion(version string) bool {
return len(version) <= 64 && releaseVersionPattern.MatchString(version)
}
// CompareVersions compares strict stable vMAJOR.MINOR.PATCH versions. It does
// not require callers to install intermediate releases.
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
}
for index := range a {
if a[index] < b[index] {
return -1, nil
}
if a[index] > b[index] {
return 1, nil
}
}
return 0, nil
}
func parseVersion(raw string) ([3]int, error) {
if !ValidVersion(raw) {
return [3]int{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
}
parts := strings.Split(strings.TrimPrefix(raw, "v"), ".")
var values [3]int
for index, part := range parts {
value, err := strconv.Atoi(part)
if err != nil || value < 0 || value > 1_000_000 {
return [3]int{}, fmt.Errorf("version contains an invalid number")
}
values[index] = value
}
return values, nil
}
+10 -2
View File
@@ -390,13 +390,13 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
t.Fatal(err)
}
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
updateState := appupdate.State{CurrentVersion: "v1.2.2", LatestVersion: "v1.2.3", UpdateAvailable: true, AgentAvailable: true, CredentialConfigured: true}
updateState := appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, AgentCompatible: true, AgentVersion: "v1.4.0", AgentProtocol: 2, CredentialConfigured: true}
var notification bytes.Buffer
accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
if err := templates["security"].ExecuteTemplate(&notification, "security", accountData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Neue Version verfügbar", "v1.2.3 ist bereit", `data-software-update-form`, `data-software-update-overlay`} {
for _, expected := range []string{"Neue Version verfügbar", "v2.0.0 ist bereit", `data-software-update-form`, `data-software-update-overlay`} {
if !strings.Contains(notification.String(), expected) {
t.Errorf("global update notification is missing %q", expected)
}
@@ -425,6 +425,14 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
if strings.Contains(freshPage.String(), "Update-Kanal") {
t.Error("confusing legacy update channel box is still rendered")
}
var syncPage bytes.Buffer
syncData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, ComposeSyncRequired: true, CredentialConfigured: true}}
if err := templates["system"].ExecuteTemplate(&syncPage, "system", syncData); err != nil {
t.Fatal(err)
}
if !strings.Contains(syncPage.String(), "Compose einmal aktualisieren") || strings.Contains(syncPage.String(), "Jetzt aktualisieren") {
t.Fatal("compose synchronization state was not rendered cleanly")
}
}
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
+3 -3
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>{{.Title}} · DNS Control</title>
<link rel="stylesheet" href="/static/styles.css?v=features-21">
<script src="/static/app.js?v=features-21" defer></script>
<link rel="stylesheet" href="/static/styles.css?v=features-23">
<script src="/static/app.js?v=features-23" defer></script>
</head>
<body class="page-{{.Page}}">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
@@ -49,7 +49,7 @@
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
<div class="software-update-banner-actions">
<a class="button button-quiet" href="/system">Details</a>
{{if .SoftwareUpdate.AgentAvailable}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
{{if .SoftwareUpdate.AgentCompatible}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
</div>
</aside>
{{end}}
+7 -4
View File
@@ -13,19 +13,22 @@
<div class="software-version-cell is-latest"><span>Verfügbar</span><strong>{{if .SoftwareUpdate.LatestVersion}}{{.SoftwareUpdate.LatestVersion}}{{else}}–{{end}}</strong></div>
</div>
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
<div class="software-version-meta"><span>Letzte Prüfung <b>{{formatTime .SoftwareUpdate.LastCheckedAt}}</b></span><span>Update-Agent <b>{{if .SoftwareUpdate.AgentAvailable}}bereit{{else}}nicht erreichbar{{end}}</b></span></div>
<div class="software-version-meta"><span>Letzte Prüfung <b>{{formatTime .SoftwareUpdate.LastCheckedAt}}</b></span><span>Update-Agent <b>{{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}</b></span></div>
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
</div>
<div class="software-version-action">
{{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentAvailable}}
{{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentCompatible}}
<form action="/system/update" method="post" data-software-update-form>
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
</form>
{{else if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.ComposeSyncRequired}}
<span class="status-pill status-warn">Compose aktualisieren</span>
{{else if .SoftwareUpdate.UpdateAvailable}}
<span class="status-pill status-warn">Update-Agent nicht erreichbar</span>
<span class="status-pill status-warn">Agent nicht erreichbar</span>
{{else}}
<span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured)}}status-warn{{else}}status-good{{end}}">{{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}}</span>
<span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured) .SoftwareUpdate.ComposeSyncRequired}}status-warn{{else}}status-good{{end}}">{{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}}</span>
{{end}}
</div>
</section>