Files
tiennm99 c953c94dcf feat: sync Cloudflare DNS records with Traefik container hosts
Create a record for each Host(...) in running containers' Traefik router
labels, tagged with a configurable comment and optional tags, and delete
owned records once their host has been down for DELETE_AFTER.
2026-10-11 03:01:45 +07:00

174 lines
4.6 KiB
Go

package main
import (
"context"
"log/slog"
"slices"
"time"
)
// DNS is the record store the reconciler writes to.
type DNS interface {
ListRecords(ctx context.Context) ([]Record, error)
CreateRecord(ctx context.Context, r Record) (Record, error)
DeleteRecord(ctx context.Context, id string) error
}
// ContainerSource lists the running containers.
type ContainerSource interface {
RunningContainers(ctx context.Context) ([]Container, error)
}
// Reconciler keeps the zone in line with the hosts of running containers.
//
// A record is owned when its content is this instance's target and it
// carries the configured comment and every configured tag. Only owned
// records are deleted, so records made by hand, or by another instance
// pointing at another server, are never touched.
type Reconciler struct {
cfg Config
dns DNS
containers ContainerSource
log *slog.Logger
now func() time.Time
// missingSince tracks when each owned record's host was last served.
missingSince map[string]time.Time
// warned avoids repeating the same conflict warning every pass.
warned map[string]bool
}
// NewReconciler returns a reconciler for cfg.
func NewReconciler(cfg Config, dns DNS, containers ContainerSource, log *slog.Logger) *Reconciler {
return &Reconciler{
cfg: cfg,
dns: dns,
containers: containers,
log: log,
now: time.Now,
missingSince: map[string]time.Time{},
warned: map[string]bool{},
}
}
func (r *Reconciler) owns(rec Record) bool {
if rec.Type != r.cfg.RecordType || rec.Content != r.cfg.Target || rec.Comment != r.cfg.Comment {
return false
}
for _, tag := range r.cfg.Tags {
if !slices.Contains(rec.Tags, tag) {
return false
}
}
return true
}
// Reconcile runs one pass: create records for new hosts, and delete owned
// records whose host has been absent for at least DeleteAfter.
func (r *Reconciler) Reconcile(ctx context.Context) error {
containers, err := r.containers.RunningContainers(ctx)
if err != nil {
return err
}
desired := map[string]bool{}
for _, c := range containers {
for _, h := range HostsFromLabels(c.Labels) {
if InDomain(h, r.cfg.Domain) {
desired[h] = true
}
}
}
records, err := r.dns.ListRecords(ctx)
if err != nil {
return err
}
byName := map[string][]Record{}
for _, rec := range records {
byName[rec.Name] = append(byName[rec.Name], rec)
}
for _, host := range sortedKeys(desired) {
existing := byName[host]
if slices.ContainsFunc(existing, r.owns) {
delete(r.missingSince, host)
continue
}
if len(existing) > 0 {
if !r.warned[host] {
r.log.Warn("record exists and is not managed by this instance; leaving it alone",
"host", host, "type", existing[0].Type, "content", existing[0].Content)
r.warned[host] = true
}
continue
}
rec := Record{
Type: r.cfg.RecordType,
Name: host,
Content: r.cfg.Target,
TTL: r.cfg.TTL,
Proxied: r.cfg.Proxied,
Comment: r.cfg.Comment,
Tags: r.cfg.Tags,
}
if r.cfg.DryRun {
r.log.Info("dry run: would create record", "host", host, "type", rec.Type, "content", rec.Content)
continue
}
if _, err := r.dns.CreateRecord(ctx, rec); err != nil {
r.log.Error("create record failed", "host", host, "err", err)
continue
}
r.log.Info("created record", "host", host, "type", rec.Type, "content", rec.Content)
}
for host := range r.warned {
if !desired[host] {
delete(r.warned, host)
}
}
now := r.now()
owned := map[string]bool{}
for _, rec := range records {
if !r.owns(rec) || desired[rec.Name] {
continue
}
owned[rec.Name] = true
since, tracked := r.missingSince[rec.Name]
if !tracked {
r.missingSince[rec.Name] = now
r.log.Info("host no longer served; record will be deleted if it stays down",
"host", rec.Name, "delete_after", r.cfg.DeleteAfter)
since = now
}
if now.Sub(since) < r.cfg.DeleteAfter {
continue
}
if r.cfg.DryRun {
r.log.Info("dry run: would delete record", "host", rec.Name, "down_for", now.Sub(since).Round(time.Second))
continue
}
if err := r.dns.DeleteRecord(ctx, rec.ID); err != nil {
r.log.Error("delete record failed", "host", rec.Name, "err", err)
continue
}
r.log.Info("deleted record", "host", rec.Name, "down_for", now.Sub(since).Round(time.Second))
delete(r.missingSince, rec.Name)
}
for host := range r.missingSince {
if !owned[host] {
delete(r.missingSince, host)
}
}
return nil
}
func sortedKeys(m map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
return keys
}