Compare commits
1 Commits
673302c130
...
fix/empty-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713a2b7332 |
14
README.md
14
README.md
@@ -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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
87
internal/config/config_test.go
Normal file
87
internal/config/config_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user