From 27c03fff46a8699623481673fe6859e1e8b06059 Mon Sep 17 00:00:00 2001 From: Zeusina Date: Sun, 15 Mar 2026 19:29:27 +0300 Subject: [PATCH] feat: init commit with main func --- .env.example | 79 ++++++++++++ .gitignore | 22 ++++ Dockerfile | 34 ++++++ config.go | 112 +++++++++++++++++ dns.go | 121 ++++++++++++++++++ git.go | 80 ++++++++++++ go.mod | 5 + go.sum | 4 + main.go | 237 ++++++++++++++++++++++++++++++++++++ reconcile.go | 103 ++++++++++++++++ traefik-dns-watcher.service | 37 ++++++ traefik.go | 140 +++++++++++++++++++++ 12 files changed, 974 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 config.go create mode 100644 dns.go create mode 100644 git.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 reconcile.go create mode 100644 traefik-dns-watcher.service create mode 100644 traefik.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a3627e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,79 @@ +# ────────────────────────────────────────────────────────────────────────────── +# traefik-dns-watcher — environment configuration +# Copy this file to /etc/traefik-dns-watcher/env and fill in real values. +# For Docker Compose, pass these as environment: directives or an env_file. +# ────────────────────────────────────────────────────────────────────────────── + +# ── Traefik API ─────────────────────────────────────────────────────────────── + +# Base URL of the Traefik API endpoint (no trailing slash). +# When running inside Docker on the same network, use the service name. +TRAEFIK_URL=http://traefik:8080 + +# Optional Basic Auth credentials if the Traefik API is protected. +# Leave empty if Traefik API is accessible without authentication (internal network). +TRAEFIK_USERNAME= +TRAEFIK_PASSWORD= + +# ── DNS zones ───────────────────────────────────────────────────────────────── + +# Comma-separated list of DNS zones that the watcher manages. +# Only Traefik routers with Host() rules matching these zones will produce DNS records. +DNS_ZONES=example.com,example.net + +# Public IPv4 address that all A records will point to. +PUBLIC_IP=1.2.3.4 + +# Public IPv6 address for AAAA records. Leave empty to disable AAAA record generation. +PUBLIC_IPV6= + +# TTL (in seconds) for all generated DNS records. +RECORD_TTL=300 + +# ── DNS Git repository ──────────────────────────────────────────────────────── + +# Absolute path to the pre-cloned DNS OctoDNS repository on the local filesystem. +# The service will not clone the repository; it must already exist. +# Docker: mount this path as a volume. +DNS_REPO_PATH=/dns-repo + +# Branch to pull from and push to. +DNS_REPO_BRANCH=main + +# Git remote name. +DNS_REPO_REMOTE=origin + +# Directory inside the repository where dynamic zone files are stored. +# The watcher will only write to this directory; static zones are left untouched. +DNS_REPO_DYNAMIC_DIR=zones-dynamic + +# Author identity for git commits produced by this service. +DNS_REPO_AUTHOR_NAME=traefik-dns-watcher +DNS_REPO_AUTHOR_EMAIL=dns-bot@example.com + +# ── Timing ──────────────────────────────────────────────────────────────────── + +# Interval between full periodic reconciles (independent of Docker events). +# Compensates for missed events after restarts or stream interruptions. +# Valid Go duration strings: 60s, 5m, 1h +RECONCILE_INTERVAL=60s + +# Quiet-period after the last Docker event before a reconcile is triggered. +# Coalesces rapid bursts (e.g. rolling restarts) into a single reconcile. +DEBOUNCE_DELAY=5s + +# ── Docker ──────────────────────────────────────────────────────────────────── + +# Docker daemon endpoint. Leave empty to use the default Unix socket. +# The standard DOCKER_HOST variable is read automatically by the Docker SDK. +# Examples: +# unix:///var/run/docker.sock (default) +# tcp://remote-host:2376 +DOCKER_HOST= + +# ── Filtering ───────────────────────────────────────────────────────────────── + +# Comma-separated list of Traefik router names to exclude from DNS management. +# Useful for internal or special-purpose routers that happen to match a managed zone. +# Example: my-internal-router@docker,legacy-app@docker +EXCLUDE_ROUTERS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a529e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Build artifacts +*.exe +*.exe~ +*.dll +*.so +*.dylib +traefik-dns-watcher + +# Logs +*.log + +# Local environment files +.env +.env.local + +# IDE settings (keep MCP config) +.vscode/* +!.vscode/mcp.json + +# OS files +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..78b3823 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 + +# ─── Build stage ─────────────────────────────────────────────────────────────── +FROM golang:1.23-alpine AS builder + +WORKDIR /build + +# Download dependencies first for better layer caching. +COPY go.mod go.sum ./ +RUN go mod download + +COPY *.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o traefik-dns-watcher . + +# ─── Final stage ─────────────────────────────────────────────────────────────── +FROM alpine:3.20 + +# git — required for all DNS repo operations +# openssh-client — required for SSH-based git push/pull +# ca-certificates — required for HTTPS git remotes and Traefik API calls +RUN apk add --no-cache git openssh-client ca-certificates \ + && adduser -D -u 1001 appuser + +WORKDIR /app +COPY --from=builder /build/traefik-dns-watcher . + +# The container runs as a non-root user. +# Required bind-mounts / volumes: +# /var/run/docker.sock — Docker events API (read-only is sufficient) +# /dns-repo — pre-cloned DNS git repository (DNS_REPO_PATH) +# /root/.ssh or /home/appuser/.ssh — SSH key for git authentication (if using SSH) +USER appuser + +ENTRYPOINT ["/app/traefik-dns-watcher"] diff --git a/config.go b/config.go new file mode 100644 index 0000000..afa1ca9 --- /dev/null +++ b/config.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Config holds all runtime configuration loaded from environment variables. +type Config struct { + TraefikURL string + TraefikUsername string + TraefikPassword string + + Zones []string + PublicIP string + PublicIPv6 string // empty = no AAAA records + + RepoPath string + RepoBranch string + RepoRemote string + DynamicDir string + AuthorName string + AuthorEmail string + + ReconcileInterval time.Duration + DebounceDelay time.Duration + + RecordTTL int + ExcludeRouters map[string]struct{} +} + +// LoadConfig reads configuration from environment variables and validates required fields. +func LoadConfig() (*Config, error) { + cfg := &Config{} + + cfg.TraefikURL = envOrDefault("TRAEFIK_URL", "http://localhost:8080") + cfg.TraefikUsername = os.Getenv("TRAEFIK_USERNAME") + cfg.TraefikPassword = os.Getenv("TRAEFIK_PASSWORD") + + zonesStr := os.Getenv("DNS_ZONES") + if zonesStr == "" { + return nil, fmt.Errorf("DNS_ZONES is required (comma-separated list of zones, e.g. example.com,example.net)") + } + for _, z := range strings.Split(zonesStr, ",") { + if z = strings.TrimSpace(z); z != "" { + cfg.Zones = append(cfg.Zones, z) + } + } + + cfg.PublicIP = os.Getenv("PUBLIC_IP") + if cfg.PublicIP == "" { + return nil, fmt.Errorf("PUBLIC_IP is required") + } + cfg.PublicIPv6 = os.Getenv("PUBLIC_IPV6") + + cfg.RepoPath = os.Getenv("DNS_REPO_PATH") + if cfg.RepoPath == "" { + return nil, fmt.Errorf("DNS_REPO_PATH is required") + } + + cfg.RepoBranch = envOrDefault("DNS_REPO_BRANCH", "main") + cfg.RepoRemote = envOrDefault("DNS_REPO_REMOTE", "origin") + cfg.DynamicDir = envOrDefault("DNS_REPO_DYNAMIC_DIR", "zones-dynamic") + cfg.AuthorName = envOrDefault("DNS_REPO_AUTHOR_NAME", "traefik-dns-watcher") + cfg.AuthorEmail = envOrDefault("DNS_REPO_AUTHOR_EMAIL", "dns-bot@localhost") + + var err error + cfg.ReconcileInterval, err = parseDurationEnv("RECONCILE_INTERVAL", "60s") + if err != nil { + return nil, err + } + cfg.DebounceDelay, err = parseDurationEnv("DEBOUNCE_DELAY", "5s") + if err != nil { + return nil, err + } + + ttlStr := envOrDefault("RECORD_TTL", "300") + cfg.RecordTTL, err = strconv.Atoi(ttlStr) + if err != nil { + return nil, fmt.Errorf("RECORD_TTL: invalid integer %q: %w", ttlStr, err) + } + + cfg.ExcludeRouters = make(map[string]struct{}) + if v := os.Getenv("EXCLUDE_ROUTERS"); v != "" { + for _, r := range strings.Split(v, ",") { + if r = strings.TrimSpace(r); r != "" { + cfg.ExcludeRouters[r] = struct{}{} + } + } + } + + return cfg, nil +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func parseDurationEnv(key, def string) (time.Duration, error) { + s := envOrDefault(key, def) + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("%s: invalid duration %q: %w", key, s, err) + } + return d, nil +} diff --git a/dns.go b/dns.go new file mode 100644 index 0000000..e5e6b8b --- /dev/null +++ b/dns.go @@ -0,0 +1,121 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// ZoneFile is the in-memory representation of a dynamic OctoDNS zone YAML file. +// Keys are subdomain labels (empty string = apex record). +// Values are either a single record map or a list of record maps (for multi-type). +type ZoneFile map[string]interface{} + +// RecordStats counts the changes between two zone states. +type RecordStats struct { + Added int + Removed int + Changed int +} + +// ReadZoneFile parses an OctoDNS YAML zone file. +// If the file does not exist, returns an empty ZoneFile (not an error). +func ReadZoneFile(path string) (ZoneFile, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ZoneFile{}, nil + } + if err != nil { + return nil, fmt.Errorf("read zone file %q: %w", path, err) + } + var zf ZoneFile + if err := yaml.Unmarshal(data, &zf); err != nil { + return nil, fmt.Errorf("parse zone file %q: %w", path, err) + } + if zf == nil { + zf = ZoneFile{} + } + return zf, nil +} + +// WriteZoneFile writes an OctoDNS YAML zone file, creating parent directories as needed. +// yaml.v3 sorts map keys alphabetically, ensuring deterministic output. +func WriteZoneFile(path string, zf ZoneFile) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create directory for zone file: %w", err) + } + data, err := yaml.Marshal(map[string]interface{}(zf)) + if err != nil { + return fmt.Errorf("marshal zone file: %w", err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write zone file %q: %w", path, err) + } + return nil +} + +// BuildZoneFile constructs the desired zone file from the set of active subdomains. +func BuildZoneFile(subdomains map[string]struct{}, cfg *Config) ZoneFile { + zf := make(ZoneFile, len(subdomains)) + for sub := range subdomains { + zf[sub] = buildRecord(cfg) + } + return zf +} + +// buildRecord creates the YAML-serialisable record value for a subdomain. +// Returns a single-record map (OctoDNS simple syntax) when only IPv4 is configured, +// or a two-element list (OctoDNS list syntax) when both A and AAAA are required. +func buildRecord(cfg *Config) interface{} { + aRec := map[string]interface{}{ + "ttl": cfg.RecordTTL, + "type": "A", + "values": []string{cfg.PublicIP}, + } + if cfg.PublicIPv6 == "" { + return aRec + } + aaaaRec := map[string]interface{}{ + "ttl": cfg.RecordTTL, + "type": "AAAA", + "values": []string{cfg.PublicIPv6}, + } + return []interface{}{aRec, aaaaRec} +} + +// DiffZoneFile computes the difference between the current (on-disk) and desired zone files. +// It reports how many records were added, removed, or changed. +func DiffZoneFile(current, desired ZoneFile) RecordStats { + var stats RecordStats + for k := range desired { + if _, exists := current[k]; !exists { + stats.Added++ + } + } + for k := range current { + if _, exists := desired[k]; !exists { + stats.Removed++ + } + } + // Detect value changes (e.g. IP or TTL updated in config). + for k, dv := range desired { + cv, exists := current[k] + if !exists { + continue + } + // Marshal both sides to compare: handles nested maps and slices uniformly. + cs, _ := yaml.Marshal(cv) + ds, _ := yaml.Marshal(dv) + if string(cs) != string(ds) { + stats.Changed++ + } + } + return stats +} + +// ZoneFilePath returns the absolute path to the dynamic zone YAML file for a given zone. +func ZoneFilePath(cfg *Config, zone string) string { + return filepath.Join(cfg.RepoPath, cfg.DynamicDir, zone+".yaml") +} diff --git a/git.go b/git.go new file mode 100644 index 0000000..ffd2e53 --- /dev/null +++ b/git.go @@ -0,0 +1,80 @@ +package main + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +// gitRun executes a git command in repoPath, returning a descriptive error +// that includes the combined stdout+stderr on failure. +func gitRun(repoPath string, args ...string) error { + cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git %s: %w\n%s", + strings.Join(args, " "), err, bytes.TrimSpace(out)) + } + return nil +} + +// gitOutput executes a git command and returns its stdout as a string. +func gitOutput(repoPath string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...) + out, err := cmd.Output() + if err != nil { + var stderr []byte + if ee, ok := err.(*exec.ExitError); ok { + stderr = ee.Stderr + } + return "", fmt.Errorf("git %s: %w\n%s", + strings.Join(args, " "), err, bytes.TrimSpace(stderr)) + } + return string(out), nil +} + +// GitPull fetches from the remote and rebases the local branch. +// Using --autostash ensures any uncommitted changes are preserved across the rebase +// (should not normally happen but guards against manual edits). +func GitPull(cfg *Config) error { + if err := gitRun(cfg.RepoPath, "fetch", "--prune", cfg.RepoRemote); err != nil { + return fmt.Errorf("git fetch: %w", err) + } + if err := gitRun(cfg.RepoPath, + "pull", "--rebase", "--autostash", + cfg.RepoRemote, cfg.RepoBranch, + ); err != nil { + return fmt.Errorf("git pull --rebase: %w", err) + } + return nil +} + +// GitStatusChanged reports true when the working tree has uncommitted changes. +func GitStatusChanged(cfg *Config) (bool, error) { + out, err := gitOutput(cfg.RepoPath, "status", "--porcelain") + if err != nil { + return false, err + } + return strings.TrimSpace(out) != "", nil +} + +// GitCommitAndPush stages the dynamic directory, commits with the given message, +// and pushes to the configured remote branch. +// Author identity is passed via git -c flags to avoid requiring global git config. +func GitCommitAndPush(cfg *Config, message string) error { + if err := gitRun(cfg.RepoPath, "add", cfg.DynamicDir); err != nil { + return fmt.Errorf("git add: %w", err) + } + if err := gitRun(cfg.RepoPath, + "-c", "user.name="+cfg.AuthorName, + "-c", "user.email="+cfg.AuthorEmail, + "commit", "-m", message, + ); err != nil { + return fmt.Errorf("git commit: %w", err) + } + if err := gitRun(cfg.RepoPath, "push", cfg.RepoRemote, cfg.RepoBranch); err != nil { + return fmt.Errorf("git push: %w", err) + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..81531eb --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module traefik-dns-watcher + +go 1.23 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8635232 --- /dev/null +++ b/main.go @@ -0,0 +1,237 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + cfg, err := LoadConfig() + if err != nil { + slog.Error("configuration error", "error", err) + os.Exit(1) + } + + slog.Info("traefik-dns-watcher starting", + "traefik_url", cfg.TraefikURL, + "zones", cfg.Zones, + "repo_path", cfg.RepoPath, + "dynamic_dir", cfg.DynamicDir, + "reconcile_interval", cfg.ReconcileInterval, + "debounce_delay", cfg.DebounceDelay, + ) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + + // triggerCh is a 1-buffered channel that acts as a coalescing "reconcile needed" signal. + // Sending never blocks: if a trigger is already pending, the new one is silently dropped. + triggerCh := make(chan struct{}, 1) + maybeTrigger := func() { + select { + case triggerCh <- struct{}{}: + default: // a trigger is already queued + } + } + + // Debounce goroutine: absorbs rapid bursts of triggers and fires a single reconcile + // after the quiet period defined by cfg.DebounceDelay. + go func() { + timer := time.NewTimer(0) + // Drain the initial zero tick so the timer starts in a stopped state. + if !timer.Stop() { + <-timer.C + } + for { + select { + case <-triggerCh: + // Reset the timer on every incoming trigger — extends the quiet window. + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(cfg.DebounceDelay) + case <-timer.C: + go Reconcile(cfg) + case <-ctx.Done(): + timer.Stop() + return + } + } + }() + + // Periodic ticker: ensures DNS state is reconciled even when Docker events are silent. + go func() { + ticker := time.NewTicker(cfg.ReconcileInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + maybeTrigger() + case <-ctx.Done(): + return + } + } + }() + + // Docker events watcher: subscribes to container lifecycle events and uses them + // as low-latency triggers for reconciliation. + go watchDockerEvents(ctx, maybeTrigger) + + // Perform an immediate reconcile at startup to bring DNS into sync. + go Reconcile(cfg) + + <-ctx.Done() + slog.Info("shutdown signal received") + + // Wait for any in-flight reconcile to finish before exiting. + reconcileMu.Lock() + reconcileMu.Unlock() //nolint:staticcheck + slog.Info("traefik-dns-watcher stopped") +} + +// watchDockerEvents connects to the Docker daemon and forwards container lifecycle +// events to the trigger function. Reconnects with exponential backoff on failure. +func watchDockerEvents(ctx context.Context, trigger func()) { + backoff := 2 * time.Second + const maxBackoff = 60 * time.Second + + for { + if ctx.Err() != nil { + return + } + err := runDockerEventLoop(ctx, trigger) + if ctx.Err() != nil { + return + } + slog.Warn("docker events stream ended — reconnecting", + "error", err, "backoff", backoff) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } + if backoff < maxBackoff { + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + } +} + +// dockerEvent is the minimal structure we need from the Docker events JSON stream. +type dockerEvent struct { + Type string `json:"Type"` + Action string `json:"Action"` + Actor struct { + ID string `json:"ID"` + } `json:"Actor"` +} + +// runDockerEventLoop opens a single Docker events HTTP stream (raw, no SDK) and +// forwards relevant container events to the trigger function. +// Supports both Unix socket (unix:///var/run/docker.sock) and TCP (tcp://host:port) +// via the standard DOCKER_HOST environment variable. +func runDockerEventLoop(ctx context.Context, trigger func()) error { + httpClient, baseURL, err := newDockerHTTPClient() + if err != nil { + return fmt.Errorf("build docker HTTP client: %w", err) + } + + filterVal := `{"type":["container"]}` + eventsURL := baseURL + "/events?filters=" + url.QueryEscape(filterVal) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, eventsURL, nil) + if err != nil { + return fmt.Errorf("build docker events request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("connect to docker events: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("docker events returned HTTP %d", resp.StatusCode) + } + + slog.Info("connected to docker events stream") + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var evt dockerEvent + if err := json.Unmarshal(line, &evt); err != nil { + slog.Debug("failed to parse docker event", "error", err) + continue + } + if evt.Type != "container" { + continue + } + switch evt.Action { + case "start", "stop", "die", "destroy": + actorID := evt.Actor.ID + if len(actorID) > 12 { + actorID = actorID[:12] + } + slog.Debug("docker event received", "action", evt.Action, "id", actorID) + trigger() + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("docker events stream error: %w", err) + } + return fmt.Errorf("docker events stream closed") +} + +// newDockerHTTPClient builds an HTTP client and base URL for the Docker daemon. +// Reads DOCKER_HOST from the environment (default: unix:///var/run/docker.sock). +// For Unix sockets the HTTP transport dials the socket; the placeholder host "docker" +// is used in the URL (standard practice for Unix socket HTTP clients). +func newDockerHTTPClient() (*http.Client, string, error) { + dockerHost := os.Getenv("DOCKER_HOST") + if dockerHost == "" { + dockerHost = "unix:///var/run/docker.sock" + } + + switch { + case strings.HasPrefix(dockerHost, "unix://"): + socketPath := strings.TrimPrefix(dockerHost, "unix://") + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) + }, + } + return &http.Client{Transport: transport}, "http://docker", nil + + case strings.HasPrefix(dockerHost, "tcp://"): + baseURL := strings.Replace(dockerHost, "tcp://", "http://", 1) + return &http.Client{}, baseURL, nil + + default: + return nil, "", fmt.Errorf("unsupported DOCKER_HOST scheme: %q (expected unix:// or tcp://)", dockerHost) + } +} diff --git a/reconcile.go b/reconcile.go new file mode 100644 index 0000000..f3b470b --- /dev/null +++ b/reconcile.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "log/slog" + "sync" +) + +// reconcileMu ensures only one reconcile runs at a time. +// TryLock is used so that concurrent triggers simply skip rather than queue. +var reconcileMu sync.Mutex + +// Reconcile is the core reconciliation loop: +// 1. Fetches the active router list from Traefik (source of truth). +// 2. Pulls the DNS repository to ensure it is up to date. +// 3. Writes desired zone files for each managed zone. +// 4. Commits and pushes if anything changed. +// +// If Traefik is unreachable or git pull fails, the reconcile is aborted +// without modifying any DNS files (safe-fail behaviour). +func Reconcile(cfg *Config) { + if !reconcileMu.TryLock() { + slog.Info("reconcile already in progress, skipping") + return + } + defer reconcileMu.Unlock() + + slog.Info("reconcile started") + + routers, err := FetchRouters(cfg) + if err != nil { + slog.Error("failed to fetch Traefik routers — skipping reconcile to avoid stale DNS removal", + "error", err) + return + } + slog.Info("fetched routers from Traefik", "count", len(routers)) + + desired := FilterDomains(routers, cfg) + + if err := GitPull(cfg); err != nil { + slog.Error("git pull failed — skipping reconcile", "error", err) + return + } + + var total RecordStats + for zone, subdomains := range desired { + path := ZoneFilePath(cfg, zone) + + current, err := ReadZoneFile(path) + if err != nil { + slog.Error("failed to read zone file", "zone", zone, "error", err) + return + } + + newZF := BuildZoneFile(subdomains, cfg) + stats := DiffZoneFile(current, newZF) + total.Added += stats.Added + total.Removed += stats.Removed + total.Changed += stats.Changed + + if err := WriteZoneFile(path, newZF); err != nil { + slog.Error("failed to write zone file", "zone", zone, "error", err) + return + } + + if stats.Added+stats.Removed+stats.Changed > 0 { + slog.Info("zone updated", + "zone", zone, + "records", len(subdomains), + "added", stats.Added, + "removed", stats.Removed, + "changed", stats.Changed, + ) + } else { + slog.Info("zone unchanged", "zone", zone, "records", len(subdomains)) + } + } + + changed, err := GitStatusChanged(cfg) + if err != nil { + slog.Error("git status check failed", "error", err) + return + } + if !changed { + slog.Info("reconcile complete — no changes to commit") + return + } + + msg := fmt.Sprintf( + "chore(dns): reconcile dynamic records from traefik\n\nadded: %d, removed: %d, updated: %d", + total.Added, total.Removed, total.Changed, + ) + if err := GitCommitAndPush(cfg, msg); err != nil { + slog.Error("failed to commit/push DNS changes", "error", err) + return + } + + slog.Info("reconcile complete — changes pushed", + "added", total.Added, + "removed", total.Removed, + "updated", total.Changed, + ) +} diff --git a/traefik-dns-watcher.service b/traefik-dns-watcher.service new file mode 100644 index 0000000..cf0f161 --- /dev/null +++ b/traefik-dns-watcher.service @@ -0,0 +1,37 @@ +[Unit] +Description=Traefik DNS Watcher — automatic DNS reconciliation from Traefik routers +Documentation=https://github.com/yourorg/traefik-dns-watcher +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=traefik-dns-watcher +Group=traefik-dns-watcher + +# Configuration is loaded from this file (copy from .env.example and fill in values). +EnvironmentFile=/etc/traefik-dns-watcher/env + +ExecStart=/usr/local/bin/traefik-dns-watcher + +# Restart policy: restart on unexpected exit, but not on clean stop (exit code 0). +Restart=on-failure +RestartSec=10s + +# Allow the service user to access the Docker socket. +# Ensure the user is a member of the 'docker' group: +# usermod -aG docker traefik-dns-watcher +SupplementaryGroups=docker + +# Logging goes to journald. +StandardOutput=journal +StandardError=journal +SyslogIdentifier=traefik-dns-watcher + +# Basic hardening. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full + +[Install] +WantedBy=multi-user.target diff --git a/traefik.go b/traefik.go new file mode 100644 index 0000000..17fd05a --- /dev/null +++ b/traefik.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "time" +) + +// Router represents a single Traefik HTTP router as returned by /api/http/routers. +type Router struct { + Name string `json:"name"` + Rule string `json:"rule"` + Status string `json:"status"` + EntryPoints []string `json:"entryPoints"` + Provider string `json:"provider"` +} + +// hostRuleRe extracts hostnames from a Traefik rule such as: +// +// Host(`app.example.com`) or Host(`a.com`, `b.com`) or HostRegexp(...) +// +// We capture only bare backtick-quoted tokens which are exact hostnames. +var hostRuleRe = regexp.MustCompile("`([^`]+)`") + +// FetchRouters calls the Traefik API and returns the full list of HTTP routers. +// Returns an error if Traefik is unreachable or returns a non-200 status. +func FetchRouters(cfg *Config) ([]Router, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + cfg.TraefikURL+"/api/http/routers?per_page=1000", nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + if cfg.TraefikUsername != "" { + req.SetBasicAuth(cfg.TraefikUsername, cfg.TraefikPassword) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("traefik API unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("traefik API returned HTTP %d", resp.StatusCode) + } + + var routers []Router + if err := json.NewDecoder(resp.Body).Decode(&routers); err != nil { + return nil, fmt.Errorf("decode routers response: %w", err) + } + return routers, nil +} + +// ParseHostRule extracts all exact hostnames from a Traefik rule string. +// Host(`foo.example.com`) → ["foo.example.com"] +// Host(`a.com`, `b.com`) → ["a.com", "b.com"] +// HostRegexp patterns (containing special regex chars) are skipped. +func ParseHostRule(rule string) []string { + var hosts []string + for _, m := range hostRuleRe.FindAllStringSubmatch(rule, -1) { + if len(m) < 2 { + continue + } + h := strings.ToLower(strings.TrimSpace(m[1])) + // Skip if it looks like a regex pattern (contains regexp metacharacters). + if strings.ContainsAny(h, `^${}*()+?|\\`) { + continue + } + hosts = append(hosts, h) + } + return hosts +} + +// FilterDomains builds the desired DNS state from active Traefik routers. +// Returns a map of zone → set of relative subdomain labels that should have records. +func FilterDomains(routers []Router, cfg *Config) map[string]map[string]struct{} { + result := make(map[string]map[string]struct{}, len(cfg.Zones)) + for _, zone := range cfg.Zones { + result[zone] = make(map[string]struct{}) + } + + for _, r := range routers { + // Only consider routers Traefik reports as active. + if r.Status != "enabled" { + continue + } + // Exclude Traefik's own internal routers (api@internal, dashboard@internal, etc.). + if strings.HasSuffix(r.Name, "@internal") { + continue + } + // Exclude routers explicitly listed in config. + if _, excluded := cfg.ExcludeRouters[r.Name]; excluded { + continue + } + // Must have a Host() rule to generate a DNS record. + if !strings.Contains(r.Rule, "Host(") { + continue + } + + for _, host := range ParseHostRule(r.Rule) { + zone, sub, ok := matchZone(host, cfg.Zones) + if !ok { + continue + } + result[zone][sub] = struct{}{} + } + } + + return result +} + +// matchZone finds the longest matching zone for a given FQDN. +// Returns (zone, subdomain, true) on match. +// Apex domain (fqdn == zone) returns subdomain "". +func matchZone(fqdn string, zones []string) (zone, sub string, ok bool) { + fqdn = strings.TrimSuffix(fqdn, ".") + best := "" + for _, z := range zones { + z = strings.TrimSuffix(z, ".") + if fqdn == z || strings.HasSuffix(fqdn, "."+z) { + if len(z) > len(best) { + best = z + } + } + } + if best == "" { + return "", "", false + } + if fqdn == best { + return best, "", true + } + return best, strings.TrimSuffix(fqdn, "."+best), true +}