diff --git a/README.md b/README.md index 8a7e98e..f1a4108 100644 --- a/README.md +++ b/README.md @@ -437,7 +437,7 @@ following precedence (highest to lowest): | `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | | `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` | | `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` | -| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | +| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics; required when `DNSWATCHER_METRICS_USERNAME` is set, and startup stops without it | `""` | | `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | **`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no diff --git a/TODO.md b/TODO.md index c212724..96cbcee 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: `DNSWATCHER_METRICS_USERNAME` set without + `DNSWATCHER_METRICS_PASSWORD` stops startup (closes #268). - 2026-10-13: a save syncs the state file to disk before renaming it into place, so a crash cannot leave it empty and stop the next start (closes #264). - 2026-10-13: a `PORT` that cannot be bound stops startup with an error naming diff --git a/internal/config/config.go b/internal/config/config.go index 5e7b30b..4f1d6b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,6 +61,13 @@ var ErrInvalidTLSExpiryWarning = errors.New( // unset and means the default. var ErrInvalidBool = errors.New("value must be true or false") +// ErrNoMetricsPassword is returned when DNSWATCHER_METRICS_USERNAME is set and +// DNSWATCHER_METRICS_PASSWORD is empty. Basic Auth on /metrics would otherwise +// let in anyone who sends the username with an empty password. +var ErrNoMetricsPassword = errors.New( + "DNSWATCHER_METRICS_PASSWORD must be set when DNSWATCHER_METRICS_USERNAME is set", +) + // Params contains dependencies for Config. type Params struct { fx.In @@ -108,6 +115,10 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) { return nil, err } + if cfg.MetricsUsername != "" && cfg.MetricsPassword == "" { + return nil, ErrNoMetricsPassword + } + configureDebugLogging(cfg, params) return cfg, nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0c44726..35b4307 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -354,6 +354,24 @@ func TestNew_SkipRecordNotificationsNotInTargetsStopsStartup(t *testing.T) { require.ErrorContains(t, err, `"www.example.net"`) } +// TestNew_MetricsUsernameWithoutPasswordStopsStartup checks an unset and an +// empty password; TestNew_EnvironmentOverrides checks a username with one. +func TestNew_MetricsUsernameWithoutPasswordStopsStartup(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_METRICS_USERNAME", "prom") + + _, err := config.New(nil, newTestParams(t)) + require.ErrorIs(t, err, config.ErrNoMetricsPassword, "password unset") + require.ErrorContains(t, err, "DNSWATCHER_METRICS_PASSWORD") + + viper.Reset() + t.Setenv("DNSWATCHER_METRICS_PASSWORD", "") + + _, err = config.New(nil, newTestParams(t)) + require.ErrorIs(t, err, config.ErrNoMetricsPassword, "password empty") +} + func TestNew_CustomDNSIntervalDuration(t *testing.T) { viper.Reset() t.Setenv("DNSWATCHER_TARGETS", "example.com")