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
@@ -11,6 +11,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -22,6 +23,187 @@ import (
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
type resolverFunc func(context.Context) ipresolve.Result
|
||||
|
||||
func (f resolverFunc) ResolveWithIPv6(ctx context.Context, _, _ []string, _ bool) ipresolve.Result {
|
||||
return f(ctx)
|
||||
}
|
||||
func (f resolverFunc) ValidateEndpoint(context.Context, string) error { return nil }
|
||||
|
||||
func TestOneFailedFamilyDoesNotBlockTheOther(t *testing.T) {
|
||||
for _, protectedFamily := range []string{"A", "AAAA"} {
|
||||
t.Run(protectedFamily, func(t *testing.T) {
|
||||
var writes atomic.Int32
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/"+protectedFamily+"/") {
|
||||
t.Error("wrote protected family")
|
||||
}
|
||||
writes.Add(1)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
ttl := 60
|
||||
rrsets := map[string]hetzner.RRSet{}
|
||||
for _, family := range []string{"A", "AAAA"} {
|
||||
rrset := hetzner.RRSet{Name: "home", Type: family, TTL: &ttl, Records: []hetzner.Record{{Value: "old"}}}
|
||||
rrset.Protection.Change = family == protectedFamily
|
||||
rrsets[rrKey("home", family)] = rrset
|
||||
}
|
||||
schedule := New(nil, hetzner.New(api.URL), nil, nil, nil)
|
||||
changed, status, err := schedule.syncTarget(context.Background(), "token", model.Zone{ProviderID: "123"}, model.Target{RecordName: "home", TTL: 60, UpdateIPv4: true, UpdateIPv6: true}, rrsets, "198.51.100.42", "2001:db8::42", true)
|
||||
if !changed || err == nil || !strings.Contains(err.Error(), protectedFamily+":") || !strings.Contains(status, "aktualisiert") || writes.Load() != 1 {
|
||||
t.Fatalf("partial update lost: changed=%v status=%q err=%v writes=%d", changed, status, err, writes.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersistsConfirmedPartialTargetChanges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
cipher, err := secure.LoadOrCreateCipher(filepath.Join(dir, "secrets", "key"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.org")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.org", Active: true, UpdateIPv4: true, UpdateIPv6: true, TTL: 60})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(ctx, 300, true, true, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var writes atomic.Int32
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"protection":{"change":true},"records":[{"value":"198.51.100.1"}]},{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::1"}]}]}`))
|
||||
} else {
|
||||
if r.URL.Path != "/zones/123/rrsets/home/AAAA/actions/set_records" {
|
||||
t.Errorf("unexpected write: %s", r.URL.Path)
|
||||
}
|
||||
writes.Add(1)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
worker := New(database, hetzner.New(api.URL), nil, cipher, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
worker.resolver = resolverFunc(func(context.Context) ipresolve.Result {
|
||||
return ipresolve.Result{IPv4: "198.51.100.42", IPv6: "2001:db8::42"}
|
||||
})
|
||||
summary, err := worker.Run(ctx)
|
||||
if err == nil || summary != "1 Ziel(e) aktualisiert, 1 Fehler" || writes.Load() != 1 {
|
||||
t.Fatalf("incorrect partial result: %q %v writes=%d", summary, err, writes.Load())
|
||||
}
|
||||
target, err = database.GetTarget(ctx, target.ID)
|
||||
if err != nil || target.LastUpdatedAt.IsZero() || target.LastError == "" {
|
||||
t.Fatalf("confirmed change or error lost: %#v %v", target, err)
|
||||
}
|
||||
runs, err := database.RecentRuns(ctx, 1)
|
||||
if err != nil || len(runs) != 1 || runs[0].Status != "partial" {
|
||||
t.Fatalf("wrong partial run status: %#v %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledRunFinishesHistoryAndReleasesWorker(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
settings, err := database.GetSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(context.Background(), 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
schedule.resolver = resolverFunc(func(context.Context) ipresolve.Result { cancel(); return ipresolve.Result{IPv4Err: context.Canceled} })
|
||||
if _, err := schedule.Run(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("lost cancellation: %v", err)
|
||||
}
|
||||
runs, err := database.RecentRuns(context.Background(), 10)
|
||||
if err != nil || len(runs) != 1 || runs[0].Status != "error" || runs[0].FinishedAt.IsZero() || schedule.IsRunning() {
|
||||
t.Fatalf("canceled run stuck: %#v err=%v", runs, err)
|
||||
}
|
||||
schedule.resolver = resolverFunc(func(context.Context) ipresolve.Result { return ipresolve.Result{IPv4: "198.51.100.42"} })
|
||||
if _, err := schedule.Run(context.Background()); err != nil {
|
||||
t.Fatalf("worker did not recover: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSChangesAndUpdaterCannotOverlap(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
settings, err := database.GetSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(context.Background(), 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
unlock, err := schedule.LockChanges(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := schedule.Run(context.Background()); !errors.Is(err, ErrRunInProgress) {
|
||||
t.Fatalf("updater entered active edit: %v", err)
|
||||
}
|
||||
unlock()
|
||||
entered := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
schedule.resolver = resolverFunc(func(ctx context.Context) ipresolve.Result {
|
||||
close(entered)
|
||||
<-ctx.Done()
|
||||
return ipresolve.Result{IPv4Err: ctx.Err()}
|
||||
})
|
||||
schedule.Start(ctx)
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("worker did not start")
|
||||
}
|
||||
waitCtx, stop := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer stop()
|
||||
if release, err := schedule.LockChanges(waitCtx); err == nil {
|
||||
release()
|
||||
t.Fatal("edit entered active updater")
|
||||
}
|
||||
cancel()
|
||||
shutdownCtx, stopShutdown := context.WithTimeout(context.Background(), time.Second)
|
||||
defer stopShutdown()
|
||||
if err := schedule.Wait(shutdownCtx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release, err := schedule.LockChanges(shutdownCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("shutdown left lock held: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
type retryResolver struct {
|
||||
calls atomic.Int32
|
||||
recovered chan struct{}
|
||||
@@ -238,7 +420,7 @@ func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
|
||||
func TestRetryDelayStaysWithinOneMinuteEvenWithLongSchedule(t *testing.T) {
|
||||
t.Parallel()
|
||||
schedule := &Scheduler{retryMin: 5 * time.Second}
|
||||
tests := []struct {
|
||||
@@ -253,7 +435,7 @@ func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
|
||||
{failures: 20, want: time.Minute},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := schedule.retryDelay(test.failures, time.Minute); got != test.want {
|
||||
if got := schedule.retryDelay(test.failures, time.Hour); got != test.want {
|
||||
t.Errorf("retry delay after %d failures = %s, want %s", test.failures, got, test.want)
|
||||
}
|
||||
}
|
||||
@@ -296,6 +478,7 @@ func TestSyncTargetTTLUpdatesExistingRRSet(t *testing.T) {
|
||||
}
|
||||
changes <- body.TTL
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
@@ -359,6 +542,7 @@ func TestSyncStaticTargetWritesManualAddress(t *testing.T) {
|
||||
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)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
@@ -424,6 +608,7 @@ func TestSyncZoneTTLUpdatesManagedAddressAndCNAMERecords(t *testing.T) {
|
||||
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)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
@@ -492,7 +677,8 @@ func TestDeleteTargetFamiliesAndGlobalIPv6Cleanup(t *testing.T) {
|
||||
return
|
||||
}
|
||||
deletions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
@@ -573,7 +759,8 @@ func TestDeleteZoneRecordsRemovesOnlyLocallyRepresentedRRSets(t *testing.T) {
|
||||
return
|
||||
}
|
||||
deletions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user