mirror of
https://github.com/tiennm99/traefik-cloudflare-dns.git
synced 2026-10-11 03:13:52 +00:00
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.
174 lines
4.6 KiB
Go
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
|
|
}
|