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
|
||||||
13
.vscode/mcp.json
vendored
Normal file
13
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"git": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "git-mcp-go",
|
||||||
|
"args": [
|
||||||
|
"serve",
|
||||||
|
"D:\\Projects\\DevOps\\traefik-dns-watcher"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inputs": []
|
||||||
|
}
|
||||||
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