1 Commits
Author SHA1 Message Date
clawbot 50ff42ed26 watcher: notify nameserver address changes (closes #105)
check / check (push) Failing after 2m14s
Each domain check now looks up the addresses every nameserver's name
resolves to, with the resolver's ResolveIPAddresses, and saves them
sorted in the domain's state. A nameserver that stays in the
delegation and resolves to different addresses sends one NS Address
Change notification naming the domain, the nameserver and the old and
new addresses. Added or removed nameservers get only the NS change
notification. A failed or empty lookup keeps the previous addresses,
because the resolver returns no address without an error when every
server it asks times out. State files without the field load, and the
next check fills it in silently. Watcher tests that run domain checks
use example.com, which has two nameservers, to stay within the
per-attempt limit.

Model: opus-5-5
2026-10-01 20:38:22 +00:00
6 changed files with 28 additions and 156 deletions
+1 -2
View File
@@ -633,8 +633,7 @@ repository's `Dockerfile` and runs it. The app needs:
abandoned, and the number abandoned is logged at warn level rather
than dropped silently. Notifications generated after shutdown has
begun are refused and logged, so a late burst cannot extend the
shutdown. A DNS lookup, port check or TLS check that shutdown cuts
short saves nothing and sends no notification.
shutdown.
---
-2
View File
@@ -22,8 +22,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/107
- 2026-10-01: each nameserver's addresses are saved with its domain, and a
change while it stays in the delegation is notified (closes #105).
- 2026-10-01: a port or TLS check that shutdown cuts short saves nothing and
sends no notification, as a cut-short DNS lookup already did (closes #185).
- 2026-10-01: the client address from `X-Forwarded-For` is the last entry that
is not a trusted proxy, not the first, which the client sets (closes #181).
- 2026-10-01: a nameserver that does not answer is saved as `error` with the
-81
View File
@@ -1,81 +0,0 @@
package watcher_test
import (
"context"
"log/slog"
"reflect"
"testing"
"sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/tlscheck"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// TestCancelledCheckSavesNothing runs a check with its context already
// cancelled, which is how the rest of a check runs once shutdown cuts it
// short. The real resolver drops the DNS lookup without sending a query,
// and the real port and TLS checkers fail without connecting. The port
// and certificate state the last check saved must stay as it was, and
// nothing may be notified.
func TestCancelledCheckSavesNothing(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host}
// newTestWatcher's watcher has stand-in checkers. This one, on the
// same state and notifier, has the real ones.
_, deps := newTestWatcher(t, cfg)
w := watcher.NewForTest(
cfg,
deps.state,
resolver.NewFromLogger(slog.Default()),
portcheck.NewStandalone(),
tlscheck.NewStandalone(),
deps.notifier,
)
// The last check found host at a local address, with both ports
// open and a good certificate.
const localIP = "127.0.0.1"
deps.state.SetHostnameState(host, hostnameState(
map[string]map[string][]string{nsA: {"A": {localIP}}},
))
ports := map[string]*state.PortState{
localIP + ":80": {Open: true, Hostnames: []string{host}},
localIP + ":443": {Open: true, Hostnames: []string{host}},
}
for key, ps := range ports {
deps.state.SetPortState(key, ps)
}
certKey := localIP + ":443:" + host
cert := &state.CertificateState{CommonName: host, Status: "ok"}
deps.state.SetCertificateState(certKey, cert)
ctx, cancel := context.WithCancel(t.Context())
cancel()
w.RunOnce(ctx)
for key, want := range ports {
got, _ := deps.state.GetPortState(key)
if !reflect.DeepEqual(got, want) {
t.Errorf("port %s saved as %+v, want %+v", key, got, want)
}
}
got, _ := deps.state.GetCertificateState(certKey)
if !reflect.DeepEqual(got, cert) {
t.Errorf("certificate saved as %+v, want %+v", got, cert)
}
notifications := deps.notifier.getNotifications()
if len(notifications) != 0 {
t.Errorf("sent %v, want no notifications", notifications)
}
}
+4 -8
View File
@@ -118,10 +118,8 @@ func TestNSAddressChangeAlertNamesDomainNameserverAndAddresses(
}
// TestNameserverWithNoAddressKeepsPrevious looks up nameserver names
// with no address: two under .invalid, whose lookup fails with an
// error, and one that does not exist under a real zone, which live DNS
// answers with no address and no error. Each one with addresses saved
// by the previous check keeps them; the one without gets none.
// under .invalid, which live DNS never resolves. The one with addresses
// saved by the previous check keeps them; the one without gets none.
func TestNameserverWithNoAddressKeepsPrevious(t *testing.T) {
t.Parallel()
@@ -129,9 +127,7 @@ func TestNameserverWithNoAddressKeepsPrevious(t *testing.T) {
nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil,
)
nonexistentNS := "this-surely-does-not-exist-xyz." + testSmallDomain + "."
prev := map[string][]string{oldNS1: {oldIP}, nonexistentNS: {oldIP}}
prev := map[string][]string{oldNS1: {oldIP}}
var got map[string][]string
@@ -139,7 +135,7 @@ func TestNameserverWithNoAddressKeepsPrevious(t *testing.T) {
// lookup is not retried.
_ = livednstest.Run(func(ctx context.Context) error {
got = w.ResolveNameserverAddresses(
ctx, []string{oldNS1, oldNS2, nonexistentNS}, prev,
ctx, []string{oldNS1, oldNS2}, prev,
)
return nil
-14
View File
@@ -737,13 +737,6 @@ func (w *Watcher) checkSinglePort(
hostnames []string,
) {
result, err := w.portCheck.CheckPort(ctx, ip, port)
// A check the context cut short says nothing about the port, so it
// is neither saved nor notified.
if ctx.Err() != nil {
return
}
if err != nil {
w.log.Error(
"port check failed",
@@ -819,13 +812,6 @@ func (w *Watcher) checkTLSCert(
hostname string,
) {
cert, err := w.tlsCheck.CheckCertificate(ctx, ip, hostname)
// A check the context cut short says nothing about the certificate,
// so it is neither saved nor notified.
if ctx.Err() != nil {
return
}
certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname)
now := time.Now().UTC()
prev, hasPrev := w.state.GetCertificateState(certKey)
+23 -49
View File
@@ -27,12 +27,11 @@ import (
// so tests assert on what the watcher does with the answers, never on
// the records these zones publish. testHost's nameservers and addresses
// stay the same from one check to the next, which the tests that check
// it twice rely on, and testSmallDomain's nameservers stay the same
// between a test looking them up and its check. A domain check looks up
// each nameserver's addresses, about a second per nameserver, so the
// tests that check a domain use testSmallDomain, which has two
// nameservers, and check it once. The tests that query testDomain's
// nameservers directly do no domain check.
// it twice rely on, and so do the addresses of testSmallDomain's
// nameservers. A domain check looks up each nameserver's addresses,
// about a second per nameserver, so the tests that check a domain use
// testSmallDomain, which has two nameservers. The tests that query
// testDomain's nameservers directly do no domain check.
const (
testDomain = "google.com"
testSmallDomain = "example.com"
@@ -285,26 +284,6 @@ func runChecks(
return deps
}
// lookupNameservers returns the nameservers live DNS lists for domain,
// for a test to save in the state its check starts from.
func lookupNameservers(t *testing.T, domain string) []string {
t.Helper()
res := resolver.NewFromLogger(slog.Default())
var nameservers []string
livednstest.Retry(t, "LookupNS("+domain+")", func(ctx context.Context) error {
var err error
nameservers, err = res.LookupNS(ctx, domain)
return err
})
return nameservers
}
// addresses returns the A and AAAA values saved for a hostname.
func addresses(hs *state.HostnameState) []string {
var ips []string
@@ -472,21 +451,16 @@ func TestNSAddressChangeDetection(t *testing.T) {
cfg := defaultTestConfig(t)
cfg.Domains = []string{testSmallDomain}
nameservers := lookupNameservers(t, testSmallDomain)
// The saved state lists the nameservers live DNS lists, each at an
// address live DNS never returns.
deps := runChecks(t, cfg, func(deps *testDeps) {
nsAddresses := make(map[string][]string, len(nameservers))
for _, ns := range nameservers {
nsAddresses[ns] = []string{oldIP}
// Between the checks, save for every nameserver an address live DNS
// never returns.
deps := runChecks(t, cfg, nil, func(deps *testDeps) {
ds, _ := deps.state.GetDomainState(testSmallDomain)
for _, ns := range ds.Nameservers {
ds.NameserverAddresses[ns] = []string{oldIP}
}
deps.state.SetDomainState(testSmallDomain, &state.DomainState{
Nameservers: nameservers,
NameserverAddresses: nsAddresses,
})
}, nil)
deps.state.SetDomainState(testSmallDomain, ds)
})
title := "NS Address Change: " + testSmallDomain
ds, _ := deps.state.GetDomainState(testSmallDomain)
@@ -527,17 +501,17 @@ func TestNSAddedAndRemovedIsNoAddressChange(t *testing.T) {
cfg := defaultTestConfig(t)
cfg.Domains = []string{testSmallDomain}
nameservers := lookupNameservers(t, testSmallDomain)
// Between the checks, replace the first nameserver in the saved
// state with one live DNS does not list, so that the next check
// finds the first nameserver added and that one removed.
deps := runChecks(t, cfg, nil, func(deps *testDeps) {
ds, _ := deps.state.GetDomainState(testSmallDomain)
delete(ds.NameserverAddresses, ds.Nameservers[0])
ds.NameserverAddresses[oldNS1] = []string{oldIP}
ds.Nameservers = append([]string{oldNS1}, ds.Nameservers[1:]...)
// The saved state lists oldNS1, which live DNS does not, in place of
// the first nameserver live DNS lists, so that the check finds that
// one added and oldNS1 removed. Only oldNS1 has addresses saved.
deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetDomainState(testSmallDomain, &state.DomainState{
Nameservers: append([]string{oldNS1}, nameservers[1:]...),
NameserverAddresses: map[string][]string{oldNS1: {oldIP}},
})
}, nil)
deps.state.SetDomainState(testSmallDomain, ds)
})
if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 1 {
t.Errorf("sent %d NS changes, want 1", n)