feat: production-ready domain control dashboard
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
9c60eaf3fe
commit
df7516e7ab
45 files changed
+7932
-1
No files matched your search
@@ -0,0 +1,830 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRunInProgress = errors.New("an update run is already in progress")
|
||||
ErrDisabled = errors.New("the updater is disabled")
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
store *store.Store
|
||||
provider *hetzner.Client
|
||||
resolver *ipresolve.Resolver
|
||||
cipher *secure.Cipher
|
||||
logger *slog.Logger
|
||||
running atomic.Bool
|
||||
nextRun atomic.Int64
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
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)}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
go func() {
|
||||
defer s.nextRun.Store(0)
|
||||
for {
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("read scheduler settings", "error", err)
|
||||
s.nextRun.Store(0)
|
||||
timer := time.NewTimer(time.Minute)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-s.wake:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
s.nextRun.Store(0)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.wake:
|
||||
}
|
||||
continue
|
||||
}
|
||||
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
|
||||
if wait < time.Minute {
|
||||
wait = time.Minute
|
||||
}
|
||||
s.nextRun.Store(time.Now().UTC().Add(wait).Unix())
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-s.wake:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Trigger() {
|
||||
s.nextRun.Store(0)
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) IsRunning() bool { return s.running.Load() }
|
||||
|
||||
func (s *Scheduler) NextRunAt() time.Time {
|
||||
value := s.nextRun.Load()
|
||||
if value == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(value, 0).UTC()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
||||
if !s.running.CompareAndSwap(false, true) {
|
||||
return "", ErrRunInProgress
|
||||
}
|
||||
defer s.running.Store(false)
|
||||
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !settings.UpdaterEnabled {
|
||||
return "", ErrDisabled
|
||||
}
|
||||
runID, err := s.store.CreateUpdateRun(ctx)
|
||||
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)
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
summary := "Keine aktiven DynDNS-Ziele"
|
||||
return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil)
|
||||
}
|
||||
|
||||
grouped := make(map[int64][]store.ManagedTarget)
|
||||
for _, target := range targets {
|
||||
grouped[target.Zone.ID] = append(grouped[target.Zone.ID], target)
|
||||
}
|
||||
updated, failed := 0, 0
|
||||
for _, group := range grouped {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.syncZoneTargets(ctx, group, ipResult.IPv4, ipResult.IPv6, settings.IPv6Enabled, &updated, &failed); err != nil {
|
||||
failed += len(group)
|
||||
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)
|
||||
}
|
||||
_ = s.store.MarkZoneSynced(ctx, group[0].Zone.ID, safeError(err))
|
||||
}
|
||||
}
|
||||
status := "ok"
|
||||
if failed > 0 {
|
||||
status = "partial"
|
||||
}
|
||||
summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed)
|
||||
if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return summary, runErr
|
||||
}
|
||||
|
||||
func (s *Scheduler) syncZoneTargets(ctx context.Context, targets []store.ManagedTarget, ipv4, ipv6 string, ipv6Enabled bool, updated, failed *int) error {
|
||||
zone, credential := targets[0].Zone, targets[0].Credential
|
||||
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byKey := make(map[string]hetzner.RRSet, len(rrsets))
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Type == "A" || rrset.Type == "AAAA" {
|
||||
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
updated = updated || changed
|
||||
states = append(states, state)
|
||||
}
|
||||
if target.UpdateIPv6 {
|
||||
if !ipv6Enabled {
|
||||
states = append(states, "AAAA global deaktiviert")
|
||||
} 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
|
||||
}
|
||||
|
||||
func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zone, target model.Target, rrset hetzner.RRSet, recordType, ip string) (bool, string, error) {
|
||||
if ip == "" {
|
||||
return false, recordType + " nicht verfügbar", nil
|
||||
}
|
||||
if rrset.Name == "" {
|
||||
ttl := target.TTL
|
||||
if ttl < 60 {
|
||||
ttl = 60
|
||||
}
|
||||
if err := s.provider.CreateRRSet(ctx, token, zone.ProviderID, target.RecordName, recordType, ttl, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
return true, recordType + " angelegt", nil
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return false, "", errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
if len(rrset.Records) != 1 {
|
||||
return false, "", fmt.Errorf("%s enthält %d Records und wird aus Sicherheitsgründen nicht geändert", rrset.Name, len(rrset.Records))
|
||||
}
|
||||
ttlChanged := false
|
||||
if rrset.TTL == nil || *rrset.TTL != targetTTL(target.TTL) {
|
||||
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, target.RecordName, recordType, targetTTL(target.TTL)); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
ttlChanged = true
|
||||
}
|
||||
current := rrset.Records[0]
|
||||
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
|
||||
}
|
||||
status := recordType + " Kommentar synchronisiert"
|
||||
if ttlChanged {
|
||||
status += " · TTL aktualisiert"
|
||||
}
|
||||
return true, status, nil
|
||||
}
|
||||
if ttlChanged {
|
||||
return true, recordType + " TTL aktualisiert", nil
|
||||
}
|
||||
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
|
||||
}
|
||||
status := recordType + " aktualisiert"
|
||||
if ttlChanged {
|
||||
status += " · TTL aktualisiert"
|
||||
}
|
||||
return true, status, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) SyncZone(ctx context.Context, zoneID int64) (int, error) {
|
||||
zone, credential, err := s.store.GetZoneWithCredential(ctx, zoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
_ = s.store.MarkZoneSynced(ctx, zone.ID, safeError(err))
|
||||
return 0, err
|
||||
}
|
||||
type imported struct {
|
||||
ipv4, ipv6 bool
|
||||
description string
|
||||
ttl int
|
||||
}
|
||||
grouped := map[string]*imported{}
|
||||
var cnames []model.CNAMERecord
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Type == "CNAME" {
|
||||
if len(rrset.Records) != 1 {
|
||||
continue
|
||||
}
|
||||
ttl := targetTTL(60)
|
||||
if rrset.TTL != nil {
|
||||
ttl = targetTTL(*rrset.TTL)
|
||||
}
|
||||
cnames = append(cnames, model.CNAMERecord{
|
||||
ZoneID: zone.ID,
|
||||
Active: true,
|
||||
RecordName: normalRecordName(rrset.Name),
|
||||
FQDN: fqdn(rrset.Name, zone.Name),
|
||||
Destination: rrset.Records[0].Value,
|
||||
Description: rrset.Records[0].Comment,
|
||||
TTL: ttl,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if rrset.Type != "A" && rrset.Type != "AAAA" {
|
||||
continue
|
||||
}
|
||||
entry := grouped[rrset.Name]
|
||||
if entry == nil {
|
||||
entry = &imported{ttl: 60}
|
||||
grouped[rrset.Name] = entry
|
||||
}
|
||||
if rrset.Type == "A" {
|
||||
entry.ipv4 = true
|
||||
} else {
|
||||
entry.ipv6 = true
|
||||
}
|
||||
if rrset.TTL != nil && *rrset.TTL >= 60 {
|
||||
entry.ttl = *rrset.TTL
|
||||
}
|
||||
if entry.description == "" && len(rrset.Records) > 0 {
|
||||
entry.description = rrset.Records[0].Comment
|
||||
}
|
||||
}
|
||||
added := 0
|
||||
for name, entry := range grouped {
|
||||
target := model.Target{ZoneID: zone.ID, RecordName: normalRecordName(name), FQDN: fqdn(name, zone.Name), UpdateIPv4: entry.ipv4, UpdateIPv6: entry.ipv6, Description: entry.description, TTL: entry.ttl}
|
||||
isNew, err := s.store.UpsertImportedTarget(ctx, target)
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
if isNew {
|
||||
added++
|
||||
continue
|
||||
}
|
||||
if err := s.store.UpdateImportedTargetTTL(ctx, zone.ID, target.RecordName, target.TTL); err != nil {
|
||||
return added, err
|
||||
}
|
||||
}
|
||||
if err := s.store.ReplaceCNAMERecords(ctx, zone.ID, cnames); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_ = s.store.MarkZoneSynced(ctx, zone.ID, "")
|
||||
_ = s.store.AddAudit(ctx, "zone.import", fmt.Sprintf("%s: %d neue A/AAAA-Ziele importiert", zone.Name, added))
|
||||
return added, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) SyncTargetComment(ctx context.Context, target model.Target) error {
|
||||
zone, credential, err := s.store.GetZoneWithCredential(ctx, target.ZoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Name != target.RecordName || (rrset.Type != "A" && rrset.Type != "AAAA") {
|
||||
continue
|
||||
}
|
||||
if (rrset.Type == "A" && !target.UpdateIPv4) || (rrset.Type == "AAAA" && !target.UpdateIPv6) || len(rrset.Records) != 1 {
|
||||
continue
|
||||
}
|
||||
if rrset.Records[0].Comment != target.Description {
|
||||
if err := s.provider.UpdateRecordComments(ctx, token, zone.ProviderID, target.RecordName, rrset.Type, []hetzner.Record{{Value: rrset.Records[0].Value, Comment: target.Description}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncTargetTTL applies a changed TTL immediately when the selected A/AAAA
|
||||
// RRsets already exist. Missing RRsets will receive the same TTL when the
|
||||
// DynDNS worker creates them on its next run.
|
||||
func (s *Scheduler) SyncTargetTTL(ctx context.Context, target model.Target) error {
|
||||
zone, token, err := s.zoneToken(ctx, target.ZoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ttl := targetTTL(target.TTL)
|
||||
var recordTypes []string
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Name != target.RecordName {
|
||||
continue
|
||||
}
|
||||
if (rrset.Type == "A" && !target.UpdateIPv4) || (rrset.Type == "AAAA" && !target.UpdateIPv6) || (rrset.Type != "A" && rrset.Type != "AAAA") {
|
||||
continue
|
||||
}
|
||||
if rrset.TTL != nil && *rrset.TTL == ttl {
|
||||
continue
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
recordTypes = append(recordTypes, rrset.Type)
|
||||
}
|
||||
for _, recordType := range recordTypes {
|
||||
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, target.RecordName, recordType, ttl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncStaticTarget writes manually entered addresses while DynDNS is disabled.
|
||||
// It deliberately reuses the guarded RRSet update path used by the updater:
|
||||
// protected or multi-record RRSets are left untouched.
|
||||
func (s *Scheduler) SyncStaticTarget(ctx context.Context, target model.Target) (bool, string, error) {
|
||||
zone, token, err := s.zoneToken(ctx, target.ZoneID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
byKey := make(map[string]hetzner.RRSet, len(rrsets))
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Type == "A" || rrset.Type == "AAAA" {
|
||||
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 {
|
||||
return false, "Keine feste IP eingetragen", nil
|
||||
}
|
||||
return updated, strings.Join(states, " · "), nil
|
||||
}
|
||||
|
||||
// DeleteTargetFamilies removes selected A/AAAA RRSets while keeping the local
|
||||
// target itself. This lets users temporarily disable every family without
|
||||
// losing its configuration.
|
||||
func (s *Scheduler) DeleteTargetFamilies(ctx context.Context, target model.Target, deleteIPv4, deleteIPv6 bool) (int, error) {
|
||||
if !deleteIPv4 && !deleteIPv6 {
|
||||
return 0, nil
|
||||
}
|
||||
zone, token, err := s.zoneToken(ctx, target.ZoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var recordTypes []string
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Name != target.RecordName || (rrset.Type != "A" && rrset.Type != "AAAA") {
|
||||
continue
|
||||
}
|
||||
if (rrset.Type == "A" && !deleteIPv4) || (rrset.Type == "AAAA" && !deleteIPv6) {
|
||||
continue
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return 0, errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
recordTypes = append(recordTypes, rrset.Type)
|
||||
}
|
||||
for _, recordType := range recordTypes {
|
||||
if err := s.provider.DeleteRRSet(ctx, token, zone.ProviderID, target.RecordName, recordType); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(recordTypes), nil
|
||||
}
|
||||
|
||||
// DeleteZoneRecords removes every A, AAAA and CNAME RRSet represented by the
|
||||
// supplied local records. Other provider records in the zone remain untouched.
|
||||
// Protected RRsets are rejected before the first remote deletion.
|
||||
func (s *Scheduler) DeleteZoneRecords(ctx context.Context, zoneID int64, targets []model.Target, cnames []model.CNAMERecord) (int, error) {
|
||||
zone, token, err := s.zoneToken(ctx, zoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
managed := make(map[string]struct{}, len(targets)*2+len(cnames))
|
||||
for _, target := range targets {
|
||||
managed[rrKey(target.RecordName, "A")] = struct{}{}
|
||||
managed[rrKey(target.RecordName, "AAAA")] = struct{}{}
|
||||
}
|
||||
for _, record := range cnames {
|
||||
managed[rrKey(record.RecordName, "CNAME")] = struct{}{}
|
||||
}
|
||||
deletions := make([]hetzner.RRSet, 0, len(managed))
|
||||
for _, rrset := range rrsets {
|
||||
if _, found := managed[rrKey(rrset.Name, rrset.Type)]; !found {
|
||||
continue
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return 0, fmt.Errorf("das RRset %s (%s) ist bei Hetzner gegen Änderungen geschützt", fqdn(rrset.Name, zone.Name), rrset.Type)
|
||||
}
|
||||
deletions = append(deletions, rrset)
|
||||
}
|
||||
deleted := 0
|
||||
for _, rrset := range deletions {
|
||||
if err := s.provider.DeleteRRSet(ctx, token, zone.ProviderID, rrset.Name, rrset.Type); err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// DeleteIPv6Targets removes all managed AAAA RRSets before global IPv6 is
|
||||
// disabled. All protected RRSets are detected before the first deletion.
|
||||
func (s *Scheduler) DeleteIPv6Targets(ctx context.Context, targets []model.Target) (int, error) {
|
||||
type zoneState struct {
|
||||
zone model.Zone
|
||||
token string
|
||||
rrsets map[string]hetzner.RRSet
|
||||
}
|
||||
type deletion struct {
|
||||
zone model.Zone
|
||||
token string
|
||||
name string
|
||||
}
|
||||
states := make(map[int64]zoneState)
|
||||
deletions := make([]deletion, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
state, found := states[target.ZoneID]
|
||||
if !found {
|
||||
zone, token, err := s.zoneToken(ctx, target.ZoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
state = zoneState{zone: zone, token: token, rrsets: make(map[string]hetzner.RRSet, len(rrsets))}
|
||||
for _, rrset := range rrsets {
|
||||
state.rrsets[rrKey(rrset.Name, rrset.Type)] = rrset
|
||||
}
|
||||
states[target.ZoneID] = state
|
||||
}
|
||||
rrset, exists := state.rrsets[rrKey(target.RecordName, "AAAA")]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return 0, errors.New("mindestens ein AAAA-Record ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
deletions = append(deletions, deletion{zone: state.zone, token: state.token, name: target.RecordName})
|
||||
}
|
||||
for _, item := range deletions {
|
||||
if err := s.provider.DeleteRRSet(ctx, item.token, item.zone.ProviderID, item.name, "AAAA"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(deletions), nil
|
||||
}
|
||||
|
||||
// DisableIPv6 removes managed AAAA RRSets and disables IPv6 on every linked
|
||||
// local target. It is safe to run when IPv6 is already disabled.
|
||||
func (s *Scheduler) DisableIPv6(ctx context.Context) (int, error) {
|
||||
targets, err := s.store.ListTargetsWithIPv6(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted, err := s.DeleteIPv6Targets(ctx, targets)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := s.store.DisableIPv6ForTargets(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// SyncZoneTTL updates the TTL of every RRSet this application manages in a
|
||||
// zone. It preflights protected or stale CNAME RRSets before changing any
|
||||
// remote value, while missing DynDNS RRSets keep the new TTL locally for their
|
||||
// eventual creation.
|
||||
func (s *Scheduler) SyncZoneTTL(ctx context.Context, zoneID int64, targets []model.Target, cnames []model.CNAMERecord, ttl int) (int, error) {
|
||||
zone, token, err := s.zoneToken(ctx, zoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
byKey := make(map[string]hetzner.RRSet, len(rrsets))
|
||||
for _, rrset := range rrsets {
|
||||
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
|
||||
}
|
||||
type change struct {
|
||||
name string
|
||||
recordType string
|
||||
}
|
||||
changes := make([]change, 0, len(targets)*2+len(cnames))
|
||||
seen := make(map[string]bool, cap(changes))
|
||||
queue := func(name, recordType string, required bool) error {
|
||||
key := rrKey(name, recordType)
|
||||
rrset, found := byKey[key]
|
||||
if !found {
|
||||
if required {
|
||||
return fmt.Errorf("der CNAME %s wurde bei Hetzner nicht gefunden; lies die Zone erneut ein", fqdn(name, zone.Name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rrset.TTL != nil && *rrset.TTL == targetTTL(ttl) {
|
||||
return nil
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return errors.New("mindestens ein RRset ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
changes = append(changes, change{name: name, recordType: recordType})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, target := range targets {
|
||||
if target.UpdateIPv4 {
|
||||
if err := queue(target.RecordName, "A", false); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if target.UpdateIPv6 {
|
||||
if err := queue(target.RecordName, "AAAA", false); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, record := range cnames {
|
||||
if !record.Active {
|
||||
continue
|
||||
}
|
||||
if err := queue(record.RecordName, "CNAME", true); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
for _, item := range changes {
|
||||
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, item.name, item.recordType, targetTTL(ttl)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(changes), nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) CreateCNAME(ctx context.Context, record model.CNAMERecord) error {
|
||||
zone, token, err := s.zoneToken(ctx, record.ZoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.provider.CreateRRSet(ctx, token, zone.ProviderID, record.RecordName, "CNAME", targetTTL(record.TTL), []hetzner.Record{{Value: record.Destination, Comment: record.Description}})
|
||||
}
|
||||
|
||||
func (s *Scheduler) UpdateCNAME(ctx context.Context, previous, record model.CNAMERecord) error {
|
||||
if previous.ZoneID != record.ZoneID || previous.RecordName != record.RecordName {
|
||||
return errors.New("der Name eines CNAME-Records kann nicht geändert werden")
|
||||
}
|
||||
if previous.Destination == record.Destination && previous.Description == record.Description && previous.TTL == record.TTL {
|
||||
return nil
|
||||
}
|
||||
zone, token, err := s.zoneToken(ctx, record.ZoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var current *hetzner.RRSet
|
||||
for index := range rrsets {
|
||||
if rrsets[index].Name == record.RecordName && rrsets[index].Type == "CNAME" {
|
||||
current = &rrsets[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if current == nil {
|
||||
return errors.New("der CNAME-Record wurde bei Hetzner nicht gefunden; lies die Zone erneut ein")
|
||||
}
|
||||
if current.Protection.Change {
|
||||
return errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
if previous.TTL != record.TTL {
|
||||
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, record.RecordName, "CNAME", targetTTL(record.TTL)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if previous.Destination != record.Destination || previous.Description != record.Description {
|
||||
if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, record.RecordName, "CNAME", []hetzner.Record{{Value: record.Destination, Comment: record.Description}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) DeleteCNAME(ctx context.Context, record model.CNAMERecord) error {
|
||||
zone, token, err := s.zoneToken(ctx, record.ZoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.provider.DeleteRRSet(ctx, token, zone.ProviderID, record.RecordName, "CNAME")
|
||||
}
|
||||
|
||||
func (s *Scheduler) zoneToken(ctx context.Context, zoneID int64) (model.Zone, string, error) {
|
||||
zone, credential, err := s.store.GetZoneWithCredential(ctx, zoneID)
|
||||
if err != nil {
|
||||
return model.Zone{}, "", err
|
||||
}
|
||||
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||
if err != nil {
|
||||
return model.Zone{}, "", err
|
||||
}
|
||||
return zone, token, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) ValidateEndpoints(ctx context.Context, endpoints []string) error {
|
||||
for _, endpoint := range endpoints {
|
||||
if err := s.resolver.ValidateEndpoint(ctx, endpoint); err != nil {
|
||||
return fmt.Errorf("%s: %w", endpoint, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) ValidateToken(ctx context.Context, token string) ([]hetzner.Zone, error) {
|
||||
return s.provider.ListZones(ctx, token)
|
||||
}
|
||||
|
||||
func (s *Scheduler) Provider() *hetzner.Client { return s.provider }
|
||||
|
||||
func rrKey(name, recordType string) string { return normalRecordName(name) + "|" + recordType }
|
||||
|
||||
func targetTTL(ttl int) int {
|
||||
if ttl < 60 {
|
||||
return 60
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func normalRecordName(name string) string {
|
||||
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
|
||||
if name == "" || name == "@" {
|
||||
return "@"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func fqdn(name, zone string) string {
|
||||
if normalRecordName(name) == "@" {
|
||||
return zone
|
||||
}
|
||||
return normalRecordName(name) + "." + zone
|
||||
}
|
||||
|
||||
func safeError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
message := err.Error()
|
||||
if len(message) > 300 {
|
||||
return message[:300]
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/zones/123/rrsets" || r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"rrsets": [
|
||||
{"name":"@","type":"A","ttl":60,"records":[{"value":"198.51.100.10","comment":"Apex"}]},
|
||||
{"name":"home","type":"A","ttl":120,"records":[{"value":"198.51.100.11","comment":"Router"}]},
|
||||
{"name":"home","type":"AAAA","ttl":120,"records":[{"value":"2001:db8::10","comment":"Router"}]},
|
||||
{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"example.de."}]}
|
||||
],
|
||||
"meta":{"pagination":{"next_page":null}}
|
||||
}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
keyFile := filepath.Join(dir, "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)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
added, err := schedule.SyncZone(ctx, zone.ID)
|
||||
if err != nil || added != 2 {
|
||||
t.Fatalf("expected apex and home to be imported, added=%d err=%v", added, err)
|
||||
}
|
||||
targets, err := database.ListTargets(ctx, zone.ID)
|
||||
if err != nil || len(targets) != 2 {
|
||||
t.Fatalf("expected two address targets, got %#v err=%v", targets, err)
|
||||
}
|
||||
var homeID int64
|
||||
for _, target := range targets {
|
||||
if target.RecordName == "home" {
|
||||
homeID = target.ID
|
||||
if !target.UpdateIPv4 || !target.UpdateIPv6 || target.Active || target.Description != "Router" || target.TTL != 120 {
|
||||
t.Fatalf("home target was not grouped safely: %#v", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
if homeID == 0 {
|
||||
t.Fatal("home target missing")
|
||||
}
|
||||
cnames, err := database.ListCNAMERecords(ctx, zone.ID)
|
||||
if err != nil || len(cnames) != 1 || !cnames[0].Active || cnames[0].RecordName != "www" || cnames[0].Destination != "example.de." || cnames[0].TTL != 300 {
|
||||
t.Fatalf("CNAME was not imported: %#v err=%v", cnames, err)
|
||||
}
|
||||
home, err := database.GetTarget(ctx, homeID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home.TTL = 60
|
||||
if err := database.UpdateTarget(ctx, home); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
added, err = schedule.SyncZone(ctx, zone.ID)
|
||||
if err != nil || added != 0 {
|
||||
t.Fatalf("TTL refresh import failed: added=%d err=%v", added, err)
|
||||
}
|
||||
home, err = database.GetTarget(ctx, homeID)
|
||||
if err != nil || home.TTL != 120 {
|
||||
t.Fatalf("provider TTL was not refreshed on existing target: %#v err=%v", home, err)
|
||||
}
|
||||
if err := database.ArchiveTarget(ctx, homeID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
added, err = schedule.SyncZone(ctx, zone.ID)
|
||||
if err != nil || added != 0 {
|
||||
t.Fatalf("re-import should not restore archived target, added=%d err=%v", added, err)
|
||||
}
|
||||
linked, _ := database.ListTargets(ctx, zone.ID)
|
||||
archived, _ := database.ListArchivedTargets(ctx, zone.ID)
|
||||
if len(linked) != 1 || len(archived) != 1 || archived[0].ID != homeID {
|
||||
t.Fatalf("archive state changed during provider import: linked=%#v archived=%#v", linked, archived)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(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() })
|
||||
settings, err := database.GetSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(ctx, settings.UpdateIntervalSeconds, false, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
if _, err := schedule.Run(ctx); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("disabled updater accepted a run: %v", err)
|
||||
}
|
||||
runs, err := database.RecentRuns(ctx, 10)
|
||||
if err != nil || len(runs) != 0 {
|
||||
t.Fatalf("disabled run must not create history: %#v err=%v", runs, err)
|
||||
}
|
||||
|
||||
schedule.nextRun.Store(time.Now().Add(time.Hour).Unix())
|
||||
schedule.Trigger()
|
||||
if !schedule.NextRunAt().IsZero() {
|
||||
t.Fatalf("trigger did not reset the visible countdown: %v", schedule.NextRunAt())
|
||||
}
|
||||
select {
|
||||
case <-schedule.wake:
|
||||
default:
|
||||
t.Fatal("trigger did not wake the scheduler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) {
|
||||
t.Parallel()
|
||||
schedule := &Scheduler{}
|
||||
changed, status, err := schedule.syncTarget(
|
||||
context.Background(),
|
||||
"unused-token",
|
||||
model.Zone{ProviderID: "123"},
|
||||
model.Target{RecordName: "home", UpdateIPv6: true},
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
)
|
||||
if err != nil || changed || status != "AAAA global deaktiviert" {
|
||||
t.Fatalf("disabled IPv6 was not skipped cleanly: changed=%v status=%q err=%v", changed, status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTargetTTLUpdatesExistingRRSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
changes := make(chan int, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/zones/123/rrsets":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]}],"meta":{"pagination":{"next_page":null}}}`))
|
||||
case "/zones/123/rrsets/home/A/actions/change_ttl":
|
||||
var body struct {
|
||||
TTL int `json:"ttl"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
changes <- body.TTL
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(dir, "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)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
if err := schedule.SyncTargetTTL(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, TTL: 300}); err != nil {
|
||||
t.Fatalf("sync target TTL: %v", err)
|
||||
}
|
||||
select {
|
||||
case ttl := <-changes:
|
||||
if ttl != 300 {
|
||||
t.Fatalf("unexpected changed TTL: %d", ttl)
|
||||
}
|
||||
default:
|
||||
t.Fatal("TTL action was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncStaticTargetWritesManualAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
actions := make(chan string, 2)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/zones/123/rrsets":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11","comment":"Old"}]}],"meta":{"pagination":{"next_page":null}}}`))
|
||||
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records":
|
||||
actions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(dir, "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)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
changed, status, err := schedule.SyncStaticTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, StaticIPv4: "203.0.113.25", Description: "Manual", TTL: 300})
|
||||
if err != nil || !changed || status != "A aktualisiert · TTL aktualisiert" {
|
||||
t.Fatalf("static target was not synchronized: changed=%v status=%q err=%v", changed, status, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for range 2 {
|
||||
seen[<-actions] = true
|
||||
}
|
||||
for _, expected := range []string{"/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records"} {
|
||||
if !seen[expected] {
|
||||
t.Errorf("missing static update action %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncZoneTTLUpdatesManagedAddressAndCNAMERecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
actions := make(chan string, 2)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/zones/123/rrsets":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"home.example.de."}]}],"meta":{"pagination":{"next_page":null}}}`))
|
||||
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl":
|
||||
actions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(dir, "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)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
changed, err := schedule.SyncZoneTTL(ctx, zone.ID, []model.Target{{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true}}, []model.CNAMERecord{{ZoneID: zone.ID, Active: true, RecordName: "www"}}, 600)
|
||||
if err != nil || changed != 2 {
|
||||
t.Fatalf("zone TTL was not synchronized: changed=%d err=%v", changed, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for range 2 {
|
||||
seen[<-actions] = true
|
||||
}
|
||||
for _, expected := range []string{"/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl"} {
|
||||
if !seen[expected] {
|
||||
t.Errorf("missing bulk TTL action %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTargetFamiliesAndGlobalIPv6Cleanup(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
deletions := make(chan string, 2)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/zones/123/rrsets":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::11"}]}],"meta":{"pagination":{"next_page":null}}}`))
|
||||
case "/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA":
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "unexpected method", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
deletions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(dir, "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)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, UpdateIPv6: true}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
deleted, err := schedule.DeleteTargetFamilies(ctx, target, true, false)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("A record was not deleted: deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
deleted, err = schedule.DeleteIPv6Targets(ctx, []model.Target{target})
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("global AAAA cleanup failed: deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for range 2 {
|
||||
seen[<-deletions] = true
|
||||
}
|
||||
for _, expected := range []string{"/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA"} {
|
||||
if !seen[expected] {
|
||||
t.Errorf("missing delete request %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteZoneRecordsRemovesOnlyLocallyRepresentedRRSets(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
deletions := make(chan string, 4)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/zones/123/rrsets":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"rrsets":[
|
||||
{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},
|
||||
{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::11"}]},
|
||||
{"name":"legacy","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::12"}]},
|
||||
{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"home.example.de."}]},
|
||||
{"name":"mail","type":"CNAME","ttl":300,"records":[{"value":"external.example."}]},
|
||||
{"name":"@","type":"TXT","ttl":300,"records":[{"value":"unmanaged"}]}
|
||||
],"meta":{"pagination":{"next_page":null}}}`))
|
||||
case "/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA", "/zones/123/rrsets/legacy/AAAA", "/zones/123/rrsets/www/CNAME":
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "unexpected method", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
deletions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(dir, "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)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
deleted, err := schedule.DeleteZoneRecords(
|
||||
ctx,
|
||||
zone.ID,
|
||||
[]model.Target{{RecordName: "home"}, {RecordName: "legacy"}},
|
||||
[]model.CNAMERecord{{RecordName: "www"}},
|
||||
)
|
||||
if err != nil || deleted != 4 {
|
||||
t.Fatalf("managed zone records were not deleted: deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for range 4 {
|
||||
seen[<-deletions] = true
|
||||
}
|
||||
for _, expected := range []string{"/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA", "/zones/123/rrsets/legacy/AAAA", "/zones/123/rrsets/www/CNAME"} {
|
||||
if !seen[expected] {
|
||||
t.Errorf("missing delete request %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user