Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1504b9c64b | ||
|
|
39e7bc9067 | ||
|
|
02cb1ec580 |
@@ -103,10 +103,12 @@ 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, and nothing is sent
|
at the servers of the zone it is in, as a hostname has. A nameserver the
|
||||||
when a nameserver leaves that zone. A domain that does not exist has none:
|
domain's records were asked at on the previous check but not on this one gets
|
||||||
they are not asked for, and those saved by an earlier check are removed
|
a hostname's NS change notification (see below), unless it was in the domain's
|
||||||
without a notification.
|
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)
|
### DNS Hostname Monitoring (Subdomains)
|
||||||
|
|
||||||
@@ -245,7 +247,8 @@ 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, 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
|
- **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
|
||||||
@@ -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,
|
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:
|
||||||
|
|||||||
@@ -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;
|
- 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).
|
||||||
|
|||||||
+115
-14
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ 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",
|
||||||
@@ -40,6 +44,23 @@ 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
|
||||||
@@ -127,16 +148,9 @@ func buildConfig(
|
|||||||
log *slog.Logger,
|
log *slog.Logger,
|
||||||
params *Params,
|
params *Params,
|
||||||
) (*Config, error) {
|
) (*Config, error) {
|
||||||
err := viper.ReadInConfig()
|
err := readConfigFile(log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var notFound viper.ConfigFileNotFoundError
|
return nil, err
|
||||||
if !errors.As(err, ¬Found) {
|
|
||||||
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")
|
||||||
@@ -161,9 +175,34 @@ 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: viper.GetInt("PORT"),
|
Port: port,
|
||||||
Debug: viper.GetBool("DEBUG"),
|
Debug: debug,
|
||||||
DataDir: viper.GetString("DATA_DIR"),
|
DataDir: viper.GetString("DATA_DIR"),
|
||||||
Domains: domains,
|
Domains: domains,
|
||||||
Hostnames: hostnames,
|
Hostnames: hostnames,
|
||||||
@@ -173,12 +212,12 @@ func buildConfig(
|
|||||||
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
||||||
DNSInterval: dnsInterval,
|
DNSInterval: dnsInterval,
|
||||||
TLSInterval: tlsInterval,
|
TLSInterval: tlsInterval,
|
||||||
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
|
TLSExpiryWarning: tlsExpiryWarning,
|
||||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
MaintenanceMode: maintenanceMode,
|
||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
|
SendTestNotification: sendTestNotification,
|
||||||
params: params,
|
params: params,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
@@ -186,6 +225,21 @@ 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, ¬Found) {
|
||||||
|
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.
|
||||||
@@ -202,6 +256,53 @@ 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")),
|
||||||
|
|||||||
@@ -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_EmptyIntervalMeansDefault check that an
|
// TestNew_DefaultValues and TestNew_EmptyValueMeansDefault 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,16 +144,107 @@ func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_EmptyIntervalMeansDefault(t *testing.T) {
|
func TestNew_EmptyValueMeansDefault(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) {
|
||||||
|
|||||||
+12
-2
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -270,12 +271,21 @@ func (s *State) Save() error {
|
|||||||
return nil
|
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 {
|
func (s *State) GetSnapshot() Snapshot {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
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.
|
// SetDomainState updates the state for a domain.
|
||||||
|
|||||||
@@ -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) {
|
func TestGetSnapshot_ReturnsCopy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1005,15 +1006,67 @@ func TestGetSnapshot_ReturnsCopy(t *testing.T) {
|
|||||||
|
|
||||||
snap := s.GetSnapshot()
|
snap := s.GetSnapshot()
|
||||||
|
|
||||||
// Mutate the returned snapshot's map.
|
const added = "added.example.com"
|
||||||
snap.Domains["mutated.example.com"] = &state.DomainState{
|
|
||||||
Nameservers: []string{"ns1.mutated.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.
|
if _, ok := s.GetHostnameState(added); ok {
|
||||||
_, ok := s.GetDomainState("example.com")
|
t.Error("the snapshot's Hostnames map is the state's own")
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -74,11 +73,11 @@ b.ns.example.net.: 192.0.2.2`,
|
|||||||
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
notifier := &mockNotifier{}
|
cfg := defaultTestConfig(t)
|
||||||
w := watcher.NewForTest(
|
cfg.Domains = []string{domain}
|
||||||
&config.Config{Domains: []string{domain}},
|
|
||||||
nil, nil, nil, nil, notifier,
|
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
|
// nsA's address changes, which also makes it differ from nsC; nsB
|
||||||
// fails; nsC answers again.
|
// fails; nsC answers again.
|
||||||
@@ -110,7 +109,7 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
|||||||
|
|
||||||
sent := make(map[string]int)
|
sent := make(map[string]int)
|
||||||
|
|
||||||
for _, n := range notifier.getNotifications() {
|
for _, n := range deps.notifier.getNotifications() {
|
||||||
kind, _, _ := strings.Cut(n.Title, ":")
|
kind, _, _ := strings.Cut(n.Title, ":")
|
||||||
sent[kind]++
|
sent[kind]++
|
||||||
|
|
||||||
@@ -126,18 +125,57 @@ 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. For the hostname one NS Change lists both; for the
|
// nameservers. One NS Change lists both, for the hostname and for the
|
||||||
// configured apex domain nothing is sent, because the check of its
|
// configured apex domain with no delegation of its own. Nothing is sent
|
||||||
// delegation sends its NS Change.
|
// 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) {
|
func TestRemovedNameserversAreAnNSChange(t *testing.T) {
|
||||||
t.Parallel()
|
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(
|
w := watcher.NewForTest(
|
||||||
&config.Config{Domains: []string{domain}},
|
cfg, deps.state, nil, nil, nil, deps.notifier,
|
||||||
nil, nil, nil, nil, notifier,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
deps.state.SetDomainState(domain, &state.DomainState{
|
||||||
|
Nameservers: tt.delegation,
|
||||||
|
})
|
||||||
|
|
||||||
prev := saved(map[string]*state.NameserverRecordState{
|
prev := saved(map[string]*state.NameserverRecordState{
|
||||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
nsB: 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}}),
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
})
|
})
|
||||||
|
|
||||||
w.DetectHostnameChanges(t.Context(), domain, prev, current)
|
w.DetectHostnameChanges(t.Context(), tt.target, prev, current)
|
||||||
w.DetectHostnameChanges(t.Context(), host, prev, current)
|
|
||||||
|
|
||||||
title := "NS Change: " + host
|
got := deps.notifier.getNotifications()
|
||||||
want := `Hostname: www.example.net
|
|
||||||
Removed: b.ns.example.net., c.ns.example.net.`
|
|
||||||
|
|
||||||
got := notifier.getNotifications()
|
if tt.want == "" {
|
||||||
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
|
if len(got) != 0 {
|
||||||
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
|
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
@@ -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,23 +772,30 @@ func (w *Watcher) detectRecordChanges(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectNSDisappearances sends one NS Change for a hostname, listing
|
// detectNSDisappearances sends one NS Change listing the nameservers of
|
||||||
// the nameservers of the previous check that are no longer those of its
|
// the previous check that are no longer those of the zone hostname is
|
||||||
// zone. Nothing is sent for a configured apex domain, whose removed
|
// in. For a configured apex domain it leaves out those of the domain's
|
||||||
// nameservers detectNSChanges notifies.
|
// 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(
|
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) {
|
||||||
return
|
if domainState, ok := w.state.GetDomainState(hostname); ok {
|
||||||
|
delegation = domainState.Nameservers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var removed []string
|
var removed []string
|
||||||
|
|
||||||
for ns := range prev.RecordsByNameserver {
|
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)
|
removed = append(removed, ns)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -800,8 +807,8 @@ func (w *Watcher) detectNSDisappearances(
|
|||||||
slices.Sort(removed)
|
slices.Sort(removed)
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Hostname: %s\nRemoved: %s",
|
"%s\nRemoved: %s",
|
||||||
hostname,
|
w.nameLine(hostname),
|
||||||
strings.Join(removed, ", "),
|
strings.Join(removed, ", "),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -558,6 +558,9 @@ 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()
|
||||||
|
|
||||||
@@ -573,6 +576,15 @@ 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;
|
||||||
@@ -593,6 +605,22 @@ 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user