feat: init commit with main func

This commit is contained in:
2026-03-15 19:29:27 +03:00
commit 27c03fff46
12 changed files with 974 additions and 0 deletions

79
.env.example Normal file
View File

@@ -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=

22
.gitignore vendored Normal file
View File

@@ -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

34
Dockerfile Normal file
View File

@@ -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"]

112
config.go Normal file
View File

@@ -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
}

121
dns.go Normal file
View File

@@ -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")
}

80
git.go Normal file
View File

@@ -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
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module traefik-dns-watcher
go 1.23
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -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=

237
main.go Normal file
View File

@@ -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)
}
}

103
reconcile.go Normal file
View File

@@ -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,
)
}

View File

@@ -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

140
traefik.go Normal file
View File

@@ -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
}