check / check (push) Canceled after 0s
DNSWATCHER_METRICS_USERNAME turns on /metrics, and its Basic Auth used DNSWATCHER_METRICS_PASSWORD as it was, empty included, so the username with an empty password got in. config.New now refuses a username without a password with ErrNoMetricsPassword, whose message names both variables. The README's variable table says the password is required with the username. Judgement call: the check is in New, after buildConfig, since it compares two settings buildConfig has already read; buildConfig is also at the linter's length limit. Model: opus-5-5
393 lines
11 KiB
Go
393 lines
11 KiB
Go
// 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")
|
|
|
|
// 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
|
|
|
|
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
|
|
}
|
|
|
|
if cfg.MetricsUsername != "" && cfg.MetricsPassword == "" {
|
|
return nil, ErrNoMetricsPassword
|
|
}
|
|
|
|
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"
|
|
}
|