fix: harden DynDNS recovery and publish v1.5.1

This commit is contained in:
admin committed 2026-10-10 02:26:47 +02:00
1 parent 3383b91e49
commit 81def77e3c
37 files changed
+2415 -250

No files matched your search

+110 -52
View File
@@ -9,6 +9,7 @@ import (
"sync/atomic"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
@@ -21,7 +22,10 @@ var (
ErrDisabled = errors.New("the updater is disabled")
)
const defaultRetryInitial = 5 * time.Second
const (
defaultRetryInitial = 5 * time.Second
defaultRetryMaximum = time.Minute
)
type ipResolver interface {
ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result
@@ -37,6 +41,8 @@ type Scheduler struct {
running atomic.Bool
nextRun atomic.Int64
wake chan struct{}
changes chan struct{}
done chan struct{}
retryMin time.Duration
retryMax time.Duration
}
@@ -49,14 +55,18 @@ func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Re
cipher: cipher,
logger: logger,
wake: make(chan struct{}, 1),
changes: make(chan struct{}, 1),
done: make(chan struct{}),
retryMin: defaultRetryInitial,
}
}
func (s *Scheduler) Start(ctx context.Context) {
go func() {
defer close(s.done)
defer s.nextRun.Store(0)
consecutiveFailures := 0
var failureSince, networkLoggedAt time.Time
for {
settings, err := s.store.GetSettings(ctx)
if err != nil {
@@ -86,10 +96,24 @@ func (s *Scheduler) Start(ctx context.Context) {
if settings.UpdaterEnabled {
_, runErr := s.Run(ctx)
if runErr == nil {
if consecutiveFailures > 0 {
diagnostics.Record(diagnostics.Event{Code: "update_recovered", Target: "updater", Attempt: consecutiveFailures, DurationMS: time.Since(failureSince).Milliseconds()})
}
consecutiveFailures = 0
} else if !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
} else if errors.Is(runErr, ErrRunInProgress) {
retryAfter = time.Second
} else if !errors.Is(runErr, ErrDisabled) {
consecutiveFailures++
if consecutiveFailures == 1 {
failureSince = time.Now()
}
retryAfter = s.retryDelay(consecutiveFailures, wait)
event := diagnostics.Event{Code: "update_failed", Target: "updater", Failure: diagnostics.Failure(runErr), Attempt: consecutiveFailures, RetrySeconds: int64(retryAfter / time.Second)}
if consecutiveFailures == 1 || time.Since(networkLoggedAt) >= 15*time.Minute {
event.Network = diagnostics.CurrentNetwork()
networkLoggedAt = time.Now()
}
diagnostics.Record(event)
s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter)
}
} else {
@@ -112,7 +136,6 @@ func (s *Scheduler) Start(ctx context.Context) {
timer.Stop()
return
case <-s.wake:
consecutiveFailures = 0
if !timer.Stop() {
select {
case <-timer.C:
@@ -130,8 +153,12 @@ func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.D
if minimum <= 0 {
minimum = defaultRetryInitial
}
if s.retryMax > 0 && s.retryMax < maximum {
maximum = s.retryMax
retryMax := s.retryMax
if retryMax <= 0 {
retryMax = defaultRetryMaximum
}
if retryMax < maximum {
maximum = retryMax
}
if maximum < minimum {
maximum = minimum
@@ -167,11 +194,40 @@ func (s *Scheduler) NextRunAt() time.Time {
return time.Unix(value, 0).UTC()
}
// LockChanges serializes a complete WebUI mutation (provider and database)
// with updater snapshots. Callers must hold it until the local change is saved.
func (s *Scheduler) LockChanges(ctx context.Context) (func(), error) {
if err := ctx.Err(); err != nil {
return nil, err
}
select {
case s.changes <- struct{}{}:
return func() { <-s.changes }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (s *Scheduler) Wait(ctx context.Context) error {
select {
case <-s.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Scheduler) Run(ctx context.Context) (string, error) {
if !s.running.CompareAndSwap(false, true) {
return "", ErrRunInProgress
}
defer s.running.Store(false)
select {
case s.changes <- struct{}{}:
defer func() { <-s.changes }()
default:
return "", ErrRunInProgress
}
settings, err := s.store.GetSettings(ctx)
if err != nil {
@@ -234,6 +290,7 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
}
if err := s.syncZoneTargets(ctx, group, ipResult.IPv4, ipResult.IPv6, settings.IPv6Enabled, &updated, &failed); err != nil {
failed += len(group)
diagnostics.Record(diagnostics.Event{Code: "provider_update_failed", Target: "api.hetzner.cloud", RunID: runID, Failure: diagnostics.Failure(err), HTTPStatus: diagnostics.HTTPStatus(err)})
s.logger.Warn("sync DNS zone", "zone", group[0].Zone.Name, "error", err)
for _, target := range group {
_ = s.store.UpdateTargetResult(ctx, target.Target.ID, "Fehler", safeError(err), false)
@@ -261,11 +318,16 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
}
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
_ = s.store.FinishUpdateRun(ctx, runID, status, ipv4, ipv6, summary)
_ = s.store.UpdateRunState(ctx, ipv4, ipv6, status)
if status != "ok" {
_ = s.store.AddAudit(ctx, "update."+status, summary)
// Persist cancellation too; using the canceled run context leaves history
// stuck at "running". Keep cleanup bounded during shutdown.
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err := s.store.CompleteUpdateRun(finishCtx, runID, status, ipv4, ipv6, summary); err != nil {
s.logger.Error("persist update result", "run_id", runID, "error", err)
runErr = errors.Join(runErr, fmt.Errorf("Updater-Ergebnis konnte nicht gespeichert werden: %w", err))
status = "error"
}
diagnostics.Record(diagnostics.Event{Code: "update_run_finished", Target: "updater", RunID: runID, Status: status, Failure: diagnostics.Failure(runErr), HTTPStatus: diagnostics.HTTPStatus(runErr)})
return summary, runErr
}
@@ -287,46 +349,53 @@ func (s *Scheduler) syncZoneTargets(ctx context.Context, targets []store.Managed
}
for _, item := range targets {
targetUpdated, status, targetErr := s.syncTarget(ctx, token, zone, item.Target, byKey, ipv4, ipv6, ipv6Enabled)
if targetErr != nil {
*failed = *failed + 1
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, "Fehler", safeError(targetErr), false)
continue
}
if targetUpdated {
*updated = *updated + 1
}
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, status, "", targetUpdated)
if targetErr != nil {
*failed = *failed + 1
diagnostics.Record(diagnostics.Event{Code: "provider_update_failed", Target: "api.hetzner.cloud", TargetID: item.Target.ID, ActionID: diagnostics.ActionID(targetErr), Failure: diagnostics.Failure(targetErr), HTTPStatus: diagnostics.HTTPStatus(targetErr)})
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, "Fehler", safeError(targetErr), targetUpdated)
continue
}
if err := s.store.UpdateTargetResult(ctx, item.Target.ID, status, "", targetUpdated); err != nil {
return err
}
}
return s.store.MarkZoneSynced(ctx, zone.ID, "")
}
func (s *Scheduler) syncTarget(ctx context.Context, token string, zone model.Zone, target model.Target, rrsets map[string]hetzner.RRSet, ipv4, ipv6 string, ipv6Enabled bool) (bool, string, error) {
var states []string
var familyErrors []error
updated := false
if target.UpdateIPv4 {
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "A")], "A", ipv4)
if err != nil {
return false, "", err
for _, family := range []struct {
enabled bool
recordType, ip string
}{
{target.UpdateIPv4, "A", ipv4},
{target.UpdateIPv6, "AAAA", ipv6},
} {
if !family.enabled {
continue
}
updated = updated || changed
states = append(states, state)
}
if target.UpdateIPv6 {
if !ipv6Enabled {
if family.recordType == "AAAA" && !ipv6Enabled {
states = append(states, "AAAA global deaktiviert")
continue
}
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, family.recordType)], family.recordType, family.ip)
updated = updated || changed
if err != nil {
familyErrors = append(familyErrors, fmt.Errorf("%s: %w", family.recordType, err))
states = append(states, family.recordType+" Fehler")
} else {
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "AAAA")], "AAAA", ipv6)
if err != nil {
return false, "", err
}
updated = updated || changed
states = append(states, state)
}
}
if len(states) == 0 {
return false, "Deaktivierte IP-Familien", nil
}
return updated, strings.Join(states, " · "), nil
return updated, strings.Join(states, " · "), errors.Join(familyErrors...)
}
func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zone, target model.Target, rrset hetzner.RRSet, recordType, ip string) (bool, string, error) {
@@ -360,7 +429,7 @@ func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zon
if current.Value == ip {
if current.Comment != target.Description {
if err := s.provider.UpdateRecordComments(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: current.Value, Comment: target.Description}}); err != nil {
return false, "", err
return ttlChanged, "", err
}
status := recordType + " Kommentar synchronisiert"
if ttlChanged {
@@ -374,7 +443,7 @@ func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zon
return false, recordType + " unverändert", nil
}
if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil {
return false, "", err
return ttlChanged, "", err
}
status := recordType + " aktualisiert"
if ttlChanged {
@@ -551,28 +620,12 @@ func (s *Scheduler) SyncStaticTarget(ctx context.Context, target model.Target) (
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
}
}
updated := false
var states []string
if target.UpdateIPv4 && target.StaticIPv4 != "" {
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "A")], "A", target.StaticIPv4)
if err != nil {
return false, "", err
}
updated = updated || changed
states = append(states, state)
}
if target.UpdateIPv6 && target.StaticIPv6 != "" {
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "AAAA")], "AAAA", target.StaticIPv6)
if err != nil {
return false, "", err
}
updated = updated || changed
states = append(states, state)
}
if len(states) == 0 {
target.UpdateIPv4 = target.UpdateIPv4 && target.StaticIPv4 != ""
target.UpdateIPv6 = target.UpdateIPv6 && target.StaticIPv6 != ""
if !target.UpdateIPv4 && !target.UpdateIPv6 {
return false, "Keine feste IP eingetragen", nil
}
return updated, strings.Join(states, " · "), nil
return s.syncTarget(ctx, token, zone, target, byKey, target.StaticIPv4, target.StaticIPv6, true)
}
// DeleteTargetFamilies removes selected A/AAAA RRSets while keeping the local
@@ -859,6 +912,11 @@ func (s *Scheduler) zoneToken(ctx context.Context, zoneID int64) (model.Zone, st
}
func (s *Scheduler) ValidateEndpoints(ctx context.Context, endpoints []string) error {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
if len(endpoints) > 8 {
return errors.New("höchstens acht IP-Endpoints pro Familie erlaubt")
}
for _, endpoint := range endpoints {
if err := s.resolver.ValidateEndpoint(ctx, endpoint); err != nil {
return fmt.Errorf("%s: %w", endpoint, err)