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

+99 -20
View File
@@ -21,24 +21,42 @@ var (
ErrDisabled = errors.New("the updater is disabled")
)
const defaultRetryInitial = 5 * time.Second
type ipResolver interface {
ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result
ValidateEndpoint(ctx context.Context, raw string) error
}
type Scheduler struct {
store *store.Store
provider *hetzner.Client
resolver *ipresolve.Resolver
resolver ipResolver
cipher *secure.Cipher
logger *slog.Logger
running atomic.Bool
nextRun atomic.Int64
wake chan struct{}
retryMin time.Duration
retryMax time.Duration
}
func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Resolver, cipher *secure.Cipher, logger *slog.Logger) *Scheduler {
return &Scheduler{store: database, provider: provider, resolver: resolver, cipher: cipher, logger: logger, wake: make(chan struct{}, 1)}
return &Scheduler{
store: database,
provider: provider,
resolver: resolver,
cipher: cipher,
logger: logger,
wake: make(chan struct{}, 1),
retryMin: defaultRetryInitial,
}
}
func (s *Scheduler) Start(ctx context.Context) {
go func() {
defer s.nextRun.Store(0)
consecutiveFailures := 0
for {
settings, err := s.store.GetSettings(ctx)
if err != nil {
@@ -60,11 +78,22 @@ func (s *Scheduler) Start(ctx context.Context) {
}
continue
}
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
if wait < time.Minute {
wait = time.Minute
}
retryAfter := time.Duration(0)
if settings.UpdaterEnabled {
if _, runErr := s.Run(ctx); runErr != nil && !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
s.logger.Warn("scheduled update run failed", "error", runErr)
_, runErr := s.Run(ctx)
if runErr == nil {
consecutiveFailures = 0
} else if !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
consecutiveFailures++
retryAfter = s.retryDelay(consecutiveFailures, wait)
s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter)
}
} else {
consecutiveFailures = 0
s.nextRun.Store(0)
select {
case <-ctx.Done():
@@ -73,9 +102,8 @@ func (s *Scheduler) Start(ctx context.Context) {
}
continue
}
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
if wait < time.Minute {
wait = time.Minute
if retryAfter > 0 {
wait = retryAfter
}
s.nextRun.Store(time.Now().UTC().Add(wait).Unix())
timer := time.NewTimer(wait)
@@ -84,6 +112,7 @@ func (s *Scheduler) Start(ctx context.Context) {
timer.Stop()
return
case <-s.wake:
consecutiveFailures = 0
if !timer.Stop() {
select {
case <-timer.C:
@@ -96,6 +125,30 @@ func (s *Scheduler) Start(ctx context.Context) {
}()
}
func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.Duration {
minimum, maximum := s.retryMin, scheduledWait
if minimum <= 0 {
minimum = defaultRetryInitial
}
if s.retryMax > 0 && s.retryMax < maximum {
maximum = s.retryMax
}
if maximum < minimum {
maximum = minimum
}
wait := minimum
for attempt := 1; attempt < failures && wait < maximum; attempt++ {
if wait > maximum/2 {
return maximum
}
wait *= 2
}
if wait > maximum {
return maximum
}
return wait
}
func (s *Scheduler) Trigger() {
s.nextRun.Store(0)
select {
@@ -131,22 +184,43 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
if err != nil {
return "", err
}
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled)
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
message := "Keine öffentliche IP ermittelt"
if ipResult.IPv4Err != nil {
message += ": " + ipResult.IPv4Err.Error()
}
return s.finishRun(ctx, runID, "error", "", "", message, errors.New(message))
}
targets, err := s.store.ListManagedTargets(ctx)
if err != nil {
return s.finishRun(ctx, runID, "error", ipResult.IPv4, ipResult.IPv6, "Aktive Ziele konnten nicht geladen werden", err)
return s.finishRun(ctx, runID, "error", "", "", "Aktive Ziele konnten nicht geladen werden", err)
}
needsIPv6 := false
for _, target := range targets {
if target.Target.UpdateIPv6 {
needsIPv6 = true
break
}
}
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled && needsIPv6)
var resolutionErr error
if ipResult.IPv4Err != nil {
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv4-Auflösung fehlgeschlagen: %w", ipResult.IPv4Err))
}
if settings.IPv6Enabled && needsIPv6 && ipResult.IPv6Err != nil {
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv6-Auflösung fehlgeschlagen: %w", ipResult.IPv6Err))
}
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
message := "Keine öffentliche IP ermittelt"
if resolutionErr == nil {
resolutionErr = errors.New(message)
} else {
message += ": " + resolutionErr.Error()
}
return s.finishRun(ctx, runID, "error", "", "", message, resolutionErr)
}
if len(targets) == 0 {
summary := "Keine aktiven DynDNS-Ziele"
return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil)
status := "ok"
if resolutionErr != nil {
status = "partial"
summary += "; eine IP-Familie war nicht verfügbar"
}
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, resolutionErr)
}
grouped := make(map[int64][]store.ManagedTarget)
@@ -172,13 +246,18 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
status = "partial"
}
summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed)
if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != nil) {
if resolutionErr != nil {
summary += "; eine IP-Familie war nicht verfügbar"
if status == "ok" {
status = "partial"
}
}
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, nil)
var runErr error
if failed > 0 {
runErr = fmt.Errorf("%d DynDNS-Ziel(e) konnten nicht synchronisiert werden", failed)
}
runErr = errors.Join(runErr, resolutionErr)
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, runErr)
}
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
+95
View File
@@ -5,11 +5,13 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
@@ -20,6 +22,24 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
type retryResolver struct {
calls atomic.Int32
recovered chan struct{}
}
func (r *retryResolver) ResolveWithIPv6(_ context.Context, _, _ []string, _ bool) ipresolve.Result {
call := r.calls.Add(1)
if call == 1 {
return ipresolve.Result{IPv4Err: errors.New("network is not ready")}
}
if call == 2 {
close(r.recovered)
}
return ipresolve.Result{IPv4: "198.51.100.42"}
}
func (r *retryResolver) ValidateEndpoint(_ context.Context, _ string) error { return nil }
func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -164,6 +184,81 @@ func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(t *testing.T) {
}
}
func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t *testing.T) {
t.Parallel()
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
settings, err := database.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(context.Background(), settings.UpdateIntervalSeconds, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
t.Fatal(err)
}
resolver := &retryResolver{recovered: make(chan struct{})}
schedule := New(database, nil, ipresolve.New(), nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
schedule.resolver = resolver
schedule.retryMin = 5 * time.Millisecond
schedule.retryMax = 20 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
schedule.Start(ctx)
select {
case <-resolver.recovered:
case <-time.After(2 * time.Second):
t.Fatal("scheduler did not retry the failed startup resolution")
}
deadline := time.Now().Add(time.Second)
for {
settings, err = database.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if settings.LastIPv4 == "198.51.100.42" {
break
}
if time.Now().After(deadline) {
t.Fatalf("successful retry did not persist IPv4: %#v", settings)
}
time.Sleep(5 * time.Millisecond)
}
runs, err := database.RecentRuns(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
if len(runs) != 2 || runs[0].Status != "ok" || runs[1].Status != "error" {
t.Fatalf("unexpected startup retry history: %#v", runs)
}
}
func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
t.Parallel()
schedule := &Scheduler{retryMin: 5 * time.Second}
tests := []struct {
failures int
want time.Duration
}{
{failures: 1, want: 5 * time.Second},
{failures: 2, want: 10 * time.Second},
{failures: 3, want: 20 * time.Second},
{failures: 4, want: 40 * time.Second},
{failures: 5, want: time.Minute},
{failures: 20, want: time.Minute},
}
for _, test := range tests {
if got := schedule.retryDelay(test.failures, time.Minute); got != test.want {
t.Errorf("retry delay after %d failures = %s, want %s", test.failures, got, test.want)
}
}
}
func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) {
t.Parallel()
schedule := &Scheduler{}