diff --git a/README.md b/README.md index eb52839..94d45d9 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ the following precedence (highest to lowest): | `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` | | `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` | | `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | +| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | **`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no monitoring targets are configured. A monitoring daemon with nothing to monitor @@ -248,6 +249,7 @@ DNSWATCHER_TARGETS=example.com,example.org,www.example.com,api.example.com,mail. DNSWATCHER_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-dns-alerts +DNSWATCHER_SEND_TEST_NOTIFICATION=true ``` --- @@ -380,6 +382,7 @@ docker run -d \ -v dnswatcher-data:/var/lib/dnswatcher \ -e DNSWATCHER_TARGETS=example.com,www.example.com \ -e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \ + -e DNSWATCHER_SEND_TEST_NOTIFICATION=true \ dnswatcher ``` diff --git a/internal/config/config.go b/internal/config/config.go index 1368c46..3e6df75 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,23 +38,24 @@ 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 - params *Params - log *slog.Logger + 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 } // New creates a new Config instance from environment and config files. @@ -105,6 +106,7 @@ func setupViper(name string) { viper.SetDefault("MAINTENANCE_MODE", false) viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_PASSWORD", "") + viper.SetDefault("SEND_TEST_NOTIFICATION", false) } func buildConfig( @@ -143,23 +145,24 @@ func buildConfig( } 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"), - params: params, - log: log, + 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, } return cfg, nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0ee5c83..e20a715 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -55,6 +55,7 @@ func TestNew_DefaultValues(t *testing.T) { assert.Empty(t, cfg.SentryDSN) assert.Empty(t, cfg.MetricsUsername) assert.Empty(t, cfg.MetricsPassword) + assert.False(t, cfg.SendTestNotification) } func TestNew_EnvironmentOverrides(t *testing.T) { @@ -73,6 +74,7 @@ func TestNew_EnvironmentOverrides(t *testing.T) { t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "true") t.Setenv("DNSWATCHER_METRICS_USERNAME", "admin") t.Setenv("DNSWATCHER_METRICS_PASSWORD", "secret") + t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "true") cfg, err := config.New(nil, newTestParams(t)) require.NoError(t, err) @@ -90,6 +92,7 @@ func TestNew_EnvironmentOverrides(t *testing.T) { assert.True(t, cfg.MaintenanceMode) assert.Equal(t, "admin", cfg.MetricsUsername) assert.Equal(t, "secret", cfg.MetricsPassword) + assert.True(t, cfg.SendTestNotification) } func TestNew_NoTargetsError(t *testing.T) { diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 60b70ba..bdf4f22 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -129,6 +129,7 @@ func (w *Watcher) Run(ctx context.Context) { ) w.RunOnce(ctx) + w.maybeSendTestNotification(ctx) dnsTicker := time.NewTicker(w.config.DNSInterval) tlsTicker := time.NewTicker(w.config.TLSInterval) @@ -854,6 +855,38 @@ func (w *Watcher) saveState() { } } +// maybeSendTestNotification sends a startup status notification +// after the first full scan completes, if SEND_TEST_NOTIFICATION +// is enabled. The message is clearly informational ("all ok") +// and not an error or anomaly alert. +func (w *Watcher) maybeSendTestNotification(ctx context.Context) { + if !w.config.SendTestNotification { + return + } + + snap := w.state.GetSnapshot() + + msg := fmt.Sprintf( + "dnswatcher has started and completed its initial scan.\n"+ + "Monitoring %d domain(s) and %d hostname(s).\n"+ + "Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+ + "All notification channels are working.", + len(snap.Domains), + len(snap.Hostnames), + len(snap.Ports), + len(snap.Certificates), + ) + + w.log.Info("sending startup test notification") + + w.notify.SendNotification( + ctx, + "✅ dnswatcher startup complete", + msg, + "success", + ) +} + // --- Utility functions --- func toSet(items []string) map[string]bool { diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 54d444f..ea6dbef 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -756,6 +756,117 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { } } +func TestSendTestNotification_Enabled(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{"example.com"} + cfg.Hostnames = []string{"www.example.com"} + cfg.SendTestNotification = true + + w, deps := newTestWatcher(t, cfg) + setupBaselineMocks(deps) + + w.RunOnce(t.Context()) + + // RunOnce does not send the test notification — it is + // sent by Run after RunOnce completes. Call the exported + // RunOnce then check that no test notification was sent + // (only Run triggers it). We test the full path via Run. + notifications := deps.notifier.getNotifications() + if len(notifications) != 0 { + t.Errorf( + "RunOnce should not send test notification, got %d", + len(notifications), + ) + } +} + +func TestSendTestNotification_ViaRun(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{"example.com"} + cfg.Hostnames = []string{"www.example.com"} + cfg.SendTestNotification = true + cfg.DNSInterval = 24 * time.Hour + cfg.TLSInterval = 24 * time.Hour + + w, deps := newTestWatcher(t, cfg) + setupBaselineMocks(deps) + + ctx, cancel := context.WithCancel(t.Context()) + + done := make(chan struct{}) + + go func() { + w.Run(ctx) + close(done) + }() + + // Wait for the initial scan and test notification. + time.Sleep(500 * time.Millisecond) + cancel() + + <-done + + notifications := deps.notifier.getNotifications() + + found := false + + for _, n := range notifications { + if n.Priority == "success" && + n.Title == "✅ dnswatcher startup complete" { + found = true + } + } + + if !found { + t.Errorf( + "expected startup test notification, got: %v", + notifications, + ) + } +} + +func TestSendTestNotification_Disabled(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{"example.com"} + cfg.Hostnames = []string{"www.example.com"} + cfg.SendTestNotification = false + cfg.DNSInterval = 24 * time.Hour + cfg.TLSInterval = 24 * time.Hour + + w, deps := newTestWatcher(t, cfg) + setupBaselineMocks(deps) + + ctx, cancel := context.WithCancel(t.Context()) + + done := make(chan struct{}) + + go func() { + w.Run(ctx) + close(done) + }() + + time.Sleep(500 * time.Millisecond) + cancel() + + <-done + + notifications := deps.notifier.getNotifications() + + for _, n := range notifications { + if n.Title == "✅ dnswatcher startup complete" { + t.Error( + "test notification should not be sent when disabled", + ) + } + } +} + func TestNSFailureAndRecovery(t *testing.T) { t.Parallel()