Compare commits
3 Commits
fix/empty-
...
e8cd0705a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8cd0705a7 | ||
| 0a74971ade | |||
| e882e7d237 |
14
README.md
14
README.md
@@ -52,10 +52,6 @@ without requiring an external database.
|
||||
responding again.
|
||||
- **Inconsistency detected**: Two nameservers that previously agreed
|
||||
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
|
||||
|
||||
@@ -136,8 +132,6 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
|
||||
|---------------------------------------|--------------------------------|
|
||||
| `GET /health` | Health check (JSON) |
|
||||
| `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) |
|
||||
|
||||
---
|
||||
@@ -210,6 +204,12 @@ the following precedence (highest to lowest):
|
||||
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username 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`
|
||||
|
||||
```sh
|
||||
@@ -319,8 +319,6 @@ tracks reachability:
|
||||
|-------------|-------------------------------------------------|
|
||||
| `ok` | Query succeeded, records are current |
|
||||
| `error` | Query failed (timeout, SERVFAIL, network error) |
|
||||
| `nxdomain` | Authoritative NXDOMAIN response |
|
||||
| `nodata` | Authoritative empty response (NODATA) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,12 +15,6 @@ import (
|
||||
"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.
|
||||
const (
|
||||
defaultPort = 8080
|
||||
@@ -29,6 +23,11 @@ const (
|
||||
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.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -124,7 +123,21 @@ func buildConfig(
|
||||
}
|
||||
}
|
||||
|
||||
domains, hostnames, err := classifyAndValidateTargets()
|
||||
dnsInterval, err := time.ParseDuration(
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -138,8 +151,8 @@ func buildConfig(
|
||||
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
|
||||
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
|
||||
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
||||
DNSInterval: parseDurationOrDefault("DNS_INTERVAL", defaultDNSInterval),
|
||||
TLSInterval: parseDurationOrDefault("TLS_INTERVAL", defaultTLSInterval),
|
||||
DNSInterval: dnsInterval,
|
||||
TLSInterval: tlsInterval,
|
||||
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||
@@ -152,7 +165,7 @@ func buildConfig(
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func classifyAndValidateTargets() ([]string, []string, error) {
|
||||
func parseAndValidateTargets() ([]string, []string, error) {
|
||||
domains, hostnames, err := ClassifyTargets(
|
||||
parseCSV(viper.GetString("TARGETS")),
|
||||
)
|
||||
@@ -169,15 +182,6 @@ func classifyAndValidateTargets() ([]string, []string, error) {
|
||||
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 {
|
||||
if input == "" {
|
||||
return nil
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
func TestNewReturnsErrNoTargetsWhenEmpty(t *testing.T) {
|
||||
// Cannot use t.Parallel() because t.Setenv modifies the process
|
||||
// environment.
|
||||
t.Setenv("DNSWATCHER_TARGETS", "")
|
||||
t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir())
|
||||
|
||||
var cfg *config.Config
|
||||
|
||||
app := fx.New(
|
||||
fx.Provide(
|
||||
func() *globals.Globals {
|
||||
return &globals.Globals{
|
||||
Appname: "dnswatcher-test-empty",
|
||||
}
|
||||
},
|
||||
logger.New,
|
||||
config.New,
|
||||
),
|
||||
fx.Populate(&cfg),
|
||||
fx.NopLogger,
|
||||
)
|
||||
|
||||
err := app.Err()
|
||||
if err == nil {
|
||||
t.Fatal(
|
||||
"expected error when DNSWATCHER_TARGETS is empty, got nil",
|
||||
)
|
||||
}
|
||||
|
||||
if !errors.Is(err, config.ErrNoTargets) {
|
||||
t.Errorf("expected ErrNoTargets, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSucceedsWithTargets(t *testing.T) {
|
||||
// Cannot use t.Parallel() because t.Setenv modifies the process
|
||||
// environment.
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir())
|
||||
|
||||
// Prevent loading a local config file by changing to a temp dir.
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
if len(cfg.Domains) != 1 || cfg.Domains[0] != "example.com" {
|
||||
t.Errorf(
|
||||
"expected [example.com], got domains=%v",
|
||||
cfg.Domains,
|
||||
)
|
||||
}
|
||||
}
|
||||
1130
internal/notify/delivery_test.go
Normal file
1130
internal/notify/delivery_test.go
Normal file
File diff suppressed because it is too large
Load Diff
75
internal/notify/export_test.go
Normal file
75
internal/notify/export_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user