dnscontrol/pkg/diff2/externaldns.go
tridion f1b30a1a04
feat: Add IGNORE_EXTERNAL_DNS() for Kubernetes external-dns coexistence (#3869)s
## 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>
2025-12-03 08:56:55 -05:00

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)
}