Compare commits
2
Commits
d61d8e2c69
...
8b8800d76d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b8800d76d | ||
|
|
82836b41fd |
+7
-7
@@ -1,9 +1,9 @@
|
|||||||
.git/
|
# .git is sent, without its config: the builder stage derives the version it
|
||||||
|
# stamps into the binary from it, and `git describe` does not need the config,
|
||||||
|
# which can hold a credential (a password in the remote URL, a CI token). No
|
||||||
|
# tracked file may be listed here: git in the build would see it as deleted
|
||||||
|
# and mark the version -dirty, and an excluded .md would silently drop out of
|
||||||
|
# the prettier check in Dockerfile.fmt.
|
||||||
|
.git/config
|
||||||
bin/
|
bin/
|
||||||
node_modules/
|
node_modules/
|
||||||
# No .md may be excluded: Dockerfile.fmt checks every document with
|
|
||||||
# prettier, and an exclusion here would drop a file from that check while
|
|
||||||
# prettier still reports every file it was handed clean.
|
|
||||||
LICENSE
|
|
||||||
.editorconfig
|
|
||||||
.gitignore
|
|
||||||
|
|||||||
+20
-5
@@ -36,11 +36,26 @@ COPY . .
|
|||||||
# Run the tests - build fails if any test fails
|
# Run the tests - build fails if any test fails
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Build the binary. .dockerignore leaves out .git, so `git describe` in
|
# Version stamped into the binary: the VERSION build arg when one is
|
||||||
# the Makefile cannot find the version here: script/docker passes it as
|
# given and not empty (script/docker passes one), otherwise what
|
||||||
# --build-arg VERSION, and a build that passes none reports `dev`.
|
# `git describe` says of the .git in the build context, so a plain
|
||||||
ARG VERSION=dev
|
# `docker build .` of a clone stamps its tag or short commit. The build
|
||||||
RUN make build VERSION="${VERSION}"
|
# arg reaches make through the environment.
|
||||||
|
ARG VERSION
|
||||||
|
|
||||||
|
# A context that carries .git, as a directory or as a file, must yield a
|
||||||
|
# real version: one that is empty, `dev` or `unknown` cannot be traced
|
||||||
|
# back to a commit.
|
||||||
|
RUN version="$(make version)"; \
|
||||||
|
if [ -e .git ]; then \
|
||||||
|
case "$version" in \
|
||||||
|
"" | dev | unknown) \
|
||||||
|
echo "version is \"$version\" although the build context carries .git" >&2; \
|
||||||
|
exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN make build
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
# alpine 3.21, 2026-02-28
|
# alpine 3.21, 2026-02-28
|
||||||
|
|||||||
+2
-3
@@ -30,9 +30,8 @@ COPY . .
|
|||||||
# --config, not discovery: a .prettierrc that failed to arrive would
|
# --config, not discovery: a .prettierrc that failed to arrive would
|
||||||
# otherwise leave prettier on its defaults, where proseWrap is "preserve"
|
# otherwise leave prettier on its defaults, where proseWrap is "preserve"
|
||||||
# and every wrap this check exists to enforce passes. Missing the file is
|
# and every wrap this check exists to enforce passes. Missing the file is
|
||||||
# a hard error instead. --no-editorconfig for the same reason in reverse:
|
# a hard error instead. --no-editorconfig so that .prettierrc alone sets
|
||||||
# .editorconfig is not in the build context, so honouring it here and on
|
# the style.
|
||||||
# a developer's machine would be two different answers.
|
|
||||||
RUN prettier --config .prettierrc --no-editorconfig --check "**/*.md"
|
RUN prettier --config .prettierrc --no-editorconfig --check "**/*.md"
|
||||||
|
|
||||||
# Write path. Not a check: script/fmt builds this and takes the files.
|
# Write path. Not a check: script/fmt builds this and takes the files.
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
.PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker
|
.PHONY: all bootstrap setup build version lint fmt fmt-check test check clean hooks docker
|
||||||
|
|
||||||
BINARY := dnswatcher
|
BINARY := dnswatcher
|
||||||
# `make build VERSION=...` overrides this; the Dockerfile does so, as the
|
# VERSION given on the command line (`make build VERSION=...`) or in the
|
||||||
# image has no .git to describe.
|
# environment, which is how the Dockerfile's VERSION build arg arrives,
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
# wins over what `git describe` says of this checkout. An empty one counts
|
||||||
|
# as not given; `override` is what replaces an empty command-line value.
|
||||||
|
ifeq ($(VERSION),)
|
||||||
|
override VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
|
endif
|
||||||
LDFLAGS := -X main.Version=$(VERSION)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
|
|
||||||
# Standard targets are thin shims; the implementations live in script/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
@@ -21,6 +25,10 @@ setup:
|
|||||||
build:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher
|
||||||
|
|
||||||
|
# Prints the version `make build` stamps; the Dockerfile checks it.
|
||||||
|
version:
|
||||||
|
@echo "$(VERSION)"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
|
|||||||
@@ -407,6 +407,13 @@ it watches. Instead, it performs full iterative resolution:
|
|||||||
4. **Authoritative query**: Queries all discovered authoritative nameservers
|
4. **Authoritative query**: Queries all discovered authoritative nameservers
|
||||||
directly for the requested records.
|
directly for the requested records.
|
||||||
|
|
||||||
|
In steps 2 and 3 the servers are asked one at a time in a random order, chosen
|
||||||
|
anew each time, so no one root server gets every first query. A server that does
|
||||||
|
not reply, refuses the query, or gives an error reply such as SERVFAIL or a
|
||||||
|
referral that leads no closer to the name is passed over for the next one. When
|
||||||
|
a referral names a zone's nameservers without their addresses, the addresses of
|
||||||
|
all of them are looked up, so that each can be asked.
|
||||||
|
|
||||||
This approach ensures:
|
This approach ensures:
|
||||||
|
|
||||||
- Independence from any upstream resolver's cache or filtering.
|
- Independence from any upstream resolver's cache or filtering.
|
||||||
@@ -567,6 +574,7 @@ provide:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
make build # Build binary to bin/dnswatcher
|
make build # Build binary to bin/dnswatcher
|
||||||
|
make version # Print the version make build stamps
|
||||||
make test # Run tests with race detector
|
make test # Run tests with race detector
|
||||||
make lint # Run golangci-lint in Docker (requires docker)
|
make lint # Run golangci-lint in Docker (requires docker)
|
||||||
make fmt # Format code and Markdown (requires docker)
|
make fmt # Format code and Markdown (requires docker)
|
||||||
@@ -577,13 +585,21 @@ make clean # Remove build artifacts
|
|||||||
### Build-Time Variables
|
### Build-Time Variables
|
||||||
|
|
||||||
`make build` sets the version with `-ldflags "-X main.Version=..."`, taking it
|
`make build` sets the version with `-ldflags "-X main.Version=..."`, taking it
|
||||||
from `git describe --tags --always --dirty`, or from `VERSION` when given on the
|
from `VERSION` when given on the command line (`make build VERSION=1.2.3`) or in
|
||||||
command line (`make build VERSION=1.2.3`). The version appears in the startup
|
the environment, otherwise from `git describe --tags --always --dirty`, and
|
||||||
log and in the health check response.
|
`dev` without git metadata. An empty `VERSION` counts as not given. The version
|
||||||
|
appears in the startup log and in the health check response.
|
||||||
|
|
||||||
The Docker image has no `.git`, so the `Dockerfile` takes the version as
|
The image takes it the same way, from the `.git` the build context carries, so a
|
||||||
`--build-arg VERSION`. `make docker` passes it; a plain `docker build` passes
|
plain `docker build .` of a clone stamps the commit it was built from; a clone
|
||||||
none, and that image reports `dev`.
|
without tags, such as a shallow one, stamps the short commit. `.dockerignore`
|
||||||
|
sends `.git` without `.git/config`, which `git describe` does not need and which
|
||||||
|
can hold a credential. A non-empty `--build-arg VERSION=...` takes precedence;
|
||||||
|
`make docker` passes the version `git describe` gives on the host. The build
|
||||||
|
fails when the context carries `.git`, as a directory or as a file, and the
|
||||||
|
version comes out empty, `dev` or `unknown`. `.dockerignore` must list no
|
||||||
|
tracked file: git in the build would see it as deleted and mark the version
|
||||||
|
`-dirty`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-02: a plain `docker build .` of a clone stamps its tag or short
|
||||||
|
commit, not `dev`: the build context now carries `.git` (closes #210).
|
||||||
|
- 2026-10-02: the resolver tries root servers, and every other server list it
|
||||||
|
walks, in a random order each time, not always from the top (closes #138).
|
||||||
- 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any
|
- 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any
|
||||||
letter case or with a trailing dot, is watched once (closes #207).
|
letter case or with a trailing dot, is watched once (closes #207).
|
||||||
- 2026-10-01: README checked against the code and corrected: metrics, CORS,
|
- 2026-10-01: README checked against the code and corrected: metrics, CORS,
|
||||||
@@ -131,5 +135,4 @@ trial run of the finished image: 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
|
||||||
- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138
|
|
||||||
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
|
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
//
|
//
|
||||||
// 1. Bounded concurrency. Tests run in parallel and the build hosts
|
// 1. Bounded concurrency. Tests run in parallel and the build hosts
|
||||||
// have many cores, so without a limit every test starts its own
|
// have many cores, so without a limit every test starts its own
|
||||||
// iterative resolution at the same instant and they all hit the
|
// iterative resolution at the same instant and they all send their
|
||||||
// first root server within a few milliseconds of each other. Root
|
// first queries to the root servers within a few milliseconds of
|
||||||
// servers rate-limit that, which shows up as a different arbitrary
|
// each other. Root servers rate-limit that, which shows up as a
|
||||||
// subset of tests failing on each run. Run caps how many live
|
// different arbitrary subset of tests failing on each run. Run caps
|
||||||
// operations are in flight at once in one test binary.
|
// how many live operations are in flight at once in one test binary.
|
||||||
//
|
//
|
||||||
// 2. Retry with exponential backoff. Each live operation gets several
|
// 2. Retry with exponential backoff. Each live operation gets several
|
||||||
// attempts with its own timeout. An attempt is retried when it
|
// attempts with its own timeout. An attempt is retried when it
|
||||||
|
|||||||
@@ -36,3 +36,24 @@ func (r *Resolver) QueryEachNS(
|
|||||||
) (map[string]*NameserverResponse, error) {
|
) (map[string]*NameserverResponse, error) {
|
||||||
return r.queryEachNS(ctx, nameservers, hostname)
|
return r.queryEachNS(ctx, nameservers, hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveNSIPs exports resolveNSIPs for testing.
|
||||||
|
func (r *Resolver) ResolveNSIPs(
|
||||||
|
ctx context.Context,
|
||||||
|
nsNames []string,
|
||||||
|
) []string {
|
||||||
|
return r.resolveNSIPs(ctx, nsNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RootServerList exports rootServerList for testing.
|
||||||
|
func RootServerList() []string {
|
||||||
|
return rootServerList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffled exports shuffled for testing.
|
||||||
|
func Shuffled(
|
||||||
|
servers []string,
|
||||||
|
shuffle func(n int, swap func(i, j int)),
|
||||||
|
) []string {
|
||||||
|
return shuffled(servers, shuffle)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"net"
|
"net"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -259,9 +261,25 @@ func (r *Resolver) followDelegation(
|
|||||||
return nil, ErrNoNameservers
|
return nil, ErrNoNameservers
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryServers asks servers, the servers of zone, about name until one
|
// shuffled returns a copy of servers in the order shuffle puts them
|
||||||
// gives a usable reply. A server that times out, refuses or gives a
|
// in. The resolver passes rand.Shuffle, so each time it walks a list of
|
||||||
// reply that is not usable is passed over for the next.
|
// servers it starts at a random one, and no one server gets every
|
||||||
|
// first query.
|
||||||
|
func shuffled(
|
||||||
|
servers []string,
|
||||||
|
shuffle func(n int, swap func(i, j int)),
|
||||||
|
) []string {
|
||||||
|
order := slices.Clone(servers)
|
||||||
|
shuffle(len(order), func(i, j int) {
|
||||||
|
order[i], order[j] = order[j], order[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryServers asks servers, the servers of zone, about name in a random
|
||||||
|
// order until one gives a usable reply. A server that times out, refuses
|
||||||
|
// or gives a reply that is not usable is passed over for the next.
|
||||||
func (r *Resolver) queryServers(
|
func (r *Resolver) queryServers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
servers []string,
|
servers []string,
|
||||||
@@ -271,7 +289,7 @@ func (r *Resolver) queryServers(
|
|||||||
) (*dns.Msg, error) {
|
) (*dns.Msg, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for _, ip := range servers {
|
for _, ip := range shuffled(servers, rand.Shuffle) {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
return nil, ErrContextCanceled
|
return nil, ErrContextCanceled
|
||||||
}
|
}
|
||||||
@@ -340,6 +358,10 @@ func nsSetFrom(resp *dns.Msg, domain string) []string {
|
|||||||
return extractNSSet(resp.Answer)
|
return extractNSSet(resp.Answer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveNSIPs returns the addresses of every nameserver in nsNames
|
||||||
|
// whose name resolves, for a referral that carries none. The walk can
|
||||||
|
// then go on to the zone's other nameservers when one gives no usable
|
||||||
|
// reply.
|
||||||
func (r *Resolver) resolveNSIPs(
|
func (r *Resolver) resolveNSIPs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsNames []string,
|
nsNames []string,
|
||||||
@@ -351,10 +373,6 @@ func (r *Resolver) resolveNSIPs(
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
ips = append(ips, resolved...)
|
ips = append(ips, resolved...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ips) > 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ips
|
return ips
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package resolver_test
|
package resolver_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
@@ -235,3 +237,30 @@ func TestExtractRecordValue_LetterCase(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestShuffled shuffles the root servers with many seeds. Every order
|
||||||
|
// must hold each root server once, so each is tried before a
|
||||||
|
// resolution fails; each root server must come first for some seed, so
|
||||||
|
// no one root server gets every first query; and the list passed in
|
||||||
|
// must be left as it was.
|
||||||
|
func TestShuffled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const seeds = 1000
|
||||||
|
|
||||||
|
roots := resolver.RootServerList()
|
||||||
|
before := slices.Clone(roots)
|
||||||
|
first := make(map[string]bool)
|
||||||
|
|
||||||
|
for seed := range uint64(seeds) {
|
||||||
|
rng := rand.New(rand.NewPCG(seed, 0)) //nolint:gosec // seeded on purpose
|
||||||
|
order := resolver.Shuffled(roots, rng.Shuffle)
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, roots, order)
|
||||||
|
|
||||||
|
first[order[0]] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, first, len(roots))
|
||||||
|
assert.Equal(t, before, roots)
|
||||||
|
}
|
||||||
|
|||||||
@@ -383,3 +383,37 @@ func liveResolveIPsAllowingEmpty(
|
|||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// liveResolveNSIPs looks up the addresses of the nameservers named
|
||||||
|
// names, retrying until there are at least atLeast of them: a name
|
||||||
|
// whose lookup got no reply is left out of the result, not an error.
|
||||||
|
func liveResolveNSIPs(
|
||||||
|
t *testing.T,
|
||||||
|
r *resolver.Resolver,
|
||||||
|
names []string,
|
||||||
|
atLeast int,
|
||||||
|
) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"ResolveNSIPs("+strings.Join(names, ", ")+")",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
ips := r.ResolveNSIPs(ctx, names)
|
||||||
|
if len(ips) < atLeast {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %d addresses, expected at least %d",
|
||||||
|
livednstest.ErrNoAnswer, len(ips), atLeast,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
out = ips
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -139,6 +139,28 @@ func TestFindAuthoritativeNameservers_CloudflareDomain(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResolveNSIPs_EveryNameserver looks up the addresses of two of
|
||||||
|
// google.com's nameservers together, as the walk does when a referral
|
||||||
|
// names a zone's nameservers without their addresses, and compares them
|
||||||
|
// with each looked up alone. Together they must give the addresses of
|
||||||
|
// both, not only of the first that resolves, so that when one gives no
|
||||||
|
// usable reply the walk goes on to the other.
|
||||||
|
func TestResolveNSIPs_EveryNameserver(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
names := []string{"ns3.google.com.", "ns4.google.com."}
|
||||||
|
want := make([]string, 0, len(names))
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
want = append(want, liveResolveNSIPs(t, r, []string{name}, 1)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := liveResolveNSIPs(t, r, names, len(want))
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// QueryNameserver tests
|
// QueryNameserver tests
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@ main() {
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# Own line: a failing command substitution inside an argument does
|
# Own line: a failing command substitution inside an argument does
|
||||||
# not trip `set -e`, so the inline form degrades silently to an
|
# not trip `set -e`, so the inline form degrades silently to an
|
||||||
# empty constant. VERSION is computed here because .dockerignore
|
# empty constant. The VERSION build arg takes precedence over what
|
||||||
# excludes .git, so `git describe` in a build stage cannot find it.
|
# the build would derive from the .git in its context.
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
[ -n "$version" ] || version="unknown"
|
[ -n "$version" ] || version="unknown"
|
||||||
docker build --no-cache-filter=lint,builder \
|
docker build --no-cache-filter=lint,builder \
|
||||||
|
|||||||
Reference in New Issue
Block a user