Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b8800d76d | ||
|
|
82836b41fd |
+7
-4
@@ -1,6 +1,9 @@
|
|||||||
# .git is sent: the builder stage derives the version it stamps into the
|
# .git is sent, without its config: the builder stage derives the version it
|
||||||
# binary from it. No tracked file may be listed here: git in the build
|
# stamps into the binary from it, and `git describe` does not need the config,
|
||||||
# would see it as deleted and mark the version -dirty, and an excluded .md
|
# which can hold a credential (a password in the remote URL, a CI token). No
|
||||||
# would silently drop out of the prettier check in Dockerfile.fmt.
|
# 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/
|
||||||
|
|||||||
+8
-7
@@ -37,16 +37,17 @@ COPY . .
|
|||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Version stamped into the binary: the VERSION build arg when one is
|
# Version stamped into the binary: the VERSION build arg when one is
|
||||||
# given (script/docker passes one), otherwise what `git describe` says of
|
# given and not empty (script/docker passes one), otherwise what
|
||||||
# the .git in the build context, so a plain `docker build .` of a clone
|
# `git describe` says of the .git in the build context, so a plain
|
||||||
# stamps its tag or short commit. The build arg reaches make through the
|
# `docker build .` of a clone stamps its tag or short commit. The build
|
||||||
# environment.
|
# arg reaches make through the environment.
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
|
|
||||||
# A context that carries .git must yield a real version: one that is
|
# A context that carries .git, as a directory or as a file, must yield a
|
||||||
# empty, `dev` or `unknown` cannot be traced back to a commit.
|
# real version: one that is empty, `dev` or `unknown` cannot be traced
|
||||||
|
# back to a commit.
|
||||||
RUN version="$(make version)"; \
|
RUN version="$(make version)"; \
|
||||||
if [ -d .git ]; then \
|
if [ -e .git ]; then \
|
||||||
case "$version" in \
|
case "$version" in \
|
||||||
"" | dev | unknown) \
|
"" | dev | unknown) \
|
||||||
echo "version is \"$version\" although the build context carries .git" >&2; \
|
echo "version is \"$version\" although the build context carries .git" >&2; \
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
BINARY := dnswatcher
|
BINARY := dnswatcher
|
||||||
# VERSION given on the command line (`make build VERSION=...`) or in the
|
# VERSION given on the command line (`make build VERSION=...`) or in the
|
||||||
# environment, which is how the Dockerfile's VERSION build arg arrives,
|
# environment, which is how the Dockerfile's VERSION build arg arrives,
|
||||||
# wins over what `git describe` says of this checkout.
|
# wins over what `git describe` says of this checkout. An empty one counts
|
||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
# 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/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -580,17 +587,19 @@ make clean # Remove build artifacts
|
|||||||
`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 `VERSION` when given on the command line (`make build VERSION=1.2.3`) or in
|
from `VERSION` when given on the command line (`make build VERSION=1.2.3`) or in
|
||||||
the environment, otherwise from `git describe --tags --always --dirty`, and
|
the environment, otherwise from `git describe --tags --always --dirty`, and
|
||||||
`dev` without git metadata. The version appears in the startup log and in the
|
`dev` without git metadata. An empty `VERSION` counts as not given. The version
|
||||||
health check response.
|
appears in the startup log and in the health check response.
|
||||||
|
|
||||||
The image takes it the same way, from the `.git` the build context carries, so a
|
The image takes it the same way, from the `.git` the build context carries, so a
|
||||||
plain `docker build .` of a clone stamps the commit it was built from; a clone
|
plain `docker build .` of a clone stamps the commit it was built from; a clone
|
||||||
without tags, such as a shallow one, stamps the short commit. A
|
without tags, such as a shallow one, stamps the short commit. `.dockerignore`
|
||||||
`--build-arg VERSION=...` takes precedence; `make docker` passes the version
|
sends `.git` without `.git/config`, which `git describe` does not need and which
|
||||||
`git describe` gives on the host. The build fails when the context carries
|
can hold a credential. A non-empty `--build-arg VERSION=...` takes precedence;
|
||||||
`.git` and the version comes out empty, `dev` or `unknown`. `.dockerignore` must
|
`make docker` passes the version `git describe` gives on the host. The build
|
||||||
list no tracked file: git in the build would see it as deleted and mark the
|
fails when the context carries `.git`, as a directory or as a file, and the
|
||||||
version `-dirty`.
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
- 2026-10-02: a plain `docker build .` of a clone stamps its tag or short
|
- 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).
|
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,
|
||||||
@@ -133,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
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user