From 7b9334e089856c15255510f3f4671cb5df47ee59 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 09:55:28 -0400 Subject: [PATCH] feat: dyndns daemon + secrets setup --plugin (consolidated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #736 + #737 with lint fixes applied: dyndns/ (T14..T16): - IP-detect multi-source quorum + diff + Update callback + exp-backoff with jitter. 12 tests. cmd/wfctl/secrets_setup_plugin.go (T3+T4): - secrets setup --plugin reads plugin.json required_secrets[], prompts each (masked iff sensitive), writes to chosen GH scope (repo|env|org). 7 tests. Lint fixes vs the original PRs: - dyndns/dyndns.go Run() — replaced empty-branch (SA9003) with explicit `_ = d.Tick(...)` ignoring the error. - dyndns/dyndns.go jitter — math/rand/v2 G404 false-positive silenced with nolint:gosec annotation (decorrelation, not crypto). - dyndns/dyndns.go timeAfter — replaced unlambda wrapper with direct `var timeAfter = time.After`. - cmd/wfctl/secrets.go — hoisted `args[1:]` into a local var to silence gosec G602 (already bounded by outer switch). All tests pass; golangci-lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/wfctl/secrets.go | 12 +- cmd/wfctl/secrets_setup_plugin.go | 227 ++++++++++++++++++ cmd/wfctl/secrets_setup_plugin_test.go | 163 +++++++++++++ dyndns/dyndns.go | 313 +++++++++++++++++++++++++ dyndns/dyndns_test.go | 268 +++++++++++++++++++++ 5 files changed, 982 insertions(+), 1 deletion(-) create mode 100644 cmd/wfctl/secrets_setup_plugin.go create mode 100644 cmd/wfctl/secrets_setup_plugin_test.go create mode 100644 dyndns/dyndns.go create mode 100644 dyndns/dyndns_test.go diff --git a/cmd/wfctl/secrets.go b/cmd/wfctl/secrets.go index 2ed3fc32..1d59f22f 100644 --- a/cmd/wfctl/secrets.go +++ b/cmd/wfctl/secrets.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "strings" ) func runSecrets(args []string) error { @@ -31,7 +32,16 @@ func runSecrets(args []string) error { case "sync": return runSecretsSync(args[1:]) case "setup": - return runSecretsSetup(args[1:]) + // `secrets setup --plugin ` shells out to a separate + // dispatcher that reads plugin.json required_secrets[]. The + // env-name flow stays on runSecretsSetup. + rest := args[1:] + for _, a := range rest { + if a == "--plugin" || strings.HasPrefix(a, "--plugin=") { + return runSecretsSetupPlugin(rest) + } + } + return runSecretsSetup(rest) case "list-orphans": return runSecretsListOrphans(args[1:]) default: diff --git a/cmd/wfctl/secrets_setup_plugin.go b/cmd/wfctl/secrets_setup_plugin.go new file mode 100644 index 00000000..d9dc8915 --- /dev/null +++ b/cmd/wfctl/secrets_setup_plugin.go @@ -0,0 +1,227 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/GoCodeAlone/workflow/secrets" + "github.com/mattn/go-isatty" + "golang.org/x/term" +) + +// PluginRequiredSecret mirrors the plugin.json `required_secrets[]` +// entry. Each entry tells `wfctl secrets setup --plugin ` what +// to prompt for + whether to mask input. +type PluginRequiredSecret struct { + Name string `json:"name"` + Sensitive bool `json:"sensitive"` + Description string `json:"description,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + +// pluginManifest is the slice of plugin.json this command actually +// reads. Other fields are ignored. +type pluginManifest struct { + Name string `json:"name"` + RequiredSecrets []PluginRequiredSecret `json:"required_secrets,omitempty"` +} + +// runSecretsSetupPlugin is the entry-point for `wfctl secrets setup +// --plugin `. It reads the plugin's plugin.json, prompts for +// each declared required secret, and writes the values to the chosen +// GitHub scope (repo|env|org). +func runSecretsSetupPlugin(args []string) error { + return runSecretsSetupPluginWithIO(args, nil, os.Stdout) +} + +func runSecretsSetupPluginWithIO(args []string, in io.Reader, out io.Writer) error { + fs := flag.NewFlagSet("secrets setup --plugin", flag.ContinueOnError) + pluginName := fs.String("plugin", "", "Plugin name (must match a directory under --plugin-dir / $WFCTL_PLUGIN_DIR)") + pluginDir := fs.String("plugin-dir", "", "Plugin install dir (default: $WFCTL_PLUGIN_DIR or ./data/plugins)") + scope := fs.String("scope", "repo", "GitHub scope: repo | env | org") + envName := fs.String("env", "", "Environment name (required with --scope=env)") + org := fs.String("org", "", "Organization slug (required with --scope=org)") + orgVisibility := fs.String("visibility", "all", "Org-scope visibility: all | selected | private") + tokenEnv := fs.String("token-env", "GITHUB_TOKEN", "Env var holding the GitHub PAT") + configFile := fs.String("config", "app.yaml", "app.yaml (used to resolve the github repo when --scope=repo|env)") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl secrets setup --plugin [options] + +Interactively set the secrets declared by a plugin's plugin.json +required_secrets[] block. Sensitive fields are masked. + +Options: +`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if *pluginName == "" { + return errors.New("--plugin is required") + } + + manifest, err := loadPluginManifest(*pluginName, *pluginDir) + if err != nil { + return err + } + if len(manifest.RequiredSecrets) == 0 { + fmt.Fprintf(out, "plugin %q declares no required_secrets[]; nothing to do\n", manifest.Name) + return nil + } + + // Pre-build the destination provider so a malformed scope fails + // loud BEFORE prompting. + scopeStr := strings.ToLower(strings.TrimSpace(*scope)) + provider, scopeLabel, err := buildSecretWriter(scopeStr, *envName, *org, *orgVisibility, *tokenEnv, *configFile) + if err != nil { + return err + } + + fmt.Fprintf(out, "Setting up secrets for plugin %q → %s\n\n", manifest.Name, scopeLabel) + + for _, rs := range manifest.RequiredSecrets { + val, err := promptOne(rs, in) + if err != nil { + return err + } + if val == "" { + fmt.Fprintf(out, " %s: skipped (no value provided)\n", rs.Name) + continue + } + if err := provider.Set(context.Background(), rs.Name, val); err != nil { + return fmt.Errorf("set %s: %w", rs.Name, err) + } + fmt.Fprintf(out, " %s: set\n", rs.Name) + } + fmt.Fprintf(out, "\nAll done.\n") + return nil +} + +// loadPluginManifest looks for the plugin.json under the resolved +// plugin install dir, parses it, and returns the manifest. Returns +// a clear error when the directory is missing. +func loadPluginManifest(name, dirOverride string) (*pluginManifest, error) { + dir := dirOverride + if dir == "" { + dir = os.Getenv("WFCTL_PLUGIN_DIR") + } + if dir == "" { + dir = "./data/plugins" + } + path := filepath.Join(dir, name, "plugin.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read plugin manifest %s: %w (run `wfctl plugin install` first; or pass --plugin-dir)", path, err) + } + var m pluginManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse plugin manifest %s: %w", path, err) + } + return &m, nil +} + +// promptOne reads a value for one required secret. Masks if Sensitive. +// When `in` is non-nil (tests / piped input) it reads a line from it +// regardless of Sensitive — masking is interactive-only. +func promptOne(rs PluginRequiredSecret, in io.Reader) (string, error) { + label := rs.Prompt + if label == "" { + label = rs.Name + } + if rs.Description != "" { + fmt.Fprintf(os.Stderr, "\n# %s\n", rs.Description) + } + fmt.Fprintf(os.Stderr, "%s: ", label) + + if in != nil { + // Test/piped path — read one line. + buf := make([]byte, 4096) + n, err := in.Read(buf) + if err != nil && err != io.EOF { + return "", err + } + return strings.TrimRight(string(buf[:n]), "\r\n"), nil + } + + if rs.Sensitive && isatty.IsTerminal(os.Stdin.Fd()) { + fd, err := stdinFileDescriptor() + if err != nil { + return "", err + } + b, err := term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", fmt.Errorf("read masked: %w", err) + } + return string(b), nil + } + // Non-sensitive interactive — echo. + var line string + if _, err := fmt.Fscanln(os.Stdin, &line); err != nil && err.Error() != "unexpected newline" { + return "", err + } + return line, nil +} + +// scopedWriter is the narrow interface secrets setup --plugin needs. +// Both secrets.GitHubSecretsProvider satisfies it. +type scopedWriter interface { + Set(ctx context.Context, key, value string) error +} + +// buildSecretWriter mints the GitHub provider for the requested scope. +// scopeLabel is a human-readable string for the setup prelude. +func buildSecretWriter(scope, envName, org, visibility, tokenEnv, configFile string) (scopedWriter, string, error) { + switch scope { + case "org": + if org == "" { + return nil, "", errors.New("--scope=org requires --org ") + } + vis, err := parseGitHubOrgVisibility(visibility) + if err != nil { + return nil, "", err + } + p, err := secrets.NewGitHubOrgSecretsProvider(org, tokenEnv, vis, nil) + if err != nil { + return nil, "", err + } + return p, fmt.Sprintf("github org %q (visibility=%s)", org, visibility), nil + + case "env": + if envName == "" { + return nil, "", errors.New("--scope=env requires --env ") + } + repo, err := readGitHubRepoFromAppYAML(configFile) + if err != nil { + return nil, "", err + } + p, err := secrets.NewGitHubSecretsProvider(repo, tokenEnv) + if err != nil { + return nil, "", err + } + p.SetEnvironment(envName) + return p, fmt.Sprintf("github env %q on %s", envName, repo), nil + + case "", "repo": + repo, err := readGitHubRepoFromAppYAML(configFile) + if err != nil { + return nil, "", err + } + p, err := secrets.NewGitHubSecretsProvider(repo, tokenEnv) + if err != nil { + return nil, "", err + } + return p, fmt.Sprintf("github repo %s", repo), nil + + default: + return nil, "", fmt.Errorf("unknown --scope %q (want repo|env|org)", scope) + } +} diff --git a/cmd/wfctl/secrets_setup_plugin_test.go b/cmd/wfctl/secrets_setup_plugin_test.go new file mode 100644 index 00000000..4f17b248 --- /dev/null +++ b/cmd/wfctl/secrets_setup_plugin_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func writePluginManifestFile(t *testing.T, dir, name, manifest string) string { + t.Helper() + pdir := filepath.Join(dir, name) + if err := os.MkdirAll(pdir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + path := filepath.Join(pdir, "plugin.json") + if err := os.WriteFile(path, []byte(manifest), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + return pdir +} + +func TestLoadPluginManifest_HappyPath(t *testing.T) { + dir := t.TempDir() + writePluginManifestFile(t, dir, "workflow-plugin-fake", `{ + "name": "workflow-plugin-fake", + "required_secrets": [ + {"name": "FAKE_USER", "sensitive": false}, + {"name": "FAKE_TOKEN", "sensitive": true, "description": "API token"} + ] + }`) + m, err := loadPluginManifest("workflow-plugin-fake", dir) + if err != nil { + t.Fatalf("loadPluginManifest: %v", err) + } + if m.Name != "workflow-plugin-fake" { + t.Errorf("name = %q", m.Name) + } + if len(m.RequiredSecrets) != 2 { + t.Fatalf("required_secrets = %d want 2", len(m.RequiredSecrets)) + } + if m.RequiredSecrets[0].Name != "FAKE_USER" || m.RequiredSecrets[0].Sensitive { + t.Errorf("rs[0] = %+v", m.RequiredSecrets[0]) + } + if m.RequiredSecrets[1].Name != "FAKE_TOKEN" || !m.RequiredSecrets[1].Sensitive { + t.Errorf("rs[1] = %+v", m.RequiredSecrets[1]) + } +} + +func TestLoadPluginManifest_MissingDir(t *testing.T) { + _, err := loadPluginManifest("nope", t.TempDir()) + if err == nil { + t.Fatal("expected error for missing manifest") + } + if !strings.Contains(err.Error(), "wfctl plugin install") { + t.Errorf("error should hint at remediation: %v", err) + } +} + +func TestLoadPluginManifest_BadJSON(t *testing.T) { + dir := t.TempDir() + writePluginManifestFile(t, dir, "x", `{not-json}`) + _, err := loadPluginManifest("x", dir) + if err == nil { + t.Fatal("expected parse error") + } +} + +// TestPromptOne_PipedNonSensitive reads a single line from a piped +// reader and returns it. +func TestPromptOne_PipedNonSensitive(t *testing.T) { + got, err := promptOne(PluginRequiredSecret{Name: "X"}, strings.NewReader("hello\n")) + if err != nil { + t.Fatalf("promptOne: %v", err) + } + if got != "hello" { + t.Errorf("got %q want hello", got) + } +} + +// TestPromptOne_PipedSensitive — sensitive value can still come via +// pipe (tests bypass tty path). +func TestPromptOne_PipedSensitive(t *testing.T) { + got, err := promptOne(PluginRequiredSecret{Name: "Y", Sensitive: true}, strings.NewReader("hunter2\n")) + if err != nil { + t.Fatalf("promptOne: %v", err) + } + if got != "hunter2" { + t.Errorf("got %q want hunter2", got) + } +} + +// TestRunSecretsSetupPlugin_PiperReadsRequiredSecrets exercises the +// full flow with a piped reader for input. Output goes to a buffer. +// +// We swap out stdin via the io.Reader arg + verify the buffered out +// reports each secret as "set". +func TestRunSecretsSetupPlugin_PiperReadsRequiredSecrets(t *testing.T) { + dir := t.TempDir() + writePluginManifestFile(t, dir, "wp-fake", `{ + "name": "wp-fake", + "required_secrets": [ + {"name": "A", "sensitive": false}, + {"name": "B", "sensitive": true} + ] + }`) + // Stub the writer side by setting the org via env so + // buildSecretWriter is short-circuited (we just want to exercise + // the prompt loop). Use --scope=org with a stub provider not + // reachable in tests; the call will fail at network → we assert + // we got at least to the writer construction. + in := io.Reader(strings.NewReader("alice\nhunter2\n")) + var out bytes.Buffer + t.Setenv("GITHUB_TOKEN", "stub") + + // We can't actually hit the GH API; use --scope=org pointing + // at a non-resolvable token+org, then assert error returns from + // the network-side path (after the prompts succeed). + err := runSecretsSetupPluginWithIO([]string{ + "--plugin", "wp-fake", + "--plugin-dir", dir, + "--scope", "org", + "--org", "test-org", + "--token-env", "GITHUB_TOKEN", + }, in, &out) + if err == nil { + t.Fatal("expected network-side error reaching GH (test runs offline)") + } + // The output buffer should still show that we entered the setup + // loop (prompt prelude). + if !strings.Contains(out.String(), "Setting up secrets for plugin") { + t.Errorf("setup prelude missing from output:\n%s", out.String()) + } +} + +func TestBuildSecretWriter_ScopeRouting(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "stub") + // Org happy path. + w, label, err := buildSecretWriter("org", "", "my-org", "all", "GITHUB_TOKEN", "") + if err != nil || w == nil { + t.Errorf("org: err=%v writer=%v", err, w) + } + if !strings.Contains(label, "github org \"my-org\"") { + t.Errorf("org label: %q", label) + } + + // Org rejects missing --org. + if _, _, err := buildSecretWriter("org", "", "", "all", "GITHUB_TOKEN", ""); err == nil { + t.Error("org: expected error without --org") + } + + // Env rejects missing --env. + if _, _, err := buildSecretWriter("env", "", "", "all", "GITHUB_TOKEN", "app.yaml"); err == nil { + t.Error("env: expected error without --env") + } + + // Unknown scope. + if _, _, err := buildSecretWriter("nope", "", "", "all", "GITHUB_TOKEN", ""); err == nil { + t.Error("unknown scope should error") + } +} diff --git a/dyndns/dyndns.go b/dyndns/dyndns.go new file mode 100644 index 00000000..91897452 --- /dev/null +++ b/dyndns/dyndns.go @@ -0,0 +1,313 @@ +// 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 { + // Tick failures are absorbed; nextSleep() applies exponential + // backoff after recordFailure() bumps the counter inside Tick. + _ = d.Tick(ctx) + 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 is decorrelation, not security; math/rand/v2 is fine. + jitter := time.Duration(rand.Int64N(int64(base) / 5)) //nolint:gosec // G404: decorrelation jitter, not crypto. + if rand.IntN(2) == 0 { //nolint:gosec // same as above. + base += jitter + } else { + base -= jitter + } + return base +} + +// timeAfter is injectable for tests but defaults to time.After. +var timeAfter = time.After + +// 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()) + } +}