fix: harden DynDNS recovery and publish v1.5.1
This commit is contained in:
1 parent
3383b91e49
commit
81def77e3c
37 files changed
+2415
-250
No files matched your search
+110
-52
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user