2 Commits
Author SHA1 Message Date
clawbot ac4a17352a watcher: notify nameserver address changes (closes #105)
check / check (push) Failing after 3m10s
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 21:00:18 +00:00
clawbot fe01cdda1e watcher: save and notify nothing for a cut-short port or TLS check (closes #185)
check / check (push) Successful in 1m8s
When shutdown cancels a check that is under way, the rest of the check
still runs with the cancelled context. The resolver already drops a
lookup the context cut short, but a cancelled connection attempt was
saved as a closed port or a failed certificate check and notified as
Port Change or TLS Failure. The watcher now drops a port or TLS check
result when its context was cancelled, the same way. The test runs a
check with the context already cancelled, using the real resolver and
the real port and TLS checkers; no query is sent and no connection is
made.

Model: opus-5-5
2026-10-01 22:49:45 +02:00
6 changed files with 156 additions and 28 deletions
+2 -1
View File
@@ -633,7 +633,8 @@ 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.
shutdown. A DNS lookup, port check or TLS check that shutdown cuts
short saves nothing and sends no notification.
---
+2
View File
@@ -22,6 +22,8 @@ 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
@@ -0,0 +1,81 @@
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)
}
}
+8 -4
View File
@@ -118,8 +118,10 @@ func TestNSAddressChangeAlertNamesDomainNameserverAndAddresses(
}
// TestNameserverWithNoAddressKeepsPrevious looks up nameserver names
// under .invalid, which live DNS never resolves. The one with addresses
// saved by the previous check keeps them; the one without gets none.
// 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.
func TestNameserverWithNoAddressKeepsPrevious(t *testing.T) {
t.Parallel()
@@ -127,7 +129,9 @@ func TestNameserverWithNoAddressKeepsPrevious(t *testing.T) {
nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil,
)
prev := map[string][]string{oldNS1: {oldIP}}
nonexistentNS := "this-surely-does-not-exist-xyz." + testSmallDomain + "."
prev := map[string][]string{oldNS1: {oldIP}, nonexistentNS: {oldIP}}
var got map[string][]string
@@ -135,7 +139,7 @@ func TestNameserverWithNoAddressKeepsPrevious(t *testing.T) {
// lookup is not retried.
_ = livednstest.Run(func(ctx context.Context) error {
got = w.ResolveNameserverAddresses(
ctx, []string{oldNS1, oldNS2}, prev,
ctx, []string{oldNS1, oldNS2, nonexistentNS}, prev,
)
return nil
+14
View File
@@ -737,6 +737,13 @@ 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",
@@ -812,6 +819,13 @@ 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)
+49 -23
View File
@@ -27,11 +27,12 @@ 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 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.
// 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.
const (
testDomain = "google.com"
testSmallDomain = "example.com"
@@ -284,6 +285,26 @@ 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
@@ -451,16 +472,21 @@ func TestNSAddressChangeDetection(t *testing.T) {
cfg := defaultTestConfig(t)
cfg.Domains = []string{testSmallDomain}
// 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}
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}
}
deps.state.SetDomainState(testSmallDomain, ds)
})
deps.state.SetDomainState(testSmallDomain, &state.DomainState{
Nameservers: nameservers,
NameserverAddresses: nsAddresses,
})
}, nil)
title := "NS Address Change: " + testSmallDomain
ds, _ := deps.state.GetDomainState(testSmallDomain)
@@ -501,17 +527,17 @@ func TestNSAddedAndRemovedIsNoAddressChange(t *testing.T) {
cfg := defaultTestConfig(t)
cfg.Domains = []string{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:]...)
nameservers := lookupNameservers(t, testSmallDomain)
deps.state.SetDomainState(testSmallDomain, ds)
})
// 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)
if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 1 {
t.Errorf("sent %d NS changes, want 1", n)