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 }