2 Commits
Author SHA1 Message Date
sneak d21e0ff99b resolver: error from ResolveIPAddresses when no nameserver answered (closes #190)
check / check (push) Failing after 2m13s
ResolveIPAddresses now returns an error, not no addresses, when no
nameserver of the name's zone answered. A nameserver with status
timeout or error is not an answer; one answer, even NXDOMAIN, is
enough for an empty result without an error.

When every server of a zone fails, FindAuthoritativeNameservers moves
on to the parent name, whose servers only refer the query onward. Such
a referral now has status error, so it is no answer either, and a
hostname's saved records show it as 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 22:10:29 +00:00
clawbot b5814b2451 fmt: check goimports in fmt-check, run it at its pinned commit (#119)
check / check (push) Failing after 2m28s
script/fmt-check now runs goimports in list mode and fails naming any
file it would change, and checks gofmt with -s, as script/fmt applies
it. Both scripts run goimports with `go run` at the commit that
script/bootstrap used to install, so a goimports on PATH is never used
and bootstrap no longer installs it. The pin is written in both
scripts; change them together. The first run on a machine, and every
Dockerfile lint stage run, downloads and builds goimports.

The Markdown half of the issue (prettier) is not done here: it needs
node in the lint image or a separate build, a decision for the owner.

Model: opus-5-5
2026-10-01 23:51:22 +02:00
11 changed files with 199 additions and 31 deletions
+9 -7
View File
@@ -491,8 +491,9 @@ 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 has status `error`, empty empty `records`. A nameserver whose query failed, or that only referred it to
`records`, and the reason in `error`. other nameservers, has status `error`, empty `records`, and the reason in
`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
@@ -508,9 +509,8 @@ 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, pinned goimports, - `script/bootstrap` — install all dependencies (go, `go mod download`).
`go mod download`). It does not install golangci-lint: see It does not install golangci-lint: see `script/lint` below.
`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,8 +527,10 @@ 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) - `script/fmt` — format all code (gofmt -s, goimports). goimports runs
- `script/fmt-check` — check formatting (read-only) 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/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,
+5 -1
View File
@@ -20,6 +20,10 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 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 - 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
@@ -112,7 +116,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
- `goimports` in `make fmt-check`, Markdown formatting: - Markdown formatting with prettier:
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:
+5
View File
@@ -10,6 +10,11 @@ var (
"no authoritative nameservers found", "no authoritative nameservers found",
) )
// 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.
ErrNoNameserverAnswered = errors.New("no nameserver answered")
// ErrCNAMEDepthExceeded is returned when a CNAME chain // ErrCNAMEDepthExceeded is returned when a CNAME chain
// exceeds MaxCNAMEDepth. // exceeds MaxCNAMEDepth.
ErrCNAMEDepthExceeded = errors.New( ErrCNAMEDepthExceeded = errors.New(
+7
View File
@@ -11,6 +11,13 @@ func ExtractRecordValue(rr dns.RR) string {
return extractRecordValue(rr) return extractRecordValue(rr)
} }
// CollectIPs exports collectIPs for testing.
func CollectIPs(
results map[string]*NameserverResponse,
) ([]string, string, error) {
return collectIPs(results)
}
// QueryEachNS exports queryEachNS for testing. // QueryEachNS exports queryEachNS for testing.
func (r *Resolver) QueryEachNS( func (r *Resolver) QueryEachNS(
ctx context.Context, ctx context.Context,
+41 -4
View File
@@ -516,6 +516,7 @@ 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
} }
@@ -578,6 +579,18 @@ 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)
} }
@@ -626,6 +639,9 @@ 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
} }
@@ -734,7 +750,9 @@ 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. // addresses, following CNAME chains up to MaxCNAMEDepth. When no
// nameserver of the name's zone answered, it returns an error rather
// than no addresses.
func (r *Resolver) ResolveIPAddresses( func (r *Resolver) ResolveIPAddresses(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -760,7 +778,10 @@ func (r *Resolver) resolveIPWithCNAME(
return nil, err return nil, err
} }
ips, cnameTarget := collectIPs(results) ips, cnameTarget, err := collectIPs(results)
if err != nil {
return nil, fmt.Errorf("resolving %s: %w", hostname, err)
}
if len(ips) == 0 && cnameTarget != "" { if len(ips) == 0 && cnameTarget != "" {
return r.resolveIPWithCNAME(ctx, cnameTarget, depth+1) return r.resolveIPWithCNAME(ctx, cnameTarget, depth+1)
@@ -771,16 +792,28 @@ func (r *Resolver) resolveIPWithCNAME(
return ips, nil return ips, nil
} }
// 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.
func collectIPs( func collectIPs(
results map[string]*NameserverResponse, results map[string]*NameserverResponse,
) ([]string, string) { ) ([]string, string, error) {
seen := make(map[string]bool) seen := make(map[string]bool)
var ips []string var ips []string
var cnameTarget string var cnameTarget string
answered := false
for _, resp := range results { for _, resp := range results {
if resp.Status == StatusTimeout || resp.Status == StatusError {
continue
}
answered = true
if resp.Status == StatusNXDomain { if resp.Status == StatusNXDomain {
continue continue
} }
@@ -804,5 +837,9 @@ func collectIPs(
} }
} }
return ips, cnameTarget if !answered {
return nil, "", ErrNoNameserverAnswered
}
return ips, cnameTarget, nil
} }
+32
View File
@@ -5,10 +5,42 @@ import (
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/resolver"
) )
// TestCollectIPs_OneAnswerIsEnough checks that one nameserver answering
// NXDOMAIN says the name has no addresses, though the other timed out.
func TestCollectIPs_OneAnswerIsEnough(t *testing.T) {
t.Parallel()
ips, _, err := resolver.CollectIPs(
map[string]*resolver.NameserverResponse{
"ns1.example.": {Status: resolver.StatusTimeout},
"ns2.example.": {Status: resolver.StatusNXDomain},
},
)
require.NoError(t, err)
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()
+76
View File
@@ -633,6 +633,82 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
assert.NotEmpty(t, resp.Error) assert.NotEmpty(t, resp.Error)
} }
// TestCollectIPs_NoNameserverAnswered takes the response of a
// nameserver at 192.0.2.1, where nothing answers, as
// TestQueryNameserverIP_Timeout does. Addresses collected from
// nameservers that all failed to answer are an error, not none.
func TestCollectIPs_NoNameserverAnswered(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
// The deadline outlasts the first try, as in
// TestQueryNameserverIP_Timeout.
ctx, cancel := context.WithTimeout(
context.Background(), 3*time.Second,
)
t.Cleanup(cancel)
resp, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1",
"example.com",
)
require.NoError(t, err)
ips, _, err := resolver.CollectIPs(
map[string]*resolver.NameserverResponse{resp.Nameserver: resp},
)
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
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()
+3 -4
View File
@@ -321,10 +321,9 @@ func (w *Watcher) detectNSChanges(
} }
// resolveNameserverAddresses returns the sorted addresses each // resolveNameserverAddresses returns the sorted addresses each
// nameserver's name resolves to. A nameserver whose lookup fails or // nameserver's name resolves to. A nameserver whose lookup fails, as it
// finds no address keeps its addresses from prev: the resolver finds no // does when no nameserver of the name's zone answers, or finds no
// address, without an error, when every server it asks times out, and // address keeps its addresses from prev and is not an address change.
// that is not an address change.
func (w *Watcher) resolveNameserverAddresses( func (w *Watcher) resolveNameserverAddresses(
ctx context.Context, ctx context.Context,
nameservers []string, nameservers []string,
+2 -11
View File
@@ -3,9 +3,8 @@
# 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 installed via `go install` at a pinned commit (never # goimports is not installed here: script/fmt and script/fmt-check run
# "latest") because script/fmt runs it on the host; script/fmt-check # it with `go run` at a pinned commit.
# 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.
@@ -13,10 +12,6 @@ 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=""
@@ -71,10 +66,6 @@ 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
+7 -1
View File
@@ -1,13 +1,19 @@
#!/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 .
goimports -w . go run "$GOIMPORTS_REF" -w .
} }
main "$@" main "$@"
+12 -3
View File
@@ -1,18 +1,27 @@
#!/bin/sh #!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as # script/fmt-check: check formatting (read-only). Same tools and scope
# script/fmt, but fails instead of writing. # as 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 -l .)" files="$(gofmt -s -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 "$@"