feat: add DNSWATCHER_SEND_TEST_NOTIFICATION env var
All checks were successful
check / check (push) Successful in 4s
All checks were successful
check / check (push) Successful in 4s
When set to a truthy value, sends a startup status notification to all configured notification channels after the first full scan completes. The notification is clearly an all-ok/success message showing the number of monitored domains, hostnames, ports, and certificates. Closes #84
This commit is contained in:
@@ -231,6 +231,7 @@ the following precedence (highest to lowest):
|
|||||||
| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
|
| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
|
||||||
| `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_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
|
||||||
|
|
||||||
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
|
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
|
||||||
monitoring targets are configured. A monitoring daemon with nothing to monitor
|
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_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx
|
||||||
DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx
|
DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx
|
||||||
DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-dns-alerts
|
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 \
|
-v dnswatcher-data:/var/lib/dnswatcher \
|
||||||
-e DNSWATCHER_TARGETS=example.com,www.example.com \
|
-e DNSWATCHER_TARGETS=example.com,www.example.com \
|
||||||
-e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \
|
-e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \
|
||||||
|
-e DNSWATCHER_SEND_TEST_NOTIFICATION=true \
|
||||||
dnswatcher
|
dnswatcher
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -38,23 +38,24 @@ type Params struct {
|
|||||||
|
|
||||||
// Config holds application configuration.
|
// Config holds application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int
|
Port int
|
||||||
Debug bool
|
Debug bool
|
||||||
DataDir string
|
DataDir string
|
||||||
Domains []string
|
Domains []string
|
||||||
Hostnames []string
|
Hostnames []string
|
||||||
SlackWebhook string
|
SlackWebhook string
|
||||||
MattermostWebhook string
|
MattermostWebhook string
|
||||||
NtfyTopic string
|
NtfyTopic string
|
||||||
DNSInterval time.Duration
|
DNSInterval time.Duration
|
||||||
TLSInterval time.Duration
|
TLSInterval time.Duration
|
||||||
TLSExpiryWarning int
|
TLSExpiryWarning int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MaintenanceMode bool
|
MaintenanceMode bool
|
||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
MetricsPassword string
|
MetricsPassword string
|
||||||
params *Params
|
SendTestNotification bool
|
||||||
log *slog.Logger
|
params *Params
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Config instance from environment and config files.
|
// 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("MAINTENANCE_MODE", false)
|
||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
|
viper.SetDefault("SEND_TEST_NOTIFICATION", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildConfig(
|
func buildConfig(
|
||||||
@@ -143,23 +145,24 @@ func buildConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Port: viper.GetInt("PORT"),
|
Port: viper.GetInt("PORT"),
|
||||||
Debug: viper.GetBool("DEBUG"),
|
Debug: viper.GetBool("DEBUG"),
|
||||||
DataDir: viper.GetString("DATA_DIR"),
|
DataDir: viper.GetString("DATA_DIR"),
|
||||||
Domains: domains,
|
Domains: domains,
|
||||||
Hostnames: hostnames,
|
Hostnames: hostnames,
|
||||||
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: dnsInterval,
|
||||||
TLSInterval: tlsInterval,
|
TLSInterval: tlsInterval,
|
||||||
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"),
|
||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
params: params,
|
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
|
||||||
log: log,
|
params: params,
|
||||||
|
log: log,
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func TestNew_DefaultValues(t *testing.T) {
|
|||||||
assert.Empty(t, cfg.SentryDSN)
|
assert.Empty(t, cfg.SentryDSN)
|
||||||
assert.Empty(t, cfg.MetricsUsername)
|
assert.Empty(t, cfg.MetricsUsername)
|
||||||
assert.Empty(t, cfg.MetricsPassword)
|
assert.Empty(t, cfg.MetricsPassword)
|
||||||
|
assert.False(t, cfg.SendTestNotification)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_EnvironmentOverrides(t *testing.T) {
|
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_MAINTENANCE_MODE", "true")
|
||||||
t.Setenv("DNSWATCHER_METRICS_USERNAME", "admin")
|
t.Setenv("DNSWATCHER_METRICS_USERNAME", "admin")
|
||||||
t.Setenv("DNSWATCHER_METRICS_PASSWORD", "secret")
|
t.Setenv("DNSWATCHER_METRICS_PASSWORD", "secret")
|
||||||
|
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "true")
|
||||||
|
|
||||||
cfg, err := config.New(nil, newTestParams(t))
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -90,6 +92,7 @@ func TestNew_EnvironmentOverrides(t *testing.T) {
|
|||||||
assert.True(t, cfg.MaintenanceMode)
|
assert.True(t, cfg.MaintenanceMode)
|
||||||
assert.Equal(t, "admin", cfg.MetricsUsername)
|
assert.Equal(t, "admin", cfg.MetricsUsername)
|
||||||
assert.Equal(t, "secret", cfg.MetricsPassword)
|
assert.Equal(t, "secret", cfg.MetricsPassword)
|
||||||
|
assert.True(t, cfg.SendTestNotification)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_NoTargetsError(t *testing.T) {
|
func TestNew_NoTargetsError(t *testing.T) {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ func (w *Watcher) Run(ctx context.Context) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
w.RunOnce(ctx)
|
w.RunOnce(ctx)
|
||||||
|
w.maybeSendTestNotification(ctx)
|
||||||
|
|
||||||
dnsTicker := time.NewTicker(w.config.DNSInterval)
|
dnsTicker := time.NewTicker(w.config.DNSInterval)
|
||||||
tlsTicker := time.NewTicker(w.config.TLSInterval)
|
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 ---
|
// --- Utility functions ---
|
||||||
|
|
||||||
func toSet(items []string) map[string]bool {
|
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) {
|
func TestNSFailureAndRecovery(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user