Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21e0ff99b | ||
|
|
b5814b2451 |
@@ -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,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ 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
|
||||||
@@ -114,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:
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ErrNoNameserverAnswered is returned when every nameserver
|
// ErrNoNameserverAnswered is returned when every nameserver
|
||||||
// asked about a name timed out or failed, so whether the name
|
// asked about a name timed out, failed or returned a referral,
|
||||||
// has addresses is unknown.
|
// so whether the name 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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -735,8 +751,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 wrapping
|
// nameserver of the name's zone answered, it returns an error rather
|
||||||
// ErrNoNameserverAnswered rather than no addresses.
|
// than no addresses.
|
||||||
func (r *Resolver) ResolveIPAddresses(
|
func (r *Resolver) ResolveIPAddresses(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
@@ -778,8 +794,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 or failed: that is not a name with no
|
// every nameserver timed out, failed or returned a referral: that is not
|
||||||
// addresses.
|
// a name with no addresses.
|
||||||
func collectIPs(
|
func collectIPs(
|
||||||
results map[string]*NameserverResponse,
|
results map[string]*NameserverResponse,
|
||||||
) ([]string, string, error) {
|
) ([]string, string, error) {
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -662,6 +662,53 @@ 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()
|
||||||
|
|
||||||
|
|||||||
+2
-11
@@ -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
@@ -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
@@ -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 "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user