feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
// 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
|
||||
Reference in New Issue
Block a user