check / check (push) Successful in 1m3s
DNSWATCHER_DNS_INTERVAL and DNSWATCHER_TLS_INTERVAL were parsed with time.ParseDuration and silently replaced by the default when that failed, so a value like 5 or 1d gave hourly checks with no hint why, and zero or negative values were accepted. Both now go through parseInterval, which returns an error naming the variable and the value, and startup stops the same way it does for invalid targets. An unset or empty variable still gets its default from setupViper. The three tests that pinned the old fallback are replaced. The README says what a valid value looks like. Model: opus-5-5
236 lines
5.9 KiB
Go
236 lines
5.9 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"
|
|
)
|
|
|
|
// Default configuration values.
|
|
const (
|
|
defaultPort = 8080
|
|
defaultDNSInterval = 1 * time.Hour
|
|
defaultTLSInterval = 12 * time.Hour
|
|
defaultTLSExpiryWarning = 7
|
|
)
|
|
|
|
// 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",
|
|
)
|
|
|
|
// 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
|
|
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("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 := 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,
|
|
)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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: dnsInterval,
|
|
TLSInterval: tlsInterval,
|
|
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"),
|
|
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
|
|
params: params,
|
|
log: log,
|
|
}
|
|
|
|
return cfg, 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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"
|
|
}
|