watcher, config: no Record Change or Inconsistency for listed names (closes #255)
check / check (push) Canceled after 1m56s

DNSWATCHER_SKIP_RECORD_NOTIFICATIONS takes a comma-separated list of
names from DNSWATCHER_TARGETS, read as the targets are (letter case,
trailing dot, repeats). For a listed name no Record Change and no
Inconsistency notification is sent; its records are still checked and
saved, and its other notifications are sent. A listed name that is not
a target stops startup with an error naming it.

The two detections return early for a listed name. Apex domains are
covered too: their own records go through the same detection.

Live DNS cannot be made to disagree on purpose, so the Inconsistency
test feeds records to the change detection directly, as the existing
inconsistency tests do; the live test covers Record Change.

Model: opus-5-5
This commit is contained in:
2026-10-06 00:04:59 +00:00
parent 6822996134
commit bbba4bfdcb
6 changed files with 266 additions and 63 deletions
+81 -36
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"time"
@@ -35,6 +36,10 @@ var ErrInvalidInterval = errors.New(
"interval must be a positive duration such as 30m or 1h",
)
// ErrNotInTargets is returned when DNSWATCHER_SKIP_RECORD_NOTIFICATIONS
// lists a name that is not in DNSWATCHER_TARGETS.
var ErrNotInTargets = errors.New("name is not in DNSWATCHER_TARGETS")
// Params contains dependencies for Config.
type Params struct {
fx.In
@@ -45,24 +50,25 @@ type Params struct {
// Config holds application configuration.
type Config struct {
Port int
Debug bool
DataDir string
Domains []string
Hostnames []string
SlackWebhook string
MattermostWebhook string
NtfyTopic string
DNSInterval time.Duration
TLSInterval time.Duration
TLSExpiryWarning int
SentryDSN string
MaintenanceMode bool
MetricsUsername string
MetricsPassword string
SendTestNotification bool
params *Params
log *slog.Logger
Port int
Debug bool
DataDir string
Domains []string
Hostnames []string
SkipRecordNotifications []string
SlackWebhook string
MattermostWebhook string
NtfyTopic string
DNSInterval time.Duration
TLSInterval time.Duration
TLSExpiryWarning int
SentryDSN string
MaintenanceMode bool
MetricsUsername string
MetricsPassword string
SendTestNotification bool
params *Params
log *slog.Logger
}
// New creates a new Config instance from environment and config files.
@@ -103,6 +109,7 @@ func setupViper(name string) {
viper.SetDefault("DEBUG", false)
viper.SetDefault("DATA_DIR", "/var/lib/"+name)
viper.SetDefault("TARGETS", "")
viper.SetDefault("SKIP_RECORD_NOTIFICATIONS", "")
viper.SetDefault("SLACK_WEBHOOK", "")
viper.SetDefault("MATTERMOST_WEBHOOK", "")
viper.SetDefault("NTFY_TOPIC", "")
@@ -147,25 +154,33 @@ func buildConfig(
return nil, err
}
skipRecordNotifications, err := parseSkipRecordNotifications(
domains, hostnames,
)
if err != nil {
return nil, err
}
cfg := &Config{
Port: viper.GetInt("PORT"),
Debug: viper.GetBool("DEBUG"),
DataDir: viper.GetString("DATA_DIR"),
Domains: domains,
Hostnames: hostnames,
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
NtfyTopic: viper.GetString("NTFY_TOPIC"),
DNSInterval: dnsInterval,
TLSInterval: tlsInterval,
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
params: params,
log: log,
Port: viper.GetInt("PORT"),
Debug: viper.GetBool("DEBUG"),
DataDir: viper.GetString("DATA_DIR"),
Domains: domains,
Hostnames: hostnames,
SkipRecordNotifications: skipRecordNotifications,
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
NtfyTopic: viper.GetString("NTFY_TOPIC"),
DNSInterval: dnsInterval,
TLSInterval: tlsInterval,
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
params: params,
log: log,
}
return cfg, nil
@@ -204,6 +219,36 @@ func parseAndValidateTargets() ([]string, []string, error) {
return domains, hostnames, nil
}
// parseSkipRecordNotifications reads DNSWATCHER_SKIP_RECORD_NOTIFICATIONS,
// a comma-separated list of names from the targets. Each name is written
// as ClassifyTargets writes a target, in lower case without a trailing
// dot, and a name listed more than once is kept once. A name that is
// not one of domains or hostnames is an error naming it.
func parseSkipRecordNotifications(
domains, hostnames []string,
) ([]string, error) {
value := viper.GetString("SKIP_RECORD_NOTIFICATIONS")
var names []string
for _, listed := range parseCSV(value) {
name := strings.ToLower(strings.TrimSuffix(listed, "."))
if !slices.Contains(domains, name) && !slices.Contains(hostnames, name) {
return nil, fmt.Errorf(
"invalid DNSWATCHER_SKIP_RECORD_NOTIFICATIONS %q: %w",
listed, ErrNotInTargets,
)
}
if !slices.Contains(names, name) {
names = append(names, name)
}
}
return names, nil
}
func parseCSV(input string) []string {
if input == "" {
return nil
+28
View File
@@ -57,6 +57,7 @@ func TestNew_DefaultValues(t *testing.T) {
assert.Empty(t, cfg.MetricsUsername)
assert.Empty(t, cfg.MetricsPassword)
assert.False(t, cfg.SendTestNotification)
assert.Empty(t, cfg.SkipRecordNotifications)
}
func TestNew_EnvironmentOverrides(t *testing.T) {
@@ -235,6 +236,33 @@ func TestNew_TargetsWithTrailingComma(t *testing.T) {
"trailing comma should be ignored")
}
func TestNew_SkipRecordNotifications(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.net,www.example.net,example.org")
t.Setenv("DNSWATCHER_SKIP_RECORD_NOTIFICATIONS",
" WWW.Example.net. , example.net,www.example.net")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t,
[]string{"www.example.net", "example.net"},
cfg.SkipRecordNotifications,
"names are written as targets are, each once",
)
}
func TestNew_SkipRecordNotificationsNotInTargetsStopsStartup(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.net")
t.Setenv("DNSWATCHER_SKIP_RECORD_NOTIFICATIONS",
"example.net,www.example.net")
_, err := config.New(nil, newTestParams(t))
require.ErrorIs(t, err, config.ErrNotInTargets)
require.ErrorContains(t, err, "DNSWATCHER_SKIP_RECORD_NOTIFICATIONS")
require.ErrorContains(t, err, `"www.example.net"`)
}
func TestNew_CustomDNSIntervalDuration(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")