141 lines
3.9 KiB
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
|
|
}
|