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) |
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
other nameservers, has status `error`, empty `records`, and the reason in
`error`.
empty `records`. A nameserver whose query failed has status `error`, empty
`records`, and the reason in `error`.
`nameserverAddresses` lists, by nameserver, the sorted addresses its name
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
them. We provide:
- `script/bootstrap` — install all dependencies (go, `go mod download`).
It does not install golangci-lint: see `script/lint` below.
- `script/bootstrap` — install all dependencies (go, pinned goimports,
`go mod download`). It does not install golangci-lint: see
`script/lint` below.
- `script/setup` — make a fresh clone ready for development: bootstrap
plus the git pre-commit hook
- `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
linting: the lint stage is forced to execute on every run with
`--no-cache-filter`, because a cached build lints nothing.
- `script/fmt` — format all code (gofmt -s, goimports). goimports runs
with `go run` at a pinned commit, never from your `PATH`.
- `script/fmt-check` — check formatting (read-only) with the same tools,
failing on any file `script/fmt` would change
- `script/fmt` — format all code (gofmt -s, goimports)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`, with
`--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
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
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
@@ -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:
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
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
- README sections required by policy:
+2 -2
View File
@@ -11,8 +11,8 @@ var (
)
// ErrNoNameserverAnswered is returned when every nameserver
// asked about a name timed out, failed or returned a referral,
// so whether the name has addresses is unknown.
// asked about a name timed out or failed, so whether the name
// has addresses is unknown.
ErrNoNameserverAnswered = errors.New("no nameserver answered")
// ErrCNAMEDepthExceeded is returned when a CNAME chain
+4 -20
View File
@@ -516,7 +516,6 @@ type queryState struct {
gotSERVFAIL bool
gotRefused bool
gotTimeout bool
gotReferral bool
netErr error
hasRecords bool
}
@@ -579,18 +578,6 @@ func (r *Resolver) querySingleType(
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)
}
@@ -639,9 +626,6 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.netErr != nil && !state.hasRecords:
resp.Status = StatusError
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:
resp.Status = StatusNoData
}
@@ -751,8 +735,8 @@ func (r *Resolver) LookupAllRecords(
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
// addresses, following CNAME chains up to MaxCNAMEDepth. When no
// nameserver of the name's zone answered, it returns an error rather
// than no addresses.
// nameserver of the name's zone answered, it returns an error wrapping
// ErrNoNameserverAnswered rather than no addresses.
func (r *Resolver) ResolveIPAddresses(
ctx context.Context,
hostname string,
@@ -794,8 +778,8 @@ func (r *Resolver) resolveIPWithCNAME(
// collectIPs returns the addresses in the nameservers' answers and the
// first CNAME target among them. It returns ErrNoNameserverAnswered when
// every nameserver timed out, failed or returned a referral: that is not
// a name with no addresses.
// every nameserver timed out or failed: that is not a name with no
// addresses.
func collectIPs(
results map[string]*NameserverResponse,
) ([]string, string, error) {
-16
View File
@@ -25,22 +25,6 @@ func TestCollectIPs_OneAnswerIsEnough(t *testing.T) {
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) {
t.Parallel()
-47
View File
@@ -662,53 +662,6 @@ func TestCollectIPs_NoNameserverAnswered(t *testing.T) {
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) {
t.Parallel()
+11 -2
View File
@@ -3,8 +3,9 @@
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present.
# goimports is not installed here: script/fmt and script/fmt-check run
# it with `go run` at a pinned commit.
# goimports is installed via `go install` at a pinned commit (never
# "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
# (script/lint), pinned by image digest, so its only prerequisite is a
# working docker.
@@ -12,6 +13,10 @@ set -eu
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=""
SUDO=""
APT_UPDATED=""
@@ -66,6 +71,10 @@ main() {
if missing make; then pkg_install gnumake make make make; 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:
# everything except `make lint` works without it.
if missing docker; then
+1 -7
View File
@@ -1,19 +1,13 @@
#!/bin/sh
# 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
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() {
cd "$ROOT"
gofmt -s -w .
go run "$GOIMPORTS_REF" -w .
goimports -w .
}
main "$@"
+3 -12
View File
@@ -1,27 +1,18 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same tools and scope
# as script/fmt, but fails instead of writing.
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
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() {
cd "$ROOT"
files="$(gofmt -s -l .)"
files="$(gofmt -l .)"
if [ -n "$files" ]; then
echo "gofmt: files not formatted:" >&2
echo "$files" >&2
exit 1
fi
files="$(go run "$GOIMPORTS_REF" -l .)"
if [ -n "$files" ]; then
echo "goimports: files not formatted:" >&2
echo "$files" >&2
exit 1
fi
}
main "$@"