Compare commits

..
3 Commits
Author SHA1 Message Date
clawbot 1504b9c64b 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. The check of a name's
records now sends one NS Change at warning level, starting Hostname: or
Domain:, listing the nameservers it was asked at before and not now.
For a domain it leaves out those of its previous delegation, which the
domain's own NS Change lists, so a domain's records are now checked
before its new delegation is saved. Nothing is sent for a nameserver
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.

Model: opus-5-5
2026-10-13 13:40:56 +00:00
clawbot 39e7bc9067 config: a bad number or true/false setting stops startup (closes #269)
check / check (push) Canceled after 0s
PORT and DNSWATCHER_TLS_EXPIRY_WARNING were read with viper.GetInt,
and DNSWATCHER_DEBUG, DNSWATCHER_MAINTENANCE_MODE and
DNSWATCHER_SEND_TEST_NOTIFICATION with viper.GetBool, which turn a
value that does not parse into 0 or false with no error. Each is now
parsed the way parseInterval parses an interval: PORT must be 1 to
65535, the expiry warning 1 or more days, the true/false settings a
value strconv.ParseBool accepts. Anything else stops startup with an
error naming the variable and the value; empty still means the
default.

Reading the config file moved, unchanged, from buildConfig into
readConfigFile, so buildConfig stays within the linter's length limit.

Model: opus-5-5
2026-10-13 15:27:32 +02:00
clawbot 02cb1ec580 state: GetSnapshot copies the state's maps (closes #261)
check / check (push) Canceled after 0s
The dashboard and /api/v1/status range over the maps in the snapshot
GetSnapshot returns after the read lock is released, while the watcher
writes the same maps under the lock. Go aborts the process when a write
lands during such a range. GetSnapshot now returns copies of the four
maps.

The entries in the maps are still shared with the state. That is safe
because the watcher replaces an entry with a Set call and never changes
one in place; the comment on GetSnapshot says so.

TestGetSnapshot_WhileWritten ranges over snapshots while another
goroutine writes the state and fails under -race without the copies.
TestGetSnapshot_ReturnsCopy now checks that each map is not the state's
own.

Model: opus-5-5
2026-10-13 13:44:31 +02:00
9 changed files with 437 additions and 83 deletions
+18 -5
View File
@@ -103,10 +103,12 @@ notification endpoint set, changes show only on the dashboard; see
NS change notification above. A domain listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
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, and nothing is sent
when a nameserver leaves that zone. A domain that does not exist has none:
they are not asked for, and those saved by an earlier check are removed
without a notification.
at the servers of the zone it is in, as a hostname has. A nameserver the
domain's records were asked at on the previous check but not on this one gets
a hostname's NS change notification (see below), unless it was in the domain's
delegation on the previous check, as when that delegation is withdrawn. A
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)
@@ -245,7 +247,8 @@ includes:
- **DNS record changes**: Which hostname or domain, which nameserver, what
record type, old values, new values.
- **DNS NS changes**: Which domain, which nameservers were added to or removed
from its delegation. For a hostname, which nameservers left the zone it is in.
from its delegation. For a hostname, or a domain with no delegation of its
own, which nameservers left the zone it is in.
- **NS address changes**: Which domain, which nameserver, its old and new
addresses.
- **CNAME address changes**: Which hostname or domain, the old and new addresses
@@ -471,6 +474,16 @@ 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,
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
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:
+4
View File
@@ -21,6 +21,10 @@ 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;
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
and test are phases of the `Dockerfile`, every scripted build uncached (closes
#257).
+115 -14
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"time"
@@ -24,6 +25,9 @@ const (
defaultTLSExpiryWarning = 7
)
// maxPort is the highest TCP port number.
const maxPort = 65535
// ErrNoTargets is returned when no monitoring targets are configured.
var ErrNoTargets = errors.New(
"no monitoring targets configured: set DNSWATCHER_TARGETS environment variable",
@@ -40,6 +44,23 @@ var ErrInvalidInterval = errors.New(
// lists a name that 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.
type Params struct {
fx.In
@@ -127,16 +148,9 @@ func buildConfig(
log *slog.Logger,
params *Params,
) (*Config, error) {
err := viper.ReadInConfig()
err := readConfigFile(log)
if err != nil {
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,
)
}
return nil, err
}
dnsInterval, err := parseInterval("DNS_INTERVAL")
@@ -161,9 +175,34 @@ func buildConfig(
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{
Port: viper.GetInt("PORT"),
Debug: viper.GetBool("DEBUG"),
Port: port,
Debug: debug,
DataDir: viper.GetString("DATA_DIR"),
Domains: domains,
Hostnames: hostnames,
@@ -173,12 +212,12 @@ func buildConfig(
NtfyTopic: viper.GetString("NTFY_TOPIC"),
DNSInterval: dnsInterval,
TLSInterval: tlsInterval,
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
TLSExpiryWarning: tlsExpiryWarning,
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MaintenanceMode: maintenanceMode,
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
SendTestNotification: sendTestNotification,
params: params,
log: log,
}
@@ -186,6 +225,21 @@ func buildConfig(
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
// 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.
@@ -202,6 +256,53 @@ func parseInterval(key string) (time.Duration, error) {
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) {
domains, hostnames, err := ClassifyTargets(
parseCSV(viper.GetString("TARGETS")),
+93 -2
View File
@@ -116,7 +116,7 @@ func TestNew_OnlyEmptyCSVSegments(t *testing.T) {
}
// TestNew_InvalidIntervalStopsStartup checks values that must stop startup;
// TestNew_DefaultValues and TestNew_EmptyIntervalMeansDefault check that an
// TestNew_DefaultValues and TestNew_EmptyValueMeansDefault check that an
// unset or empty interval means the default.
func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"}
@@ -144,16 +144,107 @@ func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
}
}
func TestNew_EmptyIntervalMeansDefault(t *testing.T) {
func TestNew_EmptyValueMeansDefault(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DNS_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))
require.NoError(t, err)
assert.Equal(t, time.Hour, cfg.DNSInterval)
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) {
+12 -2
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"maps"
"os"
"path/filepath"
"slices"
@@ -270,12 +271,21 @@ func (s *State) Save() error {
return nil
}
// GetSnapshot returns a copy of the current snapshot.
// GetSnapshot returns a copy of the current snapshot, with copies of
// 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 {
s.mu.RLock()
defer s.mu.RUnlock()
return *s.snapshot
snap := *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.
+61 -8
View File
@@ -995,7 +995,8 @@ func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) {
}
}
// TestGetSnapshot_ReturnsCopy verifies that GetSnapshot returns a value copy.
// TestGetSnapshot_ReturnsCopy verifies that the maps in the snapshot
// GetSnapshot returns are not the state's own.
func TestGetSnapshot_ReturnsCopy(t *testing.T) {
t.Parallel()
@@ -1005,15 +1006,67 @@ func TestGetSnapshot_ReturnsCopy(t *testing.T) {
snap := s.GetSnapshot()
// Mutate the returned snapshot's map.
snap.Domains["mutated.example.com"] = &state.DomainState{
Nameservers: []string{"ns1.mutated.com."},
const added = "added.example.com"
snap.Domains[added] = &state.DomainState{}
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")
}
// The original domain data should still be accessible.
_, ok := s.GetDomainState("example.com")
if !ok {
t.Error("original domain should still exist")
if _, ok := s.GetHostnameState(added); ok {
t.Error("the snapshot's Hostnames map is the state's own")
}
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)
}
}
}
+68 -21
View File
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
@@ -74,11 +73,11 @@ b.ns.example.net.: 192.0.2.2`,
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
t.Parallel()
notifier := &mockNotifier{}
w := watcher.NewForTest(
&config.Config{Domains: []string{domain}},
nil, nil, nil, nil, notifier,
)
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier)
// nsA's address changes, which also makes it differ from nsC; nsB
// fails; nsC answers again.
@@ -110,7 +109,7 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
sent := make(map[string]int)
for _, n := range notifier.getNotifications() {
for _, n := range deps.notifier.getNotifications() {
kind, _, _ := strings.Cut(n.Title, ":")
sent[kind]++
@@ -126,18 +125,57 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
}
// nsB, which answered, and nsC, which did not, are no longer among the
// nameservers. For the hostname one NS Change lists both; for the
// configured apex domain nothing is sent, because the check of its
// delegation sends its NS Change.
// nameservers. One NS Change lists both, for the hostname and for the
// configured apex domain with no delegation of its own. Nothing is sent
// for the domain whose delegation on the previous check listed them:
// the check of its delegation sends its NS Change.
func TestRemovedNameserversAreAnNSChange(t *testing.T) {
t.Parallel()
notifier := &mockNotifier{}
tests := []struct {
name string
target string
// The domain's nameservers saved by the previous check.
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 {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
&config.Config{Domains: []string{domain}},
nil, nil, nil, nil, notifier,
cfg, deps.state, nil, nil, nil, deps.notifier,
)
deps.state.SetDomainState(domain, &state.DomainState{
Nameservers: tt.delegation,
})
prev := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
nsB: answered(map[string][]string{"A": {ip1}}),
@@ -147,16 +185,25 @@ func TestRemovedNameserversAreAnNSChange(t *testing.T) {
nsA: answered(map[string][]string{"A": {ip1}}),
})
w.DetectHostnameChanges(t.Context(), domain, prev, current)
w.DetectHostnameChanges(t.Context(), host, prev, current)
w.DetectHostnameChanges(t.Context(), tt.target, prev, current)
title := "NS Change: " + host
want := `Hostname: www.example.net
Removed: b.ns.example.net., c.ns.example.net.`
got := deps.notifier.getNotifications()
got := notifier.getNotifications()
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)
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)
}
})
}
}
+28 -21
View File
@@ -327,25 +327,25 @@ func (w *Watcher) checkDomain(
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{
Nameservers: nameservers,
NameserverAddresses: addresses,
NXDomain: nxdomain,
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(
@@ -772,23 +772,30 @@ func (w *Watcher) detectRecordChanges(
}
}
// detectNSDisappearances sends one NS Change for a hostname, listing
// the nameservers of the previous check that are no longer those of its
// zone. Nothing is sent for a configured apex domain, whose removed
// nameservers detectNSChanges notifies.
// detectNSDisappearances sends one NS Change listing the nameservers of
// the previous check that are no longer those of the zone hostname is
// in. For a configured apex domain it leaves out those of the domain's
// delegation on the previous check, which detectNSChanges notifies when
// they are removed from it; checkDomain saves the new delegation only
// after this runs.
func (w *Watcher) detectNSDisappearances(
ctx context.Context,
hostname string,
prev, current *state.HostnameState,
) {
var delegation []string
if w.isDomain(hostname) {
return
if domainState, ok := w.state.GetDomainState(hostname); ok {
delegation = domainState.Nameservers
}
}
var removed []string
for ns := range prev.RecordsByNameserver {
if _, ok := current.RecordsByNameserver[ns]; !ok {
_, ok := current.RecordsByNameserver[ns]
if !ok && !slices.Contains(delegation, ns) {
removed = append(removed, ns)
}
}
@@ -800,8 +807,8 @@ func (w *Watcher) detectNSDisappearances(
slices.Sort(removed)
msg := fmt.Sprintf(
"Hostname: %s\nRemoved: %s",
hostname,
"%s\nRemoved: %s",
w.nameLine(hostname),
strings.Join(removed, ", "),
)
+28
View File
@@ -558,6 +558,9 @@ func TestDomainThatDoesNotExist(t *testing.T) {
// nameservers and without nxdomain, and its records, asked at the
// codeberg.page servers, are saved. Those are testSmallDomain's two
// 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) {
t.Parallel()
@@ -573,6 +576,15 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
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)
// A domain saved as not existing has no records to wait for;
@@ -593,6 +605,22 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
if _, ok := deps.state.GetHostnameState(domain); !ok {
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) {