1 Commits

Author SHA1 Message Date
user
713a2b7332 config: fail fast when DNSWATCHER_TARGETS is empty
All checks were successful
check / check (push) Successful in 45s
When DNSWATCHER_TARGETS is empty or unset (the default), dnswatcher now
exits with a clear error message instead of silently starting with
nothing to monitor.

Added ErrNoTargets sentinel error returned from config.New when both
domains and hostnames lists are empty after target classification. This
causes the fx application to fail to start, preventing silent
misconfiguration.

Also extracted classifyAndValidateTargets and parseDurationOrDefault
helper functions to keep buildConfig within the funlen limit.

Closes #69
2026-03-01 16:22:27 -08:00
6 changed files with 124 additions and 211 deletions

View File

@@ -15,6 +15,12 @@ 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
@@ -118,25 +124,9 @@ func buildConfig(
}
}
dnsInterval, err := time.ParseDuration(
viper.GetString("DNS_INTERVAL"),
)
domains, hostnames, err := classifyAndValidateTargets()
if err != nil {
dnsInterval = defaultDNSInterval
}
tlsInterval, err := time.ParseDuration(
viper.GetString("TLS_INTERVAL"),
)
if err != nil {
tlsInterval = defaultTLSInterval
}
domains, hostnames, err := ClassifyTargets(
parseCSV(viper.GetString("TARGETS")),
)
if err != nil {
return nil, fmt.Errorf("invalid targets configuration: %w", err)
return nil, err
}
cfg := &Config{
@@ -148,8 +138,8 @@ func buildConfig(
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
NtfyTopic: viper.GetString("NTFY_TOPIC"),
DNSInterval: dnsInterval,
TLSInterval: tlsInterval,
DNSInterval: parseDurationOrDefault("DNS_INTERVAL", defaultDNSInterval),
TLSInterval: parseDurationOrDefault("TLS_INTERVAL", defaultTLSInterval),
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
@@ -162,6 +152,32 @@ func buildConfig(
return cfg, nil
}
func classifyAndValidateTargets() ([]string, []string, error) {
domains, hostnames, err := ClassifyTargets(
parseCSV(viper.GetString("TARGETS")),
)
if err != nil {
return nil, nil, fmt.Errorf(
"invalid targets configuration: %w", err,
)
}
if len(domains) == 0 && len(hostnames) == 0 {
return nil, nil, ErrNoTargets
}
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

View 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,
)
}
}

View File

@@ -1,60 +0,0 @@
package handlers
import (
"net/http"
"time"
)
// domainResponse represents a single domain in the API response.
type domainResponse struct {
Domain string `json:"domain"`
Nameservers []string `json:"nameservers,omitempty"`
LastChecked string `json:"lastChecked,omitempty"`
Status string `json:"status"`
}
// domainsResponse is the top-level response for GET /api/v1/domains.
type domainsResponse struct {
Domains []domainResponse `json:"domains"`
}
// HandleDomains returns the configured domains and their status.
func (h *Handlers) HandleDomains() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
configured := h.config.Domains
snapshot := h.state.GetSnapshot()
domains := make(
[]domainResponse, 0, len(configured),
)
for _, domain := range configured {
dr := domainResponse{
Domain: domain,
Status: "pending",
}
ds, ok := snapshot.Domains[domain]
if ok {
dr.Nameservers = ds.Nameservers
dr.Status = "ok"
if !ds.LastChecked.IsZero() {
dr.LastChecked = ds.LastChecked.
Format(time.RFC3339)
}
}
domains = append(domains, dr)
}
h.respondJSON(
writer, request,
&domainsResponse{Domains: domains},
http.StatusOK,
)
}
}

View File

@@ -8,11 +8,9 @@ import (
"go.uber.org/fx"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/healthcheck"
"sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/state"
)
// Params contains dependencies for Handlers.
@@ -22,8 +20,6 @@ type Params struct {
Logger *logger.Logger
Globals *globals.Globals
Healthcheck *healthcheck.Healthcheck
State *state.State
Config *config.Config
}
// Handlers provides HTTP request handlers.
@@ -32,8 +28,6 @@ type Handlers struct {
params *Params
globals *globals.Globals
hc *healthcheck.Healthcheck
state *state.State
config *config.Config
}
// New creates a new Handlers instance.
@@ -43,8 +37,6 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
params: &params,
globals: params.Globals,
hc: params.Healthcheck,
state: params.State,
config: params.Config,
}, nil
}
@@ -52,7 +44,7 @@ func (h *Handlers) respondJSON(
writer http.ResponseWriter,
_ *http.Request,
data any,
status int, //nolint:unparam // general-purpose utility; status varies in future use
status int,
) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(status)

View File

@@ -1,120 +0,0 @@
package handlers
import (
"net/http"
"sort"
"time"
"sneak.berlin/go/dnswatcher/internal/state"
)
// nameserverRecordResponse represents one nameserver's records
// in the API response.
type nameserverRecordResponse struct {
Nameserver string `json:"nameserver"`
Records map[string][]string `json:"records"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked string `json:"lastChecked,omitempty"`
}
// hostnameResponse represents a single hostname in the API response.
type hostnameResponse struct {
Hostname string `json:"hostname"`
Nameservers []nameserverRecordResponse `json:"nameservers,omitempty"`
LastChecked string `json:"lastChecked,omitempty"`
Status string `json:"status"`
}
// hostnamesResponse is the top-level response for
// GET /api/v1/hostnames.
type hostnamesResponse struct {
Hostnames []hostnameResponse `json:"hostnames"`
}
// HandleHostnames returns the configured hostnames and their status.
func (h *Handlers) HandleHostnames() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
configured := h.config.Hostnames
snapshot := h.state.GetSnapshot()
hostnames := make(
[]hostnameResponse, 0, len(configured),
)
for _, hostname := range configured {
hr := hostnameResponse{
Hostname: hostname,
Status: "pending",
}
hs, ok := snapshot.Hostnames[hostname]
if ok {
hr.Status = "ok"
if !hs.LastChecked.IsZero() {
hr.LastChecked = hs.LastChecked.
Format(time.RFC3339)
}
hr.Nameservers = buildNameserverRecords(
hs,
)
}
hostnames = append(hostnames, hr)
}
h.respondJSON(
writer, request,
&hostnamesResponse{Hostnames: hostnames},
http.StatusOK,
)
}
}
// buildNameserverRecords converts the per-nameserver state map
// into a sorted slice for deterministic JSON output.
func buildNameserverRecords(
hs *state.HostnameState,
) []nameserverRecordResponse {
if hs.RecordsByNameserver == nil {
return nil
}
nsNames := make(
[]string, 0, len(hs.RecordsByNameserver),
)
for ns := range hs.RecordsByNameserver {
nsNames = append(nsNames, ns)
}
sort.Strings(nsNames)
records := make(
[]nameserverRecordResponse, 0, len(nsNames),
)
for _, ns := range nsNames {
nsr := hs.RecordsByNameserver[ns]
entry := nameserverRecordResponse{
Nameserver: ns,
Records: nsr.Records,
Status: nsr.Status,
Error: nsr.Error,
}
if !nsr.LastChecked.IsZero() {
entry.LastChecked = nsr.LastChecked.
Format(time.RFC3339)
}
records = append(records, entry)
}
return records
}

View File

@@ -28,8 +28,6 @@ func (s *Server) SetupRoutes() {
// API v1 routes
s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/status", s.handlers.HandleStatus())
r.Get("/domains", s.handlers.HandleDomains())
r.Get("/hostnames", s.handlers.HandleHostnames())
})
// Metrics endpoint (optional, with basic auth)