From 02c607fad86eb8eee79feb1aa43aab053c5a0b03 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 07:27:05 -0400 Subject: [PATCH] feat(dyndns): pure-Go IP-detect + DNS-update daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/plans/2026-05-20-dns-providers.md T14..T16. dyndns/dyndns.go (260 lines): - IPDetector interface: Detect + Name. - HTTPDetector default impl; DefaultDetectors() returns the three canonical sources (icanhazip / ifconfig.me / ipify.org). - Updater callback — provider-agnostic; caller wires DO/Namecheap/ Hover via the IaC ResourceDriver UpdateRecord RPC. - Config: PollInterval (≥30s), QuorumSize (default ceil(N/2)), MaxBackoff (default 1h), Now + Sleep injectable for tests. - Daemon.Tick(): detect-quorum → diff vs Current → Update on change. Concurrent-safe via mu. - Daemon.Run(): blocking loop until ctx cancel, with exponential backoff + ±10% jitter on consecutive failures. - detectQuorum: fan-out goroutines, tolerate per-detector errors, return first IP that QuorumSize detectors agree on. 12 unit tests: - Update fires on IP change, noop on unchanged. - Quorum: majority requirement, 2-of-3 success, single-detector-err tolerated. - Failure backoff escalates between consecutive ticks. - New() rejects nil Updater + <30s PollInterval. - Updater errors surface + leave Current() unset. - Run() exits on context cancel. - Concurrent Tick() calls are safe (10 goroutines). Co-Authored-By: Claude Opus 4.7 (1M context) --- dyndns/dyndns.go | 314 ++++++++++++++++++++++++++++++++++++++++++ dyndns/dyndns_test.go | 268 +++++++++++++++++++++++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 dyndns/dyndns.go create mode 100644 dyndns/dyndns_test.go diff --git a/dyndns/dyndns.go b/dyndns/dyndns.go new file mode 100644 index 00000000..7eb9f0a7 --- /dev/null +++ b/dyndns/dyndns.go @@ -0,0 +1,314 @@ +// Package dyndns implements a dynamic-DNS daemon that periodically +// detects a host's public IP and pushes updates to a DNS provider +// when the IP changes. +// +// Per docs/plans/2026-05-20-dns-providers.md T14..T16. +// +// The package is intentionally provider-agnostic: callers supply an +// Updater closure that talks to their DNS driver of choice (DO, +// Namecheap, Hover, etc.) via wfctl's existing infra.dns surface. +package dyndns + +import ( + "context" + "errors" + "fmt" + "io" + "math/rand/v2" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// IPDetector returns the public IP this host appears to be reaching +// the internet from. Implementations should be lightweight; a single +// HTTPS GET is the canonical shape. +type IPDetector interface { + Detect(ctx context.Context) (net.IP, error) + Name() string +} + +// Updater applies the new IP to a DNS record. Implementations talk to +// a DNS provider (DO/Namecheap/Hover) via the wfctl IaC ResourceDriver. +// +// Called only when the detected IP differs from the previously-known +// value; idempotent re-runs are still safe. +type Updater func(ctx context.Context, ip net.IP) error + +// Config controls the daemon loop. +type Config struct { + // Detectors quorum the public IP. Default: HTTPDetector against + // icanhazip.com + ifconfig.me + ipify.org (need ≥ 2 agreeing). + Detectors []IPDetector + + // PollInterval is the steady-state interval between IP checks. + // Default 5m. Must be >= 30s. + PollInterval time.Duration + + // QuorumSize is the number of detectors that must agree before + // an update fires. Default = (len(Detectors)+1)/2 — simple + // majority. Set to 1 for single-source mode. + QuorumSize int + + // MaxBackoff caps the exponential backoff applied after + // consecutive Update failures. Default 1h. + MaxBackoff time.Duration + + // Update is the callback that applies a new IP to DNS. + Update Updater + + // Now is injectable for tests. Defaults to time.Now. + Now func() time.Time + + // Sleep is injectable for tests. Defaults to time.Sleep. + Sleep func(time.Duration) +} + +// Daemon runs the detect → diff → update loop. +type Daemon struct { + cfg Config + mu sync.Mutex + current net.IP + failures int + lastSuccess time.Time + totalUpdates int +} + +// New builds a Daemon. Returns an error if Config is missing fields. +func New(cfg Config) (*Daemon, error) { + if cfg.Update == nil { + return nil, errors.New("dyndns: Update callback required") + } + if len(cfg.Detectors) == 0 { + cfg.Detectors = DefaultDetectors() + } + if cfg.PollInterval == 0 { + cfg.PollInterval = 5 * time.Minute + } + if cfg.PollInterval < 30*time.Second { + return nil, fmt.Errorf("dyndns: PollInterval %v < 30s minimum", cfg.PollInterval) + } + if cfg.QuorumSize == 0 { + cfg.QuorumSize = (len(cfg.Detectors) + 1) / 2 + if cfg.QuorumSize < 1 { + cfg.QuorumSize = 1 + } + } + if cfg.QuorumSize > len(cfg.Detectors) { + return nil, fmt.Errorf("dyndns: QuorumSize %d > %d detectors", cfg.QuorumSize, len(cfg.Detectors)) + } + if cfg.MaxBackoff == 0 { + cfg.MaxBackoff = 1 * time.Hour + } + if cfg.Now == nil { + cfg.Now = time.Now + } + if cfg.Sleep == nil { + cfg.Sleep = time.Sleep + } + return &Daemon{cfg: cfg}, nil +} + +// Current returns the most recently confirmed IP. Empty until first +// successful detection. +func (d *Daemon) Current() net.IP { + d.mu.Lock() + defer d.mu.Unlock() + if d.current == nil { + return nil + } + cp := make(net.IP, len(d.current)) + copy(cp, d.current) + return cp +} + +// TotalUpdates reports the cumulative count of successful Update calls. +func (d *Daemon) TotalUpdates() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.totalUpdates +} + +// Tick executes one detect/diff/update cycle. Tests call this +// directly to bypass the timer; Run() invokes it in a loop. +func (d *Daemon) Tick(ctx context.Context) error { + ip, err := d.detectQuorum(ctx) + if err != nil { + d.recordFailure() + return err + } + + d.mu.Lock() + currentSame := d.current != nil && d.current.Equal(ip) + d.mu.Unlock() + if currentSame { + d.recordSuccess() + return nil + } + + if err := d.cfg.Update(ctx, ip); err != nil { + d.recordFailure() + return fmt.Errorf("dyndns: update IP %s: %w", ip, err) + } + + d.mu.Lock() + d.current = ip + d.totalUpdates++ + d.mu.Unlock() + d.recordSuccess() + return nil +} + +// Run blocks until ctx is cancelled, ticking every PollInterval. +// Backoff applies after consecutive failures. +func (d *Daemon) Run(ctx context.Context) error { + for { + if err := d.Tick(ctx); err != nil { + // continue the loop; backoff is applied via nextSleep. + } + delay := d.nextSleep() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeAfter(delay): + } + } +} + +// detectQuorum runs every detector in parallel + returns the IP that +// at least QuorumSize detectors agree on. Errors from individual +// detectors are tolerated; only complete consensus-failure is fatal. +func (d *Daemon) detectQuorum(ctx context.Context) (net.IP, error) { + type result struct { + ip net.IP + name string + err error + } + results := make(chan result, len(d.cfg.Detectors)) + for _, det := range d.cfg.Detectors { + go func(det IPDetector) { + ip, err := det.Detect(ctx) + results <- result{ip: ip, name: det.Name(), err: err} + }(det) + } + tally := map[string]int{} + errs := []string{} + for i := 0; i < len(d.cfg.Detectors); i++ { + r := <-results + if r.err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", r.name, r.err)) + continue + } + if r.ip == nil { + continue + } + tally[r.ip.String()]++ + } + var winner string + for ipStr, votes := range tally { + if votes >= d.cfg.QuorumSize && votes > tally[winner] { + winner = ipStr + } + } + if winner == "" { + return nil, fmt.Errorf("dyndns: no IP reached quorum (%d/%d); errors: %s", d.cfg.QuorumSize, len(d.cfg.Detectors), strings.Join(errs, "; ")) + } + return net.ParseIP(winner), nil +} + +func (d *Daemon) recordSuccess() { + d.mu.Lock() + defer d.mu.Unlock() + d.failures = 0 + d.lastSuccess = d.cfg.Now() +} + +func (d *Daemon) recordFailure() { + d.mu.Lock() + defer d.mu.Unlock() + d.failures++ +} + +func (d *Daemon) nextSleep() time.Duration { + d.mu.Lock() + failures := d.failures + d.mu.Unlock() + if failures == 0 { + return d.cfg.PollInterval + } + // Exponential backoff: 2^n × PollInterval, capped at MaxBackoff, + // with ±10% jitter to avoid thundering herd. + base := d.cfg.PollInterval + for i := 0; i < failures && base < d.cfg.MaxBackoff; i++ { + base *= 2 + } + if base > d.cfg.MaxBackoff { + base = d.cfg.MaxBackoff + } + jitter := time.Duration(rand.Int64N(int64(base) / 5)) + if rand.IntN(2) == 0 { + base += jitter + } else { + base -= jitter + } + return base +} + +// timeAfter is injectable for tests but defaults to time.After. +var timeAfter = func(d time.Duration) <-chan time.Time { + return time.After(d) +} + +// HTTPDetector queries a simple "what's my IP" HTTP endpoint. +type HTTPDetector struct { + URL string + Label string + HTTP *http.Client +} + +// Detect implements IPDetector. +func (h HTTPDetector) Detect(ctx context.Context) (net.IP, error) { + client := h.HTTP + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.URL, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + s := strings.TrimSpace(string(body)) + ip := net.ParseIP(s) + if ip == nil { + return nil, fmt.Errorf("not an IP: %q", s) + } + return ip, nil +} + +// Name implements IPDetector. +func (h HTTPDetector) Name() string { + if h.Label != "" { + return h.Label + } + return h.URL +} + +// DefaultDetectors returns the three-source quorum used when no +// detectors are configured. +func DefaultDetectors() []IPDetector { + return []IPDetector{ + HTTPDetector{URL: "https://icanhazip.com", Label: "icanhazip"}, + HTTPDetector{URL: "https://ifconfig.me/ip", Label: "ifconfig.me"}, + HTTPDetector{URL: "https://api.ipify.org", Label: "ipify"}, + } +} diff --git a/dyndns/dyndns_test.go b/dyndns/dyndns_test.go new file mode 100644 index 00000000..fa1b8264 --- /dev/null +++ b/dyndns/dyndns_test.go @@ -0,0 +1,268 @@ +package dyndns + +import ( + "context" + "errors" + "net" + "strings" + "sync" + "testing" + "time" +) + +// stubDetector returns a fixed IP or err. +type stubDetector struct { + name string + ip net.IP + err error +} + +func (s stubDetector) Detect(_ context.Context) (net.IP, error) { + return s.ip, s.err +} +func (s stubDetector) Name() string { return s.name } + +func TestDaemon_Tick_UpdatesOnIPChange(t *testing.T) { + var calls int + var updatedIP net.IP + updater := func(_ context.Context, ip net.IP) error { + calls++ + updatedIP = ip + return nil + } + + d, err := New(Config{ + Detectors: []IPDetector{stubDetector{name: "a", ip: net.ParseIP("1.2.3.4")}}, + PollInterval: 30 * time.Second, + QuorumSize: 1, + Update: updater, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := d.Tick(context.Background()); err != nil { + t.Fatalf("first Tick: %v", err) + } + if calls != 1 { + t.Errorf("updater calls = %d want 1", calls) + } + if !updatedIP.Equal(net.ParseIP("1.2.3.4")) { + t.Errorf("updater IP = %v want 1.2.3.4", updatedIP) + } + if !d.Current().Equal(net.ParseIP("1.2.3.4")) { + t.Errorf("daemon current = %v", d.Current()) + } +} + +func TestDaemon_Tick_NoopWhenIPUnchanged(t *testing.T) { + calls := 0 + updater := func(_ context.Context, _ net.IP) error { + calls++ + return nil + } + d, _ := New(Config{ + Detectors: []IPDetector{stubDetector{name: "a", ip: net.ParseIP("5.6.7.8")}}, + PollInterval: 30 * time.Second, + QuorumSize: 1, + Update: updater, + }) + _ = d.Tick(context.Background()) + _ = d.Tick(context.Background()) + _ = d.Tick(context.Background()) + if calls != 1 { + t.Errorf("updater fired %d times; want 1 (subsequent ticks should be noops)", calls) + } + if d.TotalUpdates() != 1 { + t.Errorf("TotalUpdates = %d want 1", d.TotalUpdates()) + } +} + +func TestDaemon_Tick_QuorumRequiresMajority(t *testing.T) { + updater := func(_ context.Context, _ net.IP) error { return nil } + d, _ := New(Config{ + Detectors: []IPDetector{ + stubDetector{name: "a", ip: net.ParseIP("1.1.1.1")}, + stubDetector{name: "b", ip: net.ParseIP("2.2.2.2")}, + stubDetector{name: "c", ip: net.ParseIP("3.3.3.3")}, + }, + PollInterval: 30 * time.Second, + Update: updater, + }) + err := d.Tick(context.Background()) + if err == nil { + t.Fatal("expected quorum failure when no two detectors agree") + } + if !strings.Contains(err.Error(), "quorum") { + t.Errorf("err = %v; want quorum error", err) + } +} + +func TestDaemon_Tick_QuorumSatisfiedBy2Of3(t *testing.T) { + calls := 0 + updater := func(_ context.Context, _ net.IP) error { + calls++ + return nil + } + d, _ := New(Config{ + Detectors: []IPDetector{ + stubDetector{name: "a", ip: net.ParseIP("9.9.9.9")}, + stubDetector{name: "b", ip: net.ParseIP("9.9.9.9")}, + stubDetector{name: "c", ip: net.ParseIP("1.1.1.1")}, + }, + PollInterval: 30 * time.Second, + Update: updater, + }) + if err := d.Tick(context.Background()); err != nil { + t.Fatalf("Tick: %v", err) + } + if calls != 1 { + t.Errorf("expected 1 update on 2-of-3 quorum; got %d", calls) + } + if !d.Current().Equal(net.ParseIP("9.9.9.9")) { + t.Errorf("Current = %v want 9.9.9.9", d.Current()) + } +} + +func TestDaemon_Tick_TolerateOneDetectorErr(t *testing.T) { + d, _ := New(Config{ + Detectors: []IPDetector{ + stubDetector{name: "a", ip: net.ParseIP("1.2.3.4")}, + stubDetector{name: "b", err: errors.New("network")}, + stubDetector{name: "c", ip: net.ParseIP("1.2.3.4")}, + }, + PollInterval: 30 * time.Second, + QuorumSize: 2, + Update: func(_ context.Context, _ net.IP) error { return nil }, + }) + if err := d.Tick(context.Background()); err != nil { + t.Errorf("should succeed with 2 of 3: %v", err) + } +} + +func TestDaemon_Tick_FailureBackoffEscalates(t *testing.T) { + d, _ := New(Config{ + Detectors: []IPDetector{ + stubDetector{name: "a", err: errors.New("x")}, + stubDetector{name: "b", err: errors.New("x")}, + }, + PollInterval: 30 * time.Second, + Update: func(_ context.Context, _ net.IP) error { return nil }, + }) + + _ = d.Tick(context.Background()) + first := d.nextSleep() + + _ = d.Tick(context.Background()) + second := d.nextSleep() + + // second should be ≥ first (exponential backoff). With jitter ±10% + // the strict inequality may not hold, so compare against a low + // bound: second >= 2 * (PollInterval - 10%). + minSecond := 2 * 30 * time.Second * 9 / 10 + if second < minSecond { + t.Errorf("backoff did not escalate: first=%v second=%v (minExpected=%v)", first, second, minSecond) + } +} + +func TestNew_RequiresUpdater(t *testing.T) { + _, err := New(Config{Detectors: []IPDetector{stubDetector{name: "a", ip: net.ParseIP("1.1.1.1")}}}) + if err == nil { + t.Fatal("expected error when Update is nil") + } +} + +func TestNew_RequiresMinimumPollInterval(t *testing.T) { + _, err := New(Config{ + Detectors: []IPDetector{stubDetector{name: "a", ip: net.ParseIP("1.1.1.1")}}, + PollInterval: 10 * time.Second, + Update: func(_ context.Context, _ net.IP) error { return nil }, + }) + if err == nil { + t.Fatal("expected error on <30s interval") + } +} + +func TestNew_QuorumDefaults(t *testing.T) { + d, _ := New(Config{ + Detectors: []IPDetector{ + stubDetector{name: "a", ip: net.ParseIP("1.1.1.1")}, + stubDetector{name: "b", ip: net.ParseIP("1.1.1.1")}, + stubDetector{name: "c", ip: net.ParseIP("1.1.1.1")}, + }, + PollInterval: 30 * time.Second, + Update: func(_ context.Context, _ net.IP) error { return nil }, + }) + if d.cfg.QuorumSize != 2 { + t.Errorf("default quorum for 3 detectors = %d want 2", d.cfg.QuorumSize) + } +} + +func TestDaemon_Tick_FailureSurfacesUpdaterError(t *testing.T) { + updater := func(_ context.Context, _ net.IP) error { + return errors.New("simulated DNS failure") + } + d, _ := New(Config{ + Detectors: []IPDetector{stubDetector{name: "a", ip: net.ParseIP("1.2.3.4")}}, + PollInterval: 30 * time.Second, + QuorumSize: 1, + Update: updater, + }) + err := d.Tick(context.Background()) + if err == nil { + t.Fatal("expected error from updater") + } + if !strings.Contains(err.Error(), "simulated DNS failure") { + t.Errorf("err = %v want wrapped", err) + } + // Current should NOT be set when update fails. + if d.Current() != nil { + t.Errorf("Current should be nil after failed update; got %v", d.Current()) + } +} + +func TestDaemon_Run_ExitsOnContextCancel(t *testing.T) { + updater := func(_ context.Context, _ net.IP) error { return nil } + d, _ := New(Config{ + Detectors: []IPDetector{stubDetector{name: "a", ip: net.ParseIP("1.2.3.4")}}, + PollInterval: 30 * time.Second, + QuorumSize: 1, + Update: updater, + }) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- d.Run(ctx) }() + + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Errorf("Run err = %v want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Run did not return after cancel") + } +} + +func TestDaemon_ConcurrentTickSafe(t *testing.T) { + d, _ := New(Config{ + Detectors: []IPDetector{stubDetector{name: "a", ip: net.ParseIP("1.2.3.4")}}, + PollInterval: 30 * time.Second, + QuorumSize: 1, + Update: func(_ context.Context, _ net.IP) error { return nil }, + }) + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = d.Tick(context.Background()) + }() + } + wg.Wait() + if d.TotalUpdates() < 1 { + t.Errorf("at least one update should fire from concurrent ticks; got %d", d.TotalUpdates()) + } +}