Compare commits
2
Commits
017eede779
...
87aa5c2d04
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87aa5c2d04 | ||
|
|
97c8138c85 |
@@ -110,7 +110,9 @@ Contributions that introduce mocked, faked, or stubbed DNS will be rejected.
|
|||||||
- Port transitioned from open to closed (or vice versa).
|
- Port transitioned from open to closed (or vice versa).
|
||||||
- New IP appeared (from DNS change) and its port state was recorded.
|
- New IP appeared (from DNS change) and its port state was recorded.
|
||||||
- IP disappeared (from DNS change) — noted in the DNS change notification;
|
- IP disappeared (from DNS change) — noted in the DNS change notification;
|
||||||
port state for that IP is removed.
|
port state for that IP is removed. When none of a name's nameservers
|
||||||
|
answered, its addresses are not known, so the port state saved for them is
|
||||||
|
kept.
|
||||||
|
|
||||||
### TLS Certificate Monitoring
|
### TLS Certificate Monitoring
|
||||||
|
|
||||||
@@ -399,6 +401,11 @@ 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, or refuses the query, is passed over for the next one; the first
|
||||||
|
other reply is used, even a SERVFAIL, and no further server is 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.
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-01: 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-01: when none of a configured name's nameservers answered, the port
|
||||||
|
state saved for its addresses is kept, not removed (closes #193).
|
||||||
- 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` and `make fmt-check` cover Markdown with prettier, run
|
- 2026-10-01: `make fmt` and `make fmt-check` cover Markdown with prettier, run
|
||||||
@@ -120,5 +124,4 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
- 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:
|
||||||
https://git.eeqj.de/sneak/dnswatcher/issues/173
|
https://git.eeqj.de/sneak/dnswatcher/issues/173
|
||||||
- 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
|
||||||
|
|||||||
@@ -26,3 +26,16 @@ func (r *Resolver) QueryEachNS(
|
|||||||
) (map[string]*NameserverResponse, error) {
|
) (map[string]*NameserverResponse, error) {
|
||||||
return r.queryEachNS(ctx, nameservers, hostname)
|
return r.queryEachNS(ctx, nameservers, hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
@@ -255,6 +257,24 @@ func (r *Resolver) followDelegation(
|
|||||||
return nil, ErrNoNameservers
|
return nil, ErrNoNameservers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shuffled returns a copy of servers in the order shuffle puts them
|
||||||
|
// in. The resolver passes rand.Shuffle, so each time it walks a list of
|
||||||
|
// 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 the servers in a random order and returns the
|
||||||
|
// first reply; it fails only when every server has failed.
|
||||||
func (r *Resolver) queryServers(
|
func (r *Resolver) queryServers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
servers []string,
|
servers []string,
|
||||||
@@ -263,7 +283,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
|
||||||
}
|
}
|
||||||
@@ -279,13 +299,15 @@ func (r *Resolver) queryServers(
|
|||||||
return nil, fmt.Errorf("all servers failed: %w", lastErr)
|
return nil, fmt.Errorf("all servers failed: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveNSIPs returns the addresses of one of the nameservers, trying
|
||||||
|
// their names in a random order until one resolves.
|
||||||
func (r *Resolver) resolveNSIPs(
|
func (r *Resolver) resolveNSIPs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsNames []string,
|
nsNames []string,
|
||||||
) []string {
|
) []string {
|
||||||
var ips []string
|
var ips []string
|
||||||
|
|
||||||
for _, ns := range nsNames {
|
for _, ns := range shuffled(nsNames, rand.Shuffle) {
|
||||||
resolved, err := r.resolveARecord(ctx, ns)
|
resolved, err := r.resolveARecord(ctx, ns)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ips = append(ips, resolved...)
|
ips = append(ips, resolved...)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -92,3 +94,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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ func (w *Watcher) DetectNSAddressChanges(
|
|||||||
w.detectNSAddressChanges(ctx, domain, prev, current)
|
w.detectNSAddressChanges(ctx, domain, prev, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckAllPorts exports checkAllPorts for testing.
|
||||||
|
func (w *Watcher) CheckAllPorts(ctx context.Context) {
|
||||||
|
w.checkAllPorts(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// BuildHostnameState exports buildHostnameState for testing.
|
// BuildHostnameState exports buildHostnameState for testing.
|
||||||
func BuildHostnameState(
|
func BuildHostnameState(
|
||||||
results map[string]*resolver.NameserverResponse,
|
results map[string]*resolver.NameserverResponse,
|
||||||
|
|||||||
@@ -331,3 +331,118 @@ func TestNameserverThatRefuses(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPortStateWhenNoNameserverAnswered runs the port checks on
|
||||||
|
// hostname state built here, which gives the name no address. The port
|
||||||
|
// state saved for its old address is kept only when the name is a
|
||||||
|
// configured hostname or domain and none of its nameservers answered.
|
||||||
|
func TestPortStateWhenNoNameserverAnswered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
noneAnswered := saved(map[string]*state.NameserverRecordState{
|
||||||
|
nsA: failed(), nsB: failed(),
|
||||||
|
})
|
||||||
|
oneAnsweredNoAddress := saved(map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{}), nsB: failed(),
|
||||||
|
})
|
||||||
|
|
||||||
|
configured := []string{host}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hostname *state.HostnameState
|
||||||
|
hostnames []string
|
||||||
|
domains []string
|
||||||
|
wantKept bool
|
||||||
|
}{
|
||||||
|
{"no nameserver answered", noneAnswered, configured, nil, true},
|
||||||
|
{
|
||||||
|
"no nameserver answered, configured as a domain",
|
||||||
|
noneAnswered, nil, configured, true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"one answered with no address",
|
||||||
|
oneAnsweredNoAddress, configured, nil, false,
|
||||||
|
},
|
||||||
|
{"no nameserver answered, not configured", noneAnswered, nil, nil, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Hostnames = tt.hostnames
|
||||||
|
cfg.Domains = tt.domains
|
||||||
|
|
||||||
|
// The port checks read the saved hostname state and look
|
||||||
|
// nothing up, so the watcher has no resolver.
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg, deps.state, nil,
|
||||||
|
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
key := ip1 + ":443"
|
||||||
|
|
||||||
|
deps.state.SetHostnameState(host, tt.hostname)
|
||||||
|
deps.state.SetPortState(key, &state.PortState{
|
||||||
|
Open: true, Hostnames: []string{host},
|
||||||
|
})
|
||||||
|
|
||||||
|
w.CheckAllPorts(t.Context())
|
||||||
|
|
||||||
|
_, kept := deps.state.GetPortState(key)
|
||||||
|
if kept != tt.wantKept {
|
||||||
|
t.Errorf("port state %s kept: %v, want %v", key, kept, tt.wantKept)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortStateWhenNoNameserverAnsweredAndOtherNameMovesAway saves the
|
||||||
|
// port state of an address two configured hostnames resolve to. While
|
||||||
|
// none of the first one's nameservers answer, the port checks run with
|
||||||
|
// the other one still at that address, then after it moved away; the
|
||||||
|
// port state is kept both times.
|
||||||
|
func TestPortStateWhenNoNameserverAnsweredAndOtherNameMovesAway(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const other = "mail.example.net"
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Hostnames = []string{host, other}
|
||||||
|
|
||||||
|
// The port checks read the saved hostname state and look nothing
|
||||||
|
// up, so the watcher has no resolver.
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg, deps.state, nil,
|
||||||
|
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
key := ip1 + ":443"
|
||||||
|
|
||||||
|
deps.state.SetPortState(key, &state.PortState{
|
||||||
|
Open: true, Hostnames: []string{host, other},
|
||||||
|
})
|
||||||
|
deps.state.SetHostnameState(host, saved(
|
||||||
|
map[string]*state.NameserverRecordState{nsA: failed(), nsB: failed()},
|
||||||
|
))
|
||||||
|
|
||||||
|
for _, otherIP := range []string{ip1, ip2} {
|
||||||
|
deps.state.SetHostnameState(other, saved(
|
||||||
|
map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{"A": {otherIP}}),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
w.CheckAllPorts(t.Context())
|
||||||
|
|
||||||
|
if _, kept := deps.state.GetPortState(key); !kept {
|
||||||
|
t.Fatalf("port state %s removed with %s at %s", key, other, otherIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -708,15 +709,46 @@ func parsePortKey(key string) (string, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cleanupStalePorts removes port state entries that are no
|
// cleanupStalePorts removes port state entries that are no
|
||||||
// longer referenced by any hostname in the current DNS data.
|
// longer referenced by any hostname in the current DNS data. An
|
||||||
|
// entry saved for a configured name none of whose nameservers
|
||||||
|
// answered is kept: that name's addresses are not known, not gone.
|
||||||
func (w *Watcher) cleanupStalePorts(
|
func (w *Watcher) cleanupStalePorts(
|
||||||
currentAssociations map[string][]string,
|
currentAssociations map[string][]string,
|
||||||
) {
|
) {
|
||||||
for _, key := range w.state.GetAllPortKeys() {
|
for _, key := range w.state.GetAllPortKeys() {
|
||||||
if _, exists := currentAssociations[key]; !exists {
|
if _, exists := currentAssociations[key]; exists {
|
||||||
w.state.DeletePortState(key)
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ps, ok := w.state.GetPortState(key)
|
||||||
|
if ok && slices.ContainsFunc(ps.Hostnames, w.noNameserverAnswered) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
w.state.DeletePortState(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noNameserverAnswered reports whether name is a configured domain or
|
||||||
|
// hostname and none of its nameservers answered on its last check.
|
||||||
|
func (w *Watcher) noNameserverAnswered(name string) bool {
|
||||||
|
if !slices.Contains(w.config.Hostnames, name) &&
|
||||||
|
!slices.Contains(w.config.Domains, name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
hs, ok := w.state.GetHostnameState(name)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, nsState := range hs.RecordsByNameserver {
|
||||||
|
if nsState.Status == statusOK {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) collectIPs(hostname string) []string {
|
func (w *Watcher) collectIPs(hostname string) []string {
|
||||||
@@ -795,9 +827,24 @@ func (w *Watcher) checkSinglePort(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A configured name on the saved list none of whose nameservers
|
||||||
|
// answered stays on it, so the entry is kept when the other names
|
||||||
|
// stop resolving to this address.
|
||||||
|
savedHostnames := slices.Clone(hostnames)
|
||||||
|
|
||||||
|
if hasPrev {
|
||||||
|
for _, name := range prev.Hostnames {
|
||||||
|
if !slices.Contains(hostnames, name) && w.noNameserverAnswered(name) {
|
||||||
|
savedHostnames = append(savedHostnames, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(savedHostnames)
|
||||||
|
}
|
||||||
|
|
||||||
w.state.SetPortState(key, &state.PortState{
|
w.state.SetPortState(key, &state.PortState{
|
||||||
Open: result.Open,
|
Open: result.Open,
|
||||||
Hostnames: hostnames,
|
Hostnames: savedHostnames,
|
||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user