322 lines
10 KiB
Go
322 lines
10 KiB
Go
// Package reconciler приводит DNS-записи Selectel в соответствие с роутерами Traefik.
|
||
package reconciler
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"sort"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/realmanual/traefik-selectel/internal/hostparse"
|
||
"github.com/realmanual/traefik-selectel/internal/selectel"
|
||
"github.com/realmanual/traefik-selectel/internal/traefik"
|
||
)
|
||
|
||
// DNSClient — операции с DNS, необходимые reconciler-у.
|
||
// Имена — нормализованные (lower-case, без точки в конце).
|
||
type DNSClient interface {
|
||
ListRRSets(ctx context.Context, zone string) ([]selectel.RRSet, error)
|
||
CreateRRSet(ctx context.Context, zone string, rr selectel.RRSet) error
|
||
UpdateRRSet(ctx context.Context, zone, rrsetID string, ttl int, records []selectel.Record, comment string) error
|
||
DeleteRRSet(ctx context.Context, zone, rrsetID string) error
|
||
}
|
||
|
||
// Config — параметры reconcile.
|
||
type Config struct {
|
||
Zones []string // нормализованные разрешённые зоны
|
||
IP string // IPv4 для A-записей
|
||
TTL int
|
||
Comment string // метка владения
|
||
DryRun bool
|
||
DeleteEnabled bool
|
||
GracePeriod time.Duration
|
||
}
|
||
|
||
// Result — итоги одного цикла.
|
||
type Result struct {
|
||
Created, Updated, Deleted, Unchanged, SkippedForeign, SkippedConflict int
|
||
PendingDelete int
|
||
}
|
||
|
||
// Reconciler — не потокобезопасен по вызовам Reconcile (вызывайте последовательно).
|
||
type Reconciler struct {
|
||
cfg Config
|
||
source traefik.RouterSource
|
||
dns DNSClient
|
||
log *slog.Logger
|
||
now func() time.Time
|
||
|
||
hadRouters bool
|
||
pending map[string]pendingDelete // ключ — имя записи
|
||
warnedMu sync.Mutex
|
||
warned map[string]struct{}
|
||
}
|
||
|
||
type pendingDelete struct {
|
||
zone string
|
||
since time.Time
|
||
}
|
||
|
||
// New создаёт reconciler.
|
||
func New(cfg Config, source traefik.RouterSource, dns DNSClient, log *slog.Logger) *Reconciler {
|
||
if log == nil {
|
||
log = slog.Default()
|
||
}
|
||
return &Reconciler{
|
||
cfg: cfg, source: source, dns: dns, log: log, now: time.Now,
|
||
pending: map[string]pendingDelete{}, warned: map[string]struct{}{},
|
||
}
|
||
}
|
||
|
||
// warnOnce логирует warning один раз на ключ (дальше — debug), чтобы не шуметь каждый цикл.
|
||
func (r *Reconciler) warnOnce(key, msg string, args ...any) {
|
||
r.warnedMu.Lock()
|
||
_, seen := r.warned[key]
|
||
r.warned[key] = struct{}{}
|
||
r.warnedMu.Unlock()
|
||
if seen {
|
||
r.log.Debug(msg, args...)
|
||
return
|
||
}
|
||
r.log.Warn(msg, args...)
|
||
}
|
||
|
||
type desiredSet struct {
|
||
create map[string]string // имя -> зона (enabled-роутеры)
|
||
present map[string]string // имя -> зона (enabled + warning): защита от удаления
|
||
}
|
||
|
||
// Reconcile выполняет один цикл.
|
||
func (r *Reconciler) Reconcile(ctx context.Context) (Result, error) {
|
||
var res Result
|
||
|
||
routers, err := r.source.Routers(ctx)
|
||
if err != nil {
|
||
// Источник недоступен: ничего не создаём и, главное, ничего не удаляем.
|
||
return res, fmt.Errorf("получение роутеров: %w", err)
|
||
}
|
||
if len(routers) == 0 && r.hadRouters {
|
||
r.log.Warn("Traefik API вернул пустой список роутеров при ранее непустом — цикл пропущен, удаления заблокированы")
|
||
return res, errors.New("пустой список роутеров при ранее непустом")
|
||
}
|
||
if len(routers) > 0 {
|
||
r.hadRouters = true
|
||
}
|
||
|
||
desired := r.collect(routers)
|
||
|
||
// Какие зоны надо читать: с желаемыми хостами, а при включённом удалении — все.
|
||
zoneSet := map[string]struct{}{}
|
||
for _, z := range desired.create {
|
||
zoneSet[z] = struct{}{}
|
||
}
|
||
if r.cfg.DeleteEnabled {
|
||
for _, z := range r.cfg.Zones {
|
||
zoneSet[z] = struct{}{}
|
||
}
|
||
}
|
||
zones := make([]string, 0, len(zoneSet))
|
||
for z := range zoneSet {
|
||
zones = append(zones, z)
|
||
}
|
||
sort.Strings(zones)
|
||
|
||
var errs []error
|
||
listed := map[string][]selectel.RRSet{}
|
||
for _, zone := range zones {
|
||
rrsets, err := r.dns.ListRRSets(ctx, zone)
|
||
if err != nil {
|
||
errs = append(errs, fmt.Errorf("зона %s: список записей: %w", zone, err))
|
||
r.log.Error("не удалось получить записи зоны", "zone", zone, "error", err)
|
||
continue
|
||
}
|
||
listed[zone] = rrsets
|
||
}
|
||
|
||
// Создание/обновление.
|
||
names := make([]string, 0, len(desired.create))
|
||
for n := range desired.create {
|
||
names = append(names, n)
|
||
}
|
||
sort.Strings(names)
|
||
for _, name := range names {
|
||
zone := desired.create[name]
|
||
rrsets, ok := listed[zone]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if err := r.ensure(ctx, zone, name, rrsets, &res); err != nil {
|
||
errs = append(errs, err)
|
||
}
|
||
}
|
||
|
||
if r.cfg.DeleteEnabled {
|
||
errs = append(errs, r.deleteStale(ctx, listed, desired, &res)...)
|
||
}
|
||
res.PendingDelete = len(r.pending)
|
||
return res, errors.Join(errs...)
|
||
}
|
||
|
||
// collect извлекает желаемые имена из роутеров.
|
||
func (r *Reconciler) collect(routers []traefik.Router) desiredSet {
|
||
d := desiredSet{create: map[string]string{}, present: map[string]string{}}
|
||
for _, rt := range routers {
|
||
if rt.Status != "enabled" && rt.Status != "warning" {
|
||
continue
|
||
}
|
||
p := hostparse.Parse(rt.Rule)
|
||
if p.RegexpCount > 0 {
|
||
r.log.Debug("HostRegexp игнорируется", "router", rt.Name, "count", p.RegexpCount)
|
||
}
|
||
for _, w := range p.Wildcards {
|
||
r.warnOnce("wildcard|"+rt.Name+"|"+w, "wildcard-хосты в Host() не поддерживаются, пропущено", "router", rt.Name, "host", w)
|
||
}
|
||
for _, inv := range p.Invalid {
|
||
r.warnOnce("invalid|"+rt.Name+"|"+inv, "некорректное имя хоста в Host(), пропущено", "router", rt.Name, "host", inv)
|
||
}
|
||
for _, h := range p.Hosts {
|
||
zone, ok := hostparse.MatchZone(h, r.cfg.Zones)
|
||
if !ok {
|
||
r.log.Debug("хост вне разрешённых зон, пропущен", "router", rt.Name, "host", h)
|
||
continue
|
||
}
|
||
d.present[h] = zone
|
||
if rt.Status == "enabled" {
|
||
d.create[h] = zone
|
||
}
|
||
}
|
||
}
|
||
return d
|
||
}
|
||
|
||
func isManaged(rr selectel.RRSet, comment string) bool { return rr.Comment == comment }
|
||
|
||
func (r *Reconciler) ensure(ctx context.Context, zone, name string, rrsets []selectel.RRSet, res *Result) error {
|
||
var existingA *selectel.RRSet
|
||
var cname *selectel.RRSet
|
||
for i := range rrsets {
|
||
rr := &rrsets[i]
|
||
if rr.Name != name {
|
||
continue
|
||
}
|
||
switch rr.Type {
|
||
case "A":
|
||
existingA = rr
|
||
case "CNAME":
|
||
cname = rr
|
||
}
|
||
}
|
||
|
||
// Хост снова востребован — отменяем отложенное удаление.
|
||
delete(r.pending, name)
|
||
|
||
switch {
|
||
case existingA != nil && !isManaged(*existingA, r.cfg.Comment):
|
||
res.SkippedForeign++
|
||
r.warnOnce("foreign|"+name, "A-запись существует без метки владения, не трогаем", "name", name, "zone", zone)
|
||
return nil
|
||
case existingA != nil:
|
||
if r.matches(*existingA) {
|
||
res.Unchanged++
|
||
r.log.Debug("запись актуальна", "name", name)
|
||
return nil
|
||
}
|
||
if r.cfg.DryRun {
|
||
r.log.Info("dry-run: обновил бы A-запись", "name", name, "ip", r.cfg.IP, "ttl", r.cfg.TTL)
|
||
return nil
|
||
}
|
||
if err := r.dns.UpdateRRSet(ctx, zone, existingA.ID, r.cfg.TTL, []selectel.Record{{Content: r.cfg.IP}}, r.cfg.Comment); err != nil {
|
||
r.log.Error("не удалось обновить A-запись", "name", name, "error", err)
|
||
return fmt.Errorf("обновление %s: %w", name, err)
|
||
}
|
||
res.Updated++
|
||
r.log.Info("A-запись обновлена", "name", name, "ip", r.cfg.IP, "ttl", r.cfg.TTL)
|
||
return nil
|
||
case cname != nil:
|
||
res.SkippedConflict++
|
||
r.warnOnce("cname|"+name, "для имени существует CNAME, A-запись не создаём", "name", name, "zone", zone)
|
||
return nil
|
||
}
|
||
|
||
if r.cfg.DryRun {
|
||
r.log.Info("dry-run: создал бы A-запись", "name", name, "ip", r.cfg.IP, "ttl", r.cfg.TTL)
|
||
return nil
|
||
}
|
||
err := r.dns.CreateRRSet(ctx, zone, selectel.RRSet{
|
||
Name: name, Type: "A", TTL: r.cfg.TTL,
|
||
Records: []selectel.Record{{Content: r.cfg.IP}}, Comment: r.cfg.Comment,
|
||
})
|
||
if err != nil {
|
||
r.log.Error("не удалось создать A-запись", "name", name, "error", err)
|
||
return fmt.Errorf("создание %s: %w", name, err)
|
||
}
|
||
res.Created++
|
||
r.log.Info("A-запись создана", "name", name, "ip", r.cfg.IP, "ttl", r.cfg.TTL)
|
||
return nil
|
||
}
|
||
|
||
func (r *Reconciler) matches(rr selectel.RRSet) bool {
|
||
return rr.TTL == r.cfg.TTL && len(rr.Records) == 1 &&
|
||
rr.Records[0].Content == r.cfg.IP && !rr.Records[0].Disabled
|
||
}
|
||
|
||
// deleteStale удаляет managed A-записи, для которых роутер отсутствует дольше GracePeriod.
|
||
func (r *Reconciler) deleteStale(ctx context.Context, listed map[string][]selectel.RRSet, d desiredSet, res *Result) []error {
|
||
var errs []error
|
||
now := r.now()
|
||
|
||
zones := make([]string, 0, len(listed))
|
||
for z := range listed {
|
||
zones = append(zones, z)
|
||
}
|
||
sort.Strings(zones)
|
||
|
||
stale := map[string]struct{}{}
|
||
for _, zone := range zones {
|
||
for _, rr := range listed[zone] {
|
||
if rr.Type != "A" || !isManaged(rr, r.cfg.Comment) {
|
||
continue
|
||
}
|
||
if _, ok := d.present[rr.Name]; ok {
|
||
continue
|
||
}
|
||
stale[rr.Name] = struct{}{}
|
||
p, ok := r.pending[rr.Name]
|
||
if !ok {
|
||
r.pending[rr.Name] = pendingDelete{zone: zone, since: now}
|
||
r.log.Info("роут исчез, запись будет удалена после grace-периода", "name", rr.Name, "grace", r.cfg.GracePeriod.String())
|
||
continue
|
||
}
|
||
if now.Sub(p.since) < r.cfg.GracePeriod {
|
||
continue
|
||
}
|
||
if r.cfg.DryRun {
|
||
r.log.Info("dry-run: удалил бы A-запись", "name", rr.Name)
|
||
continue
|
||
}
|
||
if err := r.dns.DeleteRRSet(ctx, zone, rr.ID); err != nil {
|
||
r.log.Error("не удалось удалить A-запись", "name", rr.Name, "error", err)
|
||
errs = append(errs, fmt.Errorf("удаление %s: %w", rr.Name, err))
|
||
continue
|
||
}
|
||
delete(r.pending, rr.Name)
|
||
res.Deleted++
|
||
r.log.Info("A-запись удалена", "name", rr.Name)
|
||
}
|
||
}
|
||
|
||
// Чистим состояние для успешно прочитанных зон: запись уже не stale (вернулась/удалена извне).
|
||
for name, p := range r.pending {
|
||
if _, zoneOK := listed[p.zone]; !zoneOK {
|
||
continue
|
||
}
|
||
if _, ok := stale[name]; !ok {
|
||
delete(r.pending, name)
|
||
}
|
||
}
|
||
return errs
|
||
}
|