feat: init commit with main func
This commit is contained in:
79
.env.example
Normal file
79
.env.example
Normal 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
22
.gitignore
vendored
Normal 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
34
Dockerfile
Normal 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
112
config.go
Normal 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
121
dns.go
Normal 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
80
git.go
Normal 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
5
go.mod
Normal 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
4
go.sum
Normal 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
237
main.go
Normal 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
103
reconcile.go
Normal 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,
|
||||
)
|
||||
}
|
||||
37
traefik-dns-watcher.service
Normal file
37
traefik-dns-watcher.service
Normal 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
140
traefik.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user