Files
traefik-dns-watcher/traefik.go

141 lines
3.9 KiB
Go

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
}