// Package config provides application configuration via Viper. package config import ( "errors" "fmt" "log/slog" "slices" "strconv" "strings" "time" "github.com/spf13/viper" "go.uber.org/fx" "sneak.berlin/go/dnswatcher/internal/globals" "sneak.berlin/go/dnswatcher/internal/logger" ) // Default configuration values. const ( defaultPort = 8080 defaultDNSInterval = 1 * time.Hour defaultTLSInterval = 12 * time.Hour defaultTLSExpiryWarning = 7 ) // maxPort is the highest TCP port number. const maxPort = 65535 // ErrNoTargets is returned when no monitoring targets are configured. var ErrNoTargets = errors.New( "no monitoring targets configured: set DNSWATCHER_TARGETS environment variable", ) // ErrInvalidInterval is returned when DNSWATCHER_DNS_INTERVAL or // DNSWATCHER_TLS_INTERVAL is set but is not a positive duration. An empty // value counts as unset and means the default. var ErrInvalidInterval = errors.New( "interval must be a positive duration such as 30m or 1h", ) // ErrNotInTargets is returned when DNSWATCHER_SKIP_RECORD_NOTIFICATIONS // lists a name that is not in DNSWATCHER_TARGETS. var ErrNotInTargets = errors.New("name is not in DNSWATCHER_TARGETS") // ErrInvalidPort is returned when PORT is set but is not a whole number // from 1 to 65535. An empty value counts as unset and means the default. var ErrInvalidPort = errors.New("port must be a whole number from 1 to 65535") // ErrInvalidTLSExpiryWarning is returned when DNSWATCHER_TLS_EXPIRY_WARNING // is set but is not a whole number, 1 or more. An empty value counts as unset // and means the default. var ErrInvalidTLSExpiryWarning = errors.New( "TLS expiry warning must be a whole number of days, 1 or more", ) // ErrInvalidBool is returned when DNSWATCHER_DEBUG, // DNSWATCHER_MAINTENANCE_MODE or DNSWATCHER_SEND_TEST_NOTIFICATION is set // but is not a value strconv.ParseBool accepts. An empty value counts as // unset and means the default. var ErrInvalidBool = errors.New("value must be true or false") // 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 SkipRecordNotifications []string SlackWebhook string MattermostWebhook string NtfyTopic string DNSInterval time.Duration TLSInterval time.Duration TLSExpiryWarning int SentryDSN string MaintenanceMode bool MetricsUsername string MetricsPassword string SendTestNotification bool 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", "/var/lib/"+name) viper.SetDefault("TARGETS", "") viper.SetDefault("SKIP_RECORD_NOTIFICATIONS", "") 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", "") viper.SetDefault("SEND_TEST_NOTIFICATION", false) } func buildConfig( log *slog.Logger, params *Params, ) (*Config, error) { err := readConfigFile(log) if err != nil { return nil, err } dnsInterval, err := parseInterval("DNS_INTERVAL") if err != nil { return nil, err } tlsInterval, err := parseInterval("TLS_INTERVAL") if err != nil { return nil, err } domains, hostnames, err := parseAndValidateTargets() if err != nil { return nil, err } skipRecordNotifications, err := parseSkipRecordNotifications( domains, hostnames, ) if err != nil { return nil, err } port, err := parsePort() if err != nil { return nil, err } tlsExpiryWarning, err := parseTLSExpiryWarning() if err != nil { return nil, err } debug, err := parseBool("DEBUG") if err != nil { return nil, err } maintenanceMode, err := parseBool("MAINTENANCE_MODE") if err != nil { return nil, err } sendTestNotification, err := parseBool("SEND_TEST_NOTIFICATION") if err != nil { return nil, err } cfg := &Config{ Port: port, Debug: debug, DataDir: viper.GetString("DATA_DIR"), Domains: domains, Hostnames: hostnames, SkipRecordNotifications: skipRecordNotifications, SlackWebhook: viper.GetString("SLACK_WEBHOOK"), MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"), NtfyTopic: viper.GetString("NTFY_TOPIC"), DNSInterval: dnsInterval, TLSInterval: tlsInterval, TLSExpiryWarning: tlsExpiryWarning, SentryDSN: viper.GetString("SENTRY_DSN"), MaintenanceMode: maintenanceMode, MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), SendTestNotification: sendTestNotification, params: params, log: log, } return cfg, nil } // readConfigFile reads the config file, if there is one. func readConfigFile(log *slog.Logger) error { err := viper.ReadInConfig() if err != nil { var notFound viper.ConfigFileNotFoundError if !errors.As(err, ¬Found) { log.Error("config file malformed", "error", err) return fmt.Errorf("config file malformed: %w", err) } } return nil } // parseInterval reads the DNSWATCHER_-prefixed setting key as a duration. A // value that does not parse, or is zero or negative, is an error naming the // variable and the value; an unset variable has its default from setupViper. func parseInterval(key string) (time.Duration, error) { value := viper.GetString(key) interval, err := time.ParseDuration(value) if err != nil || interval <= 0 { return 0, fmt.Errorf( "invalid DNSWATCHER_%s %q: %w", key, value, ErrInvalidInterval, ) } return interval, nil } // parsePort reads PORT, which has no DNSWATCHER_ prefix, as a port number. A // value that is not a whole number from 1 to 65535 is an error naming the // variable and the value; an unset variable has its default from setupViper. func parsePort() (int, error) { value := viper.GetString("PORT") port, err := strconv.Atoi(value) if err != nil || port < 1 || port > maxPort { return 0, fmt.Errorf("invalid PORT %q: %w", value, ErrInvalidPort) } return port, nil } // parseTLSExpiryWarning reads DNSWATCHER_TLS_EXPIRY_WARNING as a number of // days. A value that is not a whole number, 1 or more, is an error naming the // variable and the value; an unset variable has its default from setupViper. func parseTLSExpiryWarning() (int, error) { value := viper.GetString("TLS_EXPIRY_WARNING") days, err := strconv.Atoi(value) if err != nil || days < 1 { return 0, fmt.Errorf( "invalid DNSWATCHER_TLS_EXPIRY_WARNING %q: %w", value, ErrInvalidTLSExpiryWarning, ) } return days, nil } // parseBool reads the DNSWATCHER_-prefixed setting key as true or false. A // value strconv.ParseBool does not accept is an error naming the variable and // the value; an unset variable has its default from setupViper. func parseBool(key string) (bool, error) { value := viper.GetString(key) result, err := strconv.ParseBool(value) if err != nil { return false, fmt.Errorf( "invalid DNSWATCHER_%s %q: %w", key, value, ErrInvalidBool, ) } return result, nil } func parseAndValidateTargets() ([]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 } // parseSkipRecordNotifications reads DNSWATCHER_SKIP_RECORD_NOTIFICATIONS, // a comma-separated list of names from the targets. Each name is written // as ClassifyTargets writes a target, in lower case without a trailing // dot, and a name listed more than once is kept once. A name that is // not one of domains or hostnames is an error naming it. func parseSkipRecordNotifications( domains, hostnames []string, ) ([]string, error) { value := viper.GetString("SKIP_RECORD_NOTIFICATIONS") var names []string for _, listed := range parseCSV(value) { name := strings.ToLower(strings.TrimSuffix(listed, ".")) if !slices.Contains(domains, name) && !slices.Contains(hostnames, name) { return nil, fmt.Errorf( "invalid DNSWATCHER_SKIP_RECORD_NOTIFICATIONS %q: %w", listed, ErrNotInTargets, ) } if !slices.Contains(names, name) { names = append(names, name) } } return names, nil } 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" }