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
210 lines
5.0 KiB
Go
210 lines
5.0 KiB
Go
// Package config provides application configuration via Viper.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/viper"
|
|
"go.uber.org/fx"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/globals"
|
|
"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
|
|
defaultDNSInterval = 1 * time.Hour
|
|
defaultTLSInterval = 12 * time.Hour
|
|
defaultTLSExpiryWarning = 7
|
|
)
|
|
|
|
// Params contains dependencies for Config.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Globals *globals.Globals
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// Config holds application configuration.
|
|
type Config struct {
|
|
Port int
|
|
Debug bool
|
|
DataDir string
|
|
Domains []string
|
|
Hostnames []string
|
|
SlackWebhook string
|
|
MattermostWebhook string
|
|
NtfyTopic string
|
|
DNSInterval time.Duration
|
|
TLSInterval time.Duration
|
|
TLSExpiryWarning int
|
|
SentryDSN string
|
|
MaintenanceMode bool
|
|
MetricsUsername string
|
|
MetricsPassword string
|
|
params *Params
|
|
log *slog.Logger
|
|
}
|
|
|
|
// New creates a new Config instance from environment and config files.
|
|
func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|
log := params.Logger.Get()
|
|
|
|
name := params.Globals.Appname
|
|
if name == "" {
|
|
name = "dnswatcher"
|
|
}
|
|
|
|
setupViper(name)
|
|
|
|
cfg, err := buildConfig(log, ¶ms)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
configureDebugLogging(cfg, params)
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func setupViper(name string) {
|
|
viper.SetConfigName(name)
|
|
viper.SetConfigType("yaml")
|
|
viper.AddConfigPath("/etc/" + name)
|
|
viper.AddConfigPath("$HOME/.config/" + name)
|
|
viper.AddConfigPath(".")
|
|
|
|
viper.SetEnvPrefix("DNSWATCHER")
|
|
viper.AutomaticEnv()
|
|
|
|
// PORT is not prefixed for compatibility
|
|
_ = viper.BindEnv("PORT", "PORT")
|
|
|
|
viper.SetDefault("PORT", defaultPort)
|
|
viper.SetDefault("DEBUG", false)
|
|
viper.SetDefault("DATA_DIR", "./data")
|
|
viper.SetDefault("TARGETS", "")
|
|
viper.SetDefault("SLACK_WEBHOOK", "")
|
|
viper.SetDefault("MATTERMOST_WEBHOOK", "")
|
|
viper.SetDefault("NTFY_TOPIC", "")
|
|
viper.SetDefault("DNS_INTERVAL", defaultDNSInterval.String())
|
|
viper.SetDefault("TLS_INTERVAL", defaultTLSInterval.String())
|
|
viper.SetDefault("TLS_EXPIRY_WARNING", defaultTLSExpiryWarning)
|
|
viper.SetDefault("SENTRY_DSN", "")
|
|
viper.SetDefault("MAINTENANCE_MODE", false)
|
|
viper.SetDefault("METRICS_USERNAME", "")
|
|
viper.SetDefault("METRICS_PASSWORD", "")
|
|
}
|
|
|
|
func buildConfig(
|
|
log *slog.Logger,
|
|
params *Params,
|
|
) (*Config, error) {
|
|
err := viper.ReadInConfig()
|
|
if err != nil {
|
|
var notFound viper.ConfigFileNotFoundError
|
|
if !errors.As(err, ¬Found) {
|
|
log.Error("config file malformed", "error", err)
|
|
|
|
return nil, fmt.Errorf(
|
|
"config file malformed: %w", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
domains, hostnames, err := classifyAndValidateTargets()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cfg := &Config{
|
|
Port: viper.GetInt("PORT"),
|
|
Debug: viper.GetBool("DEBUG"),
|
|
DataDir: viper.GetString("DATA_DIR"),
|
|
Domains: domains,
|
|
Hostnames: hostnames,
|
|
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),
|
|
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
|
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
|
params: params,
|
|
log: log,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
parts := strings.Split(input, ",")
|
|
result := make([]string, 0, len(parts))
|
|
|
|
for _, part := range parts {
|
|
trimmed := strings.TrimSpace(part)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func configureDebugLogging(cfg *Config, params Params) {
|
|
if cfg.Debug {
|
|
params.Logger.EnableDebugLogging()
|
|
cfg.log = params.Logger.Get()
|
|
}
|
|
}
|
|
|
|
// StatePath returns the full path to the state JSON file.
|
|
func (c *Config) StatePath() string {
|
|
return c.DataDir + "/state.json"
|
|
}
|