1 Commits
Author SHA1 Message Date
sneak 5fab7b7417 resolver: error from ResolveIPAddresses when no nameserver answered (closes #190)
check / check (push) Failing after 2m25s
ResolveIPAddresses now returns an error wrapping ErrNoNameserverAnswered
when every nameserver of the name's zone timed out or failed, instead of
no addresses and no error. It reads each nameserver's status as the
lookup already sets it; one answer, even NXDOMAIN, is enough for an
empty result without an error. The only caller, the nameserver address
lookup, already keeps the previous addresses on an error; its comment
no longer says the resolver hides this case.

Model: opus-5-5
2026-10-01 21:42:48 +00:00
9 changed files with 29 additions and 118 deletions
+7 -9
View File
@@ -491,9 +491,8 @@ tracks reachability:
| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) | | `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
A nameserver that answers NXDOMAIN or with no records has status `ok` and A nameserver that answers NXDOMAIN or with no records has status `ok` and
empty `records`. A nameserver whose query failed, or that only referred it to empty `records`. A nameserver whose query failed has status `error`, empty
other nameservers, has status `error`, empty `records`, and the reason in `records`, and the reason in `error`.
`error`.
`nameserverAddresses` lists, by nameserver, the sorted addresses its name `nameserverAddresses` lists, by nameserver, the sorted addresses its name
resolves to. A state file without it loads, and the next check fills it in resolves to. A state file without it loads, and the next check fills it in
@@ -509,8 +508,9 @@ standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call development workflow, and the Makefile targets are thin shims that call
them. We provide: them. We provide:
- `script/bootstrap` — install all dependencies (go, `go mod download`). - `script/bootstrap` — install all dependencies (go, pinned goimports,
It does not install golangci-lint: see `script/lint` below. `go mod download`). It does not install golangci-lint: see
`script/lint` below.
- `script/setup` — make a fresh clone ready for development: bootstrap - `script/setup` — make a fresh clone ready for development: bootstrap
plus the git pre-commit hook plus the git pre-commit hook
- `script/projectname` — print the project name (used for the Docker - `script/projectname` — print the project name (used for the Docker
@@ -527,10 +527,8 @@ them. We provide:
host, and Docker is the only prerequisite. Caching is waived for host, and Docker is the only prerequisite. Caching is waived for
linting: the lint stage is forced to execute on every run with linting: the lint stage is forced to execute on every run with
`--no-cache-filter`, because a cached build lints nothing. `--no-cache-filter`, because a cached build lints nothing.
- `script/fmt` — format all code (gofmt -s, goimports). goimports runs - `script/fmt` — format all code (gofmt -s, goimports)
with `go run` at a pinned commit, never from your `PATH`. - `script/fmt-check` — check formatting (read-only)
- `script/fmt-check` — check formatting (read-only) with the same tools,
failing on any file `script/fmt` would change
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`, with - `script/docker` — build the Docker image tagged via `script/projectname`, with
`--no-cache-filter=lint,builder` so the lint stage and the builder stage, `--no-cache-filter=lint,builder` so the lint stage and the builder stage,
+1 -3
View File
@@ -22,8 +22,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149
- 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no - 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no
nameserver of the name's zone answered (closes #190). nameserver of the name's zone answered (closes #190).
- 2026-10-01: `make fmt-check` fails on a file `goimports` would change; both
format scripts run `goimports` at its pinned commit, not from `PATH` (#119).
- 2026-10-01: a hostname is queried at the servers of the zone it is in, found - 2026-10-01: a hostname is queried at the servers of the zone it is in, found
by following delegations for the name, not its last two labels (closes #189). by following delegations for the name, not its last two labels (closes #189).
- 2026-10-01: each nameserver's addresses are saved with its domain, and a - 2026-10-01: each nameserver's addresses are saved with its domain, and a
@@ -116,7 +114,7 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149
- 1.0 readiness: run it with a real config and read the logs: - 1.0 readiness: run it with a real config and read the logs:
https://git.eeqj.de/sneak/dnswatcher/issues/66 https://git.eeqj.de/sneak/dnswatcher/issues/66
- Markdown formatting with prettier: - `goimports` in `make fmt-check`, Markdown formatting:
https://git.eeqj.de/sneak/dnswatcher/issues/119 https://git.eeqj.de/sneak/dnswatcher/issues/119
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 - README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
- README sections required by policy: - README sections required by policy:
+2 -2
View File
@@ -11,8 +11,8 @@ var (
) )
// ErrNoNameserverAnswered is returned when every nameserver // ErrNoNameserverAnswered is returned when every nameserver
// asked about a name timed out, failed or returned a referral, // asked about a name timed out or failed, so whether the name
// so whether the name has addresses is unknown. // has addresses is unknown.
ErrNoNameserverAnswered = errors.New("no nameserver answered") ErrNoNameserverAnswered = errors.New("no nameserver answered")
// ErrCNAMEDepthExceeded is returned when a CNAME chain // ErrCNAMEDepthExceeded is returned when a CNAME chain
+4 -20
View File
@@ -516,7 +516,6 @@ type queryState struct {
gotSERVFAIL bool gotSERVFAIL bool
gotRefused bool gotRefused bool
gotTimeout bool gotTimeout bool
gotReferral bool
netErr error netErr error
hasRecords bool hasRecords bool
} }
@@ -579,18 +578,6 @@ func (r *Resolver) querySingleType(
return return
} }
// A reply with no answer that lists other nameservers, from a server
// that does not hold the name's zone, is a referral and says nothing
// about the name's records. A parent zone's servers send one when
// every server of the name's own zone failed and
// FindAuthoritativeNameservers moved on to the parent name.
if !msg.Authoritative && len(msg.Answer) == 0 &&
len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true
return
}
collectAnswerRecords(msg, resp, state) collectAnswerRecords(msg, resp, state)
} }
@@ -639,9 +626,6 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.netErr != nil && !state.hasRecords: case state.netErr != nil && !state.hasRecords:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "network error: " + state.netErr.Error() resp.Error = "network error: " + state.netErr.Error()
case state.gotReferral && !state.hasRecords:
resp.Status = StatusError
resp.Error = "server returned a referral"
case !state.hasRecords && !state.gotNXDomain: case !state.hasRecords && !state.gotNXDomain:
resp.Status = StatusNoData resp.Status = StatusNoData
} }
@@ -751,8 +735,8 @@ func (r *Resolver) LookupAllRecords(
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 // ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
// addresses, following CNAME chains up to MaxCNAMEDepth. When no // addresses, following CNAME chains up to MaxCNAMEDepth. When no
// nameserver of the name's zone answered, it returns an error rather // nameserver of the name's zone answered, it returns an error wrapping
// than no addresses. // ErrNoNameserverAnswered rather than no addresses.
func (r *Resolver) ResolveIPAddresses( func (r *Resolver) ResolveIPAddresses(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -794,8 +778,8 @@ func (r *Resolver) resolveIPWithCNAME(
// collectIPs returns the addresses in the nameservers' answers and the // collectIPs returns the addresses in the nameservers' answers and the
// first CNAME target among them. It returns ErrNoNameserverAnswered when // first CNAME target among them. It returns ErrNoNameserverAnswered when
// every nameserver timed out, failed or returned a referral: that is not // every nameserver timed out or failed: that is not a name with no
// a name with no addresses. // addresses.
func collectIPs( func collectIPs(
results map[string]*NameserverResponse, results map[string]*NameserverResponse,
) ([]string, string, error) { ) ([]string, string, error) {
-16
View File
@@ -25,22 +25,6 @@ func TestCollectIPs_OneAnswerIsEnough(t *testing.T) {
assert.Empty(t, ips) assert.Empty(t, ips)
} }
// TestCollectIPs_FailedIsNoAnswer checks that nameservers that all have
// status error, from a refusal, a server failure, a network error or a
// referral, are no answer rather than a name with no addresses.
func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
t.Parallel()
ips, _, err := resolver.CollectIPs(
map[string]*resolver.NameserverResponse{
"ns1.example.": {Status: resolver.StatusError},
"ns2.example.": {Status: resolver.StatusError},
},
)
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
assert.Empty(t, ips)
}
func TestExtractRecordValue_LetterCase(t *testing.T) { func TestExtractRecordValue_LetterCase(t *testing.T) {
t.Parallel() t.Parallel()
-47
View File
@@ -662,53 +662,6 @@ func TestCollectIPs_NoNameserverAnswered(t *testing.T) {
assert.Empty(t, ips) assert.Empty(t, ips)
} }
// TestCollectIPs_ReferralIsNoAnswer asks a root server about
// example.com, which the root zone does not hold, so it only refers the
// query to the com servers. That reply is no answer, as is a parent
// zone's when every server of the name's own zone failed.
func TestCollectIPs_ReferralIsNoAnswer(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var resp *resolver.NameserverResponse
livednstest.Retry(
t,
"QueryNameserverIP(a.root-servers.net, example.com)",
func(ctx context.Context) error {
var err error
resp, err = r.QueryNameserverIP(
ctx, "a.root-servers.net.", "198.41.0.4",
"example.com",
)
if err != nil {
return err
}
// A timeout or a network error is no reply at all.
if resp.Status == resolver.StatusTimeout ||
strings.HasPrefix(resp.Error, "network error") {
return fmt.Errorf(
"%w: %s", livednstest.ErrNoAnswer, resp.Error,
)
}
return nil
},
)
assert.Equal(t, resolver.StatusError, resp.Status)
assert.Equal(t, "server returned a referral", resp.Error)
ips, _, err := resolver.CollectIPs(
map[string]*resolver.NameserverResponse{resp.Nameserver: resp},
)
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
assert.Empty(t, ips)
}
func TestResolveIPAddresses_ContextCanceled(t *testing.T) { func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
t.Parallel() t.Parallel()
+11 -2
View File
@@ -3,8 +3,9 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. # or apk (detected in that order); assumes nothing is present.
# goimports is not installed here: script/fmt and script/fmt-check run # goimports is installed via `go install` at a pinned commit (never
# it with `go run` at a pinned commit. # "latest") because script/fmt runs it on the host; script/fmt-check
# does not (it runs gofmt only).
# The linter is NOT installed here: golangci-lint runs via docker only # The linter is NOT installed here: golangci-lint runs via docker only
# (script/lint), pinned by image digest, so its only prerequisite is a # (script/lint), pinned by image digest, so its only prerequisite is a
# working docker. # working docker.
@@ -12,6 +13,10 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned version, 2026-08-07 (same pin as the Dockerfile)
# goimports v0.42.0
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
PKGMGR="" PKGMGR=""
SUDO="" SUDO=""
APT_UPDATED="" APT_UPDATED=""
@@ -66,6 +71,10 @@ main() {
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
# Format tools, pinned via go install (installs into
# "$(go env GOPATH)/bin"; ensure that is on your PATH).
if missing goimports; then go install "$GOIMPORTS_REF"; fi
# Linting runs via docker only (script/lint). Warn, don't fail: # Linting runs via docker only (script/lint). Warn, don't fail:
# everything except `make lint` works without it. # everything except `make lint` works without it.
if missing docker; then if missing docker; then
+1 -7
View File
@@ -1,19 +1,13 @@
#!/bin/sh #!/bin/sh
# script/fmt: format all files (writes). # script/fmt: format all files (writes).
#
# goimports runs with `go run` at a pinned commit, never from PATH, so
# every machine formats with the same version and nothing installs it.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# goimports v0.42.0, 2026-08-07. Must match script/fmt-check.
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
main() { main() {
cd "$ROOT" cd "$ROOT"
gofmt -s -w . gofmt -s -w .
go run "$GOIMPORTS_REF" -w . goimports -w .
} }
main "$@" main "$@"
+3 -12
View File
@@ -1,27 +1,18 @@
#!/bin/sh #!/bin/sh
# script/fmt-check: check formatting (read-only). Same tools and scope # script/fmt-check: check formatting (read-only). Same scope as
# as script/fmt, but fails instead of writing. # script/fmt, but fails instead of writing.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# goimports v0.42.0, 2026-08-07. Must match script/fmt.
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
main() { main() {
cd "$ROOT" cd "$ROOT"
files="$(gofmt -s -l .)" files="$(gofmt -l .)"
if [ -n "$files" ]; then if [ -n "$files" ]; then
echo "gofmt: files not formatted:" >&2 echo "gofmt: files not formatted:" >&2
echo "$files" >&2 echo "$files" >&2
exit 1 exit 1
fi fi
files="$(go run "$GOIMPORTS_REF" -l .)"
if [ -n "$files" ]; then
echo "goimports: files not formatted:" >&2
echo "$files" >&2
exit 1
fi
} }
main "$@" main "$@"