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

124 lines
3.1 KiB
Go

// Command traefik-cloudflare-dns creates Cloudflare DNS records for the
// hostnames in Traefik router labels of running Docker containers, and
// deletes the records it created once their host has been down long enough.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
)
const (
debounce = 2 * time.Second
retryAfter = 10 * time.Second
)
func main() {
cfg, err := LoadConfig(osGetenv)
if err != nil {
slog.Error("invalid configuration", "err", err)
os.Exit(2)
}
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
docker, err := NewDockerClient(cfg.DockerHost)
if err != nil {
log.Error("invalid configuration", "err", err)
os.Exit(2)
}
rec := NewReconciler(cfg, NewCloudflareClient(cfg.APIToken, cfg.ZoneID), docker, log)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
log.Info("starting",
"domain", cfg.Domain, "target", cfg.Target, "type", cfg.RecordType,
"proxied", cfg.Proxied, "comment", cfg.Comment, "tags", cfg.Tags,
"delete_after", cfg.DeleteAfter, "resync_interval", cfg.ResyncInterval,
"dry_run", cfg.DryRun, "docker_host", cfg.DockerHost)
trigger := make(chan struct{}, 1)
go watchEvents(ctx, docker, trigger, log)
run(ctx, rec, trigger, cfg.ResyncInterval, log)
log.Info("stopped")
}
// run reconciles once at start, then after each burst of container events,
// on every resync tick, and shortly after a failed pass, until ctx is done.
func run(ctx context.Context, rec *Reconciler, trigger <-chan struct{}, every time.Duration, log *slog.Logger) {
var retry <-chan time.Time
pass := func() {
retry = nil
if err := rec.Reconcile(ctx); err != nil && ctx.Err() == nil {
log.Error("reconcile failed; retrying", "err", err, "in", retryAfter)
retry = time.After(retryAfter)
}
}
pass()
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
pass()
case <-retry:
pass()
case <-trigger:
select {
case <-ctx.Done():
return
case <-time.After(debounce):
}
pass()
}
}
}
// watchEvents keeps a Docker event stream open and signals trigger on every
// container start, die or destroy, reconnecting with backoff when it drops.
func watchEvents(ctx context.Context, docker *DockerClient, trigger chan<- struct{}, log *slog.Logger) {
backoff := time.Second
for ctx.Err() == nil {
started := time.Now()
err := docker.WatchContainerEvents(ctx, func(action string) {
log.Debug("container event", "action", action)
select {
case trigger <- struct{}{}:
default:
}
})
if ctx.Err() != nil {
return
}
if time.Since(started) > time.Minute {
backoff = time.Second
}
log.Warn("docker event stream ended; reconnecting", "err", err, "in", backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff = min(backoff*2, time.Minute)
}
}
func parseLevel(s string) slog.Level {
switch s {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}