mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-12-09 05:36:27 +08:00
## Summary This PR adds a new domain modifier `IGNORE_EXTERNAL_DNS()` that automatically detects and ignores DNS records managed by Kubernetes [external-dns](https://github.com/kubernetes-sigs/external-dns) controller. **Related Issue:** This addresses the feature request discussed in StackExchange/dnscontrol#935 (Idea: Ownership system), where @tlimoncelli indicated openness to accepting a PR for this functionality. ## Problem When running DNSControl alongside Kubernetes external-dns, users face a challenge: - **external-dns** dynamically creates DNS records based on Kubernetes Ingress/Service resources - Users cannot use `IGNORE()` because they cannot predict which record names external-dns will create - Using `NO_PURGE()` is too broad - it prevents DNSControl from cleaning up any orphaned records The fundamental issue is that `IGNORE()` requires static patterns known at config-time, but external-dns creates records dynamically at runtime. ## Solution `IGNORE_EXTERNAL_DNS()` solves this by detecting external-dns managed records at runtime: ```javascript D("example.com", REG_CHANGEME, DnsProvider(DSP_MY_PROVIDER), IGNORE_EXTERNAL_DNS(), // Automatically ignore external-dns managed records A("@", "1.2.3.4"), CNAME("www", "@") ); ``` ### How It Works external-dns uses a TXT record registry to track ownership. For each managed record, it creates a TXT record like: - `a-myapp.example.com` → TXT containing `heritage=external-dns,external-dns/owner=...` - `cname-api.example.com` → TXT containing `heritage=external-dns,external-dns/owner=...` This PR: 1. Scans existing TXT records for the `heritage=external-dns` marker 2. Parses the TXT record name prefix (e.g., `a-`, `cname-`) to determine the managed record type 3. Automatically adds those records to the ignore list during diff operations ## Changes | File | Purpose | |------|---------| | `models/domain.go` | Add `IgnoreExternalDNS` field to DomainConfig | | `pkg/js/helpers.js` | Add `IGNORE_EXTERNAL_DNS()` JavaScript helper | | `pkg/diff2/externaldns.go` | Core detection logic for external-dns TXT records | | `pkg/diff2/externaldns_test.go` | Unit tests for detection logic | | `pkg/diff2/handsoff.go` | Integrate external-dns detection into handsoff() | | `pkg/diff2/diff2.go` | Pass IgnoreExternalDNS flag to handsoff() | | `commands/types/dnscontrol.d.ts` | TypeScript definitions for IDE support | | `documentation/.../IGNORE_EXTERNAL_DNS.md` | User documentation | ## Design Philosophy This follows DNSControl's pattern of convenience builders (like `M365_BUILDER`, `SPF_BUILDER`, `DKIM_BUILDER`) that make complex operations simple. Just as those builders abstract away implementation details, `IGNORE_EXTERNAL_DNS()` abstracts away the complexity of detecting external-dns managed records. ## Testing All unit tests pass: ``` go test ./pkg/diff2/... -v # Tests detection logic go test ./pkg/js/... # Tests JS helpers go build ./... # Builds successfully ``` ## Caveats Documented - Only supports TXT registry (the default for external-dns) - Requires external-dns to use default naming conventions - May need updates if external-dns changes its registry format --------- Co-authored-by: Tom Limoncelli <6293917+tlimoncelli@users.noreply.github.com>
230 lines
8.2 KiB
Go
230 lines
8.2 KiB
Go
package diff2
|
|
|
|
// This file implements the IGNORE_EXTERNAL_DNS feature that automatically
|
|
// detects and ignores DNS records managed by Kubernetes external-dns.
|
|
//
|
|
// External-dns uses TXT records to track ownership of DNS records it manages.
|
|
// The TXT record format is:
|
|
// "heritage=external-dns,external-dns/owner=<owner-id>,external-dns/resource=<resource>"
|
|
//
|
|
// External-dns TXT record naming conventions:
|
|
// - For A records: prefix + original name (e.g., "a-myapp.example.com" for "myapp.example.com")
|
|
// - For CNAME records: prefix + original name (e.g., "cname-myapp.example.com")
|
|
// - Default prefixes: "a-", "aaaa-", "cname-", "ns-", "mx-"
|
|
// - Can also use --txt-prefix or --txt-suffix flags in external-dns
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/StackExchange/dnscontrol/v4/models"
|
|
)
|
|
|
|
const (
|
|
// externalDNSHeritage is the heritage value that external-dns uses in its TXT records
|
|
externalDNSHeritage = "heritage=external-dns"
|
|
)
|
|
|
|
// externalDNSManagedRecord represents a record managed by external-dns
|
|
type externalDNSManagedRecord struct {
|
|
Label string // The label of the managed record (without domain suffix)
|
|
RecordType string // The type of the managed record (A, AAAA, CNAME, etc.)
|
|
}
|
|
|
|
// isExternalDNSTxtRecord checks if a TXT record is an external-dns ownership record.
|
|
// It returns true and the managed record info if it is, false otherwise.
|
|
// customPrefix is an optional prefix that external-dns was configured with (e.g., "extdns-").
|
|
func isExternalDNSTxtRecord(rec *models.RecordConfig, domain string, customPrefix string) (bool, *externalDNSManagedRecord) {
|
|
if rec.Type != "TXT" {
|
|
return false, nil
|
|
}
|
|
|
|
// Get the TXT record content
|
|
target := rec.GetTargetTXTJoined()
|
|
|
|
// Check if it contains the external-dns heritage marker
|
|
if !strings.Contains(target, externalDNSHeritage) {
|
|
return false, nil
|
|
}
|
|
|
|
// This is an external-dns TXT record. Now we need to figure out what record it manages.
|
|
// External-dns TXT record naming:
|
|
// - New format with record type prefix: "a-myapp.example.com" manages "myapp.example.com" A record
|
|
// - Old format without type: "myapp.example.com" (legacy, manages the record with same name)
|
|
// - With custom prefix: e.g., "externaldns-a-myapp.example.com"
|
|
// - With custom suffix: e.g., "myapp-externaldns.example.com"
|
|
|
|
label := rec.GetLabel()
|
|
managed := parseExternalDNSTxtLabel(label, customPrefix)
|
|
|
|
return true, managed
|
|
}
|
|
|
|
// parseExternalDNSTxtLabel parses an external-dns TXT record label to extract
|
|
// the managed record information.
|
|
//
|
|
// External-dns uses these prefixes by default (when using %{record_type} in prefix):
|
|
// - "a-" for A records
|
|
// - "aaaa-" for AAAA records
|
|
// - "cname-" for CNAME records
|
|
// - "ns-" for NS records
|
|
// - "mx-" for MX records
|
|
//
|
|
// Without %{record_type}, it just uses the prefix directly, and the record type
|
|
// is encoded as "a-", "cname-", etc. at the start of the label.
|
|
//
|
|
// If customPrefix is non-empty, it will be stripped first before looking for
|
|
// record type prefixes.
|
|
func parseExternalDNSTxtLabel(label string, customPrefix string) *externalDNSManagedRecord {
|
|
workingLabel := label
|
|
|
|
// If a custom prefix is specified, strip it first
|
|
if customPrefix != "" {
|
|
if strings.HasPrefix(strings.ToLower(workingLabel), strings.ToLower(customPrefix)) {
|
|
workingLabel = workingLabel[len(customPrefix):]
|
|
} else {
|
|
// Custom prefix specified but not found - this might be a legacy record
|
|
// Continue with original label
|
|
}
|
|
}
|
|
|
|
// Standard prefixes used by external-dns
|
|
// Supports both hyphen format (a-www) and period format (a.www)
|
|
// Period format is used when --txt-prefix includes %{record_type}.
|
|
prefixes := []struct {
|
|
prefix string
|
|
recordType string
|
|
}{
|
|
{"aaaa.", "AAAA"}, // Period format - must check before "a."
|
|
{"aaaa-", "AAAA"}, // Hyphen format - must check before "a-"
|
|
{"a.", "A"}, // Period format
|
|
{"a-", "A"}, // Hyphen format
|
|
{"cname.", "CNAME"},
|
|
{"cname-", "CNAME"},
|
|
{"ns.", "NS"},
|
|
{"ns-", "NS"},
|
|
{"mx.", "MX"},
|
|
{"mx-", "MX"},
|
|
{"srv.", "SRV"},
|
|
{"srv-", "SRV"},
|
|
{"txt.", "TXT"},
|
|
{"txt-", "TXT"},
|
|
}
|
|
|
|
for _, p := range prefixes {
|
|
if strings.HasPrefix(strings.ToLower(workingLabel), p.prefix) {
|
|
managedLabel := workingLabel[len(p.prefix):]
|
|
// managedLabel is already lowercase from the prefix match
|
|
// Handle the case where the managed label is empty (apex domain)
|
|
if managedLabel == "" {
|
|
managedLabel = "@"
|
|
}
|
|
return &externalDNSManagedRecord{
|
|
Label: managedLabel,
|
|
RecordType: p.recordType,
|
|
}
|
|
}
|
|
}
|
|
|
|
// If custom prefix was specified and stripped, check if the remaining label
|
|
// is a record type indicator (for period format apex domains: extdns-a. at apex becomes extdns-a)
|
|
if customPrefix != "" && workingLabel != label {
|
|
// Check if remaining label is just a record type (apex domain with period format)
|
|
// e.g., prefix "extdns-" with label "extdns-a" → workingLabel "a" → apex A record
|
|
apexRecordTypes := map[string]string{
|
|
"a": "A",
|
|
"aaaa": "AAAA",
|
|
"cname": "CNAME",
|
|
"ns": "NS",
|
|
"mx": "MX",
|
|
"srv": "SRV",
|
|
"txt": "TXT",
|
|
}
|
|
if recType, ok := apexRecordTypes[strings.ToLower(workingLabel)]; ok {
|
|
return &externalDNSManagedRecord{
|
|
Label: "@",
|
|
RecordType: recType,
|
|
}
|
|
}
|
|
|
|
// The prefix was stripped but no record type found
|
|
// This means it's a simple prefix like "extdns-" without record type
|
|
// We can't determine the record type, so match all types
|
|
if workingLabel == "" {
|
|
workingLabel = "@"
|
|
}
|
|
return &externalDNSManagedRecord{
|
|
Label: workingLabel,
|
|
RecordType: "", // Empty means match any type
|
|
}
|
|
}
|
|
|
|
// No recognized prefix - this might be a legacy format or custom prefix
|
|
// In legacy format, the TXT record has the same name as the managed record
|
|
// We can't determine the record type in this case, so we'll match all types
|
|
return &externalDNSManagedRecord{
|
|
Label: label,
|
|
RecordType: "", // Empty means match any type
|
|
}
|
|
}
|
|
|
|
// findExternalDNSManagedRecords scans the existing records for external-dns TXT records
|
|
// and builds a map of records that are managed by external-dns.
|
|
// Returns a map keyed by "label:type" -> true for managed records
|
|
// customPrefix is an optional prefix that external-dns was configured with (e.g., "extdns-").
|
|
func findExternalDNSManagedRecords(existing models.Records, domain string, customPrefix string) map[string]bool {
|
|
managed := make(map[string]bool)
|
|
|
|
// Scan all external-dns TXT records
|
|
for _, rec := range existing {
|
|
isExtDNS, info := isExternalDNSTxtRecord(rec, domain, customPrefix)
|
|
if isExtDNS && info != nil {
|
|
// Mark the TXT record itself as managed
|
|
txtKey := rec.GetLabel() + ":TXT"
|
|
managed[txtKey] = true
|
|
|
|
// Mark the record that this TXT record manages
|
|
if info.RecordType != "" {
|
|
// Specific record type
|
|
key := info.Label + ":" + info.RecordType
|
|
managed[key] = true
|
|
} else {
|
|
// Legacy format - we need to find matching records
|
|
// We'll mark this label as managed for common record types
|
|
for _, rtype := range []string{"A", "AAAA", "CNAME", "NS", "MX", "SRV"} {
|
|
key := info.Label + ":" + rtype
|
|
managed[key] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return managed
|
|
}
|
|
|
|
// filterExternalDNSRecords takes a list of existing records and returns those
|
|
// that should be ignored because they are managed by external-dns.
|
|
// customPrefix is an optional prefix that external-dns was configured with (e.g., "extdns-").
|
|
func filterExternalDNSRecords(existing models.Records, domain string, customPrefix string) models.Records {
|
|
managedMap := findExternalDNSManagedRecords(existing, domain, customPrefix)
|
|
if len(managedMap) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var ignored models.Records
|
|
for _, rec := range existing {
|
|
key := rec.GetLabel() + ":" + rec.Type
|
|
if managedMap[key] {
|
|
ignored = append(ignored, rec)
|
|
}
|
|
}
|
|
|
|
return ignored
|
|
}
|
|
|
|
// GetExternalDNSIgnoredRecords returns the records that should be ignored
|
|
// because they are managed by external-dns. This is called from handsoff()
|
|
// when IgnoreExternalDNS is enabled for a domain.
|
|
// customPrefix is an optional prefix that external-dns was configured with (e.g., "extdns-").
|
|
func GetExternalDNSIgnoredRecords(existing models.Records, domain string, customPrefix string) models.Records {
|
|
return filterExternalDNSRecords(existing, domain, customPrefix)
|
|
}
|