1 Commits

Author SHA1 Message Date
user
713a2b7332 config: fail fast when DNSWATCHER_TARGETS is empty
All checks were successful
check / check (push) Successful in 45s
When DNSWATCHER_TARGETS is empty or unset (the default), dnswatcher now
exits with a clear error message instead of silently starting with
nothing to monitor.

Added ErrNoTargets sentinel error returned from config.New when both
domains and hostnames lists are empty after target classification. This
causes the fx application to fail to start, preventing silent
misconfiguration.

Also extracted classifyAndValidateTargets and parseDurationOrDefault
helper functions to keep buildConfig within the funlen limit.

Closes #69
2026-03-01 16:22:27 -08:00
7 changed files with 89 additions and 1519 deletions

View File

@@ -52,6 +52,10 @@ without requiring an external database.
responding again. responding again.
- **Inconsistency detected**: Two nameservers that previously agreed - **Inconsistency detected**: Two nameservers that previously agreed
now return different record sets for the same hostname. now return different record sets for the same hostname.
- **Inconsistency resolved**: Nameservers that previously disagreed
are now back in agreement.
- **Empty response**: A nameserver that previously returned records
now returns an authoritative empty response (NODATA/NXDOMAIN).
### TCP Port Monitoring ### TCP Port Monitoring
@@ -132,6 +136,8 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
|---------------------------------------|--------------------------------| |---------------------------------------|--------------------------------|
| `GET /health` | Health check (JSON) | | `GET /health` | Health check (JSON) |
| `GET /api/v1/status` | Current monitoring state | | `GET /api/v1/status` | Current monitoring state |
| `GET /api/v1/domains` | Configured domains and status |
| `GET /api/v1/hostnames` | Configured hostnames and status|
| `GET /metrics` | Prometheus metrics (optional) | | `GET /metrics` | Prometheus metrics (optional) |
--- ---
@@ -204,12 +210,6 @@ the following precedence (highest to lowest):
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` | | `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` |
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | | `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
monitoring targets are configured. A monitoring daemon with nothing to monitor
is a misconfiguration, so dnswatcher fails fast with a clear error message
rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated
list of DNS names before starting.
### Example `.env` ### Example `.env`
```sh ```sh
@@ -319,6 +319,8 @@ tracks reachability:
|-------------|-------------------------------------------------| |-------------|-------------------------------------------------|
| `ok` | Query succeeded, records are current | | `ok` | Query succeeded, records are current |
| `error` | Query failed (timeout, SERVFAIL, network error) | | `error` | Query failed (timeout, SERVFAIL, network error) |
| `nxdomain` | Authoritative NXDOMAIN response |
| `nodata` | Authoritative empty response (NODATA) |
--- ---

View File

@@ -15,6 +15,12 @@ import (
"sneak.berlin/go/dnswatcher/internal/logger" "sneak.berlin/go/dnswatcher/internal/logger"
) )
// ErrNoTargets is returned when DNSWATCHER_TARGETS is empty or unset.
var ErrNoTargets = errors.New(
"no targets configured: set DNSWATCHER_TARGETS to a comma-separated " +
"list of DNS names to monitor",
)
// Default configuration values. // Default configuration values.
const ( const (
defaultPort = 8080 defaultPort = 8080
@@ -23,11 +29,6 @@ const (
defaultTLSExpiryWarning = 7 defaultTLSExpiryWarning = 7
) )
// ErrNoTargets is returned when no monitoring targets are configured.
var ErrNoTargets = errors.New(
"no monitoring targets configured: set DNSWATCHER_TARGETS environment variable",
)
// Params contains dependencies for Config. // Params contains dependencies for Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -123,21 +124,7 @@ func buildConfig(
} }
} }
dnsInterval, err := time.ParseDuration( domains, hostnames, err := classifyAndValidateTargets()
viper.GetString("DNS_INTERVAL"),
)
if err != nil {
dnsInterval = defaultDNSInterval
}
tlsInterval, err := time.ParseDuration(
viper.GetString("TLS_INTERVAL"),
)
if err != nil {
tlsInterval = defaultTLSInterval
}
domains, hostnames, err := parseAndValidateTargets()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -151,8 +138,8 @@ func buildConfig(
SlackWebhook: viper.GetString("SLACK_WEBHOOK"), SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"), MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
NtfyTopic: viper.GetString("NTFY_TOPIC"), NtfyTopic: viper.GetString("NTFY_TOPIC"),
DNSInterval: dnsInterval, DNSInterval: parseDurationOrDefault("DNS_INTERVAL", defaultDNSInterval),
TLSInterval: tlsInterval, TLSInterval: parseDurationOrDefault("TLS_INTERVAL", defaultTLSInterval),
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"), TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
@@ -165,7 +152,7 @@ func buildConfig(
return cfg, nil return cfg, nil
} }
func parseAndValidateTargets() ([]string, []string, error) { func classifyAndValidateTargets() ([]string, []string, error) {
domains, hostnames, err := ClassifyTargets( domains, hostnames, err := ClassifyTargets(
parseCSV(viper.GetString("TARGETS")), parseCSV(viper.GetString("TARGETS")),
) )
@@ -182,6 +169,15 @@ func parseAndValidateTargets() ([]string, []string, error) {
return domains, hostnames, nil return domains, hostnames, nil
} }
func parseDurationOrDefault(key string, fallback time.Duration) time.Duration {
d, err := time.ParseDuration(viper.GetString(key))
if err != nil {
return fallback
}
return d
}
func parseCSV(input string) []string { func parseCSV(input string) []string {
if input == "" { if input == "" {
return nil return nil

View File

@@ -1,260 +1,87 @@
package config_test package config_test
import ( import (
"errors"
"testing" "testing"
"time"
"github.com/spf13/viper" "go.uber.org/fx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals" "sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/logger" "sneak.berlin/go/dnswatcher/internal/logger"
) )
// newTestParams creates config.Params suitable for testing func TestNewReturnsErrNoTargetsWhenEmpty(t *testing.T) {
// without requiring the fx dependency injection framework. // Cannot use t.Parallel() because t.Setenv modifies the process
func newTestParams(t *testing.T) config.Params { // environment.
t.Helper()
g := &globals.Globals{
Appname: "dnswatcher",
Version: "test",
Buildarch: "amd64",
}
l, err := logger.New(nil, logger.Params{Globals: g})
require.NoError(t, err, "failed to create logger")
return config.Params{
Globals: g,
Logger: l,
}
}
// These tests exercise viper global state and MUST NOT use
// t.Parallel(). Each test resets viper for isolation.
func TestNew_DefaultValues(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Port)
assert.False(t, cfg.Debug)
assert.Equal(t, "./data", cfg.DataDir)
assert.Equal(t, time.Hour, cfg.DNSInterval)
assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
assert.Equal(t, 7, cfg.TLSExpiryWarning)
assert.False(t, cfg.MaintenanceMode)
assert.Empty(t, cfg.SlackWebhook)
assert.Empty(t, cfg.MattermostWebhook)
assert.Empty(t, cfg.NtfyTopic)
assert.Empty(t, cfg.SentryDSN)
assert.Empty(t, cfg.MetricsUsername)
assert.Empty(t, cfg.MetricsPassword)
}
func TestNew_EnvironmentOverrides(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("PORT", "9090")
t.Setenv("DNSWATCHER_DEBUG", "true")
t.Setenv("DNSWATCHER_DATA_DIR", "/tmp/test-data")
t.Setenv("DNSWATCHER_DNS_INTERVAL", "30m")
t.Setenv("DNSWATCHER_TLS_INTERVAL", "6h")
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "14")
t.Setenv("DNSWATCHER_SLACK_WEBHOOK", "https://hooks.slack.com/t")
t.Setenv("DNSWATCHER_MATTERMOST_WEBHOOK", "https://mm.test/hooks/t")
t.Setenv("DNSWATCHER_NTFY_TOPIC", "https://ntfy.sh/test")
t.Setenv("DNSWATCHER_SENTRY_DSN", "https://sentry.test/1")
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "true")
t.Setenv("DNSWATCHER_METRICS_USERNAME", "admin")
t.Setenv("DNSWATCHER_METRICS_PASSWORD", "secret")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 9090, cfg.Port)
assert.True(t, cfg.Debug)
assert.Equal(t, "/tmp/test-data", cfg.DataDir)
assert.Equal(t, 30*time.Minute, cfg.DNSInterval)
assert.Equal(t, 6*time.Hour, cfg.TLSInterval)
assert.Equal(t, 14, cfg.TLSExpiryWarning)
assert.Equal(t, "https://hooks.slack.com/t", cfg.SlackWebhook)
assert.Equal(t, "https://mm.test/hooks/t", cfg.MattermostWebhook)
assert.Equal(t, "https://ntfy.sh/test", cfg.NtfyTopic)
assert.Equal(t, "https://sentry.test/1", cfg.SentryDSN)
assert.True(t, cfg.MaintenanceMode)
assert.Equal(t, "admin", cfg.MetricsUsername)
assert.Equal(t, "secret", cfg.MetricsPassword)
}
func TestNew_NoTargetsError(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "") t.Setenv("DNSWATCHER_TARGETS", "")
t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir())
_, err := config.New(nil, newTestParams(t)) var cfg *config.Config
require.Error(t, err)
assert.ErrorIs(t, err, config.ErrNoTargets) app := fx.New(
fx.Provide(
func() *globals.Globals {
return &globals.Globals{
Appname: "dnswatcher-test-empty",
} }
},
func TestNew_OnlyEmptyCSVSegments(t *testing.T) { logger.New,
viper.Reset() config.New,
t.Setenv("DNSWATCHER_TARGETS", " , , ") ),
fx.Populate(&cfg),
_, err := config.New(nil, newTestParams(t)) fx.NopLogger,
require.Error(t, err) )
assert.ErrorIs(t, err, config.ErrNoTargets)
} err := app.Err()
if err == nil {
func TestNew_InvalidDNSInterval_FallsBackToDefault(t *testing.T) { t.Fatal(
viper.Reset() "expected error when DNSWATCHER_TARGETS is empty, got nil",
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DNS_INTERVAL", "banana")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, time.Hour, cfg.DNSInterval,
"invalid DNS interval should fall back to 1h default")
}
func TestNew_InvalidTLSInterval_FallsBackToDefault(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_TLS_INTERVAL", "notaduration")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 12*time.Hour, cfg.TLSInterval,
"invalid TLS interval should fall back to 12h default")
}
func TestNew_BothIntervalsInvalid(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DNS_INTERVAL", "xyz")
t.Setenv("DNSWATCHER_TLS_INTERVAL", "abc")
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)
}
func TestNew_DebugEnablesDebugLogging(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DEBUG", "true")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.True(t, cfg.Debug)
}
func TestNew_PortEnvNotPrefixed(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("PORT", "3000")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 3000, cfg.Port,
"PORT env should work without DNSWATCHER_ prefix")
}
func TestNew_TargetClassification(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS",
"example.com,www.example.com,api.example.com,example.org")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
// example.com and example.org are apex domains
assert.Len(t, cfg.Domains, 2)
// www.example.com and api.example.com are hostnames
assert.Len(t, cfg.Hostnames, 2)
}
func TestNew_InvalidTargetPublicSuffix(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "co.uk")
_, err := config.New(nil, newTestParams(t))
require.Error(t, err, "public suffix should be rejected")
}
func TestNew_EmptyAppnameDefaultsToDnswatcher(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
g := &globals.Globals{Appname: "", Version: "test"}
l, err := logger.New(nil, logger.Params{Globals: g})
require.NoError(t, err)
cfg, err := config.New(
nil, config.Params{Globals: g, Logger: l},
) )
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Port,
"defaults should load when appname is empty")
} }
func TestNew_TargetsWithWhitespace(t *testing.T) { if !errors.Is(err, config.ErrNoTargets) {
viper.Reset() t.Errorf("expected ErrNoTargets, got: %v", err)
t.Setenv("DNSWATCHER_TARGETS", " example.com , www.example.com ") }
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames),
"whitespace around targets should be trimmed")
} }
func TestNew_TargetsWithTrailingComma(t *testing.T) { func TestNewSucceedsWithTargets(t *testing.T) {
viper.Reset() // Cannot use t.Parallel() because t.Setenv modifies the process
t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com,") // environment.
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames),
"trailing comma should be ignored")
}
func TestNew_CustomDNSIntervalDuration(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com") t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DNS_INTERVAL", "5s") t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir())
cfg, err := config.New(nil, newTestParams(t)) // Prevent loading a local config file by changing to a temp dir.
require.NoError(t, err) t.Chdir(t.TempDir())
assert.Equal(t, 5*time.Second, cfg.DNSInterval)
var cfg *config.Config
app := fx.New(
fx.Provide(
func() *globals.Globals {
return &globals.Globals{
Appname: "dnswatcher-test-ok",
}
},
logger.New,
config.New,
),
fx.Populate(&cfg),
fx.NopLogger,
)
err := app.Err()
if err != nil {
t.Fatalf(
"expected no error with valid targets, got: %v",
err,
)
} }
func TestStatePath(t *testing.T) { if len(cfg.Domains) != 1 || cfg.Domains[0] != "example.com" {
t.Parallel() t.Errorf(
"expected [example.com], got domains=%v",
tests := []struct { cfg.Domains,
name string )
dataDir string
want string
}{
{"default", "./data", "./data/state.json"},
{"absolute", "/var/lib/dw", "/var/lib/dw/state.json"},
{"nested", "/opt/app/data", "/opt/app/data/state.json"},
{"empty", "", "/state.json"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &config.Config{DataDir: tt.dataDir}
assert.Equal(t, tt.want, cfg.StatePath())
})
} }
} }

View File

@@ -1,6 +0,0 @@
package config
// ParseCSVForTest exports parseCSV for use in external tests.
func ParseCSVForTest(input string) []string {
return parseCSV(input)
}

View File

@@ -1,44 +0,0 @@
package config_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/config"
)
func TestParseCSV(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want []string
}{
{"empty string", "", nil},
{"single value", "a", []string{"a"}},
{"multiple values", "a,b,c", []string{"a", "b", "c"}},
{"whitespace trimmed", " a , b ", []string{"a", "b"}},
{"trailing comma", "a,b,", []string{"a", "b"}},
{"leading comma", ",a,b", []string{"a", "b"}},
{"consecutive commas", "a,,b", []string{"a", "b"}},
{"all empty segments", ",,,", nil},
{"whitespace only", " , , ", nil},
{"tabs", "\ta\t,\tb\t", []string{"a", "b"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := config.ParseCSVForTest(tt.input)
require.Len(t, got, len(tt.want))
for i, w := range tt.want {
assert.Equal(t, w, got[i])
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,75 +0,0 @@
package notify
import (
"context"
"io"
"log/slog"
"net/http"
"net/url"
)
// NtfyPriority exports ntfyPriority for testing.
func NtfyPriority(priority string) string {
return ntfyPriority(priority)
}
// SlackColor exports slackColor for testing.
func SlackColor(priority string) string {
return slackColor(priority)
}
// NewRequestForTest exports newRequest for testing.
func NewRequestForTest(
ctx context.Context,
method string,
target *url.URL,
body io.Reader,
) *http.Request {
return newRequest(ctx, method, target, body)
}
// NewTestService creates a Service suitable for unit testing.
// It discards log output and uses the given transport.
func NewTestService(transport http.RoundTripper) *Service {
return &Service{
log: slog.New(slog.DiscardHandler),
transport: transport,
}
}
// SetNtfyURL sets the ntfy URL on a Service for testing.
func (svc *Service) SetNtfyURL(u *url.URL) {
svc.ntfyURL = u
}
// SetSlackWebhookURL sets the Slack webhook URL on a
// Service for testing.
func (svc *Service) SetSlackWebhookURL(u *url.URL) {
svc.slackWebhookURL = u
}
// SetMattermostWebhookURL sets the Mattermost webhook URL on
// a Service for testing.
func (svc *Service) SetMattermostWebhookURL(u *url.URL) {
svc.mattermostWebhookURL = u
}
// SendNtfy exports sendNtfy for testing.
func (svc *Service) SendNtfy(
ctx context.Context,
topicURL *url.URL,
title, message, priority string,
) error {
return svc.sendNtfy(ctx, topicURL, title, message, priority)
}
// SendSlack exports sendSlack for testing.
func (svc *Service) SendSlack(
ctx context.Context,
webhookURL *url.URL,
title, message, priority string,
) error {
return svc.sendSlack(
ctx, webhookURL, title, message, priority,
)
}