mirror of
https://github.com/tiennm99/traefik-cloudflare-dns.git
synced 2026-10-11 03:13:52 +00:00
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.
This commit is contained in:
commit
c953c94dcf
17 files changed
+1620
No files matched your search
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultComment = "managed by traefik-cloudflare-dns"
|
||||
|
||||
// Config holds every setting, read from environment variables.
|
||||
type Config struct {
|
||||
APIToken string
|
||||
ZoneID string
|
||||
Domain string
|
||||
Target string
|
||||
RecordType string
|
||||
Proxied bool
|
||||
TTL int
|
||||
Comment string
|
||||
Tags []string
|
||||
DeleteAfter time.Duration
|
||||
ResyncInterval time.Duration
|
||||
DryRun bool
|
||||
DockerHost string
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
// LoadConfig reads the configuration through getenv and validates it.
|
||||
func LoadConfig(getenv func(string) string) (Config, error) {
|
||||
c := Config{
|
||||
APIToken: strings.TrimSpace(getenv("CF_API_TOKEN")),
|
||||
ZoneID: strings.TrimSpace(getenv("CF_ZONE_ID")),
|
||||
Domain: strings.ToLower(strings.Trim(strings.TrimSpace(getenv("DOMAIN")), ".")),
|
||||
Target: strings.TrimSpace(getenv("TARGET")),
|
||||
Comment: getenv("RECORD_COMMENT"),
|
||||
DockerHost: strings.TrimSpace(getenv("DOCKER_HOST")),
|
||||
LogLevel: strings.ToLower(strings.TrimSpace(getenv("LOG_LEVEL"))),
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for name, value := range map[string]string{
|
||||
"CF_API_TOKEN": c.APIToken,
|
||||
"CF_ZONE_ID": c.ZoneID,
|
||||
"DOMAIN": c.Domain,
|
||||
"TARGET": c.Target,
|
||||
} {
|
||||
if value == "" {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
slices.Sort(missing)
|
||||
return Config{}, fmt.Errorf("missing required variables: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
c.RecordType = recordTypeFor(c.Target)
|
||||
if c.RecordType == "CNAME" {
|
||||
c.Target = strings.ToLower(strings.TrimSuffix(c.Target, "."))
|
||||
}
|
||||
|
||||
if c.Comment == "" {
|
||||
c.Comment = defaultComment
|
||||
}
|
||||
if strings.ContainsAny(c.Comment, "\r\n") {
|
||||
return Config{}, fmt.Errorf("RECORD_COMMENT must not contain line breaks")
|
||||
}
|
||||
|
||||
for _, tag := range strings.Split(getenv("RECORD_TAGS"), ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(tag, ":") {
|
||||
return Config{}, fmt.Errorf("RECORD_TAGS entry %q is not in name:value form", tag)
|
||||
}
|
||||
c.Tags = append(c.Tags, tag)
|
||||
}
|
||||
|
||||
var err error
|
||||
if c.Proxied, err = parseBool(getenv, "PROXIED", false); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.DryRun, err = parseBool(getenv, "DRY_RUN", false); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.TTL, err = parseInt(getenv, "TTL", 1); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.DeleteAfter, err = parseDuration(getenv, "DELETE_AFTER", time.Hour); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.ResyncInterval, err = parseDuration(getenv, "RESYNC_INTERVAL", time.Minute); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.ResyncInterval <= 0 {
|
||||
return Config{}, fmt.Errorf("RESYNC_INTERVAL must be positive")
|
||||
}
|
||||
|
||||
if c.DockerHost == "" {
|
||||
c.DockerHost = "unix:///var/run/docker.sock"
|
||||
}
|
||||
if c.LogLevel == "" {
|
||||
c.LogLevel = "info"
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// recordTypeFor returns A or AAAA for an IP target and CNAME for a hostname.
|
||||
func recordTypeFor(target string) string {
|
||||
ip := net.ParseIP(target)
|
||||
switch {
|
||||
case ip == nil:
|
||||
return "CNAME"
|
||||
case ip.To4() != nil:
|
||||
return "A"
|
||||
default:
|
||||
return "AAAA"
|
||||
}
|
||||
}
|
||||
|
||||
func parseBool(getenv func(string) string, name string, def bool) (bool, error) {
|
||||
v := strings.TrimSpace(getenv(name))
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s: %q is not a boolean", name, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func parseInt(getenv func(string) string, name string, def int) (int, error) {
|
||||
v := strings.TrimSpace(getenv(name))
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %q is not an integer", name, v)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseDuration(getenv func(string) string, name string, def time.Duration) (time.Duration, error) {
|
||||
v := strings.TrimSpace(getenv(name))
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil || d < 0 {
|
||||
return 0, fmt.Errorf("%s: %q is not a non-negative duration such as 1h or 30m", name, v)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// osGetenv is the production getenv.
|
||||
var osGetenv = os.Getenv
|
||||
Reference in new issue
Block a user