// Package config загружает конфигурацию демона: YAML-файл + переопределения из env. // Секреты Selectel читаются только из env (с поддержкой суффикса _FILE). package config import ( "bytes" "errors" "fmt" "io" "net" "net/netip" "os" "strconv" "strings" "time" "gopkg.in/yaml.v3" "github.com/realmanual/traefik-selectel/internal/hostparse" "github.com/realmanual/traefik-selectel/internal/selectel" ) // ManagedComment — метка владения записями. const ManagedComment = "managed-by=traefik-selectel-dns" const ( MinTTL = 60 MaxTTL = 604800 ) // Config — конфигурация демона. type Config struct { Zones []string `yaml:"zones"` IP string `yaml:"ip"` TTL int `yaml:"ttl"` Interval time.Duration `yaml:"interval"` DryRun bool `yaml:"dryRun"` Traefik TraefikConfig `yaml:"traefik"` Docker DockerConfig `yaml:"docker"` Delete DeleteConfig `yaml:"delete"` Selectel SelectelConfig `yaml:"selectel"` Listen string `yaml:"listen"` LogLevel string `yaml:"logLevel"` LogFormat string `yaml:"logFormat"` // Credentials заполняется только из env, не из YAML. Credentials selectel.Credentials `yaml:"-"` } type TraefikConfig struct { APIURL string `yaml:"apiURL"` Timeout time.Duration `yaml:"timeout"` } type DockerConfig struct { Enabled bool `yaml:"enabled"` Host string `yaml:"host"` } type DeleteConfig struct { Enabled bool `yaml:"enabled"` GracePeriod time.Duration `yaml:"gracePeriod"` } // SelectelConfig — несекретные настройки API. type SelectelConfig struct { BaseURL string `yaml:"baseURL"` AuthURL string `yaml:"authURL"` Timeout time.Duration `yaml:"timeout"` } // Default возвращает конфигурацию со значениями по умолчанию. func Default() Config { return Config{ TTL: 300, Interval: 30 * time.Second, Traefik: TraefikConfig{APIURL: "http://traefik:8080", Timeout: 10 * time.Second}, Docker: DockerConfig{Enabled: true, Host: "unix:///var/run/docker.sock"}, Delete: DeleteConfig{Enabled: false, GracePeriod: 10 * time.Minute}, Selectel: SelectelConfig{Timeout: 30 * time.Second}, Listen: ":9090", LogLevel: "info", LogFormat: "json", } } // Load читает YAML (path может быть пустым), применяет env-переопределения и секреты, валидирует. func Load(path string, getenv func(string) string, readFile func(string) ([]byte, error)) (Config, error) { cfg := Default() if path != "" { data, err := readFile(path) if err != nil { return cfg, fmt.Errorf("config: чтение %s: %w", path, err) } dec := yaml.NewDecoder(bytes.NewReader(data)) dec.KnownFields(true) if err := dec.Decode(&cfg); err != nil && !errors.Is(err, io.EOF) { return cfg, fmt.Errorf("config: разбор %s: %w", path, err) } } if err := applyEnv(&cfg, getenv); err != nil { return cfg, err } creds, err := LoadCredentials(getenv, readFile) if err != nil { return cfg, err } cfg.Credentials = creds if err := cfg.Normalize(); err != nil { return cfg, err } return cfg, cfg.Validate() } // LoadCredentials читает SELECTEL_* из env / *_FILE. func LoadCredentials(getenv func(string) string, readFile func(string) ([]byte, error)) (selectel.Credentials, error) { var c selectel.Credentials var err error for _, f := range []struct { name string dst *string }{ {"SELECTEL_USERNAME", &c.Username}, {"SELECTEL_PASSWORD", &c.Password}, {"SELECTEL_ACCOUNT_ID", &c.AccountID}, {"SELECTEL_PROJECT_ID", &c.ProjectID}, } { if *f.dst, err = Secret(f.name, getenv, readFile); err != nil { return c, err } } return c, nil } // Secret возвращает значение NAME либо содержимое файла из NAME_FILE. // Одновременное задание обоих — ошибка. func Secret(name string, getenv func(string) string, readFile func(string) ([]byte, error)) (string, error) { direct, file := getenv(name), getenv(name+"_FILE") if direct != "" && file != "" { return "", fmt.Errorf("config: заданы и %s, и %s_FILE — оставьте что-то одно", name, name) } if file != "" { b, err := readFile(file) if err != nil { return "", fmt.Errorf("config: чтение %s_FILE: %w", name, err) } return strings.TrimSpace(string(b)), nil } return strings.TrimSpace(direct), nil } func applyEnv(c *Config, getenv func(string) string) error { var errs []error str := func(key string, dst *string) { if v := getenv(key); v != "" { *dst = v } } boolean := func(key string, dst *bool) { if v := getenv(key); v != "" { b, err := strconv.ParseBool(v) if err != nil { errs = append(errs, fmt.Errorf("config: %s: %w", key, err)) return } *dst = b } } dur := func(key string, dst *time.Duration) { if v := getenv(key); v != "" { d, err := time.ParseDuration(v) if err != nil { errs = append(errs, fmt.Errorf("config: %s: %w", key, err)) return } *dst = d } } if v := getenv("TSD_ZONES"); v != "" { c.Zones = nil for _, z := range strings.Split(v, ",") { c.Zones = append(c.Zones, z) } } str("TSD_IP", &c.IP) if v := getenv("TSD_TTL"); v != "" { n, err := strconv.Atoi(v) if err != nil { errs = append(errs, fmt.Errorf("config: TSD_TTL: %w", err)) } else { c.TTL = n } } dur("TSD_INTERVAL", &c.Interval) boolean("TSD_DRY_RUN", &c.DryRun) str("TSD_TRAEFIK_API_URL", &c.Traefik.APIURL) dur("TSD_TRAEFIK_TIMEOUT", &c.Traefik.Timeout) boolean("TSD_DOCKER_ENABLED", &c.Docker.Enabled) str("TSD_DOCKER_HOST", &c.Docker.Host) boolean("TSD_DELETE_ENABLED", &c.Delete.Enabled) dur("TSD_DELETE_GRACE_PERIOD", &c.Delete.GracePeriod) str("TSD_SELECTEL_BASE_URL", &c.Selectel.BaseURL) str("TSD_SELECTEL_AUTH_URL", &c.Selectel.AuthURL) dur("TSD_SELECTEL_TIMEOUT", &c.Selectel.Timeout) str("TSD_LISTEN", &c.Listen) str("TSD_LOG_LEVEL", &c.LogLevel) str("TSD_LOG_FORMAT", &c.LogFormat) return errors.Join(errs...) } // Normalize приводит зоны к нормализованному виду и убирает дубликаты. func (c *Config) Normalize() error { seen := map[string]struct{}{} var zones []string for _, z := range c.Zones { n := hostparse.Normalize(z) if n == "" { continue } if _, ok := seen[n]; ok { continue } seen[n] = struct{}{} zones = append(zones, n) } c.Zones = zones c.IP = strings.TrimSpace(c.IP) c.Traefik.APIURL = strings.TrimRight(strings.TrimSpace(c.Traefik.APIURL), "/") c.LogLevel = strings.ToLower(c.LogLevel) c.LogFormat = strings.ToLower(c.LogFormat) return nil } // Validate проверяет конфигурацию. func (c *Config) Validate() error { var errs []error if len(c.Zones) == 0 { errs = append(errs, errors.New("zones: нужна хотя бы одна зона")) } for _, z := range c.Zones { if !hostparse.ValidHostname(z) { errs = append(errs, fmt.Errorf("zones: некорректная зона %q", z)) } } if addr, err := netip.ParseAddr(c.IP); err != nil || !addr.Is4() { errs = append(errs, fmt.Errorf("ip: %q не является IPv4-адресом", c.IP)) } if c.TTL < MinTTL || c.TTL > MaxTTL { errs = append(errs, fmt.Errorf("ttl: %d вне диапазона %d..%d", c.TTL, MinTTL, MaxTTL)) } if c.Interval < time.Second { errs = append(errs, fmt.Errorf("interval: %s слишком мал (минимум 1s)", c.Interval)) } if c.Traefik.APIURL == "" { errs = append(errs, errors.New("traefik.apiURL: не задан")) } if c.Traefik.Timeout <= 0 || c.Selectel.Timeout <= 0 { errs = append(errs, errors.New("timeout: должен быть положительным")) } if c.Delete.Enabled && c.Delete.GracePeriod < c.Interval { errs = append(errs, fmt.Errorf("delete.gracePeriod: %s меньше interval %s", c.Delete.GracePeriod, c.Interval)) } if c.Docker.Enabled && c.Docker.Host == "" { errs = append(errs, errors.New("docker.host: не задан")) } if _, _, err := net.SplitHostPort(c.Listen); err != nil { errs = append(errs, fmt.Errorf("listen: %q: %w", c.Listen, err)) } switch c.LogLevel { case "debug", "info", "warn", "error": default: errs = append(errs, fmt.Errorf("logLevel: %q (debug|info|warn|error)", c.LogLevel)) } switch c.LogFormat { case "json", "text": default: errs = append(errs, fmt.Errorf("logFormat: %q (json|text)", c.LogFormat)) } if err := c.Credentials.Validate(); err != nil { errs = append(errs, err) } return errors.Join(errs...) } // OSReadFile — os.ReadFile (для main). var OSReadFile = os.ReadFile