Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 79ab40a87b watcher: a removed nameserver is an NS Change, not an NS Failure (closes #262)
check / check (push) Canceled after 0s
A nameserver that left a domain's delegation was reported twice: as the
domain's NS Change, and by the check of the domain's own records as an
error-level NS Failure saying it disappeared. That check now sends
nothing for a domain. For a hostname, the nameservers that leave its
zone are sent as one NS Change at warning level, with a Hostname: line
and a Removed: line, as decided on the issue; nothing is sent for one
that joins. The README says what each case gets.

A nameserver that was failing before it left is now listed too; the
NS Failure skipped it.
A domain with no delegation of its own gets nothing when a nameserver
leaves the zone it is in.

Model: opus-5-5
2026-10-13 11:27:04 +00:00
9 changed files with 83 additions and 437 deletions
+5 -18
View File
@@ -103,12 +103,10 @@ notification endpoint set, changes show only on the dashboard; see
NS change notification above. A domain listed in NS change notification above. A domain listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
notification. A domain with no delegation of its own has these records asked notification. A domain with no delegation of its own has these records asked
at the servers of the zone it is in, as a hostname has. A nameserver the at the servers of the zone it is in, as a hostname has, and nothing is sent
domain's records were asked at on the previous check but not on this one gets when a nameserver leaves that zone. A domain that does not exist has none:
a hostname's NS change notification (see below), unless it was in the domain's they are not asked for, and those saved by an earlier check are removed
delegation on the previous check, as when that delegation is withdrawn. A without a notification.
domain that does not exist has none: they are not asked for, and those saved
by an earlier check are removed without a notification.
### DNS Hostname Monitoring (Subdomains) ### DNS Hostname Monitoring (Subdomains)
@@ -247,8 +245,7 @@ includes:
- **DNS record changes**: Which hostname or domain, which nameserver, what - **DNS record changes**: Which hostname or domain, which nameserver, what
record type, old values, new values. record type, old values, new values.
- **DNS NS changes**: Which domain, which nameservers were added to or removed - **DNS NS changes**: Which domain, which nameservers were added to or removed
from its delegation. For a hostname, or a domain with no delegation of its from its delegation. For a hostname, which nameservers left the zone it is in.
own, which nameservers left the zone it is in.
- **NS address changes**: Which domain, which nameserver, its old and new - **NS address changes**: Which domain, which nameserver, its old and new
addresses. addresses.
- **CNAME address changes**: Which hostname or domain, the old and new addresses - **CNAME address changes**: Which hostname or domain, the old and new addresses
@@ -474,16 +471,6 @@ or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If either is
set to anything else, including a bare number or a zero or negative duration, set to anything else, including a bare number or a zero or negative duration,
dnswatcher refuses to start with an error naming the variable and the value. dnswatcher refuses to start with an error naming the variable and the value.
**`PORT` and `DNSWATCHER_TLS_EXPIRY_WARNING`** take a whole number: 1 to 65535
for `PORT`, and a number of days, 1 or more, for
`DNSWATCHER_TLS_EXPIRY_WARNING`. `DNSWATCHER_DEBUG`,
`DNSWATCHER_MAINTENANCE_MODE` and `DNSWATCHER_SEND_TEST_NOTIFICATION` take
`true` or `false`; `1`, `t`, `T`, `TRUE` and `True` also mean true, and `0`,
`f`, `F`, `FALSE` and `False` also mean false. An unset or empty variable means
the default. Any other value, such as `PORT=http`,
`DNSWATCHER_TLS_EXPIRY_WARNING=7d` or `DNSWATCHER_DEBUG=yes`, makes dnswatcher
refuse to start with an error naming the variable and the value.
**`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it **`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it
is set, a panic in an HTTP request handler is sent to Sentry, and the request is set, a panic in an HTTP request handler is sent to Sentry, and the request
still gets a `500 Internal Server Error` answer. Nothing else is sent to Sentry: still gets a `500 Internal Server Error` answer. Nothing else is sent to Sentry:
-4
View File
@@ -21,10 +21,6 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change; - 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change;
one leaving a hostname's zone sends NS Change, not NS Failure (closes #262). one leaving a hostname's zone sends NS Change, not NS Failure (closes #262).
- 2026-10-13: a bad `PORT`, `DNSWATCHER_TLS_EXPIRY_WARNING` or true/false
setting stops startup instead of being read as 0 or false (closes #269).
- 2026-10-13: a check saving the state while the dashboard or `/api/v1/status`
is read cannot crash the process (closes #261).
- 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint - 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint
and test are phases of the `Dockerfile`, every scripted build uncached (closes and test are phases of the `Dockerfile`, every scripted build uncached (closes
#257). #257).
+14 -115
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"slices" "slices"
"strconv"
"strings" "strings"
"time" "time"
@@ -25,9 +24,6 @@ const (
defaultTLSExpiryWarning = 7 defaultTLSExpiryWarning = 7
) )
// maxPort is the highest TCP port number.
const maxPort = 65535
// ErrNoTargets is returned when no monitoring targets are configured. // ErrNoTargets is returned when no monitoring targets are configured.
var ErrNoTargets = errors.New( var ErrNoTargets = errors.New(
"no monitoring targets configured: set DNSWATCHER_TARGETS environment variable", "no monitoring targets configured: set DNSWATCHER_TARGETS environment variable",
@@ -44,23 +40,6 @@ var ErrInvalidInterval = errors.New(
// lists a name that is not in DNSWATCHER_TARGETS. // lists a name that is not in DNSWATCHER_TARGETS.
var ErrNotInTargets = errors.New("name is not in DNSWATCHER_TARGETS") var ErrNotInTargets = errors.New("name is not in DNSWATCHER_TARGETS")
// ErrInvalidPort is returned when PORT is set but is not a whole number
// from 1 to 65535. An empty value counts as unset and means the default.
var ErrInvalidPort = errors.New("port must be a whole number from 1 to 65535")
// ErrInvalidTLSExpiryWarning is returned when DNSWATCHER_TLS_EXPIRY_WARNING
// is set but is not a whole number, 1 or more. An empty value counts as unset
// and means the default.
var ErrInvalidTLSExpiryWarning = errors.New(
"TLS expiry warning must be a whole number of days, 1 or more",
)
// ErrInvalidBool is returned when DNSWATCHER_DEBUG,
// DNSWATCHER_MAINTENANCE_MODE or DNSWATCHER_SEND_TEST_NOTIFICATION is set
// but is not a value strconv.ParseBool accepts. An empty value counts as
// unset and means the default.
var ErrInvalidBool = errors.New("value must be true or false")
// Params contains dependencies for Config. // Params contains dependencies for Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -148,9 +127,16 @@ func buildConfig(
log *slog.Logger, log *slog.Logger,
params *Params, params *Params,
) (*Config, error) { ) (*Config, error) {
err := readConfigFile(log) err := viper.ReadInConfig()
if err != nil { if err != nil {
return nil, err var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) {
log.Error("config file malformed", "error", err)
return nil, fmt.Errorf(
"config file malformed: %w", err,
)
}
} }
dnsInterval, err := parseInterval("DNS_INTERVAL") dnsInterval, err := parseInterval("DNS_INTERVAL")
@@ -175,34 +161,9 @@ func buildConfig(
return nil, err return nil, err
} }
port, err := parsePort()
if err != nil {
return nil, err
}
tlsExpiryWarning, err := parseTLSExpiryWarning()
if err != nil {
return nil, err
}
debug, err := parseBool("DEBUG")
if err != nil {
return nil, err
}
maintenanceMode, err := parseBool("MAINTENANCE_MODE")
if err != nil {
return nil, err
}
sendTestNotification, err := parseBool("SEND_TEST_NOTIFICATION")
if err != nil {
return nil, err
}
cfg := &Config{ cfg := &Config{
Port: port, Port: viper.GetInt("PORT"),
Debug: debug, Debug: viper.GetBool("DEBUG"),
DataDir: viper.GetString("DATA_DIR"), DataDir: viper.GetString("DATA_DIR"),
Domains: domains, Domains: domains,
Hostnames: hostnames, Hostnames: hostnames,
@@ -212,12 +173,12 @@ func buildConfig(
NtfyTopic: viper.GetString("NTFY_TOPIC"), NtfyTopic: viper.GetString("NTFY_TOPIC"),
DNSInterval: dnsInterval, DNSInterval: dnsInterval,
TLSInterval: tlsInterval, TLSInterval: tlsInterval,
TLSExpiryWarning: tlsExpiryWarning, TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: maintenanceMode, MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SendTestNotification: sendTestNotification, SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
params: params, params: params,
log: log, log: log,
} }
@@ -225,21 +186,6 @@ func buildConfig(
return cfg, nil return cfg, nil
} }
// readConfigFile reads the config file, if there is one.
func readConfigFile(log *slog.Logger) error {
err := viper.ReadInConfig()
if err != nil {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) {
log.Error("config file malformed", "error", err)
return fmt.Errorf("config file malformed: %w", err)
}
}
return nil
}
// parseInterval reads the DNSWATCHER_-prefixed setting key as a duration. A // parseInterval reads the DNSWATCHER_-prefixed setting key as a duration. A
// value that does not parse, or is zero or negative, is an error naming the // value that does not parse, or is zero or negative, is an error naming the
// variable and the value; an unset variable has its default from setupViper. // variable and the value; an unset variable has its default from setupViper.
@@ -256,53 +202,6 @@ func parseInterval(key string) (time.Duration, error) {
return interval, nil return interval, nil
} }
// parsePort reads PORT, which has no DNSWATCHER_ prefix, as a port number. A
// value that is not a whole number from 1 to 65535 is an error naming the
// variable and the value; an unset variable has its default from setupViper.
func parsePort() (int, error) {
value := viper.GetString("PORT")
port, err := strconv.Atoi(value)
if err != nil || port < 1 || port > maxPort {
return 0, fmt.Errorf("invalid PORT %q: %w", value, ErrInvalidPort)
}
return port, nil
}
// parseTLSExpiryWarning reads DNSWATCHER_TLS_EXPIRY_WARNING as a number of
// days. A value that is not a whole number, 1 or more, is an error naming the
// variable and the value; an unset variable has its default from setupViper.
func parseTLSExpiryWarning() (int, error) {
value := viper.GetString("TLS_EXPIRY_WARNING")
days, err := strconv.Atoi(value)
if err != nil || days < 1 {
return 0, fmt.Errorf(
"invalid DNSWATCHER_TLS_EXPIRY_WARNING %q: %w",
value, ErrInvalidTLSExpiryWarning,
)
}
return days, nil
}
// parseBool reads the DNSWATCHER_-prefixed setting key as true or false. A
// value strconv.ParseBool does not accept is an error naming the variable and
// the value; an unset variable has its default from setupViper.
func parseBool(key string) (bool, error) {
value := viper.GetString(key)
result, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf(
"invalid DNSWATCHER_%s %q: %w", key, value, ErrInvalidBool,
)
}
return result, nil
}
func parseAndValidateTargets() ([]string, []string, error) { func parseAndValidateTargets() ([]string, []string, error) {
domains, hostnames, err := ClassifyTargets( domains, hostnames, err := ClassifyTargets(
parseCSV(viper.GetString("TARGETS")), parseCSV(viper.GetString("TARGETS")),
+2 -93
View File
@@ -116,7 +116,7 @@ func TestNew_OnlyEmptyCSVSegments(t *testing.T) {
} }
// TestNew_InvalidIntervalStopsStartup checks values that must stop startup; // TestNew_InvalidIntervalStopsStartup checks values that must stop startup;
// TestNew_DefaultValues and TestNew_EmptyValueMeansDefault check that an // TestNew_DefaultValues and TestNew_EmptyIntervalMeansDefault check that an
// unset or empty interval means the default. // unset or empty interval means the default.
func TestNew_InvalidIntervalStopsStartup(t *testing.T) { func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"} variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"}
@@ -144,107 +144,16 @@ func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
} }
} }
func TestNew_EmptyValueMeansDefault(t *testing.T) { func TestNew_EmptyIntervalMeansDefault(t *testing.T) {
viper.Reset() viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com") t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DNS_INTERVAL", "") t.Setenv("DNSWATCHER_DNS_INTERVAL", "")
t.Setenv("DNSWATCHER_TLS_INTERVAL", "") t.Setenv("DNSWATCHER_TLS_INTERVAL", "")
t.Setenv("PORT", "")
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "")
t.Setenv("DNSWATCHER_DEBUG", "")
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "")
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "")
cfg, err := config.New(nil, newTestParams(t)) cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, time.Hour, cfg.DNSInterval) assert.Equal(t, time.Hour, cfg.DNSInterval)
assert.Equal(t, 12*time.Hour, cfg.TLSInterval) assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
assert.Equal(t, 8080, cfg.Port)
assert.Equal(t, 7, cfg.TLSExpiryWarning)
assert.False(t, cfg.Debug)
assert.False(t, cfg.MaintenanceMode)
assert.False(t, cfg.SendTestNotification)
}
// TestNew_InvalidNumberOrBoolStopsStartup checks values that must stop
// startup; TestNew_EnvironmentOverrides, the two tests after this one and
// TestNew_EmptyValueMeansDefault check values that must not.
func TestNew_InvalidNumberOrBoolStopsStartup(t *testing.T) {
notBools := []string{"yes", "on", "2", "tRUE"}
tests := []struct {
variable string
values []string
want error
}{
{
variable: "PORT",
values: []string{"notaport", "0", "-1", "65536", "8080.5"},
want: config.ErrInvalidPort,
},
{
variable: "DNSWATCHER_TLS_EXPIRY_WARNING",
values: []string{"abc", "7d", "0", "-5"},
want: config.ErrInvalidTLSExpiryWarning,
},
{
variable: "DNSWATCHER_DEBUG",
values: notBools,
want: config.ErrInvalidBool,
},
{
variable: "DNSWATCHER_MAINTENANCE_MODE",
values: notBools,
want: config.ErrInvalidBool,
},
{
variable: "DNSWATCHER_SEND_TEST_NOTIFICATION",
values: notBools,
want: config.ErrInvalidBool,
},
}
for _, tt := range tests {
for _, value := range tt.values {
t.Run(tt.variable+"="+value, func(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv(tt.variable, value)
_, err := config.New(nil, newTestParams(t))
require.ErrorIs(t, err, tt.want)
require.ErrorContains(t, err, tt.variable)
require.ErrorContains(t, err, strconv.Quote(value))
})
}
}
}
func TestNew_LowestNumbersAndOtherSpellingsOfTrue(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("PORT", "1")
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "1")
t.Setenv("DNSWATCHER_DEBUG", "1")
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "T")
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "True")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 1, cfg.Port)
assert.Equal(t, 1, cfg.TLSExpiryWarning)
assert.True(t, cfg.Debug)
assert.True(t, cfg.MaintenanceMode)
assert.True(t, cfg.SendTestNotification)
}
func TestNew_HighestPort(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("PORT", "65535")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 65535, cfg.Port)
} }
func TestNew_DebugEnablesDebugLogging(t *testing.T) { func TestNew_DebugEnablesDebugLogging(t *testing.T) {
+2 -12
View File
@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -271,21 +270,12 @@ func (s *State) Save() error {
return nil return nil
} }
// GetSnapshot returns a copy of the current snapshot, with copies of // GetSnapshot returns a copy of the current snapshot.
// its maps, so that a caller can range over them while the state is
// written. The entries in the maps are not copied: an entry saved in the
// state must be replaced, never changed in place.
func (s *State) GetSnapshot() Snapshot { func (s *State) GetSnapshot() Snapshot {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
snap := *s.snapshot return *s.snapshot
snap.Domains = maps.Clone(snap.Domains)
snap.Hostnames = maps.Clone(snap.Hostnames)
snap.Ports = maps.Clone(snap.Ports)
snap.Certificates = maps.Clone(snap.Certificates)
return snap
} }
// SetDomainState updates the state for a domain. // SetDomainState updates the state for a domain.
+8 -61
View File
@@ -995,8 +995,7 @@ func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) {
} }
} }
// TestGetSnapshot_ReturnsCopy verifies that the maps in the snapshot // TestGetSnapshot_ReturnsCopy verifies that GetSnapshot returns a value copy.
// GetSnapshot returns are not the state's own.
func TestGetSnapshot_ReturnsCopy(t *testing.T) { func TestGetSnapshot_ReturnsCopy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1006,67 +1005,15 @@ func TestGetSnapshot_ReturnsCopy(t *testing.T) {
snap := s.GetSnapshot() snap := s.GetSnapshot()
const added = "added.example.com" // Mutate the returned snapshot's map.
snap.Domains["mutated.example.com"] = &state.DomainState{
snap.Domains[added] = &state.DomainState{} Nameservers: []string{"ns1.mutated.com."},
snap.Hostnames[added] = &state.HostnameState{}
snap.Ports[added] = &state.PortState{}
snap.Certificates[added] = &state.CertificateState{}
if _, ok := s.GetDomainState(added); ok {
t.Error("the snapshot's Domains map is the state's own")
} }
if _, ok := s.GetHostnameState(added); ok { // The original domain data should still be accessible.
t.Error("the snapshot's Hostnames map is the state's own") _, ok := s.GetDomainState("example.com")
} if !ok {
t.Error("original domain should still exist")
if _, ok := s.GetPortState(added); ok {
t.Error("the snapshot's Ports map is the state's own")
}
if _, ok := s.GetCertificateState(added); ok {
t.Error("the snapshot's Certificates map is the state's own")
}
}
// TestGetSnapshot_WhileWritten ranges over snapshots while another
// goroutine writes the state, as the dashboard does while a check runs.
// Under -race it fails when a snapshot's maps are the state's own.
func TestGetSnapshot_WhileWritten(t *testing.T) {
t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir())
const writes = 1000
written := make(chan struct{})
go func() {
defer close(written)
for range writes {
s.SetDomainState("example.com", &state.DomainState{})
s.SetHostnameState(testHostname, &state.HostnameState{})
s.SetPortState(testIPv4+":443", &state.PortState{})
s.SetCertificateState(
testIPv4+":443:"+testHostname, &state.CertificateState{},
)
}
}()
for {
select {
case <-written:
return
default:
}
// json.Marshal ranges over each of the snapshot's maps.
_, err := json.Marshal(s.GetSnapshot())
if err != nil {
t.Fatal(err)
}
} }
} }
+31 -78
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher" "sneak.berlin/go/dnswatcher/internal/watcher"
) )
@@ -73,11 +74,11 @@ b.ns.example.net.: 192.0.2.2`,
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) { func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) notifier := &mockNotifier{}
cfg.Domains = []string{domain} w := watcher.NewForTest(
&config.Config{Domains: []string{domain}},
deps := newTestDeps(t, cfg) nil, nil, nil, nil, notifier,
w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier) )
// nsA's address changes, which also makes it differ from nsC; nsB // nsA's address changes, which also makes it differ from nsC; nsB
// fails; nsC answers again. // fails; nsC answers again.
@@ -109,7 +110,7 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
sent := make(map[string]int) sent := make(map[string]int)
for _, n := range deps.notifier.getNotifications() { for _, n := range notifier.getNotifications() {
kind, _, _ := strings.Cut(n.Title, ":") kind, _, _ := strings.Cut(n.Title, ":")
sent[kind]++ sent[kind]++
@@ -125,85 +126,37 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
} }
// nsB, which answered, and nsC, which did not, are no longer among the // nsB, which answered, and nsC, which did not, are no longer among the
// nameservers. One NS Change lists both, for the hostname and for the // nameservers. For the hostname one NS Change lists both; for the
// configured apex domain with no delegation of its own. Nothing is sent // configured apex domain nothing is sent, because the check of its
// for the domain whose delegation on the previous check listed them: // delegation sends its NS Change.
// the check of its delegation sends its NS Change.
func TestRemovedNameserversAreAnNSChange(t *testing.T) { func TestRemovedNameserversAreAnNSChange(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { notifier := &mockNotifier{}
name string w := watcher.NewForTest(
target string &config.Config{Domains: []string{domain}},
// The domain's nameservers saved by the previous check. nil, nil, nil, nil, notifier,
delegation []string )
// The NS Change sent, or "" for none.
want string
}{
{
name: "hostname",
target: host,
want: `Hostname: www.example.net
Removed: b.ns.example.net., c.ns.example.net.`,
},
{
name: "domain with no delegation of its own",
target: domain,
delegation: []string{},
want: `Domain: example.net
Removed: b.ns.example.net., c.ns.example.net.`,
},
{
name: "domain whose delegation listed them",
target: domain,
delegation: []string{nsA, nsB, nsC},
},
}
for _, tt := range tests { prev := saved(map[string]*state.NameserverRecordState{
t.Run(tt.name, func(t *testing.T) { nsA: answered(map[string][]string{"A": {ip1}}),
t.Parallel() nsB: answered(map[string][]string{"A": {ip1}}),
nsC: failed(),
})
current := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
})
cfg := defaultTestConfig(t) w.DetectHostnameChanges(t.Context(), domain, prev, current)
cfg.Domains = []string{domain} w.DetectHostnameChanges(t.Context(), host, prev, current)
deps := newTestDeps(t, cfg) title := "NS Change: " + host
w := watcher.NewForTest( want := `Hostname: www.example.net
cfg, deps.state, nil, nil, nil, deps.notifier, Removed: b.ns.example.net., c.ns.example.net.`
)
deps.state.SetDomainState(domain, &state.DomainState{ got := notifier.getNotifications()
Nameservers: tt.delegation, if len(got) != 1 || got[0].Title != title || got[0].Message != want {
}) t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
prev := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
nsB: answered(map[string][]string{"A": {ip1}}),
nsC: failed(),
})
current := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
})
w.DetectHostnameChanges(t.Context(), tt.target, prev, current)
got := deps.notifier.getNotifications()
if tt.want == "" {
if len(got) != 0 {
t.Errorf("sent %v, want nothing", got)
}
return
}
title := "NS Change: " + tt.target
if len(got) != 1 || got[0].Title != title ||
got[0].Message != tt.want {
t.Errorf("sent %v, want one %q with message:\n%s",
got, title, tt.want)
}
})
} }
} }
+21 -28
View File
@@ -327,25 +327,25 @@ func (w *Watcher) checkDomain(
w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses) w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses)
} }
// A domain that does not exist has no records of its own: none are
// asked for, and those saved by an earlier check are removed.
// Otherwise the apex domain's records are also checked and saved as
// a hostname's, so that the port and TLS checks find its addresses.
// Notifications about them name it as a domain (see nameLine). They
// are checked before the domain's new nameservers are saved:
// detectNSDisappearances reads those of the previous check.
if nxdomain {
w.state.DeleteHostnameState(domain)
} else {
w.checkHostname(ctx, domain)
}
w.state.SetDomainState(domain, &state.DomainState{ w.state.SetDomainState(domain, &state.DomainState{
Nameservers: nameservers, Nameservers: nameservers,
NameserverAddresses: addresses, NameserverAddresses: addresses,
NXDomain: nxdomain, NXDomain: nxdomain,
LastChecked: now, LastChecked: now,
}) })
// A domain that does not exist has no records of its own: none are
// asked for, and those saved by an earlier check are removed.
if nxdomain {
w.state.DeleteHostnameState(domain)
return
}
// The apex domain's records are also checked and saved as a
// hostname's, so that the port and TLS checks find its addresses.
// Notifications about them name it as a domain (see nameLine).
w.checkHostname(ctx, domain)
} }
func (w *Watcher) detectNSChanges( func (w *Watcher) detectNSChanges(
@@ -772,30 +772,23 @@ func (w *Watcher) detectRecordChanges(
} }
} }
// detectNSDisappearances sends one NS Change listing the nameservers of // detectNSDisappearances sends one NS Change for a hostname, listing
// the previous check that are no longer those of the zone hostname is // the nameservers of the previous check that are no longer those of its
// in. For a configured apex domain it leaves out those of the domain's // zone. Nothing is sent for a configured apex domain, whose removed
// delegation on the previous check, which detectNSChanges notifies when // nameservers detectNSChanges notifies.
// they are removed from it; checkDomain saves the new delegation only
// after this runs.
func (w *Watcher) detectNSDisappearances( func (w *Watcher) detectNSDisappearances(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev, current *state.HostnameState, prev, current *state.HostnameState,
) { ) {
var delegation []string
if w.isDomain(hostname) { if w.isDomain(hostname) {
if domainState, ok := w.state.GetDomainState(hostname); ok { return
delegation = domainState.Nameservers
}
} }
var removed []string var removed []string
for ns := range prev.RecordsByNameserver { for ns := range prev.RecordsByNameserver {
_, ok := current.RecordsByNameserver[ns] if _, ok := current.RecordsByNameserver[ns]; !ok {
if !ok && !slices.Contains(delegation, ns) {
removed = append(removed, ns) removed = append(removed, ns)
} }
} }
@@ -807,8 +800,8 @@ func (w *Watcher) detectNSDisappearances(
slices.Sort(removed) slices.Sort(removed)
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nRemoved: %s", "Hostname: %s\nRemoved: %s",
w.nameLine(hostname), hostname,
strings.Join(removed, ", "), strings.Join(removed, ", "),
) )
-28
View File
@@ -558,9 +558,6 @@ func TestDomainThatDoesNotExist(t *testing.T) {
// nameservers and without nxdomain, and its records, asked at the // nameservers and without nxdomain, and its records, asked at the
// codeberg.page servers, are saved. Those are testSmallDomain's two // codeberg.page servers, are saved. Those are testSmallDomain's two
// nameservers; github.io, the zone of the README's example, has eight. // nameservers; github.io, the zone of the README's example, has eight.
// The previous check saved a delegation to oldNS1, now withdrawn, and
// records asked at oldNS1: the NS Change of its delegation removes
// oldNS1, and no other NS Change lists it again.
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) { func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
t.Parallel() t.Parallel()
@@ -576,15 +573,6 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
w, deps = newTestWatcher(t, cfg) w, deps = newTestWatcher(t, cfg)
deps.state.SetDomainState(domain, &state.DomainState{
Nameservers: []string{oldNS1},
})
deps.state.SetHostnameState(domain, saved(
map[string]*state.NameserverRecordState{
oldNS1: answered(map[string][]string{"A": {oldIP}}),
},
))
err := checkOnce(ctx, w, deps) err := checkOnce(ctx, w, deps)
// A domain saved as not existing has no records to wait for; // A domain saved as not existing has no records to wait for;
@@ -605,22 +593,6 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
if _, ok := deps.state.GetHostnameState(domain); !ok { if _, ok := deps.state.GetHostnameState(domain); !ok {
t.Errorf("no records saved for %s", domain) t.Errorf("no records saved for %s", domain)
} }
title := "NS Change: " + domain
want := "Domain: " + domain + "\nAdded: \nRemoved: " + oldNS1
var changes []notification
for _, n := range deps.notifier.getNotifications() {
if n.Title == title {
changes = append(changes, n)
}
}
if len(changes) != 1 || changes[0].Message != want {
t.Errorf("sent %v, want one %q with message:\n%s",
changes, title, want)
}
} }
func TestNSAddressChangeDetection(t *testing.T) { func TestNSAddressChangeDetection(t *testing.T) {