commit 735e2052e83faff9343ac6197d4d3db716a69109 Author: Zeusina Date: Sun Mar 15 19:29:27 2026 +0300 feat: init commit with main func 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/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..9c183bd --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,13 @@ +{ + "servers": { + "git": { + "type": "stdio", + "command": "git-mcp-go", + "args": [ + "serve", + "D:\\Projects\\DevOps\\traefik-dns-watcher" + ] + } + }, + "inputs": [] +} \ No newline at end of file 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 +}