feat: add DNSWATCHER_SEND_TEST_NOTIFICATION env var (#85)
All checks were successful
check / check (push) Successful in 5s
All checks were successful
check / check (push) Successful in 5s
When set to a truthy value, sends a startup status notification to all configured notification channels after the first full scan completes on application startup. The notification is clearly an all-ok/success message showing the number of monitored domains, hostnames, ports, and certificates. Changes: - Added `SendTestNotification` config field reading `DNSWATCHER_SEND_TEST_NOTIFICATION` - Added `maybeSendTestNotification()` in watcher, called after initial `RunOnce` in `Run` - Added 3 watcher tests (enabled via Run, enabled via RunOnce alone, disabled) - Added config tests for the new field - Updated README: env var table, example .env, Docker example Closes #84 Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #85 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #85.
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user