Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package doggo for openSUSE:Factory checked in at 2026-05-28 23:12:18 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/doggo (Old) and /work/SRC/openSUSE:Factory/.doggo.new.1937 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "doggo" Thu May 28 23:12:18 2026 rev:10 rq:1355657 version:1.1.7 Changes: -------- --- /work/SRC/openSUSE:Factory/doggo/doggo.changes 2026-05-20 15:27:15.560908531 +0200 +++ /work/SRC/openSUSE:Factory/.doggo.new.1937/doggo.changes 2026-05-28 23:12:46.857476497 +0200 @@ -1,0 +2,22 @@ +Thu May 28 07:28:02 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 1.1.7: + * New Features + - be67297: feat(app): debug-log nameserver strategy application + (@mr-karan) + - 622bd7f: feat(resolvers): tag lookup errors with nameserver + and support partial success (@mr-karan) + * Bug fixes + - af1fa7f: fix(resolvers): apply strategy to nameserver + overrides (#238) (@mr-karan) + - dd88d6e: fix(resolvers): avoid invalid root search queries + (#239) (@mr-karan) + - da5d86a: fix(resolvers): preserve completed work when context + expires mid-flight (@mr-karan) + - b400c46: fix(web): preserve partial resolver results in API + handler (@mr-karan) + * Others + - dea30ad: chore: bump Go to 1.26.3 for stdlib security fixes + (@mr-karan) + +------------------------------------------------------------------- Old: ---- doggo-1.1.6.obscpio New: ---- doggo-1.1.7.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ doggo.spec ++++++ --- /var/tmp/diff_new_pack.nk3Dgi/_old 2026-05-28 23:12:51.057649309 +0200 +++ /var/tmp/diff_new_pack.nk3Dgi/_new 2026-05-28 23:12:51.061649474 +0200 @@ -17,7 +17,7 @@ Name: doggo -Version: 1.1.6 +Version: 1.1.7 Release: 0 Summary: CLI tool and API server DNS client implemented in Go License: GPL-3.0-only @@ -25,7 +25,7 @@ URL: https://github.com/mr-karan/doggo Source0: %{name}-%{version}.tar Source1: vendor.tar.xz -BuildRequires: go1.26 >= 1.26.2 +BuildRequires: go1.26 >= 1.26.3 Recommends: %{name}-bash-completion Suggests: %{name}-fish-completion Suggests: %{name}-zsh-completion ++++++ _service ++++++ --- /var/tmp/diff_new_pack.nk3Dgi/_old 2026-05-28 23:12:51.109651449 +0200 +++ /var/tmp/diff_new_pack.nk3Dgi/_new 2026-05-28 23:12:51.113651613 +0200 @@ -2,7 +2,7 @@ <service name="obs_scm" mode="manual"> <param name="scm">git</param> <param name="url">https://github.com/mr-karan/doggo.git</param> - <param name="revision">v1.1.6</param> + <param name="revision">v1.1.7</param> <param name="match-tag">*</param> <param name="versionrewrite-pattern">v(\d+\.\d+\.\d+)</param> <param name="versionformat">@PARENT_TAG@</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.nk3Dgi/_old 2026-05-28 23:12:51.137652601 +0200 +++ /var/tmp/diff_new_pack.nk3Dgi/_new 2026-05-28 23:12:51.145652930 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/mr-karan/doggo.git</param> - <param name="changesrevision">2bcf2f719d2017db2d525cd8b31c65f4b8b54e69</param></service></servicedata> + <param name="changesrevision">b400c464b445c79805a9987475f862357ce7f49d</param></service></servicedata> (No newline at EOF) ++++++ doggo-1.1.6.obscpio -> doggo-1.1.7.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/cmd/doggo/cli.go new/doggo-1.1.7/cmd/doggo/cli.go --- old/doggo-1.1.6/cmd/doggo/cli.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/cmd/doggo/cli.go 2026-05-22 15:05:55.000000000 +0200 @@ -3,6 +3,7 @@ import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "math" @@ -19,6 +20,15 @@ flag "github.com/spf13/pflag" ) +// Exit codes used by the CLI. Exit 0 is implicit success; partial success +// (some resolvers answered, others failed) is 2; full lookup failure remains +// 9 to preserve compatibility with the pre-existing convention. +const ( + exitGenericFailure = 1 + exitPartialFailure = 2 + exitLookupFailure = 9 +) + var ( buildVersion = "unknown" buildDate = "unknown" @@ -89,8 +99,8 @@ os.Exit(0) } - responses, errors := performLookup(app, cfg) - outputResults(app, responses, errors) + responses, lookupErrors := performLookup(app, cfg) + outputResults(app, responses, lookupErrors) } type config struct { @@ -283,12 +293,16 @@ defer wg.Done() responses, err := r.Lookup(ctx, app.Questions, cfg.queryFlags) mu.Lock() + defer mu.Unlock() + // Collect any responses the resolver produced even when err != nil + // so partial successes within a single resolver still surface. + allResponses = append(allResponses, responses...) if err != nil { - allErrors = append(allErrors, err) - } else { - allResponses = append(allResponses, responses...) + allErrors = append(allErrors, &resolvers.LookupError{ + Nameserver: r.Address(), + Err: err, + }) } - mu.Unlock() }(resolver) } @@ -300,30 +314,83 @@ if app.QueryFlags.ShowJSON { outputJSON(app.Logger, responses, responseErrors) } else { - if len(responseErrors) > 0 { - app.Logger.Error("Error looking up DNS records", "error", responseErrors[0]) - os.Exit(9) + // Full failure: no resolver produced a usable response. Surface every + // per-resolver error so the user can see which nameservers failed and + // why, then exit with the legacy lookup-failure code. + if len(responses) == 0 && len(responseErrors) > 0 { + for _, err := range responseErrors { + logResolverError(app.Logger, slog.LevelError, "Error looking up DNS records", err) + } + os.Exit(exitLookupFailure) + } + // Partial success: at least one resolver answered while another + // failed. Demote the failure to a warning and print whatever we have. + for _, err := range responseErrors { + logResolverError(app.Logger, slog.LevelWarn, "lookup failed", err) } app.Output(responses) } + + if len(responseErrors) > 0 && len(responses) > 0 { + os.Exit(exitPartialFailure) + } + if len(responseErrors) > 0 { + os.Exit(exitLookupFailure) + } +} + +// logResolverError emits a per-resolver lookup error at the given level, +// unwrapping LookupError so the nameserver shows up as its own structured +// field rather than embedded in the message. +func logResolverError(logger *slog.Logger, level slog.Level, msg string, err error) { + var lookupErr *resolvers.LookupError + if errors.As(err, &lookupErr) { + logger.Log(context.Background(), level, msg, + "nameserver", lookupErr.Nameserver, + "error", lookupErr.Err, + ) + return + } + logger.Log(context.Background(), level, msg, "error", err) +} + +// resolverErrorJSON is the per-resolver error shape returned in JSON output. +type resolverErrorJSON struct { + Nameserver string `json:"nameserver,omitempty"` + Error string `json:"error"` } func outputJSON(logger *slog.Logger, responses []resolvers.Response, responseErrors []error) { jsonOutput := struct { Responses []resolvers.Response `json:"responses,omitempty"` - Error string `json:"error,omitempty"` + Errors []resolverErrorJSON `json:"errors,omitempty"` + // Error is kept for backwards compatibility with scripts that parsed + // the previous schema. It is populated only on full failure. + Error string `json:"error,omitempty"` }{ Responses: responses, } - if len(responseErrors) > 0 { + for _, err := range responseErrors { + var lookupErr *resolvers.LookupError + if errors.As(err, &lookupErr) { + jsonOutput.Errors = append(jsonOutput.Errors, resolverErrorJSON{ + Nameserver: lookupErr.Nameserver, + Error: lookupErr.Err.Error(), + }) + continue + } + jsonOutput.Errors = append(jsonOutput.Errors, resolverErrorJSON{Error: err.Error()}) + } + + if len(responses) == 0 && len(responseErrors) > 0 { jsonOutput.Error = responseErrors[0].Error() } jsonData, err := json.MarshalIndent(jsonOutput, "", " ") if err != nil { logger.Error("Error marshaling JSON") - os.Exit(1) + os.Exit(exitGenericFailure) } fmt.Println(string(jsonData)) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/cmd/doggo/completions.go new/doggo-1.1.7/cmd/doggo/completions.go --- old/doggo-1.1.6/cmd/doggo/completions.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/cmd/doggo/completions.go 2026-05-22 15:05:55.000000000 +0200 @@ -65,7 +65,7 @@ '(-c --class)'{-c,--class}'[Network class of the DNS record being queried]:network class:(IN CH HS)' \ '(-r --reverse)'{-r,--reverse}'[Performs a DNS Lookup for an IPv4 or IPv6 address]' \ '--any[Query all supported DNS record types]' \ - '--strategy[Strategy to query nameserver listed in etc/resolv.conf]:strategy:(all random first internal)' \ + '--strategy[Strategy to query nameservers]:strategy:(all random first internal)' \ '--ndots[Number of required dots in hostname to assume FQDN]:number of dots' \ '--search[Use the search list defined in resolv.conf]:setting:(true false)' \ '--timeout[Timeout (in seconds) for the resolver to return a response]:seconds' \ @@ -129,7 +129,7 @@ complete -c doggo -n '__fish_doggo_no_subcommand' -l 'any' -d "Query all supported DNS record types" # Resolver options -complete -c doggo -n '__fish_doggo_no_subcommand' -l 'strategy' -d "Strategy to query nameserver listed in etc/resolv.conf" -x -a "all random first internal" +complete -c doggo -n '__fish_doggo_no_subcommand' -l 'strategy' -d "Strategy to query nameservers" -x -a "all random first internal" complete -c doggo -n '__fish_doggo_no_subcommand' -l 'ndots' -d "Specify ndots parameter" complete -c doggo -n '__fish_doggo_no_subcommand' -l 'search' -d "Use the search list defined in resolv.conf" -x -a "true false" complete -c doggo -n '__fish_doggo_no_subcommand' -l 'timeout' -d "Specify timeout (in seconds) for the resolver to return a response" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/cmd/doggo/help.go new/doggo-1.1.7/cmd/doggo/help.go --- old/doggo-1.1.6/cmd/doggo/help.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/cmd/doggo/help.go 2026-05-22 15:05:55.000000000 +0200 @@ -121,7 +121,7 @@ {"--any", "Query all supported DNS record types (A, AAAA, CNAME, MX, NS, PTR, SOA, SRV, TXT, CAA)."}, }, "ResolverOptions": []Option{ - {"--strategy=STRATEGY", "Specify strategy to query nameserver listed in etc/resolv.conf. Options: all, random, first, internal (RFC 1918/ULA private IPs only)."}, + {"--strategy=STRATEGY", "Specify strategy to query nameservers. Options: all, random, first, internal (RFC 1918/ULA private IPs only)."}, {"--ndots=INT", "Specify ndots parameter. Takes value from /etc/resolv.conf if using the system namesever or 1 otherwise."}, {"--search", "Use the search list defined in resolv.conf. Defaults to true. Set --search=false to disable search list."}, {"--timeout=DURATION", "Specify timeout for the resolver to return a response (e.g., 5s, 400ms, 1m)."}, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/cmd/doggo/integration_test.go new/doggo-1.1.7/cmd/doggo/integration_test.go --- old/doggo-1.1.6/cmd/doggo/integration_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/doggo-1.1.7/cmd/doggo/integration_test.go 2026-05-22 15:05:55.000000000 +0200 @@ -0,0 +1,328 @@ +package main_test + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/miekg/dns" +) + +// integrationBuild lazily compiles the doggo binary for the integration suite +// so the tests exercise the real exit codes and stdout/stderr emitted by the +// CLI, not just the library APIs. +var ( + integrationBuildOnce sync.Once + integrationBinPath string + integrationBuildErr error +) + +func doggoBin(t *testing.T) string { + t.Helper() + integrationBuildOnce.Do(func() { + if testing.Short() { + integrationBuildErr = errors.New("skipped in -short mode") + return + } + dir, err := os.MkdirTemp("", "doggo-bin-") + if err != nil { + integrationBuildErr = err + return + } + bin := filepath.Join(dir, "doggo") + if runtime.GOOS == "windows" { + bin += ".exe" + } + // Build from the cmd/doggo package; integration_test.go lives there so + // using `.` would pick up tests-only deps. Use the module path + // explicitly to avoid that. + cmd := exec.Command("go", "build", "-o", bin, "github.com/mr-karan/doggo/cmd/doggo") + out, err := cmd.CombinedOutput() + if err != nil { + integrationBuildErr = fmt.Errorf("go build failed: %v\n%s", err, out) + return + } + integrationBinPath = bin + }) + if integrationBuildErr != nil { + t.Skipf("doggo binary unavailable: %v", integrationBuildErr) + } + return integrationBinPath +} + +// startDNSServer starts a UDP DNS test server bound to 127.0.0.1 on a random +// port. The handler answers A queries for the supplied domain with the given +// IP. Returns the address as "host:port" and a shutdown function the test +// must call. +func startDNSServer(t *testing.T, domain, answer string) (string, func()) { + t.Helper() + mux := dns.NewServeMux() + mux.HandleFunc(dns.Fqdn(domain), func(w dns.ResponseWriter, req *dns.Msg) { + m := new(dns.Msg) + m.SetReply(req) + m.Authoritative = true + for _, q := range req.Question { + if q.Qtype == dns.TypeA { + rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, answer)) + if err != nil { + continue + } + m.Answer = append(m.Answer, rr) + } + } + _ = w.WriteMsg(m) + }) + + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + if err != nil { + t.Fatalf("ListenUDP: %v", err) + } + srv := &dns.Server{PacketConn: conn, Handler: mux} + ready := make(chan struct{}) + srv.NotifyStartedFunc = func() { close(ready) } + + go func() { + _ = srv.ActivateAndServe() + }() + + select { + case <-ready: + case <-time.After(2 * time.Second): + _ = srv.Shutdown() + t.Fatal("DNS test server did not start within 2s") + } + + return conn.LocalAddr().String(), func() { + _ = srv.Shutdown() + } +} + +// reservedClosedPort returns a TCP/UDP port that almost certainly has nothing +// listening: we bind, capture the port, then close. There is a TOCTOU window +// but it is large enough for these tests and the port lives on loopback only. +func reservedClosedPort(t *testing.T) int { + t.Helper() + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + if err != nil { + t.Fatalf("ListenUDP: %v", err) + } + port := conn.LocalAddr().(*net.UDPAddr).Port + _ = conn.Close() + return port +} + +func runDoggo(t *testing.T, args ...string) (stdout, stderr string, exit int) { + t.Helper() + bin := doggoBin(t) + cmd := exec.Command(bin, args...) + cmd.Env = append(os.Environ(), "NO_COLOR=1") + var outBuf, errBuf strings.Builder + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err := cmd.Run() + exit = 0 + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + exit = ee.ExitCode() + } else { + t.Fatalf("cmd.Run: %v\nstderr: %s", err, errBuf.String()) + } + } + return outBuf.String(), errBuf.String(), exit +} + +func TestPartialFailureExitsTwoAndPrintsResponse(t *testing.T) { + serverAddr, stop := startDNSServer(t, "example.test", "192.0.2.10") + defer stop() + + deadPort := reservedClosedPort(t) + deadAddr := fmt.Sprintf("127.0.0.1:%d", deadPort) + + stdout, stderr, exit := runDoggo(t, + "--timeout=2s", + "@"+serverAddr, + "@"+deadAddr, + "A", + "example.test", + ) + + if exit != 2 { + t.Fatalf("exit = %d, want 2 (partial failure)\nstdout:\n%s\nstderr:\n%s", exit, stdout, stderr) + } + if !strings.Contains(stdout, "192.0.2.10") { + t.Fatalf("stdout missing successful answer\nstdout:\n%s", stdout) + } + if !strings.Contains(stderr, "lookup failed") { + t.Fatalf("stderr missing per-resolver warning\nstderr:\n%s", stderr) + } + if !strings.Contains(stderr, deadAddr) { + t.Fatalf("stderr missing dead nameserver identity\nstderr:\n%s", stderr) + } +} + +func TestFullFailureExitsNine(t *testing.T) { + deadPort := reservedClosedPort(t) + deadAddr := fmt.Sprintf("127.0.0.1:%d", deadPort) + + stdout, stderr, exit := runDoggo(t, + "--timeout=2s", + "@"+deadAddr, + "A", + "example.test", + ) + + if exit != 9 { + t.Fatalf("exit = %d, want 9 (full failure)\nstdout:\n%s\nstderr:\n%s", exit, stdout, stderr) + } + if !strings.Contains(stderr, "Error looking up DNS records") { + t.Fatalf("stderr missing top-level failure message\nstderr:\n%s", stderr) + } + if !strings.Contains(stderr, deadAddr) { + t.Fatalf("stderr missing dead nameserver identity\nstderr:\n%s", stderr) + } +} + +func TestCleanSuccessExitsZero(t *testing.T) { + serverAddr, stop := startDNSServer(t, "clean.test", "192.0.2.20") + defer stop() + + stdout, _, exit := runDoggo(t, + "--timeout=2s", + "@"+serverAddr, + "A", + "clean.test", + ) + + if exit != 0 { + t.Fatalf("exit = %d, want 0", exit) + } + if !strings.Contains(stdout, "192.0.2.20") { + t.Fatalf("stdout missing answer\nstdout:\n%s", stdout) + } +} + +func TestPartialFailureJSONOutputIncludesErrorsArray(t *testing.T) { + serverAddr, stop := startDNSServer(t, "json.test", "192.0.2.30") + defer stop() + + deadPort := reservedClosedPort(t) + deadAddr := fmt.Sprintf("127.0.0.1:%d", deadPort) + + stdout, stderr, exit := runDoggo(t, + "--timeout=2s", + "--json", + "@"+serverAddr, + "@"+deadAddr, + "A", + "json.test", + ) + + if exit != 2 { + t.Fatalf("exit = %d, want 2\nstdout:\n%s\nstderr:\n%s", exit, stdout, stderr) + } + + var payload struct { + Responses []map[string]any `json:"responses"` + Errors []struct { + Nameserver string `json:"nameserver"` + Error string `json:"error"` + } `json:"errors"` + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("invalid JSON: %v\nstdout:\n%s", err, stdout) + } + if len(payload.Responses) == 0 { + t.Fatalf("expected at least one response, got %d\nstdout:\n%s", len(payload.Responses), stdout) + } + if len(payload.Errors) == 0 { + t.Fatalf("expected populated errors[], got 0\nstdout:\n%s", stdout) + } + if payload.Errors[0].Nameserver != deadAddr { + t.Fatalf("errors[0].nameserver = %q, want %q", payload.Errors[0].Nameserver, deadAddr) + } + if payload.Error != "" { + t.Fatalf(`legacy "error" field should be empty on partial failure, got %q`, payload.Error) + } +} + +func TestFullFailureJSONOutputPopulatesLegacyErrorField(t *testing.T) { + deadPort := reservedClosedPort(t) + deadAddr := fmt.Sprintf("127.0.0.1:%d", deadPort) + + stdout, _, exit := runDoggo(t, + "--timeout=2s", + "--json", + "@"+deadAddr, + "A", + "json.test", + ) + + if exit != 9 { + t.Fatalf("exit = %d, want 9\nstdout:\n%s", exit, stdout) + } + + var payload struct { + Errors []struct { + Nameserver string `json:"nameserver"` + Error string `json:"error"` + } `json:"errors"` + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("invalid JSON: %v\nstdout:\n%s", err, stdout) + } + if payload.Error == "" { + t.Fatalf(`legacy "error" should be populated on full failure\nstdout:\n%s`, stdout) + } + if len(payload.Errors) == 0 { + t.Fatalf("errors[] should still be populated for new clients\nstdout:\n%s", stdout) + } +} + +func TestDebugLogsStrategyApplication(t *testing.T) { + serverAddr, stop := startDNSServer(t, "debug.test", "192.0.2.40") + defer stop() + + deadPort := reservedClosedPort(t) + deadAddr := fmt.Sprintf("127.0.0.1:%d", deadPort) + + _, stderr, exit := runDoggo(t, + "--timeout=2s", + "--debug", + "--strategy=first", + "@"+serverAddr, + "@"+deadAddr, + "A", + "debug.test", + ) + if exit != 0 && exit != 2 { + t.Fatalf("exit = %d, want 0 or 2\nstderr:\n%s", exit, stderr) + } + + // Debug log should describe the strategy decision so users no longer have + // to guess why their second @host was silently dropped. + if !strings.Contains(stderr, "Applying nameserver strategy") { + t.Fatalf("missing strategy-application debug log\nstderr:\n%s", stderr) + } + if !strings.Contains(stderr, "Applied nameserver strategy") { + t.Fatalf("missing strategy-applied debug log\nstderr:\n%s", stderr) + } + if !strings.Contains(stderr, `source=explicit`) { + t.Fatalf(`missing source="explicit" label\nstderr:\n%s`, stderr) + } + if !regexp.MustCompile(`dropped_count=1`).MatchString(stderr) { + t.Fatalf("missing dropped_count=1 indicating @deadAddr was filtered\nstderr:\n%s", stderr) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/docs/src/content/docs/resolvers/system.md new/doggo-1.1.7/docs/src/content/docs/resolvers/system.md --- old/doggo-1.1.6/docs/src/content/docs/resolvers/system.md 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/docs/src/content/docs/resolvers/system.md 2026-05-22 15:05:55.000000000 +0200 @@ -42,7 +42,7 @@ ## Resolver Strategy -The resolver strategy determines how Doggo uses the nameservers listed in `/etc/resolv.conf`. You can specify a strategy using the `--strategy` flag: +The resolver strategy determines how Doggo uses nameservers, whether they come from the system resolver configuration or are specified directly with `@host` / `--nameserver`. You can specify a strategy using the `--strategy` flag: ```bash $ doggo example.com --strategy=first @@ -50,16 +50,17 @@ Available strategies: -- `all` (default): Use all nameservers listed in `/etc/resolv.conf`. +- `all` (default): Use all nameservers. - `first`: Use only the first nameserver in the list. - `random`: Randomly choose one nameserver from the list for each query. This can help distribute the load across multiple nameservers. +- `internal`: Use private IP nameservers only (RFC 1918 IPv4 or RFC 4193 IPv6 ULA). ## Command-line Options ```bash --ndots=INT Specify ndots parameter. Takes value from /etc/resolv.conf if using the system nameserver or 1 otherwise. --search Use the search list defined in resolv.conf. Defaults to true. Set --search=false to disable search list. ---strategy=STRATEGY Specify strategy to query nameservers listed in /etc/resolv.conf. Options: all, first, random. Defaults to all. +--strategy=STRATEGY Specify strategy to query nameservers. Options: all, first, random, internal. Defaults to all. --timeout=DURATION Set the timeout for resolver responses (e.g., 5s, 400ms, 1m). ``` @@ -84,6 +85,10 @@ ```bash doggo example.com @1.1.1.1 @8.8.8.8 ``` - Note: When specifying nameservers directly, the system resolver configuration (including strategy) is not used. -You can find more examples at [Examples](/guide/examples) section. \ No newline at end of file +5. Use only the first explicitly specified nameserver: + ```bash + doggo example.com --strategy=first @1.1.1.1 @8.8.8.8 + ``` + +You can find more examples at [Examples](/guide/examples) section. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/go.mod new/doggo-1.1.7/go.mod --- old/doggo-1.1.6/go.mod 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/go.mod 2026-05-22 15:05:55.000000000 +0200 @@ -1,6 +1,6 @@ module github.com/mr-karan/doggo -go 1.26.2 +go 1.26.3 require ( github.com/ameshkov/dnscrypt/v2 v2.4.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/internal/app/nameservers.go new/doggo-1.1.7/internal/app/nameservers.go --- old/doggo-1.1.6/internal/app/nameservers.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/internal/app/nameservers.go 2026-05-22 15:05:55.000000000 +0200 @@ -30,6 +30,13 @@ app.Logger.Debug("Added nameserver", "nameserver", ns) } } + + var err error + app.Nameservers, err = app.applyNameserverStrategy(app.Nameservers, "explicit") + if err != nil { + app.Logger.Error("error applying nameserver strategy", "error", err) + return err + } } // If no nameservers were successfully loaded, fall back to system nameservers @@ -43,7 +50,7 @@ func (app *App) loadSystemNameservers() error { app.Logger.Debug("No user specified nameservers, falling back to system nameservers") - ns, ndots, search, err := getDefaultServers(app.QueryFlags.Strategy, app.QueryFlags.UseIPv4, app.QueryFlags.UseIPv6) + ns, ndots, search, err := app.getDefaultServers() if err != nil { app.Logger.Error("error fetching system default nameserver", "error", err) return fmt.Errorf("error fetching system default nameserver: %v", err) @@ -314,82 +321,136 @@ return filtered } -func getDefaultServers(strategy string, useIPv4, useIPv6 bool) ([]models.Nameserver, int, []string, error) { +// applyNameserverStrategy narrows the supplied nameserver list according to +// app.QueryFlags.Strategy and emits debug logs describing what it did. The +// source argument labels the origin of the list (e.g. "explicit" for CLI +// overrides, "system" for resolv.conf) so the same log lines can describe +// both call sites without ambiguity. +func (app *App) applyNameserverStrategy(nameservers []models.Nameserver, source string) ([]models.Nameserver, error) { + if len(nameservers) == 0 { + return nameservers, nil + } + + strategy := app.QueryFlags.Strategy + app.Logger.Debug("Applying nameserver strategy", + "source", source, + "strategy", strategy, + "before_count", len(nameservers), + "before", nameservers, + ) + + switch strategy { + case "random": + src := rand.NewSource(time.Now().UnixNano()) + rnd := rand.New(src) + selected := []models.Nameserver{nameservers[rnd.Intn(len(nameservers))]} + app.logStrategyApplied(source, strategy, len(nameservers), selected) + return selected, nil + + case "first": + selected := []models.Nameserver{nameservers[0]} + app.logStrategyApplied(source, strategy, len(nameservers), selected) + return selected, nil + + case "internal": + internalServers := make([]models.Nameserver, 0) + for _, ns := range nameservers { + if isPrivateIP(nameserverHost(ns)) { + internalServers = append(internalServers, ns) + } + } + + if len(internalServers) == 0 { + app.Logger.Debug("Nameserver strategy rejected all nameservers", + "source", source, + "strategy", strategy, + "before_count", len(nameservers), + ) + return nil, fmt.Errorf("no internal (private IP) nameservers found") + } + + app.logStrategyApplied(source, strategy, len(nameservers), internalServers) + return internalServers, nil + + default: + app.Logger.Debug("Nameserver strategy left nameservers unchanged", + "source", source, + "strategy", strategy, + "count", len(nameservers), + ) + return nameservers, nil + } +} + +func (app *App) logStrategyApplied(source, strategy string, beforeCount int, after []models.Nameserver) { + app.Logger.Debug("Applied nameserver strategy", + "source", source, + "strategy", strategy, + "after_count", len(after), + "dropped_count", beforeCount-len(after), + "after", after, + ) +} + +func nameserverHost(ns models.Nameserver) string { + if u, err := url.Parse(ns.Address); err == nil && u.Hostname() != "" { + return u.Hostname() + } + + host, _, err := net.SplitHostPort(ns.Address) + if err == nil { + return host + } + + return ns.Address +} + +func (app *App) getDefaultServers() ([]models.Nameserver, int, []string, error) { // Load nameservers from `/etc/resolv.conf`. dnsServers, ndots, search, err := config.GetDefaultServers() if err != nil { return nil, 0, nil, err } + app.Logger.Debug("Loaded system resolver configuration", + "nameservers", dnsServers, + "ndots", ndots, + "search", search, + ) + // Filter nameservers based on IPv4/IPv6 flags - dnsServers = filterNameserversByIPVersion(dnsServers, useIPv4, useIPv6) + beforeFilter := len(dnsServers) + dnsServers = filterNameserversByIPVersion(dnsServers, app.QueryFlags.UseIPv4, app.QueryFlags.UseIPv6) + if beforeFilter != len(dnsServers) { + app.Logger.Debug("Filtered system nameservers by IP version", + "use_ipv4", app.QueryFlags.UseIPv4, + "use_ipv6", app.QueryFlags.UseIPv6, + "before_count", beforeFilter, + "after_count", len(dnsServers), + ) + } // If after filtering we have no servers, return an error if len(dnsServers) == 0 { ipVersion := "IPv4" - if useIPv6 { + if app.QueryFlags.UseIPv6 { ipVersion = "IPv6" } return nil, ndots, search, fmt.Errorf("no %s nameservers found in system configuration", ipVersion) } servers := make([]models.Nameserver, 0, len(dnsServers)) - - switch strategy { - case "random": - // Create a new local random source and generator. - src := rand.NewSource(time.Now().UnixNano()) - rnd := rand.New(src) - - // Choose a random server from the list. - srv := dnsServers[rnd.Intn(len(dnsServers))] + for _, s := range dnsServers { ns := models.Nameserver{ Type: models.UDPResolver, - Address: net.JoinHostPort(srv, models.DefaultUDPPort), + Address: net.JoinHostPort(s, models.DefaultUDPPort), } servers = append(servers, ns) + } - case "first": - // Choose the first from the list, always. - srv := dnsServers[0] - ns := models.Nameserver{ - Type: models.UDPResolver, - Address: net.JoinHostPort(srv, models.DefaultUDPPort), - } - servers = append(servers, ns) - - case "internal": - // Filter for nameservers with private IPs only (RFC 1918 / RFC 4193 ULA) - internalServers := make([]string, 0) - for _, srv := range dnsServers { - if isPrivateIP(srv) { - internalServers = append(internalServers, srv) - } - } - - // Return error if no internal servers found - if len(internalServers) == 0 { - return nil, ndots, search, fmt.Errorf("no internal (private IP) nameservers found in system configuration") - } - - // Return all internal servers - for _, s := range internalServers { - ns := models.Nameserver{ - Type: models.UDPResolver, - Address: net.JoinHostPort(s, models.DefaultUDPPort), - } - servers = append(servers, ns) - } - - default: - // Default behaviour is to load all nameservers. - for _, s := range dnsServers { - ns := models.Nameserver{ - Type: models.UDPResolver, - Address: net.JoinHostPort(s, models.DefaultUDPPort), - } - servers = append(servers, ns) - } + servers, err = app.applyNameserverStrategy(servers, "system") + if err != nil { + return nil, ndots, search, fmt.Errorf("%w in system configuration", err) } return servers, ndots, search, nil diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/internal/app/nameservers_test.go new/doggo-1.1.7/internal/app/nameservers_test.go --- old/doggo-1.1.6/internal/app/nameservers_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/doggo-1.1.7/internal/app/nameservers_test.go 2026-05-22 15:05:55.000000000 +0200 @@ -0,0 +1,74 @@ +package app + +import ( + "io" + "log/slog" + "testing" + + "github.com/mr-karan/doggo/pkg/models" +) + +func TestLoadNameserversAppliesFirstStrategyToExplicitNameservers(t *testing.T) { + app := newTestApp() + app.QueryFlags.Nameservers = []string{"1.0.0.1", "1.1.1.1"} + app.QueryFlags.Strategy = "first" + + if err := app.LoadNameservers(); err != nil { + t.Fatalf("LoadNameservers() error = %v", err) + } + + want := []models.Nameserver{ + {Address: "1.0.0.1:53", Type: models.UDPResolver}, + } + assertNameservers(t, app.Nameservers, want) +} + +func TestLoadNameserversAppliesInternalStrategyToExplicitNameservers(t *testing.T) { + app := newTestApp() + app.QueryFlags.Nameservers = []string{"1.1.1.1", "10.0.0.2", "tls://172.16.0.2"} + app.QueryFlags.Strategy = "internal" + + if err := app.LoadNameservers(); err != nil { + t.Fatalf("LoadNameservers() error = %v", err) + } + + want := []models.Nameserver{ + {Address: "10.0.0.2:53", Type: models.UDPResolver}, + {Address: "172.16.0.2:853", Type: models.DOTResolver}, + } + assertNameservers(t, app.Nameservers, want) +} + +func TestLoadNameserversReturnsErrorWhenExplicitInternalStrategyHasNoPrivateNameservers(t *testing.T) { + app := newTestApp() + app.QueryFlags.Nameservers = []string{"1.1.1.1", "8.8.8.8"} + app.QueryFlags.Strategy = "internal" + + if err := app.LoadNameservers(); err == nil { + t.Fatal("LoadNameservers() error = nil, want error") + } +} + +func newTestApp() App { + return App{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + QueryFlags: models.QueryFlags{ + Nameservers: []string{}, + }, + Nameservers: []models.Nameserver{}, + } +} + +func assertNameservers(t *testing.T, got, want []models.Nameserver) { + t.Helper() + + if len(got) != len(want) { + t.Fatalf("len(nameservers) = %d, want %d: %#v", len(got), len(want), got) + } + + for i := range want { + if got[i] != want[i] { + t.Fatalf("nameservers[%d] = %#v, want %#v", i, got[i], want[i]) + } + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/classic.go new/doggo-1.1.7/pkg/resolvers/classic.go --- old/doggo-1.1.6/pkg/resolvers/classic.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/pkg/resolvers/classic.go 2026-05-22 15:05:55.000000000 +0200 @@ -135,6 +135,11 @@ return rsp, nil } +// Address implements the Resolver interface. +func (r *ClassicResolver) Address() string { + return r.server +} + // Lookup implements the Resolver interface func (r *ClassicResolver) Lookup(ctx context.Context, questions []dns.Question, flags QueryFlags) ([]Response, error) { return ConcurrentLookup(ctx, questions, flags, r.query, r.resolverOptions.Logger) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/common.go new/doggo-1.1.7/pkg/resolvers/common.go --- old/doggo-1.1.6/pkg/resolvers/common.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/pkg/resolvers/common.go 2026-05-22 15:05:55.000000000 +0200 @@ -2,6 +2,7 @@ import ( "context" + "errors" "log/slog" "sync" @@ -11,55 +12,48 @@ // QueryFunc represents the signature of a query function type QueryFunc func(ctx context.Context, question dns.Question, flags QueryFlags) (Response, error) -// ConcurrentLookup performs concurrent DNS lookups +// ConcurrentLookup performs concurrent DNS lookups across multiple questions +// against a single resolver. It always waits for all in-flight goroutines so +// completed work is never discarded when the context is cancelled or expires +// mid-flight; callers receive whatever responses finished plus a joined error +// describing any per-question failures. func ConcurrentLookup(ctx context.Context, questions []dns.Question, flags QueryFlags, queryFunc QueryFunc, logger *slog.Logger) ([]Response, error) { var wg sync.WaitGroup responses := make([]Response, len(questions)) - errors := make([]error, len(questions)) - done := make(chan struct{}) + errs := make([]error, len(questions)) for i, q := range questions { wg.Add(1) go func(i int, q dns.Question) { defer wg.Done() - select { - case <-ctx.Done(): - errors[i] = ctx.Err() - default: - resp, err := queryFunc(ctx, q, flags) - responses[i] = resp - errors[i] = err + if err := ctx.Err(); err != nil { + errs[i] = err + return } + resp, err := queryFunc(ctx, q, flags) + responses[i] = resp + errs[i] = err }(i, q) } - go func() { - wg.Wait() - close(done) - }() - - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-done: - // All goroutines have finished - } + wg.Wait() - // Collect non-nil responses and handle errors var validResponses []Response + var lookupErrs []error for i, resp := range responses { - if errors[i] != nil { - if errors[i] != context.Canceled && errors[i] != context.DeadlineExceeded { - logger.Error("error in lookup", "error", errors[i]) + if errs[i] != nil { + lookupErrs = append(lookupErrs, errs[i]) + if !errors.Is(errs[i], context.Canceled) && !errors.Is(errs[i], context.DeadlineExceeded) { + logger.Error("error in lookup", "error", errs[i]) } - } else { - validResponses = append(validResponses, resp) + continue } + validResponses = append(validResponses, resp) } - if len(validResponses) == 0 && ctx.Err() != nil { - return nil, ctx.Err() + if len(validResponses) == 0 && len(lookupErrs) > 0 { + return nil, errors.Join(lookupErrs...) } - return validResponses, nil + return validResponses, errors.Join(lookupErrs...) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/common_test.go new/doggo-1.1.7/pkg/resolvers/common_test.go --- old/doggo-1.1.6/pkg/resolvers/common_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/doggo-1.1.7/pkg/resolvers/common_test.go 2026-05-22 15:05:55.000000000 +0200 @@ -0,0 +1,140 @@ +package resolvers + +import ( + "context" + "errors" + "io" + "log/slog" + "sync/atomic" + "testing" + "time" + + "github.com/miekg/dns" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// TestConcurrentLookupReturnsCompletedWorkAfterContextExpiry guards against +// the prior race where ctx.Done() in the parent select caused already-finished +// goroutine results to be dropped on the floor. The first goroutine completes +// successfully and cancels the parent context; the second blocks until +// cancellation and then returns ctx.Err() — both must reach the caller. +func TestConcurrentLookupReturnsCompletedWorkAfterContextExpiry(t *testing.T) { + good := dns.Question{Name: "good.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + slow := dns.Question{Name: "slow.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + qf := func(ctx context.Context, q dns.Question, _ QueryFlags) (Response, error) { + if q.Name == good.Name { + cancel() + return Response{Questions: []Question{{Name: q.Name, Type: "A", Class: "IN"}}}, nil + } + // Block deterministically until the parent context is cancelled so + // this goroutine cannot finish before the cancel signal lands. + <-ctx.Done() + return Response{}, ctx.Err() + } + + responses, err := ConcurrentLookup(ctx, []dns.Question{good, slow}, QueryFlags{}, qf, discardLogger()) + + if len(responses) != 1 { + t.Fatalf("len(responses) = %d, want 1 (the completed work)", len(responses)) + } + if responses[0].Questions[0].Name != good.Name { + t.Fatalf("responses[0].Questions[0].Name = %q, want %q", responses[0].Questions[0].Name, good.Name) + } + if err == nil { + t.Fatal("err = nil, want joined error describing the cancelled question") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want errors.Is(err, context.Canceled)", err) + } +} + +// TestConcurrentLookupJoinsPerQuestionErrors ensures partial per-question +// failures are surfaced via errors.Join rather than discarded. +func TestConcurrentLookupJoinsPerQuestionErrors(t *testing.T) { + a := dns.Question{Name: "a.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + b := dns.Question{Name: "b.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + + errBoom := errors.New("boom") + errKaboom := errors.New("kaboom") + + qf := func(_ context.Context, q dns.Question, _ QueryFlags) (Response, error) { + switch q.Name { + case a.Name: + return Response{}, errBoom + case b.Name: + return Response{}, errKaboom + } + return Response{}, errors.New("unexpected question") + } + + responses, err := ConcurrentLookup(context.Background(), []dns.Question{a, b}, QueryFlags{}, qf, discardLogger()) + if len(responses) != 0 { + t.Fatalf("len(responses) = %d, want 0", len(responses)) + } + if err == nil { + t.Fatal("err = nil, want joined error") + } + if !errors.Is(err, errBoom) || !errors.Is(err, errKaboom) { + t.Fatalf("err = %v, want both errBoom and errKaboom to be unwrappable", err) + } +} + +// TestConcurrentLookupSurfacesPartialErrorAlongsideResponse verifies that a +// successful question and a failed question in the same call both reach the +// caller — the response in the slice, the error via errors.Join. +func TestConcurrentLookupSurfacesPartialErrorAlongsideResponse(t *testing.T) { + good := dns.Question{Name: "good.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + bad := dns.Question{Name: "bad.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + + errBoom := errors.New("boom") + + qf := func(_ context.Context, q dns.Question, _ QueryFlags) (Response, error) { + if q.Name == good.Name { + return Response{Questions: []Question{{Name: q.Name, Type: "A", Class: "IN"}}}, nil + } + return Response{}, errBoom + } + + responses, err := ConcurrentLookup(context.Background(), []dns.Question{good, bad}, QueryFlags{}, qf, discardLogger()) + if len(responses) != 1 { + t.Fatalf("len(responses) = %d, want 1", len(responses)) + } + if err == nil || !errors.Is(err, errBoom) { + t.Fatalf("err = %v, want errors.Is(err, errBoom)", err) + } +} + +// TestConcurrentLookupWaitsForAllGoroutines makes sure we never short-circuit +// the wait — a slow goroutine still gets to record its result. +func TestConcurrentLookupWaitsForAllGoroutines(t *testing.T) { + q1 := dns.Question{Name: "fast.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + q2 := dns.Question{Name: "slow.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + + var slowFinished atomic.Bool + qf := func(_ context.Context, q dns.Question, _ QueryFlags) (Response, error) { + if q.Name == q2.Name { + time.Sleep(50 * time.Millisecond) + slowFinished.Store(true) + } + return Response{Questions: []Question{{Name: q.Name, Type: "A", Class: "IN"}}}, nil + } + + responses, err := ConcurrentLookup(context.Background(), []dns.Question{q1, q2}, QueryFlags{}, qf, discardLogger()) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if len(responses) != 2 { + t.Fatalf("len(responses) = %d, want 2", len(responses)) + } + if !slowFinished.Load() { + t.Fatal("ConcurrentLookup returned before slow goroutine finished") + } +} + diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/dnscrypt.go new/doggo-1.1.7/pkg/resolvers/dnscrypt.go --- old/doggo-1.1.6/pkg/resolvers/dnscrypt.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/pkg/resolvers/dnscrypt.go 2026-05-22 15:05:55.000000000 +0200 @@ -41,6 +41,11 @@ }, nil } +// Address implements the Resolver interface. +func (r *DNSCryptResolver) Address() string { + return r.server +} + // Lookup implements the Resolver interface func (r *DNSCryptResolver) Lookup(ctx context.Context, questions []dns.Question, flags QueryFlags) ([]Response, error) { return ConcurrentLookup(ctx, questions, flags, r.query, r.resolverOptions.Logger) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/doh.go new/doggo-1.1.7/pkg/resolvers/doh.go --- old/doggo-1.1.6/pkg/resolvers/doh.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/pkg/resolvers/doh.go 2026-05-22 15:05:55.000000000 +0200 @@ -151,6 +151,11 @@ return rsp, nil } +// Address implements the Resolver interface. +func (r *DOHResolver) Address() string { + return r.server +} + // Lookup implements the Resolver interface func (r *DOHResolver) Lookup(ctx context.Context, questions []dns.Question, flags QueryFlags) ([]Response, error) { return ConcurrentLookup(ctx, questions, flags, r.query, r.resolverOptions.Logger) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/doq.go new/doggo-1.1.7/pkg/resolvers/doq.go --- old/doggo-1.1.6/pkg/resolvers/doq.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/pkg/resolvers/doq.go 2026-05-22 15:05:55.000000000 +0200 @@ -57,6 +57,11 @@ }, nil } +// Address implements the Resolver interface. +func (r *DOQResolver) Address() string { + return r.server +} + // Lookup implements the Resolver interface func (r *DOQResolver) Lookup(ctx context.Context, questions []dns.Question, flags QueryFlags) ([]Response, error) { return ConcurrentLookup(ctx, questions, flags, r.query, r.resolverOptions.Logger) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/lookup_error_test.go new/doggo-1.1.7/pkg/resolvers/lookup_error_test.go --- old/doggo-1.1.6/pkg/resolvers/lookup_error_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/doggo-1.1.7/pkg/resolvers/lookup_error_test.go 2026-05-22 15:05:55.000000000 +0200 @@ -0,0 +1,37 @@ +package resolvers + +import ( + "errors" + "testing" +) + +func TestLookupErrorTagsNameserver(t *testing.T) { + inner := errors.New("connection refused") + le := &LookupError{Nameserver: "127.0.0.1:53", Err: inner} + + got := le.Error() + want := "127.0.0.1:53: connection refused" + if got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } + + if !errors.Is(le, inner) { + t.Fatal("errors.Is should unwrap LookupError to the inner error") + } + + var target *LookupError + if !errors.As(le, &target) { + t.Fatal("errors.As(&LookupError{}, &target) failed") + } + if target.Nameserver != "127.0.0.1:53" { + t.Fatalf("Nameserver = %q, want 127.0.0.1:53", target.Nameserver) + } +} + +func TestLookupErrorWithoutNameserverFallsBackToInnerMessage(t *testing.T) { + inner := errors.New("oops") + le := &LookupError{Err: inner} + if le.Error() != "oops" { + t.Fatalf("Error() = %q, want %q", le.Error(), "oops") + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/resolver.go new/doggo-1.1.7/pkg/resolvers/resolver.go --- old/doggo-1.1.6/pkg/resolvers/resolver.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/pkg/resolvers/resolver.go 2026-05-22 15:05:55.000000000 +0200 @@ -2,6 +2,7 @@ import ( "context" + "fmt" "log/slog" "time" @@ -29,9 +30,30 @@ // Client. Different types of providers can load // a DNS Resolver satisfying this interface. type Resolver interface { + // Address returns the nameserver identity used when reporting errors. + Address() string Lookup(ctx context.Context, questions []dns.Question, flags QueryFlags) ([]Response, error) } +// LookupError tags a resolver failure with the nameserver that produced it +// so partial failures can be reported per-resolver instead of as an opaque +// top-level error. +type LookupError struct { + Nameserver string + Err error +} + +func (e *LookupError) Error() string { + if e.Nameserver == "" { + return e.Err.Error() + } + return fmt.Sprintf("%s: %v", e.Nameserver, e.Err) +} + +func (e *LookupError) Unwrap() error { + return e.Err +} + // Response represents a custom output format // for DNS queries. It wraps metadata about the DNS query // and the DNS Answer as well. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/utils.go new/doggo-1.1.7/pkg/resolvers/utils.go --- old/doggo-1.1.6/pkg/resolvers/utils.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/pkg/resolvers/utils.go 2026-05-22 15:05:55.000000000 +0200 @@ -128,18 +128,36 @@ // If name has enough dots, try that first. if hasNdots { - names = append(names, name) + names = appendQuestionName(names, name) } for _, s := range searchList { - names = append(names, dns.Fqdn(name+s)) + names = appendQuestionName(names, joinSearchDomain(name, s)) } // If we didn't have enough dots, try after suffixes. if !hasNdots { - names = append(names, name) + names = appendQuestionName(names, name) } return names } +func joinSearchDomain(name, searchDomain string) string { + if searchDomain == "." { + return name + } + + return dns.Fqdn(strings.TrimSuffix(name, ".") + "." + strings.TrimSuffix(searchDomain, ".")) +} + +func appendQuestionName(names []string, name string) []string { + for _, existing := range names { + if existing == name { + return names + } + } + + return append(names, name) +} + // toUnicodeDomain converts a punycode domain name to Unicode. // If conversion fails, returns the original domain name. func toUnicodeDomain(name string) string { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/utils_test.go new/doggo-1.1.7/pkg/resolvers/utils_test.go --- old/doggo-1.1.6/pkg/resolvers/utils_test.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/pkg/resolvers/utils_test.go 2026-05-22 15:05:55.000000000 +0200 @@ -1,6 +1,7 @@ package resolvers import ( + "reflect" "testing" "github.com/miekg/dns" @@ -67,4 +68,52 @@ } }) } +} + +func TestConstructPossibleQuestionsWithRootSearchDomain(t *testing.T) { + tests := []struct { + name string + qName string + ndots int + searchList []string + want []string + }{ + { + name: "root search does not append an extra trailing dot", + qName: "non-existent.test", + ndots: 0, + searchList: []string{"."}, + want: []string{"non-existent.test."}, + }, + { + name: "root search is de-duplicated when original name is tried first", + qName: "non-existent.test", + ndots: 1, + searchList: []string{"."}, + want: []string{"non-existent.test."}, + }, + { + name: "root search can follow regular search domains", + qName: "printer", + ndots: 1, + searchList: []string{"lan", "."}, + want: []string{"printer.lan.", "printer."}, + }, + { + name: "fully qualified names ignore search domains", + qName: "example.com.", + ndots: 1, + searchList: []string{"."}, + want: []string{"example.com."}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := constructPossibleQuestions(tt.qName, tt.ndots, tt.searchList) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("constructPossibleQuestions() = %#v, want %#v", got, tt.want) + } + }) + } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/doggo-1.1.6/web/handlers.go new/doggo-1.1.7/web/handlers.go --- old/doggo-1.1.6/web/handlers.go 2026-05-20 09:36:30.000000000 +0200 +++ new/doggo-1.1.7/web/handlers.go 2026-05-22 15:05:55.000000000 +0200 @@ -132,28 +132,37 @@ defer wg.Done() responses, err := r.Lookup(ctx, app.Questions, queryFlags) mu.Lock() + defer mu.Unlock() + // Collect any responses the resolver produced even when err != nil + // so partial successes (some questions answered, some failed) and + // multi-resolver fan-outs where one resolver is unreachable do not + // drop the responses we already have. + allResponses = append(allResponses, responses...) if err != nil { - allErrors = append(allErrors, err) - } else { - allResponses = append(allResponses, responses...) + allErrors = append(allErrors, &resolvers.LookupError{ + Nameserver: r.Address(), + Err: err, + }) } - mu.Unlock() }(resolver) } wg.Wait() - if len(allErrors) > 0 { - app.Logger.Error("errors looking up DNS records", "errors", allErrors) - sendErrorResponse(w, "Error looking up for records.", http.StatusInternalServerError, nil) - return - } - if len(allResponses) == 0 { + if len(allErrors) > 0 { + app.Logger.Error("errors looking up DNS records", "errors", allErrors) + sendErrorResponse(w, "Error looking up for records.", http.StatusInternalServerError, nil) + return + } sendErrorResponse(w, "No records found.", http.StatusNotFound, nil) return } + if len(allErrors) > 0 { + app.Logger.Warn("partial lookup failure", "errors", allErrors) + } + sendResponse(w, http.StatusOK, allResponses) } ++++++ doggo.obsinfo ++++++ --- /var/tmp/diff_new_pack.nk3Dgi/_old 2026-05-28 23:12:51.533668895 +0200 +++ /var/tmp/diff_new_pack.nk3Dgi/_new 2026-05-28 23:12:51.553669718 +0200 @@ -1,5 +1,5 @@ name: doggo -version: 1.1.6 -mtime: 1779262590 -commit: 2bcf2f719d2017db2d525cd8b31c65f4b8b54e69 +version: 1.1.7 +mtime: 1779455155 +commit: b400c464b445c79805a9987475f862357ce7f49d ++++++ vendor.tar.xz ++++++
