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
|
||||
@@ -0,0 +1,140 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||
|
||||
func files(m map[string]string) func(string) ([]byte, error) {
|
||||
return func(p string) ([]byte, error) {
|
||||
if v, ok := m[p]; ok {
|
||||
return []byte(v), nil
|
||||
}
|
||||
return nil, fmt.Errorf("no such file %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
var goodEnv = map[string]string{
|
||||
"SELECTEL_USERNAME": "u", "SELECTEL_PASSWORD": "p", "SELECTEL_ACCOUNT_ID": "1", "SELECTEL_PROJECT_ID": "2",
|
||||
}
|
||||
|
||||
func withEnv(extra map[string]string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for k, v := range goodEnv {
|
||||
m[k] = v
|
||||
}
|
||||
for k, v := range extra {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestLoadYAMLAndDefaults(t *testing.T) {
|
||||
yml := "zones: [Example.com., example.com, dev.example.org]\nip: 1.2.3.4\ndelete:\n enabled: true\n gracePeriod: 15m\n"
|
||||
cfg, err := Load("/cfg.yaml", env(goodEnv), files(map[string]string{"/cfg.yaml": yml}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Zones) != 2 || cfg.Zones[0] != "example.com" {
|
||||
t.Errorf("zones = %v", cfg.Zones)
|
||||
}
|
||||
if cfg.TTL != 300 || cfg.Interval != 30*time.Second || cfg.Listen != ":9090" || cfg.Traefik.APIURL != "http://traefik:8080" {
|
||||
t.Errorf("defaults: %+v", cfg)
|
||||
}
|
||||
if !cfg.Delete.Enabled || cfg.Delete.GracePeriod != 15*time.Minute {
|
||||
t.Errorf("delete: %+v", cfg.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDisabledByDefault(t *testing.T) {
|
||||
cfg, err := Load("", env(withEnv(map[string]string{"TSD_ZONES": "example.com", "TSD_IP": "1.2.3.4"})), files(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Delete.Enabled || cfg.Delete.GracePeriod != 10*time.Minute {
|
||||
t.Errorf("delete: %+v", cfg.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverrides(t *testing.T) {
|
||||
yml := "zones: [example.com]\nip: 1.2.3.4\nttl: 300\n"
|
||||
cfg, err := Load("/c.yaml", env(withEnv(map[string]string{
|
||||
"TSD_IP": "5.6.7.8", "TSD_TTL": "120", "TSD_ZONES": "a.com, b.com", "TSD_DRY_RUN": "true", "TSD_INTERVAL": "1m",
|
||||
})), files(map[string]string{"/c.yaml": yml}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.IP != "5.6.7.8" || cfg.TTL != 120 || len(cfg.Zones) != 2 || cfg.Zones[1] != "b.com" || !cfg.DryRun || cfg.Interval != time.Minute {
|
||||
t.Errorf("cfg = %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownYAMLFieldRejected(t *testing.T) {
|
||||
_, err := Load("/c.yaml", env(goodEnv), files(map[string]string{"/c.yaml": "zones: [a.com]\nip: 1.1.1.1\npassword: leak\n"}))
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка на неизвестное поле")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
base := func() map[string]string {
|
||||
return withEnv(map[string]string{"TSD_ZONES": "example.com", "TSD_IP": "1.2.3.4"})
|
||||
}
|
||||
cases := map[string]map[string]string{
|
||||
"ip не IPv4": {"TSD_IP": "::1"},
|
||||
"ip мусор": {"TSD_IP": "example"},
|
||||
"ttl мал": {"TSD_TTL": "59"},
|
||||
"ttl велик": {"TSD_TTL": "604801"},
|
||||
"нет зон": {"TSD_ZONES": " "},
|
||||
"grace < interval": {"TSD_DELETE_ENABLED": "true", "TSD_DELETE_GRACE_PERIOD": "1s"},
|
||||
"bad loglevel": {"TSD_LOG_LEVEL": "trace"},
|
||||
"bad bool": {"TSD_DRY_RUN": "maybe"},
|
||||
"нет пароля": {"SELECTEL_PASSWORD": ""},
|
||||
}
|
||||
for name, extra := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m := base()
|
||||
for k, v := range extra {
|
||||
m[k] = v
|
||||
}
|
||||
if _, err := Load("", env(m), files(nil)); err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := Load("", env(base()), files(nil)); err != nil {
|
||||
t.Fatalf("базовый конфиг должен быть валиден: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretFileSupport(t *testing.T) {
|
||||
e := map[string]string{
|
||||
"SELECTEL_USERNAME_FILE": "/run/secrets/u", "SELECTEL_PASSWORD_FILE": "/run/secrets/p",
|
||||
"SELECTEL_ACCOUNT_ID": "1", "SELECTEL_PROJECT_ID_FILE": "/run/secrets/pid",
|
||||
}
|
||||
f := files(map[string]string{"/run/secrets/u": "user\n", "/run/secrets/p": " pass \n", "/run/secrets/pid": "pid-1\n"})
|
||||
c, err := LoadCredentials(env(e), f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Username != "user" || c.Password != "pass" || c.ProjectID != "pid-1" || c.AccountID != "1" {
|
||||
t.Errorf("creds = %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretBothSetIsError(t *testing.T) {
|
||||
_, err := Secret("X", env(map[string]string{"X": "a", "X_FILE": "/f"}), files(map[string]string{"/f": "b"}))
|
||||
if err == nil || !strings.Contains(err.Error(), "одно") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMissingFile(t *testing.T) {
|
||||
if _, err := Secret("X", env(map[string]string{"X_FILE": "/nope"}), files(nil)); err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user