287 lines
8.7 KiB
Go
287 lines
8.7 KiB
Go
// 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
|